[单刷APUE系列]第八章——进程控制[1]

山河永寂

目录

[单刷APUE系列]第一章——Unix基础知识[1]
[单刷APUE系列]第一章——Unix基础知识[2]
[单刷APUE系列]第二章——Unix标准及实现
[单刷APUE系列]第三章——文件I/O
[单刷APUE系列]第四章——文件和目录[1]
[单刷APUE系列]第四章——文件和目录[2]
[单刷APUE系列]第五章——标准I/O库
[单刷APUE系列]第六章——系统数据文件和信息
[单刷APUE系列]第七章——进程环境
[单刷APUE系列]第八章——进程控制[1]
[单刷APUE系列]第八章——进程控制[2]
[单刷APUE系列]第九章——进程关系
[单刷APUE系列]第十章——信号[1]

进程标识

在日常的开发使用过程当中,以及以往的开发经验,都应该知道进程是存在一个ID的,也就是进程ID(process ID),进程ID是唯一的,用以保证进程是唯一存在并且能被唯一获得。但是,在Unix系统中,进程ID是唯一的,但是在进程退出后,系统非常有可能将这个pid交付给新启动的进程,但是这样会导致新进程被误认为是之前存在的进程,所以现有的Unix系统有一个队列,用于pid的延时复用。
Linux系统是类Unix系统,它的实现也是很具有代表性的,大家都知道,Linux实际上应该叫GNU/Linux,而且Linux只是一个内核,当将这个内核和软件集合打包,就形成了一个Linux发行版,当然其中不包含各个发行商的修改内容,内核是启动后获得控制权,内核有一部分就形成了pid为0的进程,也叫作调度进程,没有什么卵用,然后pid为1的进程被启动,也就是其他进程的父进程,一般都是init程序,它负责启动整个Unix系统,并将系统根据配置文件引导到一个可使用的状态,init和前面的调度进程不一样,调度进程实际上是内核的一部分,而init是内核启动的一个普通进程,但是它拥有root权限,在苹果系统中,init进程被launchd进程替代,但是其作用也是差不多的。
除了上面的两个进程以外,还有很多和系统密切相关的内核进程,这些进程都以守护进程的形式常驻。系统提供了一系列函数用于获取当前进程的各项属性。

pid_t getpid(void);
pid_t getppid(void);

uid_t getuid(void);
uid_t geteuid(void);

gid_t getgid(void);
gid_t getegid(void);

上面6个函数,分别是获取当前进程pid、父进程pid、真实用户ID、有效用户ID、真实组ID和有效组ID。至于这些属性的解释,前面几章已经都提到过了,所以这里就不再解释。

进程派生

进程可以派生出子进程,这是一个很普遍的行为,Unix系统也为此提供了函数

pid_t fork(void);

Fork() causes creation of a new process.  The new process (child process) is an exact copy of the calling process (parent process) except for the following:

o   The child process has a unique process ID.

o   The child process has a different parent process ID (i.e., the process ID of the parent process).

o   The child process has its own copy of the parent's descriptors.  These descriptors reference the same underlying objects, so that, for instance,file pointers in file objects are  shared between the child and the parent, so that an lseek(2) on a descriptor in the child process can affect a subsequent read or write by the parent.  This descriptor copying is also used by the shell to establish standard input and output for newly cre-ated processes as well as to set up pipes.

o   The child processes resource utilizations are set to 0; see setrlimit(2).

这是一个很重要的函数,前面也使用过fork函数派生子进程,fork函数创建一个新进程,新进程是父进程的完整复制,当然,子进程的pid是重新生成的,子进程也拥有父进程pid作为ppid属性,子进程拥有父进程描述符的一份拷贝,但是实际上引用了相同的底层对象,所以实际上父进程子进程都是共享同样的文件对象,就像第三章里面讲到的,进程只维护了文件描述符和文件指针的映射,内核为所有的打开的文件维护了一个文件表,每个文件表项包含了文件状态标志、文件偏移量等等,在学习文件共享这块的时候,笔者还提到一个重点就是内核维护的文件表是为所有打开的文件,同一个文件被不同进程打开是两个文件表项,但是父子进程实际上是拷贝了完整的进程空间,所以说子进程拥有父进程的文件描述符和文件指针的映射,所以说,父子进程的文件描述符指向了同一个文件表项目,所以lseek这样的修改偏移量的函数会影响到父子进程,这个特性也被shell用于建立标准输入输出错误给新启动的进程。当然,子进程的资源限制和父进程是不同的,将被重置。
前面几章中提到,fork函数被调用一次,但是返回两次,子进程和父进程都会得到返回值,但是子进程得到的返回值是0,父进程的返回值则是子进程的pid。子进程复制了整个父进程的进程空间,例如堆和栈等,当然,这只是个副本,父子进程实际共享的只有正文段,这样可以节约空间,并且前面提到正文段实际上是只读的。
在实际的Unix系统实现中,常常使用差分存储的技术,也就是说,原来的堆栈不会被复制,两个进程以只读的形式共享同一个堆栈区域,当需要修改区域内容的时候,则在新的区域制作差分存储。

