当前位置: 首页 > article >正文

(六) 进程控制

(六) 进程控制

ps(1)

命令可以帮助我们分析本章中的一些示例,所以简单介绍一些参数的组合方式,更详细的信息请查阅 man 手册。

	ps axf 主要用于查看当前系统中进程的 PID 以及执行终端(tty)和状态等信息,更重要的是它能显示出进程的父子关系。

	ps axj  主要用于查看当前系统中进程的 PPID、PID、PGID、SID、TTY 等信息。

	ps axm 显示进程的详细信息,PID 列下面的减号(-)是这个进程中的线程。

	ps ax -L 以 Linux 的形式显示当前系统中的进程列表。

PID 是系统中进程的唯一标志,在系统中使用 pid_t 类型表示,它是一个非负整型。

1号 init 进程是所有进程的祖先进程(但不一定是父进程),内核启动后会启动 init 进程,然后内核就会像一个库一样守在后台等待出现异常等情况的时候再出来处理一下,其它的事情都由 init 进程创建子进程来完成。

进程号是不断向后使用的,当进程号达到最大值的时候,再回到最小一个可用的数值重新使用。

在讲 fork(2) 函数之前先来认识两个函数:

getpid, getppid - get process identification
 
 #include <sys/types.h>
 #include <unistd.h>
 
 pid_t getpid(void);
 pid_t getppid(void);

getpid(2)

获得当前进程 ID。

getppid(2)

获得父进程 ID。

现在轮到我们今天的主角之一:frok(2) 函数上场了。

 fork - create a child process
 
 #include <unistd.h>
 
 pid_t fork(void);

fork(2)

函数的作用就是创建子进程。

调用 fork(2) 创建子进程的时候,刚开始父子进程是一模一样的,就连代码执行到的位置都是一模一样的。

fork(2) 执行一次,但返回两次。它在父进程中的返回值是子进程的 PID,在子进程中的返回值是 0。子进程想要获得父进程的 PID 需要调用 getppid(2) 函数。

一般来说调用fork后会执行 if(依赖fork的返回值) 分支语句,用来区分下面的哪些代码由父进程执行,哪些代码由子进程执行。

我们画幅图来辅助解释上面说的一大坨是什么意思。fork(2) 与写时拷贝
结合上图,我们来聊聊 fork(2) 的前世今生。

最初的 frok(2) 函数在创建子进程的时候会把父进程的数据空间、堆和栈的副本等数据统统给子进程拷贝一份,如果父进程携带的数据量特别大,那么这种情况创建子进程就会比较耗费资源。

这还不是最要命的,万一费了这么大劲创建了一个子进程出来,结果子进程没有使用父进程给它的数据,而只是打印了一句 “Hello World!” 就结束退出了,岂不是白白的浪费了之前的资源开销?

于是聪明的程序猿们想出了一个办法来替代:让父子进程共享同一块数据空间,这样创建子进程的时候就不必担心复制数据耗费的资源较高的问题了,这就是传说中的 vfork(2) 函数实现的效果。

那么问题来了,如果子进程修改了数据会发生什么情况呢?Sorry,这个标准里没说,天知道会发生什么事情,所以 vfork(2) 通常被认为是过时了的函数,已经不推荐大家使用了。

既然上面两个办法都不完美,程序猿们只好再次改良 fork(2) 函数,这次虽然效率稍微比 vfork(2) 稍稍低了那么一点点,但是安全性是可以保证的,这就是写时拷贝技术。

**写时复制(Copy-On-Write,COW)**就是 上图里下面的部分,fork(2) 函数刚刚创建子进程的时候父子进程的数据指向同一块物理内存,但是内核将这些内存的访问变为只读的了,当父子进程中的任何一个想要修改数据的时候,内核会为修改区域的那块内存制作一个副本,并将自己的虚拟地址映射到物理地址的指向修改为副本的地址,从此父子进程自己玩自己的,谁也不影响谁,效率也提高了许多。新分配的副本大小通常是虚拟存储系统中的一“页”。

