【Linux】进程控制

发布于:2024-05-09 ⋅ 阅读:(147) ⋅ 点赞:(0)

目录

一、进程创建

1.1 fork回顾

1.2 写时拷贝

二、进程终止

2.1 进程终止时的操作

2.2 引起进程终止的原因

2.3 退出状态码

2.4  C语言的错误码 errno

2.5 进程的退出方式

三、进程等待

3.1 进程等待的概念

3.2 进程等待的必要性

3.3 进程等待的方法

3.3.1 wait 方法

3.3.2 waitpid方法

3.3.3 waitpid 更详细的学习


一、进程创建

1.1 fork回顾

#include <unistd.h>
pid_t fork(void);

功能:从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程

返回值:子进程中返回0,父进程返回子进程id,出错返回-1

 进程调用fork(),控制立即从用户空间转移到内核空间,内核执行:

  • 创建新的进程控制块(PCB):内核为子进程创建一个新的PCB,用于存储子进程的上下文信息,如寄存器状态、页表、打开的文件描述符等。
  • 复制父进程的页表:内核复制父进程的页表,为新进程创建一个相同的虚拟地址空间。父子进程开始共享相同的内存页面。
  • 设置新的进程标识:内核为新进程设置一个新的PID,并将其PPID设置为调用fork()的父进程的PID。
  • 初始化子进程的状态:内核为新进程设置基本的进程状态,如进程调度信息、信号处理等。
  • 复制父进程的环境变量和文件描述符:内核为新进程复制父进程的环境变量和打开的文件描述符。
  • 修改子进程的程序计数器:内核将子进程的程序计数器设置为父进程调用fork()之后的下一条指令,这样子进程可以从父进程调用fork()之后的地方开始执行。
  • 设置子进程的返回值:对于父进程,fork()返回子进程的PID;对于子进程,返回值为0。
  • 更新系统状态:内核更新系统状态,如进程计数、进程队列等。
  • 控制返回:内核将控制返回到调用fork()的用户空间程序。
  • 子进程开始执行:子进程从调用fork()之后的下一条指令开始执行,而父进程从调用fork()之后的下一条指令继续执行。

fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。

fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

1.2 写时拷贝

写时拷贝(Copy-on-Write,简称COW)是一种内存管理技术,用于提高文件系统的性能和效率。在写时拷贝中,当一个进程试图写入一个共享的内存区域时,系统不会立即复制整个内存区域,而是先保留原来的内存区域,然后在新的内存区域中创建该共享内存的副本。只有在需要写入操作时,才会真正复制内存。

写时拷贝的过程

  1. 父进程创建子进程时父进程会将自己页表项的权限由读写改为只读,然后创建子进程,内核会为子进程复制父进程的页表。这样父子进程页表项权限都是只读。
  2. 这一过程用户不知道,用户可能对某一批数据进行写入。如果子进程尝试修改父进程的物理页面,由于权限设置为只读,操作会被内核拦截,并引发一个异常。(两种情况:①真正出错,例如修改代码区;②不是出错,而是触发重新申请内存拷贝内容的策略机制,需要写时拷贝)
  3. 此时操作系统介入处理,如果页面是写时拷贝的,内核会为子进程申请新的物理页面进行拷贝,然后更新页表,设置父子进程页表项的权限。

写时拷贝的用途
写时拷贝主要用于文件系统,特别是在支持多进程共享文件的文件系统中。它可以提高文件系统的性能和效率,因为它减少了不必要的内存复制操作。此外,写时拷贝还可以提高系统的安全性,因为它可以防止一个进程的错误影响到其他进程。

二、进程终止

2.1 进程终止时的操作

进程终止后,操作系统需要回收其占用的资源,并通知其父进程。

当一个进程终止时,操作系统会执行以下操作:

  1. 设置进程状态:操作系统会将进程的状态设置为退出状态。
  2. 清理资源:操作系统会清理进程占用的资源,如关闭文件描述符、释放内存、终止子进程等。
  3. 更新进程计数:操作系统会更新进程计数器,以反映进程的终止。
  4. 通知父进程:操作系统会发送一个信号给进程的父进程,通知它其子进程已经终止。父进程可以通过调用wait()或waitpid()系统调用来接收子进程的终止通知。

