Unix环境高级编程 读书笔记 第八章 进程控制

进程标识

每个进程都有一个非负整数来作为进程ID标识,进程ID标识总是唯一的。

进程ID为0的进程,通常是调度进程,也被称为交换进程(swapper),该进程是内核的一部分,而不是磁盘上的某个程序。

进程ID为1的进程是init进程,在系统启动过程中由内核调用。init进程不会终止,以超级用户特权运行。

除了进程ID,还有父进程ID,进程的实际用户ID,进程的实际组ID,进程的有效用户ID,进程的有效组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

一个现有的进程可以调用函数fork创建一个子进程。函数原型为:

#include <unistd.h>
pid_t fork(void);
/*子进程返回0,父进程返回创建的子进程ID,若出错,返回-1*/

函数fork调用一次,但是返回两次。

对于子进程,函数fork返回的是0,因为一个进程只有一个父进程,进程可以调用getppid获得其父进程的ID,并且进程ID为0的进程是内核的交换进程,所以一个新创建的子进程ID不可能为0。

对于父进程,函数fork返回是新创建的子进程ID,将子进程ID返回给父进程的理由是,因为一个进程的子进程可以有多个,且没有函数可以获得一个进程的所有子进程ID。

调用函数fork创建子进程后,子进程是父进程的副本,子进程获得父进程的数据空间,堆与栈的副本。子进程与父进程共享正文段。现在技术使用写时复制的策略,使得在写操作之前,实际子进程与父进程共享数据空间、堆与栈等。

在函数fork之后,子进程先运行还是父进程先运行是不确定的。取决于内核的调度算法。

在fork之后,父进程的所有打开文件描述符都被复制到子进程中,子进程与父进程每个相同的打开文件描述符共享同一个文件表项。因此,子进程与父进程共享同一个文件偏移量。
在这里插入图片描述
如果子进程与父进程写同一个文件描述符指向的文件,而又没有任何同步的手段,那么他们的输出将混淆在一起。

在fork之后处理文件描述符有以下2种情况:

  1. 父进程等待子进程完成。当子进程终止后,其文件偏移量已经作了更新。
  2. 父进程和子进程各自执行不同的程序段。父进程和子进程各自关闭不需要使用的文件描述符。

除了打开的文件描述符,由子进程继承的父进程属性还包括:

  1. 实际用户ID、实际组ID、有效用户ID、有效组ID;
  2. 附属组ID;
  3. 进程组ID;
  4. 会话ID;
  5. 控制终端;
  6. 设置用户ID标志、设置组ID标志;
  7. 当前工作目录;
  8. 根目录;
  9. 文件模式创建屏蔽字;
  10. 信号屏蔽与安排;
  11. 环境变量;
  12. 资源限制;
  13. 存储映像。

对于子进程与父进程的区别为:

  1. 调用fork函数的返回值不同;
  2. 进程ID不同;
  3. 进程的父进程ID不同;
  4. 子进程的tms_utime、 tms_stime、tms_cutime、tms_ustime的值设置为0;
  5. 子进程不继承父进程设置的文件锁;
  6. 子进程的未处理闹钟被清除;
  7. 子进程的未处理信号集设置为空集。

使得fork函数调用失败的原因包括:

  1. 系统中已经有太多的进程;
  2. 该实际用户ID的进程总数超出了系统限制。

fork函数具有以下2种用法:

  1. 一个父进程希望复制自己,使得父进程与子进程同时执行不同的代码段,这在网络服务进程中比较常见,父进程等待客户端的请求,当请求到达时,父进程fork一个子进程对请求进行处理,父进程则继续等待下一个请求。
  2. 一个进程要执行不同的程序。比较常见的例子是shell程序。子进程从fork返回后立马执行exec函数。

函数vfork

函数vfork的调用序列和返回值与fork相同,但是两者的语义不相同。

vfork函数用于创建一个新进程,而该新进程的目的是exec一个新的程序。因此vfork与fork一样会创建一个子进程,但是其不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec执行新的程序。

不过在子进程调用exec之前,子进程是在父进程的地址空间中运行的。这样的处理提高了执行效率。但是带来的风险是,如果子进程没有立即调用exec,而是修改数据,进行函数调用,则可能带来未知的结果。