当然,写是复制技术中所谓制作一个副本,这个是在物理地址中制作的,并非是我们在程序中拿到的那个指针所指向的地址,我们的指针所指向的地址其实是虚拟地址,所以这些动作对用户态程序员是透明的,不需要我们自己进行管理,内核会自动为我们打点好一切。

好了,罗嗦了这么多都是说父进程通过复制一份自己创建了子进程,难道父子进程就是一模一样的吗?

当然其实父子进程之间是有五点不同的:

(1) fork(2) 的返回值不同;

(2) 父子进程的 PID 不相同;

(3) 父子进程的 PPID 不相同; // PPID 就是父进程 PID

(4) 在子进程中资源的利用量清零,否则如果父进程打开了很多资源,子进程能使用的资源量就很少了;

(5) 未决信号和文件锁不继承。

父进程与子进程谁先运行是不确定的,这个执行顺序是由进程调度器决定的,不过 vfork(2) 会保证子进程先运行。进程调度器不是一个工具,是在内核中的一块代码。

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

#include <unistd.h>

int main (void)
{
        pid_t pid;

        printf("[%d] Start!\n", getpid());

        pid = fork();
        if (pid < 0) {
                perror("fork()");
                exit(1);
        } else if (0 == pid) { // child
                printf("[%d] Child process.\n", getpid());
        } else { // parent
                printf("[%d] Parent process.\n", getpid());
        }

        sleep(1000);

        puts("End!");

        return 0;
}

执行结果:

>$ make 1fork
cc     1fork.c   -o 1fork
>$ ./1fork
[3713] Start!
[3714] Child process.
[3713] Parent process.7 [3713] End!8 [3714] End!

新打开一个终端,验证它们的父子进程关系:

 >$ ps axf
  3565 pts/1    Ss     0:00  \_ bash
  3713 pts/1    S+     0:00  |   \_ ./1fork
  3714 pts/1    S+     0:00  |       \_ ./1fork

从 ps(1) 命令可以看出来,3713 进程确实产生了一个子进程 3714。

但是这里面有一个问题,我们重新执行一遍这个程序,这次将输出重定向到文件中。

>$ ./1fork > result.txt
>$ cat result.txt
[3807] Start!
[3807] Parent process.
End!
[3807] Start!
[3808] Child process.
End!

发现有什么不同了吗?父进程竟然输出了两次 Start!,这是为什么呢?

其实第二次 Start! 并不是父进程输出的,而是子进程输出的。但是为什么 PID 却是父进程的呢?

其实这是因为行缓冲变成了全缓冲导致的,之前我们讲过,标准输出是行缓冲模式,而系统默认的是全缓冲模式。所以当我们将它输出到控制台的时候是可以得到预期结果的,但是一旦重定向到文件的时候就由行缓冲模式变成了全缓冲模式,而子进程产生的时候是会复制父进程的缓冲区的数据的,所以子进程刷新缓冲区的时候子进程也会将从父进程缓冲区中复制到的内容刷新出来。因此,在使用 fork(2) 产生子进程之前一定要使用 fflush(NULL) 刷新所有缓冲区!

那么大家再考虑一个问题,当程序运行的时候,为什么子进程的输出结果是在当前 shell 中,而没有打开一个新的 shell 呢?

这是因为子进程被创建的时候会复制父进程所有打开的文件描述符,所谓的“复制”是指就像执行了 dup(2) 函数一样,父子进程每个相同的打开的文件描述符共享一个文件表项。

而父进程默认开启了 0(stdin)、1(stdout)、2(stderr) 三个文件描述符,所以子进程中也同样存在这三个文件描述符。

既然子进程会复制父进程的文件描述符,也就是说如果父进程在创建子进程之前关闭了三个标准的文件描述符,那么子进程也就没有这三个文件描述符可以使用了。

从上面的 ps(1) 命令执行结果可以看出来,我们的父进程是 bash 的子进程,所以我们父进程的三个标准文件描述符是从 bash 中复制过来的。

再看一个例子:

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

#include <sys/types.h>

