from : https://segmentfault.com/a/1190000004436805

进程标识

在日常的开发使用过程当中,以及以往的开发经验,都应该知道进程是存在一个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函数派生子进程,fork函数创建一个新进程,新进程是父进程的完整复制,当然,子进程的pid是重新生成的,子进程也拥有父进程pid作为ppid属性,子进程拥有父进程描述符的一份拷贝,但是实际上引用了相同的底层对象,所以实际上父进程子进程都是共享同样的文件对象。

就像第三章里面讲到的,进程只维护了文件描述符和文件指针的映射,内核为所有的打开的文件维护了一个文件表,每个文件表项包含了文件状态标志、文件偏移量等等,在学习文件共享这块的时候,笔者还提到一个重点就是内核维护的文件表是为所有打开的文件,同一个文件被不同进程打开是两个文件表项,但是父子进程实际上是拷贝了完整的进程空间,所以说子进程拥有父进程的文件描述符和文件指针的映射,所以说,父子进程的文件描述符指向了同一个文件表项目,所以lseek这样的修改偏移量的函数会影响到父子进程,这个特性也被shell用于建立标准输入输出错误给新启动的进程。

当然,子进程的资源限制和父进程是不同的,将被重置。

前面几章中提到,fork函数被调用一次,但是返回两次,子进程和父进程都会得到返回值,但是子进程得到的返回值是0,父进程的返回值则是子进程的pid。子进程复制了整个父进程的进程空间,例如堆和栈等,当然,这只是个副本,父子进程实际共享的只有正文段,这样可以节约空间,并且前面提到正文段实际上是只读的。

在实际的Unix系统实现中,常常使用差分存储的技术,也就是说,原来的堆栈不会被复制,两个进程以只读的形式共享同一个堆栈区域,当需要修改区域内容的时候,则在新的区域制作差分存储。

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

#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函数族会发生什么。

wait函数会阻塞父进程知道子进程终止或者受到SIGCHLD信号,当wait函数返回时,stat_loc将会包含进程结束信息。

waitwaitpid函数就在于参数的区别,waitpid可以传入一个options选项用于行为的改变,还有就是可以指定进程ID。wait函数则是等待直到第一个子进程退出。

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

#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是非阻塞,而其他两个参数则是作业控制。

results matching ""

    No results matching ""