fork创建子进程之后资源如何分配?

运行中的进程的有效用户和程序文件的所属用户的区别?

运行中的进程都有什么权限?

函数system是否成功执行?

什么是进程会计?

进程标识

每个进程都有一个非负整数的唯一进程ID。

进程ID是可复用的。当进程终止,进程ID就是可复用的候选项。

Unix系统实现了延迟服用算法,使得赋予新建进程的ID不同于最近终止进程的ID。

每个Unix系统实现都有他自己的一套提供操作系统服务的内核进程。

下面函数用来获取进程相关的标识符:

#include <unistd.h>

/* 分别获取进程ID和父进程ID */
pid_t getpid();
pid_t getppid();

/* 分别获取进程的实际用户ID和有效用户ID */
uid_t getuid();
uid_t geteuid();

/* 分别获取进程的实际组ID和有效组ID */
gid_t getgid();
gid_t getegid();

默认情况下,进程的实际用户ID和有效用户ID相同,实际组ID和有效组ID相同。

下面是测试实例:

#include <stdio.h>
#include <unistd.h>

int main()
{
    pid_t pid = getpid();
    pid_t ppid = getppid();
    printf("pid=%d, ppid=%d\n", pid, ppid);

    uid_t realUID = getuid();
    uid_t effectiveUID = geteuid();
    printf("realUID=%d, effectiveUID==%d\n", realUID, effectiveUID);

    gid_t realGID = getgid();
    gid_t effectiveGID = getegid();
    printf("realGID=%d, effectiveGID=%d\n", realGID, effectiveGID);
    return 0;
}

执行结果如下:

$ ./proc_identifier
pid=67679, ppid=51979
realUID=1000, effectiveUID==1000
realGID=1000, effectiveGID=1000

创建进程

函数fork

现有进程可以通过调用fork函数来创建新进程。

#include <unistd.h>

pid_t fork();
/* 成功:子进程返回0,父进程返回子进程ID;失败:返回-1*/

fork创建的进程为调用进程的子进程。

fork调用一次返回两次:子进程返回0,父进程返回子进程ID。原因是父进程可以拥有多个子进程,子进程只有一个父进程并且可以通过函数getppid获取父进程ID。

下面是fork函数和I/O的使用实例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int gVal = 1;
static char buf[] = "a write to stdout\n";
int main()
{
    pid_t pid;
    int var = 88;

    if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1)
        perror("write error: ");
    printf("before fork\n");
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
    }
    else if (pid == 0)
    {
        gVal++;
        var++;
    }
    else
    {
        sleep(2);
    }
    printf("pid=%ld, gVal=%d, var=%d\n", pid, gVal, var);
    exit(0);
}

执行结果如下:

$ ./fork_demo
a write to stdout
before fork
pid=0, gVal=2, var=89
pid=75104, gVal=1, var=88
$ ./fork_demo > temp
$ cat temp
a write to stdout
before fork
pid=0, gVal=2, var=89
before fork
pid=75106, gVal=1, var=88

由此可以得出以下结论:

  1. 子进程和父进程都会执行fork函数之后的部分。可以得出父子进程共享正文段,但父子进程谁先执行,取决于内核的调度算法。
  2. 通过gValval的值可以得出,子进程会获得父进程数据空间、堆、栈的副本,但不共享,是相互独立的,每个进程都会可以修改自己的数据。
  3. 由于fork后进程跟随exec函数,因此fork执行之后并不立即给子进程分配父进程数据空间、堆以及栈的副本,而是采用写时复制技术,即这些区域改为父子进程共享且只读,只有当一个进程试图修改这些区域时,内核才会这个区域的内存制作一个副本。
  4. 父子进程同时写一个文件,fork函数执行之后父进程的所有文件描述符都被复制给子进程。
  5. write函数是不缓冲的,因此无论是终端设备还是文件都只写一次。
  6. 标准I/O是带缓冲的,标准输出重定向为终端设备时是行缓冲,因此遇到\n就会直接输出。但是当重定向到文件时是全缓冲,因此执行printf之后数据还在缓冲区中,同时也会被子进程共享,此时父子进程都有该该行内容的缓冲区,因此会输出两遍。 最后exit之前的printf函数会将其数据添加到已有缓冲区,在进程退出时写入到文件中。
  7. 根据写入的结果来看,父子进程共享同一文件偏移量。由此,父子进程的文件描述符表项指向指向的同一个文件表。如下图所示:

fork之后处理文件描述符有两种方法

  1. 父进程等待子进程读写完成。此时父进程无需对文件描述符做任何处理,因为文件偏移量已更新。
  2. 父子进程各自执行不同代码段。这种情况父子进程关闭其不适用的文件描述符。

其他被子进程继承的属性如下:

  • 实际用户ID、实际组ID、有效用户ID以及有效组ID。
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端。
  • 设置用户ID位和设置组ID标志
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 信号屏蔽和安排
  • 对任一打开文件描述符的执行时关闭标志
  • 环境
  • 连接的共享存储段
  • 资源限制
  • 存储映像

不被继承的属性如下:

  • 父进程设置的文件锁
  • 未处理的闹钟
  • 未处理的信号集

fork函数的两种用法

  • 父子进程各自执行不同的代码段。类似于网络服务进程,父进程继续等待客户端请求,子进程处理业务。
  • 子进程执行一个不同的程序。类似于shell程序,fork返回后立即调用exec

函数vfork

vfork函数和fork的调用返回值相同。

vfork函数也是用于创建一个新进程,而该新进程的目的是执行一个新程序。

vfork区别于fork的地方在于它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用execexit,于是也就不会引用该地址空间。