int main (void)
{
        pid_t pid;
        int i = 0;

        for (i = 0; i < 10; i++) {
                fflush(NULL);
                pid = fork();
                if (pid < 0) {
                        perror("fork()");
                        exit(1);
                } else if (0 == pid) {
                        printf("pid = %d\n", getpid());
                        exit(0);
                }
        }

        sleep(100);

        return 0;
}

运行:

>$ make 2fork
cc     2fork.c   -o 2fork
>$ ./2fork
pid = 5101
pid = 5103
pid = 5105
pid = 5107
pid = 5108
pid = 5106
pid = 5104
pid = 5102
pid = 5110
pid = 5109
# ... 这里父进程处于 sleep 状态,便于我们新打开一个终端查看进程状态
>$ ps axf
 3565 pts/1    Ss     0:00  \_ bash
 5100 pts/1    S+     0:00  |   \_ ./2fork
 5101 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5102 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5103 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5104 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5105 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5106 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5107 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5108 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5109 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
 5110 pts/1    Z+     0:00  |       \_ [2fork] <defunct>
>$

从上面 ps(1) 命令的执行结果来看,所有子进程的父进程都变成了 1 号 init 进程。

很多人会认为僵尸进程不应该出现,它们会占用大量的资源。其实不然,它们在内核中仅仅保留一个结构体,也就是自身的状态信息,其它的资源都释放了。但是它占用了一个重要的系统资源:PID,因为系统中 PID 的数量是有限的,所以及时释放僵尸进程还是很有必要的。

我们的父进程没有对子进程进行收尸,所以才会出现这样的情况。其实对于这种转瞬即逝的程序而言不会有什么危害,但是假设父进程是一个要不断执行一个月的程序,而它却又不为子进程收尸,那么子进程就会占用这些 PID 一个月之久,那么就可能出现问题了。

所以在一个完善的程序中,父进程是要为子进程收尸的,至于如何为子进程收尸,下面我们会讲,fork(2) 函数就先讨论到这里。

vfork(2)

vfork - create a child process and block parent

#include <sys/types.h>
#include <unistd.h>

pid_t vfork(void);

   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       vfork():
           Since glibc 2.12:
               _BSD_SOURCE ||
                   (_XOPEN_SOURCE >= 500 ||
                       _XOPEN_SOURCE && _XOPEN_SOURCE_EXTENDED) &&
                   !(_POSIX_C_SOURCE >= 200809L || _XOPEN_SOURCE >= 700)
           Before glibc 2.12:
               _BSD_SOURCE || _XOPEN_SOURCE >= 500 ||
               _XOPEN_SOURCE && _XOPEN_SOURCE_EXTENDED

vfork(2) 函数在上面介绍写时拷贝技术的时候我们就提到了它的工作方式,并且也说了这是一个过时的函数,不推荐大家使用了,简单了解一下就可以了。

使用 vfork(2) 函数创建的子进程除了与父进程共享数据外,vfork(2) 还保证子进程先运行,在子进程调用 exec(3) 函数族 或 exit(3)(_exit(2)、_Exit(2)) 函数前父进程处于休眠状态。

另外,使用 vfork(2) 创建的子进程是不允许使用 return 语句返回的,只能使用 exit(3) 函数族的函数结束,否则会被信号杀死,父进程则不受这个限制。

wait(2)

wait, waitpid, waitid - wait for process to change state

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

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

wait(2) 阻塞的等待子进程资源的释放,相当于上面提到的“收尸”。

每次调用 wait(2) 函数会为一个子进程收尸,而 wait(2) 函数并没有让我们指定是哪个特定的子进程。如果想要为特定的子进程收尸,需要调用 waitpid(2) 函数。

收尸这件事只能是父进程对子进程做,而且只能对自己的子进程做。子进程是不能为父进程收尸的,父进程也不能为别人的子进程收尸。

参数列表:

status:由函数回填,表示子进程的退出状态。如果填 NULL,表示仅回收资源,并不关心子进程的退出状态。

status 参数可以使用以下的宏进行解析:

