跳到主要内容

3. 多进程编程

3.1 进程的基本概念

  • 进程是操作系统中一个正在执行中的程序实例,它拥有自己的执行状态、数据空间和系统资源(例如CPU时间、内存、文件描述符等)。每个进程都有一个唯一的进程标识符(PID),可以用来唯一地标识和管理进程。
  • 进程可以独立运行,也可以与其他进程协作。进程还可以使用信号来与其他进程进行通信和同步。进程是操作系统中的基本执行单元,操作系统通过进程调度算法来分配CPU时间和系统资源,以实现多任务处理和并发执行。多个进程可以同时运行在操作系统上,每个进程都有自己的地址空间和执行环境,互不干扰。操作系统通过进程管理机制来确保进程之间的安全和稳定性,防止进程之间互相干扰和破坏系统的稳定性。
  • 程序是一组指令或代码,用于执行特定的任务或完成特定的工作。通常,程序被编写成源代码形式,需要通过编译器或解释器将其转换为可执行代码才能在计算机上运行。
  • 进程是正在执行的程序的实例。进程包括了程序的代码、数据和当前的执行状态。在操作系统中,每个进程都有其自己的进程标识符(PID),用于唯一地标识该进程。 操作系统利用进程调度算法来控制进程的执行顺序,以实现并发执行多个进程的目的。
  • 简单来说,程序是静态的,它们只是一组指令,而进程则是动态的,它们是正在执行的程序的实例。程序只有在被加载到内存中并执行时才会成为进程。

3.2 结合实例理解进程

  • 在Windows系统中我们可以通过任务管理器来进行进程管理,它能监视系统进程的运行情况,管理员可以自行终止一些失控的进程。在Linux 系统中虽然使用命令进行进程管理,但是进程管理的主要目的是一样的,即查看系统中运行的程序和进程、判断服务器的健康状态和强制中止不需要的进程。
  • 一个常见的生活实例是在电脑上打开一个应用程序,比如双击文本编辑器图标打开一个文本编辑器,在文本编辑器中输入文本并保存。在这个例子中,打开文本编辑器的过程就是创建了一个进程。该进程负责执行文本编辑器程序的代码,并占用一些系统资源(例如内存、CPU时间等)。当你在文本编辑器中输入文本并保存时,该进程会将数据保存到磁盘上的文件中。

3.3 进程的状态

  • 在单任务操作系统中,CPU 只能同时执行一个进程,当一个进程在运行时,其他进程必须等待该进程执行完毕才能开始运行。
  • 在多任务操作系统中,CPU 可以同时处理多个进程。当一个进程被分配 CPU 时间片时,它就可以运行,并且其他进程则会在就绪队列中等待下一个时间片。多任务操作系统可以同时运行多个进程,通过调度器选择进程执行,并通过上下文切换实现进程之间的切换。而单任务操作系统只能处理一个进程,并使用简单的调度算法来决定运行哪个进程。多任务操作系统中,进程在执行过程中可能会经历多种状态:
  • 开始状态:当一个进程被创建时,它处于初始状态,此时操作系统为进程分配必要的资源和空间。
  • 就绪状态:当进程获得了所有必需的资源,并且等待 CPU 时间时,它被称为就绪状态。此时进程已经准备好运行,只需等待 CPU 调度它即可执行。
  • 运行状态:当进程被 CPU 执行时,它处于运行状态。在此状态下,进程使用 CPU 时间来执行其指令,并且能够访问其分配的资源。
  • 阻塞状态:当进程在执行过程中等待某个事件发生时,例如等待用户输入或等待某个文件被读入,它被称为阻塞状态。此时进程不会占用 CPU 时间,但也无法继续执行,直到事件发生并且进程再次变为就绪状态。
  • 结束状态:当进程完成其执行或者因为某种原因被终止时,它被称为终止状态。此时进程被删除,并释放其分配的资源。
  • 以上五种状态构成了进程的生命周期,进程在这些状态之间转移,直到最终结束。

