「Linux」- 进程状态(学习笔记)

相关文档及学习资料

Understanding Linux Process States

关于进程状态关系的图解

Understanding Processes on Linux

R Running or runnable (on run queue)
根据 Understanding Linux Process States 描述,进程具有 Running 与 Runnable 状态,但是 p->state = TASK_RUNNING 状态,即状态字段是相同的。

D Uninterruptible sleep (waiting for some event)

S Interruptible sleep (waiting for some event or signal)

T Stopped, either by a job control signal or because it is being traced by a debugger.

t stopped by debugger during the tracing
当使用调试器(如 gdb)调试某个进程时,在使用断点中断进程后,进程就会变成跟踪状态,这其实也是种特殊的暂停状态,只不过可以用调试器来跟踪并按需要控制进程的运行。

Z Zombie process, terminated but not yet reaped by its parent.

I Idle kernel thread
用在不可中断睡眠的内核线程上。硬件交互导致的不可中断进程用 D 表示,但对某些内核线程来说,它们有可能实际上并没有任何负载,用 Idle 正是为了区分这种情况。要注意,D 状态的进程会导致平均负载升高, I 状态的进程却不会。

W paging (not valid since the 2.6.xx kernel)

X dead (should never be seen)

使用命令查看进程当前状态

使用命令 ps l 查看进程状态,显示进程休眠或者运行。如果进程正在休眠,WCHAN 列(wait channel,等待队列的名称)显示进程正在等待的内核事件。

# ps l
F   UID   PID  PPID PRI  NI    VSZ   RSS WCHAN  STAT TTY        TIME COMMAND
0   500  5942  5928  15   0  12916  1460 wait   Ss   pts/14     0:00 -/bin/bash
0   500 12235  5942  15   0  21004  3572 wait   S+   pts/14     0:01 vim index.php
0   500 12580 12235  15   0   8080  1440 wait   S+   pts/14     0:00 /bin/bash -c (ps l) >/tmp/v727757/1 2>&1
0   500 12581 12580  15   0   4412   824 -      R+   pts/14     0:00 ps l

wait 等待队列对应 wait(2) 系统调用,因此只要其中某个子进程的状态发生变化,这些进程就会移至运行状态(R)。有两种休眠状态:可中断睡眠;不可中断睡眠。可中断睡眠:虽然进程是等待队列的一部分,但是向其发送信号,也可使其进入运行状态。如果查看内核代码,你会发现的任何等待事件的内核代码必须检查在 schedule() 返回后是否有待处理的信号,并且在这种情况下终止系统调用。

STAT 列显示进程的当前状态,但是会具有多个标签:s 表示进程是 session leader;+ 表示进程是前端进程组的一部分。这些标签用于作业控制。

运行或可运行(Running or Runnable)

是指正在使用 CPU 或者正在等待 CPU 的进程。

也就是我们常用 ps 命令看到的,处于 R 状态(Running 或 Runnable)的进程。

不可中断睡眠(Uninterruptible sleep )

是正处于内核态关键流程中的进程,并且这些流程是不可打断的。不可中断状态是系统对进程和硬件设备的一种保护机制。比如:最常见的是等待硬件设备的 I/O 响应;当某个进程向磁盘读写数据时,为了保证数据的一致性,在得到磁盘回复前,它是不能被其他进程或者中断打断的,否则容易导致磁盘数据与进程数据不一致。

也就是我们在 ps 命令中看到的,处于 D 状态(Uninterruptible Sleep,也称为 Disk Sleep)的进程。

僵尸进程(Zombie process)

Zombie and Orphan Processes in C – GeeksforGeeks

当进程结束执行时,依旧在进程表(Process Table)中存在对应记录,则被称为僵尸进程。当子进程执行结束时,在被从进程表中移除前,总是僵尸进程。只有当父进程获取子进程退出状态,并从进程表中移除该子进程时,才算正确处理子进程退出。

正常情况下:
1)当一个进程创建了子进程后,它应该通过系统调用 wait() 或者 waitpid() 等待子进程结束,回收子进程的资源;
2)而子进程在结束时,会向它的父进程发送 SIGCHLD 信号,所以父进程还可以注册 SIGCHLD 信号的处理函数,异步回收资源;

如果产生僵尸进程,则可能是因为:
1)如果父进程没这么做,子进程就会变成僵尸进程
2)或是子进程执行太快,父进程还没来得及处理子进程状态,子进程就会变成僵尸进程

通常,僵尸进程持续的时间都比较短,会被回收处理:
1)在父进程回收它的资源后就会消亡;
2)或者在父进程退出后,由 init 进程回收后也会消亡