描述
WIFEXITED(status)返回真表示子进程正常终止,返回假表示子进程异常终止。正常与异常终止的8种方式上面讲过。
WEXITSTATUS(status)返回子进程的退出码。只有上一个宏返回正常终止时才能使用,异常终止是不会有返回值的。
WTERMSIG(status)可以获得子进程具体被哪个信号杀死了。
WIFSTOPPED(status)子进程是否被信号 stop 了。stop 和杀死是不同的,stop 的进程可以被恢复(resumed)。
WSTOPSIG(status)如果子进程是被信号 stop 了,可以查看具体是被哪个信号 stop 了。
WIFCONTINUED(status)如果子进程被 stop 了,可以查看它是否被 resumed 了。

表1 解析 wait(2) 函数 status 参数的宏

pid:一共分为四种情况:

pid 参数解释
< -1为归属于进程组 ID 为 pid 参数的绝对值的进程组中的任何一个子进程收尸
== -1为任意一个子进程收尸
== 0为与父进程同一个进程组中的任意一个子进程收尸
> 0为一个 PID 等于参数 pid 的子进程收尸

表2 wait(2) 函数 pid 参数的取值说明

options:为特殊要求;这个参数是这个函数的设计精髓。可以通过 WNOHANG 宏要求 waitpid(2) 函数以非阻塞的形式为子进程收尸,这个也是最常用的特殊要求。

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

#include <sys/types.h>
#include <sys/wait.h>

int main (void)
{
        pid_t pid;
        int i = 0;

        for (i = 0; i < 10; i++) {
                fflush(NULL);
                pid = fork();
                if (pid < 0) {
                        perror("fork()");
                        exit(1);
                } else if (0 == pid) {
                        printf("pid = %d\n", getpid());
                        exit(0);
                }
        }

        // 为所有的子进程收尸
        for (i = 0; i < 10; i++) {
                wait(NULL);
        }

        return 0;
}

大家有没有想过为什么要由父进程为子进程收尸呢,为什么不让子进程结束后自动释放所有资源?试想如果没有收尸这步会发生什么。

假设父进程需要创建一个子进程并且要让它做 3 秒钟的事情,很不巧子进程刚启动就出现了一个异常然后就挂掉了,并且直接释放了自己的资源。
而此时系统 PID 资源紧张,很快已死掉的子进程的 PID 被分配给了其它进程,而父进程此时并不知道手里的子进程的 PID 已经不属于它了。

如果这时候父进程后悔执行子进程了,它要 kill 掉这个子进程。。。。。后果就是系统大乱对吧。

而使用了收尸技术之后,子进程状态改变时会给父进程发送一个 SIGCHLD 信号,wait(2) 函数其实就是阻塞等待被这个信号打断,然后为子进程收尸。

系统通过收尸这种机制来保证父进程未执行收尸动作之前,手里拿到的子进程 PID 一定是有效的了(即使子进程已死掉,但是这个 PID 依然是属于父进程的子进程的,而不会归属于别人)。

exec(3)

execl, execlp, execle, execv, execvp, execvpe - execute a file

#include <unistd.h>

extern char **environ;

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
                  ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
                   char *const envp[]);

   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       execvpe(): _GNU_SOURCE

我们再来看上面第一个 fork(2) 代码的栗子执行时使用 ps -axf 命令查看父子依赖关系的结果:

 >$ ps axf
  3565 pts/1    Ss     0:00  \_ bash
  3713 pts/1    S+     0:00  |   \_ ./1fork
  3714 pts/1    S+     0:00  |       \_ ./1fork
 >$

我们知道 fork(2) 创建出来的子进程是通过复制父进程的形式实现的,但是我们的父进程又是 bash 的子进程,为什么 bash 没有创建出来一个与自己一模一样的子进程呢?

这就是 exec(3) 函数族的功劳了。

它可以使调用的它进程“外壳”不变,“内容物”改变为新的东西。“外壳”就是父子关系、PID 等东西,“内容物”其实是指一个新的可执行程序。也就是说 exec(3) 函数会将调用它的进程完全(整个4GB虚拟内存空间,即代码段、数据段、堆栈等等)变成另一个可执行程序,但父子关系、PID 等东西不会改变。

