目录
Linux进程控制
进程创建
在Linux进程基础篇已经提到过使用fork
函数创建一个子进程。
回想使用fork
函数时,其返回值当子进程创建失败时,父进程接收到-1;当子进程创建成功时,则对于子进程来说接收到的返回值为0,而父进程接收到的返回值为子进程的PID
其原因现在也可以解释:前面提到当子进程不修改与父进程共享的数据时,子进程和父进程拥有同一块数据空间,但是因为fork
函数创建完子进程后,函数会对两个进程的同一块区域的数据进行修改,导致子进程拥有的是一块新的空间,并在新的空间中写入0,而父进程依旧对应原来的空间,并在原空间中写入子进程的PID
以上便是在代码中创建子进程的步骤
进程终止
前面在父进程中创建子进程只满足了创建,并没有满足终止。在程序中,如果想终止一个进程,可以使用三种方式:
main
函数中return
- 在代码任何位置使用
exit()
函数,参数可以是任意整数 - 在代码任何位置使用
_exit()
函数,参数可以是任意整数
第二个方式和第三个方式与第一个方式最大的不同:第一个方式只会结束某一个函数,而第二个方式和第三个方式会直接结束一个进程
之前C语言部分介绍过:在不同的系统和C语言标准库的实现中都规定了一些错误码,一般是放在 errno.h
这个头文件中说明的,C语言程序启动的时候就会使用一个全局变量errno
来记录程序的当前错误码,只不过程序启动的时候errno
是0,一般0表示没有错误,而非0表示存在错误。当我们在使用标准库中的函数的时候发生了某种错误,就会将对应的错误码存放在errno
中,而一个错误码的数字是整数很难理解是什么意思,所以每一个错误码都是有对应的错误信息的,但是不同的操作系统会存在不同的错误码和错误信息,为了更方便的显示指定的错误码对应的错误信息,可以使用strerror
函数,参数传递errno
变量或者指定的错误码。除了标准库中已经定义的错误,也可以约定某些错误码表示某种错误
不论是库或者系统已经提供的错误码,还是自定义的错误码都可以作为exit
函数和_exit
函数的参数传递
但是,exit
函数和_exit
函数存在细微的差别:exit
函数在结束进程时,会刷新进程的缓冲区,但是_exit
函数不会,例如下面的代码:
// 使用exit函数
#include <stdio.h>
#include <stdlib.h>
void fun() {
printf("函数退出");
exit(1);
}
int main(){
fun();
printf("进程正常退出\n");
return 0;
}
输出结果:
函数退出
// 使用_exit函数
#include <stdio.h>
#include <unistd.h>
void fun() {
printf("函数退出");
_exit(1);
}
int main(){
fun();
printf("进程正常退出\n");
return 0;
}
无输出结果
因为exit
函数是基于系统调用接口的函数,属于语言层面的函数,而_exit
函数是系统的调用接口函数,前面提到\n
可以刷新缓冲区,所以可以推断出,此处所谓的缓冲区实际上是语言级别的缓冲区。_exit
函数在操作系统内部运行,并不知道有缓冲区的存在,自然也就无法刷新该缓冲区,对应的exit
函数是语言层面的函数,对应的缓冲区是语言层的缓冲区,所以可以做到缓冲区刷新
可以认为exit
函数底层调用了_exit
函数和fflush
函数
在进程结束部分提到过,可以使用echo $?
获取最近一次结束的进程的退出码,所以此处也可以获取到通过return
、exit
或者_exit
结束的进程返回值,查看上面的代码进程的退出码如下:
进程回收
在前面创建子进程中,并没有提到如何回收子进程,一旦子进程在父进程结束之前结束,子进程便会一直处于僵尸状态等待父进程回收,如果一直不回收便一直处于僵尸状态导致内存泄漏。在Linux,如果需要父进程对子进程进行回收,避免持续僵尸可以使用下面的两种方式:
- 使用
wait()
函数,该函数会等到任意子进程执行结束之后回收该子进程,再执行该函数后面的代码,参数传递一个用于接收子进程状态的状态变量地址,函数返回回收的子进程的PID
- 使用
waitpid()
函数,同wait
函数,但是该函数可以指定接收的进程,第一个参数传递需要回收的子进程的PID
,第二个参数传递用于回收的子进程状态的状态变量地址,第三个参数传递指定函数的行为,函数返回回收的子进程的PID
如果waitpid
函数第一个参数传递一个大于0的数(即子进程PID
),则回收指定的进程,如果传递-1,则作用等同于wait
函数
基本使用如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
exit(0);
} else {
sleep(10);
pid_t ret = wait(NULL);
while(1) {
printf("父进程pid: %d\n", getpid());
sleep(1);
}
}
return 0;
}
为了更好得监视执行时子进程的变化,可以使用下面的Shell脚本每隔一秒进行一次监视:
while :; do sleep 1; ps ajx | head -1 && ps ajx | grep process; done
编译运行上面的代码后,可以看到下面的效果:
使用waitpid
函数可以修改为如下代码,效果同上面的代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
exit(0);
} else {
sleep(10);
pid_t ret = waitpid(id, NULL, 0);
while(1) {
printf("父进程pid: %d\n", getpid());
sleep(1);
}
}
return 0;
}
如果需要回收子进程的状态,可以通过状态变量接收,因为waitpid
函数的第二个参数相当于一个输出型参数:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
int status = 0;
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
exit(0);
} else {
sleep(10);
pid_t ret = waitpid(id, &status, 0);
while(1) {
printf("父进程pid: %d, status = %d\n", getpid(), status);
sleep(1);
}
}
return 0;
}
如果上面子进程的退出码为非0,那么status
中的值可能会有所不同,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
int status = 0;
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
exit(1);
} else {
sleep(10);
pid_t ret = waitpid(id, &status, 0);
while(1) {
printf("父进程pid: %d, status = %d\n", getpid(), status);
sleep(1);
}
}
return 0;
}
运行上面的代码效果如下:
在上面的代码中,子进程的退出码是1,但是status
中存储的却是256,因为在Linux中,一个进程退出一般不仅仅包含退出码,还有其他内容,这些内容统称为为进程退出信息。
进程退出信息
下面主要讨论两种进程退出信息:
- 进程退出码
- 进程退出信号
在Linux中,存在一个整型值status
,该值存储了进程的相关退出信息,一般情况下,一个整型值占用4个字节,所以在status
中,低两个字节(比特位从9到15)存储的就是进程退出码(占用8-15)和进程退出信号(占用0-7),示意图如下:
第8位比特位存储的core dump标志,具体见后续讲解
所以在上面的代码中,进程退出码设置为1,实际上是在第9位比特位设置为1,即0000001
,但是因为进程是正常退出的,所以进程信号部分为全0,core dump此时也为0,就出现了000000100000000
,所以此时获取到的status
就是256
如果想要直接获取进程退出码可以使用下面的思路:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
int status = 0;
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
exit(1);
} else {
sleep(10);
pid_t ret = waitpid(id, &status, 0);
while(1) {
printf("父进程pid: %d, status = %d\n", getpid(), (status>>8)&255);
sleep(1);
}
}
return 0;
}
上面的代码中,使用了(status>>8)&255
取出进程退出码,注意尽管已经将status
右移 8 位把高 8 位移到低 8 位的位置,但是为了保证其他位置为0,需要与255进行&
运算
在Linux中,进程退出码一般即为0或者errno
中的值,也可以是自定义的整数值,而进程退出信号中0表示正常退出,其他对应着不同的进程退出信号,如下图所示:
例如信号8和信号11:
// 信号8
#include <stdio.h>
int main()
{
int a = 1 / 0;
return 0;
}
运行后:
Floating point exception
// 信号11
#include <stdio.h>
int main()
{
int* a = NULL;
*a = 1;
return 0;
}
运行后:
Segmentation fault
这些退出信号默认由操作系统检测并向进程发出,所以当用户看到进程退出信号时,其实操作系统在这之前已经终止了进程,这种进程退出也被称为进程异常退出。至此,在Linux中,进程退出一共有三种情况:
- 进程代码执行完成,进程退出码为0,进程退出信号为0,正常退出
- 进程代码执行完成,进程退出码为非0,进程退出信号为0,正常退出但是程序出现逻辑问题导致指定任务未完成
- 进程代码未执行完成,进程退出信号为非0,进程异常退出
对于子进程出现进程退出信号非0,也可以通过下面的方式获取对应的进程退出信号:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
int status = 0;
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
int a = 1 / 0;
exit(1);
} else {
sleep(10);
pid_t ret = waitpid(id, &status, 0);
while(1) {
printf("父进程pid: %d, status = %d\n", getpid(), status&255);
sleep(1);
}
}
return 0;
}
除了操作系统可以向进程发送进程退出信号,用户也可以通过kill
指令向进程发送对应的退出信号,使用方式与使用kill
结束进程一致
上面获取进程退出信息的方式稍微有点繁琐,在Linux中,有对应的宏可以帮助获取到指定的进程退出信息:
例如下面的代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(){
printf("父进程pid: %d\n", getpid());
pid_t id = fork();
int status = 0;
if(id == 0) {
int cnt = 5 ;
while(cnt--) {
printf("子进程pid: %d\n", getpid());
sleep(1);
}
// int a = 1 / 0;
exit(1);
} else {
// sleep(10);
pid_t ret = waitpid(id, &status, 0);
if (WIFEXITED(status)) // 进程正常退出时
{
printf("父进程pid: %d, status = %d\n", getpid(), WEXITSTATUS(status)); // 获取进程的退出码
} else if(WIFSIGNALED(status)) {
printf("父进程pid: %d, status = %d\n", getpid(), WTERMSIG(status)); // 否则获取进程的退出信号
}
}
return 0;
}
在Linux源码中,PCB中存在着两个变量分别存储着进程退出码和进程退出信号:
struct task_struct {
// ...
int exit_code, exit_signal;
// ...
};
子进程使用案例
前面的子进程都只是完成一些简单的打印工作,现在可以通过子进程完成一些其他工作,实现子进程执行其他任务的效果:
目标:父进程向容器中添加数据,而子进程将数据保存到指定的文件中
示例代码如下:
#include <iostream>
#include <vector>
#include <fstream>
#include <sstream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
// 定义全局变量
vector<int> data;
int _save() {
ofstream ofs("content.txt", ios::out | ios::trunc);
stringstream ss;
string value;
// 将内容转换成字符串
for(auto num : data) {
ss << num;
value += ss.str() + " ";
ss.str("");
}
// 写入文件
ofs << value;
return 0;
}
void save() {
// 创建子进程
pid_t id = fork();
if (id == 0) {
// 子进程执行保存
int code = _save();
//子进程退出
exit(code);
}
// 父进程回收子进程
waitpid(id, nullptr, 0);
}
int main() {
int count = 0;
// 持续向容器中存入数据
while(true) {
data.push_back(count+1);
// 如果已经添加了10次,则子进程执行保存
if (count++ % 10 == 0) {
save();
}
cout << count << endl;
sleep(1);
}
return 0;
}
阻塞与非阻塞
在上面的子进程案例中,每一次子进程在执行任务时,父进程都需要等待子进程执行完毕后回收子进程才可以继续执行父进程的代码,这个过程中父进程处于阻塞等待状态
但是如果希望父进程在子进程还在执行的时候一边等待子进程结束,一边执行自己的任务就需要让父进程处于非阻塞等待状态
需要使用非阻塞状态就需要设置waitpid
第三个参数为WNOHANG
,此时waitpid
的返回值从原来的两种(大于0和等于-1)变为三种(大于0、等于0和等于-1),此时大于0表示已经回收到对应的子进程的PID
,等于0说明子进程还在执行需要继续等待,等于-1说明回收失败
因为非阻塞等待无法确定子进程何时等待,所以需要手动循环持续等待
使用非阻塞等待修改前面的案例代码:
#include <iostream>
#include <vector>
#include <fstream>
#include <sstream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
vector<int> data;
int _save() {
ofstream ofs("content.txt", ios::out | ios::trunc);
stringstream ss;
string value;
// 将内容转换成字符串
for(auto num : data) {
ss << num;
value += ss.str() + " ";
ss.str("");
}
// 写入文件
ofs << value;
return 0;
}
void save() {
// 创建子进程
pid_t id = fork();
if (id == 0) {
// 子进程执行保存
int code = _save();
//子进程退出
exit(code);
}
// 使用非阻塞等待
while(true) {
pid_t ret = waitpid(id, nullptr, WNOHANG);
if(ret > 0) {
cout << "子进程回收成功" << endl;
return;
} else if (ret == 0) {
cout << "子进程还在运行" << endl;
} else {
cout << "回收失败" << endl;
return;
}
sleep(1);
}
}
int main() {
int count = 0;
// 持续向容器中存入数据
while(true) {
data.push_back(count+1);
// 如果已经添加了10次,则子进程执行保存
if (count++ % 10 == 0) {
save();
}
cout << count << endl;
sleep(1);
}
return 0;
}
进程程序替换
介绍
前面子进程在完成任务都是基于分支语句中属于子进程的代码,而进程程序替换的作用就是为了让子进程执行其他程序的代码和数据
在Linux中,有七个可以调用其他程序的函数,如下:
// C语言封装的接口
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 execvpe(const char *file, char *const argv[], char *const envp[]);
// 系统接口
int execve(const char *filename, char *const argv[], char *const envp[]);
参数解释:
const char *path
:执行的目标程序的路径const char *arg, ...
:如何执行目标程序,一般传入的内容与命令行执行对应程序时输入的内容相同,例如ls -a -l
,参数写为"ls", "-a", "-l", "nullptr/NULL"
(需要传入nullptr
或者NULL
是因为命令行参数表以nullptr
/NULL
结尾)char *const argv[]
:如何执行目标程序,与const char *arg, ...
不同的是,char *const argv[]
接收的是一个数组,所以需要额外创建一个数组,而数组中的内容即为命令行参数,以nullptr
或者NULL
作为最后一个字符串const char *file
或者const char *filename
:执行的目标程序名称(不需要路径)char * const envp[]
:需要传递的环境变量,与char *const argv[]
使用基本一致,一般有新的环境变量时才会使用本参数
函数返回值解释:
上面所有的函数当正常调用程序时,返回-1,否则没有返回值
下面以三种函数为例:
共用的Makefile
文件如下:
TARGET=process
SRC=process.c
$(TARGET):$(SRC)
gcc $^ -o $@
.PHONT:clean
clean:
rm -f $(TARGET)
int execl(const char *path, const char *arg, ...)
#include <stdio.h>
#include <unistd.h>
int main()
{
execl("/usr/bin/ls", "ls", "--color", "-a", "-l", NULL);
return 0;
}
运行结果:
int execvp(const char *file, char *const argv[])
#include <stdio.h>
#include <unistd.h>
int main()
{
char* const argv[] = {
(char*) "ls",
(char*) "--color",
(char*) "-a",
(char*) "-l",
NULL
};
execvp("ls", argv);
return 0;
}
运行结果:
上面的代码中,使用(char*)
是为了将字符串的类型(const char*)
强制转换为char*
,从而类型匹配以消除编译器警告
也可以使用下面的更安全的方式:
#include <stdio.h>
#include <unistd.h>
int main()
{
char cmd[] = "ls";
char opt1[] = "--color";
char opt2[] = "-a";
char opt3[] = "-l";
char* const argv[] = { cmd, opt1, opt2, opt3, NULL };
execvp("ls", argv);
return 0;
}
int execvpe(const char *file, char *const argv[], char *const envp[])
#include <stdio.h>
#include <unistd.h>
int main()
{
char cmd[] = "ls";
char opt1[] = "--color";
char opt2[] = "-a";
char opt3[] = "-l";
char* const argv[] = { cmd, opt1, opt2, opt3, NULL };
char newEnv[] = "newEnviron=10000";
char* const envp[] = {newEnv, NULL};
execvpe("ls", argv, envp);
return 0;
}
运行结果:
如果需要添加环境变量到当前系统的环境变量并以新的系统环境变量执行,可以使用下面的代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
extern char** environ;
int main()
{
char cmd[] = "ls";
char opt1[] = "--color";
char opt2[] = "-a";
char opt3[] = "-l";
char* const argv[] = { cmd, opt1, opt2, opt3, NULL };
char newEnv[] = "newEnviron=10000";
putenv(newEnv);
execvpe("ls", argv, environ);
return 0;
}
此处char**
和char* []
是兼容的类型,都表示一个指向字符串指针的指针数组,所以可以将char** environ
传递给char* const envp[]
参数,因为两者在内存布局上是相同的
原理简单解释
进程程序替换的本质是将进程对应的虚拟地址空间中的代码和数据替换为指定的程序的代码和数据,从而可以执行其他程序,示意图如下:
因为发生了覆盖,所以一旦执行了exec
家族的函数,则该函数后面的代码都会被覆盖从而无法再执行,除非exec
家族函数执行失败返回-1
但是需要注意的是,因为是直接覆盖,所以这个过程中exec
函数并不会创建一个新进程,运行下面的代码可以证明(只要PID相同,就说明不是新进程执行的exec
函数中的程序):
// 调用程序
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("当前进程pid为%d\n", getpid());
execl("./other", "other", NULL);
return 0;
}
// 被exec调用程序
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("当前进程pid为%d\n", getpid());
return 0;
}
输出结果:
当前进程pid为4408
当前进程pid为4408
需要注意,上面的execl
第二个参数不用使用./other
,此处只需要使用other
即可,因为已经指定了路径
前面提到大部分运行在bash
上的进程都是bash
子进程,所以bash
执行子进程的本质就是调用了exec
家族函数,而之所以没有覆盖bash
原有的进程,就是因为bash
开辟了一个子进程再在子进程中调用了exec
家族函数。所以可以模仿bash
的方式使用多进程,让子进程执行exec
家族函数:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0) {
execl("/usr/bin/pwd", "pwd", NULL);
printf("正常执行exec不会执行本语句\n");
}
waitpid(id, NULL, 0);
printf("父进程代码未被覆盖\n");
return 0;
}
输出结果:
/home/epsda/test_alter_process
父进程代码未被覆盖
有了进程程序替换,进程的独立性更加明显,原因如下:
- 完全替换进程映像:当一个进程调用
exec
系列函数时,它的当前进程映像被新程序替换,除了进程PID
和一些资源之外,原进程的代码、数据和堆栈都被新程序的内容替换。这意味着新程序完全独立于调用exec
之前的程序 - 资源独立性:尽管新程序继承了原进程的文件描述符和环境变量,但它使用的是自己的代码和数据,这使得程序的执行和行为是独立的,不会受到原程序的影响
- 内存空间独立:
exec
调用后,新程序运行在独立的内存空间中,之前的内存内容被清除,新程序的运行不受之前程序的内存内容的影响 - 进程控制:调用
exec
后,原进程的执行流被终止,新程序开始执行。控制权完全交给新程序,使得新程序在执行和资源管理上是独立的
手撕一个简单的Shell
待更新