跳转至

Process, Thread and Concurrency

约 5367 个字 49 行代码 5 张图片 预计阅读时间 19 分钟

进程/Process

1. 基本概念

进程/Process 是被加载到内存中、正在运行的程序,是资源的分配和保护的单元/A Unit of Resource Allocation and Protection。包括以下部分:

alt text

  • Code or Text:文本段,存放可执行代码,最开始存放在硬盘内的可执行文件里;
  • Data Section:数据段,存放全局变量(比如 .bss 段和 .data 段);
  • Program Counter:程序计数器,存放下一条执行的指令的地址;
  • Content of Process's Registers:寄存器内容,进程之间切换的时候需要保存寄存器的值,以便下次运行使用;
  • Stack and Heap:栈和堆,存放调用函数时候的临时数据和动态分配的内存。

对于 C 语言的内存布局,从高地址到低地址简单概述如下:

  • 高端内存:内存位置最高的地方单独用于传递参数 argcargvmain() 函数;
  • 栈和堆:分别向低地址和高地址增长;
  • 未初始化的全局变量(.bss 段);
  • 初始化的全局变量(.data 段);
  • 程序代码(.text 段)。

仍需注意的是,在 CPU 调度一节会有另外一个关于进程的内存布局的图,这两张图的区别是:这张图是进程 User Space 的内存布局,下一张图是进程 Kernel Space 的内存布局。

运行时栈/Runtime Stack:A stack on which items can be pushed or popped。

  • 运行时栈的条目被称为激活记录/Activation Record;
  • 激活记录内保存了调用函数时的函数参数、局部变量、返回地址与返回值,调用者和被调用者也需要遵循调用惯例柏村寄存器的值;
  • 栈完全由用户本身操控,编译器代表用户生成代码来操作栈。
  • 运行时栈也会向下增长,递归调用过深就会爆掉(Runtime Stack Overflow),这时候就产生一个陷阱/Trap,操作系统内核就会终止程序。

进程控制块/Process Control Block/PCB/Task Control Block:保存与每个进程有关的信息。

  • 每个进程有且只有一个 PCB,PCB 会在新进程被创建的时候分配,在进程终止的时候被释放;
  • 进程状态/Process State:可以包含新建、就绪、运行、等待、停止等状态;
  • 程序计数器/Program Counter:该进程将要执行的下条指令的地址;
  • CPU 寄存器:所有和进程相关的寄存器的值,比如累加器、索引寄存器、堆栈指针和通用寄存器等等;
  • CPU 调度信息:包括进程的优先级、调度队列指针、进程状态等;
  • 内存管理信息:包括数据的基址和界限、页表、段表等;
  • 记账信息/Accounting Information:包括 CPU 使用时间、实际使用时间、时间期限等;
  • I/O 状态信息:分配给进程的 I/O 设备列表、打开文件列表等。

进程的状态:

  • 新建/New:进程正在被创建;
  • 就绪/Ready:进程已经准备好运行,等待分配处理器;
  • 运行/Running:进程正在被执行;
  • 等待/Waiting:进程正在等待某个事件发生;
  • 终止/Terminated:进程已经执行完毕。

drawing

2. 进程的创建

当某个进程创建新进程的时候,有两种执行可能:

  • 父进程继续执行(父进程和子进程并发执行);
  • 父进程等待子进程执行完毕之后再继续执行;

被创建的子进程的地址空间也有两种可能:

  • 子进程是父进程的副本/Clone,具有和父进程同样的程序和数据;
  • 子进程加载另一个新程序。

UNIX 系统使用 fork() 系统调用来创建新进程,创建的新进程基本完全是父进程的副本,子进程地址空间完全复制了父进程的地址空间,但是别的地方有一些区别:

  • 子进程和父进程的 pidppid 是不一样的;
  • 子进程拥有父进程文件描述符的副本,这些描述符指向同样的底层对象、共享文件指针位置,便于使用管道等设施完成进程间通信;
  • 子进程的资源计数被设置为 0。

