htop输出详解

First Post:

Last Update:

Word Count:
17.4k

Read Time:
70 min

说明

本文主要是对Peteris Rukšāns的博客文章 htop explained 的翻译和整理, 在原文的基础上进行了部分修正和补充, 并使用AI对某些表达进行了润色, 总体而言, 本文涉及的内容偏基础, 不过还是可以学到一些有意思的东西. 具体的修改可以在文末看到.

htop 详解

详尽解析 Linux 系统中 htop/top 命令展示的所有信息
最后更新于 2019年11月17日

很长一段时间里,我都不清楚 htop 里各项数据到底代表什么意思。
我曾以为,在我那台双核机器上,平均负载 (Load average) 为 1.0 就意味着 CPU 使用率是 50%。
但这并不完全正确。
而且,它为什么会显示 1.0 呢?
于是我决定把这些概念查清楚,并记录在此。
俗话说得好,最好的学习方法就是把它教给别人。

Ubuntu Server 16.04 x64 上的 htop

下面这张 htop 截图是我接下来要详细讲解的对象。
htop 截图

运行时间 (Uptime)

运行时间展示了系统已经持续运行了多久。

你可以通过执行 uptime 命令来查看相同信息:

1
2
$ uptime
12:17:58 up 111 days, 31 min, 1 user, load average: 0.00, 0.01, 0.05

那么,uptime 程序是怎么知道这些数据的呢?

实际上,它是从 /proc/uptime 文件中读取的。

1
9592411.58 9566042.33

第一个数字代表系统自启动以来的总秒数。

第二个数字则是机器在此期间处于空闲状态 (idle) 的总秒数。
在多核系统上,第二个值可能会大于系统的总运行时间,因为它是所有 CPU 核心空闲时间的累加总和。
我是怎么知道这个底层原理的?通过观察 uptime 程序运行时打开了哪些文件。
我们可以使用 strace 工具来追踪。

1
strace uptime

这会产生大量输出。

我们原本可以用 grep 过滤包含 open 系统调用的行。
但这没法直接生效,因为 strace 会将所有追踪信息输出到标准错误流 (stderr) 。
不过,我们可以通过 2>&1 将 stderr 重定向到标准输出流 (stdout) 。
于是输出变成了这样:

1
2
3
4
5
$ strace uptime 2>&1 | grep open
...
open("/proc/uptime", O_RDONLY) = 3
open("/var/run/utmp", O_RDONLY|O_CLOEXEC) = 4
open("/proc/loadavg", O_RDONLY) = 4

从中可以清楚地看到,它打开了刚才提到的 /proc/uptime 文件。
其实,你也可以直接执行 strace -e open uptime,省去用 grep 过滤的麻烦。
既然可以直接读取文件内容,为什么还需要 uptime
因为 uptime 的输出格式对人类阅读更友好,而原始秒数则更适合在编写程序或脚本时直接调用。

平均负载 (Load average)

除了运行时间外,这里还有三个数字代表平均负载。

1
2
$ uptime
12:59:09 up 32 min, 1 user, load average: 0.00, 0.01, 0.03

它们取自 /proc/loadavg 文件。
如果你回顾一下刚才的 strace 输出,会发现这个文件也被打开了。

1
2
$ cat /proc/loadavg
0.00 0.01 0.03 1/120 1500

前三列代表过去 1 分钟、5 分钟和 15 分钟的系统平均负载。第四列展示当前正在运行的进程数和总进程数。最后一列显示系统最近分配的进程ID (PID)。
先从最后一个数字说起。
每当启动新进程 (Process) 时,系统都会为其分配一个 ID。通常进程ID是递增的,除非达到上限,旧 ID 才会被循环复用。
进程ID 为 1 的进程是 /sbin/init,它在系统启动时最先被拉起。
让我们再看一眼 /proc/loadavg 的内容,然后在后台运行 sleep 命令。
当程序在后台启动时,会显示对应的进程ID。

1
2
3
4
$ cat /proc/loadavg
0.00 0.01 0.03 1/123 1566
$ sleep 10 &
[1] 1567

这里的 1/123 表示当前有 1 个进程正在运行或准备运行,而系统中总共有 123 个进程。
当你在终端运行 htop,并且只看到 1 个运行中的进程时,说明它其实就是 htop 进程本身。
如果你执行 sleep 30,然后再次运行 htop,会发现运行中的进程依然只有 1 个。
因为 sleep 并没有在“运行”,而是处于休眠 (sleeping) 或空闲状态,换言之,它在等待某事件发生。
正在运行的进程,是指当前正在物理 CPU 上执行,或正在排队等待 CPU 资源的进程。
如果执行 cat /dev/urandom > /dev/null,该命令会不断生成随机字节,并写入一个永远不会被读取的特殊黑洞文件中,此时你会看到正在运行的进程变成了两个。

1
2
3
4
$ cat /dev/urandom > /dev/null &
[1] 1639
$ cat /proc/loadavg
1.00 0.69 0.35 2/124 1679

现在有了两个运行中的进程 (一个是生成随机数的进程,另一个是读取 /proc/loadavgcat 进程) ,同时你会注意到平均负载的数值上升了。
平均负载代表系统在一段时间内的平均负荷水平。
所谓的“负载数” (load number) ,是通过计算处于运行状态 (正在运行或等待运行) 以及不可中断休眠 (uninterruptible sleep,通常在等待磁盘或网络活动) 状态的进程数量得出的。
简单来说,它就是一个衡量进程数量的指标。
那么,平均负载就仅仅是这些进程在过去 1、5 和 15 分钟内的平均数量吗?
事实并非如此简单。
平均负载其实是负载数值的指数衰减移动平均值 (exponentially damped moving average) 。根据维基百科的解释:

从数学角度说,这三个值计算的永远是自系统启动以来全部系统负载的平均情况。它们都呈指数级衰减,只是衰减速度各不相同。因此,1 分钟的平均负载会累加过去一分钟内 63% 的负载,再加上系统启动以来除最后一分钟外 37% 的负载。也就是说,认为 1 分钟平均负载“仅包含过去 60 秒的活动”在技术上并不准确 (因为它仍包含过去 37% 的活动数据) ,只能说它绝大部分反映了最后一分钟的情况。

这跟你原先预想的一样吗?
让我们回到刚才生成随机数的例子。

1
2
$ cat /proc/loadavg
1.00 0.69 0.35 2/124 1679

尽管技术上不够严谨,但我通常会这样简化理解平均负载,以便更直观地分析。
在上述场景中,生成随机数的进程是 CPU 密集型的,因此过去一分钟的平均负载是 1.00,即平均有 1 个正在运行的进程。
由于我的系统只有一个 CPU 核心,且同一时刻只能运行一个进程,因此 CPU 使用率为 100%。
如果我有两个核心,这台计算机就能同时运行两个进程,那么整体 CPU 使用率将是 50%。
在一台拥有双核且 CPU 使用率达到 100% 的计算机上,其平均负载应为 2.00
你可以在 htop 的左上角看到核心或 CPU 数量,也可以直接执行 nproc 命令查看。
由于负载数值还包含处于不可中断状态的进程,而这类进程通常对 CPU 使用率影响不大,因此像刚才那样直接由平均负载推断 CPU 使用率其实不够准确。
这也解释了为什么有时会看到平均负载很高,但 CPU 实际负荷并不重。
不过,我们可以借助 mpstat 等工具来查看瞬时的 CPU 使用率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ sudo apt install sysstat -y
$ mpstat 1
Linux 4.4.0-47-generic (hostname) 12/03/2016 _x86_64_ (1 CPU)

10:16:20 PM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
10:16:21 PM all 0.00 0.00 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
10:16:22 PM all 0.00 0.00 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
10:16:23 PM all 0.00 0.00 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

# ...

# kill cat /dev/urandom

# ...

10:17:00 PM all 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:17:01 PM all 1.00 0.00 0.00 2.00 0.00 0.00 0.00 0.00 0.00 97.00
10:17:02 PM all 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00

那么,为什么还要使用平均负载 (Load average) 呢?

1
2
3
4
5
6
7
8
9
10
11
12
$ curl -s https://raw.githubusercontent.com/torvalds/linux/v4.8/kernel/sched/loadavg.c | head -n 7
/*
* kernel/sched/loadavg.c
*
* This file contains the magic bits required to compute the global loadavg
* figure. Its a silly number but people think its important. We go through
* great pains to make it work on big machines and tickless kernels.
*
* [译] 这个文件包含计算全局平均负载 (loadavg) 所需的“神奇”代码。
* 这是一个很傻的数字,但人们觉得它很重要。为了让它在大型机器
* 和无滴答 (tickless) 内核上正常运行,我们可是费尽了心思。
*/

进程

在右上角,htop 显示了进程 (Process) 总数及正在运行的进程数。
但它显示的是“任务” (Tasks) 而不是进程。这是为什么呢?
进程的另一个名字就是任务 (task) 。Linux 内核在内部将进程称为任务。
htop 使用“任务”而不是“进程”,可能只是因为它单词更短,能节省屏幕空间。
你也可以在 htop 中查看线程 (Thread) 。按下键盘上的 Shift+H 可切换线程的可见性。
如果看到类似 Tasks: 23, 10 thr 的内容,表示线程已显示。
按下 Shift+K 则可查看内核线程。当它们显示时,你会看到类似 Tasks: 23, 40 kthr 的信息。

