多进程编程
学习《Linux高性能服务器编程》第十三章多进程编程,里面介绍了各种Linux编程中多进程编程的内容,为了印象深刻一些,多动手多实践,所以记下这个笔记。这一篇主要记录Linux中。这一章分为fork系统调用、exec系列系统调用、处理僵尸进程、信号量、共享内存、消息队列、IPC命令。
fork系统调用
Linux当作创建新进程的系统调用是fork
1 |
|
fork函数的每次调用都返回两次,在父进程中返回的是子进程的PID,在子进程中则返回0。该返回值是后续代码判断当前进程是父进程还是子进程的依据。
fork调用失败时返回-1,并设置errno。
fork函数深入起来需要注意的点有很多,书中给了三个需要注意的部分:
fork函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的PPID被设置成原进程的PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。- 子进程的代码与父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是所谓的写时复制(copy on writte),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。即便如此,如果我们在程序中分配了大量内存,那么使用
fork时也应当十分谨慎,尽量避免没必要的内存分配和数据复制。 - 创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1。不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加1。
写个简单的小例子
1 |
|

exec系列系统调用
有时我们需要在子进程中执行其他程序,即替换当前进程映像,这就需要使用如下exec系列函数之一:
1 |
|
path参数指定可执行文件的完整路径,file参数可以接受文件名,该文件的具体位置则在环境变量PATH中搜寻。
arg接受可变参数,argv则接受参数数组,它们都会被传递给新程序(path或file指定的程序)的main函数。envp参数用于设置新程序的环境变量。如果未设置它,则新程序将使用由全局变量environ指定的环境变量。
一般exec函数是不返回的,除非出错。它出错时返回-1,并设置errno。如果没出错,则原程序中exec调用之后的代码都不会执行,因为此时原程序已经被exec的参数指定的程序完全替换(包括代码和数据)。
exec函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性。
exec函数族一般规律:
1 | l(list) 命令行参数列表 |
事实上,只有execve是真正的系统调用,其他5个函数最终都调用execve,是库函数,所以execve在man手册第二节,其它函数在man手册第3节。
小栗子:
1 |
|

处理僵尸进程(回收子进程)
对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。因此有时候子进程会产生两种特殊状态:孤儿进程和僵尸进程(大概就这种意思)
孤儿进程:父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。(孤儿进程并不会有什么危害,因为init进程会循环地wait()它的已经退出的子进程)
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
孤儿进程例子:
1 |
|
可以看的父进程退出后,子进程的ppid变成了1(init的pid),也就是说子进程被init领养了。

僵尸进程例子:
1 |
|

子进程处于僵尸态会占用内核资源,内核资源长期被占用得不到释放显然是一件不好的事情。所以父进程需要正确的进行调用处理好子进程
1 |
|
wait函数将阻塞进程,直到该进程的某个子进程结束运行为止。它返回结束运行的子进程PID。并将该子进程的退出状态信息存储于wstatus参数指向的内存中。Linux中有几个宏来帮助解释子进程的退出状态信息。

wait例子
1 |
|

wait会阻塞进程,显然不是我们服务器所期望的,而waitpid函数解决了这个问题,它可以设置非阻塞。
waitpid只等待由pid参数指定的子进程。如果pid取值为-1,那么它就和 wait函数相同,即等待任意一个子进程结束。
wstatus参数的含义和wait函数的wstatus参数相同。options参数可以控制waitpid函数的行为。该参数最常用的取值是WNOHANG。当options的取值是WNOHANG时,waitpid调用将是非阻塞的:如果pid指定的目标子进程还没有结束或意外终止,则waitpid立即返回0;如果目标子进程确实正常退出了,则 waitpid返回该子进程的PID。waitpid调用失败时返回-1并设置errno。
Linux在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。对waitpid函数而言,我们最好在某个子进程退出之后再调用它。那么父进程从何得知某个子进程已经退出了呢?这正是SIGCHLD信号的用途。当一个进程结束时,它将给其父进程发送一个SIGCHLD信号。因此,我们可以在父进程中捕获SIGCHLD信号,并在信号处理函数中调用waitpid函数以“彻底结束”一个子进程。
waitpid简单小例子
1 |
|

