并发编程

Posted zuanzuan

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程相关的知识,希望对你有一定的参考价值。

前言

在第三代计算机的发展中因为出现了多道技术使得同一时刻可以在内存中同时运行多个程序,那么这样就产生了进程的概念.

在本篇博客中将介绍进程相关的理论知识,对操作系统进一步的认识.

进程

什么是进程

进程( process),是指计算机中已运行的程序.进程曾经是分时系统的基本运行单位.在面向进程设计的系统(如早期的 UNIX,Linux2.4及更早的版本)中,进程是程序的基本执行实体,是操作系统的资源单位;在面向线程设计的系统(如当代多数操作系统, Linux2.6及更新的版本)中,进程本身不是基本运行单位,运行单位变为线程,进程是线程的容器.程序本身只是指令,数据及其组织形式的描述,进程才是(指令和数据)的真正运行实例.若干进程有可能与同一个程序相关系,且每个进程皆可以同步(循序)或异步(平行)的方式独立运行.现代计算机系统因为空间共享(空间复用)可在同一段时间内以进程的形式将多个程序加载到存储器中,并借由时间共享(时间复用),以在一个处理器上表现出异步(平行)运行的感觉.

开启进程需要的资源

用户下达运行程序的命令后,就会产生进程.同一程序可产生多个进程(一对多的关系),以允许同时又多位用户运行同一程序,却不会产生冲突.

开启进程需要一些必要的资源才能完成工作,如 CPU 使用时间,存储器,文件以及 I/O 设备,且为依序逐一进行,也就是每个 CPU 内核任何时间内仅能运行一项进程.

进程包含的资源

一个计算机系统集成包括(或者说拥有)下列资源:

  • 那个程序的可执行机器代码的一个在存储器的映像;
  • 分配到的存储器(通常是虚拟的一个存储器区域).存储器的内容包括可执行代码,特定于进程的数据(输入或输出),调用堆栈(用于保存运行时运输中途产生的数据);
  • 分配给该进程的资源的操作系统描述符,诸如文件描述符( UNIX 术语)或文件句柄( Windows),数据源和数据终端;
  • 安全特性,诸如进程拥有者和进程的权限集(可以容许的操作);
  • 处理器状态,诸如寄存器内容,物理存储器定址等.当进程正在运行时,状态通常保存在寄存器,其他数据保存在存储器.

进程的状态

进程在运行中,状态会随时发生改变.所谓状态,就是指进程目前的动作:

  • 新生( new):进程新产生重;
  • 运行(running):正在运行;
  • 等待( waiting):等待某事发生,例如等待用户输入完成.也称之为阻塞;
  • 就绪( ready):等待 CPU;
  • 结束( terminated):完成运行.

各状态名称可能虽不同的操作能够系统而不同,对于单核系统( UP),任何时间可能有多个进程为等待,就绪,但必定仅有一个进程在运行.

进程表

技术分享图片

对于一个进程来说,操作系统为了能够在CPU离开后继续执行该进程,会把此时进程运行的所有状态保存下来,为此,操作系统和会维护一张表格,即进程表( process table),每个进程占用一个进程表项(也称之为进程控制块).

对于上图中重要项的解释如下:

  • 寄存器:如累加器,变址寄存器,堆栈指针以及一般用途寄存器,状况代码等,主要用途在于中断

进程时暂时存储数据,以便稍后继续利用;其数量及类别因计算机体系结构有所差异;

  • 程序计数器:接下来要运行的指令地址;
  • 进程状态:可以是 new,ready,running,waiting,blocked或 terminated;
  • 优先级( CPU 排班法):优先级,排班队列等指针以及其他参数;
  • 存储管理:如标签页表,正文段指针,数据段指针以及堆栈指针等;
  • 会计信息:如 CPU 遇实际时间值使用数量,时限,账号,工作或进程号;
  • 输入输出状态:配置进程使用 I/O 设备,如磁带机.

Unix进程

类 Unix 系统进程概念

Unix进程PID

在类 Unix 系统中可以使用 ps 命令查询正在运行的进程,比如 ps -eo pid,comm,cmd,下图为执行结果:(-e 表示列出全部进程, -o pis,comm,cmd 表示我们需要 PID,COMMAND,CMD 信息)

技术分享图片

