1. 进程创建
1.1 fork函数
在linux
中fork
函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
返回值:⾃进程中返回0,⽗进程返回⼦进程id,出错返回-1
进程调用fork
,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
fork
返回,开始调度器调度
当一个进程调用fork
之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程,看如下程序。
int main( void )
{
pid_t pid;
printf("Before: pid is %d\n", getpid());
if ( (pid=fork()) == -1 )perror("fork()"),exit(1);
printf("After:pid is %d, fork return %d\n", getpid(), pid);
sleep(1);
return 0;
}
运⾏结果:
[root@localhost linux]# ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0
这里看到了三行输出,一行before
,两行after
。进程43676先打印before
消息,然后它有打印after
。另一个afte
r消息有43677打印的。注意到进程43677没有打印before
,为什么呢?如下图所示
所以,fork
之前父进程独立执行,fork
之后,父子两个执行流分别执行。
注意:fork
之后,谁先执行完全由调度器决定。
1.2 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷备的方式各自一份副本。具体见下图:
上图显示父进程代码段在自己的页表中是只读的,包括我们以前定义的字符常量区,代码是不可写的,可是数据段为什么也是只读的?起始在我们的父进程还没创建子进程前,代码段是只读的没问题,但是数据段对应的映射关系,可能有一百个一千个映射地址,这些映射地址的权限实际上是读写的,但一旦创建了子进程,操作系统就会把数据段的权限也改成只读的。 然后后面的父子进程,比如说子进程尝试对它的数据进行写入,当它写入时,操作系统就会发现你要访问的数据,第一,数据是合法的,因为虚拟地址物理地址都有,而且它发现访问的区域是数据段,如果是代码段肯定在start_code
,end_code
这个区间里面,如果是数据段肯定在start_data
,start_end
这个区间里面,发现你是数据段,而且页表的映射关系是正确的,但是发现数据段怎么是只读的,所以这时候操作系统就会出错,这种出错不是真的错了,是操作系统检测到一个用户对一个只读的区域进行写入,但操作系统经过检查发现它是数据段,而且是子进程,这时候操作系统就会触发写时拷贝。写时拷贝是通过设置页表的权限,让页表让操作系统出错的行为。让操作系统知道我们正在访问一个只读的区域,进而在错误的驱使之下让操作系统完成对应的写时拷贝这样的任务。
因为有写时拷贝技术的存在,所以父子进程得以彻底分离离!完成了进程独立性的技术保证!
写时拷贝,是一种延时申请技术,可以提高整机内存的使用率
为什么要写时拷贝?
- 减少子进程的创建时间
- 减少内存浪费
1.3 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
2. 进程终止
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2.1 进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止(退出码无意义)
2.2 进程常见退出方式
正常终止(可以通过 echo $?
查看最近进程的退出码):
- 从
main
返回(main
函数结束表示进程结束,其他函数只表示自己函数调用完成) - 调用
exit(status)
(任何地方调用exit
表示进程结束,并返回给父进程bash
子进程的退出码) _exit
:终止一个调用进程(相当于谁调用它,它把谁“弄死”)
2.2.1 exit函数
#include <unistd.h>
void exit(int status);
exit
最后也会调用_exit
,但在调用_exit
之前,还做了其他工作:
- 执行用户通过
atexit
或on_exit
定义的清理函数。 - 关闭所有打开的流,所有的缓存数据均被写入
- 调用
_exit
2.2.2 _exit函数
#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终⽌状态,⽗进程通过wait来获取该值
说明:虽然
status
是int
,但是仅有低8位可以被父进程所用。所以_exit(-1)
时,在终端执行$?
发现返回值是255。
2.2.3 exit和_exit的区别:
exit
是c语言提供的,_exit
是系统提供的
进程如果exit
退出的时候,exit()
会进行缓冲区的刷新
进程如果exit
退出的时候,_exit
不会进行缓冲区的刷新
库函数和系统调用是上下层的关系,库函数没有进程终止能力,只能调用系统调用,操作系统给它提供的进程终止的接口它才能终止进程,所以exit
的底层封装了_exit
,所以我们之前谈论的缓冲区一定不在操作系统的内部,而是库缓冲区(c语言提供的缓冲区)
异常退出:
ctrl + c
,信号终止
2.2.4 退出码
退出码在Linux中通常用来表示命令执行后的结果,0
表示成功,非0
表示不同的错误类型
Linux Shell
中的主要退出码:
退出码 | 说明 |
---|---|
0 | 成功(命令正常执行) |
1 | 一般性错误(如参数错误、文件未找到、权限不足等) |
2 | Shell 内置命令误用(如语法错误、未找到命令等) |
126 | 权限问题(命令不可执行,如缺少执行权限) |
127 | 命令未找到(Shell 找不到指定命令) |
130 | 进程被 Ctrl+C 终止(SIGINT 信号) |
141 | 进程被 SIGHUP 信号终止(如终端关闭) |
3. 进程等待
3.1 进程等待必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏。
- 进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的
kill-9
也无能为力,因为谁也没有办法杀死一个已经死去的进程。 - 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
3.2 进程等待的方法
3.2.1 wait方法
如果等待子进程,子进程没有退出,父进程就会阻塞在wait
调用处(相当于scanf)
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关⼼则可以设置成为NULL
3.2.2 waitpid方法
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,⽽调⽤中waitpid发现没有已退出的子进程可收集,则返回0;
如果调⽤中出错,则返回-1,这时errno会被设置成相应的值以指⽰错误所在;
参数:
pid:
Pid = -1,等待任⼀个子进程。与wait等效。
Pid > 0.等待其进程ID与pid相等的⼦进程。
status: 输出型参数
WIFEXITED(status): 若为正常终⽌子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status)((status>>8)&0xFF): 若WIFEXITED⾮零,提取子进程退出码。(查看进程的退出码)
options:默认为0,表⽰阻塞等待
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等
待。若正常结束,则返回该子进程的ID。
- 如果子进程已经退出,调用
wait/waitpid
时,wait/waitpid
会立即返回,并且释放资源,获得子进程退出信息。 - 如果在任意时刻调用
wait/waitpid
,子进程存在且正常运行,则进程可能阻塞。 - 如果不存在该子进程,则立即出错返回。
3.2.3 获取子进程status
wait
和waitpid
,都有一个status
参数,该参数是一个输出型参数,由操作系统填充。- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status
不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status
低16比特位):
3.2.4 阻塞与非阻塞等待
- 进程的阻塞等待方式:
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main(void)
{
pid_t pid;
if ((pid = fork()) == -1)
perror("fork"), exit(1);
if (pid == 0) {
sleep(20);
exit(10);
}
else {
int st;
int ret = wait(&st);
if (ret > 0 && (st & 0X7F) == 0) { // 正常退出
printf("child exit code:%d\n", (st >> 8) & 0XFF);
}
else if (ret > 0) { // 异常退出
printf("sig code : %d\n", st & 0X7F);
}
}
}
测试结果:
# ./a.out #等20秒退出
child exit code : 10
# ./a.out #在其他终端kill掉
sig code : 9
- 进程的非阻塞等待方式:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
typedef void (*handler_t)(); // 函数指针类型
std::vector<handler_t> handlers; // 函数指针数组
void fun_one() {
printf("这是⼀个临时任务1\n");
}
void fun_two() {
printf("这是⼀个临时任务2\n");
}
void Load() {
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
void handler() {
if (handlers.empty())
Load();
for (auto iter : handlers)
iter();
}
int main() {
pid_t pid;
pid = fork();
if (pid < 0) {
printf("%s fork error\n", __FUNCTION__);
return 1;
}
else if (pid == 0) { // child
printf("child is run, pid is : %d\n", getpid());
sleep(5);
exit(1);
}
else {
int status = 0;
pid_t ret = 0;
do {
ret = waitpid(-1, &status, WNOHANG); // ⾮阻塞式等待
if (ret == 0) {
printf("child is running\n");
}
handler();
} while (ret == 0);
if (WIFEXITED(status) && ret == pid) {
printf("wait child 5s success, child return code is :%d.\n",
WEXITSTATUS(status));
}
else {
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
4. 进程替换
我们先来看一段代码:
上面这种现象就叫做程序替换,也就是我自己的程序把系统当中的指令跑起来了。在程序替换的时候,并没有创建新的进程,只是把当前进程的代码和数据用新的进程的代码和数据覆盖式的进行替换。
- 问题一:“为什么我的程序运行结束了”,这段话没有在显示器上打印出来?
答案是一旦程序替换成功就去执行新代码了,原始代码的后半部分已经不存在了
- 有没有办法让后面的代码能继续执行?
答案是有的,创建一个子进程,让子进程去做替换工作,让父进程继续执行后面的代码。
效果演示:
📌Tips:程序替换也能替换我们自己写的程序,就相当于一种加载器,可以加载各种程序,包括编译型的解释型的,程序替换本质上不会创建新的进程
为什么不会影响父进程呢?
a.进程具有独立性 b.数据和代码发生写时拷贝
execl
的返回值
execl
函数只有失败返回值,没有成功返回值
💦结论:exec*
系列的函数,不用做返回值判定,只要返回,就是失败
4.1 替换原理
4.2 替换函数
int execl(const char *path,const char *arg,...)
,第一个参数表示程序+路径名
,第二个参数有个口诀:命令行怎么写,我们就怎么传(当然传-al
也是可以的),而把参数一个一个的传进来我们称之为list
,类似于以链表的形式传给它,所以execl
这个l
就是llist
的意思,execl
函数的最后一个参数必须以NULL
结尾,表明参数传递完成int execlp(const char *file,cont char *arg,...)
,execlp
当中的p
表示PATH
,所以第一个参数只需传要执行的程序名就行了,因为execlp
会自动的在环境变量(PATH)里查找对应的命令,所以execlp
一般执行系统级的命令。后面参数的传递和上面的相同
int execv(const char *path,char *const argv[])
,首先它没有带p
所以它的参数是path
,所以同上上,这里的v
就相当于vector
,所以第二个参数就以数组的形式呈现了,所以就必须提供一个命令行参数表,就是指针数组,就是把ls -a -l
整体放在数组里,一次性传递,这个表也必须以NULL
结尾。所以我们以前执行的所有命令行参数都是父进程通过execv传给子进程的
int execvp(const char *path,char *const argv[])
,有p
所以不用带路径
int execvpe(const *file,char *const argv[],char *const envp[])
这里的v
表示以数组的方式传进来,p
表示不用带路径,e
表示环境变量,如果非要传递环境变量列表,要求:被替换的子进程使用全新的Env列表(自己写的)
若要以新增的方式传递环境列表呢?
➀putenv
表示哪个进程调用它,就在谁的环境变量表里新增一个环境变量(B是A的子进程,C是B的子进程,如果B在它的环境列表里导入了一个新的环境变量,A的环境列表里看不到,而C的环境列表里能看到)
➁如果我们就行用execvpe
的方式呢?environ
表示把新增的环境变量添加到环境变量表里面去,然后把环境变量表的起始地址传给execvpe
int execle(const *path,const *arg,...,char * const envp[])
总结:
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list)
:表示参数采用列表v(vector)
:参数用数组p(path)
:有p自动搜索环境变量PATHe(env)
:表示自己维护环境变量
上面这些函数都是对系统调用进行了语言型的封装,最后都要调用系统调用execve
,为什么要做语言封装呢?因为程序替换时要面对各种各样上层替换的场景。所以execve
在man手册第2节
下图exec
函数簇一个完整的例子:
👍 如果对你有帮助,欢迎:
- 点赞 ⭐️
- 收藏 📌
- 关注 🔔