进程ID (PID)

每当启动一个新进程,系统都会为其分配一个标识号 (ID) ,
称为进程ID,简称 PID。
如果在 bash 中将程序放到后台运行 (使用 &) ,你会在方括号里看到作业号 (job number) 及其 PID。

1
2
$ sleep 1000 &
[1] 12503

如果没看清,可以在 bash 中使用 $! 变量,它会自动展开为上一个后台进程的 PID。

1
2
$ echo $!
12503

进程ID非常有用,可用于查看进程的详细信息并进行控制。
procfs 是一个伪文件系统,允许用户态程序通过读取文件的方式从内核获取信息。
它通常挂载在 /proc/ 目录下。对用户而言,它看起来就像普通目录,可用 lscd 命令浏览。
与进程相关的所有信息都位于 /proc/<pid>/ 目录中。

1
2
3
4
5
6
7
$ ls /proc/12503
attr coredump_filter fdinfo maps ns personality smaps task
auxv cpuset gid_map mem numa_maps projid_map stack uid_map
cgroup cwd io mountinfo oom_adj root stat wchan
clear_refs environ limits mounts oom_score schedstat statm
cmdline exe loginuid map_files net pagemap setgroups syscall
comm fd map_files net pagemap setgroups syscall

例如,/proc/<pid>/cmdline 记录了启动该进程的命令。

1
2
$ cat /proc/12503/cmdline
sleep1000$

呃,这看起来不太对劲。原来,命令参数之间是用 \0 字节分隔的。

1
2
3
$ od -c /proc/12503/cmdline
0000000 s l e e p \0 1 0 0 0 \0
0000013

我们可以将其替换为空格或换行符:

1
2
3
4
5
6
$ tr '\0' '\n' < /proc/12503/cmdline
sleep
1000
$ strings /proc/12503/cmdline
sleep
1000

进程目录中还包含符号链接!
例如,cwd 指向当前工作目录,而 exe 指向正在执行的二进制文件。

1
2
3
$ ls -l /proc/12503/{cwd,exe}
lrwxrwxrwx 1 ubuntu ubuntu 0 Jul 6 10:10 /proc/12503/cwd -> /home/ubuntu
lrwxrwxrwx 1 ubuntu ubuntu 0 Jul 6 10:10 /proc/12503/exe -> /bin/sleep

这就是 htoptopps 及其他诊断工具获取进程详细信息的方式:它们直接读取 /proc/<pid>/<file>

进程树

当你启动新进程时,启动它的进程被称为“父进程”,而新启动的进程则成为它的“子进程”。
这种关系形成了树状结构 (进程树) 。
如果在 htop 中按下 F5 键,就能直观地看到这种进程层级关系。
你也可以在使用 ps 命令时加上 f 参数:

1
2
3
4
$ ps f
PID TTY STAT TIME COMMAND
12472 pts/0 Ss 0:00 -bash
12684 pts/0 R+ 0:00 \_ ps f

或者使用 pstree 命令:

1
2
3
4
5
6
7
8
9
10
$ pstree -a
init
├─atd
├─cron
├─sshd -D
│ └─sshd
│ └─sshd
│ └─bash
│ └─pstree -a
...

如果你曾疑惑为什么总能看到 bashsshd 作为某些进程的父进程,原因就在于此。
举例来说,当你在 bash shell 中执行 date 命令时,会发生以下情况:

bash 会创建自身副本的新进程 (使用 fork 系统调用) 。
接着,它将 /bin/date 这一可执行文件加载到内存中 (使用 exec 系统调用) 。
作为父进程的 bash 会等待子进程执行完毕并退出。

因此,系统启动时最先运行了 PID 为 1 的 /sbin/init,它随后衍生出 SSH 守护进程 sshd
当你连接到这台服务器时,sshd 会为你的会话衍生出一个进程,该进程继而启动了 bash shell。
当我想同时查看所有线程时,我非常喜欢在 htop 中使用这种树状视图。

内核线程

前面在介绍 htop 界面时,我们提到了可以按下 Shift+K 来查看“内核线程 (kernel threads)”。
其实不仅是在 htop 里,当你运行 ps 命令时,也经常会看到一些被中括号 [] 包围的特殊进程,比如 [kthreadd] 或是 [kworker/0:1]

1
2
3
4
5
6
7
$ ps aux | head -n 6
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 20500 12908 ? Ss Jul05 0:01 /usr/lib/systemd/systemd --switched-root --system --deserialize=52
root 2 0.0 0.0 0 0 ? S Jul05 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S Jul05 0:00 [pool_workqueue_release]
root 4 0.0 0.0 0 0 ? I< Jul05 0:00 [kworker/R-rcu_gp]
root 5 0.0 0.0 0 0 ? I< Jul05 0:00 [kworker/R-sync_wq]

这些并不是普通的“用户进程”,而是由 Linux 内核直接创建和管理的线程。它们专门在后台默默地处理系统底层的脏活累活,比如把缓存数据刷新到磁盘、响应硬件中断、或者回收内存等。
如果你仔细观察上面的输出,会发现这些内核线程的 VSZ(虚拟内存)和 RSS(常驻内存)列全都是 0。这是因为它们完全运行在内核空间,根本不占用用户空间的地址和物理内存。

结合上一节讲过的“进程树”,我们知道 PID 为 1init(或 systemd)是系统中所有普通用户进程的祖师爷;而 PID 为 2kthreadd 则是所有这些内核线程的共同祖先。所有的 kworker 等内核级任务,都是由 kthreadd 衍生出来的。

进程所属用户

每个进程都归属于某个特定用户,而用户在系统中是通过数字 ID 来标识的。

1
2
3
4
$ sleep 1000 &
[1] 2045
$ grep Uid /proc/2045/status
Uid: 1000 1000 1000 1000

你可以使用 id 命令通过数字 ID 查找对应的用户名:

1
2
$ id 1000
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm)

实际上,id 命令是从 /etc/passwd/etc/group 文件中获取这些信息的。

1
2
3
4
5
6
7
8
$ strace -e open id 1000
...
open("/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3
open("/lib/x86_64-linux-gnu/libnss_compat.so.2", O_RDONLY|O_CLOEXEC) = 3
open("/lib/x86_64-linux-gnu/libnss_files.so.2", O_RDONLY|O_CLOEXEC) = 3
open("/etc/passwd", O_RDONLY|O_CLOEXEC) = 3
open("/etc/group", O_RDONLY|O_CLOEXEC) = 3
...

这是因为 NSS(Name Service Switch,名称服务开关)的配置文件 /etc/nsswitch.conf 指定了系统应使用这些文件来解析名称。

1
2
3
4
5
$ head -n 9 /etc/nsswitch.conf
# ...
passwd: compat
group: compat
shadow: compat

其中 compat(兼容模式)的含义与 files 类似,只是它允许使用一些特殊的条目。
files 表示用户数据库存储在本地文件中(由 libnss_files.so 加载)。
当然,你也可以将用户信息存储在其他数据库或服务中,比如使用 LDAP(轻量级目录访问协议)。
/etc/passwd/etc/group 都是纯文本文件,作用是将数字 ID 映射为人类可读的名称。

1
2
3
4
5
6
7
8
$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
$ cat /etc/group
root:x:0:
adm:x:4:syslog,ubuntu
ubuntu:x:1000:

等等,既然叫 passwd(密码),那密码存在哪儿呢?
实际上,它们被存放在 /etc/shadow 文件中。

1
2
3
4
$ sudo cat /etc/shadow
root:$6$mS9o0QBw$P1ojPSTexV2PQ.Z./rqzYex.k7TJE2nVeIVL0dql/:17126:0:99999:7:::
daemon:*:17109:0:99999:7:::
ubuntu:$6$GIfdqlb/$ms9ZoxfrUq455K6UbmHyOfz7DVf7TWaveyHcp.:17126:0:99999:7:::

这串像乱码一样的东西是什么?

$6$ 代表所使用的密码哈希算法,这里指的是 sha512
紧随其后的是随机生成的“盐值”(salt),用于防御彩虹表(rainbow table)攻击。
最后一段则是你的密码加“盐”后计算出的哈希值。

当你运行一个程序时,它通常会以你的用户身份执行,即便该可执行文件本身并不属于你。
如果你想以 root 或其他用户的身份运行程序,就需要用到 sudo 命令。

1
2
3
4
5
6
7
8
$ id
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm)
$ sudo id
uid=0(root) gid=0(root) groups=0(root)
$ sudo -u ubuntu id
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm)
$ sudo -u daemon id
uid=1(daemon) gid=1(daemon) groups=1(daemon)

但如果你想切换到其他用户来执行多条命令呢?
可以使用 sudo bashsudo -u user bash,这样你就能以该用户的身份打开一个 shell。
如果你不想频繁被提示输入 root 密码,可以将当前用户添加到 /etc/sudoers 文件中来禁用密码验证。
我们来试一下:

1
2
$ echo "$USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
-bash: /etc/sudoers: Permission denied

对哦,只有 root 才有权限修改这个文件。

1
2
$ sudo echo "$USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
-bash: /etc/sudoers: Permission denied

搞什么鬼?
实际上,虽然你以 root 身份执行了 echo 命令,但是将输出追加(>>)到 /etc/sudoers 文件的操作,依然是由当前的普通用户 shell 执行的。
通常有两种方法可以绕过这个问题:

echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee -a /etc/sudoers
sudo bash -c "echo '$USER ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers"

第一种方法中,tee -a 会将其标准输入追加到文件中,而我们是以 root 身份执行它的。
第二种方法中,我们以 root 身份运行 bash 并要求它执行一条命令(-c),这样整条命令都在 root 权限下执行。
这里请留意双引号(")和单引号(')的小细节,它们决定了 $USER 变量会在何时被展开。

如果你查看 /etc/sudoers 文件,会发现开头写着:

1
2
3
4
$ sudo head -n 3 /etc/sudoers
#
# This file MUST be edited with the 'visudo' command as root.
#

哎呀。
这是一个非常有用的警告,提示你应该使用 sudo visudo 来编辑此文件。
它会在保存前校验文件内容,防止出现语法错误。
如果你不使用 visudo 且不小心改错了文件,很可能会导致你彻底失去 sudo 权限。
这意味着,你连修正错误的权限都没有了!

假设你想修改密码,你可以使用 passwd 命令。
正如前文所述,它会将新密码保存到 /etc/shadow 文件中。
这个文件非常敏感,只有 root 才拥有写入权限:

1
2
$ ls -l /etc/shadow
-rw-r----- 1 root shadow 1122 Nov 27 18:52 /etc/shadow

那么,由普通用户运行的 passwd 程序,怎么可能向受保护的文件写入数据呢?
我前面提到过,当你启动一个进程时,它是以你的身份运行的,哪怕可执行文件的所有者是其他用户。
不过,你可以通过修改文件权限来改变这种默认行为。让我们看看:

1
2
$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 54256 Mar 29 2016 /usr/bin/passwd

注意那个字母 s。它是通过 sudo chmod u+s /usr/bin/passwd 命令设置的。
这意味着该可执行程序在运行时,将会以文件所有者的身份(本例中为 root)启动。
你可以使用命令 find /bin -user root -perm -u+s 找出所有这类设置了 setuid(设置用户 ID)的可执行文件。
同理,你也可以对用户组进行类似的操作(g+s,即设置组 ID)。

进程状态

接下来我们看看 htop 中的进程状态列,它通常用单个字母 S (State) 表示。
以下是所有可能的状态值:

1
2
3
4
5
6
7
R    运行中或可运行(处于运行队列中)
S 可中断休眠(等待事件完成)
D 不可中断休眠(通常在等待 I/O)
Z 僵尸进程,已终止但尚未被父进程回收
T 被作业控制信号停止
t 在追踪期间被调试器停止
X 已死亡(理论上不可见)

我已经按照出现的频率由高到低进行了排序。
注意,当你运行 ps 命令时,它还会显示诸如 SsR+Ss+ 等子状态。

1
2
3
4
5
6
7
$ ps x
PID TTY STAT TIME COMMAND
1688 ? Ss 0:00 /lib/systemd/systemd --user
1689 ? S 0:00 (sd-pam)
1724 ? S 0:01 sshd: vagrant@pts/0
1725 pts/0 Ss 0:00 -bash
2628 pts/0 R+ 0:00 ps x

R - 运行中或可运行 (处于运行队列)

处于此状态的进程当前正在执行,或者正排在运行队列(run queue)中等待 CPU 时间。
“运行”究竟意味着什么?
当你把编写的源代码编译后,得到的机器码就是 CPU 的指令集,并保存在可执行文件中。
当你启动这个程序时,它会被加载进内存,随后 CPU 开始执行这些指令。
简而言之,这意味着 CPU 正在物理层面执行指令,或者说正在马不停蹄地处理数据。

S - 可中断休眠 (等待事件完成)

这表示该进程的代码指令并未在 CPU 上执行。
相反,进程正在等待某个事件或条件的发生。
当预期事件发生时,内核会将其状态唤醒为“运行中”。
coreutils 中的 sleep 命令就是一个很好的例子。
它会让进程休眠指定的(大约)秒数。

1
2
3
4
5
6
7
$ sleep 1000 &
[1] 10089
$ ps f
PID TTY STAT TIME COMMAND
3514 pts/1 Ss 0:00 -bash
10089 pts/1 S 0:00 \_ sleep 1000
10094 pts/1 R+ 0:00 \_ ps f

这就是所谓的“可中断休眠”(Interruptible sleep)。我们该如何中断它呢?
可以通过发送信号(signal)。
htop 中,你可以按下 F9,然后从左侧菜单选择一个信号发送给进程。
发送信号通常也被称为 kill(杀死),因为 kill 正是用于向进程发送信号的系统调用。
系统中有一个 /bin/kill 程序,允许我们在用户态发起这个系统调用;它默认发送的信号是 TERM(终止),
该信号会请求进程终止执行,也就是试图“杀死”它。
信号本质上只是一个数字。但数字很难记,所以我们给它们起了名字。
信号名通常全大写,并且常带有 SIG 前缀。
一些常见的信号包括 INTKILLSTOPCONTHUP
现在,我们通过发送 INT 信号(即 SIGINT、数字 2,或称“终端中断”信号)来打断刚才的休眠进程。

1
2
$ kill -INT 10089
[1]+ Interrupt sleep 1000

当你在键盘上按下 CTRL+C 时,发生的过程如出一辙。bash 会向前台进程发送 SIGINT 信号,与我们刚刚手动执行的操作完全一样。
顺便提一句,虽然大多数系统里都有 /bin/kill 程序,但在 bash 中,kill 实际上是一个内置命令。
为什么呢?这是为了确保当系统达到进程数上限时,你依然有能力去“杀死”进程。
以下这些命令的作用完全相同:

kill -INT 10089
kill -2 10089
/bin/kill -2 10089

另一个必须了解的信号是 SIGKILL(数字 9)。你大概曾用它强制清理过那些对你狂按 CTRL+C 无动于衷的顽固进程。
当你编写程序时,可以设置“信号处理函数”(signal handlers),当进程收到特定信号时,就会调用这些函数。
换言之,你可以捕获某个信号并执行相应的操作,比如清理资源并优雅地退出程序。
因此,向进程发送 SIGINT(用户希望中断进程)或 SIGTERM(用户希望终止进程),并不意味着进程就一定会终止。
在运行 Python 脚本时,你可能见过下面这种异常:

1
2
3
4
5
$ python -c 'import sys; sys.stdin.read()'
^C
Traceback (most recent call last):
File "<string>", line 1, in <module>
KeyboardInterrupt

通过发送 KILL 信号,你可以命令内核强制终止进程,且不给它任何捕获信号或做出响应的机会:

1
2
3
4
$ sleep 1000 &
[1] 2658
$ kill -9 2658
[1]+ Killed sleep 1000

D - 不可中断休眠 (通常在等待 I/O)

与可中断休眠不同,你无法使用信号唤醒处于该状态的进程。
这也是为什么很多人害怕看到这个状态的原因。
你无法杀死这样的进程,因为“杀死”本质上也就是向它发送 SIGKILL 信号,而它对此免疫。
当进程必须在不受干扰的情况下等待,或者预期某事件能瞬间完成时,便会进入这种状态。
比如从磁盘读写数据。
这通常只需几分之一秒就能完成。
关于这点,有一个 StackOverflow 上的绝佳回答

不可中断进程通常是在页面错误(page fault)发生后等待 I/O。
进程或任务在这种状态下不能被中断,因为它无法处理任何信号;
如果强行中断,只会引发另一次页面错误,从而又退回到最初的状态。

换句话说,如果你使用的是网络文件系统(NFS),而且读写操作耗时较长,就有可能出现这种状况。
根据我的经验,这也可能意味着某些进程正在进行大量的内存交换(swapping)操作,
表明你系统的可用内存严重不足。
让我们试着制造一个不可中断休眠(Uninterruptible sleep)的进程。
8.8.8.8 是 Google 提供的公共 DNS 服务器。
他们并没有在那里开放 NFS。
但这阻止不了我们去尝试挂载它。

1
2
3
4
$ sudo mount 8.8.8.8:/tmp /tmp &
[1] 12646
$ sudo ps x | grep mount.nfs
12648 pts/1 D 0:00 /sbin/mount.nfs 8.8.8.8:/tmp /tmp -o rw

要如何找出幕后黑手?用 strace
让我们用 strace 来跟踪上面 ps 输出中的那条命令。

1
2
3
$ sudo strace /sbin/mount.nfs 8.8.8.8:/tmp /tmp -o rw
...
mount("8.8.8.8:/tmp", "/tmp", "nfs", 0, ...

所以,是 mount 这个系统调用阻塞了该进程。
如果你好奇的话,可以在运行 mount 时加上 intr 选项,让其变为可中断的:
sudo mount 8.8.8.8:/tmp /tmp -o intr

Z - 僵尸进程 (已终止但尚未被父进程回收)

当一个子进程结束运行,而其父进程未能回收它(例如未调用 wait() 系统调用)时,这个子进程就会变成僵尸进程(Zombie process)。相反,如果父进程先于子进程退出,留下的未退出子进程则会变成孤儿进程(Orphan process),并由 init 进程接管。

如果僵尸进程只存在很短的时间,这属于完全正常的现象。
但如果僵尸进程长时间存在,则可能表明程序中存在 bug。
僵尸进程不消耗内存,只占用一个进程 ID。
你无法 kill 一个僵尸进程。
你可以“礼貌地”要求父进程去回收它的僵尸子进程(发送 SIGCHLD 信号)。
或者你也可以直接 kill 掉僵尸进程的父进程,这样就能将父进程及其相关的僵尸进程一并清理掉。

接下来我写一段 C 代码来演示这一点。
这是我们的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
printf("Running\n");

int pid = fork();

if (pid == 0) {
printf("I am the child process\n");
printf("The child process is exiting now\n");
exit(0);
} else {
printf("I am the parent process\n");
printf("The parent process is sleeping now\n");
sleep(20);
printf("The parent process is finished\n");
}

return 0;
}

我们来安装 GCC (GNU C 编译器):

1
sudo apt install -y gcc

编译并运行它:

1
2
gcc zombie.c -o zombie
./zombie

查看进程树(Process tree):

1
2
3
4
5
6
7
$ ps f
PID TTY STAT TIME COMMAND
3514 pts/1 Ss 0:00 -bash
7911 pts/1 S+ 0:00 \_ ./zombie
7912 pts/1 Z+ 0:00 \_ [zombie] <defunct>
1317 pts/0 Ss 0:00 -bash
7913 pts/0 R+ 0:00 \_ ps f

看,我们得到了一个僵尸进程!
当父进程结束时,这个僵尸进程也就随之消失了。

1
2
3
4
5
$ ps f
PID TTY STAT TIME COMMAND
3514 pts/1 Ss+ 0:00 -bash
1317 pts/0 Ss 0:00 -bash
7914 pts/0 R+ 0:00 \_ ps f

如果你将 sleep(20) 替换为 while (true) ;,那么僵尸进程就会一直存在。
调用 exit 后,与该进程相关的所有内存和资源都会被内核回收,以便其他进程使用。
那么,为什么要保留这些僵尸进程呢?
因为父进程可以选择通过 wait 系统调用(通常在信号处理程序中)来获取子进程的退出状态码。
如果父进程当时正在休眠,就需要等它醒来。
为什么不直接强制唤醒并清理它呢?
这就像你不能因为厌烦了你的孩子就把他扔进垃圾桶一样。
这会导致系统状态混乱等糟糕的后果。

T - 收到作业控制信号而停止 (stopped by job control signal)

我打开了两个终端窗口,可以使用 ps u 查看当前用户的进程。

1
2
3
4
5
$ ps u
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
ubuntu 1317 0.0 0.9 21420 4992 pts/0 Ss+ Jun07 0:00 -bash
ubuntu 3514 1.5 1.0 21420 5196 pts/1 Ss 07:28 0:00 -bash
ubuntu 3528 0.0 0.6 36084 3316 pts/1 R+ 07:28 0:00 ps u

在下面的输出中,我将省略 -bashps u 进程以保持简洁。
现在,在一个终端窗口中运行 cat /dev/urandom > /dev/null
它的状态是 R+,表示它正在运行。

1
2
3
$ ps u
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
ubuntu 3540 103 0.1 6168 688 pts/1 R+ 07:29 0:04 cat /dev/urandom

按下 CTRL+Z 停止该进程。

1
2
3
4
5
$ # CTRL+Z
[1]+ Stopped cat /dev/urandom > /dev/null
$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
ubuntu 3540 86.8 0.1 6168 688 pts/1 T 07:29 0:15 cat /dev/urandom

现在它的状态变成了 T
在同一个终端中运行 fg 即可恢复其运行。
另一种停止这类进程的方法是使用 kill 命令向该进程发送 STOP 信号。
要恢复该进程的执行,可以向它发送 CONT 信号。

t - 在追踪期间被调试器停止 (stopped by debugger during the tracing)

首先,安装 GDB (GNU 调试器):

1
sudo apt install -y gdb

运行一个程序,监听 1234 端口上的网络连接。

1
2
$ nc -l 1234 &
[1] 3905

此时它处于休眠状态,意味着正在等待来自网络的数据。

1
2
3
$ ps u
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
ubuntu 3905 0.0 0.1 9184 896 pts/0 S 07:41 0:00 nc -l 1234

运行调试器,并将其附加到 ID 为 3905 的进程上。

1
sudo gdb -p 3905

你会看到该进程的状态变成了 t,这表示它正在被调试器追踪。

1
2
3
$ ps u
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
ubuntu 3905 0.0 0.1 9184 896 pts/0 t 07:41 0:00 nc -l 1234

细分状态的附加修饰符

在这一节最开始展示的 ps 输出中,你可能已经注意到了诸如 SsR+Ss+ 等稍显复杂的组合状态。
其实,第一个大写字母代表的就是刚才讲过的主要状态(R、S、D、Z、T、t),而跟在它后面的那些小写字母或符号,则是所谓的 BSD 风格附加修饰符。

以下是几个最常见的修饰符及其含义:

  • s会话首进程 (Session leader)。当你通过 SSH 登录后,内核会为你创建一个会话,而你进入的那个 -bash 就是这个会话的“老大”(首进程)。这就是为什么你总是看到 bash 进程的状态带有 s(如 Ss)。
  • +位于前台进程组 (Foreground process group)。这意味着该进程目前正掌控着你的终端。当你按下 CTRL+C 时,终端产生的信号只会发给带有 + 号的进程组。比如你运行的 ps x 命令正在前台执行,它的状态就是 R+
  • l多线程 (Multi-threaded)。如果一个程序(比如 htop 或者一个 Java 应用)使用 clone 调用派生了多个内部线程,就会带有 l
  • <N:分别代表高优先级(不友善)和低优先级(友善),这与我们在下一节将要介绍的“进程谦让度 (Niceness)”直接相关。

结合起来看,-bashSs 意思是它正处于休眠等待你敲击键盘 (S),且是当前会话的首进程 (s);而你敲下的命令 ps f 状态为 R+,表示它正在 CPU 上奔跑 (R),并占据着你的前台终端 (+)。

Process time (进程时间)

Linux 是一个多任务操作系统,这意味着即使只有一个 CPU,也能同时运行多个进程。
例如,你可以通过 SSH 连接服务器并查看 htop 的输出,同时你的 Web 服务器还在通过互联网为读者提供博客内容。
既然单个 CPU 一次只能执行一条指令,那这究竟是如何实现的呢?
答案是:分时(time sharing)。
一个进程会运行一小段时间,然后被暂停,让其他等待中的进程轮流运行。进程单次运行的时间被称为“时间片”(time slice)。
时间片通常只有几毫秒,因此在系统负载不高时,你通常察觉不到它的切换。
(去探究一下 Linux 中时间片具体有多长,是一件非常有意思的事情。)
这也有助于解释为什么平均负载(Load average)反映的是正在运行的进程平均数。
如果你只有一个 CPU 核心,且平均负载为 1.0,说明 CPU 的利用率已达 100%。
如果平均负载高于 1.0,意味着排队等待运行的进程数超过了 CPU 的处理能力,此时你可能会感到系统卡顿或延迟。
如果负载低于 1.0,则意味着 CPU 有时处于空闲状态,无事可做。
这也解释了为什么一个实际运行了 10 秒的进程,其显示的运行时间有时会略多或略少于精确的 10 秒。

Process niceness and priority (进程谦让度与优先级)

当等待运行的任务数多于可用的 CPU 核心数时,系统就必须决定哪些任务优先执行,哪些任务继续排队。
这正是任务调度器(task scheduler)的职责。
Linux 内核的调度器会根据调度算法,从运行队列(run queue)中挑选下一个要执行的进程。
虽然你通常无法直接控制调度器,但可以告诉它哪些进程更重要,调度器会将其作为参考。
谦让度(niceness,简称 NI)是用户空间中进程优先级的指标,范围从 -20(最高优先级)到 19(最低优先级)。
这个概念乍看有些违反直觉,但你可以这样理解:一个越“nice”(友善)的进程,就越愿意为那些不那么“nice”的进程让路。
因此,进程的 nice 值越高,它让出的 CPU 时间就越多。
根据我在 StackOverflow 等网站上查阅的资料,谦让度每提高 1 个级别,进程大约能多获得 10% 的 CPU 时间。
优先级(priority,简称 PRI)则是 Linux 内核在内核空间中使用的实际优先级。
它的范围是 0 到 139,其中 0 到 99 保留给实时任务,100 到 139 则分配给普通的用户任务。
你可以修改进程的谦让度,内核调度时会将其考虑在内,但你无法直接修改内核优先级。
谦让度(nice 值)与用户优先级之间的换算关系为:

1
PR = 20 + NI

由此可见,PR = 20 + (-20 到 +19) 的计算结果在 0 到 39 之间,这正好映射了 100 到 139 这个用户优先级的数值范围。
你可以在启动进程时指定它的谦让度:

1
nice -n niceness program

对于已经在运行的程序,可以使用 renice 动态修改:

1
renice -n niceness -p PID

在 CPU 使用率指示条中,不同颜色代表:

蓝色:低优先级线程(nice > 0)
绿色:正常优先级线程
红色:内核线程

http://askubuntu.com/questions/656771/process-niceness-vs-priority

Memory usage - VIRT/RES/SHR/MEM (内存使用情况)

每个进程都会产生一种错觉,认为自己独占了所有内存,这都要归功于虚拟内存技术。
进程无法直接访问物理内存。相反,它拥有自己独立的虚拟地址空间,内核负责将这些虚拟地址映射到实际的物理内存地址,或者映射到磁盘上。
这就解释了为什么所有进程显示的内存占用总和,有时会超过系统实际安装的物理内存。
需要强调的是,准确衡量一个进程到底占用了多少内存并非易事。
例如,共享库或内存映射文件(mmap)是否应该计算在内?
尽管如此,内核还是提供了相关指标,htop 也将它们直观地展示了出来,帮助你评估进程的内存开销。
在内存使用率(MEM%)指示条中,各颜色的含义如下:

绿色:已用内存
蓝色:缓冲区(Buffers)
橙色:缓存(Cache)

VIRT/VSZ - Virtual Image (虚拟内存映像)

任务(Task)所使用的虚拟内存总量。
它包括程序代码、数据、共享库,以及已被换出(swapped out)到磁盘的内存页,甚至包含已映射但尚未真正分配物理内存的页面。

VIRT 代表虚拟内存总使用量。
它几乎涵盖了所有内容,包括内存映射文件。
如果一个程序申请了 1 GB 内存,但实际只使用了 1 MB,其 VIRT 仍会显示为 1 GB。
如果程序通过 mmap 映射了一个 1 GB 的文件却从未访问,VIRT 同样是 1 GB。
因此,在多数情况下,这个指标的参考价值有限。

RES/RSS - Resident size (常驻内存大小)

任务实际占用且未被换出的物理内存大小。

RES 代表常驻内存,即当前真正驻留在物理内存中的部分。
相比 VIRTRES 能更准确地反映进程实际占用的内存,但需注意:

它不包含被换出到交换空间(swap)的内存。
它可能包含了与其他进程共享的内存。

如果一个进程占用了 1 GB 内存,然后调用 fork() 派生出子进程,
那么父子进程的 RES 都会显示为 1 GB。
但得益于 Linux 的写时复制(copy-on-write)机制,它们在物理层面实际上仍共用那 1 GB 内存。

SHR - Shared Mem size (共享内存大小)

任务所使用的共享内存大小(SHR)。
它表示该进程占用的内存中,有多少是可能与其他进程共享的。

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
printf("Started\n");
sleep(10);

size_t memory = 10 * 1024 * 1024; // 10 MB
char* buffer = malloc(memory);
printf("Allocated 10M\n");
sleep(10);

for (size_t i = 0; i < memory/2; i++)
buffer[i] = 42;
printf("Used 5M\n");
sleep(10);

int pid = fork();
printf("Forked\n");
sleep(10);

if (pid != 0) {
for (size_t i = memory/2; i < memory/2 + memory/5; i++)
buffer[i] = 42;
printf("Child used extra 2M\n");
}
sleep(10);

return 0;
}
1
2
3
fallocate -l 10G
gcc -std=c99 mem.c -o mem
./mem
1
2
3
4
5
6
7
8
Process  Message               VIRT  RES SHR
main Started 4200 680 604
main Allocated 10M 14444 680 604
main Used 5M 14444 6168 1116
main Forked 14444 6168 1116
child Forked 14444 5216 0
main Child used extra 2M 8252 1116
child Child used extra 2M 5216 0

