第八章-进程控制

进程标识PID

每个进程都有自己唯一的进程标识PID,所以进程经常将pid作为其所创建文件的名字的一部分来唯一的标识一个文件。当一个进程终止后,PID就被回收了,下次新的进程执行后可以复用该PID,Unix提供了延迟复用算法,即新的进程的pid不会是最近回收的pid,避免将新进程误认为是以前该pid对应的旧进程。

系统中有一些专用进程,pid为0的进程一般是调度进程,这是内核的进程,常被称为交换进程,其并不执行任何磁盘上的程序。pid为1的一般是init进程,在系统自举结束后由内核调用,是一个普通的进程,不是系统进程,在自举内核后启动unix系统,读取与系统有关的初始化文件,将系统引导到一个状态,init进程绝不会终止,是以超级用户权限运行的,会成为所有孤儿进程的父进程。

函数fork

#include<unistd.h>

pid_t fork(void);//父进程返回子进程的pid,子进程返回0,出错返回-1

由fork创建的进程叫子进程,fork函数被调用一次但是返回两次,一次是在父进程调用点返回,另一次是在子进程中相同代码位置返回,所以才有了两次返回。因为子进程和父进程在fork之后会继续执行fork点之后的代码,如果不对父子进程要执行的代码进行区分,则fork点后的所有代码父子进程都会执行。子进程和父进程之间除了正文段以外,获得父进程数据段(初始化和未初始化数据段),堆和栈的副本,注意是副本,所以父进程和子进程对于这些数据各自有自己的副本,互不影响。

fork后,父进程的缓冲区会复制给子进程。

现在很多系统实现使用写时复制的算法,当fork之后,数据段(初始化和未初始化数据段),堆和栈对于两者都是只读的,当任何一个要修改其值时,会拷贝该数据的副本,这就是写的时候才复制副本。

父子进程的文件共享

fork的一个特性时父进程的所有打开的文件描述符都被复制到子进程中,就好像对每个fd都执行了dup函数,父子进程每个相同的描述符共享同一个文件表项。

父子进程共享文件偏移量。所以在子进程中的读写操作会更新文件偏移量,会影响到父进程,如果两者没有做好同步,可能出现读写交叉混合的现象。

在fork之后处理文件描述符有两种常见的情况;

1.父进程等待子进程完成。在子进程终止后,文件偏移量已经被更新了,这样父进程什么也不用做就能避免混合输出。

2.父进程和子进程各自执行不同的程序段。两者分别关闭自己不需要的文件描述符,使得两者的文件描述符没有重叠,这样对fd的操作就不会出现干扰。

fork失败的原因主要有:

1.系统的进程太多 2.该实际用户ID的子进程数超过了系统限制,CHILD_MAX规定了每个实际用户id在任意时刻可以拥有的子进程数量上限。

fork的两种常用用法:

1.父进程希望子进程复制自己,然后执行不同的代码段。这种在网络中常见,父进程监听请求,收到请求后,fork子进程,让子进程对请求进行处理。而父进程则继续监听请求。

2.一个进程要执行不同的程序。一般为fork后执行exec函数,相当于在这个进程中打开了其他的进程(程序)。例如在shell中启动其他的程序(例如mysql),那么mysql的父进程就是shell进程。

函数vfork

vfork的语义与fork一样,创建子进程,返回值也与fork一样。

而vfork用于创建子进程后,让子进程执行新的程序(exec),对于可移植的程序而言,不要用vfork。
vfork后的子进程在执行exec或exit函数之前,其与父进程共享空间,也即这时子进程能不仅能读还能写父进程空间中的数据,但要注意可能这样会出现与自己想要的结果不同的结果。

vfork与fork的另一个不同点在于,父进程一直等到子进程执行了exec或exit函数才继续执行,也即实现了父子进程间的同步,不需要像fork那样进行同步控制。

但是如果子进程在exec或exit之前需要等待父进程的动作,那么就会形成死锁。

函数exit

exit是标准C库函数,_exit是系统调用

5种正常终止方式:

1.在main函数内执行return,等价于执行exit

2.调用exit函数。

3.调用_exit/_Exit函数。无需处理终止处理程序,直接进入内核的终止函数,是否对标准I/O进行冲刷取决于实现。_exit由exit调用。

4.进程的最后一个线程执行return语句,但该返回值并不是进程的返回值,进程的返回值为0。线程的返回值与进程无关。