#include "include/apue.h"

int globalVar = 6;
char buf[] = "a write to stdout\n";

int main(int argc, char *argv[])
{
    int var;
    pid_t pid;
    
    var = 80;
    if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1)
        err_sys("write error");
    printf("before fork\n");
    
    if ((pid = fork()) < 0) {
        err_sys("fork error");
    } else if (pid == 0) {
        ++globalVar;
        ++var;
    } else {
        sleep(2);
    }
    
    printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globalVar, var);
    exit(0);
}

然后执行的结果如下

~/Development/Unix » ./a.out
a write to stdout
before fork
pid = 8905, glob = 7, var = 81
pid = 8904, glob = 6, var = 80
~/Development/Unix » ./a.out > temp.out
~/Development/Unix » cat temp.out
a write to stdout
before fork
pid = 8916, glob = 7, var = 81
before fork
pid = 8915, glob = 6, var = 80

很明显的可以看到两个现象,就是子进程修改了变量不会导致父进程的变化,还有就是输出到文件和输出到终端发生了区别。看上面的代码,可以发现使用write函数向标准输出写的时候,使用了sizeof(buf) - 1,这是因为strlen函数计算长度的时候是不计算终止的null字节的,但是sizeof则会包括null字节,这个其实很好理解,strlen函数实际上是一个函数调用,为了保证开发者使用的便捷,所以默认认为字符串长度实际上不应该包含null字节,但是sizeof则是一个单目运算符,它和其他的运算符一样都不是函数,sizeof操作符以字节形式给出了其操作数的存储大小。操作数可以是一个表达式或括在括号内的类型名。操作数的存储大小由操作数的类型决定。换言之,这是一个编译时计算。
在第三章中讲到,write函数是不带缓冲的IO,而标准C库提供的则是带有缓冲的,在前面的章节中也提到了缓冲的不同情况,如果标准输出是连接到终端设备,那么它是行缓冲的,否则就是全缓冲的,在标准输出是终端设备的情况下,我们只看到了一行输出,因为换行符冲洗了缓冲区,而当标准输出重定向到文件的时候,输出是全缓冲的,这样换行符不会导致系统的自动写入,当fork函数执行的时候,这行输出依旧被存储在缓冲区中,然后随着fork函数被共享给了子进程,随着后续继续的写入,两个进程都同时写入了before fork字符串。
实际上,文件共享一直是很重要的概念,我们知道,用户启动的进程一般都是shell启动的,也就是说是shell的子进程,所以shell将进程的输入输出可以进行重定向,当父进程的标准输入输出被重定向的时候,由于子进程继承了父进程的文件描述符,所以子进程也被重定向了,前面也说过,父子进程相同的文件描述符是指向同一个文件表项的,由于这个原因,两者任意一个进程修改了偏移量,下一个进程会跟在这个偏移量后,可以变相的实现一种交互。当然我们知道,由于多进程操作系统调度,进程之间的切换是很频繁的,如果没有父子进程的同步措施,两者的输出很有可能混合,所以对于派生子进程,有以下两种方式处理文件描述符

  1. 父进程使用函数等待子进程完成。这个非常简单,由于共享同一个文件表项,子进程的输出也会更新父进程的偏移量,所以等待子进程完成后直接就能读写。

  2. 父进程和子进程各自执行不同的程序段。在这种情况下,在fork以后,父子进程各自只使用不冲突的文件描述符。

