
在本文中,我想谈一谈Linux OS系列中进程的生命周期。 在理论和示例中,我将探讨过程是如何诞生和死亡的,并简要讨论系统调用和信号的机制。
本文更适合系统编程的初学者以及只想了解更多有关Linux中进程如何工作的人。
以下内容适用于4.15.0内核的Debian Linux。
目录内容
- 引言
- 工艺属性
- 工艺生命周期
- 工艺诞生
- 就绪状态
- 状态为“运行中”
- 在另一个程序中轮回
- 待处理状态
- 停止状态
- 流程完成
- 僵尸的状态
- 遗忘
- 致谢
引言
系统软件通过特殊功能-系统调用与系统核心进行交互。 在极少数情况下,存在以虚拟文件系统形式形成的备用API,例如procfs或sysfs。
工艺属性
内核中的过程简单地表示为具有许多字段的结构(可在
此处阅读结构的定义)。
但是,由于本文专门讨论系统编程,而不是内核开发,因此我们有点抽象,只关注我们的重要过程域:
- 进程ID(PID)
- 打开文件描述符(fd)
- 信号处理器
- 当前工作目录(cwd)
- 环境变量(环境)
- 返回码
工艺生命周期

工艺诞生
系统中只有一个进程以特殊方式生成
init
它是由内核直接生成的。 通过使用
fork(2)
系统调用复制当前进程,将显示所有其他进程。 执行
fork(2)
之后,我们得到两个几乎相同的过程,但以下几点除外:
fork(2)
将孩子的PID返回给父对象,0返回给孩子;- 子级将PPID(父进程ID)更改为父级的PID。
执行
fork(2)
之后,子进程的所有资源都是父资源的副本。 复制具有所有分配的内存页面的进程的开销很大,因此Linux内核使用写时复制技术。
父级内存中的所有页面都标记为只读,并且对父级和子级都可用。 一旦其中一个进程更改了特定页面上的数据,该页面就不会更改,但是副本已被复制和更改。 在这种情况下,原件将从此过程中“脱离”。 一旦只读原件仍然“绑定”到单个进程,页面将重新分配为读写状态。
一个简单的无用fork程序示例(2) #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; default: // Parent printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; } return 0; }
$ gcc test.c && ./a.out my pid = 15594, returned pid = 15595 my pid = 15595, returned pid = 0
就绪状态
执行后,
fork(2)
立即进入就绪状态。
实际上,进程将排队并等待内核中的调度程序,以使进程在处理器上运行。
状态为“运行中”
调度程序将流程执行后,“运行”状态即开始。 该过程可以运行建议的整个时间段(量子),也可以使用
sched_yield
系统导出将其
sched_yield
其他过程。
在另一个程序中轮回
一些程序实现的逻辑中,父进程会创建一个子进程来解决问题。 在这种情况下,孩子解决了一个特定的问题,而父母仅将任务委托给了他的孩子。 例如,具有传入连接的Web服务器会创建一个子级,并将连接处理传递给该子级。
但是,如果需要运行其他程序,则必须诉诸
execve(2)
系统调用:
int execve(const char *filename, char *const argv[], char *const envp[]);
或库调用
execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3)
:
int execl(const char *path, const char *arg, ... ); int execlp(const char *file, const char *arg, ... ); int execle(const char *path, const char *arg, ... ); 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[]);
以上所有调用均执行该程序,第一个参数中指示了该程序的路径。 如果成功,控制权将转移到已加载的程序,而不返回到原始程序。 在这种情况下,加载的程序具有进程结构的所有字段,但标记为
O_CLOEXEC
,它们将关闭。
如何不对所有这些挑战感到困惑并选择正确的挑战? 理解命名逻辑就足够了:
- 所有通话
exec
开头 - 第五个字母定义传递的参数类型:
- l代表list ,所有参数均以
arg1, arg2, ..., NULL
形式传递 - v代表vector ,所有参数都以一个以null结尾的数组传递;
- 代表路径的字母p可能紧随其后。 如果
file
参数以“ /”以外的字符开头,则在PATH环境变量中列出的目录中查找指定的file
- 最后一个字母可能是e ,表示环境 。 在这样的调用中,最后一个参数是一个以空值结尾的字符串,该数组以空值结尾的字符串,格式为
key=value
将被传递到新程序的环境变量。
通过execve调用/ bin / cat --help的示例 #define _GNU_SOURCE #include <unistd.h> int main() { char* args[] = { "/bin/cat", "--help", NULL }; execve("/bin/cat", args, environ); // Unreachable return 1; }
$ gcc test.c && ./a.out Usage: /bin/cat [OPTION]... [FILE]... Concatenate FILE(s) to standard output. * *
exec*
调用系列允许您以具有执行权的方式运行脚本,并以一系列shebang开头(#!)。
使用execle使用欺骗性PATH运行脚本的示例 #define _GNU_SOURCE #include <unistd.h> int main() { char* e[] = {"PATH=/habr:/rulez", NULL}; execle("/tmp/test.sh", "test.sh", NULL, e); // Unreachable return 1; }
$ cat test.sh
有一种约定意味着argv [0]与exec *系列中函数的空参数匹配。 但是,这可能被违反。
猫使用execlp成为狗的示例 #define _GNU_SOURCE #include <unistd.h> int main() { execlp("cat", "dog", "--help", NULL); // Unreachable return 1; }
$ gcc test.c && ./a.out Usage: dog [OPTION]... [FILE]... * *
一个好奇的读者可能会注意到
int main(int argc, char* argv[])
函数的签名中有一个数字
int main(int argc, char* argv[])
-参数的数量,但是没有任何类型传递给
exec*
函数系列。 怎么了 因为在程序启动时,控制权不会立即转移到main。 在此之前,将执行glibc定义的某些操作,包括对argc进行计数。
待处理状态
某些系统调用可能需要很长时间,例如I / O。 在这种情况下,过程将进入“待处理”状态。 系统调用完成后,内核会将进程置于“就绪”状态。
在Linux上,还存在“等待”状态,在该状态下,进程不响应中断信号。 在这种状态下,该过程变得“不可破坏”,并且所有进入的信号一直排队,直到该过程离开该状态。
内核本身选择将进程转移到哪个状态。 通常,请求I / O的进程处于“正在等待(无中断)”状态。 在Internet速度不是很快的情况下使用远程磁盘(NFS)时,这一点尤其明显。
停止状态
您可以随时通过发送SIGSTOP信号来暂停该进程。 该过程将进入“已停止”状态,并保持在那里,直到它收到继续工作的信号(SIGCONT)或死亡(SIGKILL)。 其余信号将排队。
流程完成
没有程序可以自行关闭。 他们只能通过
_exit
系统调用来询问系统,或者由于错误而被系统终止。 即使从
main()
返回一个数字,
_exit
仍然会隐式调用。
尽管系统调用的参数为int,但仅将数字的低字节用作返回码。
僵尸的状态
进程完成后(无论正确与否),内核立即写出有关进程如何结束的信息,并将其置于“僵尸”状态。 换句话说,僵尸是一个完整的过程,但是它的内存仍然存储在内核中。
此外,这是该进程可以安全地忽略SIGKILL信号的第二种状态,因为它不会再次死亡。
遗忘
返回代码和完成过程的原因仍存储在内核中,必须从那里获取。 为此,您可以使用适当的系统调用:
pid_t wait(int *wstatus); pid_t waitpid(pid_t pid, int *wstatus, int options);
有关进程终止的所有信息都适合int数据类型。
waitpid(2)
手册页中描述的宏用于获取返回码和程序终止的原因。
正确完成并收到返回码的示例 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child return 13; default: { // Parent int status; waitpid(pid, &status, 0); printf("exit normally? %s\n", (WIFEXITED(status) ? "true" : "false")); printf("child exitcode = %i\n", WEXITSTATUS(status)); break; } } return 0; }
$ gcc test.c && ./a.out exit normally? true child exitcode = 13
完成示例不正确将argv [0]传递为NULL会导致崩溃。
#include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child execl("/bin/cat", NULL); return 13; default: { // Parent int status; waitpid(pid, &status, 0); if(WIFEXITED(status)) { printf("Exit normally with code %i\n", WEXITSTATUS(status)); } if(WIFSIGNALED(status)) { printf("killed with signal %i\n", WTERMSIG(status)); } break; } } return 0; }
$ gcc test.c && ./a.out killed with signal 6
有时候父母的结局要早于孩子。 在这种情况下,
init
将成为孩子的父母,并且当时间到时,他将使用
wait(2)
调用。
父母掌握了有关孩子死亡的信息后,内核会删除有关该孩子的所有信息,以便尽快进行其他处理。
致谢
感谢Sasha“ Al”的编辑和设计协助;
感谢Sasha“ Reisse”为难题的清晰答案。
他们忍受了攻击我的灵感和攻击我的问题。