第十三章 多进程编程

Huan Lee Lv5

13.1 fork系统调用

1
2
3
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
  • 该函数每次调用都返回两次, 在父进程中返回的是子进程的PID, 在子进程中则返回0.
  • 新的进程表项有许多属性与原进程相同, 如堆指针, 栈指针, 标志寄存器的值; 不同的属性如PPID被设置为原进程的PID, 信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)
  • 数据的复制采取写时复制(copy on write), 即只有任一进程对数据执行了写操作时复制才会发生.
  • 父进程打开的文件描述符默认在子进程中也是打开的, 且文件描述符的引用计数加一.

13.2 exec系列系统调用

在子进程中执行其他程序, 即替换当前进程映像, 需要使用exec系列函数之一:

1
2
3
4
5
6
7
8
9
#include<unistd.h>
extern char** environ;

int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const argv[], char* const envp[]);
  • path提供完整的文件名, file则是提供文件名后, 从环境变量PATH中去搜寻.
  • arg接收可变参数, argv接收参数数组. 它们都会传递给新程序的main函数.
  • envp参数用于设置新程序的环境变量(未设置则有全局变量environ指定)

13.3 处理僵尸进程

对于许多多进程程序, 为了让父进程更好地跟踪子进程的退出状态, 在子进程结束运行时, 内核不会立即释放该进程的进程表表项. 在子进程结束运行之后, 父进程读取其退出状态之前, 称为僵尸态.

  • 另一种僵尸态是: 父进程结束或异常终止, 子进程PPID被设置为1, 即init进程. 子进程在退出之前, 处于僵尸态.
1
2
3
#include<sys/wait.h>
pid_t wait(int* stat_loc);
pid_t waitpid(pid_t pid, int* stat_loc, int options);
  • wait函数阻塞进程, 直至其某个子进程结束运行, 返回子进程PID, 并将子进程的退出状态信息存储在stat_loc中;

    • 可以通过定义的宏来查看状态信息

Untitled

  • waitpid只等待由pid指定的子进程. options最常用的取值是WNOHANG, 此时waitpid调用时非阻塞的: 如果目标子进程还没结束, 返回0; 否则返回PID.

SIGCHLD信号

子进程结束时, 会给其父进程发送一个SIGCHLD信号. 因此, 可以在父进程中捕获SIGCHLD信号, 并在信号处理函数中调用waitpid函数以彻底结束一个子进程:

Untitled

13.4 管道

管道也是父进程和子进程间通信的常用手段. 由于fork之后两个管道文件描述符(fd[0], fd[1])都保持打开, 一对这样的文件描述符能够实现数据传输. 然而一对这样的文件描述符只能保证父子进程间一个方向上的数据传输, 父进程和子进程必须有一个关闭fd[0], 另一个关闭fd[1].

Untitled

要实现父子进程间的双向数据传输, 就必须使用两个管道, 或者使用socketpair系统调用.

除了特殊的FIFO管道, 管道只能用于由关联的两个进程(如父子进程)间的通信

13.5 信号量

要实现多进程在共享资源上的同步, 要求程序实现对共享资源的独占式访问. 这种要求独占的写入或修改代码片段可能引发进程之间的竞态条件, 被称为关键代码段, 或临界区. 因此, 进程同步就是要求任一时刻只有一个进程能进入关键代码段.

信号量(Semaphore)是一种特殊的变量, 它只能取自然数值, 并且只支持两种操作: **等待(wait)和信号(signal), 分别对应更加常用的称呼—P,V操作.**对于信号量S:

  • P(S), 如果S的值大于0,就将它减1:如果S的值为0,则挂起进程的执行。
  • V(S), 如果有其他进程因为等待S而挂起,则唤醒之;如果没有,则将S加1。

Untitled

最常用, 最简单的二进制信号量

semget系统调用

1
2
#include <sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);
  • key是一个键值, 用来标识一个全局唯一的信号量集. 要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量.
  • num_sems参数指定要创建/获取的信号量集中信号量的数目. 创建时该值必须指定, 获取时可以设置为0
  • sem_flags参数指定一组标志. 它低端的9bit时该信号量的权限. 此外它还可以与IPC_CREAT标志按位或以创建新的信号量集; 联合IPC_CREAT和IPC_EXCL来确保创建一组新的唯一的信号量集.
  • 函数成功时返回一个正整数, 作为信号量集的标识符.

semop系统调用

semop系统调用改变信号量的值, 即执行PV操作. 其本质是对一些信号量相关的内核变量的操作

Untitled

1
2
3
4
5
6
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);
struct sembuf {
unsigned short int sem_num;
short int sem_op;
short int sem_flg;
}
  • sem_id 为信号量集的标识符(由semget返回);

  • sem_ops指向sembuf结构体类型的一个数组. sem_num表示要操作的信号量编号(从0开始). sem_op和sem_flg组合来影响semop的行为:

    • sem_flg可选值为IPC_NOWAIT(非阻塞, 立即返回)和SEM_UNDO(当进程退出时取消正在进行的semop操作)

    • sem_op>0, 被操作的信号量增加sem_op

    • sem_op==0, 等待0操作.

    • sem_op<0, 对信号量进行减操作, 即期望获得信号量.

  • semop对sem_ops中的每个成员按照数组顺序依次执行操作, 并且在该过程中是原子操作.

semctl系统调用

semctl系统调用允许调用者对信号量进行直接控制.

1
int semctl(int sem_id, int sem_num, int command,...);

Untitled

特殊键值IPC_PRIVATE