每一行代表一个进程.每一行分为三列.第一列为 PID(Process IDentity)是一个整数,每一个进程都有一个唯一的 PID 来表示自己的身份,进程也可以根据 PID 来识别其他的进程.第二列 COMMAND 是该进程的简称.第三列 CMD 是进程所对应的程序以及运行时所带的参数.(第三列有一些由[]括起来的,它们是内核的一部分功能)

在第一行的 PID 为1,名字为 systemd(18.04,版本为16.04该名字为 init).这个进程是执行/sbin/init 这一文件(程序)产生的(不知道我的为什么不是,查看了朋友的是/sbin/init??).当 Linux 启动的时候, systemd 是系统创建的第一个进程,这一进程会一直存在,直到关闭计算机.

实际上,当计算机开机时,内核( kernel)只建立了一个systemd 进程. Linux 内核并不提供直接建立新进程的系统调用.剩下的所有进程都是 systemd 进程通过fork 机制建立的.新的进程要通过老的进程复制自身得到,这就是fork.fork 是一个系统调用.进程存活于内存中.每个进程都在内存中分配有属于自己的一片空间(address space).当进程fork 的时候, Linux 在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行.

老进程成为新进程的父进程(parent process),而相应的,新进程就是老进程的子进程(child process).一个进程除了有一个 PID 之外,还会有一个 PPID(parent PID)来存储父进程的 PID. 如果我们循着 PPID 不断向上追溯的话,总会发现其源头是 systemd 进程.所以说,所有的进程也构成一个以 systemd 为根的树状结构.

进程树

使用 pstree命令查看进程树:

技术分享图片

可以看到 systemd 进程是整个进程树的根.

fork 通常作为一个函数调用,这个函数会有两次返回,将子进程的 PID 返回给父进程,0返回给子进程.实际上,子进程总可以查询自己的 PPID 来知道自己的父进程是谁,这样,一对父子进程就可以随时查询对方.在调用fork 函数后,程序会设计一个 if 选择结构.当 PID 等于0时,说明该进程为子进程,那么让它执行某些指令;而当 PID 为一个正整数时,说明为父进程,则执行另外一些指令.由此,就可以在子进程建立之后,让它执行与父进程不同的功能.

子进程的终结

当子进程终结时,它会通知父进程,清空自己所占据的内存,并在内核里留下自己的退出信息( exit code, 如果顺利运行,返回0;如果有错误或异常状况,为>0的整数).在这个信息里,会解释该进程为什么退出.父进程在得知子进程终结时,有责任对该子进程使用 wait 系统调用.这个 wait 函数能从内核中取出子进程的退出信息,清空该信息在内核中所占据的空间.但是,如果父进程早于子进程终结,子进程就会成为一个孤儿(orphand)进程.孤儿进程会过继给 systemd 进程, systemd 进程也就成了该进程的父进程. systemd 进程负责该子进程终结时调用 wait 函数,

一个糟糕的程序也完全可能造成子进程的退出信息滞留在内核中的状况(父进程不对子进程调用 wait 函数),这样的情况下,子进程成为僵尸( zombie)进程.当大量僵尸进程积累时,内存空间会被挤占.

类 UNIX 系统进程和线程的区别

尽管在 UNIX 中,进程与线程是有联系但不同的两个东西,但在 Linux 中,线程只是一种特殊的进程.多个线程之间可以共享内存空间和 IO 接口.所以,进程是 Linux 程序的唯一实现方式.

Linux创建进程

从系统调用fork 中返回时,两个进程除了返回值 PID 不同外,具有完全一样的用户级上下文.在子进程中, PID的值为0.在系统启动时有内核创建的进程1是唯一不通过系统调用fork 而创建的进程.也就是上图的 systemd进程.

内核为系统调用fork 完成下列操作:

  1. 为新进程在进程表中分配一个空项;
  2. 为子进程赋一个惟一的进程标识号 PID;
  3. 做一个父进程上下文的逻辑副本.由于进程的某些部分,如正文区,可能被几个进程所共享,所以内核有时只要增加某个区的引用数即可,而不是真的将该区拷贝到一个新的内存物理区;
  4. 增加与该进程相关联的文件表和索引节点表的引用数;
  5. 对父进程返回子进程的进程号,对子进程返回零.

下面是系统调用fork 的算法.内核首先确信有足够的资源来完成fork. 如果资源不满足要求,则系统调用fork 失败.如果资源满足要求,内核在进程表中找一个空项,并开始构造子进程的上下文.

输入:无

输出:对父进程是子进程的 PID, 对子进程是0

