Skip to content

进程控制开发

目录

进程控制

想要聊进程控制开发,就先要知道进程控制是什么?

好在,操作系统原理中讲了这些内容。多继承并发可以提高资源的使用效率,为了恰当管理这些进程,操作系统使用一些方法来管理进程,在开发的过程中我们需要了解进程的机制,以便编写出合适的多进程程序。

本章的进程控制开发,大多涉及父子进程,以实现比较复杂的程序,比如守护进程,单程序多进程。

首先我们可以使用fork()函数来创建子进程,完成多线程开发。

然后我们可以使用exec()函数族来执行另外的可执行文件。

wait()函数则用来等待子进程的结束,方便父进程的控制和管理。

进程结构

一文搞定 Linux 进程和线程(详细图解) | CSDN

  • 数据段:存放全局变量,常数以及静态数据
    • 普通数据段:可读可写/只读数据段,存放静态初始化的全局变量或常量
    • BSS 段:存放未初始化的全局变量
    • 堆:存放动态分配的数据
  • 代码段:存放程序的代码
  • 堆栈段:存放的是子程序的返回地址、子程序的参数以及程序的局部变量等

aHR0cHM6Ly9pbWcyMDIwLmNuYmxvZ3MuY29tL2Jsb2cvMTUxNTExMS8yMDIwMDcvMTUxNTExMS0yMDIwMDcxMDA2NTY0NTUyMy0xMjU3MTIzNDEyLnBuZw.png

Linux 下的进程模式和类型

在 Linux 系统中,进程的执行模式划分为用户模式和内核模式。如果当前运行的是用户程序、应用程序或者内核之外的系统程序,那么对应进程就在用户模式下运行;如果在用户程序执行过程中出现系统调用或者发生中断事件,那么就要运行操作系统(即核心)程序,进程模式就变成内核模式。在内核模式下运行的进程可以执行机器的特权指令,而且此时该进程的运行不受用户的干扰,即使是 root 用户也不能干扰内核模式下进程的运行。

用户进程既可以在用户模式下运行,也可以在内核模式下运行

启动进程

手工启动

用户通过 shell 命令启动进程

  1. 前台启动
  2. 后台启动

前台启动是手工启动一个进程的最常用方式。一般地,当用户键入一个命令如“ls -l”时,就已经启动了一个进程,并且是一个前台的进程。

后台启动往往是在该进程非常耗时,且用户也不急着需要结果的时候启动的。比如用户要启动一个需要长时间运行的格式化文本文件的进程。为了不使整个 shell 在格式化过程中都处于“瘫痪”状态,从后台启动这个进程是明智的选择。

调度启动

系统通过用户的设置自行启动进程

有时,系统需要进行一些比较费时而且占用资源的维护工作,并且这些工作适合在深夜无人值守的时候进行,这时用户就可以事先进行调度安排,指定任务运行的时间或者场合,到时候系统就会自动完成这一切工作。

使用调度启动进程有几个常用的命令,如 at 命令在指定时刻执行相关进程,cron 命令可以自动周期性地执行相关进程,在需要使用时读者可以查看相关帮助手册。

调度进程

调度进程包括对进程的中断操作、改变优先级、查看进程状态等,在 Linux 下可以使用相关的系统命令实现其操作。

选 项参 数 含 义
ps查看系统中的进程
top动态显示系统中的进程
nice按用户指定的优先级运行
renice改变正在运行进程的优先级
kill向进程发送信号(包括后台进程)
crontab用于安装、删除或者列出用于驱动 cron 后台进程的任务。
bg将挂起的进程放到后台执行

Linux 进程控制编程

getpid()函数

需要头文件:<unistd.h>

c
#include <unistd.h>

// ...
// 返回值:调用进程的进程ID
pid_t getpid(void);
// 返回值:调用进程的父进程ID
pid_t getppid(void);
// ...

fork | 创建进程

参考资料:

windows 下没有 fork 函数,需要找其他函数替代

需要头文件:

c
#include <sys/types.h> // 提供类型 pid_t 的定义
#include <unistd.h>

fork()函数用于从已存在的进程中创建一个新进程。新进程称为子进程,而原进程称为父进程。使用 fork()函数得到的子进程是父进程的一个复制品,它从父进程处继承了:

  • 整个进程的地址空间
    • 进程上下文
    • 代码段
    • 进程堆栈
    • 内存信息
    • 打开的文件描述符
    • 信号控制设定
    • 进程优先级
    • 进程组号
    • 当前工作目录
    • 根目录
    • 资源限制
    • 控制终