3.4 进程控制块

  • 进程由进程控制块、有关程序段和操作的数据集三部分组成。 进程控制块(Process Control Block,PCB)是操作系统实现进程管理的关键数据结构之一,它保存了操作系统用于控制和管理进程所需的所有信息。操作系统可以利用 PCB 中的信息来实现进程调度、进程同步和进程通信等功能。
  • PCB通常被保存在操作系统的内核中,操作系统可以利用PCB来维护进程的状态和控制进程的执行。每个进程的PCB中的信息是唯一的,这样操作系统就可以正确地识别和管理每个进程。
  • 当创建一个进程时,系统首先创建其PCB,然后根据PCB中的信息实施有效的管理和控制。当一个进程完成功能后,系统则释放PCB,进程也随之消亡。
  • 操作系统在处理进程时所需的全部信息,通常包括以下内容:
    • 进程标识符(Process ID,PID):每个进程都有一个唯一的PID,用于区分不同的进程。
    • 进程状态:表示进程目前所处的状态,如就绪、运行、阻塞等。
    • 寄存器:保存了进程运行时的寄存器值,包括通用寄存器、程序计数器、堆栈指针等。
    • 进程优先级:用于确定进程在就绪队列中的优先级,以便操作系统按照优先级调度进程。
    • 进程调度信息:包括进程的时间片大小、已用时间片数等信息。
    • 进程等待队列指针:指向进程等待队列中下一个进程的PCB。
    • 进程打开文件列表:记录了进程打开的文件和文件描述符。
    • 进程内存管理信息:包括进程使用的内存空间的起始地址、大小等信息。
    • 进程使用的资源:记录进程所使用的各种资源,如打开的文件、内存空间、IO设备等等。
    • 进程通信信息:记录进程与其他进程之间通信的信息,如消息队列、管道、共享内存等等。
    • 父进程和子进程关系:记录进程的父进程和子进程的关系,用于实现进程间的通信和协调。

3.5进程标识符

  • 进程标识符是操作系统中用于标识每个进程的唯一标识符,通常被称为PID(Process ID)。PID是一个整数值,它在操作系统中是唯一的。
  • 通常情况下,操作系统会按照一定的规则为每个进程分配一个唯一的PID,在进程运行时,PID是不会变化的,进程终止后,PID被系统回收,后面会重新分配给新运行的进程。

3.5.1 ps

  • 在Linux系统中,可以使用ps命令来列出系统中当前运行的进程。

  • 常用命令:

    ps aux可以查看系统中所有的进程
    ps -le可以查看系统中所有的进程,而且还能看到进程的父进程的 PID 和进程优先级
    ps -ef
    • a:显示一个终端的所有进程,除会话引线外;
    • u:显示进程的归属用户及内存的使用情况;
    • x:显示没有控制终端的进程;
    • -l:长格式显示更加详细的信息;
    • -e:显示所有进程;
    • f:用ASCII字符显示树状结构,表达程序间的相互关系。
  • ps 命令输出信息含义:

    USER/UID    该进程是由哪个用户产生的。
    PID 进程的 ID。
    PPID 父进程的进程号。
    C: 进程生命周期中的CPU利用率
    STIME: 进程启动时的系统时间
    %CPU 进程占用 CPU 资源的百分比。
    %MEM 进程占用物理内存的百分比。
    VSZ 该进程占用虚拟内存的大小,单位为 KB。
    RSS 该进程占用实际物理内存的大小,单位为 KB。
    TTY 该进程是在哪个终端运行的。

    STAT 进程状态。常见的状态有以下几种:
    -D:无法中断的休眠状态 (通常 IO 的进程)
    -R:正在运行进程。
    -S:进程处于睡眠状态。
    -T:停止状态,可能是在后台暂停或进程处于除错状态。
    -W:内存交互状态(从 2.6 内核开始无效)。
    -Z:僵尸进程。进程已经中止,但是部分程序还在内存当中。
    -<:高优先级。
    -N:低优先级。
    -L:有些页被锁入内存。
    -s:包含子进程。
    -l:多线程(小写 L)。
    -+:位于后台。

    START 该进程的启动时间。
    TIME 该进程占用 CPU 的运算时间,注意不是系统时间。
    COMMAND 产生此进程的命令名。
  • ps aux和ps -ef的区别:

    • ps aux 命令的输出格式较为详细,包含进程的 PID、CPU 占用率、内存占用率、启动时间、命令等信息,并且每行的命令行长度可能会被截断。
    • ps -ef 命令的输出格式相对简洁,仅包含进程的 PID、PPID、C、STIME、TTY、TIME 和 CMD 等信息,但命令行不会被截断。