当调用 fork() 失败的时候

一般来说,fork() 过后的子进程会使用 exec() 系统调用家族来加载新程序,exec() 系统调用会将子进程的地址空间替换为新程序的地址空间,但是子进程的 pidppid 会保持不变。在调用 exec() 的时候,可以指定可执行文件的路径、传入的命令行参数和环境变量列表。因为 exec() 完全将新进程的地址空间覆盖掉了,所以 exec() 系统调用只会在发生错误的时候返回,这时候返回值为 -1,同时全局变量 errno 会被设置为相应的错误码。也就是说下面的第 14 和 15 行代码不会被执行。

值得知道的是,Linux 内核只有一个 execve 系统调用,我们说到的 execlexecleexeclpexecvexecveexecvp 都只是 execve 的封装,参数列表不同而已。我们可以选择调用的函数,指明可执行文件的路径、传入的命令行参数和环境变量列表。

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    int status = 10086;
    if (pid < 0) {
        printf("Error\n");
    } else if (pid == 0) {
        pid_t ppid = getppid();
        execlp("/bin/ls", " ls", NULL);
        printf("Child process: with parent pid %d\n", ppid);
    } else {
        pid_t terminated = wait(&status);
        printf("Parent process: with child pid %d teriminated\n", pid);
        printf("with exit status %d/%d and pid %d\n", WEXITSTATUS(status), status, terminated);
    }
}

注意!如果没有这个 wait() 函数,那么这个程序的输出不是确定的/Deterministic。在子进程被创建之后,我们就需要关注两个活动进程了,但是是 CPU 调度程序决定了在某个时刻哪个进程被执行,这个调度程序复杂,不能假设某个进程先于另一个进程被执行。

下面是几个 PPT 上的例子:

1
2
3
4
5
6
7
int main(int argc, char *argv[]) {
    fork();
    if (fork()) {
        fork();
    }
    fork();
}

第三行是典型的 C 代码风格:在 if 的判断语句中调用 fork(),如果返回值不是零(也就是父进程),那么就会再次调用 fork()。这个程序执行完毕后,我们一共有了 12 个进程。

int a = 12;
pid = fork();
if (pid) {
    sleep(10);
    fprintf(stdout, "a = %d\n", a);
    while(1) ;
} else {
    a += 3;
    while(1) ;
}

这个程序会输出 12。在 fprintf 返回之前的内存布局是这样的:

drawing

3. 进程的终止

上面的代码其实还包含了更多内容,但是都和进程的终止有关。

当进程执行完最后一条语句并且通过系统调用 exit() 请求操作系统删除自身的时候,进程终止。exit() 系统调用接受整数参数作为这个进程的返回码(Exit/Error/Return Code)。C 语言 main 函数也会隐式调用 exit() 函数。进程终止的时候,可以将状态值(一般是整数)通过 wait() 函数传递到父进程,所有的进程资源会由操作系统释放并且回收。

别的时候也会出现进程终止,通过适当系统调用,比如 kill() 来终止另一个进程。但是通常只有父进程才能终止子进程,而且终止子进程需要知道这些进程的 pid,所以在创建进程的时候需要将新创建进程的 pid 传递给父进程。

在上面这个程序中,父进程通过 wait() 系统调用等待子进程终止,wait() 函数会阻塞父进程,直到任何一个子进程终止。仔细来看,wait() 系统调用的原型为 pid_t wait(int *stat_loc),也就是接受参数为一个指向整数的指针,这个整数(程序中为 status)会被设置为子进程的退出状态,并且返回子进程的 pid。这样父进程就可以知道是哪个进程终止了,并且通过退出状态处理异常。后面的宏 ·WEXITSTATUS(status) 可以从 status 中提取出子进程的退出状态。同时,WIFEXITED(status) 宏可以判断子进程是否正常终止,WIFSIGNALED(status) 宏可以判断子进程是否因为信号终止。