不过在子进程调用execexit之前,它在父进程的空间中运行。

vfork保证子进程先运行,在它调用execexit之后父进程才可能被调度执行。(如果调用这两个函数之前依赖于父进程的进一步动作,可能会导致死锁)

下面是vfork的一个实例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int gVal = 1;
int main()
{
    pid_t pid;
    int var = 88;

    printf("before fork\n");
    if ((pid = vfork()) < 0)
    {
        perror("fork error: ");
    }
    else if (pid == 0)
    {
        gVal++;
        var++;
        _exit(0);
    }
    else
    {
        sleep(2);
    }
    printf("pid=%ld, gVal=%d, var=%d\n", pid, gVal, var);
    exit(0);
}

执行结果如下:

$ ./vfork_demo
before fork
pid=83780, gVal=2, var=89

通过上述示例可以看出:

  1. 在子进程中对变量gValval变量加1,结果改变了父进程中的变量值,可以确定子进程在父进程的地址空间中运行。
  2. 子进程最后执行_exit而不是exit,是因为_exit并不执行标准I/O缓冲区的冲洗动作。
  3. 如果子进程执行exit函数退出进程,那么输出结果将是不确定,具体结果取决于标准I/O库的实现。
    • 如果exit函数仅冲洗标准I/O流,那么这样操作的输出与子进程调用_exit函数产生的输出相同。
    • 如果exit函数会关闭标准I/O流,那么标准输出FILE对象的相关存储区会被清0。因为子进程使用的是父进程的空间,所以父进程调用printf时会返回-1。

vfork创建的子进程虽然共享父进程的数据空间,但是它还是会有自己的文件描述符数组。 因为在执行exec函数之前重定向标准流是很常见的事情(这个原因并不充分)。 还有一个原因是vfork只会复制task_struct结构体,因为文件描述符数组也会被复制一份(这个比较靠谱)。

执行程序

函数exec

当进程调用exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。

因为调用exec函数并不创建新进程,因此进程ID前后不变。

exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

有7个不同类型的exec函数:

#include <unistd.h>
extern char **environ;
int execl(const char* pathname, const char *arg, ... /* char* NULL */);
int execlp(const char* file, const char* arg, ... /* char* NULL */);
int execle(const char* pathname, const char* arg, ... /* char* NULL, char *const envp[] */);

int execv(const char* pathname, char* const argv[]);
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execvp(const char* file, char* const argv[]);
int execvpe(const char* file, char* const argv[], char* const envp[]);

int fexecve(int fd, char* const argv[], char* const envp[]);

/* 成功不返回,失败返回-1. */

在上述函数中,只有execve是系统调用,其他7个函数都要转换参数然后调用该系统调用。 下面是函数关系图:

初始参数pathnamefile都是将要被执行的文件名。

函数可以根据前缀exec后面的字符进行分类:

包含字符说明
l参数都被展开为arg0,arg1,…,NULL,NULL为结尾,以作为待执行程序中的命令行参数,并且按照惯例,该命令行参数的第一个参数应为待执行程序的文件名。
v待执行程序的命令行参数以char* const argv[]数组形式传递,并且数组的最后一个元素为NULL。按照惯例,数组第一个元素应为待执行程序的文件名。
e调用进程的环境变量通过char* const envp[]数组传递给待执行程序,数组的最后一个元素为NULL。其他调用方式新程序的环境变量从外部环境中获取。
p如果file参数包含/,则当作待执行文件的路径名。否则在PATH环境变量指定的目录中查找该文件。

下面的例子展示exec族函数的用法:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

char* env_init[] = {"USER=unkonw", "PATH=/tmp", NULL};

int main()
{
    pid_t pid;
    /* 1.验证传递命令行参数 */
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid > 0)
    {
        printf("current process id is %d\n", getpid());
        printf("child process id is %d\n", pid);

        /* 休眠5s保证子进程先结束 */
        sleep(5);
    }
    else
    {
        /* 从外部获取环境变量 */
        if (execl("./echoall", "echoall", "arg1", "arg2", NULL) == -1)
        {
            perror("execl error: ");
        }
        exit(0);
    }

    /* 2.验证传递环境变量 */
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid > 0)
    {
        printf("current process id is %d\n", getpid());
        printf("child process id is %d\n", pid);

        /* 休眠5s保证子进程先结束 */
        sleep(5);
    }
    else
    {
        /* echoall进程的环境变量来源于传入的参数 */
        if (execle("./echoall", "echoall", "arg3", "arg4", NULL, env_init) < 0)
        {
            perror("execle error: ");
        }
    }

    /* 3.验证传递命令行参数数组 */
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid > 0)
    {
        printf("current process id is %d\n", getpid());
        printf("child process id is %d\n", pid);

        /* 休眠5s保证子进程先结束 */
        sleep(5);
    }
    else
    {
        char* arg[] = {"echoall", "arg5", "arg6", NULL};
        /* 以数组方式传递命令行参数,第一个元素是执行文件名 */
        if (execve("./echoall", arg, env_init) < 0)
        {
            perror("execve error: ");
        }
    }
}

执行结果如下:

$ ./exec_test
current process id is 26173
child process id is 26174
uid=1000, euid=1000, gid=1000, egid=1000, pid=26174, ppid=26173
argv:
argv[0]=echoall
argv[1]=arg1
argv[2]=arg2
environ:
environ[0]=SHELL=/bin/bash
environ[1]=LC_ADDRESS=zh_CN.UTF-8
environ[2]=LC_NAME=zh_CN.UTF-8
environ[3]=LC_MONETARY=zh_CN.UTF-8
...
environ[34]=_=./exec_test
current process id is 26173
child process id is 26175
uid=1000, euid=1000, gid=1000, egid=1000, pid=26175, ppid=26173
argv:
argv[0]=echoall
argv[1]=arg3
argv[2]=arg4
environ:
environ[0]=USER=unkonw
environ[1]=PATH=/tmp
current process id is 26173
child process id is 26176
uid=1000, euid=1000, gid=1000, egid=1000, pid=26176, ppid=26173
argv:
argv[0]=echoall
argv[1]=arg5
argv[2]=arg6
environ:
environ[0]=USER=unkonw
environ[1]=PATH=/tmp

根据echoallexec_test两个程序打印的结果来看,新程序的进程ID实际用户ID实际组ID等都不会发生变化,而有效用户ID和有效组ID取决于程序文件有没有设置设置用户ID位和设置组ID位。

对打开文件的处理与每个描述符的执行时关闭标志(FD_CLOEXEC)有关。若设置了该标志,则在执行exec时会关闭该描述符。

POSIX.1明确要求在exec时关闭打开目录流,opendir函数中调用fcntl函数为打开目录流的描述符表了执行时关闭(FD_CLOEXEC)。

函数system

system函数是有ISO C定义的,但是POSIX.1对system函数进行扩展。

#include <stdlib.h>
int system(const char* cmdstring);

system函数的执行有3个过程:

  1. fork创建子进程。
  2. exec函数执行调用/bin/sh -c执行命令。
  3. waitpid函数等待执行结果。

上述3个过程都可能出错,因此导致system函数的返回值取决于上述3个过程的执行结果。

fork执行失败时,返回-1。

waitpid执行失败时,也返回-1。

exec执行失败时,返回127,等同于/bin/sh执行exit(127)

当3个过程都没有失败,才会返回命令执行后的结果。

下面的例子展示了这一点:

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    int status = system("/tmp/test");
    /* 1. 判断是否是fork或waitpid错误 */
    if (status == -1)
    {
        perror("system error: ");
        return 0;
    }
    else
    {
        /* 2. 判断进程是否正常终止 */
        if (WIFEXITED(status))
        {
            /* 正常终止 */
            printf("normal termination, exit status is %d\n", WEXITSTATUS(status));
            /* 3. 判断退出码是否是指定值 */
            if (WEXITSTATUS(status) == 3)
            {
                /* 是,表示命令执行成功 */
                printf("command executed successfully\n");
            }
            else
            {
                printf("command executed failed\n");
            }
        }
        else if (WIFSIGNALED(status))
        {
            /* 异常终止 */
            printf("abnormal termination, signal is %d\n", WTERMSIG(status));
        }
        else
        {
            printf("exception\n");
        }
    }
}

执行结果如下:

$ ./system_test
sh: 1: /tmp/test: not found
# 下面的结果表示fork创建进程成功,但是sh执行失败。
normal termination, exit status is 127
command executed failed

下面是system函数的一个实现:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

static int mysystem(const char* cmdstring)
{
    pid_t pid;
    int status;

    if (cmdstring == NULL)
    {
        return 1;
    }

    if ((pid = fork()) < 0)
    {
        status = -1;
    }
    else if (pid == 0)
    {
        execl("/bin/sh", "sh", "-c", cmdstring, NULL);
        _exit(127);
    }
    else
    {
        while (waitpid(pid, &status, 0) < 0)
        {
            if (errno != EINTR)
            {
                status = -1;
                break;
            }
        }
    }
    return status;
}

static void print_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),
        /* 是否已经产生core文件 */
#ifdef WCOREDUMP
            WCOREDUMP(status) ? "(core file generated)" : ""
#else
            ""
#endif
        );
    }
    else if (WIFSTOPPED(status))
    {
        /* 当前暂停子进程的信号编号 */
        printf("child stopped, signal number = %d\n", WSTOPSIG(status));
    }
}

int main()
{
    int status;
    if ((status = mysystem("date")) < 0)
    {
        perror("mysystem error: ");
    }
    print_exit(status);

    if ((status = mysystem("nosuchcommand")) < 0)
    {
        perror("mysystem error: ");
    }
    print_exit(status);

    if ((status = mysystem("who; exit 44")) < 0)
    {
        perror("mysystem error: ");
    }
    print_exit(status);

    exit(0);
}

执行结果如下:

$ ./system_new
2022年 07月 18日 星期一 23:24:33 JST
normal termination, exit status = 0
sh: 1: nosuchcommand: not found
normal termination, exit status = 127
blduan   tty2         2022-07-18 22:52 (tty2)
blduan   pts/0        2022-07-18 22:52 (192.168.1.4)
normal termination, exit status = 44

bash程序不带-p参数,并且当进程的实际用户和有效用户不一致时, 会调用setuid将进程的有效用户和保存的设置用户ID都设置为实际用户ID。如果带-p参数,则保持不变。

解释器文件

解释器文件是文本文件,其起始行的形式是#! pathname [option-argument],感叹号和pathname之间的空格可选。

所有的类Unix系统都支持解释器文件,最常见的解释器文件的起始行是#! /bin/sh

其中pathname通常是绝对路径名,对这种文件的识别是由内核在作为exec系统调用的一部分来完成的。内核使调用exec函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname所指定的文件。

下面的代码执行了一个解释器文件:

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        if (execl("./testinterp", "testinterp", "myarg1", "MY ARG2", NULL) < 0)
        {
            perror("execl error: ");
        }
    }
    if (waitpid(pid, NULL, 0) <= 0)
    {
        perror("waitpid error: ");
    }
    exit(0);
}