而子进程所独有的只有它的进程号、资源使用和计时器等。

父进程中执行 fork()函数时,父进程会复制出一个子进程,而且父子进程的代码从 fork()函数的返回开始分别在两个地址空间中同时运行。从而两个进程分别获得其所属 fork()的返回值,其中在父进程中的返回值是子进程的进程号,而在子进程中返回 0。因此,可以通过返回值来判定该进程是父进程还是子进程

函数原型

c
/**
 * @brief 创建一个子进程
 *
 * @return pid_t 返回值为 0 时为子进程,返回值为非 0 时为父进程,返回值为 -1 时表示创建子进程失败
 */
pid_t fork(void);

示例

c
/* fork.c */
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    pid_t result;

    /*调用 fork()函数*/
    result = fork();

    /*通过 result 的值来判断 fork()函数的返回情况,首先进行出错处理*/
    if (result == -1)
    {
        printf("Fork error\n");
    }
    else if (result == 0) /*返回值为 0 代表子进程*/
    {
        printf("The returned value is %d\n  In child process!!\nMy PID is %d\n", result, getpid());
    }
    else /*返回值大于 0 代表父进程*/
    {
        printf("The returned value is %d\n  In father process!!\nMy PID is %d\n", result, getpid());
    }
    return result;
}

由于 fork()完整地复制了父进程的整个地址空间,因此执行速度是比较慢的。为了加快 fork()的执行速度,有些 UNIX 系统设计者创建了 vfork()。vfork()也能创建新进程,但它不产生父进程的副本。它是通过允许父子进程可访问相同物理内存从而伪装了对进程地址空间的真实拷贝,当子进程需要改变内存中数据时才复制父进程。这就是著名的“写操作时复制”(copy-on-write)技术。

现在很多嵌入式 Linux 系统的 fork()函数调用都采用 vfork()函数的实现方式,实际上 uClinux 所有的多进程管理都通过 vfork()来实现。

exec | 执行另外的进程

参考资料: The Single UNIX ® Specification, Version 2

在 Linux 中,exec 函数族(一组名字类似的函数)可以让你执行系统里另外的可执行文件(比如ps -ef)。exec 函数族的函数都是在当前进程的基础上执行另外的程序,也就是说,执行 exec 函数族的函数后,当前进程的代码段、数据段、堆栈段等都会被新程序的代码段、数据段、堆栈段等覆盖掉,从而使得当前进程成为新程序的一个实例。所以 exec 函数族的函数一般不会返回,除非出错了。

  • 根据文件名或者文件目录找到可执行文件

一般情况下,在 Linux 中使用 exec 函数族有 2 种情况:

  1. 当进程认为自己不能再为系统和用户做出任何贡献时,就可以调用 exec 函数族中的任意一个函数让自己重生。(不懂)
  2. 一个进程调用另一个进程:先用 fork()函数创建一个子进程,然后在子进程中调用 exec 函数族中的任意一个函数,这样就可以在子进程中执行另外一个程序了。

函数原型

c
#include <unistd.h>
/**
 * @brief 根据文件名或者文件目录找到可执行文件
 * @param path 文件名或者文件目录
 * @param file 文件名
 * @param arg0, arg1, ... 执行程序时传入的参数
 * @param argv[] 执行程序时传入的参数(使用数组)
 * @param envp 环境变量
 * @return int 成功不返回,失败返回 -1
 */
int execl(const char *path, const char *arg0, const char *arg1, ...);
int execv(const char *path, char *const argv[]);

int execle(const char *path, const char *arg0, const char *arg1, ..., char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[]);

int execlp(const char *file, const char *arg0, const char *arg1, ...);
int execvp(const char *file, char *const argv[]);

注意观察区别:

调用程序的方式\传参的方式->l(逐个例举参数)v (传入参数数组指针)
通过路径找到可执行文件execlexecv
通过路径找到可执行文件,同时传入环境变量execleexecve
通过文件名找到可执行execlpexecvp
  • 参数必须以 NULL 表示结束

  • 如果使用逐个列举方式,那么要把它强制转化成一个字符指针,否则 exec 将会把它解释为一个整型参数,如果一个整型数的长度 char*的长度不同,那么 exec 函数就会报错

  • 意目录必须以“/”开头,否则将其视为文件名

  • 使用通过文件名找到可执行的方式执行程序的时候,系统会在默认的环境变量 PATH 中寻找该可执行文件

  • 找不到文件或路径,此时 errno 被设置为 ENOENT;

  • 数组 argv 和 envp 忘记用 NULL 结束,此时 errno 被设置为 EFAULT;

  • 没有对应可执行文件的运行权限,此时 errno 被设置为 EACCES。

示例

c
/*execlp.c*/
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
    int ret =0;
    if (fork() == 0)
    {
        /*调用 execlp()函数,这里相当于调用了"ps -ef"命令*/
        if ((ret = execlp("ps", "ps", "-ef", NULL)) < 0)
        {
            printf("Execlp error\n");
        }
    }
}

// $ ./execlp
//  PID TTY Uid Size State Command
//  1 root 1832 S init
//  2 root 0 S [keventd]
//  3 root 0 S [ksoftirqd_CPU0]
//  4 root 0 S [kswapd]
//  5 root 0 S [bdflush]
//  6 root 0 S [kupdated]
//  7 root 0 S [mtdblockd]
//  8 root 0 S [khubd]
//  35 root 2104 S /bin/bash /usr/etc/rc.local
//  36 root 2324 S /bin/bash
//  41 root 1364 S /sbin/inetd
//  53 root 14260 S /Qtopia/qtopia-free-1.7.0/bin/qpe -qws
//  54 root 11672 S quicklauncher
//  65 root 0 S [usb-storage-0]
//  66 root 0 S [scsi_eh_0]
//  83 root 2020 R ps -ef




// $ ps -ef
execle("/usr/bin/env", "env", NULL, envp)
// $ env
execve("/usr/bin/env", arg, envp)

exit()&_exit() | 退出进程

参考资料:

c
#include <stdlib.h> // exit()函数
#include <unistd.h> // _exit()函数

exit()和_exit()函数都是用来终止进程的。当程序执行到 exit()或_exit()时,进程会无条件地停止剩下的所有操作,清除包括 PCB 在内的各种数据结构,并终止本进程的运行

    程序运行
    ↓     ↓
exit()    _exit()
  ↓          |
调用         |
退出处理函数  |
    ↓        |
清理I/O缓冲   |
    ↓        ↓
  调用exit系统调用

   进程终止运行

_exit():直接使进程停止运行, 清除其使用的内存空间,并清除其在内核中的各种数据结构,可能会丢失在缓存中未写入/输出的数据

exit():先调用清理函数,然后再调用_exit(),清理函数是在调用 exit()时注册的,它们是在进程终止时自动调用的,清理函数的作用是释放进程所占用的资源,如关闭文件、释放内存等

函数原型

c
/**
 * @brief 退出进程
 * @param status 退出状态,0 表示正常退出,非 0 表示异常退出
 * @return void
 */
void exit(int status);
void _exit(int status);

可以通过改变 status 的值来表示进程的退出状态,这个状态值可以被父进程用来判断子进程的退出状态,从而做出不同的响应

示例

c
/* _exit.c */
#include <stdio.h>
#include <unistd.h>
int main()
{

    printf("Using _exit...\n");
    // printf会在遇到\n时刷新缓冲区,所以这里不会输出
    printf("This is the content in buffer");
    // _exit(0);
    exit(0);
}

在一个进程调用了 exit()之后,该进程并不会立刻完全消失,而是留下一个称为僵尸进程(Zombie)的数据结构。僵尸进程是一种非常特殊的进程,它已经放弃了几乎所有的内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。

wait & waitpid | 等待子进程

阻塞父进程,等待子进程,或者起到其他作用

需要头文件:

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

函数原型

c

/**
 * @brief 阻塞父进程,直到一个子进程退出或者收到一个指定信号
 * 如果该父进程没有子进程或者他的子进程已经结束,则 wait()就会立即返回
 * @param status 用于保存子进程的退出状态
 * @return pid_t 返回值为子进程的进程号,如果调用失败则返回 -1
 */
pid_t wait(int *status);

