前言:
进程控制是操作系统中的一个核心概念,涉及到进程的创建、终止、等待以及程序替换等方面。本文将介绍进程创建、终止、等待和程序替换的相关内容,包括 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代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
1.3. 写时拷贝的机制:
写时拷贝是一种延迟拷贝的策略,即在父子进程共享数据时,并不立即进行内存复制,而是等到其中一方尝试写入数据时才执行。这样可以节省内存开销,并提高程序的运行效率。写时拷贝通过操作系统内核的页表实现,当写入发生时,操作系统会将涉及到的页面复制给写入方的私有副本,从而保证了数据的独立性。具体见下图:
为什么要使用写时拷贝?
- 节省内存开销:延迟数据复制,只在必要时才进行,避免了不必要的内存浪费。
- 提高程序效率:减少了内存复制的次数,加快了程序的执行速度。
如何做到的?
进程创建的时候,OS做了什么?
创建PCB,创建地址空间,创建页表,将磁盘中的代码和程序加载到内存,构建映射关系,然后开始调度。
2. 进程终止
在操作系统中,进程的终止和退出是指进程执行完毕或异常终止后的处理过程。
2.1. 进程退出的三种场景:
- 正常终止:进程代码执行完毕,结果正确。
- 异常终止:进程执行完毕,结果不正确。
- 异常退出:进程未能执行完毕,发生异常情况,如收到了异常信号。
2.2. 进程常见退出方法:
main函数退出:
main函数的返回值,叫做进程的退出码
- 一般0,表示进程执行成功
- 非零,表示失败(用不同的数字表示失败的原因)
错误码转换成为错误的描述
- 使用语言和系统自带的方法,进行转化
- 可以自定义!
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");
我们所谈的的缓冲区(进度条之类)绝对不是操作系统里面的缓冲区!(在库级别的缓冲区)
操作系统在进程退出时的处理过程:
- 释放地址空间和页表:操作系统会回收进程所占用的内存空间,包括释放地址空间和相应的页表。
- 释放资源:操作系统会释放进程所申请的其他资源,如文件描述符等。
- 更新进程状态:操作系统会更新进程的状态信息,将其标记为已终止。
- 回收进程控制块:操作系统会回收进程控制块等数据结构,以便系统能够重新利用这些资源。
3. 进程等待
3.1. 为什么需要进行进程等待?
在多进程编程中,父进程可能会创建多个子进程,并且需要在子进程执行完毕后进行回收资源,以防止子进程成为僵尸进程。进程等待的主要目的包括:
- 回收子进程的资源:通过等待子进程的结束,父进程可以及时回收子进程占用的资源,避免资源泄漏。(必然)
- 获取子进程的退出信息:父进程可能需要了解子进程的退出状态,以便根据子进程的执行情况进行相应的处理。(可选)
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 0000(256)
查看: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. 阻塞 与 非阻塞等待
阻塞等待:
阻塞等待是指进程在等待某个事件发生时会暂时挂起,直到事件发生后才会继续执行。在进程等待子进程退出时,调用阻塞等待的函数(如 wait
或 waitpid
)会导致父进程挂起,直到子进程退出后才会继续执行。这种等待方式具有以下特点:
- 易于理解和编程:阻塞等待是一种直观的等待方式,代码结构清晰,易于理解和维护。
- 资源利用率高:在等待的过程中,进程不会占用 CPU 资源,因此可以有效地利用系统资源。
- 确保进程顺序执行:由于阻塞等待会使得进程暂停执行,因此可以确保父进程在子进程退出后再继续执行后续代码。
非阻塞等待:
非阻塞等待是指进程在等待某个事件发生时不会挂起,而是立即返回,继续执行后续代码。在进程等待子进程退出时,可以通过设置特定的选项(如 WNOHANG
)来实现非阻塞等待,即使子进程尚未退出,等待函数也会立即返回。这种等待方式具有以下特点:
- 提高系统响应速度:非阻塞等待不会阻塞进程的执行,因此可以提高系统的响应速度,使得进程能够及时响应其他事件。
- 灵活性高:非阻塞等待允许进程在等待的同时执行其他任务,具有更高的灵活性和并发性。
- 需要额外的轮询:由于非阻塞等待会立即返回,因此需要通过轮询来检查等待的条件是否已经满足,这可能会增加编程复杂度和系统开销。
#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. 直接说原理——什么是程序替换
- 我们的程序只能执行我们的代码
- 如果我们创建的子进程,想执行其他程序的代码呢?
当一个进程调用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;
}
环境变量处理
默认可以通过地址空间继承的方式,让所有的子进程拿到环境变量,进程程序替换,不会替换环境变量数据。
- 如果我们想子进程继承全部的环境变量,直接就能拿到
- 如果单纯新增呢?putenv
- 如果我想设置全新的环境变量呢?
// 设置全新的换进变量
char *const env[] = {
(char*)"haha=hehe",
(char*)"PATH=/",
};
总结:
进程控制是操作系统中至关重要的一部分,通过fork
函数可以创建新进程,并通过写时拷贝机制高效地管理进程资源。进程的终止有多种场景和方法,包括exit
和_exit
函数的区别。进程等待是确保进程正确执行的重要手段,可以通过wait
和waitpid
方法实现。最后,程序替换通过exec
函数族实现,能够在一个进程中加载新的程序并替换当前的进程映像,为进程的灵活运行提供了可能。