执行结果:

# 下面查看解释器文件的内容
$ cat testinterp
#! /home/blduan/projects/code_snippet/unix_code/8_chapter/echoarg foo
$ ./exec_interpreter
# argv[0]是解释器文件中的pathname, argv[1]是解释器文件的可选参数。
argv[0]=/home/blduan/projects/code_snippet/unix_code/8_chapter/echoarg
argv[1]=foo
# execl携带的命令行参数偏移了两个位置,
# 并且将execl函数的第一个参数作为传递给子进程的第一个命令行参数输出,而本来的第一个命令行参数被忽略。
argv[2]=./testinterp
argv[3]=myarg1
argv[4]=MY ARG2

另一个解释器文件内容如下所示:

# -f参数指定了从接下来的内容中读取解释器awk要执行代码
$ cat awkexample
#! /usr/bin/awk -f
# Note: on Ubuntu, use nawk instead
BEGIN {
    for (i=0; i<ARGC; i++)
        printf "ARGV[%d]=%s\n", i, ARGV[i]
    exit
}
# 使用execl函数执行和下面的效果一致
$ /usr/bin/awk -f ./awkexample myarg1 "MY ARG2"
ARGV[0]=awk
ARGV[1]=myarg1
ARGV[2]=MY ARG2

解释器文件的用途:可以隐藏具体的程序内容,该程序可以是一个脚本也可以是一个二进制文件。可以写除/bin/sh之外的脚本文件,因为默认总是调用/bin/sh来执行文件。

进程终止

函数exit

进程有5种正常终止方式和3种异常终止方式:

进程正常终止方式说明进程异常终止方式说明
main函数执行return语句等同于调用exit调用abort会产生SIGABRT信号
调用exit函数ISO C定义,会调用各终止处理程序(aexit注册的),关闭所有标准I/O流进程接收到某些信号。信号可有进程自身、其他进程或内核产生
调用_exit_Exit函数ISO C定义了_Exit,目的是为进程提供一种无需执行终止程序或信号处理程序的终止方法。是否对标准I/O流冲洗取决于实现。Unix系统中,_Exit_exit同义,并不冲洗标准I/O流。_exit函数由exit调用,用于处理Unix系统的特定细节。最后一个线程对取消请求做出响应
进程的最后一个线程在其启动历程中执行return语句该线程的返回值不用作进程的返回值,该进程以终止状态0返回。
进程的最后一个线程调用pthread_exit函数进程的终止状态为0,和传递给pthread_exit的参数无关。

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码用以为相应进程关闭所有打开的描述符,释放他们使用的存储器等。

对于进程的任意一种终止情形,内核都能够通知到其父进程终止状态是什么。

  1. 对于终止进程调用_exit, _Exit, exit终止函数,其函数参数为退出状态,内核在最终调用_exit时会将退出状态转化为进程的终止状态
  2. 对于终止进程异常终止情况,内核会产生指示其终止原因的终止状态。
  3. 在任意一种情况下,该终止进程的父进程都能够通过waitwaitpid函数来获取其终止状态。

有两种特殊情况:

  1. 父进程在子进程之前终止:对于父进程已经终止的所有进程,它们的父进程都改变为init进程。
  2. 子进程在父进程之前终止:内核为每个进程都保存了一定量的信息,父进程可以通过waitwaitpid来获取这些信息(CPU时间总量,进程终止状态等)。

一个已经终止,但是其父进程并未对其进行善后处理(获取终止子进程的有关信息、释放其占用的资源)的进程被称为僵死进程。 init进程在其子进程终止时会自动调用wait函数,获取终止进程的信息、释放资源。

下面的例子演示了父进程在子进程之前终止,子进程的父进程变为init进程:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
        perror("fork error: ");
    else if (pid == 0)
    {
        while (1)
        {
            sleep(3);
        }
    }

    else
    {
        printf("pid=%d\n", pid);
        exit(0);
    }
}

执行结果如下:

$ ./parent_proc_init
pid=94706
$ ps -ef | grep 94706
blduan     94706       1  0 00:02 pts/0    00:00:00 ./parent_proc_init

下面的示例演示父进程一直不获取子进程终止后的终止状态,子进程将变为僵死进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
        perror("fork error: ");
    else if (pid > 0)
    {
        printf("pid=%d\n", pid);
        while (1)
        {
            sleep(3);
        }
    }
    else
    {
        exit(0);
    }
}

执行结果如下:

$ ./child_proc_zombie
pid=95612
^C
$ ps -aux | grep 95612
blduan     95612  0.0  0.0      0     0 pts/0    Z+   00:07   0:00 [child_proc_zomb] <defunct>

僵死进程的标志是Z+。

函数wait族

当一个进程无论正常还是异常终止,内核都会向其父进程发送SIGCHLD信号。

下面的函数用来获取子进程的终止状态:

#include <sys/wait.h>

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
/* 成功返回进程ID,失败返回0 */

函数参数statloc为整形指针,如果不是NULL,则将终止进程的终止状态保存在其中。

  1. 收到SIGCHLD信号而调用wait函数,wait函数立即返回。
  2. 随机时间点调用wait函数,结果如下:
    • 如果所有子进程都正在运行,阻塞。
    • 如果一个子进程已终止,正在等待父进程获取其终止状态(此时子进程为僵死进程),则立即返回该子进程的终止状态。
    • 如果没有任何子进程,则出错返回。

下面的例子验证了没有任何子进程时,调用wait函数会直接出错返回。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
    if (wait(NULL) <= 0)
    {
        perror("wait error: ");
    }
    return 0;
}

执行结果如下:

wait error: : No child processes