5.当最后一个线程调用pthread_exit,与4种一样,该进程以0作为返回值。

3种异常终止方式:

1.调用abort。产生SIGABRT信号。

2.当进程接收到某种信号时。信号可由自身(如SIGABRT),其他进程或内核产生,例如数组越界访问,除以0等,内核就会为该进程产生相应的信号。

3.最后一个线程对取消请求作出响应。默认情况下, 取消以延迟方式发生:一个线程要求取消另一个线程,一段时间后,目标线程终止。

不管以何种方式终止了进程,内核都会执行同一段代码,关闭进程所有打开的文件描述符和回收相应的资源(释放它所使用的存储器等)。

如何处理孤儿进程:

init的子进程要么是自己直接创建的子进程,要么是孤儿进程,init进程会在其子进程终止后立刻调用wait函数以获取其终止状态,防止系统中塞满了僵尸进程。

不管是不是僵尸进程,只要父进程先于子进程终止,其成为了孤儿进程后就会被init进程接收,能够得到释放。

僵尸进程的产生和处理:

内核为每个终止进程维护了一定量的信息,如其PID,终止状态,以及使用得到CPU时间总量;其他的资源则被系统回收。如果父进程不调用wait或waitpid对这些进程进行处理,释放这些信息,则这些进程称为僵尸进程。

函数wait waitpid

#include<sys/wait.h>

pid_t wait(int* statloc);

pid_t waitpid(pid_t pid,int *statloc,int options);

成功则返回子进程pid,否则返回0或-1

statloc是一个整型指针,若不为空,则子进程的终止状态值会被存到该对象中,如果不关心该值,可以置空。

对于wait若不存在子进程则出错返回,而对于waitpid,若其指定的子进程或进程组ID不存在则出错返回。

参数pid等于0,等待与调用进程组ID一样的子进程。

参数pid小于-1,等待与该参数绝对性相等的进程组ID的子进程。

options参数:

waitpid通过设置WNOHANG可以不阻塞的立刻返回。

当一个进程终止时,内核会向其父进程发送SIGCHLD信号,子进程终止是一个异步事件,可以在父进程执行的任何过程种发生,所以这种信号也叫内核向父进程发送的异步信号。

父进程可以选择忽略信号或者提供相应的信号处理程序,而系统默认是忽略的。

调用wait和waitpid后:

1.若所有子进程都在运行,则父进程阻塞等待子进程终止。

2.若所等待的子进程(对于wait是所有子进程的任何一个,对于waitpid是其所指定要等待的子进程)终止了,则取得其终止状态并立即返回。

3.若无子进程,则调用出错,立即返回。

waitpid有option参数,可以设置其不阻塞执行。

可以利用上述的宏,判断子进程返回的终止状态(终止状态是通过参数statloc得到的)是由于什么原因终止的。

函数waitid

#include<sys/wait.h>

int waitid(idtype_t idtype,id_t id,siginfo_t * infop,int options);//成功返回0,失败返回-1

waitid和waitpid类似,等待指定的进程,只不过提供了更多的灵活性。

使用id和idtype两个参数来指定特定进程,id与idtype有关系:

idtype可以取:

infop参数是指向siginfo的指针,它保存了进程状态改变有关信号的详细信息。

options参数根据下列常量按位或,表明调用者关心哪些状态变化:

注意必须为其三者之一。

函数wait3 wait4

竞争条件

如果不对进程之间的执行顺序进行控制,若对逻辑上有冲突的内容进行运行时,可能得到的结果不是想要的。我们可以通过进程间通信,信号,管道等机制实现多进程之间的协调。

函数exec

当我们fork一个进程后,对其子进程调用exec函数,则子进程会执行新的程序,并将从新程序的main函数开始执行,用新程序替换了当前进程的正文段,数据段,堆,栈。也即当执行了exec函数后,父进程和子进程分离了,就是两个完全不同的程序了,例如shell中fork后exec其他的应用程序。但由于是在子进程上执行新的程序,所以新程序的进程ID还是子进程的进程ID。

基本的进程控制原语:fork-->exec-->exit-->wait,fork产生子进程,子进程exec执行新程序,exit退出进程并返回终止状态,wait获取终止状态。

每个系统对环境表和参数表的长度(数组中所有字符的总长度)有限制,这个限制是由ARG_MAX常量给出的。

