UNIX Process Control

概述

这一节主要介绍UNIX系统的进程控制,包括进程创建,进程执行和进程控制。以及进程属性的各种的ID-real UID, real GID和effective UID, effective GID和save UID, set UID和set GID,以及它们如何收到进程控制原语的影响。

进程标识(pid)

每一个进程都有一个非负整数表示它的唯一进程ID。进程ID标识符总是唯一的,但是可以复用。
关于和进程ID相关的内容,可以查看

fork

fork创建一个子进程。函数原型:

fork原型

1
2
3
#include <unistd.h>

pid_t fork(void);

fork性质

  1. 进程IDfork调用一次,返回两次,分别是0和子进程ID,用以区别父进程和子进程。对于父进程,返回子进程ID,对于子进程,返回0。因为父进程可能有多个子进程,并且没有提供获得一个进程所有子进程ID的函数,而fork只有一个父进程,可以通过getppid获得它的父进程的ID。所以这样子进行区分。
  2. 子进程和父进程分别继续执行调用fork之后的指令。子进程是父进程的副本。子进程获得父本的数据段,堆和栈的完全副本。这是子进程的副本,和父进程不一样,它们并不共享数据的内存空间,但是它们共享text segment。
  3. 现代的操作系统实现,使用写时复制代替了父进程数据段,堆和栈的完全副本。这些区域是由父进程和子进程共享的,但是它们的访问权限是只读。如果父进程或者子进程想要对这些区域进行修改的话,内核会为修改区域的那块内存制作一个副本,用于进程修改。
  4. 父进程和子进程因为不共享数据,堆和栈,每个进程都有自己的变量,不会相互影响。
  5. 执行顺序fork后父进程和子进程的执行顺序是不确定的,这跟内核的调度算法有关。如果要求父进程和子进程之间进行同步,需要它们之间进行某种形式的进程通信。
  6. 文件共享。对于父进程打开的文件,fork相当于将父进程的文件描述符都复制到了子进程中,相当于对父进程的每一个文件描述符,都调用了dup函数。父进程和子进程每个相同的打开文件描述符共享同一个文件表项。一般来说,在fork之后处理文件描述符有以下两种情况:
    • 父进程等待子进程完成。父进程不需要对它的文件描述符做任何处理。
    • 父进程和子进程分别执行不同的程序段。父进程和子进程各自关闭它们不需要的文件描述符。
  7. fork后子进程继承的信息
    • real UID, real GID, effective UID, effective GID
    • set UID和 set GID
    • 附属组ID
    • 进程组ID
    • Session ID
    • 控制终端
    • cwd
    • root dir
    • umask
    • signal mask
    • 文件描述符标志
    • 环境
    • 共享的内存段
    • 内存映像
    • Resource limits
  8. 父进程和子进程的区别。
    • fork的返回值不同
    • pid不同
    • 它们有不同的ppid
    • 子进程的很多时间设置为0
    • 父进程设置的文件锁子进程不继承。
    • 子进程的未处理闹钟被清除
    • 子进程的未处理信号被设置为空集。
  9. fork的两种用法
    • 父进程和子进程分别执行不同的代码。比如网络服务中,父进程负责等待客户端请求,子进程负责处理父进程接收到的请求。
    • 一个进程要执行不同的程序。对shell比较常见,通常执行完fork返回后立即调用exec

vfork

创建一个子进程,并且阻塞父进程,函数原型如下:

vfork原型

1
2
3
4
#include <sys/types.h>
#include <unistd.h>

pid_t vfork(void);

vfork属性

  1. vforkfork都创建一个新进程,但是vfork并不会将父进程的地址空间完全复制到子进程中。因为子进程会立即调用exec或者exit,就不会引用该地址空间。但是如果在调用exec或者exit之前,它会在父进程的空间中运行,这种做法会提高效率。但是如果子进程修改除了vfork的返回值,或者在没有调用exit或者exec之前调用其他函数,这种行为是未定义的。
  2. vfork保证子进程先运行,在子进程没有调用exec或者exit时,内核会使父进程休眠,在子进程调用exec或者exit这两个中的任何一个后,父进程才会恢复运行。如果子进程需要父进程进一步操作的时候,就会产生死锁。

exit函数

