kill 命令的 bug 十年史
一个打错的 kill 命令就干掉了机器上的所有进程。罪魁祸首是 2013 年的一次 getopt 重写,其负 pid 缺陷随四个 Ubuntu LTS 版本一同发布。
中文
复制
2026 年 9 月 24 日上午,Endcrawl 出了一次短暂故障。我当时刚敲下:
sudo kill -TERM -1490947
所有服务立刻全挂了。
我第一反应是怪自己。也许那个数字我敲错了!也许我的代码有信号处理的 bug。也许我对 Unix 的理解没有我以为的那么深。也许我就是个蠢货!
结果,这次故障是 kill(1) 的一个 bug 造成的。
这实在令人意外。kill 这个程序已经有 50 年历史,规范简单而稳定,每天大概要被调用十亿次。
kill 里的一个 bug 太让人意外了,找到它之后我没法就此打住。我想知道这个 bug 是怎么来的,又是什么把程序员带偏得这么离谱。等我最终找到根因,意外更大了,它引出的不止一个 bug,而是一整串 kill bug,随 Ubuntu 14、16、18、22 和 24 LTS 一起发布了出去。
本文记录了这十年间的 kill bug,最后给出一些建议。
没搞清楚的故障会再来一次
回到那次故障。我手忙脚乱地灭火。灭完之后,又冒出了第二场火,出乎意料。等这一切结束,我已经相当沮丧,准备承认自己犯了个愚蠢的错误,然后继续干活。但心里总有个疙瘩……
还记得《阅后即焚》的最后一幕吗?
“我们学到了什么,Palmer?” “我不知道,长官。” “我他妈的也不知道。大概学到了别再这么干。” “是,长官。” “不过我他妈也不知道我们到底干了什么……”
刚才到底发生了什么?我会不会再挨第三次故障?我怎么才能确定?
没搞清楚的故障,就是会再发生一次的故障。心里那个疙瘩是在催我去弄明白。我叹了口气,把手头其他工作全放下,开始认真做复盘。
直接原因
来看看引发故障的那条命令:
sudo kill -TERM -1490947
这里的负数是一个进程组标识符,简称 pgid。pgid 和 pid 类似,但标识的是一组相关进程。按惯例,kill 一个正数会向单个进程发送信号,kill 一个负数则会向该进程组内的所有进程发送信号。
我当时想取消一个大型的 Endcrawl 7k 测试渲染。一般人会杀掉其中一个相关进程,让失败级联到其他进程。但在我看来,同时杀掉整个进程组更干净。
进程组信号并不是什么冷门操作。你一直在用它。每次你用 ^C 执行一条命令,tty 都会向前台进程组发送 SIGINT。交互式 shell 会把除最简单的命令之外的所有命令都放在新的进程组里运行,这也正是作业控制得以实现的原因之一。
进程组信号其实是 Unix 一项了不起的创新。我太喜欢它了,以至于把它加进了 daemontools,现在 svc -+h 在多进程服务重启时不会再留下孤儿进程。
总之,你可以想象我有多惊讶,这条命令不只杀掉了那些渲染进程,还杀掉了机器上除一个进程之外的所有进程!
这个 bug
下面是 procps 4.0.4 中的相关代码片段,Ubuntu 24.04 LTS 里的 /bin/kill 就来自它。你能看出 bug 在哪吗?
opterr=0; /* suppress errors on -123 */
while ((i = getopt_long(argc, argv, "l::Ls:hVq:", longopts, &optindex)) != -1)
switch (i) {
/* ... */
case '?':
if (!isdigit(optopt)) {
xwarnx(_("invalid argument %c"), optopt);
print_usage(stderr);
} else {
/* Special case for signal digit negative
* PIDs */
pid = (long)('0' - optopt);
if (!execute_kill((pid_t) pid, signo, use_sigqueue, sigval))
exitvalue = EXIT_FAILURE;
exit(exitvalue);
}
/* ... */
}
我最初的猜测是错的。-1490947 并不存在下溢或上溢的问题。
注意,我们进入的是无法识别的选项这个 switch 分支。getopt(3) 对无法识别的选项是这样说的:
默认情况下,
getopt()会在标准错误上打印一条错误信息,把出错的选项字符放进 optopt,并返回'?'作为函数结果。
啊哈。optopt 变量只保存了短横线之后的第一个字符,而不是整个负 pid。当从字符 0 中减去字符 1 时,得到的 pid 是 -1。杀掉 pid -1 会发生什么?后果很严重:
如果 pid 等于 -1,那么 sig 会发送给调用进程有权限发送信号的所有进程,进程 1(init)除外。
这段代码是以 root 身份运行的,所以把 -1490947 误解析成 -1 就杀掉了除 init 之外的所有进程。bug 找到了,谜团解开了。
等等,可这是为什么
但对我来说,这反而引出了更多问题。代码为什么会假定无法识别的选项是“signal [原文如此] digit negative PID”?为什么单个数字的负 pid 要特殊处理,而多位数的负 pid 这种一般情况又是由谁来处理的?
而且,这种 bug 怎么会出现在 2026 年,还是在一个 Ubuntu LTS 版本里?它是一直都在吗?
不,不可能。我以前杀过进程组。kill -TERM -12345 那套东西是古老的 Unix 咒语。这玩意儿以前明明能用的!
回想起来,我隐约记得有些负 pid 的 kill 出过岔子。倒不是“哎呀,把眼前所有东西都杀光了”那种。更像是“奇怪,什么都没发生”,或者“噗,我的 shell 没了”。都是些小事,很容易当成用户自己操作失误就算了。
结果发现,我不是在瞎想。过去这十年对负 pid 杀手来说挺难的。围绕负 pid 至少有 四个不同的 bug。而且它们全都随 Ubuntu LTS 版本发布出去了。
历史
在讲这四个 bug 的故事之前,先往 Unix 历史更早的地方走一走。
一开始 Unix 就有进程。到 1973 年它有了 kill(1) 程序。但进程组演化成我们今天熟悉的样子,花了十年。直到 1986 年的 Unix V9,才能通过负 pid 杀掉一个进程组。据我所知,这些年来负 pid 杀进程在 BSD 系里一直好好的。
90 年代初,有个叫 Linux 的东西出现了。Linux 的用户态最初借用了大量 BSD 代码,但到 90 年代末,这种做法在法律上有了风险。所有东西都得重写,每个轮子都得重新发明。而且得快点。那是个让 Unix 程序员兴奋的年代。
于是 Linux 的 kill 被重新发明了出来,作者是 Albert Calahan。Albert 自己 fork 了 5 个子进程把自己 DoS 了之后,Debian 的下游维护者 Craig Small 接手了可怜又没人管的 procps。
负 pid 杀进程似乎一直没问题,直到 2013 年。然后麻烦就开始了。
Bug 1:Ubuntu 14 杀不了 -1
Ubuntu 14.04 LTS 带的是 procps 3.3.9。在这之前,kill 的代码和 BSD 系一样,用指针运算来遍历和解析 argv。
我个人挺喜欢这种做法。Stallman 可不喜欢。在 GNU 的世界里,命令行解析应该通过 getopt 来标准化。一般情况下这大概是个好主意,但正如我们将看到的,在 kill(1) 这个具体场景里,这多半是个错误的选择。
下面是转换成 getopt 之后的相关代码:
opterr=0; /* suppress errors on -123 */
while (loop == 1 && (i = getopt_long(argc, argv, "l::Ls:hV", longopts, NULL)) != -1)
switch (i) {
/* ... */
case '?':
if (!isdigit(optopt)) {
xwarnx(_("invalid argument %c"), optopt);
kill_usage(stderr);
}
loop=0;
break;
/* ... */
}
argc -= optind + sigopt;
argv += optind;
for (i = 0; i < argc; i++) {
pid = strtol_or_err(argv[i], _("failed to parse argument"));
if (!kill((pid_t) pid, signo))
continue;
exitvalue = EXIT_FAILURE;
continue;
}
这段代码处理多位数负 pid 没有问题。但如果你是那种有创意的怪咖 sysadmin,喜欢时不时来个 kill -HUP -1 重启所有服务,就会发现一个 bug,什么也没发生。
这个 bug 源于选项捆绑。
- 多位数的情况:传入
kill -HUP -123时,getopt的表现等同于分别传入了-1、-2和-3。无法识别的选项分支会被命中三次,直到-3才通过递增optind消费掉-123参数。由于代码在-1处提前跳出getopt循环,未被消费的-123参数会正确地传到 pid killer 手里。 - 单数字的情况:传入
kill -HUP -1时,getopt在跳出循环之前就已经把这个参数完全消费掉并递增了optind。pid killer 看不到任何参数,于是什么也没发生。
Bug 2:Ubuntu 16 误杀 -1
得治。可惜药比病还糟。
Ubuntu 16.04 LTS 自带 procps 3.3.10。kill -HUP -1 这个 bug 修好了,代价是多位数负 pid 杀不掉了,一个 kill -TERM -12345 现在会把所有东西都杀掉。
这正是 Ubuntu 24 下造成故障的那个 bug,前面已经分析过。唯一让人费解的是,Ubuntu 24 时期的 procps 为什么把 Ubuntu 16 时期已知有问题的代码又搬了回来。我猜是因为下面这些 bug 被认为更糟。
Bug 3:Ubuntu 18 杀 -1 时段错误
Ubuntu 18.04 LTS 自带 procps 3.3.12。
这一版试图修复多位数负 pid 的杀进程问题。新代码不再解析 optopt(传入 -123 时,它只对应 -1 这样的单个捆绑字符),而是停下来尝试解析整个参数:
opterr=0; /* suppress errors on -123 */
while (loop == 1 && (i = getopt_long(argc, argv, "l::Ls:hV", longopts, NULL)) != -1)
switch (i) {
/* ... */
case '?':
if (!isdigit(optopt)) {
xwarnx(_("invalid argument %c"), optopt);
kill_usage(stderr);
} else {
/* Special case for signal digit negative
* PIDs */
pid = atoi(argv[optind]);
if (kill((pid_t)pid, signo) != 0)
exitvalue = EXIT_FAILURE;
exit(exitvalue);
}
loop=0;
break;
/* ... */
}
多位数负 pid 处理正确了。但如果你传的是单数字负 pid,比如 kill -TERM -1,pid 参数已经被完全消费,optind 就会跑出 argv[] 的末尾。段错误!
Bug 4:Ubuntu 22 杀掉你的 shell
Ubuntu 22.04 LTS 自带 procps 3.3.17。
这一版通过确保 optind 永远不会跑出 argv[] 的末尾,修掉了段错误:
opterr=0; /* suppress errors on -123 */
while ((i = getopt_long(argc, argv, "l::Ls:hVq:", longopts, NULL)) != -1)
switch (i) {
/* ... */
case '?':
if (!isdigit(optopt)) {
xwarnx(_("invalid argument %c"), optopt);
kill_usage(stderr);
} else {
/* Special case for signal digit negative
* PIDs */
pid = atoi(argv[optind-1]);
if (kill((pid_t)pid, signo) != 0)
exitvalue = EXIT_FAILURE;
exit(exitvalue);
}
xerrx(EXIT_FAILURE, _("internal error"));
/* ... */
}
看来又有一位贡献者没弄明白 getopt 的选项合并机制与 optind 是如何相互作用的。读到这里,你大概已经对他们生出几分同情了。这个补丁让 kill -TERM -1 能正常工作,不再段错误。但多位数负 pid 的终止功能第二次坏掉了,而且是以一种全新的方式。
假设我们执行 kill -TERM -123。由于选项合并,走到无法识别的选项分支时,optind 还没有自增。再加上下面会提到的某个古怪行为,此时 argv[optind-1] 已经是字符串 kill 了。
atoi(3) 遇到 "kill" 这样的非数字字符串会怎么处理?返回 0。
那么 kill 系统调用收到 0 会做什么?
如果 pid 等于 0,则 sig 会发送给调用进程所在进程组中的每一个进程。
因为这是一条简单命令,shell 不会把 kill -TERM -123 放进新的进程组。于是这个 bug 会出其不意地杀掉你的 shell。砰!
我们学到了什么,Palmer?
有几点值得一说。
狭义的经验
其一,getopt 只在处理最后一个合并选项时才推进 optind,这一点极不直观。我没找到任何证据表明有贡献者理解了这个行为。我自己一开始也没搞懂,是仔细研究之后才明白的。
这确实出人意料。这种令人意外的行为应当避免。
不过我觉得这条经验太狭隘了。人真的能避开这种出人意料的 getopt 行为吗?选项合并是关不掉的。
广义的经验
Getopt 有害。
好吧,至少在这个具体场景里成立。kill(1) 这个程序本身就是个异类。它接受的选项其实是信号名,比如 -KILL。同一个信号也可以用编号指定,比如 -9。而 pgid 是负数,看起来又像极了选项。
kill -1 -9 这样的命令行该怎么解释?
- 是给 pgid 9 发送 SIGHUP?
- 还是给 pgid 1(也就是一切)发送 SIGKILL?
- 还是给 pgid 1 和 9 发送默认信号 SIGTERM?
只要 argv 的每个元素既可能是选项又可能是 pid,这三种解释就都说得通。
解决办法是改为按位置解析 argv。这里不要用 getopt。放弃选项捆绑、选项重排这类能力,在这个场景下,它们带来的坑远多于便利。POSIX 规范规定,只有 argv[1] 能指定要发送的信号,其后的参数都是 pid 和 pgid。这些简化假设很好用。
我认为老派 Unix 和 BSD 的做法才是对的。看看 FreeBSD 的 kill.c。和 70 年代的祖传 Unix 一样,它按位置解析 argv。思路简单,代码也简单。相比之下,Linux 的 kill 有命令行解析 bug,而 FreeBSD 的 kill 一个都没有。
我确实认为 getopt 教条才是这次故障的真正根因。
已经走了一半
如果放弃 getopt、改用“原始”的 argv 解析仍让你觉得有罪,那就看看 procps 在调用 getopt_long 之前做了什么:
if (argc < 2)
print_usage(stderr);
signo = skill_sig_option(&argc, argv);
if (signo < 0)
signo = SIGTERM;
opterr=0; /* suppress errors on -123 */
while ((i = getopt_long(argc, argv, "l::Ls:hVq:", longopts, &optindex)) != -1)
/* ... */
skill_sig_option 做了什么?
int skill_sig_option(int *argc, char **argv)
{
int i;
int signo = -1;
for (i = 1; i < *argc; i++) {
if (argv[i][0] == '-') {
signo = signal_name_to_number(argv[i] + 1);
if (-1 < signo) {
memmove(argv + i, argv + i + 1,
sizeof(char *) * (*argc - i));
(*argc)--;
return signo;
}
}
}
return signo;
}
它扫描 argv,寻找第一个信号说明。一旦找到 -KILL、-9 之类的东西,就原地修改 argv(!),好让 getopt 不必处理它。
这已经是“原始”的 argv 解析了!我们已经走了一半。
几条实用建议
前面的结论是写给软件开发者看的。对于 kill 的用户,我建议如下。
第一,用 -- 把选项和 pid、pgid 分开:
kill -TERM -1490947 # no
kill -TERM -- -1490947 # yes
即便是上面列出的有 bug 的 procps 版本,也能正确解析第二种写法。
第二,以最小权限执行 kill。如果我当时用的是 sudo -u render kill 而不是 sudo kill,那就只会杀掉正在进行的渲染进程,而不是机器上的所有进程。推荐这种防御性用法让我觉得自己有点神经。说清楚,我们防的是 kill 的 bug,而 kill 是个小巧、成熟、被广泛使用且非常重要的工具。但事实就是如此。
最后,好消息是发稿时 Ubuntu 24 的修复已经在路上了。不过我仍然倾向于对 procps 的 kill 做一次简化重写。