2.2 引起进程终止的原因

  • 正常结束:当一个进程完成了它的任务或者到达了某个终止点时,它会调用exit()系统调用来通知操作系统它已经完成了。
  • 信号处理:进程可以接收到来自其他进程发送的信号,例如SIGKILL(立即终止进程)或SIGTERM(正常终止进程)。如果进程收到这些信号并且没有忽略或处理它们,进程将终止。
  • 系统调用:进程可以通过调用exit()、_exit()或return语句来正常终止。这些系统调用会设置进程状态为退出状态,并释放其占用的资源。
  • 资源耗尽:如果进程尝试打开太多文件、创建太多进程、使用太多内存等,它可能会因为资源耗尽而被终止。
  • 执行非法指令:如果进程尝试执行一个非法指令,如访问未映射的内存地址,它可能会被操作系统终止。
  • 系统调用失败:如果进程尝试调用一个系统调用,但系统调用失败(如权限不足),进程可能会被终止。

2.3 退出状态码

在Linux中,C/C++中main函数的返回值通常被解释为程序的退出状态码如果main函数返回0,通常表示程序成功执行;如果返回非零值,表示程序执行过程中出现了错误。通常某个数字代表某种出错原因。纯数字能表示出错原因,但是不便于人阅读,于是就需要能将数字转换成错误码的字符串描述方案,在C语言中这个方案(接口)就是strerror


main函数的退出码是可以被父进程获取的,用来判断子进程的运行结果。

退出码可以由用户定义:

echo $?    用于打印最近一个命令的退出状态码。

? 保存的是最近一个子进程执行完毕的退出码。$表示取变量的内容

2.4  C语言的错误码 errno

在C语言中,errno是一个全局变量,它被用于存储由系统调用或库函数报错时的出错信息。

例如fork的返回值:创建子进程没有成功时,会适当地设置错误码errno

错误码 VS 退出码

错误码通常是衡量函数或者系统调用时的调用情况。

退出码通常是一个进程退出的时候,它的退出结果。

当失败的时候,二者用来衡量函数、进程出错时的详细出错原因。

很多的系统调用接口在调用失败时都会通过errno表明它的错误,因为Linux内核是用C写的。

错误码和退出码的用法示例:

 int main()
 17 {
 18     int ret = 0;
 19     cout << "before: " << errno << endl;
 20     FILE *fp = fopen("./log.txt","r");
 21     if(fp == nullptr){
 22         cout << "after: " << errno << " , errno string: " << strerror(errno) << endl;
 23         ret = errno;
 24     }
 25 
 26     return ret;
 27 }

 

2.5 进程的退出方式

进程常见退出方式:

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

操作系统是进程的管理者,进程异常时操作系统要处理这个异常情况。进程出异常时,它的异常信息会被操作系统检测到,并被转换成信号,然后终止进程。进程出异常,本质是进程收到了对应的信号,自己终止了。

所以,一个进程是否出异常,我们只要看有没有收到信号即可。如果没有异常,就根据进程的退出码来判断 。子进程终止,父进程可以获取子进程的退出状态码和信号信息。

#include <stdlib.h>
void exit(int status);

功能:终止当前进程并返回给调用它的进程一个退出状态码。类似于main函数的return n

参数:进程的退出状态码。这个值可以被传递给父进程,父进程可以通过调用wait()或waitpid()系统调用来获取子进程的退出状态。

注:在代码中的任意地点调用exit,表示进程退出,不再进行执行后续代码。

#include <unistd.h>
void _exit(int status);

功能:系统调用,用于立即终止当前进程,并返回给父进程一个整数退出状态码。
参数:status 定义了进程的终止状态,父进程通过wait来获取该值

注:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。

_exit 和 exit 的区别:

  1. exit 是库函数,_exit 是系统调用
  2. exit终止进程的时候,会自动刷新缓冲区。_exit终止进程的时候,不会自动刷新缓冲区。
    之前讲到在C语言中,有一种刷新输出缓冲区的方式为程序退出时刷新:当程序正常终止时,输出缓冲区通常会被自动刷新。但是,如果程序异常终止(如调用_exit()函数)或者使用非正常的终止方式,输出缓冲区可能不会被刷新。

注:

  • 缓冲区不在操作系统内部。exit是库函数,使用时会调用_exit这个系统调用, 如果缓冲区在操作系统内部,那么_exit也会顺便把缓冲区刷新。那么缓冲区就可能在C标准库中。之后会进行解释。

三、进程等待

3.1 进程等待的概念

通过调用wait()或waitpid(),让父进程(一般)对子进程进行资源回收的等待过程。