前面也提到过很多关于父子进程的继承,例如各种用户组ID,当前工作目录,资源限制环境变量等,通常情况下,使用fork函数有两种原因

  1. 父进程复制自身,各自执行不同的代码段,也就是网络服务中典型的多进程模型。

  2. 一个进程想要执行不同的程序。shell就是这样的,所以子进程可以在fork后立刻使用exec,让新程序运行。

进程派生变体

pid_t vfork(void);

Vfork() can be used to create new processes without fully copying the address space of the old process, which is horrendously inefficient in a paged envi-ronment.  It is useful when the purpose of fork(2) would have been to create a new system context for an execve.  Vfork() differs from fork in that the child borrows the parent's memory and thread of control until a call to execve(2) or an exit (either by a call to exit(2) or abnormally.)  The parent process is suspended while the child is using its resources.

Vfork() returns 0 in the child's context and (later) the pid of the child in the parent's context.

Vfork() can normally be used just like fork.  It does not work, however, to return while running in the childs context from the procedure that called vfork() since the eventual return from vfork() would then return to a no longer existent stack frame.  Be careful, also, to call _exit rather than exit if you can't execve, since exit will flush and close standard I/O channels, and thereby mess up the parent processes standard I/O data structures.  (Even with fork it is wrong to call exit since buffered data would then be flushed twice.)

vfork函数也是创建一个新进程,但是不完全拷贝父进程地址空间,这个函数spawn new process in a virtual memory efficient way,实际上这个函数主要用于spawn一个新进程而做的优化,不需要复制父进程的地址空间,从而加快了函数的执行,除此以外,vfork函数还会保证子进程先运行,

#include "include/apue.h"

int globalVar = 6;

int main(int argc, char *argv[])
{
    int var;
    pid_t pid;
    
    var = 88;
    printf("before vfork\n");
    if ((pid = vfork()) < 0) {
        err_sys("vfork error");
    } else if (pid == 0) {
        ++globalVar;
        ++var;
        _exit(0);
    } else {
        printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globalVar, var);
    }
    exit(0);
}

然后运行这个程序

~/Development/Unix » ./a.out
before vfork
pid = 10616, glob = 7, var = 89

从运行结果可以很清楚的看到,子进程对变量的改变确实更改了父进程的变量值,其次,在上面的代码中,子进程使用了_exit函数关闭进程,在前面我们可以了解到,exit函数会在关闭进程之前进行一系列的操作,而子进程实际上是和父进程共享同一个内存空间,所以很可能会导致没有任何输出,所以在vfork函数只是用于spawn一个进程的前置操作,而不是正常的派生子进程,这个也在Unix系统手册中给予了警告。

退出进程

就像前面一章讲的,有5种正常退出和3种异常终止,下面是5中正常退出

  1. main函数返回,实际上等效于调用exit

  2. exit函数。exit函数实际上是ISO C定义的函数,在前面也有过详细的工作流程描述

  3. 调用_exit和_Exit函数。两者可以当做等价,只是一个是ISO C库函数,一个是Unix系统函数

  4. 进程的最后一个线程执行return语句

  5. 进程的最后一个函数使用pthread_exit函数

3种异常终止如下

  1. 调用abort函数产生SIGABRT信号

  2. 进程接收到信号

  3. 进程接收到取消请求

无论是如何退出进程,实际上在最后都需要内核进行执行清理工作,包括打开的描述符什么的,对于上面5中正常退出,都会有一个退出状态可以传递,对于3种异常终止,内核同样会产生一个终止状态,最终,都会变成退出状态,这样父进程就能得到子进程的退出状态。
在正常的使用过程中,子进程都是先于父进程退出,但是在某些特殊情况下,父进程会先于子进程结束,但是实际上在终止每个进程的时候,内核会检查所有现有的进程,如果是正在终止的进程的子进程,就将其父进程修改为init进程,也就是pid为1的进程。
在Unix系统运维中,会碰到僵尸进程,在开发的概念上来说,就是子进程已经终止,但是父进程尚未对其进行善后处理。

wait函数族

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

实际上,当一个进程退出,无论是正常还是异常,内核都会向父进程发送SIGCHLD信号,在默认情况下,都是选择忽略这个信号,这里只需要知道使用wait函数族会发生什么

The wait() function suspends execution of its calling process until stat_loc information is available for a terminated child process, or a signal is received.  On return from a successful wait() call, the stat_loc area contains termination information about the process that exited as defined below.