vfork函数与fork函数的另一个区别是:vfork函数保证子进程先运行,在子进程调用exec或者exit之后,父进程才可能被调度运行。

可移植的应用程序不应该使用vfork函数。

函数exit

进程的5种正常终止方式如下:

  1. 在main函数内执行return语句,等效于调用exit函数;
  2. 调用exit函数。其操作包括调用各种终止处理程序,关闭所有的标准IO流等;
  3. 调用_exit或者_Exit函数,目的是为进程提供一种无需运行终止处理程序或者信号处理程序而终止的方法;
  4. 进程的最后一个线程在其启动例程中执行return语句;
  5. 进程的最后一个线程调用pthread_exit函数。

进程的3种异常终止方式如下:

  1. 调用abort;
  2. 当进程接收到某些信号时;
  3. 进程的最后一个线程对“取消”请求作出响应。

终止进程的父进程能够通过wait或者waitpid函数获取终止进程的终止状态。

对于父进程已经终止的进程,其父进程将变为init进程。

在UNIX系统中,一个已经终止,但是其父进程尚未获取终止子进程的有关信息,释放其占用的资源的进程,被称为僵死进程

函数wait和waitpid

当一个进程正常终止或者异常终止时,内核向其父进程发送SIGCHLD信号,发送的是异步的通信信号。

父进程调用函数wait或者waitpid等待子进程终止状态时:

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

函数原型为:

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
/*若成功,返回子进程ID,若出错,返回0或-1*/

两个函数的区别为:

  1. 在子进程终止之前,wait使得调用的父进程阻塞,而waitpid的选项可使得调用的父进程不阻塞;
  2. wait等待其被调用后的第一个终止的子进程,而waitpid并不等待其调用后的第一个终止子进程,waitpid有若干的选项来控制其等待的特定的子进程。

两个函数的参数statloc是整型指针。如果statloc不是一个空指针,则终止的子进程的终止状态就存放在其所指向的存储单元,如果调用者不关心终止的子进程的终止状态,则可将statloc设置为NULL。

函数返回的整型状态字是由具体的实现来定义的。在这个整型数值中,某些位表示退出状态。某些位表示信号编号,某些位表示是否产生了core文件等不同的含义。为了统一各个实现的不同定义,标准规定了一些通用的宏定义来查看这些终止状态字在对应实现上的含义,这些宏在sys/wait.h中定义。

/* This will define all the `__W*' macros.  */
# include <bits/waitstatus.h>

# define WEXITSTATUS(status)    __WEXITSTATUS (__WAIT_INT (status))
# define WTERMSIG(status)       __WTERMSIG (__WAIT_INT (status))
# define WSTOPSIG(status)       __WSTOPSIG (__WAIT_INT (status))
# define WIFEXITED(status)      __WIFEXITED (__WAIT_INT (status))
# define WIFSIGNALED(status)    __WIFSIGNALED (__WAIT_INT (status))
# define WIFSTOPPED(status)     __WIFSTOPPED (__WAIT_INT (status))
# ifdef __WIFCONTINUED
#  define WIFCONTINUED(status)  __WIFCONTINUED (__WAIT_INT (status))
# endif
#endif  /* <stdlib.h> not included.  */

#ifdef  __USE_BSD
# define WCOREFLAG              __WCOREFLAG
# define WCOREDUMP(status)      __WCOREDUMP (__WAIT_INT (status))
# define W_EXITCODE(ret, sig)   __W_EXITCODE (ret, sig)
# define W_STOPCODE(sig)        __W_STOPCODE (sig)
#endif

以下4个宏可用来取得子进程终止的原因:
在这里插入图片描述

对于waitpid函数,参数pid的作用解释如下:

  1. 若pid等于-1,则函数waitpid等待任一个子进程;
  2. 若pid大于0,则函数waitpid等待进程ID与pid相同的子进程;
  3. 若pid等于0,则函数waitpid等待组ID等于调用的父进程组ID的任一个子进程;
  4. 若pid小于-1,则等待组ID等于pid绝对值的任一个子进程。

对于waitpid函数,参数options或者是0,或者是以下常量按照位或运算的结果:
在这里插入图片描述

另外,系统提供函数waitid来取得子进程的终止状态,此函数提供了更多的灵活性。