对于进程中已打开的文件,如果没有设置执行时关闭标志位(FD_CLOEXEC),则执行exec后的文件描述符还是打开状态,否则执行exec后,这些文件描述符会被关闭,若不用fcntl设置该位,系统默认是关闭该位,也即exec后文件描述符还是打开的。

POSIX.1要求对于打开的目录流在执行exec后是关闭的,这是由opendir函数打开目录后用fcntl设置FD_CLOEXEC标志位。

execve为系统调用,其他的为库函数,这些库函数最后都调用execve。

fexecve将文件描述符转换成路径名,再调用execve。(Linux中就是采用这种实现)。

更改用户ID和更改组ID

在系统中,特权和访问控制是基于用户ID和组ID的,有时候我们需要更改进程的用户ID和组ID来提升权限访问更多的文件或者降低权限,限制程序的访问。

可以用setuid和setgid来设置进程的实际和有效用户ID以及实际和有效组ID。

#include<unistd.h>

int setuid(uid_t uid);

int setgid(gid_t gid);//成功返回0,失败返回-1;

用户ID和组ID的设置思路一样,这里以用户ID为例:

1.若进程有超级用户权限,则将进程的实际、有效、和保存的设置用户ID设置为uid。

2.若没有特权的一般用户,若uid是进程的实际用户ID或保存的设置用户ID,则将进程的有效用户ID设置为uid,其他的不改变。

3.其他情况则出错。

所谓保存的设置用户ID是指进程将执行后的进程有效用户ID保存到一个副本中,这就是保存的设置用户ID。

Linux除了提供getuid,geteuid以外,还提供了getresuid,getresgid,获得保存的设置用户ID和组ID。

r为real e为efficient s为set

1.函数setreuid setregid

#include<unistd.h>

int setreuid(uid_t ruid,uid_t euid);

int setregid(gid_t rgid,gid_t egid);//成功返回0,失败返回-1

如果任一参数为-1,则表示相应的ID保持不变

一个非特权用户可以将进程的有效用户ID和实际用户ID交换,或者将保存的设置用户ID设置为有效用户ID。

组类似。

2.函数seteuid setegid

只更改进程有效用户ID和有效组ID

一个非特权用户可以将有效用户ID或组ID设置为实际用户ID和组ID或者保存的设置用户ID和组id

附属组ID不受这些函数的控制,其他组ID与这些函数对用户ID的行为一样。

解释器文件或解释器脚本

解释器文件是一个文本文件,其起始第一行必须为;

#! pathname [options]

当我们exec解释器文件时,并不是真的执行该文件,而是执行该文件第一行给定的pathname的程序,一般pathname是绝对路径。

当我们使用exec函数时,如果执行的不是机器可执行文件,就会判断成解释器文件,进而进行相应的操作。

options可以是给定的选项,或者想要执行该文件时的命令行参数argv。所以,如果用exec函数执行解释器文件,其最终命令行参数是解释器文件中给出的参数和exec函数传递的命令行参数;

如果对应的解释器,其具备-f选项,一般都会使用-f选项,该选项表明该解释器(一般为程序)执行-f后的文件名的文件:

注意这里是读awk程序,也即读awk程序可以执行的文件代码。

一个exec解释器文件的案例:

解释器文件内容:

#! /home/rex/test -f、

用exec执行该文件:

exec判断该文件不是机器可执行文件,认为其是解释器文件,转而执行其指定的程序,也即/home/rex/test

则实际上执行该指定程序的本质等价于(命令行代码):

/home/rex/test -f pathname arg1 arg2 arg3...

其中pathname是exec函数的pathname参数 arg1 arg2 arg3则为其传递给执行文件的参数,举例:

execl(pathname,testinterp,arg1,arg2);

/home/rex/test -f pathname arg1arg2

记住这个过程就能理解解释器文件了,所谓的解释器文件对应的解释器也即其#号后指定的可执行文件

第一点等价于上述笔记中的过程。

如果相同的功能用过shell脚本实现,在shell中执行时,因为不是机器可执行文件,会判断其为shell脚本,然后打开shell执行该文件,然后又要fork,exec来执行awk程序,会增加开销。

函数system

#include<stdlib.h>

int system(const char* cmdstr);

若cmdstr是空指针,返回非0值,可用于判断系统是否支持system函数。

system函数的本质是调用了fork、exec和waitpid函数,执行了shell程序,然后将cmdstr作为命令传给了shell程序,让shell对命令进行执行。