wait函数会阻塞父进程知道子进程终止或者受到SIGCHLD信号,当wait函数返回时,stat_loc将会包含进程结束信息。
waitwaitpid函数就在于参数的区别,waitpid可以传入一个options选项用于行为的改变,还有就是可以指定进程ID。wait函数则是等待直到第一个子进程退出.

The options parameter contains the bitwise OR of any of the following options.  The WNOHANG option is used to indi-cate that the call should not block if there are no processes that wish to report status.  If the WUNTRACED option is set, children of the current process that are stopped due to a SIGTTIN, SIGTTOU, SIGTSTP, or SIGSTOP signal also have their status reported.

WNOHANG参数指示没有进程报告状态则立即返回,WUNTRACED选项则是子进程由于SIGTTINSIGTTOUSIGTSTPSIGSTOP信号进入暂停状态,还有一个是WCONTINUED,头文件中存在,但是说明手册上不存在,由POSIX1.x规定。

The following macros may be used to test the manner of exit of the process.  One of the first three macros will evaluate to a non-zero (true) value:
WIFEXITED(status)
        True if the process terminated normally by a call to _exit(2) or exit(3).

WIFSIGNALED(status)
        True if the process terminated due to receipt of a signal.

WIFSTOPPED(status)
        True if the process has not terminated, but has stopped and can be restarted.  This macro can be true only if the wait call specified the WUNTRACED option or if the child process is being traced (see ptrace(2)).

Depending on the values of those macros, the following macros produce the remaining status information about the child process:

WEXITSTATUS(status)
        If WIFEXITED(status) is true, evaluates to the low-order 8 bits of the argument passed to _exit(2) or exit(3) by the child.

WTERMSIG(status)
        If WIFSIGNALED(status) is true, evaluates to the number of the signal that caused the termination of the process.

WCOREDUMP(status)
        If WIFSIGNALED(status) is true, evaluates as true if the termination of the process was accompanied by the creation of a core file containing an image of the process when the signal was received.

WSTOPSIG(status)
        If WIFSTOPPED(status) is true, evaluates to the number of the signal that caused the process to stop.

上面就是苹果系统支持的一系列宏,原著中的WIFCONTINUED宏没有出现在说明手册中,但是实际上在头文件中是存在的。

#include "include/apue.h"
#include <sys/wait.h>

void pr_exit(int status)
{
    if (WIFEXITED(status))
        printf("normal termination, exit status = %d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("abnormal termination, signal number = %d%s\n", WTERMSIG(status),
#ifdef WCOREDUMP
        WCOREDUMP(status) ? " (core file generated)" : "");
#else
        "");
#endif
    else if (WIFSTOPPED(status))
        printf("child stopped, signal number = %d\n", WSTOPSIG(status));
}

上面是原著提供的打印终端终止状态的函数,可以按照以前的方法将其打包为静态库。需要注意的是,你需要指定-D_DARWIN_C_SOURCE来保证编译添加上WCOREDUMP支持。

#include "include/apue.h"
#include <sys/wait.h>

int main(int argc, char *argv[])
{
    pid_t pid;
    int status;
    
    if ((pid = fork()) < 0)
        err_sys("fork error");
    else if (pid == 0)
        exit(7);
    
    if (wait(&status) != pid)
        err_sys("wait error");
    pr_exit(status);
    
    if ((pid = fork()) < 0)
        err_sys("fork error");
    else if (pid == 0)
        abort();
    
    if (wait(&status) != pid)
        err_sys("wait error");
    pr_exit(status);
    
    if ((pid = fork()) < 0)
        err_sys("fork error");
    else if (pid == 0)
        status /= 0;
    
    if (wait(&status) != pid)
        err_sys("wait error");
    pr_exit(status);
    
    exit(0);
}

编译运行

> ./a.out
normal termination, exit status = 7
abnormal termination, signal number = 6
abnormal termination, signal number = 8

并没有像原著一样出现(core file generated)字样,可能是因为系统虽然支持这个宏,但是只对少数错误会进行转储。
waitpid函数的options选项的三个可选值实际上起到了两种作用,WNOHANG是非阻塞,而其他两个参数则是作业控制。

阅读 3.4k

静雅斋
码代码|Minecraft|Node.jser&PHPer|iOS移动端开发者|Web前端码农
2.4k 声望
157 粉丝
0 条评论
2.4k 声望
157 粉丝
文章目录
宣传栏