深入浅出-多进程编程

追求适度,才能走向成功;人在顶峰,迈步就是下坡;身在低谷,抬足既是登高;弦,绷得太紧会断;人,思虑过度会疯;水至清无鱼,人至真无友,山至高无树;适度,不是中庸,而是一种明智的生活态度。

导读:本篇文章讲解 深入浅出-多进程编程,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

进程前言

1.复制进程映像的fork 系统调用和替换进程映像的exec系列系统调用。僵尸进程以及如何避免僵尸进程。

2.进程间通信(Inter-Process Communication,IPC)最简单的方式:管道。

3.3种System V进程间通信方式:信号量、消息队列和共享内存。它们都是由AT&TSystem V2版本的UNIX引入的,所以统称为System v IPC.

4.在进程间传递文件描述符的通用方法:通过UNIX本地域socket传递特殊的辅助数据

fork and exec 系列系统调用

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

pid_t fork(void);

该函数的每次调用都返回两次,在父进程中返回的是子进程的PID,在子进程中则返回0。该返回值是后续代码判断当前进程是父进程还是子进程的依据。fork调用失败时返回-1,并设置errno。

fork函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的PPID被设置成原进程的PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。

子进程的代码与父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是所谓的写时复制(copy on writte),即只有在任一进程(父进程或子进程〉对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。即便如此,如果我们在程序中分配了大量内存,那么使用fork 时也应当十分谨慎,尽量避免没必要的内存分配和数据复制。

此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1。不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加1。

有时我们需要在子进程中执行其他程序,即替换当前进程映像,这就需要使用如下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 execvpe(const char *file, char *const argv[],
                   char *const envp[]);

path参数指定可执行文件的完整路径,file参数可以接受文件名,该文件的具体位置则在环境变量PATH中搜寻。arg 接受可变参数,argv则接受参数数组,它们都会被传递给新程序(path或file指定的程序)的main函数。envp参数用于设置新程序的环境变量。如果未设置它,则新程序将使用由全局变量environ指定的环境变量。

在这里插入图片描述

该系列函数辨识方法
该系列函数都以“exec”为前缀,后面的字母有各自固定的含义,可以根据这点来进行区分,而无需强行记忆。看下图详解

在这里插入图片描述
exec系列函数关系剖析
在这里插入图片描述
注意事项:

如果代码想下图这样写,因为exec函数执行出错,但是后续代码仍然会被执行,可是:当前进程的内存空间(堆、栈、数据区)可能已经被破坏,所以这种写法是不妥的!
在这里插入图片描述
上图代码不妥,应该修改为下图方式,即设置进程退出:
在这里插入图片描述
参考链接实战

僵尸进程

对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态。另外一种使子进程进人僵尸态的情况是﹔父进程结束或者异常终止,而子进程继续运行。此时子进程的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参数指向的内存中
子进程状态信息
在这里插入图片描述
wait函数的阻塞特性显然不是服务器程序期望的,而 waitpid函数解决了这个问题。waitpid 只等待由pid参数指定的子进程。如果pid取值为-1,那么它就和 wait函数相同,即等待任意一个子进程结束。stat_loc参数的含义和wait函数的stat_loc参数相同。options参数可以控制waitpid函数的行为。该参数最常用的取值是WNOHANG。当options的取值是WNOHANG时,waitpid调用将是非阻塞的:如果pid指定的目标子进程还没有结束或意外终止,则waitpid立即返回0﹔如果目标子进程确实正常退出了,则waitpid返回该子进程的PID。waitpid 调用失败时返回-1并设置errno.

这个博客,要在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。对waitpid函数而言,我们最好在某个子进程退出之后再调用它。那么父进程从何得知某个子进程已经退出了呢?这正是SIGCHLD信号的用途。当一个进程结束时,它将给其父进程发辽一个SIGCHLD信号。因此,我们可以在父进程中捕获SIGCHLD信号,并在信号处理函数中调用waitpid函数以“彻底结束”一个子进程。

管道(有关联的进程)

管道能在父、子进程间传递数据,利用的是fork 调用之后两个管道文件描述符(fd[0]和fd[1])都保持打开。一对这样的文件描述符只能保证父、子进程间一个方向的数据传输,父进程和子进程必须有一个关闭fd[0],另一个关闭fd[1]。比如,我们要使用管道实现从父进程向子进程写数据,下图
在这里插入图片描述

如果要实现父、子进程之间的双向数据传输,就必须使用两个管道。第6章中我们还介绍过,socket编程接口提供了一个创建全双工管道的系统调用: socketpair。

信号量

信号量原语

当多个进程同时访问系统上的某个资源的时候,比如同时写一个数据库的某条记录,或者同时修改某个文件,就需要考虑进程的同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。通常,程序对共享资源的访问的代码只是很短的一段,但就是这一段代码引发了进程之间的竞态条件。我们称这段代码为关键代码段,或者临界区。对进程同步,也就是确保任一时刻只有一个进程能进人关键代码段。

要编写具有通用目的的代码,以确保关键代码段的独占式访问是非常困难的。有两个名为Dekker算法和 Peterson算法的解决方案,它们试图从语言本身(不需要内核支持)解决并发问题。但它们依赖于忙等待,即进程要持续不断地等待某个内存位置状态的改变。这种方式下CPU利用率太低,显然是不可取的。

Dijkstra提出的信号量(Semaphore)概念是并发编程领域迈出的重要一步。信号量是一种特殊的变量,它只能取自然数值并且只支持两种操作﹔等待(wait)和信号(signal)。不过在Linux/UNIX中,“等待”和“信号”都已经具有特殊的含义,所以对信号量的这两种操作更常用的称呼是P、V操作。这两个字母来自于荷兰语单词passeren(传递,就好像进人临界区)和vrijgeven(释放,就好像退出临界区)。假设有信号量SV,则对它的P、V操作
含义如下:
P(SV),如果SV的值大于0,就将它减1;如果SV的值为0,则挂起进程的执行。V(SV),如果有其他进程因为等待SV而挂起,则唤醒之;如果没有,则将SV加1。

信号量的取值可以是任何自然数。但最常用的、最简单的信号量是二进制信号量,它只能取О和1这两个值。本书仅讨论二进制信号量。使用二进制信号量同步两个进程,以确保关键代码段的独占式访问的一个典型例子下图:
在这里插入图片描述
在图13-2中,当关键代码段可用时,二进制信号量SV的值为1,进程A和B都有机会进入关键代码段。如果此时进程A执行了P(sv)操作将SV减1,则进程B若再执行P(SV)操作就会被挂起。直到进程A离开关键代码段,并执行v(sV)操作将SV加1,关键代码段才重新变得可用。如果此时进程B因为等待sv而处于挂起状态,则它将被唤醒,并进入关键代码段。同样,这时进程A如果再执行P(SV)操作,则也只能被操作系统挂起以等待进程B退出关键代码段。

注意
使用一个普通变量来模拟二进制信号量是行不通的,因为所有高级语言都没有一个原子操作可以同时完成如下两步操作﹔检测变量是否为true/false,如果是则再将它设置为falsc/true。

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。这种创建信号量的行为与用O_CREAT和O_EXCL标志调用open来排他式地打开一个文件相似。

如果semget用于创建信号量集,则与之关联的内核数据结构体semid_ds将被创建并初始化。semid ds结构体的定义如下.

struct ipc_perm
{
key_t key;
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
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设置为当前的系统时间。

semop

semop系统调用改变信号量的值,介绍与每个信号量关联的一些重要的内核变量:

unsigned short semval //信号量的值
unsigned short semzcnt //等待信号量的值变成0
unsignedd short semzcnt //等待信号量增加的量
pit_t sempid //最后一次执行semop的进程ID

scmop对信号量的操作实际上就是对这些内核变量的操作。scmop的定义如下:

#include<sys/sem.h>
int sem(int sem_di,struct sembuf* sem_ops,size_t num_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_fig成员的影响。sem_flg的可选值是IPC_NOWAITSEM_UNDO。
IPC_NOWAIT的含义是,无论信号量操作是否成功,semop调用都将立即返回,这类似于非阻塞IO操作。
SEM_UNDO的含义是,当进程退出时取消正在进行的semop操作。具体来说,sem_op和sem_flg将按照如下方式来影响

semop的行为:
如果sem_op大于0,则semop将被操作的信号量的值semval增加 sem_op。该操作要求调用进程对被操作信号量集拥有写权限。此时若设置了SEM_UNDO标志,则系统将更新进程的semadj变量(用以跟踪进程对信号量的修改情况)。

如果sem_op等于0,则表示这是一个“等待0”( wait-for-zero)操作。该操作要求调用进程对被操作信号量集拥有读权限。如果此时信号量的值是0,则调用立即成功返回。如果信号量的值不是0,则semop 失败返回或者阻塞进程以等待信号量变为0。在这种情况下,当IPC_NOWAIT标志被指定时,semop立即返回一个错误,并设置errno为EAGAIN。如果未指定IPC_NOWAIT标志,则信号量的semzcnt值加1,进程被投入睡眠直到下列3个条件之一发生:信号量的值semval变为0,此时系统将该信号量的semzcnt值减1﹔被操作信号量所在的信号量集被进程移除,此时semop调用失败返回,errno被设置为EIDRM;调用被信号中断,此时semop调用失败返回,errno被设置为EINTR,同时系统将该信号量的semzcnt值减1。

如果sem_op小于0,则表示对信号量值进行减操作,即期望获得信号量。该操作要求调用进程对被操作信号量集拥有写权限。如果信号量的值semval大于或等于sem_op 的绝对值,则semop操作成功,调用进程立即获得信号量,并且系统将该信号量的semval值减去sem_op的绝对值。此时如果设置了SEM_UNDO标志,则系统将更新进程的semadj变量。如果信号量的值semval小于sem_op的绝对值,则semop失败返回或者阻塞进程以等待信号量可用。在这种情况下,当IPC_NOWAIT标志被指定时,semop立即返回一个错误,并设置errno为EAGAIN。如果未指定IPC_NOWAIT标志,则信号量的semncnt值加1,进程被投入睡眠直到下列3个条件之一发生:信号量的值semval变得大于或等于sem_op 的绝对值,此时系统将该信号量的semncnt值减1,并将semval减去sem_op 的绝对值,同时,如果SEM_UNDO标志被设置,则系统更新semadj变量﹔被操作信号量所在的信号量集被进程移除,此时semop调用失败返回,errno被设置为EIDRM ;调用被信号中断,此时semop 调用失败返回,errno被设置为EINTR,同时系统将该信号量的semncnt值减1。

semctl

#include<sys/sem.h>
int sem(int sem_id,int sem_num,int command)

sem_id参数是由semget调用返回的信号量集标识符,用以指定被操作的信号量集。sem_num参数指定被操作的信号量在信号量集中的编号。command参数指定要执行的命令。有的命令需要调用者传递第4个参数。第4个参数的类型由用户自己定义,但sys/sem.h头文件给出了它的推荐格式,具体如下:

     union semun {
               int              val;    /* Value for SETVAL */
               struct semid_ds *buf;    /* Buffer for IPC_STAT, IPC_SET */
               unsigned short  *array;  /* Array for GETALL, SETALL */
               struct seminfo  *__buf;  /* Buffer for IPC_INFO
                                           (Linux-specific) */
           };



  struct  seminfo {
                         int semmap;  /* Number of entries in semaphore 
                                         map; unused within kernel  */
                         int semmni;  /* Maximum number of semaphore sets */
                         int semmns;  /* Maximum number of semaphores in all
                                         semaphore sets */
                         int semmnu;  /* System-wide maximum number of undo
                                         structures; unused within kernel */
                         int semmsl;  /* Maximum number of semaphores in a
                                         set */
                         int semopm;  /* Maximum number of operations for
                                         semop(2) */
                         int semume;  /* Maximum number of undo entries per
                                         process; unused within kernel */
                         int semusz;  /* Size of struct sem_undo */
                         int semvmx;  /* Maximum semaphore value */
                         int semaem;  /* Max. value that can be recorded for
                                         semaphore adjustment (SEM_UNDO) */
                     };

semctl的command参数
在这里插入图片描述
注意:这些操作中,GETNCNT、GETPID、GETVAL、GETZCNT和SETVAL操作的是单个信号量,它是由标识符sem_id指定的信号量集中的第sem_num个信号量﹔而其他操作针对的是整个信号量集,此时semctl的参数sem num被忽略。

内存共享

共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件。因此,共享内存通常和其他进程间通信方式一起使用。

shmget shmat shmdt 和 shmctl

#include<sys/shm.h>
int semget(key_t size_t size,int shmflg);

和semget系统调用一样,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信号。

   struct shmid_ds {
               struct ipc_perm shm_perm;    /* Ownership and permissions */
               size_t          shm_segsz;   /* Size of segment (bytes) */
               time_t          shm_atime;   /* Last attach time */
               time_t          shm_dtime;   /* Last detach time */
               time_t          shm_ctime;   /* Last change time */
               pid_t           shm_cpid;    /* PID of creator */
               pid_t           shm_lpid;    /* PID of last shmat(2)/shmdt(2) */
               shmatt_t        shm_nattch;  /* No. of current attaches */
               ...
           };


将shm_perm.cuid和shm_perm.uid设置为调用进程的有效用户ID.
将shm_perm.cgid和 shm_perm.gid设置为调用进程的有效组ID。
将shm_perm.mode的最低9位设置为shmflg参数的最低9位。将shm_segsz设置为size。
将shm_lpid、 shm_nattach、shm_atime、shm_dtime设置为0。
将shm_ctime设置为当前的时间。

共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中。使用完共享内存之后,我们也需要将它从进程地址空间中分离。这两项任务分别由如下两个系统调用实现:
#include<sys/shm.h>
void* shmat(int shm_id,const void* shm_addr,int shmflg)
int shmdt(const void* shm_addr);

shmtl系统调用:
shmctl(int shm_id,int command,struct shmid_ds* buf)

在这里插入图片描述

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/129699.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!