《UNIX高级环境编程》 第八章 进程控制 读书笔记(万字长文读懂UNIX下多进程编程)
目录
一、进程标识符
每个进程都有一个非负整形表示的唯一进程ID。ID为0的通常是调度进程,也被称为交换进程。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。进程ID为1的通常是init进程,在自举过程结束时由内核调用,此进程负责在自举内核后启动一个UNIX系统。init通常读与系统有关的初始化文件(/etc/rc*文件或/etc/inittab文件,以及init.d中的文件),并将系统引导到一个状态(例如多用户)。init绝不会终止,它是一个普通的用户进程,但以超级用户特权运行。
除了进程ID,每个进程还有一些其他的标识符:
#include <unistd.h>
pid_t getpid(void);
//返回调用进程的进程ID
pid_t getppid(void);
//返回调用进程的父进程ID
uid_t getuid(void);
//返回调用进程的实际用户ID
uid_t geteuid(void);
//返回调用进程的有效用户ID
gid_t getgid(void);
//返回调用进程的实际组ID
gid_t getegid(void);
//返回调用进程的有效组ID
二、fork()函数
创建一个新的进程:fork函数
#include <unistd.h>
pid_t fork();
//子进程中返回0,父进程中返回子进程ID,出错返回-1
fork函数被调用一次,但返回两次(在子进程和父进程中分别返回)。子进程中返回0,父进程中返回子进程ID。子进程是父进程的副本,例如,子进程获得父进程数据空间、堆和栈的副本(注意,是副本,父子进程并不共享这些空间)
由于fork之后经常跟随exec(子进程去执行一个新程序),所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全复制,而是使用了写时复制技术。这些区域由父子进程共享,而内核将他们的权限改变为只读,如果父进程或子进程试图修改这些区域,就创建一个副本,使父子进程的数据独立开来。
#include "apue.h"
int glob = 6;
cahr buf[] = "a write to studout\n"
int main(void){
int var;
pid_t pid;
var = 88;
if(write(STDOUT_FILENO,buf,sizeof(buf)-1)!=sizeof(buf)-1)
err_sys("write error\n");
printf("before fork\n");
if((pid=fork())<0){
err_sys("fork error");
}else if(pid == 0){
glob++;
var++;
}else{
sleep(2);
}
printf("pid = %d,glob = %d,var = %d\n",getpid(),glob,var);
exit(0);
}
示例中,调用fork函数之后实际上是两个进程在运行相同的代码,所以最后的printf会打印两次,一次是父进程一次是子进程;因为if条件不同,父子进程会根据fork函数的返回值去执行不同的代码,在本例中,父进程休眠,子进程将两个变量的值递增,这两个值的改变并不影响父进程中的变量,他们的存储空间是独立的。
另外,我们无法确定父进程和子进程谁先执行,所以需要打印进程ID加以辨别(后续会讲解如何使用信号使父子进程同步),父进程的ID一定比子进程的ID小,因为父进程比子进程创建的更早。输出如下:
a write to stdout
before fork
pid = 430,glob = 7,var = 89
pid = 429,glob = 6,var = 88
pid为429的是父进程,变量值没有改变,pid为430的是子进程,变量值递增。
1.文件共享:
对于上述的例子,再重定向父进程标准输出时(终端输入./a.out > test.txt),子进程的标准输出也被重定向。实际上,fork的一个特性是父进程所有打开文件描述符都被复制到子进程中,父、自进程的每个相同的打开描述符共享一个文件表项。
一个进程拥有三个文件描述符:标准输入、标准输出和标准出错。当该进程fork之后,如下图:

这种共享方式使得父子进程对同一文件使用了一个文件偏移量。如果父、子进程写道同一文件描述符,但又没有任何形式的同步,那么他们的输出就会混合。考虑之前的例子,假如我们在终端输入如下命令:./a.out >temp.out将标准输出重定向到temp.txt文件中,fork之后,父进程和子进程分别调用了一次printf,如果父进程没有sleep两秒等待子进程printf完成,那么父进程和子进程的输出很可能会混在一起。
在fork之后处理文件描述符有两种常见的情况:
(1)父进程等待子进程完成。这种情况下父进程无需对文件描述符做任何的处理,等子进程终止之后,它层进行过读、写操作的任一共享描述符的文件偏移量以及执行了相应的更新。
(2)父子进程各自执行不同的程序段。这种情况下在fork之后父子进程各自关闭它们不需要的文件描述符。
2.进程属性
除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:
- 实际用户ID、实际组ID、有效用户ID、有效组ID
- 附加组ID
- 进程组ID
- 会话ID
- 控制终端
- 设置用户ID标志和设置组ID标志
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 信号屏蔽和安排
- 针对任一打开文件描述符的在执行时关闭(close-on-exec)标志
- 环境
- 连接的共享存储段
- 存储映射
- 资源限制
父子进程之间的区别是:
- fork的返回值
- 进程ID不同
- 两个进程具有不同的父进程ID
- 子进程的tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0
- 父进程设置的文件锁不会被子进程继承
- 子进程的未处理的闹钟被清除
- 子进程的未处理信号集被设置为空集
fork失败的主要原因:1.系统中进程数超过限制 2.该实际用户ID的进程总数超过了系统限制
fork的两种用法:
4.fork使用思路
1. 一个父进程希望复制自己,使父子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求。当这种请求到达时父进程创建子进程处理此请求,父进程继续等待下一个服务请求到达。
2. 一个进程要执行一个不同的程序,使用exec函数(后续会讲到)。这种情况下fork之后立即调用exec函数
三、vfork函数
vfork函数的调用序列和返回值与fork相同,但语义不同。vfork创建一个新进程,新进程的目的是exec执行新程序(与上面讲到的fork使用思路的第二点相同)。vfork和fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec,于是也就不会访问该地址空间。相反,子进程在调用exec或exit之前,它们在父进程的空间中运行。这些措施优化在某些UNIX的页式虚拟存储器实现中提高了效率。vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度,如果调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
四、exit函数
回顾前面讲到的进程终止的八种情况(五种正常情况三种异常情况),不管进程如何终止,最后都会执行内核中的同一段代码。该代码为相应进程关闭所有打开描述符,释放它所用的存储器等等。
对于所有的终止状态,我们都希望终止进程能够通知父进程其终止的原因,对于exit、_exit、和_Exit函数,将退出状态作为参数传递给三个函数。在异常终止的情况下,内核产生一个指示其异常终止的原因的终止状态。在任意一种终止情况下,父进程都能使用wait函数或waitpid函数获取子进程的退出状态。
刚才讲到父进程可以获取子进程的退出状态,但是如果父进程在子进程终止之前就已经终止呢?对于父进程已经终止的子进程,我们称其为孤儿进程,所有孤儿进程都会被init进程领养,也就是孤儿进程的父进程变为init进程。
父进程使用wait或wiatpid函数获取子进程的终止信息,包括:进程ID、该进程的终止状态、以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储区,关闭其打开的文件。如果一个子进程已经终止且没有被父进程使用wait函数回收终止信息,则变为僵死进程。ps命令将僵死进程的状态打印为Z。
init进程被编写成无论何时只要有一个子进程终止,init进程就会调用一个wait函数获取其终止状态。所以被init收养的孤儿进程终止后不会称为僵死进程
五、wait和waitpid函数
当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。父进程可以选择忽略,也可以选择执行指定的程序作出反应。对于这种信号,默认的动作是忽略。当程序调用wait或waitpid函数时可能会:
(1)如果其子进程都还在运行,则阻塞
(2)如果一个子进程已终止,正等待父进程获取其终止状态,则取得的该子进程的终止状态然后立即返回
(3)如果它没有任何子进程,则以及出错返回
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);
//成功则返回进程ID,出错则返回-1
两个函数的区别:
- 在一个子进程中之前,wait使其调用者阻塞,而waitpid有一个选项,可使调用者不阻塞
- waitpid并不等待在其调用后的第一个终止子进程,它可以选择自己指定的要等待的终止子进程
这两个函数的参数statloc是一个整形指针,如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可直接将该指针指定为空指针。

示例:
#include "apue.h"
#include <sys/wait.h>
void pr_exit(int status){
if(WIFEXITED(status))
printf("normal termination,exit status = %d\n",WEXITSTATUS(status));
else if(WIFSIGNALED(status))
printf("normal termination,exit status = %d%s\n",
WTERMSIG(status),
#ifdef WCOREDUMP
WCOREDUMP(status) ? "(core file generated)" : "");
#else
"" );
#endif
else if(WIFSTOPPED(status))
printf("child stopped,signal number = %d\n",WSTOPSIG(status));
}
int main(void)
{
pid_t pid;
int status;
if((pid = fork())<0)
err_sys("fork error");
else if(pid == 0)
exit(7);
if(wait(&status)!=pid)
err_sys("wait error");
pr_exit(status);
if((pid = fork())<0)
err_sys("fork error");
else if(pid == 0)
sbort();
if(wait(&status)!=pid)
err_sys("wait error");
pr_exit(status);
if((pid = fork())<0)
err_sys("fork error");
else if(pid == 0)
status / = 0;
if(wait(&status)!=pid)
err_sys("wait error");
pr_exit(status);
exit(0);
}
waitpid中的pid参数:
- pid == -1等待任一子进程
- pid > 0 等待其进程ID与PID相等的子进程
- pid ==0 等待其组ID等于调用进程组ID的任一子进程
- pid < -1等待其组ID等于pid绝对值的任一子进程
而参数options可以进一步控制该函数:

对于一个程序,可能父进程创建一个子进程后就继续工作,不能确定子进程什么时候会终止,所以不能确定调用wait的时机,如果调用太早的话会使得父进程长时间阻塞;如果调用太晚的话可能使得子进程长时间处于僵死状态,这时候就需要使用“双fork”来解决这个问题:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main(){ printf("祖先进程 PID:%d\n",getpid()); pid_t pid1 = fork(); if(pid1 == 0){ //第一次fork的子进程(中间进程,它的任务是创造真正的工作进程) printf("中间进程 PID:%d(父进程:%d)\n",getpid(),getppid()); } pid_t pid2 = fork(); if(pid2 == 0){ //第二次fork的子进程(真正的工作进程) printf("工作进程: 执行实际工作...\n"); sleep(2); // 模拟工作 printf("工作进程: 工作完成,退出\n"); exit(0); }else if(pid2>0){//中间进程 printf("中间进程: 创建了工作进程 PID: %d\n", pid2); printf("中间进程: 立即退出,让工作进程成为孤儿\n"); exit(0);//这个时候中间进程变成了僵尸进程,不过不用担心,祖先进程会立刻wait回收它 } else if (pid1 > 0) { // 祖先进程 printf("祖先进程: 创建了中间进程 PID: %d\n", pid1); // 等待中间进程(立即会结束) waitpid(pid1, NULL, 0); printf("祖先进程: 中间进程已结束,不会变成僵尸\n"); // 此时工作进程已被 init 收养 printf("祖先进程: 工作进程已被 init 收养\n"); printf("祖先进程: 可以继续执行其他任务\n"); // 模拟祖先进程继续工作 for (int i = 0; i < 5; i++) { printf("祖先进程: 工作中... (%d/5)\n", i + 1); sleep(1); } printf("祖先进程: 所有工作完成\n"); } return 0; }这样编程的好处在于,祖先进程不必知晓孙进程(工作进程)何时退出,它只需要waitpid回收中间进程即可,而中间进程在fork孙进程(工作进程)之后会立即退出,所以祖先进程waitpid阻塞的时间不会太久,而且是可以预计的。
六、waitid、wait3、wait4函数
1.waitid函数
#include <sys/wait.h>
int waitid(idtype_t idtype,id_t id,siginfo_t *infop,int options);
//若成功则返回0,否则返回-1
回顾前面讲到的,waitpid既可以等待指定ID的进程,也可以等待组ID进程,但是需要根据ID数来判别;waitid则可以使用idtype指定等待的是组ID还是进程ID:

opetions参数是下列标志的按位或:

infop参数是指向siginfo结构的指针,包含了有关引起子进程状态改变的生成信号的详细信息,后面会详细讲解。
2.wait3、wait4函数
这两个函数在原有功能的基础上,要求内核返回由终止进程及其所有子进程所使用的资源汇总:
pid_t wait3(int *statloc,int options,struct rusage);
pid_t wait4(pid_t pid,int *statloc,int options,struct rusage *rusage);
资源统计信息包括用户CPU时间总量、系统CPU时间总量、页面出错次数、接收到的信号的次数等等

七、竞争条件
当(a)多个进程都企图对共享文件进行某种处理,而(b)最后的结果又取决于进程运行的顺序时,我们认为这发生了竞争条件。
竞争条件经常发生在父子进程之间,如果一个父进程要阻塞等到子进程终止,可以使用wait函数,而如果一个子进程要阻塞等到父进程终止,可以调用如下循环:
while(getppid()!=1)
sleep(1);
这种循环的问题是它浪费了CPU的时间,因为调用者每隔一秒都被唤醒,然后进行条件测试。后面会讲述如何使用信号来解决竞争
八、exec函数
讲解fork的时候曾提到,fork之后子进程往往要调用exec函数执行另一个程序,新程序从main函数开始执行。调用exec实际上是用一个全新的程序替换了当前进程的正文、数据、堆和栈段。
exec一族有六个函数:
#include <unistd.h>
int execl(const char *pathname,const char *arg0, ...);
int execv(const char *pathname,char *const argv[]);
int execle(const char *pathname,const char *arg0, ...);
int execve(const char *pathname,char *const argv[],char *const envp[]);
int execlp(const char *filename,const char *arg0, ...);
int execvp(const char *filename,char *const argv[]);
//出错返回-1,成功不返回值
这些函数之间的第一个区别是前四个取路径名作为参数,后两个取文件名作为参数,当指定filename作为参数时:
- 若filename中包含 / ,则将其视为路径名
- 否则就按PATH环境变量,在他所指定的个目录中搜寻可执行文件。
PATH变量包含了一张目录表(称为路径前缀),目录之间用冒号分隔。例如,name=value的环境字符串:
PATH = /bin:/usr/bin:/usr/local/bin: .
指定在四个目录中进行搜索。最后的路径前缀表示当前目录.
如果execlp或execvp使用路径前缀中的ige找到了一个可执行脚本,但该文件不是由连接编辑器产生的机器可执行文件,则认为该文件是一个shell脚本,于是试着调用/bin/sh,并以该filename作为shell的输入。
| 函数名 | 参数传递方式 | 环境变量 | 路径搜索 | 典型用途 |
|---|---|---|---|---|
| execl | 列表(list) | 继承当前 | 否 | 已知完整路径时 |
| execv | 数组 | 继承当前 | 否 | 已知完整路径,参数动态构建 |
| execlp | 列表 | 继承当前 | 是 | 使用PATH搜索可执行文件 |
| execvp | 数组 | 继承当前 | 是 | 使用PATH搜索,参数动态 |
| execle | 列表 | 自定义 | 否 | 需要自定义环境变量 |
| execve | 数组 | 自定义 | 否 | 系统调用,最底层 |
execl函数:
execl("/bin/ls","ls","-l","/home",NULL);
//参数:
// const char *path : "/bin/ls"可执行文件的路径
// const char *arg0 : "ls"程序名
// ... : "-l","/home"参数列表,以NULL结尾
//新程序将执行 ls -l /home命令
特点是必须知道可执行文件的完整路径,最后一个参数必须是NULL
execv函数:
char *args[] = {"ls","-l","home",NULL};//静态参数
execv("/bin/ls",args);
char **args = malloc(4 * sizeof(char *));//动态参数
args[0]="grep";
args[1]="-r";
args[2]="pattern";
args[3]=NULL;
execv("/bin/gerp",args);
特点是输入参数可以动态创建,参数以指针数组形式传递
execlp函数:
execlp("ls","ls","-l","/home",NULL);
//系统会在PATH中寻找ls
execlp("bash","bash","-c","echo $PATH",NULL);
特点是不需要知道完整路径
execvp函数:
char *args[] = {"grep","error","logfile.txt",NULL};
execvp("grep",args);//在PATH搜索grep
特点:最常用的exec函数,结合了PATH搜索和动态参数
execle函数:
char *env[] = {//自定义环境变量
"PATH=/usr/local/bin:/usr/bin",
"HOME=/home/user",
"MYVAR=custom_value",
NULL
};
execle("/usr/bin/env","env",NULL,env);
特点是可以指定新的环境变量不会继承当前进程的环境
execve函数:
char *args[] = {"program", "arg1", "arg2", NULL};
char *env[] = {"PATH=/bin", "TERM=xterm", NULL};
execve("/path/to/program", args, env);
特点:这是六个exec函数中唯一的系统调用,其他都是库函数,这个函数的功能最完整最底层,前五个函数最终都需要调用execve:

当PATH路径中存在多个同名的可执行文件时,execlp和execvp函数会按照PATH环境变量的顺序选择第一个找到的可执行文件
九、更改用户ID和组ID
UNIX系统中,特权(读写文件、修改日期表示法等等)是基于用户和组ID的。当程序需要增加特权以访问当前不允许访问的资源时,我们需要更换自己的用户ID和组ID。
一般而言,我们的程序只应当具备为完成给定任务所需的最小特权。这样减少了安全性收到损害的可能性。
setgid和setuid函数
可以用setuid函数设置实际用户ID和有效用户ID,sitgid设置实际组ID和有效组ID:
#include <unistd.h>
int setuid(uid_t uid);
int setpid(gid_t gid);
//成功则返回0,失败返回-1
改变用户ID的规则(本文所有关于用户ID所说明的一切都适用于组ID):
(1)若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID、以及保存的设置用户ID设置为uid
(2)若进程没有超级用户特权,但uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid,不改变实际用户ID和保存的设置用户ID
(3)若上面两条都不满足,则将errno设置为EPERM,并返回-1
关于内核所维护的三个用户ID,还需要注意一下几点:
(1)只有超级用户进程可以更改实际用户ID。
(2)仅当对程序文件设置了设置用户ID位时,exec函数才会设置有效用户ID(回顾前面讲过的设置用户ID权限)。
(3)保存的设置用户ID是由exec复制有效用户ID而得来的。

前面讲到的getuid和getpid只能获得实际用户ID和有效用户ID,不能获得设置用户ID
举例:
man程序,man程序可能需要执行许多其他命令,以处理包含需显示手册页的文件。为了防止被欺骗运行错误的命令或重写错误的文件,man命令不得不在两种权限之间切换:运行man命令的用户的权限,以及拥有man可执行文件用户的权限,工作步骤如下:
(1)man程序文件是由名为man的用户拥有的,并且其设置位用户ID已设置,当host用户在输入./man命令运行该程序时:
实际用户ID = host(启动该程序的用户)
有效用户ID = man(拥有man可执行文件的用户)
保存的设位用户ID = man
(2)man程序访问man用户拥有的文件,此时有效用户ID是man,可以访问
(3)先调用geteuid保存当前的有效用户ID为euid(下一步要用),然后调用setuid(getuid()),根据前面讲到的setuid用法的第二条,我们是非root用户进程,所以这个操作只将实际用户ID何有效用户ID设置为实际用户ID:
实际用户ID = host
有效用户ID = host
保存的设置位用户ID = man
现在我们可以以host的用户权限运行,访问host所拥有的文件
(4)执行完上述访问操作之后,man调用setuid(euid)(euid是上一步保存的原有效用户ID,根据前面讲到的setuid函数使用方法第二条,设置的uid与保存的设置用户ID相同,则setuid只将有效用户ID设置为uid,不改变实际用户ID和保存的设置用户ID。这就是为什么需要保存的设置用户ID的原因):
实际用户 = host
有效用户ID = man
保存的设置用户ID = man
(5)因为man程序的有效用户ID是man,所以它现在可以对其文件进行操作
以上的流程其实是man程序的有效用户ID利用实际用户ID和保存的设置用户ID在两个权限之间跳变,达到可以在相应时候操作对应文件的目的
setreuid和setregid函数
交换实际用户ID和有效用户ID的值:
#include <unistd.h>
int setreuid(uid_t ruid,uid_t euid);
int setregid(gid_t rgid,gid_t egid);
//成功返回0,失败返回-1
一个非特权用户总能交换实际用户ID和有效用户ID。这允许一个设置用户ID程序转换为只具有用户的普通权限,以后可再次转换回设置用户ID所得到的额外权限。
seteuid和setegid函数
类似于setuid和setgid,但之更改有效用户ID和有效组ID
#include <unistd.h>
int seteuid(uid_t uid);
int setegid(gid_t gid);
//成功返回0,出cup返回-1
一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。
本章讲述的各个函数提供了不同用户在三个用户之间跳转的方法:

十、system函数
system函数是一个标准库提供的用于执行shell命令的函数,system函数会启动一个新进程,在这个shell中执行你传入的命令字符串,等待命令执行完成,返回命令的退出状态
#include <stdlib.h>
int system(const char *cmdstring);
如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值,这一特征可以确定在一个给定的操作系统上是否支持system函数。
system实现中调用了fork、exec和watipid函数,有三种返回值:
(1)若fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,而且errno中设置了错误类型值。
(2)若exec失败,则返回值如同shell执行了exut(127)一样。
(3)否则所以三个函数(fork、exec、waitpid)都执行成功,system返回值是shell(子进程)的终止状态
十一、进程会计
大多数UNIX系统提供了一个选项以进行进程会计处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包括总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。
accton命令调用acct函数启动进程会计,将会计记录写入特定的路径,linux中,会计记录写入/var/account/pacct,会计记录的形式如下:

十二、进程时间
我们可以测量的时间有三种:墙上时钟时间、用户CPU时间和系统CPU时间,任一进程都可以调用times函数以获得它自己以及终止子进程的上述值
#include <sys/times.h>
clock_t times(struct tms *buf);
此函数填写由buf指向的tms结构:
struct tms{
clock_t tms_utime;
clock_t tms_stime;
clock_t tms_cutime;
clock_t tms_cstime;
}
更多推荐



所有评论(0)