信号量
信号量(注意不是信号)是操作系统(并发)里面的概念,解决的是多个进程之间的同步问题。Linux信号量的API都定义在sys/sem.h头文件中,主要包含3个系统调用:semget,semop和semctl。它们都被设计为操作一组信号量,即信号量集,而不是单个信号量,因此这些接口看上要复杂一点。
semget
semget系统调用创建一个新的信号量集,或者获取一个已经存在的信号量集。
1 |
|
key参数是一个键值,用来表示一个全局唯一的信号量集。要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。
nsems参数指定要创建/获取的信号量集中信号量的数目。如果是创建信号量,则该值必须被指定;如果是获取已经存在的信号量,则可以把它设置为0。
semflg参数指定信号量的操作类型以及操纵权限。
semget成功时返回一个正整数值,它是信号量集的标识符;semget失败时返回-1,并设置errno。
semget用于创建信号量集时,和它关联的内核数据结构体semid_ds将会被创建并初始化
1 | struct ipc_perm { |

semop系统调用
semop系统调用改变信号量的值,即执行PV操作。semop是通过在底层是通过操作一些重要的内核变量,如:semval、semzcnt、semncnt、sempid,来实现PV功能
1 | unsigned short semval; /* semaphore value */ |
semop的定义如下
1 |
|
semid参数是由semget调用返回的信号量集标识符,用以指定被操作的目标信号量集。
sops参数指向一个sembuf结构体类型的数组,sembuf结构体的定义如下:
1 | /* Structure used for argument to `semop' to describe operations. */ |
其中sem_num成员是信号量集中信号量的编号,0表示信号量集中第一个信号量。
sem_op成员指定操作类型,其可选值为正整数、0和负整数。每种类型的操作的行为又受到sem_fig成员的影响。sem_fg的可选值是IPC_NOWAIT和SEM_UNDO。IPC_NOWAIT的含义是,无论信号量操作是否成功,semop调用都将立即返回,这类似于非阻塞IO操作。SEM_UNDO的含义是,当进程退出时取消正在进行的semop操作。具体来说,sem_op和sem_flg 将按照如下方式来影响semop的行为:

semop系统调用的第3个参数num_sem_ops指定要执行的操作个数,即sem_ops数组中元素的个数。semop对数组sem_ops中的每个成员按照数组顺序依次执行操作,并且该过程是原子操作,以避免别的进程在同一时刻按照不同的顺序对该信号集中的信号量执行semop操作导致的竞态条件。
semop成功时返回0,失败则返回-1并设置errno。失败的时候,sem_ops数组中指定的所有操作都不被执行。
semtcl系统调用
semctl系统调用允许调用者对信号量进行直接控制。其定义如下:
1 |
|
sem_id参数是由semget调用返回的信号量集标识符,用以指定被操作的信号量集。
semnum参数指定被操作的信号量在信号量集中的编号。
cmd参数指定要执行的命令。有的命令需要调用者传递第4个参数。第4个参数的类型由用户自己定义,但sys/sem.h头文件给出了它的推荐格式,
1 | union semun { |
semctl支持的命令

这些操作中,GETNCNT、GETPID、GETVAL、GETZCNT和SETVAL操作的是单个信号量,它是由标识符semid指定的信号量集中的第semnum个信号量;而其他操作针对的是整个信号量集,此时semctl的参数semnum被忽略。
semctl成功时的返回值取决于cmd参数,如表13-2所示。semctl失败时返回-1,并设置errno。
特殊键值IPC_PRIVATE
semget的调用者可以给其key参数传递一个特殊的键值IPC_PRIVATE(其值为0),这样无论该信号量是否已经存在,semget都将创建一个新的信号量。使用该键值创建的信号量并非像它的名字声称的那样是进程私有的。其他进程,尤其是子进程,也有方法来访问这个信号量。所以semget的 man手册的BUGS部分上说,使用名字IPC_PRIVATE有些误导(历史原因),应该称为IPC_NEW。比如下面的代码就在父、子进程间使用一个IPC_PRIVATE信号量来同步。
1 |
|
共享内存
共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件。因此,共享内存通常和其他进程间通信方式一起使用。
Linux共享内存的API都定义sys/shm.h头文件中,包括4个系统调用: shmget、shmat、shmdt 和lshmctl。我们将依次讨论之。
shmget系统调用
shmget系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。
1 |
|
key参数是一个键值,用来标识一段全局唯一的共享内存。
size参数指定共享内存的大小,单位是字节。如果是创建新的共享内存,则size值必须被指。如果是获取已经存在的共享内存,则可以把size设置为0。
shmflg参数指定信号量的操作类型以及操纵权限。它和semget中semflg参数相同,但是shmflg支持两个额外的标志:SHM_HUGETLB和SHM_NORESERVE

shmget成功时返回—个正整数值,它是共享内存的标识符。shmget失败时返回-1,并设置errno。
如果shmget用于创建共享内存,则这段共享内存的所有字节都被初始化为0,与之关联的内核数据结构shmid_ds将被创建并初始化。shmid_ds结构体的定义如下:
1 | struct shmid_ds { |

shmat和shdt系统调用
共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中。使用完共享内存之后,我们也需要将它从进程地址空间中分离。这两项任务分别由如下两个系统调用实现:
1 |
|
shmid参数是由shmget调用返回的共享内存标识符。
shmaddr参数指定将共享内存关联到进程的那块地址空间,最终的效果还受到shmflg参数的可选标志SHM_RND的影响。

除了SHM_RND标志外,shmflg参数还支持如下标志:

shmat成功时返回共享内存被关联到的地址,失败则返回(void*)-1并设置errno。shmat成功时,将修改内核数据结构shmid_ds的部分字段,如下:

shmdt函数将关联到shm_addr处的共享内存从进程中分离。它成功时返回0,失败则返回-1并设置errno。shmdt在成功调用时将修改内核数据结构shmid_ds的部分字段,如下:

shmctl系统调用
shmctl系统调用控制共享内存的某些属性。定义如下:
1 |
|
shmid参数是由shmget调用返回的共享内存标识符。
cmd参数指定要执行的命令

shmctl成功时的返回值取决于cmd参数,如上表所展示的,shmctl失败时返回-1,并且设置erron。
共享内存POSIX方法
之前介绍过mmap函数。利用它的MAP_ANONYMOUS标志我们可以实现父、子进程之间的匿名内存共享。通过打开同一个文件,mmap也可以实现无关进程之间的内存共享。Linux提供了另外一种利用mmap在无关进程之间共享内存的方式。这种方式无须任何文件的支持,但它需要先使用如下函数来创建或打开一个POSIX共享内存对象:
1 |
|
shm_open的使用方法与open系统调用完全相同。
name参数指定要创建/打开的共享内存对象。从可移植性的角度考虑,该参数应该使用”/somename”的格式:以“/”开始,后接多个字符,且这些字符都不是“/”;以“\0”结尾,长度不超过NAME_MAX(通常是255)。
oflag参数指定创建方式。它可以是下列标志中的一个或者多个的按位或:

shm_open调用成功时返回一个文件描述符。该文件描述符可用于后续的mmap调用,从而将共享内存关联到调用进程。shm_open失败时返回-1,并设置errno。
和打开的文件最后需要关闭一样,由shm_open创建的共享内存对象使用完之后也需要被删除。这个过程是通过shm_unlink函数实现的。
该函数将name参数指定的共享内存对象标记为等待删除。当所有使用该共享内存对象的进程都使用ummap将它从进程中分离之后,系统将销毁这个共享内存对象所占据的资源。
如果代码中使用了上述POSIX共享内存函数,则编译的时候需要指定链接选项-lrt。
消息队列
消息队列是在两个进程之间传递二进制块数据的一种简单有效的方式。每个数据块都有一个特定的类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。
Linux消息队列的API都定义在sys/msg.h头文件中,包括4个系统调用: msgget、msgsnd、msgrcv和 msgctl。
msgget系统调用
msgget系统调用创建一个消息队列,或者获取一个已有的消息队列。其定义如下:
1 |
|
key参数是一个键值,用来标识一段全局唯一的共享内存。
msgflg参数指定信号量的操作类型以及操纵权限。它和semget中semflg参数相同。
msgget成功时返回一个正整数值,它是消息队列的标识符。msgget失败时返回-1,并设置errno。
如果msgget用于创建消息队列,则与之关联的内核数据结构msqid_ds将被创建并初始化。msqid_ds结构体的定义如下:
1 | struct msqid_ds { |
msgsnd系统调用
msgsnd系统调用把一条消息添加到消息队列当中。
1 |
|
msqid参数是由msgget调用返回的消息队列标识符。
msgp参数指向一个准备发生的消息,消息的类型如下:
1 | struct msgbuf { |
其中,mtype成员指定消息的类型,它必须是一个正整数。mtext是消息数据。
msgsz参数是消息的数据部分(mtext)的长度。这个长度可以为0,表示没有消息数据。
msgflg参数控制msgsnd的行为。它通常仅支持IPC_NOWAIT标志,即以非阻塞的方式发送消息。默认情况下,发送消息时如果消息队列满了,则msgsnd将阻塞。若IPC_NOWAIT标志被指定,则msgsnd将立即返回并设置errno为EAGAIN。
处于阻塞状态的msgsnd调用可能被如下两种异常情况所中断:

msgsnd成功时返回0,失败则返回-1并设置errno。msgsnd成功时将修改内核数据结构msqid_ds的部分字段,如下所示:

msgrcv调用
msgrcv系统调用从消息队列中获取消息。其定义如下:
1 |
|
msqid参数是由msgget调用返回的消息队列标识符。
msgp参数用于存储接收的消息。
msgsz参数指的是消息数据部分的长度。
msgtyp参数指定接收何种类型的消息。我们可以使用如下几种方式来指定消息类型:

msgflg参数控制msgrcv函数的行为。它可以是如下一些标志的按位或:

处于阻塞状态的msgrcv调用还可能被如下两种异常情况所中断:

msgrcv成功时返回0,失败则返回-1并设置errno。msgrcv成功时将修改内核数据结构msqid_ds的部分字段,如下所示:

IPC命令
上述3种System V IPC进程间通信方式都使用一个全局唯一的键值(key)来描述一个共享资源。当程序调用semget、shmget或者msgget时,就创建了这些共享资源的一个实例。Linux提供了ipcs命令,以观察当前系统上拥有哪些共享资源实例。

上图展示了机器里面没有任何消息队列,但是有共享内存和信号量。