Linux进程控制
🏞️1. 认识fork函数
在Linux中fork
函数是非常重要的函数,它从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程.
#include <unistd.h>
pid_t fork(void);
返回值:给子进程返回0, 父进程返回子进程pid, 出错返回-1
当进程调用fork
后,控制转移到内核中的fork
代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝给子进程
- 添加子进程到系统的进程列表当中
fork
返回,调度器开始调度
一个进程调用fork后,子进程与父进程具有相同的代码,而且此时CPU中对于它们的程序计数器都指向了相同的位置,也就是说,当这两个进程的fork返回后,它们从相同的位置开始运行.
接下来,我们使用一段代码观察一下fork
函数执行后的现象:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
printf("fork之前的代码\n");
pid_t id = fork();
if(id == -1)
{
perror("fork() error\n");
exit(1);
}
if(id == 0)
{
//子进程
while(1)
{
sleep(1);
printf("我是子进程 我的id是%d, 我的父进程是%d\n", getpid(), getppid());
}
}
else
{
//父进程
while(1)
{
sleep(1);
printf("我是父进程, 我的id是%d\n", getpid());
}
}
return 0;
}
运行结果:
我们发现了一个现象:fork之后产生的子进程并没有执行fork之前的代码,而是从fork函数返回后往后执行.
这是因为在产生子进程后,子进程会拷贝父进程的程序计数器,所以子进程在产生后,会和父进程从同样的位置开始往后执行.
1.1 fork的常规用法
- 一个父进程希望复制自己,使父子进程执行不同的代码段,例如:父进程等待客户端请求,生成子进程来处理请求
- 一个进程要执行一个不同的程序,例如子进程从
fork
返回后,调用exec
函数
1.2 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
🌁2. 写时拷贝
通常,父子进程共享代码和数据,当任意一方写入时,便以写时拷贝的方式各自拷贝一份副本:
为什么要写时拷贝,为什么不在子进程创建的时候就把数据分开呢?
- 父进程的数据,子进程不一定全用,即便全用,也不一定全部写入
- 最理想的情况,在fork的时候,只把会被父子进程修改的数据,进行分离拷贝,不需要修改的共享即可(但实现太复杂)
- 如果fork的时候,就无脑拷贝数据给子进程,会增加fork的成本(时间和空间)
所以最终采用写时拷贝:只会拷贝父子修改的,就是拷贝数据的最小成本
🌠3. 进程终止
在我们写C/C++代码时,往往在程序的最终,我们都会写一句 return 0;
,可是为什么要return 0
呢?是给谁return
呢?
其实,return X
,X代表进程退出码,当我们的进程退出时,如果return 0
,表示结果正确,非0
表示失败,那么进程退出码返回的非0值,就是用来表征进程失败退出的原因,而且这个进程退出码是让父进程来读取的.
3.1 进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
3.2 进程终止常见做法
在
main
函数中调用return
注意,这里必须是在
main
函数中return
,在非main
函数中return
代表函数调用结束,不代表进程退出.在自己代码的任意地点,调用
exit()
函数调用
_exit()
函数
_exit
函数:
#include <unistd.h>
void _exit(int status);
参数:
status
定义了进程的终止状态,父进程通过wait
来获取此值
- 说明:虽然
status
是int
类型,但是仅有低8位被父进程所用,所以exit(-1)
时,在终端执行$?
发现返回值是255
exit
函数:
void fun()
{
printf("process exit\n");
exit(111);
}
int main()
{
fun();
return 22;
}
运行结果:
所以,在程序的任意位置调用exit()函数,进程就会立即退出,不会再执行return
,进程退出码也以exit(
)为准.
exit
最终也会调用_exit()
,但在调用_exit()
之前,还做了其他工作:
- 执行用户定义的清理函数
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用
_exit()
所以exit()
函数最终会刷新缓冲区,而_exit
不会.
int main()
{
printf("process exit");
exit(1);
return 22;
}
int main()
{
printf("process exit");
_exit(1);
return 22;
}
🌌4. 进程等待
4.1 为什么要进行进程等待?
- 子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,造成内存泄露
- 另外,进程一旦变成僵尸状态,那就刀枪不入,"杀人不眨眼的
kill -9
"也无能为力,没有办法杀掉一个已经死去的进程. - 父进程派发给子进程的任务完成的如何,我们需要知道,如:子进程运行完成,结果对还是不对,或者是否正常退出
父进程通过进程等待的方式,回收子进程资源,或者子进程退出信息.
4.2 进程等待的方法
wait方法
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int* status);
返回值:成功则返回被等待进程的pid,失败返回-1
参数:输出型参数,获取子进程的退出状态,不关心则可以设置为NULLwaitpid方法
pid_t waitpid(pid_t pid, int* status, int options);
返回值:
当正常返回的时候
waitpid
返回收集到的子进程的进程ID
; 如果设置了选项
WNOHANG
,而调用中waitpid
发现没有已退出的子进程可收集,则返回0
; 如果调用中出错,则返回
-1
,这时errno
会被设置 所以:返回值
> 0
等待成功,返回值< 0
等待失败.参数:
pid:
0:
是几,就代表等待哪一个子进程pid,
-1:
等待任意子进程
status:
WIFEXITED(status)
:若为正常终止子进程返回的状态,则为真(查看子进程是否正常退出)
WEXITSTATUS(status)
:若WIFEXITED非0,提取子进程退出码. (查看子进程的退出码)
options
:如果设置了WNOHANG
,若pid
指定的子进程没有结束,则waitpid()
函数返回0
,不予以等待,若正常结束,则返回该子进程的ID
.如果子进程已经退出,调用
wait/waitpid
时,wait/waitpid
会立即返回,并且释放资源,获取子进程退出信息如果在任意时刻调用
wait/waitpid
,子进程存在且正常运行,则进程可能阻塞如果不存在该子进程,则立即出错返回.
参数status
从wait/waitpid
函数内部拿出子进程的退出信息,那么,它具体是从哪里拿到的呢?
子进程会将自己的退出信息保存在自己的task_struct
中,然后父进程通过系统调用wait/waitpid
从子进程的task_struct
中拿出子进程的退出码
4.3 status参数
wait
和waitpid
,都有一个status
参数,该参数是一个输出型参数,由操作系统填充- 如果传递
NUL
L,表示不关心子进程的退出状态信息 - 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程
status
不能简单的当作整型来看,可以当作位图来看待,具体细节如下:
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("fork error\n");
exit(1);
}
if(id == 0)
{
printf("我是子进程, 我正在运行, 我的pid是%d\n", getpid());
sleep(5);
exit(10);
}
else
{
int status = 0;
printf("我是父进程, 我在等待子进程的退出并回收它\n");
pid_t ret = waitpid(id, &status, 0); //阻塞等待
if(ret > 0)
{
printf("wait success, 等待子进程的pid: %d, 子进程的退出码: %d, 子进程的退出信号: %d\n", id,
(status>>8)&0xFF, status&0x7F);
}
else
{
printf("wait failed\n");
}
}
return 0;
}
可以使用这段代码来验证.
4.4 非阻塞等待
如何理解父进程阻塞?
父进程的进程状态由R(运行态)->S(阻塞),并将父进程由运行队列投入到等待队列,等待子进程的退出
基于轮询的非阻塞等待:
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("fork error\n");
exit(1);
}
if(id == 0)
{
while(1)
{
printf("我是子进程, 我正在运行, 我的pid是%d\n", getpid());
sleep(5);
}
exit(10);
}
else
{
int status = 0;
while(1)
{
pid_t ret = waitpid(id, &status, WNOHANG); //非阻塞等待
if(ret > 0)
{
printf("wait success, 等待子进程的pid: %d, 子进程的退出码: %d, 子进程的退出信号: %d\n", id,
(status>>8)&0xFF, status&0x7F);
break;
}
else if(ret == 0)
{
printf("子进程还没好, 我先做其他事情\n");
sleep(1);
}
else
{
}
}
}
return 0;
}
⛺5. 进程程序替换
5.1 原理
当我们用fork
创建子进程后,子进程执行的是父进程的代码片段,如果我们想要创建出的子进程,执行全新的代码片段呢?
这时我们就可以使用程序替换,让子进程调用exec
函数以执行另一个程序,当进程调用一种exec
函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的例程开始执行,调用exec
并不创建新进程,所以exec
调用前后该进程的id
并未改变.
为什么要进行程序替换呢?
我们一般在服务器设计的时候,往往需要子进程干两种事情:
- 让子进程执行父进程的代码片段(服务器代码)
- 让子进程执行磁盘中一个全新的程序,通过我们的进程,执行其他人写的代码等.
程序替换的原理:
- 将磁盘中的程序,加载进内存
- 重新建立页表映射,谁执行程序替换,就重新建立谁的映射,可以令父进程和子进程分离,并让子进程执行一个全新的程序
5.2 替换函数
替换函数共有六种,统称为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 execve(const char* path, char* const argv[], char* const envp[]);
我们用一个替换函数来演示一下:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我是一个进程, 我的id是 %d\n", getpid());
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
return 0;
}
我们可以使用execl
让我们当前的进程转去执行ls
指令(ls
也是一个可执行程序).
有一个问题:我们需不需要判断这个函数的返回值呢?
一旦替换成功,就将当前进程的代码和数据全部替换了,那该函数后面的代码也就被替换了,所以这个程序替换函数一旦替换成功,就不会有返回值,而失败的时候,会有返回值,并且会继续向后执行,所以返回值最多能让我们得到什么原因导致的替换失败.
如果我们使用fork创建一个子进程,子进程执行程序替换,会不会影响父进程呢?
不会,进程具有独立性,当程序替换时,子进程的代码和数据发生写时拷贝,完成父子进程的分离.
5.3 测试不同接口
int execv(const char* path, char* const argv[]);
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
char* const _argv[] = {
"top",
NULL
};
execv("/usr/bin/top", _argv);
}
else
{
//父进程
int status = 0;
int ret = waitpid(id, status, 0);
if(ret == id)
{
printf("wait success\n");
}
}
return 0;
}
int execlp(const char* file, const char* arg, ...);
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
execlp("ls", "ls", "-a", "-l", NULL);
}
else
{
//父进程
int status = 0;
int ret = waitpid(id, status, 0);
if(ret == id)
{
printf("wait success\n");
}
}
return 0;
}
int execvp(const char* file, char* const argv[]);
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
char* const _argv[] = {
"top",
NULL
};
execvp("top", _argv);
}
else
{
//父进程
int status = 0;
int ret = waitpid(id, status, 0);
if(ret == id)
{
printf("wait success\n");
}
}
return 0;
}
int execle(const char* path, const char* arg, ..., char* const envp[]);
可以使用这段代码验证环境变量的导入是覆盖式的:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
extern char** environ;
pid_t id = fork();
if(id == 0)
{
//子进程
char* const _env[] = {
(char*)"MYPATH=YouCanSeeMe",
NULL
};
execle("./mycmd", "mycmd", NULL, environ);
}
else
{
//父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret == id)
{
printf("wait success\n");
}
}
return 0;
}
//mycmd.cpp
#include<iostream>
#include<stdlib.h>
using namespace std;
int main()
{
cout << "PATH: " << getenv("PATH") << endl;
cout <<"----------------------------------"<< endl;
cout << "MYPATH: " << getenv("MYPATH") << endl;
cout <<"----------------------------------"<< endl;
cout << "hello C++" <<endl;
cout << "hello C++" <<endl;
cout << "hello C++" <<endl;
cout << "hello C++" <<endl;
cout << "hello C++" <<endl;
return 0;
}
int execvpe(const char *file, char *const argv[], char *const envp[]);
在程序替换函数中,还有一个特殊的函数:
int execve(const char *filename, char *const argv[], char *const envp[]);
为什么它是单独列出的呢?
execve
是一个系统调用,其他的六个替换函数都是对它的封装.
所以,最终我们可以总结出这些接口的规则:
l (list)
:表示参数采用列表v (vector)
:参数采用数组p (path)
:有p
自动搜索环境变量PATH
e (env)
:表示自己维护环境变量