下面的例子验证子进程正在运行时,父进程调用wait会阻塞。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
    }
    else if (pid == 0)
    {
        printf("child process is running\n");
        while (1)
        {
            sleep(3);
        }
    }
    else
    {
        printf("child process is blocking\n");
        if (wait(NULL) <= 0)
        {
            perror("wait error: ");
        }
        else
        {
            printf("wait return\n");
        }
    }
}

执行结果如下:

r$ ./wait_block
child process is blocking
child process is running

下面的例子验证当子进程已经终止成为僵死进程时,调用wait会直接返回。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
    }
    else if (pid == 0)
    {
        printf("child process exit\n");
        exit(3);
    }
    else
    {
        printf("child process id is %d\n", pid);
        sleep(2);
        int statloc;
        printf("wait return is %d\n", wait(&statloc));
        printf("exit code is %d\n", statloc);
        if (WIFEXITED(statloc))
        {
            printf("%d\n", WEXITSTATUS(statloc));
        }
        else if (WIFSIGNALED(statloc))
        {
            printf("%d\n", WTERMSIG(statloc));
        }
    }
}

执行结果如下:

$ ./wait_return
child process id is 110121
child process exit
wait return is 110121
3

下面的例子验证在SIGCHLD信号处理程序中执行wait函数会直接返回。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static void handle_sig_cancel(int sig)
{
    /* 验证信号处理函数中执行wait是否阻塞 */
    printf("signal handle\n");
    if (wait(NULL) <= 0)
    {
        perror("wait error: ");
    }
    else
    {
        printf("wait return\n");
    }
}
int main()
{
    signal(SIGCHLD, handle_sig_cancel);
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
    }
    else if (pid == 0)
    {
        printf("child process is running\n");
        while (1)
        {
            sleep(3);
        }
    }
    else
    {
        printf("child process id is %d\n", pid);
        printf("child process is blocking\n");
        /* 阻止父进程退出 */
        while (1)
        {
            sleep(3);
        }
    }
}

执行结果如下:

$ ./wait_sig_cancel
child process id is 109947
child process is blocking
child process is running
signal handle
wait return

父进程可以通过wait函数返回的整形状态字来判断子进程是否正常终止。

整型状态字是由系统定义的,其中某些位表示退出状态(正常终止),其他为表示信号编号(异常终止),有一位表示是否产生core文件。

系统提供了4个互斥的宏来取得进程终止的原因。其所在头文件为#include <sys/wait.h>

说明
WIFEXITED(status)若为正常终止子进程返回的状态,则为真。对于这种情况可执行WEXITSTATUS(status) 获取子进程传给exit函数的参数的低8位。
WIFSIGNALED(status)若为异常终止子进程返回的状态,则为真。对于这种情况,可以执行WTERMSIG(status),获取使子进程终止的信号编号。 有些实现会定义宏WCOREDUMP(status),若已产生终止进程的core文件,则它返回真。
WIFSTOPPED(status)若为当前暂停子进程的返回的状态,则为真。对于这种情况,可执行WSTOPSIG(status)获取使子进程暂停的信号编号。
WIFCONTINUED(status)若在作业控制暂停后已经继续的子进程返回了状态,则为真。

下面的例子展示了如何使用这些宏:

static void print_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),
        /* 是否已经产生core文件 */
#ifdef WCOREDUMP
            WCOREDUMP(status) ? "(core file generated)" : ""
#else
            ""
#endif
        );
    }
    else if (WIFSTOPPED(status))
    {
        /* 当前暂停子进程的信号编号 */
        printf("child stopped, signal number = %d\n", WSTOPSIG(status));
    }
}

int main()
{
    pid_t pid;
    int status;

    if ((pid = fork()) < 0)
        perror("fork error: ");
    else if (pid == 0)
        /* 子进程正常终止 */
        exit(7);

    if (wait(&status) != pid)
    {
        perror("wait error: ");
        return 0;
    }
    print_exit(status);

    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        /* 子进程异常终止,产生信号SIGABRT */
        abort();
    }
    if (wait(&status) != pid)
    {
        perror("wait error: ");
        return 0;
    }
    print_exit(status);

    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        /* 子进程异常终止,产生SIGFPE信号 */
        status /= 0;
    }
    if (wait(&status) != pid)
    {
        perror("wait error: ");
        return 0;
    }
    print_exit(status);
    return 0;
}

执行结果如下:

$ ./print_exit
normal termination, exit status = 7
abnormal termination, signal number = 6(core file generated)
abnormal termination, signal number = 8(core file generated)

如果一个进程有多个子进程,那么只要其中一个子进程终止,wait函数就返回。 如果要等待指定的进程终止,可以使用waitpid函数。

waitpid函数

waitpid函数参数说明:

pid参数作用如下:

  • pid==-1,等待任一子进程, 和wait函数等效。
  • pid > 0,等待进程ID与pid相等的子进程。
  • pid == 0,等待组ID等调用进程组ID的任一子进程。(同组的子进程)
  • pid < -1,等待组ID等于pid绝对值的任一子进程。(指定组的子进程)

waitpid函数返回终止子进程的进程ID,并将该子进程的终止状态保存在statloc指向的存储单元中。

如果指定的进程或进程组不存在,则waitpid调用会出错。

option参数取值范围是0和以下的值:

常量说明
WCONTINUED若实现作业控制,那么有pid指定的任一子进程在停止后已经继续,但其状态未报告,则返回其状态。
WHONANG若有pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时返回值是0。
WUNTRACED若某实现作业控制,而由pid指定的任一子进程已终止,并且其状态还未报告过,则返回其状态。WIFSTOPPED宏确定返回值是否对应与一个停止的子进程。