观察上面的输出,我们可以得出几个关键结论,这也直观展示了 Linux 内存管理的三个特点:

  1. 只申请不分配(Allocated 10M):程序申请 10MB 内存后,只有虚拟内存(VIRT)增加了约 10MB(从 4200 增至 14444),而常驻内存(RES)几乎没变。这是因为操作系统采用了“延迟分配(Lazy Allocation)”策略:直到真正写入数据时,才会分配底层的物理内存。
  2. 按需分配物理内存(Used 5M):当程序向其中 5MB 的内存写入数据后,物理内存才真正落地,因此常驻内存(RES)随之增加了约 5000KB。
  3. 进程派生(Forked)与隔离:执行 fork 后,子进程瞬间继承了父进程的虚拟内存空间(VIRT 同为 14444)。但直到其中一方尝试修改剩余那 2MB 独立内存时,父子进程的物理内存才开始发生实质性的隔离。

这种高效派生的核心在于:fork() 时 Linux 利用了写时复制(Copy-on-Write)机制。虽然虚拟内存(VIRT)被完整映射,但初始状态下子进程与父进程共享同一块物理内存(可参考 SHR 列的数值变化)。只有当任何一方尝试修改这些共享页面时,内核才会为修改方分配独立的物理内存页,此时各自的常驻内存(RES)才会增加。这种机制使得进程的创建极其轻量和高效。