在执行了 exec(3) 函数族的函数之后,整个进程的地址空间会立即被替换,所以 exec(3) 下面的代码全部都不会再执行了,替代的是新程序的代码段。

缓冲区也会被新的程序所替换,所以在执行 exec(3) 之前要使用 fflush(NULL) 刷新所有的缓冲区。这样父进程才会让它缓冲区中的数据到达它们该去的地方,而不是在数据到达目的地之前缓冲区就被覆盖掉。

参数列表:

path:要执行的二进制程序路径

arg:传递给 path 程序的 argv 参数,第一个是 argv[0],其它参数从第二个开始。

…:argv 的后续参数,最后一个参数是 NULL,表示变长参数列表的结束。

看上去 execl(3)、execlp(3) 像是变参函数,execle(3) 像是定参函数,其实正好是反过来的,execl(3) 和 execlp(3) 是定参的,而 execle(3) 函数是变参的。

下面我们来看一个 fork(2) + exec(3) + wait(2) 最经典的用法:

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

/**
 * 创建子进程 date,参数是 +%s
 * 相当于在 shell 中执行 date +%s 命令
 */
int main()
{
    pid_t pid;

    puts("Begin!");

    fflush(NULL);

    pid = fork();
    if(pid < 0)
    {
        perror("fork()");
        exit(1);
    }

    if(pid == 0)    // child
    {
        execl("/bin/date","date","+%s",NULL);
        perror("execl()");
        exit(1);
    }

    wait(NULL);

    puts("End!");

    exit(0);
}

更改用户 ID 和更改组 ID

在 *nux 系统中,特权和访问控制是基于用户 ID 和用户组 ID 的,所以当我们需要使用特权或访问无权访问的文件时需要切换用户 ID 或用户组 ID。

uid

r(real) 用于保存用户权限

e(effective) 鉴定用户权限时使用

s 与 real 相同,所以有些系统不支持

gid

r(real) 用于保存用户组权限

e(effective) 鉴定用户组权限时使用

s 与 real 相同,所以有些系统不支持

比如普通用户没有查看 /etc/shadow 文件,但是为什么有权限修改自己的密码呢?

 >$ which passwd 
 /usr/bin/passwd
 >$ ls -l /usr/bin/passwd 
 -rwsr-xr-x 1 root root 47032  217  2014 /usr/bin/passwd
 $ ls -l /etc/shadow
 ---------- 1 root root 1899 Apr 1 16:25 /etc/shadow

这是因为 passwd(1) 命令是具有 U+S 权限的,用户在使用这个程序的时候身份会切换为这个程序文件所有者的身份。

G+S 与 U+S 类似,只不过执行的瞬间身份会切换为与程序归属用户组相同的组权限。

改变用户 ID 和组 ID 可以使用 setuid(2) 和 setgid(2) 函数实现,这两个函数使用起来都比较简单,需要用的童鞋自己查阅 main 手册吧。

解释器文件

解释器文件其实就是脚本。

做一个系统级开发工程师需要具备的素质至少精通2门语言,一门面向过程,一门面向对象,还要精通至少一门脚本语言,如 shell、python等,还要具备扎实的网络知识和一点硬件知识。

解释器是一个二进制的可执行文件。就是为了用一个可执行的二进制文件解释执行解释器文件中的命令。

#! 用于装载解释器

例如:

#!/bin/shell 装载了 /bin/shell 作为解释器

#!/bin/cat 装载了 /bin/cat 作为解释器

那么装载解释器之后为什么不会递归执行装载解释器这行代码呢?因为根据约定,脚本中的 # 表示注释,所以解释器在解析这个脚本的时候不会看到这行装载解释器的命令。

装载解释器的步骤由内核 exec(3) 系统调用来完成,如果使用 exec(3) 函数来调用解释器文件,实际上 exec(3) 函数并没有执行解释器文件,而是执行了解释器文件中装载的解释器,由它来执行解释器文件中的指令。

system(3)

 system - execute a shell command
 
 #include <stdlib.h>
 
 int system(const char *command);