waitpid函数默认会阻塞调用进程,除非options参数设置为WHONANG。options=WHONANG|WUNTRACED时,返回值为0表示没有停止或终止的子进程,非0表示停止或终止的子进程ID。

非阻塞版的wait调用可以这么写waitpid(-1, NULL, WNOHANG|WUNTRACED),含义是等待任一子进程并立即返回,若子进程终止返回子进程id,否则返回0。

下面的代码验证WNOHANG参数的作用

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        printf("child process is running\n");
        while (1)
        {
            sleep(3);
        }
    }
    else
    {
        /* WNOHANG表示pid指定的子进程不是可用的,则立即返回0 */
        int ret = waitpid(pid, NULL, WNOHANG);
        if (ret == 0)
        {
            printf("waitpid return\n");
        }
        else if (ret > 0)
        {
            printf("child process id is %d\n", ret);
        }
        else
        {
            perror("waitpid error: ");
        }
    }
}

执行结果如下:

$ ./waitpid_nohang
waitpid return
child process is running
$ ps -a
    PID TTY          TIME CMD
   1562 pts/0    00:00:00 waitpid_nohang
   1567 pts/0    00:00:00 ps

waitid函数

POSIX.1的超集Single UNIX Specification定义了一个额外的获取终止进程状态的函数waitid,类似与waitpid,但灵活性更强。

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

id参数的作用由idtype参数来决定。idtype的取值返回如下:

常量说明
P_PID等待进程ID为参数id的进程。
P_PGID等待进程组ID为参数id的组内任一子进程。
P_ALL等待任一子进程,参数id忽略。

options参数的取值范围如下:

常量说明
WCONTINUED等待一进程,该进程以前终止过,此后又继续运行,但其状态未报告。
WEXITED等待已退出的进程。
WNOHANG如无可用的子进程退出状态,则立即返回而非阻塞。
WNOWAIT不破坏子进程退出状态。该子进程退出状态可由后续的waitwaitpid等调用获取。
WSTOPPED等待一进程,它已经停止,但状态未曾报告。

WCONTINUED、WEXITED和WSTOPPED三个常量,options参数必须选择一个。

进程停止和终止是不同的,stoped和terminated状态不同。

infop参数是指向siginfo结构的指针,包含了信号相关的信息。

waitid函数默认情况下也会阻塞,除非options参数设置WNOHANG。

下面的例子测试waitid函数的基本用法:

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        sleep(10);
        exit(3);
    }
    else
    {
        siginfo_t sig;
        if (waitid(P_PID, pid, &sig, WEXITED) == 0)
        {
            printf("child process id is %d, and exited\n", pid);
            printf("%d %d %d\n", sig.si_code, sig.si_errno, sig.si_signo);
        }
        else
        {
            perror("waitid error: ");
        }
    }
}

执行结果如下:

$ ./waitid_test
# 下面的结果是10s之后打印的,可见waitid会阻塞调用进程。
child process id is 3715, and exited
1 0 17

wait3和wait4函数

#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);
/* 成功返回进程id, 失败返回-1 */

这两个函数会返回终止进程及其子进程的资源概况,其中包括CPU时间总量、系统CPU时间总量、缺页次数、接收不到信号的次数等。

进程属性

实际用户

任一进程都可以得到其实际用户ID、有效用户ID以及组ID。 也可以得到运行该程序的登录名,即实际用户名。

#include <unistd.h>
char* getlogin();
/* 成功返回登录用户名,失败返回NULL */

有时多个用户名可以对应一个用户ID。通过上述函数获得登录名之后,就可以调用getpwnam在口令文件中查找相应记录,进而确定其登录终端。

如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败,通常称这些进程为守护进程。

调用实例如下:

#include <unistd.h>
#include <stdio.h>
int main(){
    printf("%s\n",getlogin());
}

/* 执行结果如下: */
$ ./get_login_name
blduan

竞争条件

当多个进程都企图对共享数据进行某种处理,而最后的结果取决于进程运行的顺序,此时就发生竞争条件。

如果fork之后的逻辑显式或隐式的依赖于fork之后是父进程先运行还是子进程先运行,那么fork函数会就会产生竞争条件。

如果一个进程希望等待一个子进程终止,则它必须调用wait函数中的一个。如果一个进程要等待其父进程终止,则可以使用下面的循环条件:

while(getppid() != 1){
    sleep(1);
}

上面这种方式被称为轮询,存在的问题是浪费CPU时间,因为调用者每隔1s都被唤醒,然后进行条件测试。

为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发送和接收的方法。

调度

Unix系统历史上对进程提供的只是基于优先级的粗粒度的控制。调度策略和调度优先级由内核控制。进程可以通过调整友好值选择以更低优先级运行。只有特权进程允许提高优先级。

Linux系统的友好值范围是-20~19,友好值越低,优先级越高。

进程可以通过nice函数获取或修改其友好值:

#include <unistd.h>

int nice(int incr);
/* 成功返回新的友好值,失败返回-1 */

incr参数被增加到调用进程的现有友好值上。如果incr太大,系统直接把它降低到最大合法值;相反,如果incr太小,系统也会提高到最小合法值。

调用成功并且返回-1,但errno为0,也被视为调用成功。

下面的例子打印NZERO的值,并在单核CPU中父子进程分别以不同友好值运行10s,来进行判断父子进程占用CPU的情况。

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>

static unsigned long long count = 0;
static struct timeval end;
static void check_time(char* str)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    if (tv.tv_sec >= end.tv_sec && tv.tv_usec >= end.tv_usec)
    {
        printf("%s count=%lld\n", str, count);
        exit(0);
    }
}

