第13章 多进程编程
进程是Linux操作系统环境的基础,控制着系统上几乎所有的活动。
13.1 fork 系统调用
#include<sys/types.h>
#include<unistd.h>
pid_t fork ( void ) ;
该函数的每次调用都返回两次,在父进程中返回的是子进程的PID,在子进程中则返回0。
该返回值是后续代码判断当前进程是父进程还是子进程的依据。
fork 调用失败时返回 -1 , 并设置 errno 。
fork 函数复制当前进程,在内核进程表中创建一个新的进程表项,
新的进程表项有很多属性和原进程相同(例如:栈指针,堆指针和标志寄存器的值),但也有许多属性被赋予新的值(例如:该进程的PPID被设置成原进程的PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用))。
子进程的代码与父进程完全相同,同时子进程还会复制父进程的数据(堆数据,栈数据和静态数据)。
数据的复制采用的是写时复制,即只有任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。
注意:如果我们在程序中分配了大量内存,那么使用fork时也应当十分谨慎,尽量避免没有必要的内存分配和数据复制。
创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1 ,而且父进程的用户根目录,当前工作目录等变量的引用计数也会加1 。
13.2 exec系列系统调用
#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指定的环境变量。
一般情况下,exec函数是不返回的,除非出错。出错时返回 -1,并设置errno 。 如果没出错,则原程序中exec调用之后的代码都不会执行,因为此时原程序已经被exec的参数指定的程序完全替换(包括代码和数据)。
注意:exec函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性。
13.3 处理僵尸进程
对于多进程程序而言,父进程一般需要跟踪子进程的退出状态,因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。
在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态。
另一种使子进程进入僵尸态的情况是:父进程结束或者异常终止,而子进程继续运行,此时子进程的PPID将被设置为1(init进程)。init进程接管了该子进程,并等待它结束。
父进程退出之后,子进程退出之前,该子进程处于僵尸态。
由此可见,无论哪种情况,如果父进程没有正确地处理子进程的返回信息,子进程都将停留在僵尸态,并占据着内核资源(这绝不能容许,因为内核资源有限)。
下面的函数在父进程调用,以获取子进程的返回信息,避免僵尸进程的产生,或者使子进程的僵尸态立即结束:
#include <sys/types.h>
#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参数指向的内存中。
sys/wait.h 头文件中定义了几个宏来帮助解释子进程的退出状态信息,如表:
wait函数的阻塞特性显然不是服务器程序期望的,而waitpid函数解决了这个问题。
waitpid只等待由pid参数指定的子进程,如果pid取值为 -1,那么它就和wait函数相同,(等待任意一个子进程结束)。
stat_loc参数的含义和wait函数的stat_loc参数相同。
options参数可以控制waitpid函数的行为,该参数最常用的值是WNOHANG。当option的取值是WNOHANG时,waitpid调用将是非阻塞的:如果pid指定的目标子进程还没有结束或意外终止,则waitpid立即返回0;如果目标子进程确实正常退出了,则waitpid返回该子进程的PID。
waitpid调用失败时返回-1,并设置errno 。
注意:
在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。
对waitpid函数而言,我们最好在某个子进程退出之后再调用它。
那么父进程如何得知某个子进程已经退出了呢?
通过SIGCHLD信号,当一个进程结束时,它将给其父进程发送一个SIGCHLD信号,父进程可以捕获SIGCHLD信号并在信号处理函数中处理该子进程。
13.4 管道
管道(pipe)只能在父子进程间传递数据,利用的是fork调用之后两个管道文件描述符( fd[0] 读 和fd[1]写 )。
有一种特殊的管道称为有名管道(FIFO),能用于无关联进程之间的通信。
注意:一对这样的文件描述符只能保证父子进程间一个方向的数据传输,父进程和子进程必须有一个关闭fd[0](读),另一个关闭fd[1](写)。
如果要实现父子进程之间的双向数据传输,必须使用两个管道。
13.5 信号量
13.5.1 信号量原语
当多个进程同时访问系统上的某个资源的时候,就需要考虑进程的同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。
通常,程序对共享资源的访问的代码只是很短的一段,我们称这段代码为关键代码段(临界区)。
对进程同步也就是确保任一时刻只有一个进程能进入关键代码段。
信号量是一种特殊的变量,它只能取自然数值并且只支持两种操作:在Linux/UNIX对信号量的两种操作称为P,V操作。
设有信号量SV,
P(SV)使SV减 1 ,若SV为0,则挂起进程的执行。
V(SV),若有其他进程因为等待信号量而挂起,则唤醒之;如果没有则将SV加 1 。
信号量的取值可以是任何自然数,但是最常用的信号量是二进制信号量,只能取0和1两个值。
注意:使用一个普通变量来模拟二进制信号量是行不通的,因为所有高级语言都没有一个原子操作可以同时完成如下两步操作:检测变量是否为true/false,如果是则再将它设置为false/true 。
13.5.2 semget系统调用
#include <sys/sem.h>
int semget ( key_t key , int num_sems , int sem_flags ) ;
key参数是一个键值,用来标识一个全局唯一的信号量集,要通过信号量通信的进程需要使用相同的键值来创建 / 获取该信号量。
num_sems参数指定要创建 / 获取的信号量集中信号量的数目,如果是创建信号量,则该值必须被指定;如果是获取已经存在的信号量,则可以把它设置为0 。
sem_flags参数指定一组标志,它的低9位是该信号量的权限 ,其格式和含义都与系统调用open和mode参数相同 。此外它还可以和IPC_CREAT标志做按位“或”运算以创建新的信号量集。此时即使信号量已经存在,semget也不会产生错误。
我们可以联合使用IPC_CREAT | IPC_EXCL标志来来确保创建一组新的,唯一的信号量集。在这种情况下,如果信号量集已经存在,则semget返回错误并设置errno为EEXIST 。
semget成功时返回一个正整数值,它是信号量集的标识符;semget失败时返回 -1 ,并设置errno 。
如果semget用于创建信号量集,则与之关联的内核的数据结构体semid_ds将被创建并初始化。
semid_ds结构体的定义如下:
#include <sys/sem.h>
struct ipc_perm{
key_t key ; // 键值
uid_t uid ; // 所有者的有效用户ID
gid_t gid ; // 所有者的有效组ID
uid_t cuid ; // 创建者的有效用户ID
gid_t cgid ; // 创建者的有效组ID
mode_t mode ; // 访问权限
} ;
struct semid_ds {
struct ipc_perm sem_perm ; //信号量的操作权限
unsigned long int sem_nsems ; //该信号量集中的信号量数目
time_t sem_otime ; //最后一次调用semop的时间
time_t sem_ctime ; //最后一次调用semctl的时间
} ;
semget对semid_ds结构体的初始化包括:
将sem_perm.cuid和sem_perm.uid设置为调用进程的有效用户ID。
将sem_perm.cgid 和 sem_perm.gid设置为调用进程的有效组ID。
将sem_perm.mode的最低9位设置为sem_flags参数的最低9位。
将sem_nsems设置为num_sems。
将sem_otime设置为0。
将sem_ctime设置为当前的系统时间。
13.5.3 semop系统调用
semop改变信号量的值,即执行P,V操作。
与每个信号量关联的一些重要内核变量:
unsigned short semval ; //信号量的值
unsigned short semzcnt ; //等待信号量的值变为0的进程数量
unsigned short semncnt ; //等待信号量值增加的进程数量
pid_t sempid ; //最后一次执行semop操作的进程ID
semop定义:
#include <sys/sem.h>
int semop ( int sem_id , struct sembuf* sem_ops , size_t num_sem_ops ) ;
sem_id 参数是由semget调用返回的信号量集标识符,用以指定被操作的目标信号量集 。
sem_ops参数指向一个sembuf结构体类型的数组,定义如下:
struct sembuf {
unsigned short int sem_num ;
short int sem_op ;
short int sem_flg ;
};
其中,sem_num成员是信号量集中信号量的编号,0表示信号量集中的第一个信号量。
sem_op成员指定操作类型,其可选值为正整数,0和负整数。
每种类型的操作的行为又受到sem_flg成员的影响。
sem_flg的可选值是IPC_NOWAIT和SEM_UNDO。
IPC_NOWAIT:无论信号量操作是否成功,semop调用都将立即返回,类似于非阻塞的I/O操作。
SEM_UNDO:当进程退出时,取消正在进行的semop操作。
sem_op和sem_flg将按照如下方式来影响semop的行为:
1)如果sem_op大于0,则semop将被操作的信号量的值semval增加sem_op 。该操作要求调用进程对被操作信号量集拥有写权限。此时若设置了SEM_UNDO标志,则系统将更新进程的semadj变量(用以跟踪进程对信号量的修改情况)。
2)如果sem_op等于0,则表示这是一个“等待0”操作,该操作要求调用进程对被操作信号量集拥有读权限。
如果此时信号量的值是0,则调用立即成功返回。
如果此时信号量的值不是0,则semop失败返回或者阻塞进程以等待信号量变为0 。在这种情况下,当IPC_NOWAIT标志被指定时,semop立即返回一个错误,并设置errno为EAGAIN 。
如果未指定IPC_NOWAIT标志,则信号量的semzcnt值加1,进程被投入睡眠直到下列3个条件之一发生:
1.信号量的值semval变为0,此时系统将信号量semzcnt值减1。
2.被操作信号量所在的信号量集被进程移除,此时semop调用失败返回,errno被设置为EIDRM 。
3.调用被信号中断,此时semop调用失败返回,errno被设置为EINTR,同时系统将该信号量的semzcnt值减1 。
3)如果sem_op小于0,则表示对信号量值进行减操作,即期望获得信号量,该操作要求调用进程对被操作信号量集拥有写权限。
如果信号量的值semval大于或等于sem_op的绝对值,则semop操作成功,调用进程立即获得信号量,并且系统将该信号量的semval值减去sem_op的绝对值。此时如果设置了SEM_UNDO标志,则系统将更新进程的semadj变量。
如果信号量的值semval小于sem_op的绝对值,则semop失败返回或者阻塞进程以等待信号量可用。在这种情况下,当IPC_NOWAIT标志被指定时,semop立即返回一个错误,并设置errno为EAGAIN 。如果未设置IPC_NOWAIT标志,则信号量的semcnt值加1,进程被投入睡眠直到下列3个条件之一发生:
1.信号量的值semval变得大于或等于sem_op的绝对值,此时系统将该信号量的semncnt值减1,并将semval减去sem_op的绝对值,同时如果SEM_UNDO标志被设置,则系统更新semadj变量。
2.被操作信号量所在的信号量集被进程移除,此时semop调用失败返回,errno被设置为EIDRM。
3.调用被信号中断,此时semop调用失败返回,errno被设置为EINTR,同时系统将该信号量semncnt值减1 。
semop系统调用的第3个参数num_sem_ops指定要执行的操作个数,即sem_ops数组中元素的个数。semop对数组sem_ops中的每个成员按照数组顺序依次执行操作,并且该过程是原子操作,以避免别的进程在同一时刻按照不同的顺序对该信号集中的信号量执行semop操作导致的竞态条件。
semop成功时返回0,失败返回 -1并设置errno 。
失败的时候,sem_ops数组中指定的所有操作都不被执行。
13.5.4 semctl 系统调用
semctl系统调用允许调用者对信号量进行直接控制,定义:
#include <sys/sem.h>
int semctl ( int sem_id , int sem_num , int command , ... ) ;
sem_id参数是由semget调用返回的信号量集标识符,用以指定被操作的信号量集。
sem_num参数指定被操作的信号量在信号量集中的编号。
command参数指定要执行的命令,有的命令需要调用者传递第4个参数。
第4个参数的推荐格式:
union semun{
int val ; // 用于SETVAL命令
struct semid_ds* buf ; //用于IPC_STAT和IPC_SET命令
unsigned short* array ; //用于GETALL和SETALL命令
struct seminfo* __buf ; //用于IPC_INFO命令
} ;
struct seminfo {
int semmap ; //Linux内核没有使用
int semmni ; //系统最多可以拥有的信号量集数目
int semmns ; //系统最多可以拥有的信号量数目
int semmnu; //Linux内核没有使用
int semmsl ; //一个信号量集最多允许包含的信号量数目
int semopm ; //semop一次最多能执行的sem_op操作数目
int semume; //Linux内核没有使用
int semusz ; //sem_undo结构体的大小
int semvmx; //最大允许的信号量值
int semaem ; 最多允许的UNDO次数
};
semctl支持的所有命令如表:
setctl成功时返回值取决于command参数,semctl失败时返回-1并设置errno 。
13.5.5 特殊键值IPC_PRIVATE
semget的调用者可以给其key参数传递一个特殊的键值IPC_PRIVATE(其值为0),这样无论该信号量是否已经存在,semget都将创建一个新的信号量。
13.6 共享内存
共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输。
注意:我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件,因此,共享内存通常和其他进程间通信方式一起使用。
13.6.1 shmget系统调用
shmget系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。
#include <sys/shm.h>
int shmget ( key_t key , size_t size , int shmflg ) ;
key参数是一个键值,用来标识一段全局唯一的共享内存。
size参数指定共享内存的大小,单位是字节。如果是创建新的共享内存,则size值必须被指定。如果是获取已经存在的共享内存,则可以把size设置为0 。
shmflg参数的使用和含义与semget系统调用的sem_flags参数相同,不过shmget支持两个额外的标志——SHM_HUGETLB和SHM_NORESERVE 。
SHM_HUGETLB:类似于mmap的MAP_HUGETLB标志,系统将使用“大页面”来为共享内存分配空间。
SHM_NORESERVE:类似于mmap的MAP_NORESERVE标志,不为共享内存保留交换分区(swap空间)。这样,当物理内存不足的时候,对该共享内存执行写操作将触发SIGSEGV信号。
shmget成功时返回一个正整数值,它是共享内存的标识符。
shmget失败时返回 -1,并设置errno 。
如果shmget用于创建共享内存,则这段共享内存的所有字节都被初始化为0,与之关联的内核数据结构shmid_ds将被创建并初始化。
13.6.2 shmat和shmdt系统调用
共享内存被创建 / 获取之后,我们需要先将它关联到进程的地址空间中,使用完共享内存之后,我们也需要将它从进程地址空间中分离。
#include <sys/shm.h>
void* shmat ( int shm_id , const void* shm_addr , int shmflg );
int shmdt ( const void* shm_addr ) ;
其中shm_id参数是由shmget调用返回的共享内存标识符。
sgm_addr参数指定将共享内存关联到进程的哪块空间,最终的效果还受到shmflg参数的可选标志SHM_RND的影响:
1)如果shm_addr为NULL,则被关联的地址由操作系统选择,以确保代码的可移植性。
2)如果shm_addr非空,并且SHM_RND标志未被设置,则共享内存被关联到addr指定的地址处。
3)如果shm_addr非空,并且设置了SHM_RND标志,则被关联的地址是[shm_addr-(shm_addr%SHMLBA)]。
SHMLBA的含义是“段低端边界地址倍数”,它必须是内存页面大小的整数倍。
SHM_RND的含义是圆整,即将共享内存被关联的地址向下圆整到离shm_addr最近的SHMLBA的整数倍地址处。
shmflg参数还支持:
SHM_RDONLY,进程仅能读取共享内存中的内容,若没有指定该标志则可对共享内存进行读写操作(这需要在创建时指定其读写权限)。
SHM_REMAP,如果地址shmaddr已经被关联到一段共享内存上,则重新关联。
SHM_EXEC,它指定对共享内存段的执行权限,对共享内存而言,执行权限实际上和读权限是一样的。
13.6.3 shmctl 系统调用
#include <sys/shm.h>
int shmctl ( int shm_id, int command, struct shmid_ds* buf ) ;
shm_id参数是由shmget调用返回的共享内存标识符。
cmmand参数指定要执行的命令。
shmctl支持的所有命令如表:
shmctl成功时的返回值取决于command参数,失败时返回 -1,并设置errno 。
13.7 消息队列
消息队列是在两个进程间传递二进制块数据的一种简单有效的方式。
每个数据块都有一个特定的类型,接收方可以根据类型来有选择地接收数据。
13.7.1 msgget系统调用
#include <sys/msg.h>
int msgget ( key_t key , int msgflg ) ;
key: 键值 。
msgflg:例如:IPC_CREAT | 0666
13.7.2 msgsnd 系统调用
#include <sys/msg.h>
int msgsnd ( int msqid , const void* msg_ptr, size_t msg_sz, int msgflg ) ;
msqid参数是由msgget调用返回的消息队列标识符。
msg_ptr参数指向一个准备发射的消息,消息模板被定义为如下类型:
struct msgbuf{
long mtype ; //消息类型
char mtext ; //消息数据
} ;
mtext是该消息的数据,并不一定就是char 类型,任意类型都可以的。
msgflg参数控制msgsnd的行为,通常支持IPC_NOWAIT标志,即以非阻塞的方式发送消息。当发送消息时队列满了,默认情况下msgsnd将阻塞;若IPC_NOWAIT标志设置了,则msgsnd将立即返回并设置errno为EAGAIN 。
处于阻塞状态的msgsnd调用可能被如下两种异常情况所中断:
1)消息队列被移除,此时msgsnd调用将立即返回并设置errno为EIDRM
2)程序接收到信号,此时msgsnd调用立即返回并设置errno为EINTR 。
msgsnd成功时返回0,失败返回 -1 并设置errno 。msgsnd成功时将修改内核数据结构 。
13.7.3 msgrcv系统调用
msgrcv 系统调用从消息队列获取消息:
#include <sys/msg.h>
int msgrvc ( int msqid, void* msg_ptr, size_t msg_sz, long int msgtype, int msgflg ) ;
msqid参数是由msgget调用返回的消息队列标识符。
msg_ptr参数用于存储接收的消息,msg_sz参数指的是消息数据部分的长度。
msgtype参数指定接收何种类型的消息:
1)msgtype等于0,读取消息队列中的第一个消息。
2)msgtype大于0,读取消息队列中第一个类型为msgtype的消息(除非指定标志MSG_EXCEPT)。
3)msgtype小于0,读取消息队列中第一个类型值比msgtype的绝对值小的消息。
参数msgflg控制msgrcv函数的行为,它可以是如下一些标志的按位或:
1)IPC_NOWAIT:不阻塞
2)MSG_EXCEPT:如果msgtype大于0,则接收消息队列中第一个非msgtype类型的消息。
3)MSG_NOERROR:如果消息数据部分的长度超过了msg_sz,就将它截断。
处于阻塞状态的msgrcv调用可能被如下两种异常情况所中断:
1)消息队列被移除,此时msgrcv调用将立即返回并设置errno为EIDRM。
2)程序接收到信号,此时msgrcv调用立即返回并设置errno为EINTR 。
13.7.4 msgctl 系统调用
#include <sys/msg.h>
int msgctl ( int msqid, int command, struct msqid_ds* buf) ;
msqid参数是由返回的共享内存的标识符。
command参数指定要执行的命令。
msgctl支持的命令如表:
msgctl成功时返回值取决于command参数,失败返回 -1并设置errno 。
13.9 在进程间传递文件描述符
由于fork调用之后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。
注意:传递一个文件描述符并不是传递一个文件描述符的值,而是要在接受进程中创建一个新的文件描述符,并且该文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。
如何在两个不相干的进程之间传递文件描述符呢?
在Linux下,我们可以利用UNIX域socket在进程间传递特殊的辅助数据,以实现文件描述符的传递。