该函数可以执行一条系统命令,是通过调用 /bin/sh -c command 实现的。

其实我们可以猜测一下 system(3) 命令是如何实现的,下面是伪代码:

pid_t pid;

pid = fork();
if(pid < 0)
{
    perror("fork()");
    exit(1);
}

if(pid == 0)    // child
{
    // system("date +%s");
    execl("/bin/sh","sh","-c","date +%s",NULL);
    perror("execl()");
    exit(1);
}

wait(NULL);

exit(0);

进程会计

连 POSIX 标准都不支持,使用比较少

它是典型的事实标准,各个系统的实现都不统一,所以建议少用为妙。

 acct - switch process accounting on or off
 
 #include <unistd.h>
 
 int acct(const char *filename);

作用是将进程的相关信息写入到 filename 所指定的文件中。

用户标识

getlogin, getlogin_r, cuserid - get username
 
 #include <unistd.h>
 
 char *getlogin(void);
 int getlogin_r(char *buf, size_t bufsize);

能够不受任何因素影响的获取当前终端的用户名。

不受任何因素影响是指,比如我们用 su(1) 等命令切换了用户,getlogin(3) 函数获得到的仍然是原始的用户名。

进程调度

用于控制进程调度优先级,一般不会调整进程的优先级。

进程时间

 times - get process and waited-for child process times
 
 #include <sys/times.h>
 
 clock_t times(struct tms *buffer);

该函数获得的是进程的执行时间。

clock_t 是滴答数。位于秒级以下,具体的与秒的换算值需要通过 sysconf(_SC_CLK_TCK) 宏获得。

终端

真正意义上的终端是“笨设备”,只能接收命令的输入并返回结果。你问它 1+1=? 它也不知道,它只能把你的问题传给计算机,再把计算机返回的结果显示给你。

它出现在计算机既昂贵又庞大的年代。那时候的计算机昂贵到了只有一部分公司买得起、另一部分公司买不起,而且有些公司只能买一台,买第二台就要破产了的程度。

所以这么昂贵的设备如果只能给一个人使用太浪费了,于是为了让计算机可以被多人使用,就出现了终端这种设备。

接下来我们简单的聊聊 Linux 是如何使用户登录的。

Linux 用户登录过程
图1 Linux 用户登录过程

如图1 所示,内核自举时创建 1 号 init 进程,init 对每一个终端执行 fork(2) + exec(3) + getty(1) 命令,getty(1) 命令的作用是要求用户输入用户名。

等待用户输入完成用户名后,getty(1) 会 exec(3) + login(1)。

login(1) 命令首先根据用户名通过 getpwnam(3) 函数得到用户的口令文件登录项,然后调用 getpass(3) 函数以屏蔽回显的形式获得用户的密码,最后再通过 crypt(3) 函数将加密后的用户口令与阴影口令文件用户登录项中的 pw_passwd 字段相比较,认证失败几次之后就会回到上图的第一步,init 进程将重新执行 fork(2) + exec(3) + getty(1)。

如果认证成功则启动用户登录 shell,并使用 chown(2) 更改当前登录终端的所有权,使登录用户成为它的所有者。登录之前的步骤都是 root 身份,所以真正用户权限被降下来就是在这个时候发生的。

当然 login 要做的事情不仅仅只有这点儿,它还要做许多其它需要为用户初始化的事情。

说句题外话,大家注意到了没有,如果获取用户名或密码的时候函数的编写者敢使用类似 scanf(3) 这样的函数读取用户输入,则很容易遭受缓冲区溢出攻击。

会话(Session)

一次成功的终端登录就是一个会话。现在一次 shell 的成功登录,相当于那时候终端的成功登录。会话相当于是进程组的容器,它能承载一个或多个进程组。

进程组

进程组用来承载进程,一个进程组中有一个或多个进程,它是一个或多个进程的集合(也可以看作是容器)。一个进程不但拥有唯一的 PID,同时也属于一个进程组。

如何产生一个进程组呢?很简单:

1 # 使用管道可以用一条命令产生一个进程组。
2 ls | more

进程组分为前台进程组和后台进程组。

一个会话中只能有一个前台进程组,也可以没有前台进程组。

终端设备(如键盘)只能与前台进程通讯,不能与后台进程通讯,根据约定,如果终端设备与一个后台进程关联,就会杀掉这个后台进程。

什么是前台进程组呢?比如你正在使用 tar 命令进行打包的时候是无法再输入其它命令的。如果 tar 命令执行的时间很长,我们就会在命令后面添加一个 & 参数,把它放到后台去运行。

ps(1) 命令的 SID(Session ID)列 就是程序运行的会话 ID。

进程是先出现的,后来人们发现进程可以拆分为多个小任务分别执行,于是便出现了线程的概念,这个到后面线程的章节会详细讨论。

如今进程已经退化为容器了,它的存在就是为了承载线程。PID 看似是进程号,实际上是线程在消耗它。

进程和线程只是我们的说法,内核中只能看到线程,内核所谓的进程管理其实就是线程管理,内核永远以线程为单位执行任务。

总结来说:会话用来承载进程组,进程组用来承载进程,进程用来承载线程。

第九章了解这几个概念就差不多了,还记得我们前面提到的 myshell 吗,用 fork(2) + exec(3) + wait(2) 来实现一个可以执行外部命令的 shell。如果你想实现一个支持内部命令的 shell 那么可以仔细学习一下第九章的内容,shell 内部命令处理的主要知识点都在第九章。我们这里就不对第九章讨论得那么详细了,感兴趣的小伙伴可以自己看看书,有什么疑问可以加入博客上方标注的邮件列表讨论。

setsid(2)

 setsid - create session and set process group ID
 
 #include <unistd.h>
 
 pid_t setsid(void);

创建一个会话并设置进程组的ID。这个函数是我们在第 9 章最有价值的函数,没有这个函数,我们后面就无法创建守护进程。

调用者不能是进程组组长,调用者在调用之后自动变为新进程组组长,并且脱离控制终端,进程 ID 将被设为进程组 ID 和会话 ID,所以守护进程通常 PID、PGID、SID 是相同的。通常的用法是父进程 fork(2) 一个子进程,然后子进程调用 setsid(2) 将自己变成守护进程,父进程退出即可。

守护进程

守护进程的栗子我就不写了,因为《APUE》第三版 P375 图13-1 已经有详细的代码了,我针对书上的栗子总结一下。

守护进程的特点:

1)脱离控制终端,ps(1) axj tty 为问号(?)。

2)是进程组的 leader,也就是 PID 和 PGID 相同。

3)通常没有父进程,由 1 号  init 接管。

4)创建了一个新会话,是 session 的 leader,所以 PID 与 SID 相同。

使用 ps(1) axj 命令查看,PID、PGID、SID 相同的进程就是守护进程。

守护进程也可以使用标准输出,但是不符合常理了,因为守护进程没有控制终端,所以守护进程一般会关闭或重定向标准输入输出流。

写守护进程的时候我们会切换工作路径,把它切换到一个一定会存在的路径,比如 /。因为假设你的守护进程是在一个可卸载设备(如U盘)上被启动的,如果不修改工作路径,该设备无法被卸载。

调用 umask(2) 是为了将文件模式创建掩码设置为一个已知值,因为通过继承得来的掩码可能会被设置为拒绝某些权限,如果守护进程中需要这些权限则要设置它。

对于书上的栗子,有两点要吐槽:

1)SIGHUP 信号用于通知服务进程软重启,比如修改了某服务的配置文件之后可以通过给服务进程发 SIGHUP 信号使它重新读取配置文件,所以如果没有特殊要求不必忽略该信号。

2)如果没有特殊要求,不必关闭所有的文件描述符,仅关闭标准输入、标准输出和标准错误即可。

系统日志

守护进程不应使用标准输出,那么当守护进程需要记录一些事件或者是错误的时候怎么办呢?那就要采用系统日志了。

