【Linux】深入解析进程控制:创建、终止、等待与替换的原理与应用

前言:

进程控制是操作系统中的一个核心概念,涉及到进程的创建、终止、等待以及程序替换等方面。本文将介绍进程创建、终止、等待和程序替换的相关内容,包括 fork 函数的原理、写时拷贝机制、进程退出的场景和方法、进程等待的方法以及 exec 函数族的介绍。

1. 进程创建

1.1. fork函数初识

在Linux中,进程的创建是通过fork函数来实现的。fork函数会创建一个新的子进程,该子进程是调用进程的副本,但拥有独立的内存空间。父进程和子进程之间通过fork函数返回的不同值来区分彼此,这一机制为多任务处理提供了基础。

1.2. fork函数的原理:

#include <unistd.h>
pid_t fork(void);
//返回值:自进程中返回0,父进程返回子进程id,出错返回-1

fork函数会返回两次,一次在父进程中返回子进程的PID,另一次在子进程中返回0。父子进程会共享代码段、数据段和堆栈段,但当其中一方尝试写入数据时,操作系统会进行写时拷贝,将数据复制给写入方的私有副本,以保持数据的独立性。

进程调用fork,当控制转移到内核中的fork代码后,内核做:

  1. 分配新的内存块和内核数据结构给子进程
  2. 将父进程部分数据结构内容拷贝至子进程
  3. 添加子进程到系统进程列表当中
  4. fork返回,开始调度器调度

1.3. 写时拷贝的机制:

写时拷贝是一种延迟拷贝的策略,即在父子进程共享数据时,并不立即进行内存复制,而是等到其中一方尝试写入数据时才执行。这样可以节省内存开销,并提高程序的运行效率。写时拷贝通过操作系统内核的页表实现,当写入发生时,操作系统会将涉及到的页面复制给写入方的私有副本,从而保证了数据的独立性。具体见下图:
在这里插入图片描述

为什么要使用写时拷贝?

  1. 节省内存开销:延迟数据复制,只在必要时才进行,避免了不必要的内存浪费。
  2. 提高程序效率:减少了内存复制的次数,加快了程序的执行速度。

如何做到的?

进程创建的时候,OS做了什么?
创建PCB,创建地址空间,创建页表,将磁盘中的代码和程序加载到内存,构建映射关系,然后开始调度。

2. 进程终止

在操作系统中,进程的终止和退出是指进程执行完毕或异常终止后的处理过程。

2.1. 进程退出的三种场景:

  1. 正常终止:进程代码执行完毕,结果正确。
  2. 异常终止:进程执行完毕,结果不正确。
  3. 异常退出:进程未能执行完毕,发生异常情况,如收到了异常信号。

2.2. 进程常见退出方法:

main函数退出:

main函数的返回值,叫做进程的退出码

  • 一般0,表示进程执行成功
  • 非零,表示失败(用不同的数字表示失败的原因)

错误码转换成为错误的描述

  1. 使用语言和系统自带的方法,进行转化
  2. 可以自定义!

main函数return返回的时候,表示进程退出,return XXX,退出码,可以设置退出码的字符串含义。
其他函数退出,仅仅表示函数调用完毕!

函数退出:

errno:除了进程退出,函数退出,我们怎么知道函数的执行情况呢?函数返回值! 调用函数,我们通常想看到两种结果:
1.函数的执行结果——FILE*, NULL
2.函数的执行情况——成功,失败,什么原因

任何进程最终的执行情况,我们可以使用两个数字表明具体执行的情况

signumber	exit_code
 0				0	
!0				0	
 0			   !0	
!0			   !0	

退出方法:

  • 正常终止(可以通过echo $? 查看进程退出码):
    1.从main返回
    2.调用exit
    3._exit
  • 异常退出
    ctrl + c, 信号终止

exit就是用来终止进程的。exit(退出码), 在我们的代码中,任意地方调用exit,都表示进程退出!

2.3. exit vs _exit

  • exit会刷新缓冲区,_exit不会
  • exit是库函数,_exit是系统调用在这里插入图片描述

c printf("hello Linux");
我们所谈的的缓冲区(进度条之类)绝对不是操作系统里面的缓冲区!(在库级别的缓冲区)