{

    检查可用的内核资源

    取一个空闲的进程表项和唯一的 PID 号

    检查用户没有过多的运行进程

    将子进程的状态设置为‘创建‘状态

    将父进程的进程表中的数据拷贝到子进程表中

    当前目录的索引节点和改变的根目录(如果可以)的引用数加1

    文件表中的打开文件的引用数加1

    在内存中做父进程上下文的拷贝

    在子进程的系统级上下文中压入虚设系统级上下文层

        /* 虚设上下文层中含有使子进程能

        /* 识别自己的数据,使子进程被调度时

        /* 从这里开始运行
if (正在执行的进程是父进程){

            将子进程的状态设置为‘就绪‘状态

            return (子进程的 PID)           //从系统到用户

    }

    else    {

            初始化计时区

            return 0;

    }

}

来看下面的例子.该程序说明的是经过系统调用fork 之后,对文件的共享存取.用户调用改程序时应有两个参数,一个是已经有的文件名;另一个是要创建的新文件名.该进程打开已有的文件,创建一个新文件,然后假定没有遇见错误,它调用fork 来创建一个子进程.子进程可以通过使用相同的文件描述符来继承的存取父进程的文件(即父进程已经打开和创建的文件).

当然,父进程和子进程要分别独立的调用rdwrt 函数并执行一个循环,即从源文件中读一个字节,然后写一个字节到目标文件中去.当系统调用 read 遇见文件尾时,函数rdwrt 立即返回.

#include <fcntl.h>

int fdrd, fdwt;
char    c;

main(int argc, char *argv[])
{
    if (argc != 3) {
        exit(1);
    }
    if ((fdrd = open(argv[1], O_RDONLY)) == -1) {
        exit(1);
    }
    if ((fdwt = creat(argv[2], 0666)) == -1) {
        exit(1);
    }

    fork();
    // 两个进程执行同样的代码
    rdwrt();
    exit(0);
}

rdwrt()
{
    for (;;) {
        if (read(fdrd, &c, 1) != 1) {
            return ;
        }
        write(fdwt, &c, 1);
    }
}

在这个例子中,两个进程的文件描述符都指向相同的文件表项.这两个进程永远不会读或写到相同的文件偏移量,因为内核在每次 read 和 write 调用后,都要增加文件的偏移量.尽管两个进程似乎是将源文件拷贝了两次,但因为它们分担了工作任务,因此,目标文件的内容依赖于内核调度两个进程的次序.如果内核这样调度两个进程:是它们交替的执行它们的系统调用,甚至使它们交替的执行每对 read 和 write 调用,则目标文件的内容和源文件的内容完全一致.但考虑这样的情况:两个进程正要读源文件中的连续的字符‘ab‘.假定父进程读了字符‘a‘,这时,内核在父进程write 之前,做了上下文切换来执行子进程。如果子进程 读到字符 "b",并在父进程被调度前,将它写到目标文件,那么目标文件将不再含有 字符串 "ab",而是含有 "ba"了。内核并不保证进程执行的相对速率。

另一个例子:

#include <string.h>

char    string[] = "Hello, world";

main()
{
    int count, i;
    int to_par[2], to_chil[2];      // 到父、子进程的管道
    char    buf[256];

    pipe(to_par);
    pipe(to_chil);

    if (fork() == 0) {
        // 子进程在此执行
        close(0);       // 关闭老的标准输入
        dup(to_child[0]);   // 将管道的读复制到标准输入
        close(1);       // 关闭老的标准输出
        dup(to_par[1]);     // 将管道的写复制到标准输出
        close(to_par[1]);   // 关闭不必要的管道描述符
        close(to_chil[0]);
        close(to_par[0]);
        close(to_chil[1]);
        for (;;) {
            if ((count = read(0, buf, sizeof(buf)) == 0)
                exit();
            write(1, buf, count);
        }

    }

    // 父进程在此执行
    close(1);       // 重新设置标准输入、输出
    dup(to_chil[1]);
    close(0);
    dup(to_par[0]);
    close(to_chil[1]);
    close(to_par[0]);
    close(to_chil[0]);
    close(to_par[1]);
    for (i = 0; i < 15; i++) {
        write(1, string, strlen(string));
        read(0, buf, sizeof(buf));
    }
}

子进程从父进程继承了文件描述符0和1(标准输入和标准输出)。两次执行系统调用 pipe 分别在数组 to_par 和 to_chil 中分配了两个文件描述符。然后该进程 执行系统调用 fork,并复制进程上下文:象前一个例子一样,每个进程存取 自己的私有数据。父进程关闭他的标准输出文件(文件描述符1),并复制(dup)从管道 线 to_chil 返回的写文件描述符。因为在父进程文件描述符表中的第一个空槽是刚刚 由关闭腾出来的,所以内核将管道线写文件描述符复制到了文件描述符表中的第一 项中,这样,标准输出文件描述符变成了管道线 to_chil 的写文件描述符。 父进程以类似的操作将标准输入文件描述符替换为管道线 to_par 的读文件 描述符。与此类似,子进程关闭他的标准输入文件(文件描述符0),然后复制 (dup) 管道 线 to_chil 的读文件描述符。由于文件描述符表的第一个空项是原先的标准 输入项,所以子进程的标准输入变成了管道线 to_chil 的读文件描述符。 子进程做一组类似的操作使他的标准输出变成管道线 to_par 的写文件描述 符。然后两个进程关闭从 pipe 返回的文件描述符。上述操作的结果是:当 父进程向标准输出写东西的时候,他实际上是写向 to_chil--向子进程发送 数据,而子进程则从他的标准输入读管道线。当子进程向他的标准输出写的时候, 他实际上是写入 to_par--向父进程发送数据,而父进程则从他的标准输入 接收来自管道线的数据。两个进程通过两条管道线交换消息。

无论两个进程执行的顺序如何,这个程序执行的结果是不变的。他们可能去执行睡眠 和唤醒来等待对方。父进程在15次循环后退出。然后子进程因管道线没有写进程而读 到“文件尾”标志,并退出。

Windows进程

进程 PID

windows 也是使用 PID 来唯一标识一个进程.

技术分享图片

在一个进程内部,使用进程句柄来标识关注的每个进程.使用 Windows API 从进程 PID 获取进程句柄:

OpenProcess(PROCESS_ALL_ACCESS, TRUE, procId); //或者PROCESS_QUERY_INFORMATION

使用 API 函数: GETModuleFileNameEx 或 GetProcessImageFileName 或QUeryFullProcessImageName 查询进程的 exe 文件名,使用 API 函数 GetCurrentProcess可以获取本进程的伪句柄(值为-1),只能用于本进程的 API 函数调用;不能被其他进程继承或复制.可用 API 函数 DuplicateHandle 获得进程的真句柄.

Windows创建进程

Windows 系统使用 CreateProcess 创建进程, WaitForSingleObject 可等待子进程的结束.例如:

#include <windows.h>
#include <stdio.h>
#include <tchar.h>

void main() {
    STARTUPINFO si;
    PROCESS_INFORMATION pi;
    ZeroMemory(&si, sizeof(si));
    si.cb = sizeof(si);
    ZeroMemory(&pi, sizeof(pi));
    // Start the child process.
    if (!CreateProcess(NULL,    // No module name (use command line)
                       "demo.exe arg1", // Command line
                       NULL,    // Process handle not inheritable
                       NULL,    // Thread handle not inheritable
                       FALSE,   // Set handle inheritance to FALSE
                       0,       // No creation flags
                       NULL,    // Use parent‘s environment block
                       NULL,    // Use parent‘s starting directory
                       &si,     // Pointer to STARTUPINFO structure
                       &pi)     // Pointer to PROCESS_INFORMATION structure,用于给出子进程主窗口的属性
       ) {
        printf("CreateProcess failed (%d).
", GetLastError());
        return;
    }
    // Wait until child process exits.
    WaitForSingleObject(pi.hProcess, INFINITE);
    // Close process and thread handles.
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
}

父子进程关系

创建的子进程可以继承父进程的:

  • CreateFile 返回的打开句柄,包括文件,控制台输入缓冲区,控制台屏幕缓冲区,命名管道,串口通信设备,邮槽;
  • 打开的句柄,包括:进程,线程,互斥锁,事件对象,信号量,命名管道,匿名管道,文件映射对象;
  • 环境变量;
  • 当前目录;
  • 控制台,除非进程脱离( detach)或创建了新的控制台;
  • 错误模式,使用 API 函数 SetErrorMode 设置;
  • 进程亲和掩码( affinity mask),用以指示期望使用 CPU 的哪些核;
  • 在哪个任务中.

子进程不能继承:

  • 优先级类别 Priority class;
  • 句柄,有 LocalAlloc,GlobalAlloc,HeapCreate,HeapAlloc 返回;
  • 伪句柄,有 GetCurrentProcess或 GetCurrentThread 返回;
  • DLL 模块句柄,由 LoadLibrary 返回;
  • GDI 对象句柄或 USER 对象句柄,如 HBITMAP 或 HMENU.

为继承句柄,父进程在创建(或者代开,复制)各种可继承对象句柄时,在 SECURITY_ATTRIBUTES 结构的 blnheritHandle 成员为 TRUE. 在 CreateProcess 的blnheritHandles 参数为 TRUE; 如果要继承标准输入,标准输出,标准错误的句柄, STARTUPINFO 结构的 dwFlags 成员包含 STARTF_USESTDHANDLES 标志位.

获取进程信息的相关函数

  • GetCommandLine:当前进程的命令行字符串
  • GetStartupInfo:当前进程被创建时的STARTUPINFO结构
  • GetProcessVersion:获取可执行头的版本信息
  • GetModuleFileName:获取包含了进程代码的可执行文件的全路径与文件名
  • GetGuiResources:获取使用中的GUI对象的句柄数量
  • IsDebuggerPresent:确定进程是否被调试
  • GetProcessIoCounters:获取进程执行的所有I/O操作的薄记信息。
  • GetProcessMemoryInfo:获取进程的工作集内存的信息
  • GetProcessWorkingSetSize:获取进程的工作集内存被允许的下限与上限
  • SetProcessWorkingSetSize:设置进程的工作集内存的下限与上限

进程终结

子进程终止时,所有打开的句柄被关闭,进程对象被处罚( signaled).进程的退出码( exit code)或者 ExitProcess,TerminateProcess 函数中指出,或者是 main,WinMain 函数返回值.如果进程由于一个致命异常(fatal exception)而终止,退出码是这个异常值,同时进程的所有执行中的线程的退出码也是这个异常值.

优雅的关闭其他进程的方法使用RegisterWindowMessage 登记私有消息,用 BroadcastSystemMessage 播放消息,收到消息的进程用ExitProcess关闭.

获取特定名字的进程 PID

如果想要获取特定名字的进程的ID,需要枚举所有进程。传统办法是CreateToolhelp32Snapshot、Process32First、Process32Next函数;也可以使用EnumProcesses、EnumProcessModules函数来获取所有的进程ID,一个进程的所有模块的句柄。示例如下:

PROCESSENTRY32 pe32;
HANDLE hSnaphot;
HANDLE hApp;
DWORD dProcess;
hSnaphot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); // 获取进程快照
Process32First(hSnaphot, &pe32); // 枚举第一个进程
do {
    if (lstrcmpi(pe32.szExeFile, _T("NotePad.exe")) == 0) { // 查找进程名称为 NotePad.exe
        dProcess = pe32.th32ProcessID;
        break;
    }
} while (Process32Next(hSnaphot, &pe32)); // 不断循环直到枚举不到进程
hApp = OpenProcess(PROCESS_VM_OPERATION | SYNCHRONIZE, FALSE, dProcess); // 根据进程 ID 获取程序的句柄
if (!WaitForSingleObject(hApp, INFINITE)) // 等待进程关闭
    AfxMessageBox(" 记事本已经关闭!");

// 另一种方法
DWORD aProcId[1024], dwProcCnt, dwModCnt;
HMODULE hMod[1000];
TCHAR szPath[MAX_PATH];

// 枚举出所有进程ID
if (!EnumProcesses(aProcId, sizeof(aProcId), &dwProcCnt)) {
    //cout << "EnumProcesses error: " << GetLastError() << endl;
    return 0;
}

// 遍例所有进程
for (DWORD i = 0; i < dwProcCnt; ++i) {
    // 打开进程,如果没有权限打开则跳过
    HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, aProcId[i]);
    if (NULL != hProc) {
        // 打开进程的第1个Module,并检查其名称是否与目标相符
        if (EnumProcessModules(hProc, &hMod, 1000, &dwModCnt)) {
            GetModuleBaseName(hProc, hMod, szPath, MAX_PATH);
            if (0 == lstrcmpi(szPath, lpName)) {
                CloseHandle(hProc);
                return aProcId[i];
            }
        }
        CloseHandle(hProc);
    }
}
return 0;

以上是关于并发编程的主要内容,如果未能解决你的问题,请参考以下文章

golang代码片段(摘抄)

《java并发编程实战》

Java并发编程实战 04死锁了怎么办?

Java并发编程实战 04死锁了怎么办?

Java编程思想之二十 并发

golang goroutine例子[golang并发代码片段]