Linux 进程控制
fork
在 C 语言中,fork
函数是一个创建新进程的系统调用。它通过复制当前进程创建一个新的子进程,使得父进程和子进程在不同的执行路径上同时运行。
fork
函数的原型如下:
#include <unistd.h>
pid_t fork(void);
调用 fork
函数会返回两次。在父进程中,fork
函数返回新创建的子进程的进程 ID(PID),而在子进程中,fork
函数返回 0。
调用 fork
,实际上做了:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
fork
返回,开始调度器调度
当一个进程调用 fork
之后,就有两个二进制代码相同的进程,而且它们都运行到 相同 的地方。
例如:
int main(void)
{
printf("Before fork, pid = %d\n", getpid());
pid_t forkPid = fork();
if(forkPid < 0)
{
perror("fork error!");
return 1;
}
printf("After fork, pid = %d, forkPid = %d\n", getpid(), forkPid);
}
输出:
Before fork, pid = 2157
After fork, pid = 2157, forkPid = 2158
After fork, pid = 2158, forkPid = 0
可以看到,Before 只打印了一次,而 After 打印了两次,这就说明了 fork
后,父子两个执行流分别执行。
注意: fork
后,谁先执行完全 由调度器决定 。
写时拷贝原则
父子进程不仅代码共享,在父子进程均没有写入新数据时,数据也是共享的
为什么不在创建子进程的时候,就为数据开辟新的空间?
- 子进程不一定要使用(修改)父进程的所有数据
- 实现按需分配
fork 使用场景
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从
fork
返回后,调用exec
函数(后面会讲)。
fork 失败场景
- 系统进程太多
- 用户进程数超过限制
进程终止
一般来说,进程终止分为两种情况:
- 正常退出,如:从 main 返回,
_exit()
系统调用,exit()
系统调用 - 异常退出,如
control + c
,kill -p <PID>
等
_exit 系统调用
函数声明:
#include <unistd.h>
void _exit(int status);
参数 status
定义了进程的终止状态,父进程通过 wait
来获取该值
exit 系统调用
函数声明:
#include <unistd.h>
void exit(int status);
参数 status
定义了进程的终止状态,父进程通过 wait
来获取该值
_exit 与 exit 的区别
_exit
与 exit
的最大区别就是:exit
会 在进程退出前执行:
- 用户通过 atexit 或 on_exit 定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用 _exit
例如:
int main(void)
{
printf("hello");
exit(0);
}
输出:
Sky_Lee@SkyLeeMacBook-Pro test % ./cfile
hello
可以看到,字符串 “hello” 是被输出了的,执行 _exit
呢?
int main(void)
{
printf("hello");
_exit(0);
}
输出:
Sky_Lee@SkyLeeMacBook-Pro test % ./cfile
可以看到,“hello” 并没有输出,这是因为 _exit
是 不会 刷新输出缓冲区的,我们可以手动刷新缓冲区来解决这个问题
int main(void)
{
printf("hello");
fflush(NULL);
_exit(0);
}
输出:
Sky_Lee@SkyLeeMacBook-Pro test % ./cfile
hello
return
return status
与 exit(status)
是等价的
return status
与 exit(status)
在 main 函数中是等价的
注意: 在其它函数中使用 return 不会使进程退出,而 exit 会(虽然这句话看起来很简单,但不注意的话…
进程等待
进程等待(Process waiting)是一种同步机制,用于父进程等待子进程的完成或状态改变。当父进程创建子进程后,通常需要等待子进程的完成或获取子进程的状态信息。进程等待可以通过系统调用(如
wait
或waitpid
)来实现。
为什么要有进程等待
进程等待的主要目的是:
同步:父进程可以等待子进程完成某个任务后再继续执行,以确保协调和顺序执行。这对于需要在父进程中依赖子进程结果的情况很有用。
回收子进程资源:当子进程终止时,它会进入一种称为"僵尸进程"的状态,此时它占用系统资源但不再执行任何任务。父进程通过等待子进程,可以及时回收子进程的资源,避免产生大量僵尸进程导致系统资源耗尽。
获取子进程状态:父进程可以通过进程等待来获取子进程的退出状态、终止原因、信号信息等。这样父进程就可以根据子进程的状态做出相应的处理,例如记录日志、重新启动子进程或采取其他措施。
进程等待的方式
有两种进程等待的方式:wait
与 waitpid
系统调用
wait 系统调用
在操作系统中,wait
是一个系统调用,用于父进程等待其子进程的结束并回收其资源。
wait
的原型如下:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
wait
函数接受一个指向整型的指针 status
作为参数,用于存储子进程的退出状态。它会挂起当前进程的执行,直到任一子进程结束为止。当子进程结束后,wait
函数会返回子进程的进程 ID(PID),并将子进程的退出状态存储在 status
指针指向的位置上。
如果我们不关心子进程的退出状态,可以向 wait
传入一个空指针(NULL)
waitpid 系统调用
waitpid
是一个用于等待子进程状态改变的系统调用,它允许父进程阻塞自己,直到指定的子进程发生状态改变,或者指定的子进程终止。
waitpid
的函数原型如下:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
参数说明:
pid
:要等待的子进程的进程ID,可以有以下取值:> 0
:等待指定进程ID的子进程。-1
:等待任意子进程,类似于wait
。0
:等待与当前进程的进程组ID相同的任意子进程。< -1
:等待进程组ID等于pid
绝对值的任意子进程。
status
:用于保存子进程的终止状态信息的指针。如果不关心子进程的终止状态,可以传入NULL
。options
:附加选项,用于控制waitpid
的行为。常见的选项有:WCONTINUED
:等待一个已经停止的子进程继续运行。WNOHANG
:如果没有子进程改变状态,则 立即返回 ,而不进入阻塞状态。WUNTRACED
:等待一个已经停止的子进程。
返回值
waitpid
函数的返回值表示等待的子进程ID:
- 如果指定的子进程终止,则返回该子进程的进程ID。
- 如果指定的子进程没有终止,且使用了
WNOHANG
选项,则返回 0。 - 如果发生错误,则返回 -1,并设置
errno
来指示错误的原因。
二者区别
wait
和 waitpid
的主要区别在于参数形式和阻塞行为。wait
等待任意子进程的状态改变并进入阻塞状态,而 waitpid
则允许指定要等待的具体子进程,并可以通过选项来控制阻塞行为。
-
参数形式:
wait
:没有指定具体的子进程ID,它会等待任意子进程的状态改变。waitpid
:需要指定要等待的具体子进程ID。
-
阻塞行为:
wait
:如果当前没有已终止的子进程,调用wait
会使父进程进入阻塞状态,直到有子进程终止并返回。waitpid
:可以通过传递WNOHANG
选项来控制其阻塞行为。如果指定了WNOHANG
,即使没有已终止的子进程,waitpid
也会立即返回。
-
等待条件:
wait
:等待任意子进程的状态改变,包括终止、停止或继续运行。waitpid
:可以通过传递不同的options
参数来控制等待的条件,如等待终止子进程、等待停止子进程或等待继续运行的子进程。
获取子进程状态
在 wait
与 waitpid
系统调用中,均包含了一个整形参数 status
,然而,这个参数并不能简单的看成一个整形变量
这里只关心 status
的低 16 位
示例
int main(void)
{
pid_t pid = fork();
if (pid < 0)
{
throw std::runtime_error("fork error!\n");
return 1;
}
if (pid == 0)
{
printf("child[%d]\n", getpid());
sleep(30); // 留点时间杀掉子进程
exit(10); // 没被杀,返回 10
}
else
{
int status;
pid_t childPID = wait(&status);
// childPID > 0 => wait 没有发生错误
// (status & 0x7f) => 如果子进程正常退出,status 低 7 位为 0
if (childPID > 0 && (status & 0x7f) == 0)
{
std::cout << "child exit successfully with exit code: "
<< ((status >> 8) & 0xff) << std::endl; // status 高 8 位是退出状态
}
else
{
std::cout << "child exit failed with SIG code: "
<< (status & 0x7f) << std::endl; // status 低 7 位为 SIG 码(终止信号)
}
}
}
输出(正常退出):
child[3080]
child exit successfully with exit code: 10
输出(另一个终端执行 kill -9 <PID>
指令):
child[2940]
child exit failed with SIG code: 9
理解了 status
的细节后,我们可以使用宏定义来简化代码:
(status & 0x7f) == 0
等价于WIFEXITED(status)
((status >> 8) & 0xff)
等价于WEXITSTATUS(status)
(status & 0x7f)
等价于_WSTATUS(status)
下面再具体演示一下两种等待方式的实现:
阻塞式等待
int main(void)
{
pid_t pid = fork();
if (pid < 0)
{
throw std::runtime_error("fork error!\n");
return 1;
}
if (pid == 0)
{
printf("child[%d]\n", getpid());
sleep(5);
exit(10);
}
else
{
int status;
pid_t ret = waitpid(pid, &status, 0); // opinion = 0,阻塞式等待,等待 5 s
if (ret == pid && WIFEXITED(status))
{
std::cout << "child exit successfully with exit code: "
<< WEXITSTATUS(status) << std::endl;;
}
else
{
std::cout << "child exit failed with SIG code: "
<< _WSTATUS(status) << std::endl;
}
}
}
非阻塞式等待
int main(void)
{
pid_t pid = fork();
if (pid < 0)
{
throw std::runtime_error("fork error!\n");
return 1;
}
if (pid == 0)
{
printf("child[%d]\n", getpid());
sleep(10);
exit(10);
}
else
{
int status;
pid_t ret = 0;
while (1)
{
ret = waitpid(pid, &status, WNOHANG);
if(ret == 0)
{
std::cout << "child is running..." << std::endl;
sleep(1);
}
else break; // 子进程退出(可能正常,也可能不正常)
}
if (ret == pid && WIFEXITED(status))
{
std::cout << "child exit successfully with exit code: "
<< WEXITSTATUS(status) << std::endl;
}
else
{
std::cout << "child exit failed with SIG code: "
<< _WSTATUS(status) << std::endl;
}
}
}
进程程序替换
用 fork
创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec
函数以执行另一个程序。
exec 函数
exec
函数是一个系列的系统调用函数,用于在 Linux 中执行其他程序。它用于将当前进程替换为新的可执行程序,从而使新程序取代原来的程序继续执行。
调用 exec
函数 不会 创建新进程,只是 进行了程序的替换,原来的 PCB,mm_struct 并没有改变
exec
函数的常见形式有以下几种:
#include <unistd.h>
int execl(const char *path, const char *arg0, ...);
int execlp(const char *file, const char *arg0, ...);
int execle(const char *path, const char *arg0, ..., 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[]);
感觉很难记?
观察上述 6 种形式,可以发现:
- l(list),表示参数用列表
- v(vector),表示参数用一个数组
- p(path),表示会自动搜索环境变量
- e(env),表示自己维护环境变量
只要不自己维护环境变量,exec 就会使用当前的环境变量
理解这四个字母对应的含义,就好理解上面的六种形式了
此外,exec
如果调用成功,那就加载新的程序,原来程序 exec
后面的部分都 不执行
如果调用失败,返回 -1
示例
假设当前目录为 /Users/Sky_Lee/Documents/Linux/Test/
,并且有以下文件:
- cfile.c
- test.cpp
- makefile
其中,test.cpp
的内容如下:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
printf("Now in test[PID = %d]...\n", getpid());
if(getenv("PATH"))
std::cout << "OS PATH = " << getenv("PATH") << std::endl;
else std::cout << "OS PATH = (null)" << std::endl;
if(getenv("MYENV"))
std::cout << "User PATH = " << getenv("MYENV") << std::endl;
else std::cout << "User PATH = (null)" << std::endl;
std::cout << "About to exit test..." << std::endl;
sleep(1);
}
cfile.c
的内容如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
char *const args[] = {"./test", NULL};
char *const envp[] = {"PATH=/usr/local/bin", "MYENV=hhh", NULL};
const char *path = "/Users/Sky_Lee/Documents/Linux/Test/test";
printf("Now in cfile[PID = %d]...\n", getpid());
// exec ... 这里分别对应了六种示例代码
printf("About exit cfile...\n");
sleep(1);
}
并且,我们使用 export
导入了环境变量 MYENV="hello exec"
现在来依次看看这六种示例:
示例 1
execl(path, "./test", "./test", NULL);
运行,输出如下:
Now in cfile[PID = 4786]...
Now in test[PID = 4786]...
OS PATH = /usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/VMware Fusion.app/Contents/Public:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin
User PATH = hello exec
About to exit test...
可以验证,调用 exec
函数 不会 创建新进程,只是 进行了程序的替换,因为两个程序的 PID 是相同的
并且,OS PATH
和 User PATH
使用的是当前的环境变量
示例 2
execlp("./test", "./test", NULL);
输出与示例 1 一致,加上 p
只是让我们不用提供可执行程序的路径,会自动搜索环境变量,以此找到路径
示例 3
execle(path, "./test", "./test", NULL, envp);
与示例 1 不同的是:
OS PATH = /usr/local/bin
User PATH = hhh
因为加上了 e
,表明自己维护环境变量,exec
就不会使用当前的环境变量了
示例 4、5、6
execv(path, args);
execvp("./test", args);
execve(path, args, envp);
三个示例的输出与示例 1、2、3 对应,因为加上 v
,只是说明提供的是数组,不是列表
总结
exec 函数的本质
事实上,只有 execve
是真正的系统调用,其它五个函数最终也会调用 execve
这张图很好地说明了它们之间的关系:
简易 Shell
#include <iostream>
#include <bitset>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
class Shell
{
static constexpr int MAX_COMMAND_LENGTH = 1024;
static constexpr int DO_EXEC_ERROR = -1;
static constexpr int DO_EXEC_SUCCESS = 0; // 后面发现没啥用(想想为啥没用
static constexpr int PARSE_SUCCESS = 1;
static constexpr int PARSE_ERROR = 2;
public:
void Interface(void)
{
std::cout << "mysh % ";
std::cout.flush();
pid_t pid = fork();
if(pid == 0)
doExec();
else
{
int status;
wait(&status);
if(status == PARSE_ERROR)
std::cout << "mysh: parse error, your command might be to long!" << std::endl;
}
}
private:
int doExec(void)
{
char* args[MAX_COMMAND_LENGTH];
// 解析命令
auto parse = [&](void) -> int
{
std::string command;
std::getline(std::cin, command);
size_t cur = 0, size = command.size();
size_t curArgPos = 0;
while (true)
{
if(curArgPos == MAX_COMMAND_LENGTH - 1) // 还要留一个空间放 NULL
return PARSE_ERROR;
auto next = command.find(' ', cur);
auto temp = command.substr(cur, next - cur);
args[curArgPos] = new char[temp.size()];
strcpy(args[curArgPos++], temp.c_str());
// std::cout << "debug: " << args[curArgPos - 1] << std::endl;
if(next == std::string::npos)
break;
cur = next + 1;
}
args[curArgPos] = NULL;
return PARSE_SUCCESS;
};
if(parse() == PARSE_ERROR)
exit(PARSE_ERROR);
execvp(args[0], args);
// 注意是 exit,而不是 return,
// exit 会导致子进程退出
// 而 return 不会,因为 return 只是让函数 doExec 退出
// 这也说明了 exit 与 return 是有区别的
std::cout << "mysh: command not found: " << args[0] << std::endl; // 如果 execvp 正常执行,这一句不会被执行
exit(DO_EXEC_ERROR);
}
};
int main(void)
{
Shell shell;
while (1)
{
shell.Interface();
}
}
注意: 简易 Shell 不支持 cd
操作(想想为什么),此外,简易 Shell 在遇到有 '
的指令时,往往不能做出正确的动作