关于exit函数的介绍,可以查看C/C++ exit and return
总共有八种方式可以让进程终止,包括五种正常和三种异常,前五种是正常终止,后五种是异常终止:

  1. main返回,相当于调用exit
  2. 调用exit,ISO C定义的,它的操作包括调用各个exit handler,处理所有标准I/O流。exit会冲洗标准I/O流,如果这是函数库所采取的唯一的动作,那么不会出现什么问题。而如果exit除了冲洗标准I/O流,还会关闭I/O流,那么在vfork时就会出问题了。当然,现在的exit实现都不会关闭流了,这个操作一般都交给内核实现。
  3. 调用_exit或者_Exit,ISO C定义了_Exit,而POSIX.1说明了_exit。它的目的是提供一种无需运行exit handler或者信号处理程序而终止的方法。是否对标准I/O流进行flush,取决于实现。在UNIX中,_Exit_exit是同义的,并不冲洗I/O流。
  4. 最后一个线程从其启动例程返回
  5. 最后一个线程调用pthread_exit
  6. 调用abort
  7. 接到一个signal
  8. 最后一个线程对取消请求做出响应

不管进程以哪种方式终止,最后都会执行内核中的同一段代码,这段代码为相关进程关闭所有打开的文件描述符,释放它使用的内存。
为了让终止进程能够通知父进程它是如何终止的。对于3个终止函数,将它的exit status作为参数传递给函数。在异常终止的情况下,内核产生一个指示其异常终止原因的terminaiton status(终止状态)。在任意终止情况下,这个终止进程的父进程都能用wait或者waitpid函数获得它的终止状态。
如果父进程在子进程之前终止,所有终止进程的子进程的父进程都变成init进程,init进程负责获得终止状态。对于一个即将终止的进程,内核检查所有活动进程,判断其中是否有待终止进程的子进程,如果有的话,将这些进程的父进程的ID改为init进程的PID 1。
如果子进程在父进程之前终止。内核为每一个终止进程保留了一部分信息,当终止进程的父进程调用wait或者waitpid时,可以获取这些信息,这些信息包含终止进程PID,进程的终止状态,进程占用的CPU时间总量。内核可以释放这些进程的内存,关闭打开的文件。如果一个进程终止了,但是它的父进程没有等待它,它被称为一个zombie(僵尸)进程,这些信息不会被释放。如果一个长期运行的进程,fork了很多子进程,除非父进程调用wait得到子进程的终止状态,否则它们就会变成僵尸进程。
init的子进程,不会变成僵尸进程,因为init进程被编写成无论何时只要有一个子进程终止,init就会调用一个wait函数获得其终止状态。

waitwaitpid

当一个进程终止的话(无论正常还是异常),内核就会向它的父进程发送SIGCHLD信号。而子进程终止是个异步事件,可以在父进程运行的任何时候发生。对于这种信号,父进程可以忽略它,或者调用一个信号处理函数。
调用了wait或者waitpid的进程,可能会处于以下几种状态之一:

  1. 所有子进程都还在运行,则阻塞。
  2. 一个子进程已经终止,正在等待父进程获取其终止状态,那么取得该子进程的终止状态立即返回。
  3. 如果它没有任何子进程,立即出错返回。

如果进程由于接收到SIGCHLD而调用wait,我们期望wait会立即返回。如果在随机的时间点调用wait,进程可能会阻塞。

waitwaitpid原型

1
2
3
4
#include <sys/wait.h>

pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);

waitwaitpid性质

  1. 在一个子进程终止前,wait使其调用者阻塞,直到任意一个子进程终止。wait返回终止子进程的进程ID。如果wait要等待一个特定的进程,将返回的pid和要等待的pid相比,如果不相等,将这个pid和termination status保存起来,再次调用wait,直到等到目标pid。下一次想要等待一个特定进程的时候,现场看已经终止的进程列表中是否已有它 ,没有的haunted继续调用wait
  2. ISO中定义了waitpid中option的四个可能值:0,WNOHANG, WUNTRACEDWCONTINUED。POSXI扩展了许多其他选项。其中0表示阻塞调用,WNOHANG表示如果没有子进程结束就立刻退出,WUNTRACED表示如果一个子进程停止了也返回,WCONTINUED表示一个子进程恢复运行了也会返回。
  3. 指定option为0时,设置waitpid阻塞等待指定的进程pid。当pid为-1时,waitpidwait一样。当pid大于或者小于0时,等待相应的pid(绝对值)。当pid等于0时,等待gid等于调用进程组id的任意一个子进程。
  4. 指定option为WNOHANG,设置waitpid不阻塞,表示如果没有子进程结束,就立刻返回。在Linux上,WNOHANG是1。
  5. 指定option为WUNTRACEDWCONTINUED,设置waitpid支持job control。
  6. 对于wait,只有当调用进程没有子进程时,才出错。对于waitpid,指定的进程或者进程组不存在,或者参数pid不是调用进程的子进程时,都会出错。
  7. wstatus是一个整形指针。如果它不为空指针,终止进程的终止状态就存放在它所指的单元内。
  8. wstatus指向的整形变量的意义是由实现定义的,其中的某一些位表示exit status,即正常退出。另外一些位表示signal number,表示不正常退出,一位表示是否产生core file,等等。POSIX.1指定了termination status可以用<sys/wait.h>中定义的宏查看。四个互斥宏可以用来取得进程终止的原因:
    • WIFEXITED(status),如果status是一个正常终止子进程返回的,为true。执行WEXITSTATUS(statue)获取子进程传递给exit或者_exit的参数的低八位。
    • WIFSIGNALED(status),如果status是一个异常终止子进程返回的,为true。执行WTERMSIG(status)获取使得子进程终止的signal。
    • WIFSTOPPED(status),如果status是一个当前暂停的子进程返回的,为true。执行WSTOPSIG(status)获取使得子进程暂停的signal。
    • WIFCONTINUED(status),。
  9. fork两次可以让原始进程不用自己调用wait,也可以避免产生僵尸进程。