3.5.2 top

  • ps命令是用于静态地查看系统中进程的信息,如果想要实时动态地查看系统中进程的情况,就可以使用top命令

  • 参数:

    • -d:改变显示的更新速度,指定 top 命令每隔几秒更新。默认是 3 秒;
    • -b:使用批处理模式输出。一般和"-n"选项合用,可以用来将 top 的结果输出到档案内;
    • -n:更新的次数,完成后将会退出 top;
    • -p:进程PID:仅查看指定 ID 的进程;
    • -s:使 top 命令在安全模式中运行,避免在交互模式中出现错误;
    • -u:用户名:只监听某个用户的进程;
    • -c: 切换显示模式,显示完整的路径与名称;
    • -q:没有任何延迟的显示速度。
  • 常用命令:

    top      显示进程信息
    top -c 显示完整命令
    top -b 以批处理模式显示程序信息
    top -S 以累积模式显示程序信息
    top -n 2 设置信息更新次数,更新两次后停止更新
    top -p 139 显示指定的进程信息
    top -n 10 显示更新十次后退出
  • 在 top 命令的显示窗口中,还可以使用如下按键,进行一下交互操作:

    • ? 或 h:显示交互模式的帮助;
    • P:按照 CPU 的使用率排序,默认就是此选项;
    • M:按照内存的使用率排序;
    • N:按照 PID 排序;
    • T:按照 CPU 的累积运算时间排序,也就是按照 TIME+ 项排序;
    • k:按照 PID 给予某个进程一个信号。一般用于中止某个进程,信号 9 是强制中止的信号;
    • r:按照 PID 给某个进程重设优先级(Nice)值;
    • q:退出 top 命令;

3.5.3 kill

  • 在 Linux 系统中,kill 命令用于向指定进程发送信号,以便控制进程的行为。其基本语法如下:

    kill [signal] PID...
    • signal 参数用于指定要发送的信号类型,可以是信号名称或信号编号;
    • PID 参数用于指定要发送信号的进程标识符,可以是一个或多个进程标识符,多个进程标识符之间用空格分隔。
  • kill命令常用信号及其含义:

    号编号信号名含义
    0EXIT程序退出时收到该信息
    1HUP挂掉电话线或终端连接的挂起信号,这个信号也会造成某些进程在没有终止的情况下重新初始化
    2INT表示结束进程,但并不是强制性的,常用的 "Ctrl+C" 组合键发出就是一个 kill -2 的信号
    3QUIT退出
    9KILL杀死进程,即强制结束进程
    11SEGV段错误
    15TERM正常结束进程,是 kill 命令的默认信号
  • 例如强制杀死进程号为2246的进程:

    kill -9 2246
  • 更多详细的信号信息可以查看进程间通信的信号部分。

3.6 进程的创建

3.6.1 使用fork创建进程

  • 在Linux系统中可以通过fork函数来创建新进程,由fork创建的新进程被称为子进程。

  • 子进程几乎与原始进程相同,包括代码、数据和打开的文件描述符。但是,它们并不共享内存,子进程的进程ID(PID)不同于父进程的PID。子进程的PID由操作系统分配并且是唯一的。系统调用fork函数声明如下:

    #include <unistd.h>
    pid_t fork();
  • 返回值:

    • 如果创建成功,在父进程的程序中fork函数将返回子进程的PID;
    • 如果创建成功,在子进程中fork函数则返回0;
    • 如果创建失败,fork返回一个负值。
  • 通过fork创建子进程:

    #include <stdio.h>
    #include <unistd.h>
    int main(void)
    {
    pid_t pid;
    pid = fork();
    if (pid < 0)
    {
    printf("fork is error \n");
    return -1;
    }
    //父进程
    if (pid > 0)
    {
    printf("This is parent,parent pid is %d\n", getpid());
    }
    //子进程
    if (pid == 0)
    {
    printf("This is child,child pid is %d,parent pid is %d\n",getpid(),getppid());
    }
    return 0;
    }
    • getpid():获得当前进程的PID;
    • getppid():获得当前进程父进程的PID。
  • 编译程序并执行:

    gcc -o fork fork.c
    ./fork