int main(int argc, char* argv[])
{
    /* 1.获取友好值范围 */
    int nzero = 0;
    int ret = 0;
    int incr = 0;
    pid_t pid;
    char* name = NULL;
    setbuf(stdout, NULL);
#ifdef NZERO
    nzero = NZERO;
#elif _SC_NZERO
    nzero = sysconf(_SC_NZERO);
#else
#endif
    printf("NZERO=%d\n", nzero);

    /* 2. 从输入参数中获取incr */
    if (argc == 2)
    {
        incr = strtol(argv[1], NULL, 10);
    }

    gettimeofday(&end, NULL);
    end.tv_sec += 10;

    /* 3. 创建子进程和父进程运行相同的时间,并计数 */
    if ((pid = fork()) < 0)
    {
        perror("fork error");
    }
    else if (pid == 0)
    {
        /* 子进程 */
        name = "child";
        printf("current nice value in child is %d\n", nice(0));
        errno = 0;
        if ((ret = nice(incr)) == -1 && errno != 0)
        {
            perror("nice error");
        }
        printf("current nice value in child is %d\n", ret);
    }
    else
    {
        /* 父进程 */
        name = "parent";
        printf("current nice value in parent is %d\n", nice(0));
    }

    /* 4. 父子进程开始计数,根据计数的值来判断占用CPU的时间长度 */
    while (1)
    {
        if (++count == 0)
        {
            exit(0);
        }
        check_time(name);
    }
    return 0;
}

执行结果如下:

$ ./nice_test
NZERO=0
current nice value in parent is 0
current nice value in child is 0
current nice value in child is 0
parent count=320828014
child count=321910059

$ ./nice_test 20
NZERO=0
current nice value in parent is 0
current nice value in child is 0
current nice value in child is 19
parent count=652649803
child count=9793971

$ sudo ./nice_test -20
NZERO=0
current nice value in parent is 0
current nice value in child is 0
current nice value in child is -20
child count=655302483
parent count=7797852

从结果中可以看出,当父子进程同时以友好值0运行时,优先级相同,占用CPU比例分别为 $\frac {320828014}{(320828014+321910059)} =49.92 \%$ 和 $\frac {321910059}{(320828014+321910059)} =50.08 \%$,两者基本相同。

当父子进程同时分别以友好值0和19运行时,父进程优先级高,子进程优先级低,占用CPU的比例分别为 $\frac {652649803}{652649803+9793971} = 98.52 \%$ 和 $\frac {9793971}{652649803+9793971} = 1.48 \%$

当父子进程分别以友好值0和-20运行时,子进程优先级高,父进程优先级低,占用CPU的比例分别为 $\frac {7797852}{655302483+7797852} = 1.18\%$ 和 $\frac {655302483}{655302483+7797852} = 98.82\%$

因此,调度程序会根据进程的友好值,来决定分配CPU时间片的多少。

getpriority函数不仅可以像nice函数获取单个进程的友好值,也可以获取一组相关进程的友好值。setpriority函数可用于为进程、进程组和属于特定用户ID的所有进程设置优先级。

#include <sys/resource.h>

int getpriority(int which, id_t who);
/* 成功返回友好值,失败返回-1 */
int setpriority(int which, id_t who, int value);
/* 增加value到现有友好值上,成功返回0, 失败返回-1 */

参数解释如下:

whichwho说明
PRIO_PROCESS0表示调用进程ID
PRIO_PGRP0调用进程组ID
PRIO_USER0调用进程的实际用户ID

如果which参数作用于多个进程,则返回所有作用进程中优先级最高的(友好值最小的)。

#include <stdio.h>
#include <sys/resource.h>
#include <unistd.h>

int main()
{
    printf("PRIO_PROCESS: %d PRIO_PGRP: %d PRIO_USER: %d\n", getpriority(PRIO_PROCESS, 0), getpriority(PRIO_PGRP, 0),
        getpriority(PRIO_USER, 0));
}
// PRIO_PROCESS: 0 PRIO_PGRP: 0 PRIO_USER: -11

更改用户ID

对于文件来说,有3个相关权限,分别是所有者(owner)ID、所有组(group)ID以及其他(other)。

对运行中的进程来说,有3个相关用户/组,分别是实际用户/组(real user or group,登录用户/组),有效用户/组(effective user or group)以及设置的用户/组(set user or group)。

有一系列函数可用用来设置进程的用户/组:

#include <unistd.h>

int setuid(uid_t uid);
int setgid(gid_t gid);

int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);

int seteuid(uid_t uid);
int setegid(gid_t gid);
/* 成功返回0, 失败返回-1 */

这些函数的使用场景是程序需要增加特权或访问当前并不允许访问的资源,此时需要更换自己的用户ID和组ID,使得新ID具有合适的特权或访问权限。

setuid函数用于设置调用进程的实际用户ID和有效用户ID;setgid函数用于设置调用进程的实际组ID和有效组ID。

不同权限调用setuid函数结果(setgid函数结果类似)如下图:

setreuidsetregid函数分别是用来设置实际用户ID有效用户ID和实际组ID有效组ID的,权限判断和setuid一致。但传入参数为-1时,则对应的ID不变。

seteuidsetegid函数分别是用来设置调用进程的有效用户ID和有效组ID。

上述所有函数设置用户ID或组ID时都需要遵循下面3点:

  1. 若进程具有超级用户权限,则可以将相应的ID设置为传入的参数值。
  2. 若进程无超级用户权限,但是传入的参数等于调用进程的实际用户ID或设置用户ID时,也可以将有效用户ID设置为传入的参数。
  3. 若上面的参数都不满足,则errno=EPERM,并返回-1。