/**
 * @brief
 * @param pid
 * - pid > 0:等待进程号为 pid 的子进程退出
 * - pid = -1:等待任意一个子进程退出(与 wait()函数功能相同)
 * - pid = 0:等待组ID等于调用进程组ID的任意子进程
 * - pid < -1:等待组ID等于 pid 绝对值的任意子进程
 * @param status 用于保存子进程的退出状态
 * @param options 选项
 * - WNOHANG:若 pid 指定的子进程没有结束,则 waitpid()函数返回 0,不予以等待
 * - WUNTRACED:The status of any child processes specified by pid that are stopped, and whose status has not yet been reported since they stopped, will also be reported to the requesting process.
 * - 0:同wait()函数,阻塞父进程,直到一个子进程退出或者收到一个指定信号
 * @return pid_t
 * - 一般情况:返回值为子进程的进程号
 * - 调用失败:返回 -1
 * - WNOHANG 选项:若 pid 指定的子进程没有结束,则返回 0
 */
pid_t waitpid(pid_t pid, int *status, int options);

示例

本例中首先使用 fork()创建一个子进程,然后让其子进程暂停 5s(使用了 sleep()函数)。接下来对原有的父进程使用 waitpid()函数,并使用参数 WNOHANG 使该父进程不会阻塞。若有子进程退出,则 waitpid()返回子进程号;若没有子进程退出,则 waitpid()返回 0,并且父进程每隔一秒循环判断一次

c
/* waitpid.c */
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
    pid_t pc, pr;

    pc = fork();
    if (pc < 0)
    {
        printf("Error fork\n");
    }
    else if (pc == 0) /*子进程*/
    {
        /*子进程暂停 5s*/
        sleep(5);
        /*子进程正常退出*/
        exit(0);
    }
    else /*父进程*/
    {
        /*循环测试子进程是否退出*/
        do
        {
            /*调用 waitpid,且父进程不阻塞*/
            pr = waitpid(pc, NULL, WNOHANG);
            /*若子进程还未退出,则父进程暂停 1s*/
            if (pr == 0)
            {
                printf("The child process has not exited\n");
                sleep(1);
            }
        } while (pr == 0);

        /*若发现子进程退出,打印出相应情况*/
        if (pr == pc)
        {
            printf("Get child exit code: %d\n", pr);
        }
        else
        {
            printf("Some error occured.\n");
        }
    }
}

守护进程 | daemon process

  • Linux 中的后台服务进程,生存期较长
  • 通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件
  • 常常在系统引导载入时启动,在系统关闭时终止
  • Linux 有很多系统服务,大多数服务都是通过守护进程实现的
  • 守护进程还能完成许多系统任务,例如,作业规划进程 crond、打印进程 lqd 等(这里的结尾字母 d 就是 Daemon 的意思)。

由于在 Linux 中,每一个系统与用户进行交流的界面称为终端,每一个从此终端开始运行的进程都会依附于这个终端,这个终端就称为这些进程的控制终端,当控制终端被关闭时,相应的进程都会自动关闭。但是守护进程却能够突破这种限制,它从被执行开始运转,直到整个系统关闭时才会退出。如果想让某个进程不因为用户、终端或者其他的变化而受到影响,那么就必须把这个进程变成一个守护进程。可见,守护进程是非常重要的。

如何创建守护进程

1 创建子进程,父进程退出

父进程不使用 wait()函数来等待守护进程而是直接推出,这样用户就可以在执行完父进程后继续做其他事情,从而在形式上做到了与控制终端的脱离。

(这个时候如果子进程有输出的话还是会输出到控制终端,即时你正在进行其他工作)

在 Linux 中,子进程在父进程结束后失去父进程,变成一个孤儿进程,系统发现孤儿进程后会把该进程的父进程设置为 init(进程号为 1),从而使该进程成为一个守护进程。

c
pid = fork();
if (pid > 0)
{
 exit(0); /*父进程退出*/
}

2 在子进程中创建新会话

脱离了控制终端的进程还没有成为一个守护进程,因为它还依赖于所在的会话。fork()函数创建的子进程会继承父进程的会话和进程组,所以要想让子进程成为一个守护进程,就要在子进程中创建一个新的会话。

c

会话组:会话组是一个或多个进程组的集合。通常,一个会话开始于用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话期

进程组:

- 进程组是一个或多个进程的集合。进程组由进程组 ID 来惟一标识。除了进程号(PID)之外,进程组 ID 也是一个进程的必备属性。
- 每个进程组都有一个组长进程,其组长进程的进程号等于进程组 ID。且该进程 ID 不会因组长进程 的退出而受到影响

