apue(五)、进程控制

118 阅读9分钟

1、简介

本节主要介绍新建进程、执行进程、进程终止的过程。以及各进程执行时的竞争情况和处理方式。

2、创建进程

2.1 进程标识信息

Linux中ID为0的进程是调度进程,常常被称为交换进程;ID为1的为init进程,init进程成为所有孤儿进程的父进程。

#include <unistd.h>

pid_t getpid(void);       //获取当前进程ID

pid_t getppid(void);      //获取调用进程的父进程

uid_t getuid(void);       //获取调用进程的实际用户ID

uid_t geteuid(void);      //获取调用进程的有效用户ID

gid_t getgid(void);       //获取调用进程的实际组ID

gid_t getegid(void);      //获取调用进程的有效组ID

2.2 创建子进程

#include <unistd.h>

pid_t fork(void);

fork 函数调用一次,但是返回两次。分别是由子进程返回0,由父进程返回新建子进程ID。此外 fork函数执行时,子进程会获得父进程数据空间、堆、栈的副本,父子进程不共享这些存储空间。

由于使用时 fork后通常会跟随 exec函数,所以从父进程复制来的数据空间、栈、堆并没有得到使用。作为替代,Linux采用了写时复制,父子进程共享上述存储空间,同时内核将这些空间的权限修改为只读。如果子进程或者父进程需要修改存储空间,则会触发页中断,内核再为修改的那部分创建一个副本,该副本通常是虚拟存储中的一页。

#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include "../err.h"

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

int main(void) {
    int var;
    pid_t pid;
    var = 88;

    if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1) {
        err_sys("write error");
    }
    printf("before work\n");    /* 默认采取行缓冲,如果输出到文件采取全缓冲,在fork时缓冲区数据也会复制到子进程 */

    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);
}

如果程序执行默认输出到标准输出,打印结果为:

a write to stdout
before work
pid = 10049, glob = 7, var = 89
pid = 10048, glob = 6, var = 88

如果调整输出到 temp.out 临时文件,打印结果为:

a write to stdout
before work
pid = 10093, glob = 7, var = 89
before work
pid = 10092, glob = 6, var = 88

结果不同是因为,程序中第一条打印语句 write 是系统IO,不带缓冲直接输出;printf 是标注IO,由于输出到终端设备,所以是行缓冲,在遇到换行符时会刷新缓冲区,而如果是输出到文件则采用全缓冲,全缓冲会等到缓冲区满之后再输出。fork 在复制进程空间时会连同缓存区一起复制到子进程存储空间,所以会出现上述情况。

3、文件共享

fork 执行后,父进程所有打开的文件描述符都复制进子进程,那么父子进程就会共享同一个文件偏移量。如果父子进程写同一描述符指向的文件就会出现文件偏移量不一致的问题,所以在fork之后有两种方式处理文件描述符:

(1)父进程等待子进程完成。这种情况下,父进程无需对其描述符做任何处理,当子进程终止后父进程以新的文件偏移量进行操作;

(2)父进程和子进程执行不同的程序段。在fork之后,父子进程关闭各自不需要的文件描述符,这样就不会相互干扰。在网络服务中,父进程等待客户端的服务请求,当请求到达时,父进程调用 fork 创建子进程处理该请求。父进程则继续等待下一个请求。关闭可以通过 fcntl 函数的 F_GETFD 先获取到文件描述符状态 flag,然后由 flag |= FD_CLOEXEC,最后通过 F_SETFD 重新设置文件描述符标志。这样就关闭了一些无用的文件描述符。

int fd=open("1",O_RDONLY); //获取当前文件描述符的相关标志位 
int flags=fcntl(fd,F_GETFD,NULL); 
flags|=FD_CLOEXEC; 
fcntl(fd,F_SETFD,flags);

4、进程终止

子进程在终止时需要通知其父进程,可以通过 exit(xxx)/_exit(xxx)/_Exit(xxx) 将异常状态返回给父进程。父进程则可以通过 wait/waitpid 获取子进程终止状态。