作业与会话

作业与进程组相同。Shell 的内置 jobs、fg、bg 命令可以用于操作会话中的作业。每个会话由 session leader 管理,通常是 shell 进程,使用系统调用与信号协议来与内核紧密合作。

以下示例显示 进程、作业、会话 的关系:

如下关系图:

这些内核结构如下:

*	TTY Driver (/dev/pts/0).
	Size: 45x13
	Controlling process group: (101)
	Foreground process group: (103)
	UART configuration (ignored, since this is an xterm):
	  Baud rate, parity, word length and much more.
	Line discipline configuration:
	  cooked/raw mode, linefeed correction,
	  meaning of interrupt characters etc.
	Line discipline state:
	  edit buffer (currently empty),
	  cursor position within buffer etc.
*	pipe0
	Readable end (connected to PID 104 as file descriptor 0)
	Writable end (connected to PID 103 as file descriptor 1)
	Buffer

每个管道都是单个作业,以为他们要被同时操纵(停止、恢复、结束),正因如此 kill 才允许向进程组发送信号。

fork 创建的新进程属于父进程的进程组。

但是 shell 作为 session leader 的责任,当启动管道时需要创建新的进程组。

TTY 需要追踪前端进程组标识,但是是被动方式。session leader 在必要是会更新此信息。类似地,TTY driver 需要追踪已连接终端的尺寸,但是这要由终端仿真程序或用户明确更新该信息。

如上图示,虽然很多进程将 /dev/pts/0 绑定到标准输入,但是只有前端作业可以接收来自 TTY 的输入。另外也之后有前端作业允许写入 TTY 设备。如果 cat 程序尝试写入 TTY,内核将使用信号将其休眠。

进程信号

TTY drivers,line disciplines,UART drivers 是如何与用户进程通讯的。

Unix 文件,包括 TTY 设备文件,可读可写,可进一步被 ioctl 调用操纵(已经预定义很多 TTY 相关操作)。ioctl 需要由进程初始化,因此当内核需要与应用程序异步通信时,不能使用它们。

在 UNIX 中,内核通过向进程发送瘫痪或致命信号与进程进行通信。进程可能会拦截某些信号,并尝试适应这种情况,但大多数情况不会。因此,信号是一种粗糙的机制,它允许内核与进程异步通信。在 UNIX 中的信号不是干净的或通用的。相反,每个信号都是唯一的,必须单独研究。

使用 kill -l 查看信号。信号从1开始编号。但是,当将它们用于位掩码(例如ps s的输出)时,最低有效位对应于信号1。

SIGHUP

当检测到挂断条件时,UART driver 将向整个会话发送 SIGHUP 信号。这通常会结束进程,但是某些程序 nohup screen 会从会话(TTY)分离,因此子进程不会注意到 SIGHUP 信号。

SIGINT

当在输入流中出现 interactive attention character 时(通常是 ^C 字符),TTY driver 将发送 SIGINT 信号给前端作业。

SIGQUIT

类似于 SIGINT 信号,但是退出字符为 ^\ 并且默认动作也不同。

SIGPIPE

内核将发送 SIGPIPE 给所有尝试写入管道但是没有读取的进程。否则类似于 yes | head 将永远无法终止。

SIGCHLD

当进程退出或者改变状态,内核将发送 SIGCHLD 给它的父进程。该信号会包含附加信息,即进程标识、用户标识、退出状态(终止信号)、执行时间统计。session leader 使用此信号追踪其作业。

SIGSTOP

该信号将无条件终止接收者,该信号不能被重新配置。在作业控制期间,内核不发送 SIGSTOP。

取而代之的是,^Z 通常会触发 SIGTSTP,它可以被应用程序拦截。然后程序会进行某些自定义操作,之后使用 SIGSTOP 进入睡眠状态。

SIGCONT

恢复停止进程。当用户调用 fg 时,将发送该信号。

SIGTSTP

类似与 SIGINT SIGQUIT 信号,通常对应 ^Z 字符,默认行为为休眠进程。

SIGTTIN

如果属于后台作业的进程尝试从 TTY 读取,TTY 发送 SIGTTIN 信号给整个作业。这通常会休眠作业。

SIGTTOU

如果属于后台作业的进程尝试向 TTY 写入,TTY 发送 SIGTTOU 信号给整个作业,这通常会休眠作业。可以在 TTY 中关闭作业。

SIGWINCH

由于 TTY 需要追踪终端尺寸,但是这个信息需要手动更新。当发生这种情况时,TTY 尝试发送 SIGWINCH 给前端进程。好的应用程序会从 TTY 获取新的终端尺寸,并进行重绘。