#### setsid | 创建新会话

需要头文件:

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

作用:

  • 让进程摆脱原会话的控制
  • 让进程摆脱原进程组的控制
  • 让进程摆脱原控制终端的控制

函数原型

c

/**
 * @brief 创建新会话
 * @return pid_t 返回值为 -1 时表示调用失败,否则返回新会话的进程组ID
 */
pid_t setsid(void);

3 改变当前工作目录

使用 fork()创建的子进程继承了父进程的当前工作目录。由于在进程运行过程中,当前目录所在的文件系统(比如“/mnt/usb”等)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入单用户模式)。因此,通常的做法是让“/”作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp。改变工作目录的常见函数是 chdir()

4 重设文件权限掩码

文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有一个文件权限掩码是 050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用 fork()函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为 0,可以大大增强该守护进程的灵活性。

设置文件权限掩码的函数是 umask()。在这里,通常的使用方法为 umask(0)

5 关闭文件描述符

同文件权限掩码一样,用 fork()函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读或写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法被卸载。

在上面的第二步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如 printf())输出的字符也不可能在终端上显示出来。所以,文件描述符为 0、1 和 2 的 3 个文件(常说的输入、输出和报错这 3 个文件)已经失去了存在的价值,也应被关闭。

通常按如下方式关闭文件描述符:

c
for(i = 0; i < MAXFILE; i++)
{
 close(i);
}

例子

下面是实现守护进程的一个完整实例,该实例首先按照以上 的创建流程建立了一个守护进程,然后让该守护进程每隔 10s 向日志 文 件/tmp/daemon.log 写入一句话。

c
/* daemon.c 创建守护进程实例 */
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<fcntl.h>
#include<sys/types.h>
#include<unistd.h>
#include<sys/wait.h>
int main()
{
 pid_t pid;
 int i, fd;
 char *buf = "This is a Daemon\n";

 pid = fork(); /* 第一步 */
 if (pid < 0)
 {
 printf("Error fork\n");
 exit(1);
 }
 else if (pid > 0)
 {
 exit(0); /* 父进程推出 */
 }

 setsid(); /*第二步*/
 chdir("/"); /*第三步*/
 umask(0); /*第四步*/
 for(i = 0; i < getdtablesize(); i++) /*第五步*/
 {
 close(i);
 }

 /*这时创建完守护进程,以下开始正式进入守护进程工作*/
 while(1)
 {
 if ((fd = open("/tmp/daemon.log",
 O_CREAT|O_WRONLY|O_APPEND, 0600)) < 0)
 {
 printf("Open file error\n");
 exit(1);
 }
 write(fd, buf, strlen(buf) + 1);
 close(fd);
 sleep(10);
 }
 exit(0);
}

守护进程的出错处理

读者在前面编写守护进程的具体调试过程中会发现,由于守护进程完全脱离了控制终端,因此,不能像其他普通进程一样将错误信息输出到控制终端来通知程序员,即使使用 gdb 也无法正常调试。那么,守护进程的进程要如何调试呢?一种通用的办法是使用 syslog 服务,将程序中的出错信息输入到系统日志文件中(例如:“/var/log/messages”),从而可以直观地看到程序的问题所在。

“/var/log/message”系统日志文件只能由拥有 root 权限的超级用户查看。在不同 Linux 发行版本中,系统日志文件路径全名可能有所不同,例如可能是”/var/log/syslog”等

syslog 是 Linux 中的系统日志管理服务,通过守护进程 syslogd 来维护。该守护进程在启动时会读一个配置文件“/etc/syslog.conf”。该文件决定了不同种类的消息会发送向何处。例如,紧急消息可被送向系统管理员并在控制台上显示,而警告消息则可被记录到一个文件中。

该机制提供了 3 个 syslog 相关函数,分别为 openlog()、syslog()和 closelog()。下面就分别介绍这 3 个函数。

(1)syslog 相关函数说明。 通常,openlog()函数用于打开系统日志服务的一个连接;syslog()函数是用于向日志文件中写入消息,在这里可以规定消息的优先级、消息输出格式等;closelog()函数是用于关闭系统日志服务的连接。 (2)syslog 相关函数格式。

TODO:openlog()syslog()和 closelog()的用法

实验 编写多进程程序

实验 编写守护进程

Copyright © 2022 田园幻想乡 浙ICP备2021038778号-1