系统日志一般保存在 /var/log/ 目录下,但是这个目录下的日志文件权限几乎都是只有 root 才能读写,那么普通用户的日志如何写入呢?这就需要借助系统日志函数来写日志了。

root 用户授权给 syslogd 服务专门写日志,然后其它程序都需要通过封装好的一系列函数调用 syslogd 服务来记录日志。这样就提高了日志的安全性了,可以防止日志文件被非法篡改。

closelog, openlog, syslog - send messages to the system logger

#include <syslog.h>

void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);

openlog(3) 函数并非是打开日志文件,而是与 syslogd 服务建立链接,表示当前进程要写日志。
参数列表:
   ident:表明自己的身份,由程序员自行指定,写什么都行。
   option:要在日志中附加什么内容,多个选项用按位或链接。LOG_PID 是附加 PID,这个是最常用的。
   facility:消息来源。一般只能指定一个。

消息来源含义
LOG_CRON消息来自定时任务
LOG_DAEMON消息来自守护进程
LOG_FTP消息来自 FTP 服务
LOG_KERN消息来自内核
LOG_USER默认,常规用户级别消息
表1 facility 参数的常见需选项

syslog(3) 函数用于提交日志内容,参数列表:

priority:优先级。详见下表:

级别 含义
LOG_EMERG「严重」导致系统不可用的问题
LOG_ALERT「严重」必须立即处理的情况
LOG_CRIT「严重」临界条件
LOG_ERR「严重」错误
LOG_WARNING警告
LOG_NOTICE正常
LOG_INFO信息
LOG_DEBUG调试
表2 日志优先级

以 LOG_ERR 为分界线,如果遇到了程序无法继续运行的问题,要报 LOG_ERR 以上的级别(包括 LOG_ERR)。

如果遇到的问题不会影响程序继续运行,报 LOG_ERR 以下级别的就可以了。

日志太多肯定对磁盘空间的要求就比较高,而且无用的日志太多会影响日志审计。日志文件中会记录哪些级别的日志是在配置文件中配置的,默认的情况是 LOG_DEBUG 以上级别的日志都会被记录。

format:类似于 printf(3) 函数的格式化字符串。注意不要使用转义字符 \n,否则日志中会记录一个字符串"\n"而不是记录一个换行符。

…:format 中占位符的参数。

closelog(3) 表示日志写入结束。

单实例守护进程

有些守护进程需要在同一时间只能有一个实例在运行,它们称为单实例守护进程。它们在启动的时候会在 /var/run 下面创建一个进程锁文件,守护进程启动的时候会先判断这个锁文件是否存在,如果已存在就报错并退出,如果不存在就继续运行并创建一个锁文件,退出的时候再删除它。

守护进程如果想要开机自动启动,可以配置到自动启动脚本中

/etc/rc.d/rc.local

http://www.kler.cn/news/359964.html

相关文章:

  • Android中的SSL/TLS加密及其作用
  • centos系统查看端口占用情况并杀死进程
  • Java缓存技术(java内置缓存,redis,Ehcache,Caffeine的基本使用方法及其介绍)
  • 机器学习驱动的智能化电池管理技术与应用
  • 音乐如何去除人声只剩背景音乐?轻松享受背景旋律
  • 戴尔电脑win11找不到D盘的解决办法
  • Python 学习笔记(十三)—— urllib获取网页
  • `connection.commit()` 和 `res = cur.fetchall()`的区别是什么
  • RTMP、FFmpeg安装测试
  • Java中巧用 parseInt 实现高效数值变换,进制转换
  • 13. Request请求与Response响应
  • 后端绘制二维码图片(支持带Logo)
  • Android中的内存泄漏及其检测方式
  • 配置Typescript环境
  • git禁用 SSL 证书验证
  • 嵌软面试一百问(持续更新中)
  • 二叉树遍历(前序、中序、后续)
  • OpenCV高级图形用户界面(14)交互式地选择一个或多个感兴趣区域函数selectROIs()的使用
  • 【大模型实战篇】大模型分词算法Unigram及代码示例
  • 【进阶OpenCV】 (11)--DNN板块--实现风格迁移