多进程编程
学习《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
命令,以观察当前系统上拥有哪些共享资源实例。
上图展示了机器里面没有任何消息队列,但是有共享内存和信号量。