一个简单的system实现

返回值有三种:

1.fork失败或者waitpid返回除EINTR之外的出错,则system返回-1.

2.若传递给shell的执行命令有误,则其返回值如同shell执行了exit(127)一样。

3.否则若fork、exec、waitpid都成功,则返回shell的终止状态。

注意,不要在一个设置用户ID或组ID的程序中调用system:

因为system函数自身调用了fork、exec、waitpid函数,fork函数会将父进程的用户ID赋给子进程的用户ID。如果调用system函数的进程刚好是有root权限的,那么其所exec的shell程序也将具有root权限,可能会被恶意用户利用进行攻击。

所以,设置用户ID和组ID的程序绝不应该调用system

进程会计

每当进程结束后,内核就会写一个会计记录,一般包括命令名,所使用的cpu时间,用户ID,组ID,启动时间等。

标准并没有对进程会计进行说明,所以不同系统的进程会计记录结构有差别,相同的字段表达的意思也可能有差

打开和关闭进程会计功能是使用acct函数,该函数在#include<sys/acct.h>中。

该函数只能超级用户调用。

大多数系统上,时间是以时钟滴答来记录的,也即每秒多少滴答。

会计记录的所需数据,由内核保存在进程表中,并在进程被创建时初始化,只有当进程结束后,内核写入该记录。

会计记录是针对进程的,不是程序的,不管一个进程exec了多少个程序,其进程记录只有一个,cpu时间是其进程执行的总cpu时间。我们永远无法得到正在运行的进程的会计记录,所以init和内核守护进程我们是无法得到其会计记录的。

在进程会计记录中记录的顺序对应于进程的终止顺序,而不是其启动顺序,为了确定启动顺序,需要获得记录文件的所有记录并按启动时间字段进行排序,但这是欠缺的,因为日历时间是秒,而墙上时间单位是时钟滴答,所以不能按照这种方法精确重构启动顺序。

在Linux中,#ifdef后不能加枚举类型的值

进程会计记录中会记录该进程的I/O量,也即读和写的字节数,读和写都会被记录,即使输出到空设备也会记录相应的写字节数。

一个进程的core文件所需的I/O不是该进程负责的,是内核负责的,所以core文件的I/O量不会被记录到会计记录中。

用户标识

有时候我们希望获得运行该程序的用户登录名,可以使用下列函数;

#include<unistd.h>

char * getlogin();

getlogin返回登录名的字符串。

一个人在口令文件中,可以有多个登录项,其用户ID相同,当登录的shell不同,所以就可以有相同的用户ID,都能不同的登录名,系统通常记录用户登录时的登录名(例如 rex)。

如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败,这些进程通常称为守护进程。

进程调度

进程可以通过调整其nice值来调整进程自身的优先级,更高的nice值其优先级越低,也即对CPU越友好。

只有特权进程允许提高调度权限。

NZERO是系统默认的nice值。

进程可以通过nice函数来获取和修改自己的nice值,使用这个函数只能更改本进程的nice值

#include<unistd.h>

int nice(int incr);//进程的nice值被更改为原本的nice值+incr,并返回其更改后的nice值,若出错,返回-1.

如果指定的incr超过了nice的范围,则系统会自动设置为边界值。

getpriority函数可以像nice函数一样获取进程的nice值,也可以获取一组相关进程的nice值(只能返回其中一个进程的nice值,因为返回类型是int,不是数组类型)

#include<sys/resource.h>

int getpriority(int which,int who);

setpriority函数可以为进程,进程组,属于特定用户ID的所有进程设置优先级

int setpriority(int which,int who,int value);参数which和who与上面含义相同,相应进程的nice值增加value。

子进程从父进程中继承nice值。

进程的nice值不同,会产生不同的CPU占用比。

进程时间

#include<sys/times.h>

clock_t times(struct tms* buf);

times函数获取进程的用户CPU时间、系统CPU时间和墙上时钟时间。

该结构中得所有时间都是以滴答为单位的,需要除以每秒滴答数才能得到秒为单位的数据。

上述结构中没有墙上时钟时间,墙上时钟时间作为其返回值(返回的是滴答数)返回给调用者,这个值是绝对值,我们需要进行两次调用times,将两次的返回值相减并除以每秒滴答数,便能得到墙上时钟时间。

注意这和获取时间的函数time的区别,time获取的是time_t类型的时间,是UTC以来的秒数。