#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
/*若成功,返回0,若出错,返回-1*/

竞争条件

当多个进程都企图对同一个共享数据进行处理,而最后的结果取决于进程的运行顺序,则认为发生了竞争条件。

如果在fork之后的某种逻辑,会显式或者隐式的依赖于在fork之后是父进程先执行还是子进程先执行,则会有竞争,出现错误。

为了避免竞争条件的出现,在多个进程之间需要有某种形式的信号发送或者接收方法,使得父进程与子进程能够实现协调同步。

函数exec

当使用函数fork创建一个子进程后,子进程往往会执行另一个程序,当子进程调用exec系列函数时,该进程执行的程序会完全替换为新的程序。exec只是用磁盘上的一个新的程序替换了当前进程的正文段、数据段、堆与栈。

#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /*(char *)0*/);
int execv(const char *pathname, char *const argv[]);
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[]);
/*若成功,不返回,若出错,返回-1*/

对于此7个函数,比较难以记忆,需要对比其中的区别。

前4个函数取路径名作为参数,后2个函数取文件名作为参数,最后一个函数取文件描述符作为参数。

对于取文件名filename作为参数的函数(execlp、execvp),如果filename中包含/,则将其视为路径名。否则按照PATH环境变量中定义的各个路径搜索可执行文件。如果使用路径前缀中的一个找到了可执行文件,但是该文件不是机器可执行文件,则函数认为该文件是一个shell脚本,试着调用/bin/bash去执行该文件。

函数中有区别的是第2个入参,为传入的参数表,对于函数名中,l表示列表list,v表示矢量vector。函数execl、execlp与execle将新程序的每个命令行参数都说明为一个单独的参数列表,该参数列表以一个空指针(char *)0结尾。
而另外的4个函数(execv、execvp、execve与fexecve)则先构造一个指向各个参数的指针数组,然后将该数组的地址作为函数的入参。

函数中最后的区别是向新程序传递的环境表。以e结尾的3个函数(execle、execve与fexecve)可以传递一个指向环境字符串指针数据的指针。其他4个函数则使用调用进程中的environ1变量为新程序复制现有的环境。

便于记忆,需要注意到,字母p表示该函数取filename作为参数,并使用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数列表,其与字母v互斥,字母v表示该函数取一个argv[]矢量。字母e表示该函数取envp[]数组,而不使用调用者的环境。
在这里插入图片描述

在很多UNIX系统的实现中,这7个函数中只有execve是内核的系统调用,其余的6个函数只是库函数,最终都要调用execve这个系统调用。这7个函数的关系为:
在这里插入图片描述

更改用户ID与更改组ID

在UNIX系统中,对于资源的特权访问或者控制,是基于用户ID和组ID的,当程序需要增加特权,则需要更换自己的用户ID或者组ID。

可以使用函数setuid设置实际用户ID与有效用户ID,用函数setgid设置实际组ID与有效组ID。

#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
/*若成功,返回0,若出错,返回-1*/

关于更改用户ID的规则为:

  1. 若进程具有超级用户权限,则setuid函数将实际用户ID,有效用户ID,以及保存的设置用户ID设置为uid;
  2. 若进程没有超级用户权限,但是uid等于实际用户ID,或者保存的设置用户ID,则setuid函数只是将有效用户ID设置为uid,不更改实际用户ID和保存的设置用户ID;
  3. 若以上的2个条件都不满足,则errno设置为EPERM,并返回-1。

内核维护3个用户ID,分别是实际用户ID,有效用户ID,保存的设置用户ID。需要注意的是:

  1. 只有超级用户进程可以更改实际用户ID,实际用户ID是在用户登录时,由login程序设置,绝不会去改变他。
  2. 仅当对程序文件设置了设置用户ID位时,exec函数才会重新设置有效用户ID,若设置用户ID位没有被设置时,exec函数不会改变有效用户ID。可以调用函数setuid,将有效用户ID设置为实际用户ID或者保存的设置用户ID。
  3. 保存的设置用户ID是由exec函数复制有效用户ID而得到的。如果对程序文件设置了设置用户ID位时,则在exec函数时根据文件的用户ID设置了进程的有效用户ID后,这个有效用户ID的副本就被保存起来。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/jiangzhangha/article/details/85799619
今日推荐