waitid

waitid原型

1
2
3
#include <sys/wait.h>

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

waitid性质

  1. waitid是SUS指定的,不是ISO C的部分。
  2. waitidwaitpid类似,但是它使用两个单独的参数表示要等到的子进程所属的类型,用idtype表明id的类型,用id表示pid或者进程组id。idtype的取值如下:
    • P_PID,等待特定进程,id指定要等待的子进程的pid
    • P_PGID,等待特定进程组中的任一子进程,id是包含要等待子进程的组ID
    • P_ALL,等待任意子进程,忽略id。
  3. options参数是以下标志的按位或运算。
    • WCONTINUED
    • WEXITED
    • WNOHANG
    • WNOWAIT
    • WSTOPPED
  4. siginfo_t结构体包含了子进程状态改变有关signal的详细信息。

wait3wait4

大多数UNIX系统都支持wait3wait4,它们是从BSD延续下来的。它们的功能比POSIX.1函数wait, waitpidwaitid要多一个。可以通过附加参数允许内核返回终止进程以及其所有子进程使用的资源概况。包含用户CPU时间总量,系统CPU时间总量,缺页次数,接收到的signal次数。
它们的原型如下:

wait3wait4原型

1
2
3
4
5
6
7
#include <sys/types.h>
#include <sys/time.h>
#include <sys/resource.h>
#include <sys/wait.h>

pid_t wait3(int *wstatus, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *wstatus, int options, struct rusage *rusage);

race condition

如果多个进程都企图对共享数据进行某种处理,而且最后的结果取决于进程运行的顺序时,我们认为发生了race condition。
如果一个进程希望等待子进程终止,可以调用wait函数中的一个。如果一个子进程想要等待父进程终止,可以使用下列形式的循环,称为轮询(polling)

1
2
3
4
while(getppid() != 1)
{
sleep();
}

这种方式浪费了很多CPU时间。为了避免这些问题,可以使用signal或者进程间通信解决这些问题。

exec

通常使用fork创建新的子进程之后,子进程往往会调用一种exec函数执行另一个程序。当进程调用exec函数时,该进程执行的程序完全替换为新程序,而新程序从其main函数开始执行。调用exec并不会创建新进程,所以前后的进程ID不变。exec使用磁盘上的一个新程序替换了当前进程的text segment, data segment, heap和stack。exec函数只有在出错的时候才返回-1,并且设置errno
总共有七种不同的exec函数,它们被统称为exec函数。它们的原型如下:

exec函数原型

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <unistd.h>

extern char **environ;

int execl(const char *path, const char *arg, ... /* (char *) NULL */);
int execv(const char *path, char *const argv[]);
int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */);
int execve(const char *filename, char *const argv[], char *const envp[]);

int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
int fexecve(int fd, char *const argv[], char *const envp[]);