pid_t waitpid(pid_t pid, int *stat_loc, int options) 函数可以指定等待哪个子进程终止,pid 参数指定等待哪个子进程,当其为 0 的时候等待与调用进程在同一进程组中的任何子进程,当其为 -1 的时候等待任何子进程,当其为负数的时候等待进程组号为 -pid 的子进程,正号不多赘述。option 可以指定为 WNOHANG,表示非阻塞模式:如果没有子进程状态变化,立即返回 0;WUNTRACED:报告因信号停止(如 SIGSTOP)的子进程。

我们又提到了信号,信号是一种特殊的软件中断/Software Interruptions,可以被进程接收,也是一种异步事件,程序在接收到信号时必须以某种方式进行处理或响应。操作系统定义了很多信号,每个信号对应着名字、数字与含义,每个信号都在进程中都有默认行为。比如 SIGKILL 信号会立即终止进程。大多数信号可以被忽略或者呗用户定义的处理程序/handler 处理,但是有一些信号是不能被忽略的,比如 SIGKILLSIGSTOP

signal() 系统调用允许进程指定如何处理信号:

  • signal(SIGINT, SIG_IGN):忽略 SIGINT 信号;
  • signal(SIGINT, SIG_DFL):恢复 SIGINT 信号的默认行为;
  • signal(SIGINT, my_handler):指定 SIGINT 信号的处理程序为 my_handler,其函数原型需要为 void my_handler(int sig)
#include <stdio.h>
#include <signal.h>

void my_handler(int sig) {
    fprintf(stdout, "I don't want to die");
    return;
}

int main() {
    signal(SIGINT, my_handler);
    while (1) ; // infinite loop
}

回到进程的终止,父进程终止子进程的原因有很多:

  • 子进程使用了超过它所分配的资源(父进程需要有检查子进程状态的机制);
  • 分配给子进程的任务不再需要;
  • 父进程正在退出,且操作系统不允许无父进程的子进程继续执行。对于这类系统,如果一个进程终止(无论是正常还是不正常终止),那么它的子进程都应该被终止,这种现象被称为级联终止/Cascading Termination。

我们经常谈论僵尸进程/Zombie Process孤儿进程/Orphan Process。当一个进程终止的时候,操作系统会释放其资源,但是有一个资源不会被释放:进程的 PCB,这是因为进程表包含进程的退出状态,父进程需要读取其退出状态来处理可能的异常。当进程终止的时候,其在进程表中的条目依然存在,这时候这个进程变为僵尸进程,直到父进程调用 wait() 系统调用来获取子进程的退出状态或者父进程结束掉了,这时候进程表中的条目才会被释放。所有的进程结束之后都会过度到僵尸进程这种状态,但是一般而言僵尸进程都是短暂存在的。再说僵尸进程并不是真正意义上的进程(回忆:进程是被加载到内存中、正在运行的程序),僵尸进程只是在内存中的一个 PCB,不消耗 CPU 资源,但是不处理的话积攒起来会大量占用内存资源,甚至导致 fork() 系统调用失败。

僵尸进程是需要避免的,除此之外还可以通过 Signal 来结束僵尸进程,当子进程结束的时候,会发一个 SIGCHLD 信号给父进程,父进程可以通过处理这个信号来调用 wait() 系统调用来处理僵尸进程。

当父进程没有调用 wait() 就终止,那么这个进程就会变成孤儿进程,孤儿进程会被 pid1 的进程收养,比如 init 会定期的调用 wait(),收集孤儿进程的退出状态,并释放进程表条目。

孤儿进程这个东西甚至是有用的:如果我们想要创建一个守护进程/Daemon Process,我们可以 fork() 两次,让孙子进程执行对应任务,而子进程直接终止,这样孙子进程就会成为孤儿进程从而被 init 收养。我们就得到了一个在后台运行的、生存期长的进程,这样的进程就是守护进程。

进程间通信/IPC

