进程管理
在 linux系统中,进程是资源调度的最小单位,进程的管理关乎着你使用linux系统的体验。
1. 进程类型
Linux 系统里有几种不同类型的进程:用户进程(User processes)、守护进程(Deamon processes)和内核进程(Kernel processes)。
1.1. 用户进程
系统里大多数进程都是用户进程。用户进程由通常的用户账户启动,并在用户空间(user space)当中执行。在没有获得额外许可的情况下,通常用户进程无法对处理器进行特殊访问,或是访问启动进程的用户无权访问的文件。
1.2. 守护进程
守护进程通常是后台程序,它们往往由一些持续运行的服务来管理。守护进程可以用来监听请求,而后访问某些服务。举例来说,httpd这一守护进程监听访问网络页面的请求。守护进程也可以用来自行启动一些任务。例如,crond 这一守护进程会在预设的时间点启动计划任务。
尽管用于管理守护进程的服务通常是 root 用户启动的,但守护进程本身往往以非 root 用户启动。这种启动方式,符合「只赋予进程运行所必须的权限」之要求,因而能使系统免于一些攻击。举例来说,若是黑客骇入了 httpd 这一由 Apache 用户启动的守护进程,黑客仍然无法访问包括 root 用户在内的其他用户的文件,或是影响其他用户启动的守护进程。
守护进程通常由系统在启动时拉起,而后一直运行到系统关闭。当然,守护进程也可以按需启动和终止,以及让守护进程在特定的系统运行级别上执行,或是在运行过程中触发重新加载配置信息。
1.3. 内核进程
内核进程仅在内核空间(kernel space)当中执行。内核进程与守护进程有些相似,它们之间主要的不同在于:
- 内核进程对内核数据结构拥有完全的访问权限。
- 内核进程不如守护进程灵活:修改配置文件并触发重载即可修改守护进程的行为;但对于内核进程来说,修改行为则需要重新编译内核本身。
2. 进程状态
linux是一个多用户、多任务的系统,可以同时运行多个用户的多个程序,就必然会产生很多的进程,而每个进程会有不同的状态。同一时间同一CPU上只能运行一个进程,其他进程只能等待,因此我们可以宽泛地将进程状态分为:
- 在CPU上执行,此时进程正在运行
- 不在CPU上执行,此时进程不在运行
进一步来讲,未在运行的进程也可能处于不同的状态:
- TASK_RUNNING
- TASK_INTERRUPTIBLE
- TASK_UNINTERRUPTIBLE
- TASK_STOPPED/TASK_TRACED
- TASK_DEAD - EXIT_ZOMBIE
- TASK_DEAD - EXIT_DEAD
2.1. R(TASK_RUNNING,可执行状态)
只有在该状态的进程才可能在CPU上运行。而同一时刻可能有多个进程处于可执行状态,这些进程的task_struct结构(进程控制块)被放入对应CPU的可执行队列中(一个进程最多只能出现在一个CPU的可执行队列中)。进程调度器的任务就是从各个CPU的可执行队列中分别选择一个进程在该CPU上运行。很多操作系统教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在linux下统一为TASK_RUNNING状态。
2.2. S(TASK_INTERRUPTIBLE,可中断状态)
这个状态的进程因为等待某事件的发生(比如等待socket连接、等待信号量等)而被挂起,然后当这些事件发生或完成后,对应的等待队列中的一个或多个进程将被唤醒。一般情况下,系统中的大部分进程都处于这个状态。因为系统的CPU数量是有限的,而系统的进程数量是非常多的,所以大部分进程都处于睡眠状态。
2.3. D(TASK_UNINTERRUPTIBLE,不可中断状态)
与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是CPU不响应外部硬件的中断,而是指进程不响应异步信号。
绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9竟然杀不死一个正在睡眠的进程了!因此我们也很好理解ps命令看到的进程几乎不会出现TASK_UNINTERRUPTIBLE状态,而总是TASK_INTERRUPTIBLE状态。
TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。在进程对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。
linux系统中也存在容易捕捉的TASK_UNINTERRUPTIBLE状态。执行vfork系统调用后,父进程将进入TASK_UNINTERRUPTIBLE状态,直到子进程调用exit或exec。
通过下面的代码就能得到处于TASK_UNINTERRUPTIBLE状态的进程:
<span class="tag">#include</span>
void main() {
if (!vfork()) {
sleep(100);
}
}
2.4. T (TASK_STOPPED or TASK_TRACED),暂停状态或跟踪状态
向进程发送一个SIGSTOP信号,它就会因响应该信号而进入TASK_STOPPED状态(除非该进程本身处于TASK_UNINTERRUPTIBLE状态而不响应信号)。(SIGSTOP与SIGKILL信号一样,是非常强制的。不允许用户进程通过signal系列的系统调用重新设置对应的信号处理函数。) 向进程发送一个SIGCONT信号,可以让其从TASK_STOPPED状态恢复到TASK_RUNNING状态。
当进程正在被跟踪时,它处于TASK_TRACED这个特殊的状态。“正在被跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于TASK_TRACED状态。而在其他时候,被跟踪的进程还是处于前面提到的那些状态。
对于进程本身来说,TASK_STOPPED和TASK_TRACED状态很类似,都是表示进程暂停下来。而TASK_TRACED状态相当于在TASK_STOPPED之上多了一层保护,处于TASK_TRACED状态的进程不能响应SIGCONT信号而被唤醒。只能等到调试进程通过ptrace系统调用执行PTRACE_CONT、PTRACE_DETACH等操作(通过ptrace系统调用的参数指定操作),或调试进程退出,被调试的进程才能恢复TASK_RUNNING状态。
2.5. Z(TASK_DEAD - EXIT_ZOMBIE),退出状态,进程成为僵尸进程
进程在退出的过程中,处于TASK_DEAD状态。在这个退出过程中,进程占有的所有资源将被回收,除task_struct结构(以及少数资源)以外。于是进程就只剩下task_struct这么个空壳,故称为僵尸。
之所以保留task_struct,是因为task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。比如在shell中,$?变量就保存了最后一个退出的前台进程的退出码,而这个退出码往往被作为if语句的判断条件。
当然,内核也可以将这些信息保存在别的地方,而将task_struct结构释放掉,以节省一些空间。但是使用task_struct结构更为方便,因为在内核中已经建立了从pid到task_struct查找关系,还有进程间的父子关系。释放掉task_struct,则需要建立一些新的数据结构,以便让父进程找到它的子进程的退出信息。
父进程可以通过wait系列的系统调用(如wait4、waitid)来等待某个或某些子进程的退出,并获取它的退出信息。然后wait系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。子进程在退出的过程中,内核会给其父进程发送一个信号,通知父进程来“收尸”。这个信号默认是SIGCHLD,在通过clone系统调用创建子进程时,可以设置这个信号。
2.6. X (TASK_DEAD - EXIT_DEAD),退出状态,进程即将被销毁
进程在退出过程中也可能不会保留它的task_struct。比如这个进程是多线程程序中被detach过的进程(进程?线程?参见《linux线程浅析》)。或者父进程通过设置SIGCHLD信号的handler为SIG_IGN,显式的忽略了SIGCHLD信号。(这是posix的规定,尽管子进程的退出信号可以被设置为SIGCHLD以外的其他信号。)
此时,进程将被置于EXIT_DEAD退出状态,这意味着接下来的代码立即就会将该进程彻底释放。所以EXIT_DEAD状态是非常短暂的,几乎不可能通过ps命令捕捉到。
3. 进程树
每一个进程都是被别的进程启动的,或者说是复刻(Fork)的。当系统刚刚启动的时候,有一个非常特别的根进程 init ,它就是是直接被操作系统内核启动的。
这样一来,这个系统中运行的所有进程集合就构成了一颗以init进程为根节点的进程树,所有的其他进程都有一个父进程,也有可能有多个子进程。
比方说,每次我们在bash命令行提示符下执行一个命令的时候,bash 会复刻一个新的进程来执行这个命令,这时这个进程就变成了bash的子进程了。
相似地,当我们看见一个「登录」提示符时,这其实是login命令在运行着。如果我们成功的登录了,login命令会复刻一个新的进程来执行登录用户选择的shell。
我们可以使用ps auxf命令来查看树形结构的进程列表,像下面这样:
-+= 00001 root /sbin/launchd
|--= 00085 root /usr/libexec/logd
|--= 00086 root /usr/libexec/UserEventAgent (System)
|--= 00089 root /System/Library/PrivateFrameworks/Uninstall.framework/Resource
|--= 00090 root /System/Library/Frameworks/CoreServices.framework/Versions/A/F
|--= 00091 root /System/Library/PrivateFrameworks/MediaRemote.framework/Suppor
|-+= 00093 root /usr/sbin/systemstats --daemon
| \--- 00359 root /usr/sbin/systemstats --logger-helper /private/var/db/system
|--= 00095 root /usr/libexec/configd
|--= 00096 root endpointsecurityd
4. 进程归属权
每一个进程都归属于某个特定的用户,归属于该用户的进程有权限像该用户直接登录了一样执行所有该用户可以执行的所有命令。
比方说,假如有一个进程归shinerio用户所有,那么这个进程就可以做所有shinerio用户能做的事情了:编辑shinerio用户home目录下的文件,启动一个归属于shinerio用户的新进程,等等。
系统进程比如init和login归属于root用户,而且当一个根进程复刻一个新进程的时候,它可以改变这个子进程的归属。
所以,当我们登录后, login命令会复刻一个新的进程我运行我们的shell,但是新的进程是所属于成功登陆的那个用户的。接下来所有的后续命令都会以该用户的名义执行,所启动的进程都归属于他。
默认情况下,只有 root 进程可以像这样改变归属权。
5. Init System
操作系统内核在初始化进程中所做的最后一件事情就是启动「init system」,也就是执行 /sbin/init命令。「init system」有很多种,但它们都有相同的职责:
- 控制哪些服务在系统启动时跟随启动
- 提供可以开启、停止服务的工具,并且给出服务的状态信息总览
- 提供一个可以编写新的服务的框架
这里的服务涵盖了从web服务器到用来管理登录的系统级服务器在内的所有服务。基本上,一个「init system」的工作就是让所有面向用户(即非内核)的程序和服务运行起来。
例如,Ubuntu和centos都使用
systemd作为默认的「init system」。根据 Linux 惯例,字母 d 是守护进程(daemon)的缩写。 Systemd这个名字的含义,就是它要守护整个系统。
(1)-(3) 中设计的特定命令和工具会因不同的「init system」而各有不同。Linux系统历史上最通用的一个「init system」叫做「System V Init」,它是以极具影响力的UNIX SYSTEM V来命名的。在现在Linux系统中,同时被CentOS、RedHad、Debian、Ubuntu等等主流发行版本所采用的「init system」叫做「systemd init system」。
有两点需要铭记:
- 不同的 Linux 发行版本可以使用不同的「init system」
- 同一 Linux 发型版本的不同版本号可以使用不同的「init system」
8. 终止进程
我们可以使用kill命令或killall命令来终止一个进程。常用的kill命令如下:
- 1 (HUP):重新加载进程。
- 2 (INT): 中断(同
Ctrl + C) - 3 ( QUIT): 退出(同
Ctrl + \) - 9 (KILL):无条件强制杀死一个进程。
- 15 (TERM):正常停止一个进程。
- 18 (CONT): 继续(与STOP相反)
- 19 (STOP): 暂停(同
Ctrl + Z)
只有第9种信号(SIGKILL)才可以无条件终止进程,其他信号进程都有权利忽略
默认情况下,kill和killall命令会发送TERM信号给特定的进程。TERM信号是一个「优雅」的终止信号,进程收到这个信号时会以合适的方式处理和结束进程。比如,被终止的进程可能想要在终止之前完成当前的任务、或者是清理可能会残留在系统中的临时文件等等。
如果一个进程有漏洞导致它已经不能响应TERM信号了,这种情况下我们就只能发送另一个比较激进的信号了。有两种方法可以发送这个信号:
- kill -KILL pid
- kill -9 pid
kill -9或者killall -9指令都是非常激进了,粗略地等同于直接拔掉计算机的电源。像这样来终止进程可能会留下一堆麻烦,只不过如果进程真的不响应了,也没啥别的办法。所以,在使用kill -9 PID之前,一定要先尽量尝试使用kill PID才是。
9. 进程API
9.1. Fork
<span class="tag">#include</span> <unistd.h>
<span class="tag">#include</span> <stdio.h>
<span class="tag">#include</span> <stdlib.h>
int main(int argc, char *argv[]){
printf("hello world (pid: %d)\n", (int) getpid());
int rc = fork();
if (rc < 0){
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0){
printf("hello, I am child (pid: %d)\n", (int) getpid());
} else {
printf("hello, I am parent of %d (pid: %d)\n", rc, (int) getpid());
}
sleep(10);
return 0;
}
- 输出hello world以及当前进程pid
- fork()系统调用,创建新进程;新进程与调用进程几乎完全一样,只不过新创建的线程不会从main函数入口开始执行,而是从fork()系统调用直接返回,父进程返回值是子进程的PID,子进程返回的是0;子进程拥有自己独立的地址空间、寄存器、程序计数器等
- 第二行和第三行的打印顺序是随机的,这取决于CPU在父子进程之间的调度顺序
hello world (pid: 80471)
hello, I am parent of 80479 (pid: 80471)
hello, I am child (pid: 80479)
# ps aux|grep fork_test|grep -v grep
shinerio 80479 0.0 0.0 4278540 420 ?? S 10:47下午 0:00.00 /Users/shinerio/WorkSpace/c_learn/fork_test
shinerio 80471 0.0 0.0 4277516 760 ?? SX 10:47下午 0:00.00 /Users/shinerio/WorkSpace/c_learn/fork_tes
9.2. Wait
wait()系统调用可以让父进程等待子进程执行完成。
<span class="tag">#include</span> <unistd.h>
<span class="tag">#include</span> <stdio.h>
<span class="tag">#include</span> <stdlib.h>
<span class="tag">#include</span> <time.h>
void print_time(){
time_t now;
time(&now);
struct tm * timeinfo = localtime(&now);
char buffer[80];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", timeinfo);
printf("time: %s", ctime(&now));
}
int main(int argc, char *argv[]){
printf("hello world (pid: %d)\n", (int) getpid());
int rc = fork();
if (rc < 0){
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0){
print_time();
printf("hello, I am child (pid: %d)\n", (int) getpid());
} else {
wait(NULL); // waitpid(rc, NULL, 0);
print_time();
printf("hello, I am parent of %d (pid: %d)\n", rc, (int) getpid());
}
sleep(10);
return 0;
}
wait(NULL)只会等待任意一个子进程完成,即多个子进程的情况下,父进程只会阻塞到第一个执行完成的子进程。使用pid_t waitpid(pid_t pid, int *status, int options);可以明确等待指定pid号的进程执行完成。
pid:指定要等待的子进程的进程 ID。其取值有以下几种情况:pid > 0:等待进程 ID 为pid的子进程。pid == 0:等待与调用进程(父进程)属于同一进程组的任何子进程。pid == -1:等待任何子进程,作用类似于wait(NULL)。pid < -1:等待进程组标识符为|pid|的任何子进程。
status:一个指向整数的指针,用于存储子进程的退出状态信息。如果不关心子进程的退出状态,可以将其设置为NULL。options:控制waitpid()的行为,是一个位掩码,可以使用以下标志的组合(使用|运算符):WNOHANG:如果没有子进程退出,立即返回,而不是阻塞父进程。WUNTRACED:除了等待已终止的子进程,还会等待已停止的子进程。WCONTINUED:等待已停止的子进程被继续运行。
hello world (pid: 80921)
time: Tue Jan 14 23:02:10 2025
hello, I am child (pid: 80926)
time: Tue Jan 14 23:02:20 2025
hello, I am parent of 80926 (pid: 80921)
9.3. Exec
Fork()系统调用可以让子进程执行与父进程一样的程序,而exec系列函数可以让子进程与父进程执行不一样的程序。
不同的 exec 函数在参数传递和环境变量处理上有不同的方式,根据实际需求选择合适的函数。例如:
- 如果要使用
PATH环境变量查找可执行文件,可以使用execlp或execvp; - 如果要明确指定路径,可以使用
execv或execve; - 如果要指定环境变量,可以使用
execle或execve
exec系列函数会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。同时,对exec函数的调用永远不会返回,也就是原进程的后续代码不会执行。
<span class="tag">#include</span> <unistd.h>
<span class="tag">#include</span> <stdio.h>
<span class="tag">#include</span> <stdlib.h>
<span class="tag">#include</span> <string.h>
int main(int argc, char *argv[]){
int rc = fork();
if (rc < 0){
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0){
printf("I am child (pid: %d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("fork_test.cpp");
myargs[2] = NULL;
execvp(myargs[0], myargs);
printf("this shouldn't print out");
} else {
printf("I am parent of %d (pid: %d)\n", rc, (int) getpid());
}
}
输出如下:
I am parent of 91679 (pid: 91678)
I am child (pid: 91679)
24 70 595 fork_test.cpp
9.4. fork和exec组合
fork和exec都是一个最简单的抽象,fork用于克隆一个进程,而exec用于执行一个新的进程,通过对这两个简单接口的组合可以实现非常强大的功能。如先fork进程,然后做一些环境准备工作,然后再执行exec,最后wait等待子进程执行完成。
以shell中最简单的重定向举例wc test.txt > count.txt
- shell本身也是一个用户程序,它先fork()创建新进程
- 然后关闭标准输出
- 打开文件count.txt
- 然后调用exec来执行wc程序,这样程序输出结果就不是屏幕,而是count.txt文件了。
<span class="tag">#include</span> <unistd.h>
<span class="tag">#include</span> <stdio.h>
<span class="tag">#include</span> <stdlib.h>
<span class="tag">#include</span> <string.h>
<span class="tag">#include</span> <fcntl.h>
int main(int argc, char *argv[]){
int rc = fork();
if (rc < 0){
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0){
close(STDOUT_FILENO);
open("./fork_test.txt", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("fork_test.cpp");
myargs[2] = NULL;
execvp(myargs[0], myargs);
} else {
printf("I am parent of %d (pid: %d)\n", rc, (int) getpid());
}
}
以上代码会将wc fork_test.cpp的命令输出结果写到fork_test.txt文件中
10. 受限执行
受限直接执行是操作系统中一种用于实现进程控制和管理的技术手段
10.1. 基本概念
受限直接执行是指在操作系统的管理下,让程序在处理器上直接运行,但对其访问资源和执行操作的权限进行严格限制的一种执行方式。它允许进程在一定的约束条件下,直接使用处理器资源来执行指令,完成任务,但又防止其对系统资源进行非法或不合理的访问和操作,以保证系统的稳定性、安全性和公平性。
10.1.1. 实现方式
- 硬件支持:硬件提供了一些机制来支持受限直接执行。例如,处理器具有不同的特权级别,如操作系统地址空间#1. 用户空间与内核空间|用户态和内核态。在用户态下运行的进程受到限制,不能直接访问一些关键的硬件资源和执行特权指令,而内核态则具有更高的权限,可以进行各种底层的系统操作。通过这种特权级别的设置,硬件可以确保进程在受限的环境下运行。
- 操作系统管理:操作系统通过进程控制块(PCB)等数据结构来管理进程的执行。操作系统会为每个进程分配一定的资源,如内存空间、CPU 时间片等,并记录进程的状态信息。在进程执行过程中,操作系统会根据进程的状态和资源分配情况,对进程进行调度和切换,确保每个进程都能在受限的条件下公平地使用系统资源。同时,操作系统还会通过内存管理、文件系统管理等模块,对进程的资源访问进行监控和限制,防止进程越界访问或非法操作。
10.1.2. 作用和意义
- 资源隔离与保护:可以将不同进程的运行环境隔离开来,防止一个进程非法访问或修改其他进程的资源,避免进程之间的相互干扰和破坏,从而保证系统中各个进程的独立性和稳定性。
- 系统安全保障:限制了进程对系统关键资源和敏感信息的访问,降低了恶意程序或错误程序对系统造成严重破坏的可能性,增强了系统的安全性。
- 公平分配资源:操作系统可以根据一定的调度算法,为各个进程分配合理的 CPU 时间和其他资源,使各个进程能够在受限的条件下公平地竞争资源,提高系统资源的利用率和整体性能。
11. 进程切换
上下文切换时长,对于一个2GHz或3GHz的处理器,上下文切换的时间可以达到亚微秒级别。
!操作系统地址空间#1.3. 进程切换