进程设置用户ID位调用关系如下:

下面是进程用户设置的使用示例:

#include <stdio.h>
#include <stdlib.h>
#define _GNU_SOURCE
#include <unistd.h>

static void print_ids()
{
    uid_t ruid;
    uid_t euid;
    uid_t saved_uid;
    if (getresuid(&ruid, &euid, &saved_uid) == 0)
    {
        printf("real uid=%d, effective uid=%d saved uid =%d\n", ruid, euid, saved_uid, saved_uid);
    }
    else
    {
        perror("getresuid error: ");
        return;
    }
}

int main()
{
    /* 1. 修改了进程文件的所有者和所有组为root,并设置了设置用户ID位和设置组ID位。
     *     此时进程的有效用户为root,保存的设置用户也为root. */
    print_ids();
    /* 2. 降低特权,调用seteuid会将有效用户设置为实际用户,此时保存的设置用户为root */
    seteuid(getuid());
    print_ids();
    /* 3. 提高特权,将有效用户设置为保存的设置用户 */
    setuid(0);
    print_ids();

    /* 4. 创建进程以执行特定用户的命令需要降低权限· */
    pid_t pid;
    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        /* 运行用户命令,降低权限 */
        setuid(getuid());
        print_ids();
    }
}

执行结果如下:

$ ./setuid_test
real uid=1000, effective uid=0 saved uid =0
real uid=1000, effective uid=1000 saved uid =0
real uid=1000, effective uid=0 saved uid =0
real uid=1000, effective uid=1000 saved uid =1000

从上述使用实例中可以看出,当前在执行特定用户命令时,可以使用fork创建进程,然后通过setuid设置为特定的实际用户ID,避免权限暴露。

进程时间

Unix系统为一个进程维护了3个进程时间值:时钟时间用户CPU时间以及系统CPU时间

时钟时间指的是进程运行的时间总量,其值与系统中同时运行的进程数有关。

用户CPU时间是执行用户指令所用的是时间长度,系统CPU时间是为该进程执行内核程序所用的时间长度。

任一进程调用times函数都可以获取上述3个值。

#include <sys/times.h>

clock_t times(struct tms *buf);
/* 成功返回时钟时间长度,失败返回-1. */ 

struct tms{
    clock_t tms_utime; /* 用户CPU时间*/
    clock_t tms_stime; /* 系统CPU时间 */
    clock_t tms_cutime; /* 子进程用户CPU时间 */
    clock_t tms_cstime; /* 子进程系统CPU时间 */
}

times函数返回的值都是以过去某一时间点开始测量的时间长度值,因此欲获取运行时间,需前后两次调用,再计算相对值。

所有由times函数返回的clock_t数据都可以使用_SC_CLK_TCK转化为秒数。

下面的示例展示了如何获取调用进程及其子进程运行使用的时间:

#include <stdio.h>
#include <stdlib.h>
#include <sys/times.h>
#include <unistd.h>

static void print_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),
        /* 是否已经产生core文件 */
#ifdef WCOREDUMP
            WCOREDUMP(status) ? "(core file generated)" : ""
#else
            ""
#endif
        );
    }
    else if (WIFSTOPPED(status))
    {
        /* 当前暂停子进程的信号编号 */
        printf("child stopped, signal number = %d\n", WSTOPSIG(status));
    }
}

static void print_times(clock_t real, struct tms* tmsstart, struct tms* tmsend)
{
    long clktck = 0;
    if (clktck == 0)
    {
        if ((clktck = sysconf(_SC_CLK_TCK)) < 0)
        {
            perror("sysconf error: ");
            exit(0);
        }
    }

    printf(" real: %7.2f\n", real / (double)clktck);
    printf(" user: %7.2f\n", (tmsend->tms_utime - tmsstart->tms_utime) / (double)clktck);
    printf(" system: %7.2f\n", (tmsend->tms_stime - tmsstart->tms_stime) / (double)clktck);
    printf(" child user: %7.2f\n", (tmsend->tms_cutime - tmsstart->tms_cutime) / (double)clktck);
    printf(" child system: %7.2f\n", (tmsend->tms_cstime - tmsstart->tms_cstime) / (double)clktck);
}

static void do_cmd(const char* cmd)
{
    struct tms tmsstart, tmsend;
    clock_t start, end;
    int status;

    printf("\ncommand: %s\n", cmd);
    if ((start = times(&tmsstart)) == -1)
        perror("times error: ");

    if ((status = system(cmd)) < 0)
        perror("system error: ");

    if ((end = times(&tmsend)) == 0)
        perror("system error: ");

    print_times(end - start, &tmsstart, &tmsend);
    print_exit(status);
}
int main(int argc, char* argv[])
{
    int i;
    setbuf(stdout, NULL);
    for (i = 1; i < argc; i++)
    {
        do_cmd(argv[i]);
    }
    exit(0);
}

运行结果如下:

$ ./process_clock "sleep 5" "date" "man bash > /dev/null"
command: sleep 5
 real:    5.01
 user:    0.00
 system:    0.00
 child user:    0.00
 child system:    0.00
normal termination, exit status = 0

command: date
2022年 07月 22日 星期五 00:23:12 JST
 real:    0.00
 user:    0.00
 system:    0.00
 child user:    0.00
 child system:    0.00
normal termination, exit status = 0

command: man bash> /dev/null
 real:    0.14
 user:    0.00
 system:    0.00
 child user:    0.13
 child system:    0.09
normal termination, exit status = 0

进程会计