3.6.2 使用exec创建进程

  • 在Linux系统中,exec是一组用于执行其他程序的系统调用函数。exec函数可以将当前进程替换为另一个进程,从而实现程序的动态加载和替换。

  • exec()函数族:

    #include <unistd.h>

    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[]);
  • execl函数声明:

    #include <unistd.h>

    int execl(const char *path, const char *arg, ...);
  • 参数含义:

    • path:指向要执行的文件路径;
    • arg以及后面省略号:代表执行该程序时传递的参数列表,path后面参数是argv[0],第二个是argv[1],对于系统命令程序,比如ls命令,argv[0]是必须要有的,但是其值可以是一个无意义的字符串。
  • 使用execl执行系统命令ls:

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    int main(void)
    {
    int i=0;
    pid_t pid;
    pid = fork();
    if (pid < 0)
    {
    printf("fork is error \n");
    return -1;
    }

    //父进程
    if (pid > 0)
    {
    printf("This is parent,parent pid is %d\n", getpid());
    }
    //子进程
    if (pid == 0)
    {
    printf("This is child,child pid is %d\n", getpid(), getppid());
    execl("/bin/ls", "lsakakk", "-l", NULL);
    exit(1);
    }
    i++;
    //printf("i is %d\n",i);
    return 0;

3.7 孤儿进程和僵尸进程

  • 孤儿进程是指父进程在子进程结束之前就已经退出或被杀死,导致子进程成为孤儿进程。孤儿进程会被init进程(进程号为1的特殊进程)接管,并成为init进程的子进程。这是因为所有进程都必须有一个父进程,而init进程是系统启动时第一个运行的进程,所以它没有父进程。当孤儿进程结束时,它的资源会被回收。

  • 僵尸进程是指一个已经结束执行的进程,但是它的父进程还没有调用wait()或waitpid()来获取它的退出状态,导致它的进程描述符在系统进程表中仍然存在,但是没有任何进程控制块和内存空间。 僵尸进程不占用CPU时间和内存空间,但是会占用进程表中的一个条目。当系统中存在大量的僵尸进程时,会影响系统性能。可以通过父进程调用wait()或waitpid()来回收僵尸进程的资源。

  • 如果不及时处理僵尸进程和孤儿进程,会导致以下后果:

    • 僵尸进程会占用系统进程表中的条目,导致系统资源浪费,降低系统的性能。
    • 孤儿进程没有父进程来管理和控制,可能会导致资源泄露或者系统崩溃。
    • 如果大量的僵尸进程和孤儿进程堆积在系统中,会导致系统进程表满,无法再创建新的进程。
    • 由于孤儿进程会被init进程接管,如果init进程也出现问题,可能会导致系统崩溃或无法正常运行。
    • 因此,处理僵尸进程和孤儿进程非常重要。对于僵尸进程,可以通过父进程调用wait()或waitpid()来回收资源;对于孤儿进程,则需要及时杀死或者让其结束,并将其资源释放回系统。
  • wait函数声明:

    #include <sys/wait.h>
    pid t wait(int *status)
    • 返回值: 成功返回回收的子进程的pid,失败返回-1
  • 与wait函数的参数有关的俩个宏定义:

    • WIFEXITED(status): 若该宏定义为真,表明子进程正常结束。
    • WEXITSTATUS(status): 如果子进程正常退出,则该宏定义的值为子进程的退出值。
    if(WIFEXITED(status))
    {
    printf("退出值为 %d\n", WEXITSTATUS(status));
    }
  • 示例程序:

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <sys/wait.h>
    int main(void)
    {
    int i=0;
    pid_t pid;
    pid = fork();
    if (pid < 0)
    {
    printf("fork is error \n");
    return -1;
    }
    if (pid > 0)
    {
    int status;
    wait(&status);
    if(WIFEXITED(status)==1)
    {
    printf("return value is %d\n",WEXITSTATUS(status));
    }
    }
    if (pid == 0)
    {
    sleep(2);
    printf("This is child\n");
    exit(6);
    }
    return 0;

3.8 进程的调度

  • Linux进程调度是指操作系统内核如何决定哪个进程应该被运行的过程。Linux进程调度的目标是提高系统的响应性、吞吐量和公平性。在Linux中,进程调度的核心是一个调度器,它负责决定哪个进程应该获得CPU时间片。
  • Linux的进程调度是基于时间片轮转算法实现的。每个进程被分配一个时间片,在时间片到期后,调度器会把当前运行的进程挂起,并把CPU时间片分配给下一个等待运行的进程。 这种轮流分配CPU时间片的方式保证了每个进程都有机会运行,而且不会一直占用CPU资源,从而导致其他进程无法运行。
  • Linux中进程调度的优先级是动态调整的。每个进程都被赋予一个优先级,该优先级取决于进程的调度策略、进程的实时性要求以及进程的历史行为。根据优先级,调度器决定哪个进程应该获得CPU时间片。
  • Linux的进程调度策略有两种:时间片轮转调度和实时调度。时间片轮转调度是默认的调度策略,适用于大多数应用程序。实时调度适用于对响应时间有严格要求的应用程序,如控制系统和嵌入式系统。
  • Linux的进程调度是一个复杂的系统,涉及到许多因素,如进程的状态、优先级、资源需求等。

3.9 进程的分类

  • 在Linux系统中,进程一般分为前台进程、后台进程和守护进程3类。

3.9.1 守护进程

  • 守护进程是后台运行的一种特殊进程,它通常不与用户直接交互,也不受用户登录或注销的影响。它是为了完成某种特定的任务而运行的,例如提供服务或者监控系统状态。
  • Linux系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogd、 web服务器httpd、邮件服务器sendmail和数据库服务器mysqld等。
  • 守护进程通常在系统启动时开始运行,并以超级用户权限运行。它们经常需要访问特殊的资源或使用特殊的端口(1-1024)。守护进程会一直运行直到系统关机,除非被强制终止。它们的父进程是init进程,因为它们的真正父进程在fork出子进程后就先于子进程exit退出了。因此,它们是由init继承的孤儿进程。由于守护进程是非交互式程序且没有控制终端,任何输出都需要特殊处理。通常,守护进程的名称以d结尾,例如sshd、xinetd和crond。

3.9.2 编写守护进程

  • 进程组:一个或多个进程的集合,进程组由进程组ID标识,进程组长的进程ID和进程组ID一致,并且进程组ID不会由于进程组长的退出而受到影响
  • 会话周期:一个或多个进程组的集合,比如用户从登陆到退出,这个期间用户运行的所有进程都属于该会话周期
  • setsid函数:创建一个新会话,并担任该会话组的组长,调用setsid函数的目的:让进程摆脱原会话,原进程组,原终端的控制
  1. 创建子进程,父进程退出

    • 在前面我们学习过,当父进程先于子进程退出会造成子进程变成孤儿进程,然后由1号init进程收养它,这样此子进程就变成了init进程的子进程。
  2. 子进程创建新会话

    • 调用setsid创建新的会话,摆脱原会话,原进程组,原终端的控制,自己成为新会话的组长。
  3. 将当前目录改为根目录

    • 正在运行的进程文件系统(如“/mnt/usb”)不能卸载,如果目录要回退,则此时进程不能做到,为了避免这种麻烦,通常以根目录作为守护进程当前目录,改变工作目录的常见函数是chdir。
  4. 重设文件权限掩码

    • 子进程的文件权限掩码是复制的父进程的,不重新设置的话,会给子进程使用文件带来诸多麻烦,设置文件掩码的函数是umask,这里使用umask(0)来增强守护进程的灵活性。
  5. 关闭不需要的文件描述符

    • 子进程的文件描述符也是从父进程复制来的,那些不需要的文件描述符永远不会被守护进程使用,会白白的浪费系统资源,还可能导致文件系统无法结束。
  6. 守护进程退出处理

    • 当用户需要外部停止守护进程运行时,往往会使用 kill 命令停止该守护进程。所以,守护进程中需要编码来实现 kill 发出的signal信号处理,达到进程的正常退出。
  7. 创建一个守护进程:

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

    int main(void)
    {
    pid_t pid;
    // 步骤一:创建一个新的进程
    pid = fork();
    //父进程直接退出
    if (pid > 0)
    {
    exit(0);
    }
    if (pid == 0)
    {
    // 步骤二:调用setsid函数摆脱控制终端
    setsid();
    // 步骤三:更改工作目录
    chdir("/");
    // 步骤四:重新设置umask文件源码
    umask(0);
    // 步骤五:0 1 2 三个文件描述符
    for (int i = 1; i < 4; i++)
    {
    close(i);
    }
    while (1)
    {
    }
    }
    return 0;
    }