文章

D状态进程

进程是计算机科学中最深刻、最成功的抽象概念之一,进程是运行中的一个程序示例,作为计算机运行的主体,理解并把控进程的状态可谓是至关重要。

从用户的角度来看,运行一个程序之后,可以认为进程的状态大致可以分为运行中、挂起两大类,而导致被挂起的原因又可分为多种:

  • 被动挂起:进程需要等待某种资源被内核挂起(如磁盘IO、网络IO、阻塞锁)。
  • 主动挂起:用户通过系统调用sleep挂起一定时间、调用pause暂停进程、调用vfork父进程挂起等待子进程结束或者载入新的程序。

一个处于挂起状态的进程从是否可被打断来看,又可以分为两大类:

  • 可被打断的:进程在挂起等待的时候可以响应信号,退出阻塞的状态,如通过sleep的挂起、网络IO的阻塞读写导致的挂起。
  • 不可被打断的:进程在挂起的时候不可以被打断,也就是不能响应信号,比如最常见的就是同步的磁盘IO。

当我们在Linux上想要获取进程的当前状态时,最常用的工具可能就是top、ps,在展示信息的S(status)列就是进程当前的状态,最最常见的就是R(runable)S(sleep),R状态的进程非常好理解,就是对应于可运行的进程状态,S状态则表示上述挂起状态中的可被打断的挂起状态,而本文的重点:在top等工具展示为D状态的进程就是不可被打断的挂起状态(Uninterruptible Sleep)。

1
2
3
4
5
6
7
8
9
10
top - 09:09:03 up 758 days, 22:13, 336 users,  load average: 0.51, 0.68, 0.75
Tasks: 11052 total,   4 running, 11039 sleeping,   2 stopped,   7 zombie
%Cpu(s):  2.3 us,  6.4 sy,  0.1 ni, 91.2 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem : 32433152 total,  5894680 free,  9042372 used, 17496100 buff/cache
KiB Swap: 15625212 total,  8610156 free,  7015056 used. 12971872 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
57058 rongcha+  20   0   55956  15220   3132 R   6.9  0.0   0:15.74 top
30440 root      20   0   31444   1508   1320 R   4.7  0.0   0:00.15 ps
27387 root      20   0 2819140  22628   6868 S   2.8  0.1 748:31.06 co_agent_d

那么D状态的进程意味着什么?什么原因会使进程处于D状态。

Uninterruptible Sleep

要解释什么是Uninterruptible Sleep,要先来探讨一下进程为什么会阻塞?一个理想的状态应该是所有进程持续运行,保证以最快的速度执行结束,但实际情况下进程的运行过程往往需要依赖一些速度比较慢/具有不确定性的数据,操作系统为了更高效的利用CPU,将当前需要等待数据的进程挂起,注册相应的回调函数,切换到其他进程执行,当数据已经就绪,被挂起的进程已满足继续运行的条件,再唤醒被挂起的进程继续执行,其实操作系统作为运行进程的一个框架,也是一个异步回调的架构,以达到更高的利用率和执行效率。

再来看对于阻塞中的进程,为什么有可中断和不可中断两种状态?顾名思义,可中断的阻塞就是指进程在等待的过程中可以被信号打断,不再继续等待,而不可中断则表示要一直等到数据的到达、条件的满足才会恢复到运行态,中间不可被信号打断。 如果从使用的角度来看,可中断一定是更灵活的,比如sleep这个系统调用关于返回值的描述:

Zero if the requested time has elapsed, or the number of seconds left to sleep, if the call was interrupted by a signal handler.

如果一个用户通过sleep系统调用使进程睡眠一段时间,但是中间用户不想等了,这时可以发送一个信号给该进程,进程便会退出阻塞状态,执行信号的回调函数,如果信号的回调函数没有退出,则会继续执行用户代码,用户可以拿到sleep返回值,也就是还剩余的睡眠时间。

对于sleep阻塞时的上下文来说,打断是无害的,但是对于其他一些场景,则是不可打断的,比如最常见的读磁盘过程,调用read系统调用读磁盘文件时,CPU委托DMA控制器将磁盘数据读到指定内存区域,进程被挂起,CPU转去执行其他进程,等待DMA传输完成后的通知,在DMA传输过程中这个上下文来说,就是不可以被打断,否则会导致错误/无法预测的后果,因为DMA数据传输这个行为无法被中断和再恢复,操作系统必须保证这个行为的原子性,这时就会将挂起的进程设置为Uninterruptible Sleep,保证在等待的时候不被打断,也就是D状态