简单示例

假设我们正在使用基于终端的编辑器(如 Vim 等)编辑文件,光标位于屏幕中间,编辑器正在执行处理器密集型任务(比如在大文件中进行查找替换)。

现在,当我们按下 ^Z 组合时,由于 line discipline 被配置为解析该字符,因此我们无需等待编辑器完成任务并开始从 TTY 读取,此时 line discipline 子系统立即发送 SIGTSTP 给前端进程组。该进程组包含我们的编辑器进程以及其他由它创建的进程。

编辑器具有 SIGTSTP 信号的处理程序,因此内核使进程进入信号处理代码。该代码将光标移动到屏幕最后行(通过想 TTY 写入对应的控制序列)。由于编辑器还在前端运行,因此按要求发送控制序列。但是然后编辑器会发送 SIGSTOP 信号给子进程。

现在编辑器进入停止状态,然后向 Session Leader 发送 SIGCHLD 信号以通知当前状况,通知中包含被停止进程的进程标识。当所有前端进程进入休眠后,Session Leader 读取 TTY 配置,并存储以在之后取出。Session Leader 继续将自己设置为前端进程组(使用 ioctl 调用)。然后打印”[1]+ Stopped”消息。

此时如果使用 ps 命令,可以看到编辑器处理 T 状态。当时用 bg 命令唤醒编辑器(或则使用 kill 发送 SIGCONT 信息),将运行编辑器的 SIGCONT 信号处理程序,编辑器将尝试写入TTY设备以重绘界面。但是由于进程是后端进程,因此 TTY 将禁止它。并且 TTY 将发送 SIGTTOU 给编辑器,再次将其停止。此状态将发送给 Session Leader(使用 SIGCHLD 信号),Shell 将再次打印”[1]+ Stopped”消息。

此时当我们使用 fg 唤醒编辑器时。Shell 首先恢复 line discipline 的配置(之前保存的)。然后通知 TTY 将编辑器视为前端程序。最后发送 SIGCONT 信号给进程组。然后编辑器进程将尝试重新绘制界面,此时由于它已经是前端进程组的一部分,因此不再会被 SIGTTOU 中断。

流控制和阻塞读写

执行 yes 命令将输出大量 y 行,然后 xterm 将在窗口中显示。但是 y 输出速度远大于 xterm 的处理速度。他们是如何工作的呢?

答案是阻塞读写。伪终端只能在内核缓冲内保存定量数据,如果缓冲填满,当 yes 再次调用 write(2) 时,write(2) 将阻塞并使 yes 进程进入可中断睡眠状态,直到进程有机会读取缓冲中的字节。

如果 TTY 连接到串行端口时,也是这样,yes 将能够以比 9600 波特更高的速率传输数据,但是如果端口被限速,当内核缓冲被填满,后来调用 write(2) 将阻塞进程。如果进程尝试使用非阻塞读写将返回 EAGIN 状态。

但是即使内核缓冲没有填满,我们也可以明确设置 TTY 进入阻塞状态。除非另行通知,否则尝试调用 write(2) 进程将自动阻塞。这个功能的作用是什么?

假设我们正在使用 9600 波特的旧VT-100硬件进行通信。当我们发送控制序列要求终端滚动时,此时终端无法再接收数据。但是终端 UATR 仍旧运行在 9600 波特,但是此时没有足够空间来保存收到字符的积压。此时应该时 TTY 进入阻塞状态。但是我们如何从终端上做到这一点?

对于 TTY 可以将某些字节序列特殊对待。比如 ^C 不会通过 read(2) 传递给应用程序,而是发送 SIGINT 给前端作业。类似的,我们可以配置 TTY 对 开始流字节与停止流字节 作出反应,它们通常分别是 ^S(ASCII code 19)和 ^Q(ASCII code 17)。旧的硬件终端会自动发送这些字节,并期望操作系统相应地调节其数据流。这就是流控制,当在 xterm 中意外按下 ^S 时会看起来像是锁定。

不同点:由于流控制或者缺少内核缓冲而无法写入 TTY,将阻塞进程;但是后端作业尝试写入 TTY 将导致发送 SIGTTOU 信号给进程组。至于 UNIX 为什么要这么设计,而不是直接使流控制来阻塞进程。可能前者是用于控制进程,而后者用于控制进程组。

配置 TTY 设备

要找出 Shell 调用的控制 TTY 是什么,可以参考前面 ps l 列出的内容,或者执行运行 tty 命令。

