1、引言
2、进程标识
- 每个进程都用一个唯一的非负整数标识,即为进程id:pid。进程ID是可以复用的,当一个进程终止时,其进程ID就可以用来标识其他进程。
- 系统中有一些专用进程:
- 进程ID为0的是调度进程,也称交换进程(swapper),它并不执行任何磁盘上的程序,它是内核中的系统进程;
- 进程ID为1的是init进程,此进程负责在自举内核后启动一个UNIX系统,init进程绝不会终止,它是一个以超级用户特权运行的普通用户进程;init通常读取与系统有关的初始化文件(
/etc/rc*
文件或/etc/inittab
文件,以及在/etc/init.d
中的文件)并将系统引导到一个状态(如多用户)。此外,init是所有孤儿进程的父进程。 - 进程ID为2的是页守护进程,负责支持虚拟存储器系统的分页操作。
- 通过以下函数获取一个进程的pid等id信息:
注意:这些函数都没有出错返回。pid_t getpid(void); // 调用进程pid pid_t getppid(void); // 调用进程的父进程pid uid_t getuid(void); // 调用进程的实际用户id uid_t geteuid(void); // 调用进程的有效用户id gid_t getgid(void); // 调用进程的实际组id gid_t getegid(void); //调用进程的有效组id
3、函数fork
-
一个现有进程可以通过调用fork复刻一个新进程
pid_t fork(void);
- fork函数调用一次返回两次。对于子进程返回值是0;对于父进程返回值是子进程pid。子进程只能有一个父进程,通过getppid获得父进程pid。
- 子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本,子进程获得父进程的数据空间、堆和栈的副本。注意这是子进程所拥有的副本,父子进程并不共享这些存储空间部分。父进程和子进程共享正文段.text。
- 由于fork之后进程跟着exec,所以现在很多fork实现并不执行父进程数据段、堆和栈的完全副本,而是使用写时复制(copy-on-write)。这些区域由父子进程共享,并且内核将它们的访问权限修改为只读。如果父子进程中的一个试图修改这些区域,则内核只为修改区域的那块内存制作副本。
-
实例:演示了一个fork函数,可以看出子进程对变量所做的改变并不影响父进程中该变量的值
#include "apue.h"int globvar = 6; /* external variable in initialized data */ char buf[] = "a write to stdout\n";int main(void) {int var; /* automatic variable on the stack */pid_t pid;var = 88;/*注意sizeof和strlen的区别:1)strlen需要进行一次系统调用,它计算的是不包含终止null字节的字符串长度。2)sizeof计算包含null字节的字符串长度,sizeof是在编译时计算缓冲区长度,也就是说它不需要进行系统调用。*/if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1) /*write函数是不带缓冲的,其数据写道标准输出一次*/err_sys("write error");/*printf属于标准I/O库,是带缓冲的。如果标准输出连接到终端设备,则它是行缓冲的,否则它是全缓冲的。这一点会影响到冲洗数据的时机。*/printf("before fork\n"); /* we don't flush stdout */if ((pid = fork()) < 0) {err_sys("fork error");} else if (pid == 0) { /* fork函数调用一次返回两次,若返回的是0,则为子进程 */globvar++; /* 子进程对数据进行更改,通过命令行可以发现子进程的变量值改变了,而父进程没有 */var++;} else { /* fork函数调用一次返回两次,若返回的是子进程的PID,结果>0,为父进程 */sleep(2);/* 父进程是自己睡眠2s,目的是让子进程先执行,但并不能保证2s是足够的 */}printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar,var);exit(0); }
命令行输出结果:
lh@LH_LINUX:~/桌面/apue.3e/proc$ ./fork1 a write to stdout before fork pid = 3638, glob = 7, var = 89 pid = 3637, glob = 6, var = 88 lh@LH_LINUX:~/桌面/apue.3e/proc$ ./fork1 > result.txt lh@LH_LINUX:~/桌面/apue.3e/proc$ cat result.txt a write to stdout before fork pid = 3650, glob = 7, var = 89 before fork pid = 3649, glob = 6, var = 88
- fork之后是父进程先执行还是子进程先执行是不确定的,取决于内核的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。
- 根据命令行输出结果我们可以发现:
- 当以交互方式运行程序时,标准输出连接至终端设备,此时为行缓冲,标准输出缓冲区由换行符
\n
冲洗,最后只得到printf
输出的行一次。 - 当将标准输出重定向到一个文件时,此时为全缓冲,当调用
fork
后,该行数据仍在缓冲区,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中,此时父进程和子进程各自有了带该行内容的缓冲区。后面的语句printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar,var);
将数据追加到已有的缓冲区中。当每个进程终止时(exit()会执行标准I/O清理程序),其缓冲区的内容都被写到相应文件中。
- 当以交互方式运行程序时,标准输出连接至终端设备,此时为行缓冲,标准输出缓冲区由换行符
-
文件共享
- 父进程的所有打开文件描述符都被复制到子进程中,就好像执行了
dup
函数。父进程和子进程每个相同的打开描述符共享一个文件表项。如下图所示。
- 可以看出,fork之后的父子进程每个相同的打开描述符共享一个文件表项,因此也共享相同的文件偏移量(读写指针)。
- 除了打开文件之外,父进程的很多其他属性也由子进程继承
- 实际用户ID、实际组ID、有效用户ID、有效组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 设置用户ID标志和设置组ID标志
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 信号屏蔽和安排
- 对任一打开文件描述符的执行时关闭标志(close-on-exec)
- 环境
- 连接的共享存储段
- 存储映像
- 资源限制
- 父子进程的区别
- fork返回值不同
- 进程ID不同
- 子进程的
tms_utime
、tms_stime
、tms_cutime
、tms_ustime
值设置为0 - 子进程不继承父进程设置的文件锁。
- 子进程的未处理闹钟被清除。
- 子进程的未处理信号集设置为空集。
- 父进程的所有打开文件描述符都被复制到子进程中,就好像执行了
-
fork失败原因
- 系统中已经有了太多的进程
- 该实际用户ID的进程总数超过了系统限制
-
fork常用以下方法
- 一个父进程希望复刻自己,使父进程和子进程同时执行不同的代码段。在网络服务器中较为常见:父进程等待客户端的服务请求,请求到达时fork使子进程处理此请求,父进程则继续等待下一个服务请求。
- 一个进程要执行一个不同的程序,shell常使用这种方式。子进程从fork返回后立即调用exec。有些操作系统将fork之后立刻exec组合成一个操作:spawn,但是UNIX将这两个操作分开,因为子进程可以在fork和exec之间更改自己的属性。
4、函数vfork(不推荐使用)
- vfork函数调用方式和fork相同,但语义不同。现在不应该使用这个函数
- vfork用于创建一个新进程,而该新进程的目的就是exec一个新程序。但是它不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit)。不过在子进程exec或exit之前,它在父进程的空间中运行。这种方式提高了效率,但是如果子进程修改了数据(子进程修改数据会影响到父进程,因为共享存储空间)、进行函数调用、或者没有调用exec或exit就返回可能会带来未知结果。
- 并且vfork保证子进程先运行,在子进程调用exec或exit之后父进程才可能被调度运行。即子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果在调用这两个函数之前子进程依赖于父进程的进一步操作,会导致死锁)。
- 实例:vfork函数的使用
命令行输出int main(int argc, char* argv[]) {int num = 1;if(vfork() == 0) { // 子进程cout << "子进程执行" << endl;num ++;cout << "子进程终止" << endl;_exit(0);} else { // 父进程cout << "父进程执行" << endl;cout << "num : " << num << endl;cout << "父进程终止" << endl;} }
可以看出子进程修改变量值影响了父进程中该变量,这是因为父子进程共享存储空间。$ ./a.out > 子进程执行 > 子进程终止 > 父进程执行 > num : 2 > 父进程终止
5、函数exit
-
进程有5种正常终止和3种异常终止。其中正常终止:
- main函数内执行return语句,等效于调用exit
- 调用exit函数,此操作调用终止处理程序(atexit登记的函数),关闭所有标准I/O流,然后调用_exit
- 调用_exit或_Exit。其操作提供一种无需运行终止处理程序或信号处理程序而终止的方法。(exit是标准C库中的一个函数,_exit是一个系统调用)
- 进程的最后一个线程在启动例程中执行return语句。但是,该线程返回值不用做进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回
- 进程的最后一个线程调用pthread_exit函数。此时进程终止状态总是0,这与传给pthread_exit参数无关
-
3种异常终止:
- 调用abort。它产生SIGABRT,这是下一种异常终止的特例
- 当进程接到某种信号时。信号可由进程自身(如调用abort函数)、其他进程或内核产生。
- 最后一个线程对“取消”请求做出响应。默认情况下“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。
-
不管进程是以何种方式终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开标识符,释放它所使用的存储器等。
-
希望子进程能够通知其父进程它是如何终止的。可以通过将退出状态作为参数传递给三个终止函数(exit、_exit、_Exit)。如果是异常终止,则不再由这三个函数的参数决定其终止状态,而是内核(不是进程本身)产生一个表明其异常终止原因的终止状态。 在任何情况下,父进程都能够通过wait或waitpid取得其终止状态。
-
退出状态和终止状态区别:
- 退出状态是传递给三个终止函数的参数,或main的返回值。
- 在调用_exit时,内核将退出状态转换成终止状态。
-
如果父进程在子进程之前终止,那么这些子进程的父进程会改变为init进程:当一个进程终止时,内核逐个检查所有活动进程,以判断他是否是正要终止进程的子进程,如果是,则该进程的父进程ID改为1,保证了每个进程都有一个父进程。
-
内核为每个终止子进程保存了一定量的信息,父进程调用wait或waitpid时可以得到这些信息(至少包括进程ID、该进程的终止状态、该进程使用的CPU时间总量)。一个已经终止,但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息并释放它仍占用的资源)的进程被称为僵尸进程。 即没有被父进程wait的终止子进程都是僵尸进程。
-
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个等信息。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。
-
被init进程收养的进程终止不会变成僵尸进程:只要有一个进程终止,init进程就会调用一个wait函数取得其终止状态,防止了系统中塞满僵尸进程。