在Linux系统上用stress这个压测工具可以简单的实验下,"stess -i n"会开启n个进程写磁盘,打满磁盘IO之后就可以看到进程出现D状态,开启stress后在另一个终端使用top和iostat观测,如下所示:

除此之外,在内核源代码中,代表进程的结构体中的state字段即task_struct->state记录着进程当前所处的状态,其中一个状态TASK_UNINTERRUPTIBLE就是我们所说的Uninterruptible Sleep,在内核源码下搜索TASK_UNINTERRUPTIBLE可以看到进程在哪些地方会设置为不可打断的状态,基本上是磁盘IO和一些锁的上下文中。对于用户来讲,在系统D状态的进程存在的时间很短,占比也非常小,通常对于用户来说是透明的,如果经常观测到,就需要考虑系统的负载、IO或者涉及内核相关路径是不是出现了问题。

总结一下:D状态的进程通常意义下表示的是进程在内核中处于Uninterruptible Sleep状态,是由于进程在内核执行某些操作的上下文中不希望或者不能够被干扰、被打断,比如磁盘IO和内核中某些锁的上下文中,也因为不可被打断,大部分情况下D状态都是短暂停留,如果很多进程或者进程长时间处于D状态,就需要注意了。

D状态进程真的不能被打断吗?

上面我们说到D状态表示Uninterruptible Sleep,也就是不可以被信号打断,但其实他们两者之间并不是等号。

首先我们来看下vfork这个系统调用,vfork也会创建一个新的进程,但是不同于fork,vfork新的进程与父进程完全共享内存空间,也就是父进程调用时所有的数据包括堆、栈、打开的文件等等对于新创建的进程都是可读可写的,可想而知,如果父子进程创建完成后可以任意执行岂不是乱套了,会导致无法预知的后果,所以vfork系统调用规定了父进程在调用vfork后会进入D状态阻塞,直到子进程通过exit退出或者调用exec执行一个全新的进程。

一个vfork示例进程如下,子进程通过pause挂起,这时可以观测父子进程的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <unistd.h>

static void run_child(void)
{
    pause();
    _exit(0);
}

int main(void)
{
    pid_t pid = vfork();

    if (pid == 0) {
        run_child();
    } else if (pid < 0) {
        return 1;
    }
    return 0;
}
1
2
3
4
rongchangyu:~$ ./vfork &
rongchangyu:~$ ps -e -o pid,ppid,state,cmd | grep vfork
2554749 2554748 D ./vfork
2554750 2554749 S ./vfork

可以看到父进程(pid=2554749)处于D状态,也就是不可打断的阻塞状态,等待子进程退出或者使用exec系统调用才会被唤醒,但是如果这个时候尝试kill父进程。

1
2
3
rongchangyu:~$ sudo kill 2554749
rongchangyu:~$ ps -e -o pid,ppid,state,cmd | grep vfork
2554750       1 S ./vfork

你会发现进程被杀掉了,只剩下子进程(pid=2554750)还在S状态,被内核进程(pid=1)收养了。

这是怎么回事呢?如果你在网上搜索相关Uninterruptible Sleep资料,很多回答会说进程无法响应任何信号,只能通过reboot恢复,难道D进程不是Uninterruptible Sleep状态吗?

答案在vfork的源码上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//linux-5.4.236/kernel/fork.c

static int wait_for_vfork_done(struct task_struct *child,
				struct completion *vfork)
{
	int killed;

	freezer_do_not_count();
	cgroup_enter_frozen();
  // 这里等待子进程完成
	killed = wait_for_completion_killable(vfork);
	cgroup_leave_frozen(false);
	freezer_count();

	if (killed) {
		task_lock(child);
		child->vfork_done = NULL;
		task_unlock(child);
	}

	put_task_struct(child);
	return killed;
}

/**
 * wait_for_completion_killable: - waits for completion of a task (killable)
 * @x:  holds the state of this particular completion
 *
 * This waits to be signaled for completion of a specific task. It can be
 * interrupted by a kill signal.
 *
 * Return: -ERESTARTSYS if interrupted, 0 if completed.
 */