3.2 进程等待的必要性

  • 解决'僵尸进程'造成的内存泄漏问题。(僵尸进程是指已经结束执行但仍然在进程表中占据一个位置的进程。)
  • 创建子进程是为了完成某项任务,父进程要知道派给子进程的任务完成的如何
    例如子进程运行完成结果对还是不对,或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。(两个数字:退出状态码、信号编号)

3.3 进程等待的方法

3.3.1 wait 方法

       #include <sys/types.h>
       #include <sys/wait.h>
       pid_t wait(int *status);

功能:

        系统调用,用于父进程等待其任意一子进程的终止。当父进程调用wait()时,它会暂停执行,直到至少有一个子进程终止。

参数:
         输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

返回值:
         成功返回被等待进程pid,失败返回-1。

注:

  • 如果子进程没有退出,父进程必须在wait上进行阻塞等待。直到子进程僵尸,然后wait自动回收,返回!
  • 进程等待条件没有就绪时,进程处于阻塞状态(S),一个进程不仅能等待硬件资源是否就绪(键盘是否输入),也可以等待软件资源是否就绪(例如子进程是否退出就绪),如果软硬件条件不就绪,进程将进入阻塞或挂起状态。
  • 所以父进程在wait时就在阻塞等待。一般而言父进程最后退出,多进程执行流由父进程发起,也由父进程回收。

3.3.2 waitpid方法

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

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

功能:

        系统调用,用于父进程等待指定进程ID(pid)的子进程的终止。这个系统调用提供了比wait()更多的灵活性,因为它允许父进程指定要等待的子进程ID,并且可以设置非阻塞等待选项。

参数:

  • pid:指定要等待的子进程的PID。
            pid = -1        等待任一个子进程。与wait等效。
            pid > 0         等待指定子进程。
  • status:指向一个整数变量的指针,用于存储子进程的退出状态码。(输出型参数)
    WIFEXITED(status): 这是一个宏,用于对status中的信号值做检测,若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
    WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码) 
  • options: 是一个掩码,用于指定等待子进程时的行为。
            默认设置为0:表示以阻塞方式等待。
            WNOHANG: 表示等待时,以非阻塞方式进行等待。

返回值:(当options设置为WNOHANG时会有3种返回值,若options为0,返回值同wait)

  • > 0 :正常返回,waitpid返回收集到的子进程的PID;
  • = 0 :等待成功,但等待的进程还没有退出。
  • < 0 :等待失败,例如调用进程出错。

注:

  • status是一个输出型参数,需要用户自己定义一个整型变量,操作系统将status对应的值赋给该变量。
  • status对应一个int整数,但它不是整存整取的,它的32个bit被按照不同的区域划分,表示不同的含义。我们在使用时只用考虑它的低16位。
    正常退出时,次低8位为退出码,低8位为0。
    异常退出时,次低8位(退出码)没有意义,低7位为终止信号。
  • 如果子进程exit(10)退出,即正常退出,status返回的值应该为2560
    原因:10的二进制为1010,那么status的次低8位为0000 1010,低16为0000 1010 0000 0000 翻译成十进制正好为2560。
  • 我们不能对status整体使用,因为不知道是否为正常退出。
  • kill -l 查询数字对应的信号(信号列表是没有0号信号的)
  • 退出信号为0,退出码为0,表示正常退出,结果正确。
    退出信号为0,退出码非0,表示正常退出,结果不正确。
    退出信号非0,表示异常退出。可通过信号知道原因。


指针指向的是0号地址,对0号地址写入会触发非法写入。

3.3.3 waitpid 更详细的学习

父进程如何得知子进程的退出信息?

父进程可以通过调用wait()或waitpid()系统调用来得知子进程的退出信息。wait()和waitpid()会设置参数int* statusp指向status,子进程退出时会将状态设置为'Z',并且在PCB中写入退出码、退出信号。wait将退出码、退出信号写入status中
(例如 *statusp = (exit_code<<8) | exit_signal)
原因:操作系统不允许用户直接访问PCB,所以设置了wait/waitpid来获取PCB的内容。

回顾阻塞和等待:

进程可能会访问底层的某些设备,例如磁盘和显示器,而每一个设备被操作系统识别和管理,就需要先描述再组织,所以每个设备在内核中都有它对应的结构体。一个进程在一个设备上等待,本质是设备给它提供了等待队列。所以一个进程的状态由'R'改成'S'时,把PCB链入到设备的结构体对象,那么当前进程就在设备里等待。同理,父进程在等待子进程时,也是将自己的PCB链入到子进程的等待队列中。当子进程退出时,操作系统直接将父进程从子进程的等待队列中取出,然后调度父进程。即执行了父进程对应的wait。

