本篇索引:
1、引言
2、进程标识
3、多进程
4、fork函数
5、vfork函数
6、exit函数
7、wait和waitpid函数
8、竞态
9.exec函数族
10.进程状态
11、system函数
1、引言
我们知道./a.out就能够让我们当前路径下的程序a.out(可执行文件)运行起来,我们称呼这个正在运行的程序叫进程(动态运行的程序),既然是动态的那么这个过程就有生有死,这就涉及对这个进程的控制,所以我们这一篇重点讲解如控制一个进程,控制进程的生,死等。
本篇主要涉及如下几个函数,fork函数,exit函数,wait函数,exec函数族,把这几个函数讲完那么本章也就结束。
2、进程标识
2.1、什么是进程?
进程就是在内存中动态运行的程序,它是活的,进程与程序对比如下:
存储位置 |
存在状态 |
运行过程 |
|
程序 (可执行文件) |
磁盘上 |
静态的 |
无运行的过程 |
进程 |
存在内存中,它是从磁盘上的程序考过来的副本 |
动态的 |
进程有生有死,有运行的过程 |
内核为了能够管理进程,给每个进程分配了一个名叫task struct的结构体(类型定义在sched.h中),该结构体大概有260个成员项左右,它应该算是我们内核中最大的一个结构体,里面包含了进程所需要的所有信息(如该进程打开的文件描述符,文件锁,进程ID等等)。
2.1、进程ID
为了管理进程,内核还给每个进程分配了各自进程ID(为非负整数),每个进程对应的进程ID都是唯一的,常将其用作其它标识的一部分以保证该标识的唯一性,如创建一个拥有唯一路径名的文件时,文件名字中就可以加入进程ID,这在一定程度下可以保证该文件名字的唯一性。
2.2、特殊进程
1)进程ID= =0的进程
称为调度进程或交换进程,功能是实现进程间的切换和调度,该进程不是由任何存在磁盘上的程序演变而来,是由内核自举时蜕变而来(是内核的一部分),因此也可称为系统进程。
2)进程ID= =1的进程
称为init进程,当内核自举结束时,会执行/sbin/init(可执行文件)这么一个磁盘程序,此进程就由该磁盘程序运行而来。该进程会读与系统相关的/etc/rc*文件以实现对系统进行相关的初始化,从而将系统引导到多用户状态,init进程是一个以超级权限运行的普通用户进程,
且init进程是所有孤儿进程的父进程。
3)进程ID= =2的进程
页精灵进程,专门负责虚拟内存的请页操作,与交换进程一样都是内核进程(系统进程)。
2.3、获取各种ID的函数
1)、函数原型和所需头文件
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
uid_t getuid(void);
uid_t geteuid(void);
gid_t getgid(void);
gid_t getegid(void);
2)、函数功能
·getpid函数:获取调用该函数进程的进程ID。
·getppid函数:获取调用该函数进程的父进程ID。
·getuid函数:获取调用该函数进程的实际用户ID,一般在没有调用setuid函数(此函
数不讲)进行修改进程对应的程序文件所属用户的情况下,该用户ID就等于当初运行
该程序时的用户ID。
·geteuid函数:获取调用该函数进程的有效用户ID,一般在没有调用seteuid函数进行修
改前,该用户ID就等于当初的运行该程序时的有效用户ID。
·getgid函数:获取调用该函数进程的实际组ID,一般在没有调用setgid函数进行修改
前,该用户组ID就等于当初运行该程序时的组ID。
·getegid函数:获取调用该函数进程的有效组ID,一般在没有调用setegid函数进行修改 前,该用户ID就等于当初运行该程序时的有效组ID。
3)、函数参数:均无参数。
4)、函数返回值:返回各种ID值。
5)、注意:以上函数永远都会被调用成功,返回各种ID值。
6)、测试用例
test.c
int main(void) { printf("pid = %d\n", getpid()); printf("ppid = %d\n\n", getppid()); printf("uid = %d\n", getuid()); printf("euid = %d\n\n", geteuid()); printf("gid = %d\n", getgid()); printf("egid = %d\n", getegid()); return 0; }
a)、第一步:gcc test.c
b)、第二步:切换到(或则sudo操作)超级用户,作如下事情:
·修改文件a.out的所属用户和所属组为root:chown root:root a.out
·设置用户设置位:chmod u+s a.out
·设置组设置位: chmod g+s a.out
最后我们的ls a.out -al,设置完后的结果如下:
-rwsrwsr-x. 1 root root 5359 Jun 4 22:52 a.out
c)、执行exit,切回到普通用户,./a.out,运行a.out程序,程序打印结果如下:
pid = 28278
ppid = 28143
uid = 500
euid = 0
gid = 500
egid = 0
通过前面课程的学习,想必大家对于上面的打印结果应该没有任何疑议,这里不再解释。
3、多进程
现代计算机中都是多个进程同时向前运行,图示如下:
从上图我们可以看出,cpu(这里假设单核)实际上在某个时刻只执行一个进程,当当前进程执行的时间片到后,保存当前进程的现场,由内核调度进程按照一定的调度算法转而执行另外一个进程,同样当时间片到后又会调度另外的一个进程运行,由于时间片很短,我们就会看到所有的进程同时向前运行的假象,假设每个进程就是一个任务的话,我们就实现了多任务并发运行,当然有时一个任务本身就可能包含很多个进程。
4、fork函数
4.1、函数原型和所需头文件
#include <unistd.h>
pid_t fork(void);
4.2、函数功能
从调用该函数的进程中复制出子进程,被复制的进程则被称为父进程,复制出来的进程称为子进程。
4.3、函数参数:无参数。
4.4、函数返回值
调用成功,在父进程中返回子进程的进程ID,在子进程中返回0,换句话说,返回0代表子进程,返回正整数(子进程ID)代表父进程,如果返回-1代表调用失败,errno会被设置。
4.5、注意
1、从父进程复制出子进程,其实是复制出一个新的虚拟内存,因为我们的程序是运行在虚内
存中的,所以我们也将虚拟内存空间称为进程空间,复制出的子进程(新的虚拟内存在空间)拥 有如下特点:父进程(父亲虚拟内存)的栈,堆,bss,.data,.ro.data会从父进程中完全的复制 出一分给子进程(孩子虚拟内存),但是.text却是父子进程所共享的,如下图所示:
进程ID等于0、1、2的这三个进程是在内核自举时产生,除了这三个外的所有进程都是由父进程通过fork函数生孩子的方式生出来的,子进程是父进程的完全拷贝。
2、为什么父进程会返回子进程的进程ID呢?
因为父进程可能会有很多的子进程,内核没有办法提供给父进程一个获取某子进程ID的函数,所以只能由fork函数直接将创建出来的子进程的进程ID返回。
fork返回给子进程0,必须清楚返回的这个0并不是进程ID,因为fork创建的普通进程的进
程ID不可能为0,因为这个ID早就被交换进程占据,如果子进程想要知道其父进程的ID的话, 子进程可调用getppid()函数实现。
4、写时复制(copy-on-write)技术:我们用fork函数创建出子进程后,子进程和父进程完全是一样的,但是这对我们来说是没有什么意义的,我们需要将我们新的程序代码和数据复制到子进程中(复制出来的虚拟内存中),这个过程由exec函数(该函数后面学习)实现,当需要向子进程中写入新的代码段时,子进程的.text段才被复制出新空间来并写入新的程序的代码,此时子进程的代码段会和父进程的代码段会慢慢分开,不再共享,这就是写时复制技术。
现在的很多系统创建出子进程后包括栈堆等全部段节都是共享的,直到exec写入新的程序的各个段和节时,才复制出子进程(复制出子进程的虚拟空间),然后父子进程才会完全分离,那么这种写时复制实现的更为彻底。每次复制时都是按一个虚拟的页进行的(一页= =4k)。
5、如果当父进程执行fork函数复制出子进程后,如果父进程的时间片还未结束的话,父进程会接着执行,时间片结束后才轮到子进程运行,否则父子进程谁先运行就很难说了,这就得看谁先被调度,但是如果我们想要实现父子进程的同步的话(竞态需要同步),我们就可以用信号(第10篇学习信号机制)来实现,假如我们希望父进程先运行的话,可以让子进程sleep两秒,但是这并不能完全保证父进程一定就先运行,因为如果cpu很忙的话,那么子进程休眠期间cpu可能不会切换到父进程上运行,所以这种方法不能保证百分之百同步成功。
6、多个进程共享同一文件时,文件表的结构如何?
我们在讲第三篇时,讲解了dup,同一进程内多次open同一文件,以及多进程多次open同一文件时的文件表的结构,我们这里重点讲多进程如何共享同一文件的,主要分为如下两种情况:
a)、子进程继承父进程已经打开的文件描述符
int main(void) { int fd = -1, ret = -1; fd = open("file", O_CREAT|O_RDWR, 0664); if(fd < 0) { perror("open file is fail"); exit(-1); } ret = fork(); if(0 == ret){ //返回0是子进程 printf("in parent fd = %d\n", fd); write(fd, "hello ", 6); } else if(ret > 0){ //返回正整数时父进程 printf("in child fd = %d\n", fd); write(fd, "world\n", 6); } else if(-1 == ret) perror("fork is fail"); return 0; }
程序运行之后,打开file文件,文件中结果如下:
hello world
我们发现这两个进程都向同一文件里面写数据,但是并没有出现数据相互覆盖的情况,但在打开文件时也没有指定O_APPEND标志,所以我们可以猜测父子进程中的这两个文件描述符必然共享同一文件表中的文件读写指针,之所以world先写,这是由于父进程先执行导致的。
例子中,在父进程fork之前打开了file这个文件,返回一个文件描述符给fd(不出意外的情况下fd= =3),那么之后复制出的子进程会继承这个文件描述符,而且父进程的0,1,2三个文件描述符一早就打开了,所以子进程也会继承这三个文件描述符,那么父子进程中0,1,2,3这几个文件描述符指向的文件表结构如何呢?如下图:
这种继承是非常重要的,因为继承之后父子进程中相同的文件描述符就指向了同一个文件表,所以就能够共享相同的文件读写指针,所以父子进程用各自的文件描述符写同一个文件时,并不会出现数据相互覆盖的情况,由于子进程的文件描述符是从父进程那里继承过来的,所以这两个文件描述符的值是相等的。
b)、父子进程各自独立的打开同一文件实现共享
int main(void) { int fd = -1, ret = -1; ret = fork(); if(0 == ret){ //返回0是子进程 fd = open("file", O_CREAT|O_RDWR, 0664); if(fd < 0) { perror("open file is fail"); exit(-1); } printf("in parent fd = %d\n", fd); write(fd, "hello ", 6); } else if(ret > 0){ //返回正整数时父进程 fd = open("file", O_CREAT|O_RDWR, 0664); if(fd < 0) { perror("open file is fail"); exit(-1); } printf("in child fd = %d\n", fd); write(fd, "world\n", 6); } else if(-1 == ret) perror("fork is fail"); return 0; }
这个例子中,父子进程各自独立的打开了同一个文件file,父子进程的fd可能相等可能不等,这里例子中只是碰巧相等(都等于3),运行后打开file文件:
hello
实际上父进程先运行,写入了world,但是之后立即被子进程的hello给覆盖了,所以推断父子进程的文件描述符并不指向同一个文件表,因为它们都有自己的文件读写指针,这种情况下的文件表结构如下:
从上图我们就可以看出,父子进程各自的fd指向独立的文件表,所以都拥有各自独立的文件读写指针,所以会导致相互的覆盖,如果我们不希望出现覆盖的情况的话,就必须指定O_APPEND标志,之后各自的文件读写位移量会被共享的文件长度更新,这样子就不会出现覆盖的情况了,请同学自己去试验下。
7、有关各父子进程自变量空间和缓冲区的问题
a)、各自的变量空间,例子如下:
int main(void) { int t_va = -1, ret = -1; ret = fork(); if(0 == ret)//返回0是子进程 { t_va = 100; printf("in child t_va = %d\n", t_va); } else if(ret > 0) //返回正整数是父进程 { sleep(1);//休眠1秒让子进程先运行 printf("in parent t_va = %d\n", t_va); } else if(-1 == ret) perror("fork is fail"); return 0; }
打印结果如下:
in child t_va = 100
in parent t_va = -1
从上面可以看到,子进程修改t_va为100以后,父进程那里仍然是-1,那是因为子进程会从父进程那里复制一份栈、堆、等空间,t_va(局部变量,开在了栈中)虽然只定义了一次,但是在fork之后父子进程各自操作属于自己的t_va,所以虽然子进程将t_va改为了100,但是父进程还是为原来复制过来的值-1。
b)、各自的变量空间,例子如下:
int main(void) { int t_va = -1, ret = -1; write(1, "aaaaaa|", 7); ret = fork(); if(0 == ret) //返回0是子进程 { } else if(ret > 0){ //返回正整数是父进程 sleep(1); //休眠1秒让子进程先运行 } else if(-1 == ret) perror("fork is fail"); return 0; }
对于write函数来说,只要调用则立即输出数据所以"aaaaaa|"只输出了一次,但是对于printf函数来说,虽然它底层调用的还是write函数,但是它要进行缓冲区类型的判断,然后根据缓冲类型的不同按照不同方式刷新缓冲区,显然标准输出是行缓冲的,如果想要立即刷新,要么写入\n,要么填满缓冲区,这里既没有\n,而且也不可能填满缓冲区,所以"AAAAAAA|"会被积压在缓冲区中(缓冲区一般开自堆中),然后fork子进程该缓冲区会被复制一份,所以fork之后父子进程的的行缓冲区中都有"AAAAAAA|",父子进程正常终止时,各自的行缓冲区都会被刷新,所以"AAAAAAA|"会被打印两次。
如果我们在"AAAAAAA|\n"的后面加上\n的话,那个printf时行缓冲区中的"AAAAAAA|\n"会被立即刷新打印出来,不会被积压,所以即便是fork之后,子进程复制了一份行缓冲区空间,但是它是空的,所以子进程结束时没有东西被刷新出来,"AAAAAAA|\n"只被打印了一次。
8、多次fork之后进程的数量
a)、例子1
int main(void) { int t_va = -1, ret = -1; ret = fork(); ret = fork(); ret = fork(); ret = fork(); while(1); return 0; }
思考,该程序运行完后会有多少个进程???2^n
b)、例子2
int main(void) { int t_va = -1, ret = -1; ret = fork(); if(0 == ret){ fork(); fork(); } else if(ret > 0){ ret = fork(); if(ret == 0) fork(); else if(0 == ret) { fork(); fork(); } } while(1); return 0; }
思考,该程序运行完后,会有多少个进程??? 9个
9、父进程先结束,子进程后结束
我们猜测打印结果应该是这样的,child_pid = =ret,child_ppid = = pare_pid。
具体答疑捏过如下:
in parent pare_pid = 17351, pare_ppid = 28143
ret = 17352
[[email protected] shang_qian]$ in child child_pid = 17352, child_ppid = 1
首先说,出现这个奇怪打印形状的原因是,父进程先于子进程结束而导致的,我们在下一篇将做解释,至少这里ret = 17352,child_pid = 17352,child_pid = =ret是成立的。
但是child_ppid = 1,pare_pid = 17351,猜测的child_ppid = = pare_pid是不成立的,导致这个情况的原因是,父进程结束了,但是子进程还没有结束的话,死掉的父进程的各个子进程会被ID为1 的inti进程收养,收养的这些进程我们称为孤儿进程,孤儿进程结束后残留在内核中资源由init 进程回收。
10、fork出来的子进程会继承父进程哪些性质
子进程继承如下性质。
1)、实际用户ID,实际组ID,有效用户ID,有效组ID,添加组ID。
2)、进程组ID(下一篇讲)。
3)、对话组ID(下一篇讲)。
4)、控制终端(下一篇讲)。
5)、设置用户ID标志和设置组ID标志。
6)、当前工作目录。
7)、根目录。
8)、文件创建方式屏蔽字。
9)、信号屏蔽额排列(第10篇讲)。
10)、对任意一打开文件描述符的在执行是的关闭标志。
11)、环境变量。
12)、连接的共享存储段。
13)、资源限制(如文件描述符个数限制)。
父子进程之间的区别
1)、fork返回值不同。
2)、进程ID。
3)、不同的父进程ID。
4)、子进程的用户时间,系统时间,时钟时间都被设置为0.
5)、父进程设置的锁,子进程不能被继承。
6)、子进程从父进程继承而来的味觉警告会被清除(第十篇讲)。
7)、子进程从父进程继承来的未决信号集集会被清零(第十篇讲)。
11、fork函数调用失败的原因有哪些
1)、系统中已经运行的进程太多,或系统出了某方面的问题。
2)、该用户拥有的进程数超过了系统的限制,系统宏CHILD_MAX定义了实际用户应该具有的最大进程数目。
12、fork函数的两种用法,
1)、父进程复制出子进程,然后父子进程同时执行不同的代码段,常见的例子是网络服务进程,父进程等待别人(委托者)的服务请求,当请求到达时,然后fork出子进程,让子进程运行服务程序处理此请求,而父进程则继续等待下一个请求。
2)、fork出新的子进程,运行新的程序,对于我们shell来说很重要,比如我们./a.out运行一个新程序,就是由shell fork出新的程序,然后执行新的a.out的这么一个可执行文件。这种是最常见的用法。
5、vfork函数
此函数和fork函数的功能几乎一致,但也有如下不同:
1)、复制出的子进程在栈,.bss、.data、.ro.dat和.text完全共享,直到exec执行新程序时根据写时复制原则,父子进程才分开。
2)、子进程一定会先运行,如果子进程一直等不到先运行的话,父进程会一直等到子进程运行了 一个时间片以后,父进程才有运行的机会。
对于这个函数这里不再赘述,用的较少,有兴趣的同学自己按照fork的例子去测试下。
6、exit函数
本函数我们之前已经讲过,但是在这里我们再次复习一遍,有些地方有必要再深入下。
6.1、进程终止的方式
1)、正常终止
a)、从main函数返回(main函数调用return);
b)、在程序的任意位置调用exit函数;
c)、在程序的任意位置调用_exit函数;
2)、异常终止
a)、自杀:自己调用abort函数,自己给自己发一个信号将自己杀死,杀人凶器是信号;
b)、他杀:由别人发一个信号,将其杀死,杀人凶器也是信号,这些信号是由其它进程或内核才生的,例如我们以前知道的段错误就是由内核发送一个叫做SIGSEGV信号给进程,进程受到这个信号后被杀死;
不管进程是那种方式被终止的,都会执行内核中的同一段的代码,它会关闭我们所有打开的文件描述符和占用的各种资源(包括占用的内存资源)。
6.2.进程的终止状态
进程终止时都会有一个终止状态,它是由内核帮忙生成的,终止进程的父进程可以通过wait函数或waitpid函数获取到这个终止状态,从了了解该进程是如何终止的,终止状态的具体的内容组成分以下两种情况:
1)、正常终止
return(退出状态)、exit(退出状态)或_exitt(退出状态)函数会返回进程的正常退出时的退出状态,这个退出状态低8位有效,如果是负数的话,取得是补码,内核同时也会生成一个整数说明当前进程的终止原因是正常终止的。终止状态按照如下方式计算。
进程终止状态 = 终止原因(正常终止)<< 8 | 退出状态的低8位
但假如我们是隐式终止的,那么这个退出就是未定的。
2)、异常终止
进程终止状态 = 是否参生core文件位 | 终止原因(异常终止)<< 8 | 终止该进程的信号编号
这里请区分退出状态和终止状态这两个不同的概念,它们之间有关系,但不要混淆。退出状态如果是负数的话,拿去构建终止状态时用的是该负数补码的低8位,什么是core留到第10篇讲。
6.2、僵尸进程和孤儿进程
1、僵尸进程
如果该进程终止时,其父进程还在,那么终止后的进程就会成为僵尸(zombie)进程,所谓僵尸进程就是进程已经运行结束,但是却任然占据着8k的task struct结构体和内核栈,这些空间还未释放,只有等到父进程终止时才会回收这些僵尸进程的资源(如果不回收,会导致内存泄露),那而父进程可以调用wait或waitpid函数获取子进程的退出状态。
当然即便是父进程没有调用wait或waitpid函数,进程结束时一样会回收僵尸子进程的资源,只是没有办法获取子进程的终止状态而已。
2、孤儿进程
不管进程是上述那种情况终止的,如果进程终止时,它的子进程还在运行,那么这些子进程(内核会将这些子进程的ppid改为1)就会变成孤儿进程而被init进程收养,当init的子进程(init直接产生的子进程或收养的孤儿进程)进程结束时所占用的8k内核资源会被init进程立即回收,没有成为僵尸进程的可能,init进程从系统启动到系统关机结束都将一直运行。
7、wait和waitpid函数
当一个进程终止时,内核就会向其父进程发送一个SIGCHILD信号,在父进程运行期间的任何时候,子进程的终止都可以发生,这是一个异步事件,所以内核向其父进程发送SIGCHILD信号也是异步的,那么父进程对于这个信号有如下三种处理方式:
1)、父进程忽略此信号。
2)、写一个信号捕获函数,当此信号发生时捕获此信号。
实际上系统对于这个信号的默认处理方式就是忽略此信号。对于信号的处理方式我们将在第10篇做详细讲解,但是实际上wait和waitpid函数也会对SIGCHILD信号做出反应,那么我看下调用wait或waitpid函数的进程会如何呢?
1)、阻塞(等待的所有的子进程都在运行,没有一个结束);
2)、阻塞被SIGCHILD唤醒并获取到子进程的终止状态并返回(某个子进程终止,父进程等
到了结束的进程,就会利用该函数获取该进程的终止状态);
3)、出错返回(该进程根本就没有任何子进程,那么调用等待函数根本就没有意义,所以会
出错返回并提示没有子进程);
7.1、wait函数
1)、函数原型和所需头文件
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
2)、函数功能
等待任意一个子进程终止,一定注意,这里指的是任意一个,调用该函数的进程的任意一个子进程结束,那么内核就会发送SIGCHILD信号给父进程通知它的某个子进程结束了,wait函数之前的阻塞会被SIGCHILD唤醒,并且获取到终止子进程的终止状态;
3)、函数参数
·int *status:整形一级子还真变量,指向一个整形的空间,这个空间用来存放终止子进
程的终止状态的。
4)、函数返回值:成功返回终止进程的进程ID,失败返回非-1值,errno被设置。
5)、注意:
a)、这个函数等待的任意一个子进程结束。
b)、如果如果调用该函数的进程没有子进程,则调用这个函数实际上是没有什么意义的, 会出错返回并提示该进程没有子进程。
c)、获取终止状态时,用的是传参方式,不是返回值方式,因为返回值被用于说明函数 是否调用成功用。
6)、测试用例
int main(void) { int ret = -1,status = -1; ret = fork(); if(0 == ret) { } else if(ret > 0) { sleep(3); wait(&status); // 宏WEXITSTATUS:用于从终止状态中取出子进程的退出状态 printf("!! = %d\n", WEXITSTATUS(status)); } return 1; }
本例中子进程retrun 1正常退出,退出状态为1,wait函数会获取到子进程的终止状态,终止,WEXITSTATUS宏其实就是将一个低8位为1其余位全部为0的文件权限掩码位与上status,从而就取出了退出状态,过程如下等式:
WEXITSTATUS(status) = 0xff & status
当子进程的退出状态出现如下情况时,终止状态中的低8为会如何?
1)子进程隐式返回:这种方式是我们不提倡的,但是如果你非要这么写的话,终止状 态中的低8位为0(也就是说它默认退出状态为0)。
2)子进程返回负数如-1: WEXITSTATUS(status)得到的退出状态时255,刚好是- 的-1的8位整形数的补码。如果返回-2,得到的退出状态时254。
7.2、waitpid函数
1)、函数原型和所需头文件
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
2)、函数功能
等待我们通过pid参数说明的某个进程的结束,当满足条件的子进程结束时,那么内核就会发送SIGCHILD信号给父进程通知它的有子进程结束了,wait函数之前的阻塞会被SIGCHILD唤醒,并且获取到终止子进程的终止状态;
4)、函数参数
·pid_t pid:pid = =-1,等待任意一子进程终止,此时waitpid等价于wait函数。
pid > 0,等待进程ID等于pid的进程终止。
pid = = 0,等待与调用该函数的同属于一个进程组的任意一进程终止(进程 组概念将在下一篇讲解)。
pid < -1,等待进程组组ID= =|pid|的组内任意一进程终止。
·int *status:整形一级指针变量,指向一个整形的空间,这个空间用来存放终止子进程
的终止状态的。
·int options:如果我们希望waitpid是阻塞的,该参数写0就好,如果我们希望是非阻塞 的就指定WNOHANG宏,此时当waitpid没有等到子进程的终止状态时,会立即返回0,
当然options还可被指定其它的宏,如WUNTRACED(主要用于作业控制),那么这里就
不做了解了。
4)、函数返回值
函数调用成功则返回状态改变了(这里状态改变主要是指终止)的进程的PID,失败则返回-1,且errno被设置,但是如果options参数指定了宏WNOHANG,并且想等的进程的进程状态并未发生任何的改变,waitpid函数不会阻塞而立即返回0,否者按正常反回。
5)、注意:
a)waitpid函数比wait函数功能更强大,wait函数只能等待任意进程一个进程状态的改
变,而且只能等待一种进程状态的改变,那就是进程终止,但是waitpid除了可以等待进
程终止外,还可以等待进程是否暂停等其它的进程状态改变。
b)wait函数一定是阻塞的,而waitpid是否阻塞则可被设置。
c)wait函数等待的是任意一进程终止,waitpid则可等待指定进程的进程状态改变。
d)、waitpid支持作业作业控制,wait不支持,对于作业控制这里不做讲解。
6)、测试用例
int main(void) { int t_va = -1, ret = -1; int status = -1; ret = fork(); if(0 == ret) { } else if(ret > 0) { waitpid(-1, &status, 0); //waitpid(ret, &status, 0); //waitpid(0, &status, 0); //waitpid(-getpgrp(), &status, 0); printf("!! = %d\n", WEXITSTATUS(status)); } return 1; }
本例子中父进程就只有一个子进程,所以四条waitpid语句都可以等待到子进程的终止。
1)、waitpid(-1, &status, 0);:等待任意一进程,本例中就只有一个子进程。
2)、waitpid(ret, &status, 0);:ret是子进程的进程ID(ret>0),等待指定PID的进程。
3)、waitpid(0, &status, 0);:等待与调用waitpid函数的进程同一进程组内的子进程结束, 这里只有一个子进程,并且一定是和父进程是同一进程组内的。
4)、waitpid(-getpgrp(), &status, 0);:getpgrp函数获取当前进程所在的进程组组ID,等待 组ID等于|-getpgrp()|的进程组内的任意一子进程,这里父进程和子进程都是|-getpgrp()| 进程组的。
以上第三个参数指定的都是0,说明waitpid是阻塞的。
7.2、检查wait函数和waitpid函数获取的进程终止状态的宏
1)、WIFEXITED(status):检查进程是否正常终止,如果是则宏表达式为真,WEXITSTATUS(status)会获取出低8位的进程退出状态。
2)、WIFSIGNALED(status):检查进程是否是异常终止的,其实就是检查是否是被信号杀死的, 如果是则宏表达式就为真,则调用WTERMSIG(status)可获取终止该进程的信号编号,当 然如果是被信号杀死的话,可能还会产生core文件,当然我们可以通过WCOREDUMP(status) 宏检测是否产生了core文件,只是有些版本的系统可能不支持这个宏。
3)、WIFSTOPPED(status):测试进程是否被停止,常用于作业控制,这里做了解即可。
以上宏的使用例子如下:
当然获取status的函数可以用wait也可以用waitpid函数,我们例子中用wait函数即可。
int main(void) { int ret = -1, status = -1; ret = fork(); if(0 == ret) { sleep(15); } else if(ret > 0) { wait(&status); if(WIFEXITED(status))//判断是否正常终止 { printf("exit_status = %d\n", WEXITSTATUS(status));//取退出状态 } else if(WIFSIGNALED(status))//判断是否异常终止 { printf("killer_sig_no = %d\n", WTERMSIG(status));//取信号编号 } } return 1; }
·子进程正常终止验证
1)、./a.out
2)、等待子进程休眠15s后,return 1正常终止。
3)、父进程wait阻塞等待子进程终止,当子进程终止后,父进程的wait被唤醒,并获取 子进程的终止状态到status中。
4)、打印结果:exit_status = 1,说明我们的子进程确实是正常终止的,并且退出状态为1。
·子进程异常终止验证
1)、./a.out &,程序后台运行
2)、在子进程休眠15s中,ps -a查出子进程的进程ID(ID大的那个a.out)
3)、执行命令kill -9 子进程ID。
3)、父进程wait阻塞等待子进程终止,当子进程终止后,父进程的wait被唤醒,并获取 子进程的终止状态到status中。
4)、打印结果:killer_sig_no = 9,说明我们的子进程确实是异常终止的,杀死子进程的信
号的编号为9。
7.2、两次fork
如何创建出满足如下条件的子进程:
·子进程先于父进程终止。
·子进程不能有出现僵尸进程的可能。
假如我们只fork一次的话,只要子进程先结束,那么子进程就一定会成为僵尸进程,解决的办法就是两次fork,第一次fork出的子进程再次fork出一个子进程后立即终止,这么一来最后一次fork的子进程的父进程就变成了init进程,子进程没有了成为僵尸进程的可能,父进程进而不用等子进程,但是代价是第一次生成的子进程就成为了僵尸进程。
实现例子如下:
int main(void) { int ret = -1, status = -1; ret = fork(); if(0 == ret){ ret = fork(); /* 子子进程休眠2s,等待它的父进程先终止,这样子子进程的父 * 进程就变为了init进程,子子进程终止后,init立即回收它,子 *子进程没有成为僵尸进程的可能 */ if(ret == 0){ sleep(2); } else if(ret > 0){ //子进程立即终止 exit(0); } } else if(ret > 0)//原始父进程一直死循环 { while(1); } return 1; }
8、竞态
8.1、概念
当多个进程都想对共享数据进行某种处理或共同实现某个目的时,最后的结果却取决于这些进程谁先运行,我们就称发生了竞态。
8.2、竞态的例子
int main(void) { int ret = -1, status = -1; ret = fork(); if(0 == ret) printf("child_ppid = %d\n", getppid()); else if(ret > 0) { } return 1; }
上面这个例子中就存在一个竞态,子进程的ppid是多少,这就取决于父子进程谁先运行,如果子进程先运行,打印的就是父进程的进程ID,如果父进程先运行,那么父进程会立即return 1而终止,那么子进程的父进程就变为了init进程,所以子进程的父进程ID就是1,只是不幸的是居多情况下都是父进程先运行。
9、exec函数族
9.1、为什么要exec函数族
fork函数复制出子进程后,我们想在子进程中运行新的程序的话,我们可以在子进程里面敲入新的子进程代码,
int main(void) { int ret = -1, status = -1; ret = fork(); if(0 == ret) //子进程 { ******; ******; 。。。。。。 ******; ******; } else if(ret > 0) //父进程 { } return 1; }
上面例子中实现了在子进程中运行新的程序代码,但这并不是一个好方法,假如我们的代码量有上千行,直接敲入就会显得不是很方便,如果子进程想要再换为运行其它程序的话,就不态可能了,所以linux内核提供了exec函数族,目的是用来运行新的可执行文件的,好处就是我们可以重新写新的程序,最后只需把它编译成可执行文件,最后利用exec函数就可以在子进程张执行这新的该程序了,假设想要再换为运行其它新程序的话,exec函数操作起来也会很方便。
9.2、exec函数族
2)、函数原型和所需头文件
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execve(const char *filename, char *const argv[], char *const envp[]);
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
2)、函数功能
这6个函数都是为了在进程中加载一个新的程序(可执行文件),但是它们在功能上略有不同:
exec后面有l、v、e、v、p几种后缀,它们的含义分别如下:
·l:采用列举的形式传递命令行参数给加载的新程序。
·v:采用字符串指针数组的形式传递命令行参数给加载的新程序。
·e:可传递给新程序自定义的环境变量表。
· p:当指定的文件名中有包含路径时,直接到该路径下找该新程序文件(可执行文件),否则就到PATH 环境变量中的各个路径下寻找该新程序文件。
如果后最终不包含p的话,新程序文件名中必须包含路径,路径如果是./则那么可省略。
但是如果即便是指定了p,然而新程序文件所在的路径并没有被加入PATH中,那么指定新 程序文件名就必须包含路径,即使路径是./,但也不能被省略。
5)、函数参数
·execl函数:const char path:新程序(可执行文件)路径名。
const char *arg,...:列举出的各个命令行参数,必须NULL结尾。
·execv函数:const char path:新程序(可执行文件)路径名。
const char *argv[]:将命令行参数做成字符串指针数组。
·execle函数:const char path:新程序(可执行文件)路径名。
const char *arg,....:列举出的各个命令行参数,必须NULL结尾。
char * const envp[]:自定义环境表。
·execve函数:const char path:新程序(可执行文件)路径名。
const char *arg,....:命令行参数做成字符串指针数组。
char * const envp[]:自定义环境表。
·execlp函数:const char *file:新程序(可执行文件)文件名。
const char *arg,....:命令行参数做成字符串指针数组。
·execvp函数:const char *file:新程序(可执行文件)文件名。
const char *argv[]:命令行参数做成字符串指针数组。
4)、函数返回值:函数调用成功不返回,失败则返回-1,且errno被设置。
5)、注意:
a)、前4个函数,指定的必须是文件的所在的路径,后2个函数可不指定文件所在路径, 但要求PATH中必须包含其所在路径,否者就必须指定其所在路径。
b)、execle和execve这两个函数,我们可以自定义新的环境表,然后传递给新程序,其 它函数传递给新程序的环境表都是从父进程那里继承过来的系统自定义的环境表。
c)、以上的函数中,只有execve是真正的系统调用的,其余的都是对其做进一步封装的
库函数而已。这几个函数的关系如下:
d)、执行了新程序后的进程还会保持原有进程的哪些特征
·进程ID和父进程ID。
·实际用户ID和实际组ID。
·添加组ID。
·进程组ID。
·对话器ID。
·控制终端。
·闹钟余留时间(第十篇讲)。
·当前工作目录。
·根目录。
·文件创建屏蔽字。
·文件锁。
·进程信号屏蔽字。
·未决信号。
·资源限制。
在exec后实际的用户ID和组ID不变,但有效ID是否改变取决于被执行的新程序文件的设置用户ID设置位和设置组ID设置位是否被设置,如果被设置则有效用户ID变为了可执行文件的文件的所属用户ID,组的有效用户ID处理方式同。
6)、测试用例
需要执行的新程序如下:
main.c
int main(int argc, char **argv, char **env) { int i = 0; for(i=0; i<argc; i++){ printf("%s ", argv[i]); } printf("\n"); for(i=0; env[i]!=NULL; i++){ printf("%s\n", env[i]); } return 0; }
执行命令gcc main.c -o main
a)、execle和execve函数的例子
int main(void) { int ret = -1; ret = fork(); if(ret > 0) wait(NULL); else if(0 == ret) { #if 1 /* execl函数 */ execl("./main", "arg1", "arg2", "arg3", NULL); #endif #if 0 /* execv函数 */ char *argv[] = {"arg1", "arg2", "arg3", NULL}; execv("./main", argv); #endif } return 0; }
新程序打印出了exec传递给它的参数表,但是环境表继承的还是系统默认的环境表。
a)、execlp和execvp函数的例子
int main(void) { int ret = -1; ret = fork(); if(ret > 0) wait(NULL); else if(0 == ret) { #if 1 /* execle函数 */ char *new_env[] = {"aa=AAAAAA", "bb=BBBBBBB", "cc=CCCCCCC", NULL}; execle("./main", "arg1", "arg2", "arg3", NULL, new_env); #endif #if 0 /* execve函数 */ char *argv[] = {"arg1", "arg2", "arg3", NULL}; char *new_env[] = {"aa=AAAAAA", "bb=BBBBBBB", "cc=CCCCCCC", NULL}; execve("./main", argv, new_env); #endif } return 0;}
这两个函数可以将自己构建的环境表传递给新程序,只是上面例子中自己构建的环境表没有什么意义。
a)、execlp和execvp函数的例子
int main(void) { int ret = -1; ret = fork(); if(ret > 0) wait(NULL); else if(0 == ret) { /* 将可执行文件main的所在路径加入PATH环境变量中 */ char *path = getenv("PATH");//或PATH环境变量 char path_buf[400] = {0}, pwd[200] = {0}; getcwd(pwd, sizeof(pwd)); //获取main所在的当前路径 //将main所在的当前路径加入PATH中 sprintf(path_buf, "%s:%s", path, pwd); putenv(path_buf);//将新的PATH加入环境表 #if 1 /* execlp函数 */ execlp("main", "arg1", "arg2", "arg3", NULL); #en #if 0 /* execvp函数 */ char *argv[] = {"arg1", "arg2", "arg3", NULL}; execvp("main", argv); #endif } return 0; }
上面的例子中将main所在的路径加入了PATH中,所以在函数中并不需要指定main所在的路径,否者的话必须指定路径,即便是是./,也是必须指定的。
10、进程状态
10.1、进程的运行状态:
1)、就绪态:一切准备就绪,等待被调度运行
2)、等待态:此时进程在等待一个事件发生或某种系统资源,此时会阻塞(休眠)。
·哪些事件会休眠:
a)、等待io事件,如read字符设备文件时。
b)、等待解锁(如文件锁,互斥锁,条件变量,信号量等等)。
c)、某些函数导致休眠,如sleep,pause函数,等待时间到或某个被捕获的信到将其唤醒。
·可否被被信号打断:
a)、不可打断(各种锁机制是不会信号打断的)。
b)、可以被信号打断(如,read字符文件时, select, poll阻塞的过程,一般都与慢速 系统调用有关)(read普通文件是不会被阻塞的),但是read或自动重启,而后面的函数
需要手动重启。
·被信号打断的系统调用重启方式:
a)、自手动重启
do { ret = read(fd, buf, n); }while(ret < 0 && EINTR= =errno); if(ret < 0) {perror(); exit;} if(ret > 0){做相应处理}
b)、系统自动重启
ret = read(fd, buf, n);
这一条语句被信号打断后,read的系统调用将会自动重启,read函数会自动重启
c)、通过对信号的设置实现重启
这一条件小对sigaction进行设置,设置如下:
void sig_fun(int signo) { } int i = 0; struct sigaction sig_act; sig_act.sa_handler = sig_fun(); sig_act.sa_flags = SA_RESTART; for(i=1; i<64; i++) sigaction(i, &sig_act, NULL);
思考:阻塞指的是文件阻塞还是函数阻塞?
有关系统调用的重启,在第10篇(信号)还会被再次讲解。
3)、停止态:进程被终止(GDB调试)
4)、僵尸态(死亡态):对进程已经终止,但是父进程忙与公干,导致僵尸进程占有的8k的 task_struct 结构体和进程的内核栈的内核空间没有释放,需要父进程终止时去释放的,要是
父进程 先结束,子进程会被init进程收养,被init领养后的进程是不会出现僵尸状态的情况
的,进程一旦失去,init会马上释放他的资源(也可认为僵尸状态异常短暂)。
10.2、进程的这些状态间的一个转换关系图
11、system函数
我们为了让一个程序运行起来,必须先fork出一个子进程,然后在子进程中调用exec函数去执行这个新的程序,但是这有一个缺点就是,fork和exec函数不是一个原子操作,可能在会出现一些问题,我们内核提供了一个system这个函数,这个函数就是对fork和exec的封装,但是这两个函数被做成了一个原子操作,所以我们想运行一个新程序时,我们可以直接system(“新程序路径名”)即可,还比如我们想在程序中执行ls这个命令,直接system(“ls”)即可。