MEM% - 内存使用率

任务(Task)当前占用物理内存的百分比。

它是将常驻内存(RES)除以系统总内存(RAM)计算得出的。
如果 RES400M,而系统总内存为 8 GB,那么 MEM% 就是 400/8192*100 = 4.88%

htop 顶栏进度条颜色解析

当你第一眼看到 htop 时,屏幕顶部的彩色进度条最为醒目。这些颜色不仅是为了视觉区分,更直观地反映了系统资源的去向。

CPU 进度条颜色含义:

  • 蓝色 (Blue):低优先级进程(nice 值大于 0 的进程)消耗的 CPU 时间。
  • 绿色 (Green):正常优先级的用户进程消耗的 CPU 时间。
  • 红色 (Red):内核线程及内核态开销消耗的 CPU 时间。当红条占据大半江山时,通常暗示系统正忙于处理大量的系统调用、中断或硬件 I/O。

Memory (内存) 进度条颜色含义:

  • 绿色 (Green):真正被进程占用的已用内存 (Used memory)。
  • 蓝色 (Blue):缓冲区 (Buffers),用于缓存磁盘块设备的元数据等。
  • 橙色 (Orange / Yellow):页缓存 (Cache),是 Linux 为了加速磁盘读取而将文件内容缓存在内存中的部分。不用担心橙色占据了大量内存,当真正需要物理内存时,内核会自动释放它们。

进程说明

让我们看看 htop 截图中的进程(Process)列表。
你真的需要运行这么多进程吗?
以下是我的研究笔记。环境是一台全新安装了 Ubuntu Server 16.04.1 LTS x64 的 Digital Ocean Droplet(一种云服务器),我梳理了它在刚启动时运行的所有默认进程。

htop截图

/sbin/init

/sbin/init(通常简称为 init)负责统筹后续的引导过程并初始化用户环境。
当 init 启动后,它便成为系统上所有自动启动进程的直接或间接父进程。

它是 systemd 吗?

1
2
$ dpkg -S /sbin/init
systemd-sysv: /sbin/init

没错,就是它。
如果尝试杀掉它会发生什么?
什么也不会发生。因为在 Linux 内核中,PID 1(init / systemd)拥有特权地位。内核会自动拦截并忽略发送给 PID 1 的 SIGKILLSIGTERM 等致命信号,以防止这一核心进程意外终止而导致系统崩溃。它只响应由其自身捕获并预设好处理逻辑的特定信号(如关机或重启指令)。

https://wiki.ubuntu.com/SystemdForUpstartUsers
https://www.centos.org/docs/5/html/5.1/Installation_Guide/s2-boot-init-shutdown-init.html

/lib/systemd/systemd-journald

systemd-journald 是一个专门收集和存储日志数据的系统服务。它接收来自系统各处的日志信息,并将其整理为带索引的结构化数据。

换言之:

journald 最大的变革在于,它用专门针对日志优化的二进制格式取代了传统的纯文本日志文件。这种格式能让系统管理员极其高效地检索特定消息,相当于把部分企业级集中式日志数据库的强悍功能,下放到了单机系统中。

你需要使用 journalctl 命令来查询这些日志:

journalctl _COMM=sshd 查看由 sshd 产生的日志
journalctl _COMM=sshd -o json-pretty 以美观的 JSON 格式输出 sshd 日志
journalctl --since "2015-01-10" --until "2015-01-11 03:00"
journalctl --since 09:00 --until "1 hour ago"
journalctl --since yesterday
journalctl -b 查看本次开机以来的所有日志
journalctl -f 实时追踪最新日志(类似于 tail -f
journalctl --disk-usage
journalctl --vacuum-size=1G

这套工具非常强大。
目前看来,你无法彻底移除或禁用该服务,只能通过配置关闭它的日志记录功能。

https://www.freedesktop.org/software/systemd/man/systemd-journald.service.html
https://www.digitalocean.com/community/tutorials/how-to-use-journalctl-to-view-and-manipulate-systemd-logs
https://www.loggly.com/blog/why-journald/
https://ask.fedoraproject.org/en/question/63985/how-to-correctly-disable-journald/

/sbin/lvmetad -f

lvmetad 守护进程用于缓存 LVM 的元数据,使得 LVM 命令行工具能直接从缓存中读取状态,而无需重新扫描所有物理磁盘。
这种缓存机制非常关键,因为频繁扫描磁盘不仅缓慢,还可能干扰系统的正常 I/O 操作。

那 LVM(逻辑卷管理,Logical Volume Manager)又是什么呢?

简单来说,你可以把 LVM 理解为“动态分区”。它允许你在 Linux 运行时,直接通过命令行创建、扩容或缩减“分区”(在 LVM 中称为“逻辑卷”)。系统内核能即时识别这些变更,完全无需重启服务器。

显然,如果你的磁盘正在使用 LVM 架构,就绝对不能动这个服务。如果你确认没有使用,可以卸载:

1
2
lvscan
sudo apt remove lvm2 -y --purge

http://manpages.ubuntu.com/manpages/xenial/man8/lvmetad.8.html
http://askubuntu.com/questions/3596/what-is-lvm-and-what-is-it-used-for

/lib/systemd/udevd

systemd-udevd 监听来自内核的设备事件(uevent)。每当硬件状态发生变化时,它会匹配预设的 udev 规则并执行相应的指令。

udev 是 Linux 现代的设备管理器。作为 devfsd 和 hotplug 的后继者,它负责动态管理 /dev 目录下的设备节点文件。

简而言之,它负责维护 /dev 目录。
如果是裸机物理服务器,它必不可少;但在高度简化的虚拟服务器上,它的存在感不强,但我建议保留。

https://www.freedesktop.org/software/systemd/man/systemd-udevd.service.html
https://wiki.archlinux.org/index.php/udev

/lib/systemd/timesyncd

systemd-timesyncd 是一个轻量级系统服务,专门用于通过网络时间协议(NTP)与远程服务器同步本地系统时钟。

它实际上取代了老旧的 ntpd 服务。

1
2
3
4
5
6
7
8
$ timedatectl status
Local time: Fri 2016-08-26 11:38:21 UTC
Universal time: Fri 2016-08-26 11:38:21 UTC
RTC time: Fri 2016-08-26 11:38:20
Time zone: Etc/UTC (UTC, +0000)
Network time on: yes
NTP synchronized: yes
RTC in local TZ: no

让我们检查一下当前服务器监听的端口:

1
2
3
4
5
$ sudo netstat -nlput
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 2178/sshd
tcp6 0 0 :::22 :::* LISTEN 2178/sshd

非常干净!它甚至不需要一直监听 UDP 端口。
回想以前在 Ubuntu 14.04 上使用 ntpd 时,端口列表简直不堪入目:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ sudo apt-get install ntp -y
$ sudo netstat -nlput
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1380/sshd
tcp6 0 0 :::22 :::* LISTEN 1380/sshd
udp 0 0 10.19.0.6:123 0.0.0.0:* 2377/ntpd
udp 0 0 139.59.256.256:123 0.0.0.0:* 2377/ntpd
udp 0 0 127.0.0.1:123 0.0.0.0:* 2377/ntpd
udp 0 0 0.0.0.0:123 0.0.0.0:* 2377/ntpd
udp6 0 0 fe80::601:6aff:fxxx:123 :::* 2377/ntpd
udp6 0 0 ::1:123 :::* 2377/ntpd
udp6 0 0 :::123 :::* 2377/ntpd

简直惨不忍睹。

https://www.freedesktop.org/software/systemd/man/systemd-timesyncd.service.html
https://wiki.archlinux.org/index.php/systemd-timesyncd

/usr/sbin/atd -f

atd - 负责执行排队等待的任务。它会在后台静默运行,处理由 at 命令提交的计划任务。

atbatch 命令可以从标准输入或指定文件中读取指令,并在将来的指定时间执行。

cron 专门处理周期性任务不同,at 只负责“一次性”的定时任务。

1
2
3
4
5
6
$ echo "touch /tmp/yolo.txt" | at now + 1 minute
job 1 at Fri Aug 26 10:44:00 2016
$ atq
1 Fri Aug 26 10:44:00 2016 a root
$ sleep 60 && ls /tmp/yolo.txt
/tmp/yolo.txt

说实话,这还是我头一次在实战中用到它。如果没有这种临时排期需求,完全可以删掉:

1
sudo apt remove at -y --purge

http://manpages.ubuntu.com/manpages/xenial/man8/atd.8.html
http://manpages.ubuntu.com/manpages/xenial/man1/at.1.html
http://askubuntu.com/questions/162439/why-does-ubuntu-server-run-both-cron-and-atd

/usr/lib/snapd/snapd

Snappy Ubuntu Core 是一个支持事务性更新的全新 Ubuntu 分支版本。它提供了一个极致精简的服务器镜像,底层库与常规 Ubuntu 保持一致,但采用了一套更简单直接的应用程序分发机制。

这具体意味着什么?

近日,来自各大 Linux 发行版和科技公司的开发者们宣布,将联合推广通用的 “snap” 软件包格式。该格式允许将程序及其依赖打包成单一的二进制文件,从而在任何 Linux 桌面、服务器、云端甚至物联网设备上安全、完美地运行。

简而言之,它是 .deb 格式的一种现代化替代方案。开发者会将程序连同所有依赖项打包进一个独立的 snap 容器中分发。
就我个人而言,我从未在服务器环境中使用 snap 部署过任何应用。如果不需要,可以直接干掉:

1
sudo apt remove snapd -y --purge

https://developer.ubuntu.com/en/snappy/
https://insights.ubuntu.com/2016/06/14/universal-snap-packages-launch-on-multiple-linux-distros/

/usr/bin/dbus-daemon

D-Bus(或 DBus)是一种进程间通信(IPC)与远程过程调用(RPC)总线机制。它允许同一台机器上并发运行的多个程序进行高效的数据交换和指令传递。

在传统的认知里,它主要服务于桌面环境。但对于一台只跑 Web 服务的服务器来说,真的需要它吗?

1
sudo apt remove dbus -y --purge

卸载后,我想随手查一下时间,确认 NTP 是否还在正常同步:

1
2
$ timedatectl status
Failed to create bus connection: No such file or directory

翻车了,timedatectl 直接崩溃。看来现代 Linux 服务器底层已经严重依赖 DBus,还是老老实实保留它吧。

https://en.wikipedia.org/wiki/D-Bus

/lib/systemd/systemd-logind

systemd-logind 是负责管理用户会话和登录状态的核心守护进程。

https://www.freedesktop.org/software/systemd/man/systemd-logind.service.html

/usr/sbin/cron -f

cron - 用于执行周期性计划任务的守护进程(源自 Vixie Cron)。

-f 参数表示让其保持在前台运行,而不是转为后台守护进程模式。

你可以使用 cron 来设定周期性的定时任务。
可以通过 crontab -e 来编辑当前用户的任务列表;不过在 Ubuntu 上,我更习惯直接把脚本扔进 /etc/cron.hourly/etc/cron.daily 这样的预设目录里。
要查看 cron 的执行日志,可以使用:

grep cron /var/log/syslog 或者
journalctl _COMM=cron,甚至加上时间过滤:
journalctl _COMM=cron --since="date" --until="date"

大多数情况下你会希望保留 cron。
但如果你铁了心要干掉它,千万别直接 apt remove,而是应该先安全地停用服务:

1
2
sudo systemctl stop cron
sudo systemctl disable cron

否则,当你直接用 apt remove cron 卸载时,系统包管理器会触发极其迷惑的行为:它会试图自动安装 postfix 邮件服务器!

1
2
3
4
5
$ sudo apt remove cron
The following packages will be REMOVED:
cron
The following NEW packages will be installed:
anacron bcron bcron-run fgetty libbg1 libbg1-doc postfix runit ssl-cert ucspi-unix

究其原因,是因为系统的默认配置认为 cron 执行出错时需要一个邮件传输代理(MTA)来给管理员发报警邮件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ apt show cron
Package: cron
Version: 3.0pl1-128ubuntu2
...
Suggests: anacron (>= 2.0-1), logrotate, checksecurity, exim4 | postfix | mail-transport-agent

$ apt depends cron
cron
...
Suggests: anacron (>= 2.0-1)
Suggests: logrotate
Suggests: checksecurity
|Suggests: exim4
|Suggests: postfix
Suggests: <mail-transport-agent>
...
exim4-daemon-heavy
postfix

https://help.ubuntu.com/community/CronHowto
https://www.digitalocean.com/community/tutorials/how-to-use-cron-to-automate-tasks-on-a-vps
http://unix.stackexchange.com/questions/212355/where-is-my-logfile-of-crontab

/usr/sbin/rsyslogd -n

Rsyslogd 是一个强大的系统级日志记录守护进程。

换句话说,正是它源源不断地向 /var/log/ 目录下的各种纯文本日志文件写入数据(例如记录 SSH 登录等鉴权信息的 /var/log/auth.log)。
它的配置文件通常放在 /etc/rsyslog.d 目录下。
它不仅能写本地文件,还可以配置为将日志实时转发到远程服务器,这是构建集中式日志收集系统的基石。
在编写 Shell 脚本(如开机启动脚本)时,你可以很方便地利用 logger 命令,将自定义消息直接推送到 /var/log/syslog

1
2
3
4
5
#!/bin/bash

logger "Starting doing something"
# NFS, get IPs, etc.
logger "Done doing something"

这听起来不错,但我们不是已经有接管一切的 systemd-journald 了吗?还需要留着老旧的 rsyslogd 吗?

Rsyslog 与 Journal 作为系统上的两大日志组件,各自具备独特的优势以满足不同场景。在很多生产环境中,将两者结合使用能达到最佳效果(例如,用 Journal 抓取结构化数据,再由 Rsyslog 过滤并转发)。它们之间的协作桥梁,则是由 Rsyslog 的 I/O 模块与 Journal 的专属通信套接字共同搭建的。

所以,结论是:可能真的还需要它。为了系统兼容性,还是不要碰它为妙。

http://manpages.ubuntu.com/manpages/xenial/man8/rsyslogd.8.html
http://manpages.ubuntu.com/manpages/xenial/man1/logger.1.html
https://wiki.archlinux.org/index.php/rsyslog
https://www.digitalocean.com/community/tutorials/how-to-centralize-logs-with-rsyslog-logstash-and-elasticsearch-on-ubuntu-14-04
https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/7/html/System_Administrators_Guide/s1-interaction_of_rsyslog_and_journal.html

/usr/sbin/acpid

acpid - 高级配置与电源接口(ACPI)事件守护进程。

它的核心作用是捕获底层硬件的 ACPI 事件,并将其转发给用户空间的应用程序。它应该在系统引导时启动,并一直驻留在后台。

在计算机硬件体系中,ACPI 规范为操作系统提供了一套开放标准,使其能动态发现、配置硬件组件,执行电源管理(如让闲置设备休眠),以及监控硬件状态(如温度、风扇转速)。
问题是,我当前操作的是一台云端虚拟机,我永远不可能去按它的“物理睡眠按钮”。
抱着探索的心态,我试着卸载了它,想看看会发生什么:

1
sudo apt remove acpid -y --purge

结果是:我依然可以通过命令行执行 reboot 重启服务器,但执行 halt 关机后,Digital Ocean 的云端控制台依然显示这台机器处于“运行中”状态。最后我不得不登录 Web 控制台点击“强制关机”(Power Off)才真正切断电源。
结论显而易见:云厂商的底层虚拟化平台依然依赖 ACPI 信号来感知虚拟机的电源状态,因此必须保留它。

http://manpages.ubuntu.com/manpages/xenial/man8/acpid.8.html
https://en.wikipedia.org/wiki/Advanced_Configuration_and_Power_Interface

/usr/bin/lxcfs /var/lib/lxcfs/

lxcfs 是一个专为 LXC(Linux 容器)设计的 FUSE 用户态文件系统。在 Ubuntu 15.04 及后续版本中,它主要解决两大痛点:一是为容器提供高度虚拟化的 /proc 视图;二是提供对宿主机 cgroup 系统的安全过滤访问。

简单来说,有了它,当你在容器内运行 uptimetop 等传统监控命令时,它们终于能输出只针对该容器的“正确”读数(而不是暴露出宿主机的全局数据)。

本质上,这是一个在用户态实现的巧妙补丁,绕开了直接修改内核带来的风险和争议。它极大地提升了容器的隔离感,让容器内部看起来更像一台完整、独立的物理机。

如果你完全不打算在这台服务器上运行 LXC 容器服务?那就毫无负担地删掉它吧:

1
sudo apt remove lxcfs -y --purge

https://insights.ubuntu.com/2015/03/02/introducing-lxcfs/
https://www.stgraber.org/2016/03/31/lxcfs-2-0-has-been-released/

/usr/lib/accountservice/accounts-daemon

AccountsService 提供了一组通过 D-Bus 暴露的接口,专门用于安全地查询和修改系统用户账户信息。它的底层仍然是基于 usermoduseradduserdel 等经典工具封装而成的。

鉴于之前卸载 DBus 直接导致系统时间管理组件 timedatectl 瘫痪的惨痛教训,我很好奇干掉这个账号服务又会引发什么连锁反应:

1
sudo apt remove accountsservice -y --purge

目前看来系统似乎没崩,至于会不会留下暗伤,只能交给时间来证明了。

http://www.linuxfromscratch.org/blfs/view/systemd/gnome/accountsservice.html

/sbin/mdadm

mdadm 是 Linux 系统中用于管理和监控软件 RAID 阵列的核心工具。

它的名字来源于它所管理的 md(Multiple Devices,多设备)节点,是早期工具 mdctl 的继任者。它最初代表“Mirror Disk(镜像磁盘)”,但随着支持 RAID 级别的不断增加,现在已经演变为“多设备”的统称。

RAID(独立磁盘冗余阵列)技术能够将多块物理硬盘捆绑成一个巨大的逻辑存储设备。它主要为了解决两大痛点:1) 容量翻倍(如 RAID 0):将两块 500 GB 的硬盘合并成 1 TB 的存储池。2) 数据冗余与容灾(如 RAID 1、RAID 5、RAID 6 和 RAID 10):在单块或多块硬盘物理损坏时,确保核心数据安然无恙。