注:

  • 不仅设备有等待队列,每个PCB也有自己的等待队列。
  • 一个进程在等待时阻塞,那么一定在等待队列中等待。
  • 在等待某个进程资源就绪时,可以把等待进程放在被等待进程中。

使用WIFEXITED和WEXITSTATUS宏
WIFEXITED(status): 这是一个宏,用于对status中的信号值做检测,若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码) 

 父进程等待多个子进程时,使用waitpid最好不要按照创建子进程的顺序等待子进程(例如创建一个子进程pid表),因为如果子进程没有结束,父进程就会阻塞,即使后创建的子进程已经结束也无法继续,可使用-1(代表等待任意一个子进程)。

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;

void Worker(int number)
{

    //int* p = NULL;
   for(int i = 0; i < 3; i++)
   {
        cout << "I am a child process, pid: " << getpid() << ", ppid: " << getppid() << ", number: " <    < number << ", i: " <<i << endl;
       sleep(1);

       //*p = 100;
   }
   //exit(10);
}
                                                                                                                 
const int n =10;

int main()
{
    for(int i = 0;i < n; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            Worker(i);                                                                                            
            exit(i);
        }
    }
    
    //等待多个子进程
    for(int i = 0; i < n; i++)
    {
        int status = 0;
        pid_t rid = waitpid(-1,NULL,0);
        if(rid > 0){
            cout << "wait child " << rid << " success, exit_code: " << WEXITSTATUS(status) << endl;
        }
    }

    return 0;
}

 我们为什么不用全局变量获取子进程的退出信息?而是用系统调用?

原因:定义的全局变量在父进程中已经存在,如果子进程退出时将退出状态写入该全局变量,则会触发写时拷贝,此时父进程是读不到这个值的。

进程具有独立性,父进程无法读到子进程的退出信息。

引入两个概念

  1. 宕机(Downtime): 宕机是指系统或服务无法正常工作的一段时间。例如蓝屏。这可能是由于硬件故障、软件错误、网络问题、电源中断、维护或升级等原因造成的。

  2. 夯(Thrashing): 夯是指在存储器(尤其是磁盘存储)中频繁地读写数据,导致系统性能急剧下降的现象。例如进程很多,电脑特别卡。这种情况通常发生在系统内存不足时,操作系统不断地在内存和磁盘之间交换页面,以尝试满足所有运行的进程的需求。这种频繁的页面交换会导致CPU利用率很高,但实际完成的工作却非常少,因为大部分时间都花在了读写操作上,而不是处理数据上。

waitpid的第三个参数:options        (是一个掩码,用于指定等待子进程时的行为。

  • 默认设置为0:表示以阻塞方式等待。
  • WNOHANG:表示等待时,以非阻塞方式进行等待。
    若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。
    若正常结束,则返回该子进程的ID。 

阻塞等待 VS 非阻塞等待

阻塞等待:子进程不退出,wait不返回,此时父进程什么也干不了。

非阻塞等待:进行等待的过程中,可以做一些占据时间不多的事情。

WNOHONG 的示例:

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

int main() {
    pid_t child_pid, terminated_pid;
    
    // 创建一个子进程
    child_pid = fork();
    
    if (child_pid == 0) {
        // 子进程
        printf("Child process is running\n");
        sleep(5); // 子进程睡眠5秒
        printf("Child process is exiting\n");
        _exit(0); // 子进程退出
    } else if (child_pid > 0) {
        // 父进程
        int status;
        do {
            terminated_pid = waitpid(child_pid, &status, WNOHANG);
            if (terminated_pid == 0) {
                printf("Child is still running\n");
                sleep(1); // 等待1秒
            }
        } while (terminated_pid == 0);
        
        if (terminated_pid == child_pid) {
            printf("Child has terminated\n");
        } else {
            printf("waitpid error\n");
        }
    } else {
        // 出错
        printf("fork error\n");
    }
    
    return 0;
}

 父进程使用 WNOHANG 选项定期检查子进程是否已经结束,并在子进程结束前执行其他任务(在这个例子中是打印一条消息并等待1秒)。一旦子进程结束,waitpid 将返回子进程的ID,父进程可以继续执行后续操作。


网站公告

今日签到

点亮在社区的每一天
去签到