进程可以使用 ioctl(2) 控制代开的 TTY deivice 配置,参考 tty_ioctl(4) 手册。由于它是应用与内核间二进制接口部分,它将在 Linux 版本间保持稳定。但是该接口不具备可移植性,应该使用 POSIX 包装程序,参考 termios(2) 手册。

这里不介绍 termios 手册,如果编写 C 程序,你可能希望在 ^C 变成 SIGINT 时,进行某些操作(禁用行编辑、字符输出、修改波率),这些可以参考 termios(2) 手册。

可以使用 stty(1) 设置 TTY,它使用 termios(3) 接口。

使用 stty -a 查看当前配置。默认会查找绑定到当前 Shell 的 TTY,使用 -F 制定其他 TTY 设备。

案例分析:分析大量不可中断状态和僵尸状态进程

第一步、准备实验环境,制造负载

# docker run --privileged --name=app-iowait -itd feisky/app:iowait

第二步、观察系统当前状态

# top
top - 14:00:33 up 219 days,  5:48,  3 users,  load average: 2.42, 1.15, 0.52
Tasks: 246 total,   1 running, 186 sleeping,   1 stopped,  58 zombie
%Cpu0  :  2.1 us,  8.3 sy,  0.0 ni,  0.0 id, 87.5 wa,  0.0 hi,  2.1 si,  0.0 st
%Cpu1  :  4.0 us,  7.1 sy,  0.0 ni,  0.0 id, 87.9 wa,  0.0 hi,  1.0 si,  0.0 st
MiB Mem :   6813.8 total,    589.8 free,   4193.9 used,   2030.1 buff/cache
MiB Swap:   8192.0 total,   4139.8 free,   4052.2 used.   2303.5 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
22608 systemd+  20   0 4298.3m   2.0g   4.4m S   5.8 29.7  16037:15 mongod --smallfiles --rest --auth
 1820 root      20   0   68.4m  64.0m   0.0m D   2.9  0.9   0:00.25 /app
 1821 root      20   0   68.4m  64.0m   0.0m D   2.9  0.9   0:00.23 /app
14995 100       20   0  142.5m  47.8m  31.0m S   2.9  0.7   6996:09 consul agent -data-dir=/consul/da
15252 systemd+  20   0 2752.8m  74.7m   1.0m S   2.9  1.1   4809:51 /usr/lib/erlang/e

// 根据 Load Average 发现系统负载正在升高
// 系统存在僵尸进程,并且数量在增加
// 系统当前的 iowait 比较高
// 由两个进程处于 D 状态,但不能确定是否是他们导致 iowat 升高

// 因此系统目前存在两个问题:
// 1)僵尸进程在不断增多,说明有程序没能正确清理子进程的资源
// 2)iowait 太高了,导致系统的平均负载升高

第三步、处理 iowait 问题

# dstat 1 10
You did not select any stats, using -cdngy by default.
----total-cpu-usage---- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai hiq siq| read  writ| recv  send|  in   out | int   csw
  4   2  94   0   0   0|  33k  127k|   0     0 |1436B  715B| 671  1399
  3  14  32  49   0   2| 376M   22k|  57k   65k|   0     0 |2312  1858
  3   7   8  79   0   2| 538M    0 |  63k   57k|   0     0 |2527  1904
  3   6  12  77   0   2| 538M  104k|  60k   56k|   0     0 |2444  1905
  4   8   6  81   0   2| 526M    0 |  61k   56k|   0     0 |2577  2019
  4   7  11  77   0   2| 488M   80k|  61k   55k|   0     0 |2494  1935
  3  14  23  58   0   1| 489M   20k|  61k   56k|   0     0 |2569  1909
  3   9  12  75   0   2| 584M   60k|  61k   63k|   0     0 |2776  2247
  4   8  15  72   0   2| 528M    0 |  60k   54k|   0     0 |2513  1883
  3   7  12  76   0   2| 528M    0 |  60k   55k|   0     0 |2427  1855
  3   7   2  88   0   1| 515M    0 |  60k   54k|   0     0 |2419  1866

// 我们可以看到,磁盘的读请求(read)很大

// 在进程状态中,top 中存在不可中断状态(D)的进程,我们跟踪这两个进程

# pidstat -d -p 3715 1 3
Linux 3.10.0-957.5.1.el7.x86_64 (host166)       07/29/20        _x86_64_        (2 CPU)

14:37:57      UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  Command
14:37:58        0      3715      0.00      0.00      0.00  app
14:37:59        0      3715      0.00      0.00      0.00  app
14:38:00        0      3715      0.00      0.00      0.00  app
Average:        0      3715      0.00      0.00      0.00  app