semget的调用者可以给其key参数传递一个特殊的键值IPC_PRIVATE(其值为0),这样无论该信号量是否已经存在,semget都将创建一个新的信号量。使用该键值创建的信号量并非像它的名字声称的那样是进程私有的。其他进程,尤其是子进程,也有方法来访问这个信号量。所以semget的man手册的BUGS部分上说,使用名字IPC_PRIVATE有些误导(历史原因),应该称为IPC_NEW。

13.6 共享内存

共享内存是最高效的IPC机制, 但是必须通过其他辅助手段来同步进程对共享内存的访问.

shmget系统调用

1
2
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
  • shmflg参数与semget系统调用的sem_flags相同, 但额外支持两个标志:

    • SHM_HUGETLB, 系统将使用大页面来为共享内存分配空间.

    • SHM_NORESERVE, 部位共享内存保留交换分区(swap空间).

shmat和shmdt系统调用

共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中。使用完共享内存之后,我们也需要将它从进程地址空间中分离。这两项任务分别由如下两个系统调用实现:

1
2
void *shmat(int shm_id, const void* *shm_addr,int shmflg);
int shmdt(const void* shm_addr);

Untitled

shmctl系统调用

shmctl系统调用控制共享内存的某些属性

1
int shmctl(int shm_id, int command, struct shmid_ds* buf);

Untitled

共享内存的POSIX方法

6.5节中我们介绍过mmap函数。利用它的MAP ANONYMOUS标志我们可以实现父、子进程之间的匿名内存共享。通过打开同一个文件,mmap也可以实现无关进程之间的内存共享。Linux提供了另外一种利用mmap在无关进程之间共享内存的方式。这种方式无须任何文件的支持,但它需要先使用如下函数来创建或打开一个POSIX共享内存对象:

1
2
3
4
5
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
int shm_open(const char* name,int oflag, mode_t mode);
int shm_unlink(const char* name);

shm_open的使用方法与open系统调用完全相同。

  • name参数指定要创建or打开的共享内存对象. 从可移植性的角度考虑, 应该使用”/something”的格式.

  • oflag指定创建方式:

    • O_RDONLY。以只读方式打开共享内存对象。

    • O_RDWR。以可读、可写方式打开共享内存对象。

    • O_CREAT。如果共享内存对象不存在,则创建之。此时mode参数的最低9位将指定该共享内存对象的访问权限。共享内存对象被创建的时候,其初始长度为0。

    • O_EXCL。和O CREAT一起使用,如果由namc指定的共享内存对象已经存在,则shm_open调用返回错误,否则就创建一个新的共享内存对象。

    • O_TRUNC。如果共享内存对象已经存在,则把它截断,使其长度为O。

  • shm_open返回一个文件描述符, 可用于后续的mmap调用, 从而将共享内存关联到调用进程.

  • 如果代码使用了上述POSIX共享内存函数, 则编译时需要指定链接选项 -lrt

使用共享内存的聊天室服务器程序

13.7 消息队列

消息队列是在两个进程之间传递二进制块数据的简单有效方式. 每个数据块都有一个特定的类型, 接收方可以根据类型来有选择地接收数据, 而不一定像管道那样必须FIFO.

msgget系统调用

创建或获取一个消息队列, 返回值为标识符.

1
2
#include <sys/msg.h>
int msgget(key_t key, int msgflg);

msgflag的使用和semget的sem_flags相同.

msgsnd系统调用

msgsnd系统调用把一条消息添加到消息队列中.

1
2
3
4
5
int msgsnd(int msqid, const void* msg_ptr, size_t msg_sz, int msgflg);
struct msgbuf{
long mtype; // 消息类型
char mtext[512]; // 消息数据
};
  • msgflag仅支持IPC_NOWAIT标志.
  • 系统调用成功时返回0, 失败则为-1并设置errno.

msgrcv系统调用

msgrcv系统调用从消息队列中获取消息

1
int msgrcv(int msqid, void* msg_ptr, size_t msg_sz, long int msgtype, int msgflg);
  • msgtype指定读取何种类型的消息.

    • 0: 第一个消息;

    • 0: 第一个类型为msgtype的消息(除非制定了标志MSG_EXCEPT)

    • <0: 读取消息队列中第一个类型值比msgtype的绝对值小的消息.

  • msgflag控制msgrcv的行为, 是以下标志的按位或:

    • IPC_NOWAIT。如果消息队列中没有消息,则msgrev调用立即返回并设置errno为

ENOMSG

  • MSG_EXCEPT。如果msgtype大于O,则接收消息队列中第一个非msgtype类型的
    消息。

  • MSG NOERROR。如果消息数据部分的长度超过了msg sz,就将它截断

msgctl系统调用

msgctl系统调用控制消息队列的某些属性

1
int msgctl(int msqid, int command, struct msqid_ds* buf);

Untitled

Untitled

13.8 IPC命令

上述三种System V IPC进程间通信方式(信号量, 共享内存, 消息队列)都使用了一个全局唯一的键值来描述一个共享资源. 当程序调用semget, shmget, msgget时, 就创建了这些共享资源的一个实例. linux提供了ipcs命令, 以查看当前系统上拥有哪些共享资源实例.

13.9 在进程间传递文件描述符

由于fork调用之后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。需要注意的是,传递一个文件描述符并不是传递一个文件描述符的值,而是要在接收进程中创建一个新的文件描述符,并且该文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。

Untitled

子进程通过管道将文件描述符发送到父进程, (同理可以发给不相关进程)

Untitled

父进程从管道接收目标文件描述符

  • Title: 第十三章 多进程编程
  • Author: Huan Lee
  • Created at : 2023-08-20 08:08:13
  • Updated at : 2024-02-26 04:53:15
  • Link: https://www.mirthfullee.com/2023/08/20/notion-第十三章 多进程编程-07777541/
  • License: This work is licensed under CC BY-NC-SA 4.0.