1、概述
UNIX系统控制包括创建新的进程、执行程序和进程终止。本章还将讲明进程属性的各种ID——实际、有效和保存的用户ID和组ID,以及它们如何受到进程控制原语的影响。
2、进程标识
利用进程号标识进程ID。PID号虽唯一,但可复用。
系统中有一些专用进程,具体细节随实现而不同。ID为0的进程通常是调度进程,即交换进程,该进程是内核的一部分,并不执行任何磁盘上的程序,因而也被称为系统进程。ID为1通常是init进程,在自举过程结束时由内核调用。该进程的程序文件通常为/etc/init或/sbin/init。init进程决不会终止,是一个普通的用户进程,但是它以超级用户特权运行。
以下函数返回相应进程或进程组标识:
#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
3、函数fork
fork函数创建一个新进程。
#include<unistd.h>
pid_t fork(void);
//返回值:子进程返回0,父进程返回子进程ID;若出错,返回-1
fork函数被调用一次,返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值则是新建子进程的进程ID。
子进程是父进程的副本。如,子进程获得父进程数据空间、堆和栈的副本。注意是子进程拥有的副本,而不与父进程共享这些空间部分。但是父进程与子进程共享正文段。
由于在fork之后经常跟随着exec,所以现在很多实现并不执行一个父进程数据段、栈和堆的完全副本。作为替代,使用写时复制技术。即,父或子进程中任一个试图修改的这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。
文件共享
父进程和子进程每个相同的打开描述符共享一个文件表项。更重要的一点是,父进程和子进程共享同一个文件偏移量,此方便父进程和子进程对文件在尾部交替写入。
考虑以下情况,一个进程具有3个不同的打开文件,分别标准输入、标准输出和标准错误。在从fork返回时,有如下文件共享结构示意图。
通过上图可知,父子进程中的文件共享,到底共享哪些东西(文件表项及v节点表)。。
在fork之后处理文件描述符有以下两种常见的情况:
i、父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。
ii、父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用的文件描述符。如在网络编程中并发服务器完成fork后,在子进程中需要关闭相应fd,close(listenfd)、close(connfd)等。
除打开文件之外,父进程的很多其他属性也由子进程继承,如下包括:
i、实际用户ID、实际组ID、有效用户ID、有效组ID
ii、附属组ID、进程组ID
iii、会话ID
iv、会话ID
v、控制终端
vi、设置用户ID标志和设置组ID标志
vii、当前工作目录
viii、根目录
ix、文件模式创建屏蔽字
x、信号屏蔽和安排
xi、对任一打开文件描述符的执行时关闭标志
xii、环境
xiii、连接的共享存储段
xiv、存储映像
xv、资源限制
父进程和子进程之间的区别,如下包括:
i、fork的返回值不同
ii、进程ID不同
iii、这两个进程的父进程ID(涉及祖父子三个进程)不同:即子进程的父进程ID是父进程ID,而父进程的父进程ID是祖进程ID。
iv、子进程的tms_utime/tms_stime/tms_cutime和tms_ustime的值设置为0
v、子进程不继承父进程设置的文件锁
vi、子进程的未处理闹钟被清除
vii、子进程的未处理信号集设置为空集
fork函数常见两种方法:
i、一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。(并发服务器就需要用着fork函数)
ii、一个进程要执行一个不同的程序。在shell中常见。即此情况下,子进程从fork返回后立即调用exec。
4、exit函数
正常结束程序:
i、在main函数内执行return语句。等效于调用exit。
ii、调用exit函数。其操作包括调用各终止处理程序(终止处理程序在调用atexit函数时登记),然后关闭所有标准I/O流等。
iii、调用_exit或_Exit函数。此两函数为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。至于对标准I/O是否进行冲洗,取决于实殃。在Unix系统中,此两函数并不冲洗标准I/O流。
iv、进程的最后一个线程在其启动例程中执行return语句。但是,该线程的返回值不用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。
v、进程的最后一个线程调用pthread_exit函数。此情况中,进程终止状态总是0,这与传送给pthread_exit的参数无关。
异常终止进程具体如下:
vi、调用abort。产生SIGABRT信号。
vii、当进程接收到某些信号时。信号可由进程自身(如调用abort函数)、其他进程或内核产生。
viii、最后一个线程对“取消”请求作出响应。默认情况下,“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。
值得注意的几点:
无论如何关闭进程,最终都要关闭fd,释放所使用存储器、所打开的描述符。
在使用fork函数,父进程产生了子进程。当子进程结束时,会将自身终止状态返回给父进程,此时父进程需要利用wait或waitpid函数检测到子进程的终止状态,释放僵尸进程相关资源(如进程ID、进程的终止状态和进程使用的CPU时间总量)。
僵尸进程:一个已终止、但其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程。所以全局进程必须得到清除。
但如果父进程在子进程之前终止,对于父进程已经终止的所有进程,它们的父进程都改变为init进程,也即相应的子进程将被init进程收养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程ID就更改为1(init进程的ID)。此处理方法保证了每个进程有一个父进程。
5、wait和waitpid函数
谈谈为什么要用到这个wait系列函数吧。
当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。因为子进程终止是个异步事件,所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号(系统默认动作),或者提供一个该信号发生时即被调用执行的函数(信号处理程序,一般在捕获SIGCHLD信号后信号处理函数中调用wait或waitpid)。
用wait或waitpid(最终是要调用wait函数)的进程可能发生如下事情:
i、如果其所有子进程都还在运行,则阻塞;
ii、如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
iii、如果它没有任何子进程,则立即出错返回。
#include<sys/wait.h>
pid_t wait(int* statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
//两个函数返回值:若成功,返回进程ID;若出错,返回0或-1
上述两函数区别:
i、在一个子进程终止前,wait使其调用者阴塞,而waitpid有一选项,可使调用者不阻塞;
ii、waitpid并不等待在其调用之后的第一个终止子进程,有一个选项(options为WNOHANG时),可以控制它所等待的进程
wait函数返回终止子进程的进程ID,所以它总能了解是哪一个子进程终止了。
两函数的参数statloc是一个整型指针,如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如不关心终止状态,则可将该参数指定为空指针。当然,此处的状态,可以通过四个宏进行查看,分别是WIFEXITED(status)、WIFSIGNALED(status)、WIFSTOPPED(status)、WIFCONTINUED(status)。具体解析,参考书上P191。
针对waitpid函数:
pid参数作用解释如下:
pid==-1 等待任一子进程。此情况下,waitpid与wait等效。
pid>0 等待进程ID与pid相等的子进程。
pid==0 等待组ID等待于调用进程组ID的任一子进程。
pid<-1 等待组ID等于pid绝对值的任一子进程。
waitpid函数返回终止子进程的进程ID,并将该子进程的终止状态存放在由statloc指向的存储单元中。对于wait,其唯一的出错是调用进程没有子进程(或者函数调用被一个信号中断时,也可能返回另一种出错)。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。
options参数可进一步控制waitpid的操作。此参数或是0、或是下图常按位或运算的结果。
常量 | 说明 |
WCONTINUED | 若实现支持作业控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态。 |
WNOHANG | 若由pid指定的子进程并不是立即可用的,则waitpid不阴塞,此时其返回值为0。 |
WUNTRACED | 若某实现支持作业控制,而由pid指定的任一子进程已处于停止状态,并且其状态自停止以来还未报告过,则返回状态。WIFSTOPPED宏确定返回值是否对应于一个停止的子进程。 |
waitpid函数提供了wait函数没有提供的3个功能:
i、waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。
ii、waitpid提供了一个wait的非阻塞版本(即利用WNOHANG选项),有时希望获取一个子进程的状态,但不想阻塞时,可以选用此选项。
iii、waitpid通过WUNTRACED和WCONTINUED选项支持作业控制。
6、竞争条件
竞争条件的通俗理解:当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,即发生了竞争条件。
如果在fork之后的某种逻辑显式或隐式地依赖于在fork之后是父进程先运行还是子进程先运行,那么fork函数就会是竞争条件活跃的滋生地。此时哪个进程先进行依赖于系统负载以及内核的调度算法。
使用信号量机制,IPC通信手段可阻止此情况。
7、exec函数
调用exec执行新程序,而新程序需要开僻新地址空间以存储正文段、数据段、堆段、栈段等。
7个exec函数(其中l表示列表list,v表示矢量vector,e表示不使用当前环境而是指定环境变量,p表示输入的是filename)如下所示:
#include<unistd.h>
int execl(const char* pathname, const char *arg0, .../* (char*)0 */);
int execv(const char* pahtname, char* const argv[]);//char* argv[]用来存命令行多个参数,const char* arg0指向命令行的单独参数
int execle(const char* pathname, const char* arg0, .../* (char*)0 ,char* const envp[] */);
int execve(const char* pathname, char* const argv[], char *const envp[]);
int execlp(const char* filename, const char *arg0, .../* (char*)0 */);
int execvp(const char* filename, char* const argv[]);
int fexecve(int fd, char* const argv[], char *const envp[]);
//7个函数返回值:若出错,返回-1;若成功,不返回
char* argv[]用来存命令行多个参数,const char* arg0指向命令行的单独参数并以(char*)0结尾,char *const envp[]存储给定的环境变量字符串。
前4个函数是取路径名作为参数,后两个函数则取文件名作为参数,最后一个取文件描述符作为参数。
i、如果filename中包含/,则将其视为路径名,否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件。
ii、fexecve函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件。
在执行exec后,进程ID没有改变,但新程序从调用进程继承了下列属性:
i、进程ID和父进程ID
ii、实际用户ID和实际组ID
iii、附属组ID
iv、进程组ID
v、会话ID
vi、控制终端
vii、闹钟尚余留的时间
viii、根目录
ix、文件模式创建屏蔽字
x、文件锁
xi、进程信号屏蔽
xii、未处理信号
xiii、资源限制
xiv、nice值
xv、tms_utime/tms_stime/tms_cutime/以及tms_cstime值
值得注意一点,在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID位和设置组ID位是否改变。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID(即变成设置用户ID位);否则有效用户ID不变。对组ID的处理方式与此相同。
7个执行函数执行的关系如下图所示:
UNIX系统中,只有execve是内核的系统调用,另外6个只是库函数,但最终都要调用该系统调用。
以下是exec函数的运用举例代码:
#include "apue.h"
#include <sys/wait.h>
char *env_init[] = { "USER=unknown", "PATH=/tmp", NULL };//NULL是空指针结尾
int
main(void)
{
pid_t pid;
if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* specify pathname, specify environment */
if (execle("/home/sar/bin/echoall", "echoall", "myarg1",
"MY ARG2", (char *)0, env_init) < 0)//(char*)0是将0转化为空指针表示命令行参数的结尾
err_sys("execle error"); //此处采用的是自定义环境变量
}
if (waitpid(pid, NULL, 0) < 0)//阻塞等待清僵尸子进程
err_sys("wait error");
if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* specify filename, inherit environment */
if (execlp("echoall", "echoall", "only 1 arg", (char *)0) < 0)//此调用采用的是系统自身的环境变量
err_sys("execlp error");
}
exit(0);
}
8、更改用户ID和更改组ID
set-user-ID存在意义:在需要提升特权时,普通用户可通过setuid函数,将set-user-ID具有root特权的传递给有效用户ID,从而暂时获得访问权限的提升。
举个例子:在UNIX系统中,特权(如能改变当前日期的表示法)以及访问控制(如能否读、写一个特定文件),是基于用户ID和组ID的。当程序坱要增加特权,或需要访问当前并不允许访问的资源时,需要更换自己的有效用户ID或有效组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,新ID不具有相应特权或访问这些资源的能力。(这也就是set-user-ID存在意义的应用所在之处)
#include<unistd.h>
int setuid(uid_t uid);//设置实际用户ID和有效用户ID
int setgid(gid_t gid);//设置实际用户组ID和有效用户组ID
//两个函数返回值:若成功,返回0;若出错,返回-1
关于谁能更改ID有若干规则。以下考虑更改用户ID的规则:
i、若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID设置为uid。
ii、若进程没有超级用户特权,但是uid等于实际用户ID(降低进程权限)或保存的设置用户ID(一般用来提升进程权限),则setuid只将有效用户ID设置为uid。记住,此处只能更改有效用户ID,而不能更改实际用户ID和保存的设置用户ID。
iii、如果上面两个条件都不满足,则errno设置为EPERM,并返回-1。
关于内核所维护的3个用户ID,需要注意的几点:
i、只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,由login程序设置,而且决不会改变它。因为login是一个超级用户进程,当它调用setuid时,设置所有3个用户ID。
ii、仅当对程序文件设置了设置用户ID位时,exec函数才改变有效用户ID。如果设置用户ID位没有设置,exec函数不会改变有效用户ID,而将维持其现有值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。自然地,不能将有效用户ID设置为任一随机值。
iii、保存的设置用户ID是由exec复制有效用户ID而等到的。如果设置了文件的设置用户ID位,则在exec根据文件的用户ID设置了进程的有效用户ID以后,这个副本就被保存起来了。
总结更改上述三个用户ID的不同方法:
ID | exec | setuid(uid) | ||
设置用户ID位关闭 | 设置用户ID位打开 | 超级用户 | 非特权用户 | |
实际用户ID | 不变 | 不变 | 设为uid | 不变 |
有效用户ID | 不变 | 设置为程序文件的用户ID | 设为uid | 设为uid |
保存的set-user-ID | 从有效用户ID复制 | 从有效用户ID复制 | 设为uid | 不变 |
getuid和geteuid函数只能获得实际用户ID和有效用户ID的当前值。无法获得保存的设置用户ID的当前值。
setreuid函数,其功能是交换实际用户ID和有效用户ID的值,而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。其目的在于,缩小实际用户所具有特权范围,增强安全措施。
seteuid与setegid函数,类似于setuid和setgid,但只更改有效用户ID和有效组ID。
#include<unistd.h>
int seteuid(uid_t ruid, uid_t euid);
int setegid(gid_t rgid, gid_t egid);
//两个函数返回值:若成功,返回0;若出错,返回-1
组ID:上述所写都以类似方式适用于各个组ID。附属组ID不受setgid、setregid和setegid函数的影响。(不太理解附属组ID是用来干什么的哈)
举一个实例说清楚set-user-ID的作用:
i、程序文件由root用户拥有,并且set-user-ID位已设置,执行时的结果:
实际用户ID=我们的用户ID(no change)
有效用户ID=root
保存的set-user-ID=root
ii、at程序利用setuid函数降低权限。把有效用户ID设置为实际用户ID,结果:
实际用户ID=我们的用户ID(no change)
有效用户ID=我们的用户ID
保存的set-user-ID=root(no change)
iii、at程序需特权时,将有效用户ID更改为set-user-ID,提升权限,结果:
实际用户ID=我们的用户ID(no change)
有效用户ID=root
保存的set-user-ID=root(no change)
iv、at程序完成任务后,需要降低权限,于是将有效用户ID更改为实际用户ID,结果:
实际用户ID=我们的用户ID(no change)
有效用户ID=我们的用户ID
保存的set-user-ID=root(no change)
v、守护进程以root特权运行,fork子进程后,子进程调用setuid将用户ID更改至我们的用户ID,也就更改了所有的ID,结果:
实际用户ID=我们的用户ID
有效用户ID=我们的用户ID
保存的set-user-ID=我们的用户ID
9、解释器文件
解释器文件是文本文件,超始行的形式是:#! pathname[optional-argument],常见例子,如#! /bin/sh
内核使调用exec函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname所指定的文件。注意区分解释器文件(文本文件,它以#!开头),解释器(由该解释器文件第一行中的pathname指定)。
解释器文件使用户得到效率方面的好处,其代价是内核的额外开销(识别解释器文件的是内核)。
解释器文件的好处:
i、有些程序是用某种语言写的脚本,解释器文件可将这一事实隐蔽起来。
ii、解释器脚本在效率方面也提供了好外。直接以解释器脚本运行,避免无谓的开销。
iii、解释器脚本可以使用除/bin/sh以外的其他shell来编写shell脚本。
10、system函数
system函数调用形参字符串命令行指令。其操作对系统的依赖性很强。
#include<stdlib.h>
int system(const char* cmdstring);
如果cmdstring是一个空指针,仅当命令处理程序可用时,返回非0值,可以用来判断一个给定的操作函数上是否支持system函数。
system函数存在有3种返回值:
i、fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,并且设置errno以指示错误类型。
ii、如果exec失败(表示不能执行shell),则其返回值如同shell执行了exit(127)。
iii、3个函数(fork、exec、waitpid)都成功,那么system的返回值是shell的终止状态。
以下是对信号没有进行处理的system函数的实现:
#include <sys/wait.h>
#include <errno.h>
#include <unistd.h>
int
system(const char *cmdstring) /* version without signal handling */
{
pid_t pid;
int status;
if (cmdstring == NULL)
return(1); /* always a command processor with UNIX */
if ((pid = fork()) < 0) {
status = -1; /* probably out of processes */
} else if (pid == 0) { /* child */
execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit(127); /* execl error */
} else { /* parent */
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) {
status = -1; /* error other than EINTR from waitpid() */
break;
}
}
}
return(status);
}
值得注意(有关安全性),如果设置用户ID程序具有超级用户权限,当调用system中执行了fork和exec之后,超级权限将被保留下来(这是一个安全漏洞,为此linux采取了相应关闭措施,即,当通过更改/bin/sh后,当有效用户ID与实际用户ID不匹配时,将有效用户ID设置为实际用户ID)。
11、进程调度(调度类别众多,此处主讲调整nice值的接口)
只有特权进程才被允许提高调度权限。
Single UNIX specification中nice值的范围在0~(2*NZERO)-1之间。nice值越小,优先级越高。因为你越友好,你的调度优先级就越低。而NZERO是系统默认的nice值。
进程可以通过nice函数获取(即,nice(0)+nzero)或更改它的nice值。此函数,进程只能影响自己的nice值,不能影响任何其他进程的nice值。
#include<unistd.h>
int nice(int incr);
//返回值:若成功,返回新的nice值NZERO;若出错,返回-1
incr参数被增加到调用进程的nice值上。如果incr太大,系统直接把它降到最大合法值,不给出提示。类似地,如果incr太小,系统也会无声无息地把它提高到最小合法值。由于-1是合法的成功返回值,在调用nice函数之前需要清楚errno,在nice函数返回-1时,需要检查它的值。如果nice调用成功,并且返回值为-1,那么errno仍然为0。如果errno不为0,说明nice调用失败。
getpriority函数除像nice函数那样获取进程的nice值,还可以获取一组相关进程的nice值。
#include<sys/resource.h>
int getpriority(int which,id_t who);
返回值:若成功,返回-NZERO~NZERO-1之间的nice值;若出错,则返回-1
which取值有PRIO_PROCESS、PRIO_PGRP、PRIO_USER
who取值为0或其他进程号
如which=PRIO_USER且who=0时,使用调用进程的实际用户ID。
setpriority函数用于为进程、进程组和属于特定用户ID的所有进程设置优先级。
#include<sys/resource.h>
int setpriority(int which,id_t who,int value);
返回值:若成功,返回-0;若出错,则返回-1
参数which和who与getpriority函数中相同。value增加到NZERO上,然后变为新的nice值。