在云服务器中,存储冗余通常由云服务商的分布式存储底层(如 Ceph)透明处理,你在系统层面完全不需要自己搭建软件 RAID。直接卸载即可:

1
sudo apt remove mdadm -y --purge

https://en.wikipedia.org/wiki/Mdadm
https://help.ubuntu.com/community/Installation/SoftwareRAID
http://manpages.ubuntu.com/manpages/xenial/man8/mdadm.8.html

/usr/lib/policykit-1/polkitd --no-debug

polkitd — PolicyKit 核心守护进程。

polkit — Linux 系统的细粒度权限授权框架。

可以把它理解为一个进阶版、细粒度的 sudo。它允许系统管理员极其精确地授权普通非特权用户执行某些仅限 root 的危险操作。比如在桌面版 Linux 中,就是靠它允许普通用户在不输入管理员密码的情况下直接点击关机按钮。
但我现在维护的是一台没有图形界面的云服务器,所有管理操作都直接通过 SSH 使用 root 或 sudo 执行,它似乎毫无用武之地。直接卸载:

1
sudo apt remove policykit-1 -y --purge

同样,卸载后系统会不会在某些边缘场景下报错,还得继续观察。

http://manpages.ubuntu.com/manpages/xenial/man8/polkitd.8.html
http://manpages.ubuntu.com/manpages/xenial/man8/polkit.8.html
http://www.admin-magazine.com/Articles/Assigning-Privileges-with-sudo-and-PolicyKit
https://wiki.archlinux.org/index.php/Polkit#Configuration

/usr/sbin/sshd -D

sshd(OpenSSH Daemon)是负责处理一切 SSH 远程连接请求的核心守护进程。

-D 参数强制 sshd 保持在前台运行,拒绝将其派生(detach)为后台进程。这在配合 systemd 这类现代初始化系统进行进程生命周期监控时非常有用。

http://manpages.ubuntu.com/manpages/xenial/man8/sshd.8.html

/sbin/iscsid

iscsid 是常驻后台的守护进程,专职处理 iSCSI 配置并建立网络存储连接。它的官方手册是这样描述的:

iscsid 实现了 iSCSI 协议的底层控制链路,并附带了一系列管理工具。例如,它可以配置为在开机时,自动读取本地持久化的 iSCSI 数据库信息,从而重新建立与远程存储目标(Target)的连接。

http://unix.stackexchange.com/questions/216239/iscsi-vs-iscsid-services
说实话,在此之前我甚至都没听说过 iSCSI 这个名词:

iSCSI(发音为 /aɪˈskʌzi/,eye-skuz-ee,互联网小型计算机系统接口)是一种基于 TCP/IP 的网络存储架构标准,旨在打破物理空间的限制,通过普通以太网连接远端的海量存储阵列。

它通过将底层的 SCSI 存储指令封装到 IP 数据包中传输,实现了内网或跨公网的高速块存储访问。

作为经典的 SAN(存储区域网络)协议,它允许前端服务器(即 Initiator,发起端)向后端的专业存储机柜(即 Target,目标端)发送原生磁盘指令。最神奇的是,挂载到前端服务器上的这块网络磁盘,在使用体验上与直接插在主板上的本地硬盘毫无二致。

同样,如果你的云服务器只使用了云厂商默认分配的块存储而没有自建 SAN 架构,这东西完全是多余的:

1
sudo apt remove open-iscsi -y --purge

/sbin/agetty --noclear tty1 linux

agetty - 经典 Linux getty 程序的一个流行变种。