// 但是没有发现读取压力,另外一个进程也是如此(这里没有记录)

// 我们直接查看 所有进程 的磁盘读取情况:

# pidstat -d 1 20
# pidstat -d 1 20
Linux 3.10.0-957.5.1.el7.x86_64 (host166)       07/29/20        _x86_64_        (2 CPU)

15:00:05      UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  Command
15:00:06       27      8074      7.77     31.07      0.00  mysqld
15:00:06        0     16982 237608.25      0.00      0.00  app
15:00:06        0     16983 254508.74      0.00      0.00  app

15:00:06      UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  Command
15:00:07       27      8074     16.00     36.00      0.00  mysqld
15:00:07        0     16982 299007.50      0.00      0.00  app
15:00:07        0     16983 262144.00      0.00      0.00  app

// 从结果上看,问题还是出现在 app 进程上,该进程的磁盘读取较高

// 进程想要访问磁盘,就必须使用系统调用,所以接下来重点就是找出 app 进程的系统调用:

# strace -p 6082
strace: attach: ptrace(PTRACE_SEIZE, 17294): Operation not permitted

// 但是我们无法使用 strace 追踪系统调用,因为再次查看发现进程已经变成僵尸进程

# ps -p 17294 -o stat
STAT
Z+

// 此时 我们只能求助那些基于事件记录的动态追踪工具

# perf record -g
# docker cp app-iowait:/ /tmp/app-iowait
# perf report --input=./perf.data // 不能指定 --symfs=/tmp/app-iowait 选项,否则无法显示系统调用
Samples: 863K of event 'cpu-clock', Event count (approx.): 215838250000
  Children      Self  Command          Shared Object               Symbol
+   83.32%     0.00%  swapper          [kernel.kallsyms]           [k] start_cpu
+   83.29%     0.03%  swapper          [kernel.kallsyms]           [k] cpu_startup_entry
+   82.68%     0.01%  swapper          [kernel.kallsyms]           [k] arch_cpu_idle
+   82.66%     0.01%  swapper          [kernel.kallsyms]           [k] default_idle
+   79.17%    79.17%  swapper          [kernel.kallsyms]           [k] native_safe_halt
+   41.82%     0.00%  swapper          [kernel.kallsyms]           [k] start_secondary
+   41.50%     0.00%  swapper          [kernel.kallsyms]           [k] x86_64_start_kernel
+   41.50%     0.00%  swapper          [kernel.kallsyms]           [k] x86_64_start_reservations
+   41.50%     0.00%  swapper          [kernel.kallsyms]           [k] start_kernel
+   41.50%     0.00%  swapper          [kernel.kallsyms]           [k] rest_init
+    8.50%     0.00%  app              libc-2.27.so                [.] 0x00007f2888accb97
+    8.49%     0.00%  app              [unknown]                   [k] 0x0b96258d4c544155
-    8.49%     0.00%  app              app                         [.] 0x00005589b7f82151
   - 0x5589b7f82151
      - 8.47% 0x7f2888bbb081
         - 8.47% system_call_fastpath
            - 8.47% sys_read
               - 8.47% vfs_read
                  - 8.47% do_sync_read
                     - blkdev_aio_read
                        - 8.47% generic_file_aio_read
                           - 8.46% blkdev_direct_IO
                              - 8.46% __blockdev_direct_IO
                                 - 8.38% do_blockdev_direct_IO
                                    + 2.59% put_page
                                      1.92% __get_page_tail
                                    + 1.37% dio_bio_complete
                                    + 1.25% get_user_pages_fast
+    8.49%     0.00%  app              [kernel.kallsyms]           [k] system_call_fastpath
+    8.47%     0.00%  app              libc-2.27.so                [.] 0x00007f2888bbb081


// 看样子是 blkdev_direct_IO  直接 I/O 导致读取压力

// 接下来就是检查程序中是否存在直接 IO 行为,这里不再展开

第四步、处理 僵尸进程 问题

// 问题在于父进程没有处理子进程,所以先定位附近进程

# pstree -a -s -p 21839
systemd,1 --switched-root --system --deserialize 22
  └─containerd,5045
      └─containerd-shim,21780 -namespace moby -workdir...
          └─app,21800
              └─(app,21839)

// 然后,再排查程序检查创建子进程的代码,检查是否处理子进程退出。

// 这里不再展开

相关连接

TODO 进程状态
Troubleshooting high load average on Linux

参考文献

!!! The TTY demystified
D state process kill
When a process will go to ‘D’ state?
What does this process STAT indicates?
9. Process States
Linux process states — Idea of the day
02 | 基础篇:到底应该怎么理解“平均负载”?