如果父进程在子进程之前先结束,那么子进程的父进程切换为 init进程;如果子进程在父进程之前先终止,而父进程尚未对子进程进行善后处理,那么这些子进程就被称为僵尸进程,通过 ps -aux 查看状态为 Z的即为僵尸进程,僵尸进程会在内存中保存一个结构体,也就是其自身的状态信息,其它资源均释放了。但是它会占用PID,进程描述符表PID的数量是有限的,所以会产生问题。

#include <sys/wait.h>

pid_t wait(int *p);

pid_t waitpid(pid_t pid, int *p, int options);

4.1 wait

wait 等待任意一个子进程终止返回,将返回的状态信息保存到整型指针 p中,如果不关心状态可以设置为 NULL指针。终止状态信息可以通过一些宏定义来判断,如下:

WIFEXITED(status):判断是否正常终止,有5种情况;

WIFSIGNALED(status):判断是否异常终止,有3种情况;

WIFSTOPPED(status):判断进程是否暂停;

WIFCONTINUED(status):判断进程是否由暂停恢复运行;
#include <sys/wait.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include "../err.h"

static void pr_exit(int);

int main(void) {
    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("wait error");
    else if (pid == 0)
        status /= 0;   /* 异常退出 */
    
    if (wait(&status) != pid)
        err_sys("wait error");
    pr_exit(status);
    exit(0);
}


