每个进程都有一个非负整型表示的唯一进程ID。因为进程ID标识符总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。
虽然是唯一的,但是进程ID是可复用的。当一个进程终止后,其进程ID就称为服用的候选者。大多数UNIX系统实现延迟复用算法。
系统中有一些专用进程。ID为0的进程通常是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。进程ID 1通常是init进程。进程ID 2是页守护进程
除了进程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函数创建一个新进程
#include <unistd.h>
pid_t fork(void);
//返回值:子进程返回0;父进程返回进程ID;若出错,返回-1
由fork创建的进程被称为子进程。fork函数被调用一次,但返回两次。两次返回的区别分别是子进程的返回值是0,而父进程的返回值则是新建子进程的进程ID。
将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程ID。
fork使子进程得到返回值0的理由是:一个进程只会有一个父进程,所以子进程总可以调用getppid以获得其父进程的进程ID。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所有用的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段。
由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全副本。作为代替,使用了写时复制(Copy-On-Write, cow)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。
#include "apue.h"
int globvar = 6;
char buf[] = "a write to stdout\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");
printf("brfore fork\n");
if((pid = fork()) < 0){
err_sys("fork error");
}else if(pid == 0){
globvar++;
var++;
}else{
sleep(2);
}
printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar, var);
exit(0);
}
$./a.out
a write to stdout
before fork
pid = 430, glob = 7, var = 89 子进程
pid = 429, glob = 6, var = 88 父进程
一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。
父进程和子进程每个相同的打开描述符共享一个文件表项。
重点是,父进程和子进程共享同一个文件偏移量。
在fork之后处理文件描述符有以下两种常见的情况。
1. 父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它层进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。
2. 父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程经常使用的。
除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:
实际用户ID、实际组ID、有效用户ID、有效组ID
附属组ID
进程组ID
会话ID
控制终端
设置用户ID标志和设置组ID标志
当前工作目录
根目录
文件模式创建屏蔽字
信号屏蔽和安排
对任一打开文件描述符的执行时关闭标志
环境
连接的共享存储段
存储映像
资源限制
父进程和子进程之间的区别具体如下。
fork的返回值不同
进程ID不同
这两个进程的父进程ID不同:子进程的父进程ID是创建它的进程的ID,而父进程的父进程ID则不变。
子进程的tms_utime、tms_stime、tms_cutime和tms_ustime的值设置为0
子进程不继承父进程设置的文件锁
子进程的未处理闹钟被清除
子进程的未处理信号集设置为空集
使fork失败的两个主要的原因是:(a)系统中已经有了太多的进程,(b)该实际用户ID的进程总数超过了系统限制。
fork有以下两种用法
1. 一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务器进程中是常见的——父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求。
2. 一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程从fork返回后立即调用exec
vfork函数的调用序列和返回值与fork相同,但两者的语义不同。
vfork函数用于创建一个新进程,而该进程的目的是exec一个新程序。vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec,于是也就不会引用该地址空间。不过在子进程调用exec或exit之前,它在父进程的空间中运行。
vfork和fork之间的另一个区别是:vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。
vfork和fork共享数据段。
进程有5种正常终止及3种异常终止。5种正常终止方式具体如下
1. 在main函数内执行return语句。这等效调用exit
2. 调用exit函数。
3. 调用_exit或_EXIT函数。
4. 进程的最后一个线程在启动例程中执行return语句。
5. 进程的最后一个线程调用pthread_exit函数。
3种异常终止具体如下
1. 调用abort。
2. 当进程收到某些信号时
3. 最后一个线程对“取消”请求做出响应。
不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。
但是如果父进程在子进程之前终止,又将如何呢?对于父进程已经终止的所有进程,它们的父进程都改变为init进程。我们称这些进程由init进程收养。
一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程。ps(1)命令将僵死进程的状态打印为Z。如果编写一个长期运行的程序,它fork了很多子进程,那么除非父进程等待取得子进程的终止状态,不然这些子进程终止后就会变成僵死进程。
当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。因为子进程终止是个异步事件。现在需要知道的是调用wait和waitpid的进程可能会发生什么。
1. 如果其所有子进程都还在运行,则阻塞。
2. 如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
3. 如果它没有任何子进程,则立即出错返回。
如果进程由于接受到SIGCHLD信号而调用wait,我们期望wait立即返回。但是如果在随机时间点调用wait,则进程可能会阻塞。
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
//两个函数返回值:若成功,返回进程ID;若出错,返回0或-1
两个函数的区别如下。
在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项,可使调用者不阻塞。
waitpid并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程
如果子进程已经终止,并且是一个僵死进程,则wait立即返回并取得该子进程的状态;否则wait使其调用者阻塞,直到一个子进程终止。如调用者阻塞而且它有多个子进程,则在其某一子进程终止时,wait就立即返回。因为wait返回终止子进程的进程ID,所以它总能了解是哪一个子进程终止了。
这两个函数的参数statloc是一个整型指针。如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将该参数指定为空指针。
终止状态用定义在 sys/wait.h 中的各个宏来查看。有4个互斥的宏可用来取得进程终止的原因。
宏 | 说明 |
---|---|
WIFEXITED | 正常终止返回真。 |
WIFSIGNALED | 异常终止返回真 |
WIFSTOPPED | 暂停子进程返回真 |
WIDCONTINUED | 暂停后继续返回真 |
对于waitpid函数中pid参数的作用:
pid == -1 等待任一子进程。此种情况下,waitpid与wait等效
pid > 0 等待进程ID与pid相等的子进程
pid == 0 等待组ID等于调用进程组ID的任一子进程
pid < -1 等待组ID等于pid绝对值的任一子进程
options参数使我们能进一步控制waitpid的操作。此参数或者是0,或者是如下
常量 | 说明 |
---|---|
WCONTINUED | 返回暂停后继续执行的子进程状态 |
WNOHANG | 若pid指定的子进程并不是立即可用的,则waitpid不阻塞,返回0 |
WUNTRACED | 返回子进程停止状态 |
1. waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。
2. waitpid提供了一个wait的非阻塞版本。有时希望获取一个子进程的状态,但不想阻塞。
3. waitpid通过WUNTRACED和WCONTINUED选项支持作业控制
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
//返回值:若成功,返回0;若出错,返回-1
与waitpid相似,waitid允许 一个进程指定要等待的子进程。但它使用两个单独的参数表示要等待的子进程所属的类型,而不是将此与进程ID或进程组ID组合成一个参数。id参数的作用与idtype的值相关。
常量 | 说明 |
---|---|
P_PID | 等待一特定进程:id包含要等待子进程的进程ID |
P_PGID | 等待一特定进程组中的任一子进程:id包含要等待子进程的进程组ID |
P_ALL | 等待任一子进程:忽略id |
options参数是下图中各标志的按位或运算
常量 | 说明 |
---|---|
WCONTINUED | 等待一进程,它以前曾被停止,此后又已继续,但其状态尚未报告 |
WEXITED | 等待已退出的进程 |
WNOHANG | 如无可用的子进程退出状态,立即返回而非阻塞 |
WNOWAIT | 不破坏子进程退出状态 |
WSTOPPED | 等待一进程,它已经停止,但其状态尚未报告 |
该参数允许内核返回由终止进程及其所有子进程使用的资源概况。
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>
pid_t wait3(int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage
*rusage);
//两个函数的返回值:若成功,返回进程ID;若出错,返回-1
当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为发生了竞争条件(race condition)。
如果一个进程希望等待一个子进程终止,则它必须调用wait函数中的一个。如果一个进程要等待其父进程终止,则可使用下列形式的循环:
while(getppid() != 1)
sleep(1);
这种形式的循环被称为轮询(polling),它的问题是浪费了CPU时间,为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发生和接受的方法。
调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
#include <unistd.h>
int execl(const char *pathname, const char *arg0,...,
/* (char*) 0 */);
int execv(const char *pathname, char *const arg[]);
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;若成功,不返回
这些函数之间的第一个区别是前4个函数取路径名作为参数,后两个函数则取文件名作为参数,最后一个取文件描述符作为参数。当指定filename作为参数时:
1. 如果filename中包含/,则将其视为路径名;
2. 否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件
PATH变量包含了一张目录表,目录之间用冒号(:)分隔。
PATH=/bin:/usr/bin:/usr/local/bin:
最后的路径前缀 . 表示当前目录。
fexecve函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。
第二个区别与参数表的传递有关。函数execl、execlp和execle要求将新程序的每个命令行参数都说明为一个单独的参数,这种参数表是以指针结尾。对于另外4个函数(execv、execvp、execve和fexecve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这4个函数的参数。
最后一个区别与向新程序传递环境表相关。以e结尾的3个函数可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境。
这7个函数的参数很难记忆。函数名中字符会给我们一些帮助。字母P表示该函数取名filename作为参数,并且用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数表,它与字母v互斥。v表示该函数取一个argv[]矢量。最后,字母e表示该函数取envp[]数组,而不使用当前环境
在执行exec之后,新进程从调研进程继承了下列的属性
1. 进程ID和父进程ID
2. 实际用户ID和实际组ID
3. 附属组ID
4. 进程组ID
5. 会话ID
6. 控制终端
7. 闹钟尚预留时间
8. 当前工作目录
9. 根目录
10. 文件模式创建屏蔽字
11. 文件锁
12. 进程信号屏蔽
13. 未处理信号
14. 资源限制
15. nice值
16. tms_utime、tms_stime、tms_cutime以及tms_cstime值
对打开文件的处理与每个描述符的执行时关闭标志值有关。
注意,在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;否则有效用户ID不变。对组ID的处理方式与此相同。
下面程序演示了exec函数
#include "apue.h"
#include <sys/wait.h>
char *env_init[] = {"USER = unknown","PATH =/ tmp",NULL};
int main(void)
{
pid_t pid;
if((pid = fork()) < 0){
err_sys("fork error");
}else if(pid == 0){
if(execle("home/sar/bin/echoall","echoall","myargl",
"MY ARG2",(char *)0, env_init) < 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){
if(execlp("echoall","echoall","only 1 arg", (char *)0) < 0)
err_sys("execlp error");
}
exit(0);
}
特权以及访问控制,是基于用户ID和组ID的。
一般而言,我们总是试图使用最小特权模型。依照此模型,我们的程序应该只具有为完成给定任务所需的最小特权。这降低了由恶意用户试图哄骗我们的程序以未预料的方式使用特权造成的安全性风险
我们可以用setuid函数设置实际用户ID和有效用户ID。可以用setgid函数设置实际组ID和有效组ID
#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
//两个函数返回值:若成功,返回0;若出错,返回-1
关于谁能更改ID有若干规则。现在先考虑更改用户ID的规则。
1. 若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID设置为uid。
2. 若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid。不更改实际用户ID和保存的设置用户ID
3. 如果上面两个条件都不满足,则errno设置为EPERM,并返回-1。
关于内核维护的3个用户ID,还要注意以下几点。
1. 只有超级用户进程可以更改实际用户ID。
2. 仅当对程序文件设置了设置用户ID位时,exec函数才设置有效用户ID。
3. 保存的设置用户ID是由exec复制有效用户ID而得到的。
为了防止被欺骗而运行不被允许的命令或读、写没有访问权限的文件,at命令和最终代表用户运行命令的守护进程必须在两种特权之间切换:用户特权和守护进程特权。
1. 程序文件是由root用户拥有的,并且设置其用户ID位已设置。
2. at程序做的第一件事就是降低特权,以用户特权运行。
3. at程序以我们的用户特权运行,直到它需要访问控制哪些命令即将运行,这些命令需要何时运行的配置文件时,at程序的特权会改变
4. 修改文件从而记录了将要运行的命令以及它们的运行时间以后,at命令通过调用seteuid,把有效用户ID设置为用户ID,降低它的特权。
5. 守护进程开始用root特权运行,代表用户运行命令,守护进程调用fork,子进程调用setuid将它的用户ID更改至我们的用户ID。
现在守护进程可以安全的代表我们执行命令,因为它只能访问我们通常可以访问的文件,我们没有额外的权限。
这种文件是文本文件,其起始行的形式是:
#!pathname [optional - argument]
在感叹号和pathname之间的空格是可选的。最常见的:
#! /bin/sh
pathname通常是绝对路径名,对它不进行什么特殊处理。对这种文件的识别是由内核作为exec系统调用处理的一部分来完成的。内核使调用exec函数的进程实际执行的并不是该解释器文件,而是在该解释器文件的第一行中pathname所指定的文件。一定要将解释器文件和解释器区分开来。
是否需要解释器呢?哪也不完全如此。但是它们确实使用户得到效率方面的好处,其代价是内核的额外开销。
1. 有些程序使用某种语言写的脚本,解释器文件可以将这一事实隐藏起来。
2. 解释器脚本在效率方面也提供了好处。
3. 解释器脚本使我们可以使用除/bin/sh以外的其他shell来编写shell脚本。
在程序中执行一个命令字符串很方便。
#include <stdlib.h>
int system(const char *cmdstring);
如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值,这一特征可以确定在一个给定的操作系统上是否支持system函数。
因为system在其实现中调用了fork、exec和waitpid,因此有3种返回值。
1. fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,并且设置errno以指示错误类型。
2. 如果exec失败,则其返回值如同shell执行了exit一样
3. 否则所有3个函数都成功,那么system的返回值是shell的终止状态,其格式已在waitpid中说明。
大多数UNIX系统提供了一个选项以进行进程会计(process accounting)处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包含总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。
超级用户执行一个带路径名参数的accton命令启用会计处理。会计记录写到指定的文件中,在Linux中,该文件是/var/account/pact;
会计记录结构定义在头文件 sys/acct.h 中。
typedef u_short copm_t
struct acct
{
char ac_flag;
char ac_stat;
uid_t ac_uid;
gid_t ac_gid;
dev_t ac_tty;
time_t ac_btime;
comp_t ac_utime;
comp_t ac_stime;
comp_t ac_etime;
comp_t ac_men;
comp_t ac_io;
comp_t ac_rw;
char ac_comm[8];
};
进程终止时写一个会计记录。这产生两个后果。
第一,我们不能获取永远不终止的进程的会计记录。
第二,在会计文件中记录的顺序对应于进程终止的顺序,而不是他们启动的顺序。
任一进程都可以得到其实际用户ID和有效用户ID及组ID。但是,我们有时希望找到运行该程序用户的登录名。我们可以调用getpwuid( getuid () ),但是如果一个用户有多个登录名,这些登录名又对应着同一个用户ID,又将如何呢?系统通常记录用户登录时使用的名字,用getlogin函数可以获取次登录名。
#include <unistd.h>
char *getlogin(void);
//返回值:若成功,返回指向登录名字字符串的指针;若出错,返回NULL
如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程。
调度策略和调度优先级是由内核确定的。进程可以用过调整nice值选择以更低优先级运行。只有特权进程允许提高调度权限。
进程可以通过nice函数获取或更改它的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值,但是getpriority还可以获取一组相关进程的nice值
#include <sys/resource.h>
int getpriority(int which, id_t who);
//返回值:若成功,返回-NZERO~NZERO-1 之间的nice值;若出错,返回-1
which参数可以取以下三个值之一:PRIO_PROCESS表示进程,PRIO_PGRP表示进程组,PRIO_USER表示用户ID。which参数控制who参数是如何解释的,who参数选择感兴趣的一个或多个进程。如果who参数为0,表示调用进程、进程组或者用户。当which设为PRIO_USER并且who为0时,使用调用进程的实际用户ID。如果which参数作用于多个进程,则返回所有进程中优先级最高的(最小的nice值)。
setpriority函数可用于为进程、进程组和属于特定用户ID的所有进程设置优先级。
#include <sys/resource.h>
int setpriority(int which, id_t who, int value);
返回值:若成功,返回0;若出错,返回-1
参数which和who与getpriority函数中相同。value增加到NZERO上,然后变为新的nice值。
我们可以度量的3个时间:墙上时钟时间,用户cpu时间和系统cpu时间。任一进程都可调用times函数获取它自己以及终止子进程的上述值。
#include <sys/times.h>
clock_t times(struct tms *buf);
//返回值:若成功,返回流失的墙上时钟时间;若出错,返回-1
此函数填写由buf指向的tms结构。
struct tms{
clock_t tms_utime;
clock_t tms_stime;
clock_t tms_cutime;
clock_t tms_cstime;
};
注意此结构没有包含墙上时钟时间。times函数返回墙上时钟时间作为其函数值。此值是相对于过去的某一时刻度量的,所以不能用其绝对值而必须使用其相对值。