操作系统内的并发执行进程可以是独立的,也可以是协作的。不与其他任何进程共享数据的进程是独立的,如果一个进程可以影响其他进程或者被其他进程影响,这个进程就是协作的/Cooperating。与其他进程共享数据的进程称为协作进程。

进程协作的理由:

  • 信息共享/Information Sharing
  • 计算加速/Computation Speedup
  • 模块化/Modularity

协作进程需要有一种进程间通信/Interprocess Communication 的机制,以允许进程相互交换数据。有两种基本模型:共享内存/Shared Memory消息传递/Message Passing

  • 共享内存会建立起一块供协作进程共享的内存区域,进程通过向共享内存区域读写数据来交换信息;
  • 消息传递则是通过在合作进程中交换信息来实现的。

大多数操作系统同时实现两种模型。两者相比较,消息传递对于交换较少量的数据很有用,也很容易实现,因为无需处理同步问题;分布式系统也更容易实现消息传递。共享内存有可能快于消息传递,额外支出也小,这是因为消息传递的实现经常依赖于系统调用(需要内核空间的支持),因此需要更多时间以便内核介入,而共享内存中,所有访问都可以看作常规内存访问。

drawing

1. 共享内存系统的 IPC

  • 建立一个共享内存区域,所有内存都可以访问
  • 进程将共享内存区域附加到自己的地址空间内。
  • 进程通过直接读写共享内存区域来交换信息,同时对不访问别的进程的私有内存负责
  • 操作系统不会干预内存的数据一致性。

回忆:操作系统需要保护进程的内存,多道程序设计不允许一个进程访问另一个进程的内存,所以共享内存需要两个或者更多个进程同意取消这一限制。

教材上和 PPT 的例子是 Bounded Buffer Problem,这个问题在后边的同步章节会具体处理。

2. 消息传递系统的 IPC

分布式计算的基础

使用消息传递,进程就不需要为了通讯共享地址空间,这时候内存隔离就被保持了。需要实现两个操作:

  • send:发送一条信息;
  • recv:接受一条信息。

如果进程 P 和 Q 希望进行通信,他们会:

  • 建立一个通信连接,这个链接是一种抽象,可以以多种形式实现(甚至以共享内存实现)‘
  • 调用 sendrecv 函数;
  • 可选地关闭通信连接。

需要通信的进程需要有一个方法以便互相引用,因此产生两种通信方法:直接通信和间接通信:

直接通信需要直接通信的几个进程必须明确制定信息的发送者和接受者:

  • send(P, message):向进程 P 发送消息 message;
  • receive(Q, message):从进程 Q 接受消息 message。

这样的通信连接有以下性质:

  • 在通信的每对进程之间,自动建立链接,进程只需要知道对方的身份就可以进行交流;
  • 每个链接只与两个进程相关;
  • 每对进程之间只有一个链接;
  • 链接可以是单向的/Unidirectional,但是一般是是双向的/Bidirectional。

间接通信需要从邮箱/Mailbox 或者端口/Port 来发送或者接收消息。每个邮箱都有一个独一无二的标识符/id,两个进程只有共享一个邮箱的时候才可以进行通信。

  • send(A, message):向邮箱 A 发送消息 message;
  • receive(A, message):从邮箱 A 接受消息 message。

除此之外,我们还可以创建/删除邮箱。对于间接通信形成的链接,有以下性质:

  • 仅当进程共享一个邮箱的时候,链接才被建立;
  • 一个链接可以和多个进程相关联;
  • 每两个进程间可以有多个链接;
  • 链接可以是单向或双向的。

间接通信还是会有同步问题:三个进程 \(P_1\)\(P_2\)\(P_3\),共享邮箱 \(A\),如果 \(P_1\) 发送消息,\(P_2\)\(P_3\) 都接受消息,那么谁会得到消息呢?这取决于我们的实现方案,简单列三种:

  • 允许一个链接最多和两个进程关联;
  • 允许一次最多只有一个进程执行 receive 操作;
  • 允许系统定义一个算法决定哪个进程接受消息,可以随意指定,也可以让发送者指定。