static void pr_exit(int status) {
    if (WIFEXITED(status)) {
        printf("normal terminated, exit status = %d\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status))
        printf("abnormal terminated, 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));
}

结果打印:

normal terminated, exit status = 7
abnormal terminated, signal number = 6
abnormal terminated, signal number = 8

4.2 waitpid

waitpid 函数参 pid 的解释:

  • pid == -1,等待任一子进程,在这种情况下 waitpid 和 wait等价;
  • pid > 0,等待进程ID和 pid相等的子进程;
  • pid == 0,等待组ID等于调用进程组ID的任一子进程;
  • pid < 0,等待组ID等于pid绝对值的任一子进程。

waitpid 提供了 wait 函数没有的3个功能:

(1)waitpid 可等待一个特定进程,而 wait 返回任一终止子进程的状态;

(2)waitpid 提供了一个 wait的非阻塞版本,将 option 参数设置为 WNOHANG 即可。这样可以非阻塞地获取子进程的状态。

(3)waitpid 通过 WCONTINUEDWUNTRACED 支持作业控制。

那么如果希望 fork 一个子进程,但不要等待子进程终止,也不希望子进程处于僵尸状态直到父进程终止,可以通过 fork 两次来实现。A进程 fork 得到 B进程,B进程 fork 得到 C进程,B进程在 fork完成后立刻 exit,这样 C进程的父进程就转换为 init进程,A进程只需要 wait等待回收 B进程资源即可。

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

int main(void) {
    pid_t pid;
    if ((pid = fork()) < 0) {
        err_sys("fork error");
    } else if (pid == 0) {          /* first fork Process B */
        if ((pid = fork()) < 0) {
            err_sys("fork error");
        } else if (pid > 0) {       /* Process B */
            exit(0);
        }
        sleep(2);
        printf("second child, parent pid = %ld\n", (long)getppid());
        exit(0);
    }

    if (waitpid(pid, NULL, 0) != pid) /* wait for Process B */
        err_sys("waitpid error");
    
    exit(0);
}

注意需要保证 sleep(2),这样可以防止 C进程在 B进程之前执行,那么它打印的父进程就是 B进程ID了。

5、进程竞争

由于 fork 后父子进程之间的执行顺序完全由内核调度,那么对于共享数据的访问就可能存在竞争问题。可以通过协调父子进程,当父进程完成操作后通知对方,并且在继续运行之前等待另一方完成操作。

TELL_WAIT();   /* 准备 TELL、WAIT */
if ((pid = fork()) < 0) {
    err_sys("fork error");
} else if (pid == 0) {           //子进程
    /* 子进程进行操作 */
    TELL_PARENT(getppid());
    WAIT_PARENT();
    exit(0);
} 
/* 父进程进行操作 */
TELL_CHILD(pid);
WAIT_CHILD();
exit(0);

这里举例父子进程同时借助标准输出打印,由于存在竞争条件会导致输出异常:

#include <fcntl.h>
#include "../err.h"

static void charatatime(char *);

int main(void) {
    pid_t pid;

    //TELL_WAIT();

    if ((pid = fork()) < 0) {
        err_sys("fork error");
    } else if (pid == 0) {
        //WAIT_PARENT();
        charatatime("output from child\n");
    } else {
        charatatime("output from parent\n");
        //TELL_CHILD(pid);
    }
    exit(0);
}

static void charatatime(char *str) {
    char *ptr;
    int c;
    setbuf(stdout, NULL);
    for (ptr = str; (c = *ptr++) != 0;)
        putc(c, stdout);
}

而如果将上述注释去掉则消除了异常,当父进程完成时通过 TELL_CHILD 通知子进程,子进程在执行操作前通过 WAIT_PARENT 等待父进程操作完成的消息。

6、exec 函数簇

 1 execl, execlp, execle, execv, execvp, execvpe - execute a file
 2 
 3 #include <unistd.h>
 4 
 5 extern char **environ;
 6 
 7 int execl(const char *path, const char *arg, ...);
 8 int execlp(const char *file, const char *arg, ...);
 9 int execle(const char *path, const char *arg, ..., char * const envp[]);
11 int execv(const char *path, char *const argv[]);
12 int execvp(const char *file, char *const argv[]);
13 int execvpe(const char *file, char *const argv[], char *const envp[]);

调用 exec 函数会将整个进程的 4GB虚拟内存空间,即代码段、数据段、堆栈段完全变成另一个可执行程序。

函数可以根据函数名划分为几种类型:如果包含 l 则表示参数采取参数列表的形式,如果包含 v 表示传递的是参数字符数组的地址。如果包含 p 则表示 filename 采取可执行文件名,它会去PATH环境变量中指定的各目录中搜索可执行文件,但是如果 filename 中包含 /,就将其视为路径名。如果包含 e 表示采用环境变量 env 数组,而不使用当前环境。

上述的函数簇中只有 execve 是对内核的系统调用,剩余的另外6个函数都只是库函数,它们最终都要调用系统调用。

7、更改用户ID 和更改组 ID

Linux 系统中特权以及访问权限是基于用户ID 和组ID的。当程序需要增加新特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID 或组ID,使得新ID具有合适的特权或访问权限。

7.1 setuid/setgid

可以通过 setuid/setgid 设置实际用户ID和有效用户ID,函数介绍如下:

#include <unistd.h>

int setuid(uid_t uid);

int setgid(gid_t gid);

(1)若进程具有超级用户权限,则 setuid 函数将实际用户ID、有效用户ID以及保存的设置用户ID设置为uid;

(2)若进程没有超级用户权限,但是 uid 等于实际用户ID或者保存设置的用户ID,则只将有效用户ID设置为uid。不更改实际用户ID 和保存的设置用户ID。

(3)如果上述两个条件都不满足,则返回 -1。

对于由内核维护的三种用户ID,需要注意:

  • 只有超级用户权限可以更改实际用户ID。通常实际用户ID 是在用户登录时,由 login 程序设置的,因为 login 作为一个超级用户进程,通过调用 setuid 来设置三个用户ID。
  • 仅当对程序文件设置了设置用户ID位时,exec 函数才设置有效用户ID。在 exec 函数执行前后,实际用户ID和实际组ID不变,而有效用户ID和有效组ID是否改变取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。
  • 保存的设置用户ID 是由 exec复制有效用户ID 而得到的。如果设置了文件的设置用户ID位,则在exec 根据文件的用户ID设置了进程的有效用户ID以后,这个副本就被保存起来了。

7.2 setreuid/setregid

#include <unistd.h>

int setreuid(uid_t ruid, uid_t euid);

int setregid(gid_t rgid, gid_t egid);

setreuid 函数用来交换实际用户ID 和有效用户ID。

7.3 seteuid/setegid

#include <unistd.h>

int seteuid(uid_t uid);

int setegid(gid_t gid);

一个非特权用户可以将其有效用户ID 设置为其实际用户ID 或其保存的设置用户ID。对于一个特权用户则可以将有效用户ID设置为 uid。

这里以 at命令为例讲解不同用户权限对程序执行的影响,在 Linux3.2.0 上程序通过 atd 守护进程来运行 at命令,at命令可以在指定时间运行指定的任务,参考 blog.csdn.net/weixin_6569…

为了防止读写没有权限的文件,at 命令和最终代表用户运行命令的守护进程必须在两种特权之间切换:用户特权和守护进程特权。

at 命令的工作步骤如下:

(1)程序文件是由 daemon 进程代表用户运行的。当我们运行此程序时,程序文件权限如下:

image.png

(2)at 程序首先会降低特权,以用户特权运行,通过 setuid 函数把有效用户ID 设置为实际用户ID:

image.png

(3)at 程序以我们的用户特权运行,直到它访问控制哪些命令即将运行,这些命令需要何时运行的配置文件时,at 程序会通过 setuid 函数将有效用户ID设置为 daemon:

image.png

(4)修改文件从而记录了将要运行的命令以及它们的运行时间以后,at 命令通过调用 seteuid 把有效用户ID设置为用户ID,降低它的特权:

image.png

(5)最后由守护进程开始以 root 特权运行,代表用户运行命令,守护进程调用 fork ,子进程调用 setuid 将它的用户ID 更改为我们的用户ID。因为子进程以 root 特权运行,更改了所有的ID 为我们的用户ID,这样守护进程只能访问我们可以访问的文件,能够安全地代表我们执行命令:

image.png

8、进程调度

Unix 系统对于进程的调度策略和调度优先级是由内核确定的。进程可以通过调整友好值选择以更低优先级运行,只有特权进程允许提高调度权限。进程友好值范围在 0 ~ 2*NZERO-1 之间,友好值越小,优先级越高。NZERO 是系统默认的友好值。

#include <unistd.h>

int nice(int incr);    //获取或更改自己进程友好值,不影响其它任何进程


#include <sys/resource.h>

int getpriority(int which, id_t who);   //获取自己或者一组相关进程的友好值

int setpriority(int which, id_t who, int value);   //设置自己或者一组相关进程的友好值

参数解释:

  • which: PRIO_PROCESS 表示进程、PRIO_PGRP 表示进程组、PRIO_USER 表示用户ID。which参数控制who参数是如何解释的。
  • who:选取感兴趣的一个或多个进程,如果 who为0,表示调用进程、进程组或者用户。当 which设为PRIO_USER并且who为0时,使用调用进程的实际用户ID。如果which参数作用于多个进程,则返回所有作用进程中优先级最高的。

这里展示通过调整进程友好值来调度进程的例子:

#include <errno.h>
#include <sys/time.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include "../err.h"

#if defined(MACOS)
#include <sys/syslimits.h>
#elif defined(SOLARIS)
#include <limits.h>
#elif defined(BSD)
#include <sys/param.h>
#endif

unsigned long long count;
struct timeval end;

void checktime(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[]) {
    pid_t pid;
    char *s;
    int nzero, ret;
    int adj = 0;
    
    setbuf(stdout, NULL);
    #if defined(NZERO)
        nzero = NZERO;
    #elif defined(_SC_NZERO)
        nzero = sysconf(_SC_NZERO);
    #else
    #error NZERO undefined
    #endif
    
    printf("NZERO = %d\n", nzero);
    if (argc == 2) 
        adj = strtol(argv[1], NULL, 10);
    gettimeofday(&end, NULL);
    end.tv_sec += 10;     //程序执行10秒

    if ((pid = fork()) < 0) {
        err_sys("fork failed");
    } else if (pid == 0) {   //子进程
        s = "child";
        printf("current nice value in child is %d, adjusting by %d\n", nice(0) + nzero, adj);
        errno = 0;
        if ((ret = nice(adj)) == -1 && errno != 0)
            err_sys("child set scheduling priority");
        printf("now child nice value is %d\n", ret+nzero);
    } else {                 //父进程
        s = "parent";
        printf("current nice value in parent is %d\n", nice(0) + nzero);
    }

    for (;;) {
        if (++count == 0)
            err_exit("%s coutner wrap", s);
        checktime(s);
    }
}

如果使用默认的友好值,NZERO为20,运行时可以携带参数查看进程调度的区别:

image.png