int __sched wait_for_completion_killable(struct completion *x)
{
	long t = wait_for_common(x, MAX_SCHEDULE_TIMEOUT, TASK_KILLABLE);
	if (t == -ERESTARTSYS)
		return t;
	return 0;
}

这里贴出了部分函数,父进程会调用wait_for_vfork_done等待子进程完成,其中wait_for_completion_killable这个就是关键函数,它会更新进程状态,阻塞,并调度到其他进程执行,没有再往下贴出来,wait_for_common中第三个参数就是设置进程的状态,即TASK_KILLABLE,而不是TASK_UNINTERRUPTIBLE。

TASK_KILLABLE这个状态在2.6.25版本以后被引入,它是用于在某些场景下替代TASK_UNINTERRUPTIBLE,因为在某些上下文中,虽然进程需要屏蔽信号来完成某些操作,但是如果打断进程仅仅是想要结束进程而不是再执行其他的命令是完成没有问题的,这就是TASK_KILLABLE的作用,比如vfork这个调用,通过信号终止父进程并不会引起什么副作用,父进程直接退出,内存空间便由子进程独享了。所以在这种情况下,进程可被打断,但也只可以被行为是终止进程的信号打断。

可以理解为TASK_KILLABLE是TASK_UNINTERRUPTIBLE一个子分类,可以看看TASK_KILLABLE这个常量在源码中的定义:

1
2
//linux-5.4.236/include/linux/sched.h
#define TASK_KILLABLE  (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)

总结:在内核中状态为TASK_UNINTERRUPTIBLE和TASK_KILLABLE状态的进程都会在top、ps等工具中展示为D状态,TASK_UNINTERRUPTIBLE状态下完全屏蔽所有信号,不可被打断,而TASK_KILLABLE可以认为是一种特殊的TASK_UNINTERRUPTIBLE状态,相比完全屏蔽的条件更为宽松、更友好,可以被回调行为是终止进程的信号打断

D是什么的缩写?

是否还有个疑惑,为什么不可打断进程状态的表示是D,最后再来看看这个小问题的答案。

top、ps等都是Linux提供的命令行工具,其原理是使用了操作系统内核提供的伪文件系统/proc提供的内核相关信息,来实时展示系统上运行进程的各种信息。 关于进程状态就可以在/proc/pid/status这个目录下获得,比如随便打开一个进程的状态文件:

1
2
3
4
5
6
7
8
9
rongchangyu:~$ cat /proc/3023/status
Name:	nginx
Umask:	0000
State:	S (sleeping)
Tgid:	3023
Ngid:	0
Pid:	3023
PPid:	24775
...

可以猜测到top、ps这些工具拿的就是/proc/pid/status中State这个字段,然后就可以看一下内核对于伪文件系统/proc相关源码中的实现,最终答案在这个位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//linux-5.4.236/fs/proc/array.c 

/*
 * The task state array is a strange "bitmap" of
 * reasons to sleep. Thus "running" is zero, and
 * you can test for combinations of others with
 * simple bit tests.
 */
static const char * const task_state_array[] = {

	/* states in TASK_REPORT: */
	"R (running)",		/* 0x00 */
	"S (sleeping)",		/* 0x01 */
	"D (disk sleep)",	/* 0x02 */
	"T (stopped)",		/* 0x04 */
	"t (tracing stop)",	/* 0x08 */
	"X (dead)",		/* 0x10 */
	"Z (zombie)",		/* 0x20 */
	"P (parked)",		/* 0x40 */

	/* states beyond TASK_REPORT: */
	"I (idle)",		/* 0x80 */
};

static inline const char *get_task_state(struct task_struct *tsk)
{
	BUILD_BUG_ON(1 + ilog2(TASK_REPORT_MAX) != ARRAY_SIZE(task_state_array));
	return task_state_array[task_state_index(tsk)];
}

所以D就是disk sleep的意思,通过上文可以知道这个描述并不全面,因为D包括了内核中状态为TASK_UNINTERRUPTIBLE和TASK_KILLABLE的进程,而不止在磁盘IO的过程才有可能进入到这两个状态。但是如果从系统性能观测的角度来看,磁盘IO负载高导致系统问题是一个很常见的原因,其他原因很少至少我还没有遇到过。

参考

Linux Load Averages: Solving the Mystery

Creating controllable D state (uninterruptible sleep) processes

cpu load中所说的不可中断状态到底是啥?

本文由作者按照 CC BY 4.0 进行授权