消息传递可以是阻塞/Blocking/同步/Synchronous 的,也可以是非阻塞/Non-Blocking/异步/Asynchronous 的。

  • 阻塞发送:发送进程阻塞,直到消息被接受进程/邮箱接受;
  • 阻塞接收:接受进程阻塞,直到有消息可以被接受;
  • 非阻塞发送:发送进程不会阻塞,发完了直接恢复操作;
  • 非阻塞接收:接收进程接收到一个有效消息或者空消息

不管通信是直接的还是间接的,通信进程交换的信息总是驻留在临时队列中,这称为缓冲。简单来讲,实现方法有三种:

  • 零容量:队列最大长度为 0,因此链接不能有任何消息处于等待,发送者应该总是阻塞,知道接收者接收信息;
  • 有界容量:队列长度有限,不妨为 \(n\),当发送新消息的时候队列没满,就直接将消息放进队列,否则发送者阻塞;
  • 无界容量:队列长度无限,发送者不会阻塞。

3. 信号

UNIX 形式的 IPC,告诉一个进程某种事件发生了,可以将信号看成异步的高级软件中断。

发送给进程的信号必须得到处理。

4. 管道

管道允许两个进程进行通信。实现管道的时候需要实现下面四个问题:

  • 管道是单向的还是双向的?
  • 如果允许双向通信,管道是半双工/Half-Duplex(数据在同一时间只允许按照一个方向传输)还是全双工/Full-Duplex(允许数据在同一时间内在两个方向上传输)?
  • 通信进程之间是否有一定的关系,比如说是父子进程?
  • 管道通信可以通过网络,还是只能在同一台机器上运行?

我们先讨论两种常见的管道:普通管道/Ordinary Pipe 和命名管道/Named Pipe。

普通管道只能被创建这个管道的进程访问,一般是父进程创建一个管道,然后拿着管道和子进程通信。普通管道按照生产者和消费者的方式进行通信,生产者向管道的一端/写入端写入数据,消费者从管道的另一端/读出端读出数据。因此普通管道是单向的

UNIX 系统上,管道通过函数 pipe(int fd[]) 函数创建,通过文件描述符 int fd[] 访问,其中 fd[0] 是管道的读出端,fd[1] 是管道的写入端。UNIX 将管道看作特殊的文件,使用系统调用 read()write() 来访问,也需要注意,子进程也继承了父进程的管道。

与命名管道对比,普通管道也被称为匿名管道/Anonymous Pipe。

drawing

命名管道:是双向的管道,父子关系在通讯的进程中并不是必须的,通信进程完毕后,管道依旧存在。但是要求通信进程需要在一个机器上,否则就需要使用 Socket。

5. 客户机-服务器系统

套接字/Socket:Socket 为通信的端点,通过网络通信的每对进程需要使用一对 Socket,每个进程各有一个 Socket,每个 Socket 由唯一一个 IP 地址和一个端口号标识。

Socket 一般用于两个不同主机之间的通讯,也可以在一个主机内部使用。

远程过程调用/RPC:对网络系统之间的过程调用进行了抽象。RPC 传递的不仅仅只是数据包,而具有一定结构。RPC 服务监听远程系统的端口号,传递的消息包含用于指定执行函数的一个标识符和传递给函数的一些参数,然后函数按照要求来执行,所有的结果都通过另一个消息传递回到请求者。

通过客户端提供的存根/Stub,RPC 系统隐藏了通信细节。当客户端调用远程过程的时候,RPC 系统调用适当的存根,这个存根位于客户端,封装/Marshal 参数,通过消息传递向服务器发送消息,服务器端进行反封装/Unmarshal,通过参数调用服务器上的内容。如果有必要的话,返回值可以通过同样的方式回到客户机。

RPC 语义

线程/Thread