Linux上的学习过程


在本文中,我想谈一谈Linux OS系列中进程的生命周期。 在理论和示例中,我将探讨过程是如何诞生和死亡的,并简要讨论系统调用和信号的机制。

本文更适合系统编程的初学者以及只想了解更多有关Linux中进程如何工作的人。

以下内容适用于4.15.0内核的Debian Linux。

目录内容


  1. 引言
  2. 工艺属性
  3. 工艺生命周期
    1. 工艺诞生
    2. 就绪状态
    3. 状态为“运行中”
    4. 在另一个程序中轮回
    5. 待处理状态
    6. 停止状态
    7. 流程完成
    8. 僵尸的状态
    9. 遗忘
  4. 致谢

引言


系统软件通过特殊功能-系统调用与系统核心进行交互。 在极少数情况下,存在以虚拟文件系统形式形成的备用API,例如procfs或sysfs。

工艺属性


内核中的过程简单地表示为具有许多字段的结构(可在此处阅读结构的定义)。
但是,由于本文专门讨论系统编程,而不是内核开发,因此我们有点抽象,只关注我们的重要过程域:

  • 进程ID(PID)
  • 打开文件描述符(fd)
  • 信号处理器
  • 当前工作目录(cwd)
  • 环境变量(环境)
  • 返回码

工艺生命周期




工艺诞生


系统中只有一个进程以特殊方式生成init它是由内核直接生成的。 通过使用fork(2)系统调用复制当前进程,将显示所有其他进程。 执行fork(2)之后,我们得到两个几乎相同的过程,但以下几点除外:

  1. fork(2)将孩子的PID返回给父对象,0返回给孩子;
  2. 子级将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, ... /* (char *) NULL */); int execlp(const char *file, const char *arg, ... /* (char *) NULL */); int execle(const char *path, const char *arg, ... /*, (char *) NULL, 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[]); 

以上所有调用均执行该程序,第一个参数中指示了该程序的路径。 如果成功,控制权将转移到已加载的程序,而不返回到原始程序。 在这种情况下,加载的程序具有进程结构的所有字段,但标记为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 #!/bin/bash echo $0 echo $PATH $ gcc test.c && ./a.out /tmp/test.sh /habr:/rulez 


有一种约定意味着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); /*  waitpid(-1, wstatus, 0) */ 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”为难题的清晰答案。

他们忍受了攻击我的灵感和攻击我的问题。

Source: https://habr.com/ru/post/zh-CN423049/


All Articles