操作系统在进程退出时的处理过程:

  1. 释放地址空间和页表:操作系统会回收进程所占用的内存空间,包括释放地址空间和相应的页表。
  2. 释放资源:操作系统会释放进程所申请的其他资源,如文件描述符等。
  3. 更新进程状态:操作系统会更新进程的状态信息,将其标记为已终止。
  4. 回收进程控制块:操作系统会回收进程控制块等数据结构,以便系统能够重新利用这些资源。

3. 进程等待

3.1. 为什么需要进行进程等待?

在多进程编程中,父进程可能会创建多个子进程,并且需要在子进程执行完毕后进行回收资源,以防止子进程成为僵尸进程。进程等待的主要目的包括:

  1. 回收子进程的资源:通过等待子进程的结束,父进程可以及时回收子进程占用的资源,避免资源泄漏。(必然)
  2. 获取子进程的退出信息:父进程可能需要了解子进程的退出状态,以便根据子进程的执行情况进行相应的处理。(可选)

3.2. 进程等待的方法

wait方法

wait 方法是一种阻塞式的进程等待方法,其基本形式如下:

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status); // 默认会进行阻塞等待,等待任意一个子进程。

wait 方法会阻塞父进程,直到任意一个子进程退出为止。成功调用后,会返回被等待进程的进程 ID,并将子进程的退出状态保存在 status 参数中。

  • 示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() 
{
    pid_t id = fork();
    if (id == 0) 
    {
        // child
        int cnt = 5;
        while(cnt) {
            printf("Child is runing, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        printf("子进程准备退出,马上变僵尸进程\n");
        exit(0);
    }
    printf("父进程休眠\n");
    sleep(10);
    printf("父进程开始回收了\n");
    // father
    pid_t rid = wait(NULL); // 阻塞等待
    if(rid > 0) 
    {
        printf("wait success, rid: %d\n", rid);
    }
    printf("父进程回收僵尸成功\n");
    sleep(3);

    return 0;
}

在这里插入图片描述

fork之后,父子进程谁先运行? 不确定,由调度器说了算
谁应该最后退出? 父进程!(承担回收任务)

waitpid 方法

waitpid 方法相比 wait 方法更为灵活,可以指定等待某个特定的子进程,其基本形式如下:

pid_ t waitpid(pid_t pid, int *status, int options);
  • 如果 pid-1,则等待任意子进程。
  • 如果 pid 大于 0,则等待其进程 ID 与 pid 相等的子进程。
  • status 参数用于获取子进程的退出状态。
  • options 参数可以设置为 WNOHANG,使 waitpid 方法在没有已退出的子进程可收集时立即返回。
  • WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
  • WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
  • WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进 程的ID。
  • 示例
int main() 
{
    pid_t id = fork();
    if (id == 0) 
    {
        // child
        int cnt = 5;
        while(cnt) {
            printf("Child is runing, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }
    // father
    int status = 0;
    pid_t rid = waitpid(id, &status, 0); // 阻塞等待
    // status 反馈子进程退出情况
    if(rid > 0) 
    {
        printf("wait success, rid: %d, status: %d\n", rid, status);
    }

    return 0;
}

在这里插入图片描述
为什么是status不是 1 而是258,因为它有自己的格式,退出码 + 退出信号
在这里插入图片描述

0000 0000 0000 0000 0000 0001 0000 0000256

查看:exit signo

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

int main() 
{
    pid_t id = fork();
    if (id == 0) 
    {
        // child
        int cnt = 5;
        while(cnt) 
        {
            printf("I am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
            cnt--;
        }
        exit(1);
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0); 
    if (rid > 0)
    {
        // // status & 0x7F: 0000 0000 0000 0000 0000 0111 1111 1111
        // printf("wait success, rid: %d, status: %d, exit signo:%d, exit code: %d\n",
        //          rid, status, status&0x7F, (status>>8)&0x7FF);
        if (WIFEXITED(status))
        {
             // 通过宏获取退出码
            printf("wait success, rid: %d, status: %d, exit signo:%d\n",rid, status, WIFEXITED(status));
        }
        else 
        {
            printf("child process error!\n");
        }
    }
    return 0;
}

在这里插入图片描述

退出信号:
在这里插入图片描述

3.3. 阻塞 与 非阻塞等待

阻塞等待:

阻塞等待是指进程在等待某个事件发生时会暂时挂起,直到事件发生后才会继续执行。在进程等待子进程退出时,调用阻塞等待的函数(如 waitwaitpid)会导致父进程挂起,直到子进程退出后才会继续执行。这种等待方式具有以下特点:

  1. 易于理解和编程:阻塞等待是一种直观的等待方式,代码结构清晰,易于理解和维护。
  2. 资源利用率高:在等待的过程中,进程不会占用 CPU 资源,因此可以有效地利用系统资源。
  3. 确保进程顺序执行:由于阻塞等待会使得进程暂停执行,因此可以确保父进程在子进程退出后再继续执行后续代码。

非阻塞等待:

非阻塞等待是指进程在等待某个事件发生时不会挂起,而是立即返回,继续执行后续代码。在进程等待子进程退出时,可以通过设置特定的选项(如 WNOHANG)来实现非阻塞等待,即使子进程尚未退出,等待函数也会立即返回。这种等待方式具有以下特点:

  1. 提高系统响应速度:非阻塞等待不会阻塞进程的执行,因此可以提高系统的响应速度,使得进程能够及时响应其他事件。
  2. 灵活性高:非阻塞等待允许进程在等待的同时执行其他任务,具有更高的灵活性和并发性。
  3. 需要额外的轮询:由于非阻塞等待会立即返回,因此需要通过轮询来检查等待的条件是否已经满足,这可能会增加编程复杂度和系统开销。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define NUM 5

typedef void(*fun_t)();
fun_t tasks[NUM];
// 任务 //
void printLog()
{
    printf("this is a log print task\n");
}
void prinNet()
{
    printf("this is a net task\n");
}
void printNPC()
{
    printf("this is a flush NPC\n");
}
void initTask()
{
    tasks[0] = printLog;
    tasks[1] = prinNet;
    tasks[2] = printNPC;

    tasks[3] = NULL;
}

void excuteTask()
{
    for(int i = 0; tasks[i]; i++) tasks[i](); // 回调
}

int main() 
{
    initTask();
    pid_t id = fork();
    if (id == 0) 
    {
        // child
        int cnt = 5;
        while(cnt) {
            printf("Child is runing, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }
    // father
    int status = 0;

    while(1) {
        pid_t rid = waitpid(id, &status, WNOHANG); // 非阻塞等待
        if(rid > 0) 
        {
            printf("wait success, rid: %d, status: %d\n", rid, status);
            break;
        }
        else if(rid == 0)
        {
            printf("father say: child is running, do other thing\n");
            printf("########### task begin ###################\n");
            excuteTask();
            printf("########### task end ###################\n");
        }
        else
        {
            perror("waitpid");
            break;
        }
        sleep(2);
    }

    return 0;
}

在这里插入图片描述

选择合适的等待方式:

在实际应用中,选择合适的等待方式取决于具体的需求和场景:

  • 如果需要确保父进程在子进程退出后再继续执行后续代码,或者需要获取子进程的退出状态,可以选择阻塞等待。
  • 如果需要提高系统的响应速度,或者需要在等待的同时执行其他任务,可以选择非阻塞等待。
  • 在某些情况下,也可以结合使用阻塞和非阻塞等待,根据具体情况灵活调整。

4. 进程程序替换及exec函数

4.1. 直接说原理——什么是程序替换

  1. 我们的程序只能执行我们的代码
  2. 如果我们创建的子进程,想执行其他程序的代码呢?
    在这里插入图片描述

当一个进程调用exec函数时,该进程的用户空间代码和数据将被新程序替换,从新程序的启动例程开始执行。这意味着进程不再执行原来的代码,而是执行新程序的代码。进程调用exec函数后,并不会创建新的进程,而是在当前进程的基础上进行替换,因此进程的ID并不会改变。

4.2. 直接写代码——最简单的单进程的demo代码——exec*(一个接口)

Linux的指令!—— 也是一个程序

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

int main()
{
    printf("I am a process, pid: %d\n", getpid()); 
    printf("exec begin ...\n");
    execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL"
    //execl("/usr/bin/top", "top", NULL); //NULL 不是 "NULL"
    printf("exec end ...\n");
    exit(1);
}

在这里插入图片描述

  • 细节1:程序替换成功,exec后续的代码不再执行。因为被替换掉了。 细节2:exec只有失败返回值,没有成功返回值
  • 细节3:替换完成,不创建新的程序
  • 细节4:创建一个进程,是先创建pcb,地址空间,页表等,还是先把程序加载到内存?程序替换的本质工作:就是加载!
  • 补充:标准传参,也可以非标准传参。

4.3. 改成多进程版本

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

int main()
{
    printf("I am a process, pid: %d\n", getpid()); 
    pid_t id = fork();
    if (id == 0) // 让子进程创建,父进程等待结果即可
    {
        printf("exec begin ...\n");
        execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL"
        //execl("/usr/bin/top", "top", NULL); //NULL 不是 "NULL"

        printf("exec end ...\n");
        exit(1);
    }

    pid_t rid = waitpid(id, NULL, 0);
    if (rid > 0) 
    {
        printf("wait success\n");
    }
    exit(1);
}

在这里插入图片描述

  • 我们为什么要这么做?父进程能得到执行结果
  • 替换为什么没有影响父进程?进程具有独立性,替换的时候,发生写时拷贝(开辟新空间)

4.4. exec函数族介绍

其实有六种以exec开头的函数,统称exec函数:(功能上没有区别,只是传参方式有区别,都是系统调用execve的封装)
在这里插入图片描述

  • execl: 以参数列表的形式传递参数
  • execlp: 与execl相似,但会在PATH环境变量中搜索可执行文件
  • execle: 允许同时传递环境变量参数
  • execv: 以参数数组的形式传递参数
  • execvp: 与execv相似,但会在PATH环境变量中搜索可执行文件
  • execve: 允许同时传递环境变量参数,并在PATH环境变量中搜索可执行文件
#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 execve(const char *path, char *const argv[], char *const envp[]);
  • p: PATH,你不用告诉系统,程序在哪里,只要告诉我名字是什么,系统替换的时候,会自动去PATH环境变量中查找。
  • l: list,列表,参数列表
  • v: vector 数组
execlp("ls", "ls", "-a", "-l", NULL); 
//第一个ls表示你想执行谁,后面的表示字母执行
char *argv[] = {
    (char*)"ls",
    (char*)"-a",
    (char*)"-l",
    NULL  // 注意以 NULL 结尾
};
execv("/usr/bin/ls", argv);

execke、excve、execvpe(环境变量)

题外话:exec可以执行系统的指令(程序),可以执行我自己的程序吗?
无论什么语言,只要能在Linux上跑都可以,因为所有语言,运行之后都是进程! exec
只有代码和数据,没有语言我们只能看到进程

执行我自己的程序:

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

int main()
{
    printf("I am a process, pid: %d\n", getpid()); 
    pid_t id = fork();
    if (id == 0) // 让子进程创建,父进程等待结果即可
    {
        execl("./mytest", "mytest", "-a", "-b", "-c", NULL);
    }

    pid_t rid = waitpid(id, NULL, 0);
    if (rid > 0) 
    {
        printf("wait success\n");
    }

    exit(1);
}
// test.cc
#include <iostream>
using namespace std;

int main(int argc, char *argv[]) 
{
    for(int i = 0; argv[i]; ++i) 
    {
        printf("argv[%d]:%s\n", i, argv[i]);
    }
    cout << "hallo C++" << endl;
    cout << "hallo C++" << endl;
    cout << "hallo C++" << endl;
    cout << "hallo C++" << endl;
    cout << "hallo C++" << endl;

    return 0;
}

在这里插入图片描述
同样子进程也同样可以拿到环境变量:

#include <iostream>
using namespace std;
int main(int argc, char *argv[], char *env[]) 
{
    for(int i = 0; env[i]; ++i)
    {
        printf("env[%d]:%s\n", i, env[i]);
    }
    return 0;
}

环境变量处理

默认可以通过地址空间继承的方式,让所有的子进程拿到环境变量,进程程序替换,不会替换环境变量数据。

  1. 如果我们想子进程继承全部的环境变量,直接就能拿到
  2. 如果单纯新增呢?putenv
  3. 如果我想设置全新的环境变量呢?
// 设置全新的换进变量
 char *const env[] = {
    (char*)"haha=hehe",
    (char*)"PATH=/",
};

总结:

进程控制是操作系统中至关重要的一部分,通过fork函数可以创建新进程,并通过写时拷贝机制高效地管理进程资源。进程的终止有多种场景和方法,包括exit_exit函数的区别。进程等待是确保进程正确执行的重要手段,可以通过waitwaitpid方法实现。最后,程序替换通过exec函数族实现,能够在一个进程中加载新的程序并替换当前的进程映像,为进程的灵活运行提供了可能。

  • 24
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Q_hd

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值