exec函数属性

  1. 传入参数的区别。 path是路径名作为参数,file是文件名作为参数。如果path中包含'/',将它看成路径名。否则按照PATH环境变量,在它指定的目录中搜寻可执行文件。如果函数execlpexecvp在PATH指定的目录中找到的文件不是link editor产生的可执行文件,就会把它当做一个shell脚本,调用/bin/sh,把这个文件当做shell的输入。
  2. argmuent list的区别。 l表示的是列表,v表示的是向量。execl, execlpexecle要求将新程序的每个命令行参数都说明为一个单独的参数,这种参数表以空指针结尾。而execv,execvp, execvefexecve,需要先构造一个指向各个参数的指针数组,然后将该数组的地址作为这四个函数的参数。
  3. environment list的区别。 以e结尾的函数,execve, execvpe,execle, fexecve等可以传递一个指向environment字符串指针数组的指针,这个是自己指定的环境。其他几个不带e的函数使用进程中的environ变量为新程序复制现有的环境。
  4. 调用exec后,进程ID没有改变,但是新程序从调用进程继承了以下属性:
    • pid和ppid
    • real UID, real GID,
    • 附属组ID
    • 进程组ID
    • Session ID
    • 控制终端
    • 闹钟余留时间
    • cwd
    • root dir
    • umask
    • 文件锁
    • 进程信号屏蔽
    • 未处理信号
    • 资源限制
    • nice值
    • 时间
  5. exec前后,real UID和real GID不变,effective ID取决于是否设置set UID和set GID。如果新程序的set UID已经设置,则effective ID变成程序文件所有者的ID,否则不变。
  6. 这几个函数中,只有execve是系统调用,其他几个都只是库函数,最终都要调用execve

解释器文件

关于解释器文件,可以查看

system

关于system的介绍,可以查看

进程会计

大多数UNIX系统都提供了一个选项进行进程会计处理。启动该选项之后,每当进程结束时内核就会写一个会记记录。典型的会计记录是一个二进制数据,一般包括命令名,所有的CPU时间总量,UID和GID,启动时间等。所有的标准都没有定义进程会记,所以实现上就千差万别。
acct函数启用和关闭进程会计。
会记记录结构定义在头文件<sys/acct.h>struct acct中,其中ac_flag标志记录了进程执行期间的某些事件:

  • AFORK,进程是fork产生的,但是未调用exec
  • ASU,进程使用superuser权限
  • ACORE,进程转储到core
  • AXSIG,进程由一个signal杀死
  • AEXPND,扩展的会计条目
  • ANVER,新纪录格式

在LINUX上,ac_flag是枚举类型,所以不能使用#ifdef判断是否支持ACCORE等flag,可以使用if !defied HAS_ACCORE进行判断。

会计记录所需的各个数据(各CPU时间,传输的字符数等)都由内核保存在process table中,并在一个新进程被创建时初始化,进程终止时写一个会计记录。这产生了两个后果:

  1. 对于那些不会终止的进程,比如init进程,我们无法获得它的会计记录。内核守护进程也不会终止,所以也不会产生会计记录。
  2. 在会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。

会记记录对应于进程而不是程序。在fork之后,内核为子进程初始化一个记录,而不是在一个新程序被执行初始化时。exec并不会创建一个新的记录,但是相应记录中的名字会改变,AFORK标志没了。

获得当前登录用户名

可以使用getlogin获得当前登录用户的用户名。

1
2
3
#include <unistd.h>

char *getlogin(void);

进程调度

调度策略和调度优先级是由内核确定的。

nice, getpriority, setpriority原型

1
2
3
4
5
6
7
8
#include <unistd.h>

int nice(int inc);

#include <sys/resource.h>

int getpriority(int which, id_t who);
int setpriority(int which, id_t who, int prio);

nice, getpriority, setpriority属性

  1. nice函数将输入的参数加到当前的nice值上。nice值越大,优先级越低,否则越高。
  2. 在单核的机器中,同时运行一个父进程和一个子进程,它们的nice值不同的话,CPU占用比也可能会不同,这取决于进程调度程序如何使用nice值。在多核的机器上可能看不到这样的结果。

进程时间

可以使用times获得某进程和它的子进程的CPU时间以及墙上时钟时间,times通过struct tms传递信息。它的内容如下:

1
2
3
4
5
6
struct tms {
clock_t tms_utime;
clock_t tms_stime;
clock_t tms_cutime;
clock_t tms_cstime;
};

它包含进程的用户CPU时间,系统CPU时间和子进程的用户CPU时间和系统CPU时间。但是不包含墙上时钟时间,墙上时钟时间是通过函数的返回值得到的,而且得到的时间是相对于过去某个时间点得到的,所以不能使用它的绝对值,要使用相对值。比如第一次调用times,记录返回值,等到下一次调用times时,用新的值减去刚才保存的值,得到墙上时间时间。
所有时间(结构体和返回值)的单位都是滴答数。
函数原型如下:

1
2
3
#include <sys/times.h>

clock_t times(struct tms *buf);

shell的time(1)可以使用times(2)实现,看程序。

参考文献

1.《APUE》第三版