getty 是 “get tty” 的缩写,是 Unix 哲学中负责镇守物理或虚拟终端(TTY)的看门人。一旦它检测到有终端建立连接,就会在屏幕上打印出熟悉的 login: 提示符,然后转交由 login 程序接手完成密码校验。

追溯到上古时期的 Unix,getty 专门用来处理通过串口线直连主机的笨重物理终端(通常是吵闹的电传打字机,Teletype machines)。这也是 tty 这个名字中两个 T 的最初来源。随着时代发展,它现在泛指一切形式的纯文本交互终端。

在数据中心,它就是让你插上实体显示器和键盘后能看到登录画面的幕后功臣。在云服务器环境下(如 Digital Ocean),当你在控制面板中点击虚拟 Console 并在浏览器里敲击键盘时,和你对话的正是这个进程(底层通常走 VNC 协议)。
在 SysVinit 时代,系统一开机就会霸道地直接拉起 6 个 tty 进程(配置在 /etc/inittab 里);而在现代 systemd 架构下,它们已经被优化为极其轻量的按需启动。
为了作死测试,我强行删除了触发 agetty 启动的 systemd 配置文件:

1
2
sudo rm /etc/systemd/system/getty.target.wants/[[email protected]](/cdn-cgi/l/email-protection)
sudo rm /lib/systemd/system/getty@.service

重启服务器后,SSH 远程连接依然稳如泰山,但 Digital Ocean 的 Web 控制台页面已经变成了一块漆黑的砖头,再也无法输入任何内容了。
Screenshot of htop

http://manpages.ubuntu.com/manpages/xenial/man8/getty.8.html
https://en.wikipedia.org/wiki/Getty_(Unix)
http://0pointer.de/blog/projects/serial-console.html
http://unix.stackexchange.com/questions/56531/how-to-get-fewer-ttys-with-systemd

sshd: root@pts/0 & -bash & htop

sshd: root@pts/0 表示 SSH 守护进程已成功为 root 用户建立了一个安全的远程连接,并分配了编号为 0 的伪终端(pts,Pseudo-terminal)。伪终端是内核在内存中虚拟出的文本交互接口,行为与真实的物理终端一致。
-bash 就是我当前敲击命令的 Shell 环境。
为什么 bash 前面会多出一个破折号 -?Reddit 网友 hirnbrot 给出了一针见血的解释:

程序名开头带破折号(如 -bash)是操作系统在告诉你,这是一个“登录 Shell(Login Shell)”。如果启动 Shell 程序的第 0 个参数的首字母是 -,或者启动时带上了 --login 参数,它就会以登录模式运行。这会导致 Bash 去读取一套完全不同的底层初始化脚本(比如 /etc/profile 及其衍生物,而不是仅仅读取 ~/.bashrc)。

最后的 htop,自然就是我用来截取这些图表、大名鼎鼎的交互式进程监控神器本身了。

清理之后

这是第一轮基础清理的清单:

1
2
3
4
5
6
7
8
sudo apt remove lvm2 -y --purge
sudo apt remove at -y --purge
sudo apt remove snapd -y --purge
sudo apt remove lxcfs -y --purge
sudo apt remove mdadm -y --purge
sudo apt remove open-iscsi -y --purge
sudo apt remove accountsservice -y --purge
sudo apt remove policykit-1 -y --purge

如果你有代码洁癖,还可以尝试下面这份风险自担的“极致版”清理清单:

1
2
3
4
5
6
sudo apt remove dbus -y --purge
sudo apt remove rsyslog -y --purge
sudo apt remove acpid -y --purge
sudo systemctl stop cron && sudo systemctl disable cron
sudo rm /etc/systemd/system/getty.target.wants/[[email protected]](/cdn-cgi/l/email-protection)
sudo rm /lib/systemd/system/getty@.service

附录

源代码

有时候,仅仅查看 strace 的输出是不够的。
要弄清程序到底在做什么,另一种方法是查阅其源代码。
首先,我们需要知道从哪里入手。

1
2
3
4
$ which uptime
/usr/bin/uptime
$ dpkg -S /usr/bin/uptime
procps: /usr/bin/uptime

这说明 uptime 的可执行文件位于 /usr/bin/uptime,在 Ubuntu 系统中,它属于 procps 软件包。
接下来,我们可以访问 packages.ubuntu.com 搜索该包。
这是 procps 的页面:http://packages.ubuntu.com/source/xenial/procps
滚动到页面底部,即可看到指向源代码仓库的链接:

Debian Package Source Repository git://git.debian.org/collab-maint/procps.git
Debian Package Source Repository (Browsable) https://anonscm.debian.org/cgit/collab-maint/procps.git/

文件描述符与重定向

当需要将标准错误 (stderr) 重定向到标准输出 (stdout) 时,应该用 2&>1 还是 2>&1
你可以这样来记忆 & 符号的位置:我们知道 echo something > file 会将 something 写入名为 file 的文件,这等同于 echo something 1> file。同理,echo something 2> file 会将标准错误输出写入 file
如果写成 echo something 2>1,实际上是将 stderr 重定向到了一个名为 1 的文件中。加上空格会更直观:echo something 2> 1
如果在 1 前加上 &,则表示 1 是一个流 ID 而不是文件名。因此,正确的写法是 echo something 2>&1

PuTTY 中的颜色设置


如果在使用 PuTTY 时发现 htop 缺失了某些界面元素,可以通过以下步骤解决:

  1. 右键点击窗口标题栏
  2. 选择 Change settings… (更改设置…)
  3. 导航至 Window (窗口) -> Colours (颜色)
  4. 勾选 Both (两者) 单选框
  5. 点击 Apply (应用)

用 C 语言编写简易 Shell

接下来,我们用 C 语言编写一个极简的 shell,以此演示 forkexecwait 系统调用的用法。以下是 shell.c 的代码:

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
printf("Welcome to my shell\n");
char line[1024];

while (1) {
printf("> ");

fgets(line, sizeof(line), stdin);
line[strlen(line)-1] = '\0'; // 去除 \n
if (strcmp(line, "exit") == 0) // shell 内建命令
break;

int pid = fork();
if (pid == 0) {
printf("Executing: %s\n", line);
if (execlp(line, "", NULL) == -1) {
printf("ERROR!\n");
exit(1);
}
} else if (pid > 0) {
int status;
waitpid(pid, &status, 0);
printf("Child exited with %d\n", WEXITSTATUS(status));
} else {
printf("ERROR!\n");
break;
}

}

return 0;
}

编译该程序:

1
gcc shell.c -o shell

运行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./shell
Welcome to my shell
> date
Executing: date
Thu Dec 1 14:10:59 UTC 2016
Child exited with 0
> true
Executing: true
Child exited with 0
> false
Executing: false
Child exited with 1
> exit

你是否好奇过:当在后台启动一个进程后,为什么通常需要按一下 Enter 键,才会看到该进程已退出的提示信息?

1
2
3
4
$ sleep 1 &
[1] 11686
$ # 按下 Enter 键
[1]+ Done sleep 1

这是因为 shell 一直在等待用户输入。只有当你按下回车输入命令时,shell 才会去检查后台进程的状态,并输出其终止信息。

TODO

以下是我希望进一步探索的主题:

  • 进程的细分状态(如 SsSs+R+ 等)
  • 内核线程(kernel threads)
  • /dev/pts 设备
  • 内存机制进阶(CODEDATASWAP
  • 确定时间片(time slice)的长度
  • Linux 调度器算法
  • 进程与特定 CPU 核心的绑定(CPU 亲和性)
  • 撰写关于 man 手册页的解析
  • 顶栏图表中 CPU/内存颜色的具体含义
  • 进程 ID (PID) 限制与 Fork 炸弹(fork bomb)
  • lsofioniceschedtool 工具的使用

更新记录

以下是自本文发布以来的重要修正与更新:

  • /proc/uptime 中的空闲时间 (Idle time) 为所有 CPU 核心的总和(2016 年 12 月 2 日)
  • 修正了 zombie.c 中父/子进程 printf 打印内容颠倒的问题(2016 年 12 月 2 日)
  • 明确了由于依赖 MTA,执行 apt remove cron 会自动安装 postfix(2016 年 12 月 3 日)
  • 补充 id 命令不仅从 /etc/passwd 获取信息,还会通过 /etc/nsswitch.conf 从其他源加载(2016 年 12 月 3 日)
  • 增加了关于 /etc/shadow 密码哈希格式的说明(2016 年 12 月 3 日)
  • 强调出于安全考虑,应始终使用 visudo 编辑 /etc/sudoers 文件(2016 年 12 月 3 日)
  • 补充了内存使用率 (MEM%) 的计算解释(2016 年 12 月 3 日)
  • 重写了平均负载 (Load average) 的相关章节(2016 年 12 月 4 日)
  • 修正错误:kill 1234 默认发送的是 TERM 信号,而非 INT 信号(2016 年 12 月 7 日)
  • 补充解释了 CPU 和内存进度条中各颜色的含义(2016 年 12 月 7 日)

相较于原文的修改

  • 补充了原文中部分未完成的内容
  • 更正了原文中关于僵尸进程的事实性错误
  • 完成了原文部分TODO