假设你在 Linux 上编写一个长期运行的多线程应用程序,可能是数据库或某种服务器。同时假设你并非在托管运行时环境(如JVM、Go或BEAM)中运行,而是通过clone系统调用管理线程。可类比C语言中通过pthread_create创建的线程,或C++中std::thread实现的线程。[^1]
一旦涉及线程启动,通常也需要处理线程终止。但前者远比后者简单。所谓“停止”,是指在完全终止线程前给予其执行清理操作的机会。换言之,我们需要在确保内存释放、锁释放、日志刷新等操作完成后终止线程。[^2]
遗憾的是,这项任务远非表面那般简单,更不存在万能解决方案。本文旨在概述该问题领域,揭示其中诸多陷阱,并在结尾呈现一个小技巧
(准)忙循环 #
若条件允许,可将每个线程结构化为:
while (true) {
if (stop) { break; }
// Perform some work completing in a reasonable time
}
此处的stop是线程级布尔变量。需终止线程时,将stop设为true,再调用pthread_join等函数确保线程真正终止。
以下是一个C++的刻意设计但可运行的示例:
#include
#include
#include
#include
static std::atomic
int main() {
std::thread thr([] {
// prints every second until stopped
for (int i = 0; !stop.load(); i++) {
printf("iterated %d timesn", i);
sleep(1);
}
printf("thread terminatingn");
});
// waits 5 seconds, then stops thread
sleep(5);
stop.store(true);
thr.join();
printf("thread terminatedn");
return 0;
}
该代码将输出:
iterated 0 times
iterated 1 times
iterated 2 times
iterated 3 times
iterated 4 times
thread terminating
thread terminated
若能将代码重构为按此时间片运行,则线程终止变得极其简单。
需注意循环体无需完全采用非阻塞模式——只需确保其终止速度能满足快速终止的需求即可。例如当线程从套接字读取时,可将SO_TIMEOUT设为100毫秒,确保每次循环迭代都能快速终止。[^3]
若需永久阻塞怎么办?#
准忙循环虽有其用,但有时并不适用。最常见的障碍是无法控制的外部代码不符合此模式——例如第三方库执行阻塞式网络调用。
正如后续将阐述的,我们几乎无法干净利落地终止运行着不受控代码的线程。但除了这个原因,还有其他理由让我们不愿将所有代码都写成准忙循环模式。
当存在大量线程时,即使相对较慢的超时也会因虚假唤醒导致显著的调度开销,尤其在系统已负荷过重时。超时机制还会大幅增加调试和系统检查的难度(例如想象strace的输出会呈现何种景象)。
因此值得探讨如何在线程阻塞于系统调用时终止其运行。最直接的方法是通过信号实现。[^4]
谈谈信号机制 #
信号是中断线程执行而不需被中断线程显式协调的主要方式,因此与本文主题密切相关。但信号机制也颇为混乱,这两点往往令人困扰。
若需全面了解信号机制,我推荐阅读内容丰富的手册页,本文也将提供足够的概述。若您已了解信号工作原理[^5],可跳至下一节。
信号可能由硬件异常[^6]触发,也可能由软件主动发起。最常见的软件信号示例是按下ctrl-c时,shell向前台进程发送的SIGINT信号。所有软件触发的信号均源自若干系统调用——例如pthread_kill会向线程发送信号。[^7]
硬件触发的信号通常会立即处理,而软件触发的信号则在内核完成某些工作后,CPU即将重新进入用户模式时处理。[^8] 无论如何,当某个线程需要处理信号时:
若接收线程已屏蔽该信号,则信号将等待至屏蔽解除后处理;
若信号未被屏蔽,则可能:
被忽略;
以“默认”方式处理;
通过自定义信号处理程序处理。
信号屏蔽机制通过修改 信号屏蔽位 实现,具体操作参见sigprocmask/ pthread_sigmask 修改 信号屏蔽 实现;若线程未被阻塞,具体处理方式则由sigaction控制。
假设信号未被阻塞,路径2.a和2.b将完全由内核管理,而路径2.c将导致内核将控制权移交至用户空间信号处理程序,由其执行信号相关操作。
需要注意的是,若某个线程正在执行系统调用(例如读取套接字时被阻塞),且需要处理信号,则系统调用将在信号处理程序运行后提前返回错误代码EINTR。
信号处理程序代码需遵守多种限制,但除此之外可自由执行任意操作,包括决定不将控制权交还给先前执行的代码。默认情况下,多数信号会导致程序突然终止,可能伴随核心转储。接下来我们将探讨多种利用信号终止线程的方法。
线程取消——虚妄的希望 #
首先考察一种看似完美实现目标的信号式线程终止方案:线程取消。
线程取消的API看似充满希望。pthread_cancel(tid)将“取消”线程tid。其工作原理可归纳为:
向线程tid发送特殊信号;
你使用的库(如glibc或musl)设置处理程序,使接收取消信号时线程逐步终止。
虽然存在其他细节,但核心机制就是如此。然而问题即将显现。
资源管理 + 线程取消 = 😢 #
需注意信号可能在代码任意位置触发。例如当存在如下代码时:
lock();
// critical work here
unlock();
信号可能在临界区内触发。若发生线程取消,线程可能在持有锁(如上例)、释放内存或持有未释放资源时被中断,导致清理代码永远无法执行——这显然不可取。
虽然存在缓解措施,但均非万全之策:
线程取消功能可临时禁用。因此我们可在任何关键段内禁用该功能。然而某些“关键段”持续时间极长(例如某些分配内存的生命周期),且必须确保在所有相关代码中适时启用/禁用取消功能。
Linux线程提供pthread_cleanup_push和pthread_cleanup_pop接口,用于添加/移除全局清理处理程序。这些处理程序在线程被取消时确实会被执行。然而要确保安全使用这些函数,必须再次为每个关键段添加装饰:不仅需要推入/弹出清理句柄,还需在设置清理句柄时临时禁用取消机制以避免竞争条件。这种做法同样极易出错,且会显著降低代码运行效率。
默认情况下,线程取消发送的信号仅在“取消点”接收,这些点近似可理解为可能阻塞的系统调用——详见pthreads(7)。因此实际问题仅出现在关键段内存在此类系统调用时。但此时仍需手动确保:关键段不包含取消点,或通过其他方式(如前文所述两种措施)实现安全防护。
线程取消机制与现代C++存在兼容性问题 #
如果你是C++/Rust程序员,或许会对上述显式锁定嗤之以鼻——毕竟你们有RAII机制来处理这类场景:
{
const std::lock_guard
// critical work here
// The destructor for `lock` will do the unlocking
}
你可能还在疑惑:若线程取消指令在此处RAII管理的临界区内触发会怎样?
答案是:线程取消会触发类似抛出异常的栈展开(实际上通过特殊异常实现),这意味着析构函数在取消时 必定 会被执行。该机制称为 强制展开。很棒吧?[^9]
然而,由于线程取消通过异常实现,且取消操作可能发生在任意位置,我们始终面临取消操作出现在noexcept代码块的风险,这将导致程序通过std::terminate崩溃。
因此自C++11起,尤其在C++14默认将析构函数标记为noexcept后,线程取消在C++中基本毫无用处。[^10]
强制展开机制本身存在安全隐患 #
但需注意,即使该机制在C++中有效,在许多场景下仍不安全。例如:
{
const std::lock_guard
balance_1 += x;
balance_2 -= x;
}
若在balance_1 += x之后发生强制展开,不变量将彻底失效。这正是Java的强制展开机制Thread.stop被废弃的原因。[^11]
无法干净地停止不受控线程的运行 #
简而言之,信号机制(以及由此延伸的线程取消机制)的本质决定了无法干净地终止不受控代码。你无法保证内存不会泄漏、文件未关闭、全局锁未释放等情况。
若需可靠地中断外部代码,更优方案是将其隔离在独立进程中。虽然临时文件等持久化资源仍可能泄漏,但当进程终止时,操作系统会清理大部分关键状态。
可控线程取消机制 #
希望您现已认同:在多数场景下,无限制的线程取消并非良策。但我们可通过限定触发时机来实现可控取消。因此事件循环将演变为:
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE);
while (true) {
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE);
// syscall that might block indefinitely, e.g. reading
// from a socket
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE);
// Perform some work completing in a reasonable time
}
默认关闭线程取消功能,但在执行阻塞系统调用时重新启用。[^12]
将代码重构为该模式看似繁琐。但许多包含长生命周期线程的应用程序,其循环结构本就遵循类似模式:开头执行阻塞式系统调用(如套接字读取、定时器休眠等),随后进行不会无限阻塞的处理。
自定义线程取消机制 #
但完成上述改造后,或许值得彻底放弃线程取消机制。依赖栈展开释放资源无法移植到其他库系统,且若需在析构函数外执行显式清理操作,则必须格外谨慎。
因此我们可直接处理信号机制:选取SIGUSR1作为“停止”信号,安装处理程序设置停止标志变量,并在执行阻塞系统调用前检查该变量。[^13]
以下是C++的实现示例 代码的关键部分在于设置信号处理程序:
// thread_local isn't really necessary here with one thread,
// but it would be necessary if we had many threads we wanted
// to kill separately.
static thread_local std::atomic
static void stop_thread_handler(int signum) {
stop.store(true);
}
int main() {
// install signal handler
{
struct sigaction act = {{ 0 }};
act.sa_handler = &stop_thread_handler;
if (sigaction(SIGUSR1, &act, nullptr) < 0) {
die_syscall("sigaction");
}
}
...
以及在执行系统调用前检查标志位的代码:
ssize_t recvlen;
if (stop.load()) {
break;
} else {
recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
}
if (recvlen < 0 && errno == EINTR && stop.load()) {
// we got the signal while running the syscall
break;
}
然而,检查标志位并启动系统调用的代码存在竞争条件:
if (stop.load()) {
break;
} else {
// signal handler runs here, syscall blocks until
// packet arrives -- no prompt termination!
recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
}
目前尚无简便方法能原子地完成标志位检查与系统调用。[^14]
解决此问题的另一种方案是让USR1信号正常阻塞, 仅在系统调用运行时解除阻塞,类似于我们对临时线程取消所采取的策略。若系统调用以EINTR异常终止,则表明应立即退出。[^15]
遗憾的是,竞争条件依然存在,就在解除阻塞和执行系统调用之间:
ptread_sigmask(SIG_SETMASK, &unblock_usr1); // unblock USR1
// signal handler runs here, syscall blocks until
// packet arrives -- no prompt termination!
ssize_t recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
ptread_sigmask(SIG_SETMASK, &block_usr1); // block USR1 again
原子修改信号掩码 #
然而,通常确实存在一种方法能原子地修改sigmask并执行系统调用:
select/poll/ epoll_wait 提供带 sigmask 参数的 pselect/ppoll/epoll_pwait 变体;
read/write 及类似系统调用可替换为非阻塞版本,配合阻塞式 ppoll 使用;
休眠操作可使用 timerfd 或直接调用无文件描述符但带超时的 ppoll;
新增的io_uring_enter可直接支持此用例。
上述系统调用已覆盖极广泛的应用场景。[^16]
采用此风格后,程序的接收循环将变为:
struct pollfd pollsock = {
.fd = sock,
.events = POLLIN,
};
if (ppoll(&pollsock, 1, nullptr, &usr1_unmasked) < 0) {
if (errno == EINTR) {
break;
}
die_syscall("ppoll");
}
ssize_t recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
适配任意系统调用 #
遗憾的是,并非所有系统调用都具备在执行过程中原子修改信号掩码的变体。futex作为实现用户空间并发原语的主要系统调用,正是缺乏此类机制的典型代表。[^17]
在futex场景中,可通过FUTEX_WAKE中断线程,但实际上我们能设计机制,在启动 任何 系统调用时原子地安全检查布尔停止标志。[^18]
问题代码如下所示:
if (stop.load()) {
break;
} else {
// signal handler runs here, syscall blocks until
// packet arrives -- no prompt termination!
recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
}
若能确保在标志检查与系统调用之间不会执行任何信号处理程序,则可确保安全。
Linux 4.18 引入了名为 rseq(“可重启序列”)的系统调用,[^19] 通过它可实现此目标,但需付出一定努力。[^20] rseq机制的工作原理如下:
编写需要在抢占或信号作用下原子执行的代码——即临界区。
在进入关键段前,通过向内核与用户空间共享的内存位写入数据,告知内核关键段即将运行。
该内存位包含:
start_ip:标记关键段起始位置的指令指针;
post_commit_offset:关键段的长度;
abort_ip,当内核需要抢占临界区时跳转的指令指针。
若内核抢占了线程,或需向线程传递信号,则检查该线程是否处于rseq临界区内。若处于该状态,则将线程的程序计数器设置为abort_ip。
上述过程强制关键区成为单个连续区块(从start_ip到start_ip+post_commit_offset),且必须知道其地址。这些要求迫使我们使用内联汇编实现。
需注意的是,rseq并非完全禁用抢占机制,而是允许我们指定一段代码(从abort_ip开始的代码)在关键段被中断时执行清理工作。因此关键段的正常运作通常依赖于其末尾的“提交指令”,该指令会使关键段内的修改生效。
在本例中,“提交指令”即为syscall——该指令将调用我们关注的系统调用。[^21]
这促使我们设计出以下x86-64架构的6参数系统调用存根组件,该组件可原子性地检查停止标志并执行syscall:
// Returns -1 and sets errno to EINTR if `*stop` was true
// before starting the syscall.
long syscall_or_stop(bool* stop, long n, long a, long b, long c, long d, long e, long f) {
long ret;
register long rd __asm__("r10") = d;
register long re __asm__("r8") = e;
register long rf __asm__("r9") = f;
__asm__ __volatile__ (
R"(
# struct rseq_cs {
# __u32 version;
# __u32 flags;
# __u64 start_ip;
# __u64 post_commit_offset;
# __u64 abort_ip;
# } __attribute__((aligned(32)));
.pushsection __rseq_cs, "aw"
.balign 32
1:
.long 0, 0 # version, flags
.quad 3f, (4f-3f), 2f # start_ip, post_commit_offset, abort_ip
.popsection
.pushsection __rseq_failure, "ax"
# sneak in the signature before abort section as
# `ud1
.byte 0x0f, 0xb9, 0x3d
.long 0x53053053
2:
# exit with EINTR
jmp 5f
.popsection
# we set rseq->rseq_cs to our structure above.
# rseq = thread pointer (that is fs) + __rseq_offset
# rseq_cs is at offset 8
leaq 1b(%%rip), %%r12
movq %%r12, %%fs:8(%[rseq_offset])
3:
# critical section start -- check if we should stop
# and if yes skip the syscall
testb $255, %[stop]
jnz 5f
syscall
# it's important that syscall is the very last thing we do before
# exiting the critical section to respect the rseq contract of
# "no syscalls".
4:
jmp 6f
5:
movq $-4, %%rax # EINTR
6:
)"
: "=a" (ret) // the output goes in rax
: [stop] "m" (*stop),
[rseq_offset] "r" (__rseq_offset),
"a"(n), "D"(a), "S"(b), "d"(c), "r"(rd), "r"(re), "r"(rf)
: "cc", "memory", "rcx", "r11", "r12"
);
if (ret < 0 && ret > -4096) {
errno = -ret;
ret = -1;
}
return ret;
}
// A version of recvfrom which atomically checks
// the flag before running.
static long recvfrom_or_stop(bool* stop, int socket, void* buffer, size_t length) {
return syscall_or_stop(stop, __NR_recvfrom, socket, (long)buffer, length, 0, 0, 0);
}
我们利用glibc近期新增的rseq支持,该机制提供__rseq_offset变量,其值为临界区信息相对于线程指针的偏移量。在临界区内只需执行三步:检查标志位,若标志位设置则跳过系统调用,否则执行系统调用。若标志位设置,则模拟系统调用以EINTR错误失败。
您可以在此处找到前例中使用此技巧调用recvfrom的完整代码。我并非特别推荐使用这种技术,但它确实是个有趣的奇闻。
总结 #
令人沮丧的是,Linux 线程目前尚无统一的中断机制和栈展开机制,也缺乏保护关键代码段免受栈展开影响的方案。虽然技术上并不存在障碍,但干净的资源释放往往是软件开发中被忽视的部分。
Haskell 通过异步异常实现了这类能力,不过开发者仍需谨慎保护关键代码段的安全。
鸣谢 #
Peter Cawley 为本文诸多细节提供了建议并审阅了初稿,同时提出了rseq作为潜在解决方案。同时衷心感谢Niklas Hambüchen、Alexandru Sçvortov、Alex Sayers及Alex Appetiti审阅本文草稿。
[^1]本文大部分内容同样适用于进程——在Linux系统中,进程与线程的本质区别仅在于线程共享虚拟内存。本文内容同样适用于除C/C++之外直接使用Linux线程的编程语言,例如Rust的thread::spawn和zig的std::Thread::spawn。↩︎
[^2]在C++中,清理工作通常通过析构函数实现。此时目标是确保线程终止前完成所有未处理的析构函数调用。若 不执行清理 直接终止线程,可使用pthread_kill(tid, SIGKILL)实现。↩︎
[^3]若在完全非阻塞的运行时环境中编写所有代码,最终可能与多数运行时实现相同:采用协程抽象来表达可能需要等待的操作,并自行执行所有调度。你选择的语言可能已为此提供现成框架(如C++的Seastar、async Rust等)。这种方法虽有诸多优点,但需要整个应用程序遵循特定框架进行结构化设计。本文关注的是直接使用Linux线程编写的程序,由内核负责调度。↩︎
[^4]需注意:即使采用准忙循环(实际上任何软件开发场景),都可能需要处理信号机制。例如,任何使用缓冲打印(如printf)的应用程序,若被信号中断且未安装信号处理程序来刷新标准输入输出缓冲区,都可能丢失输出。↩︎
[^5]恭喜你,你一定累坏了。↩︎
[^6]除以零可能引发SIGFPE异常,访问未映射内存将触发SIGSEGV异常,以此类推。↩︎
[^7]信号可指向线程(例如通过pthread_kill),也可指向进程(例如通过kill)。线程导向信号将直接传递给目标线程。进程导向信号发送时,内核会在进程中随机选择一个线程进行处理,且无法保证选择哪个线程。↩︎
[^8]通常这种情况发生在两种情形:系统调用执行完毕时,或内核调度线程时。↩︎
[^9]请注意,这种展开机制并非由任何标准强制要求,而是glibc/libstdc++特有的功能。例如使用musl时就不会进行展开,析构函数也不会运行。依赖此展开机制时还需注意其他异常现象。↩︎
[^10]在处理C语言而非C++时,可通过[__attribute__((cleanup))]实现清理机制。其工作原理与析构函数极为相似,却能规避noexcept带来的困扰。理论上这套方案相当完善,但实践中C语言编程习惯截然不同。若试图完全采用此风格编写C项目,无异于逆水行舟。况且这种方案本身存在缺陷,详见下一小节的阐述。↩︎
[^11]Rust会在恐慌期间对持有锁进行污染处理,这至少能在类似场景中维持安全性(但无法保证程序正常运行),前提是Rust运行时将线程取消等操作等效视为恐慌处理。↩︎
[^12]请注意,只要启用取消机制,并在每个循环迭代中至少设置一个启用取消的区段和取消点,循环中可包含任意数量可能无限阻塞的系统调用。↩︎
[^13]USR1默认未被占用,因此可便捷地用于此目的。我们还可屏蔽SIGINT/SIGTERM信号,仅在主线程启用这些信号以协调子进程的终止。↩︎
[^14]不过存在一种硬核实现方式,详见最后一节。↩︎
[^15]建议在处理程序中设置停止变量,以便区分因处理程序运行引发的中断与其他中断。为简洁起见,代码中省略了该变量。↩︎
[^16]需注意:若能完全依赖基于文件描述符的系统调用,则可完全省略信号机制,转而使用eventfd来传递终止信号。↩︎
[^17]有趣的是,FUTEX_FD本可让我们在ppoll中使用futex,但因其存在竞争条件问题,该功能在Linux 2.6.25中被移除——这实属Linux罕见地破坏用户可见API的案例。↩︎
[^18]此技巧的构思源自 Peter Cawley。↩︎
[^19]关于rseq的文档仍相当匮乏,但可在此处查阅包含简明示例的入门指南。↩︎
[^20]PhantomZorba 在 lobste.rs 上的评论 指出,无需 rseq 支持即可实现本文所述技巧的一种变体——通过在信号处理程序内部检查程序计数器来实现。不仅如此,musl正是通过这种方式实现了无竞争线程取消机制。相关讨论可参见LKML 和 lwn.net 的相关讨论。↩︎
[^21]请注意,rseq 关键区不能包含系统调用。然而,若临界区末条指令恰为系统调用,则线程不可能同时处于系统调用与临界区中:一旦syscall指令执行并启动系统调用,程序计数器已越过临界区边界。↩︎
元素周期表抱枕
本文文字及图片出自 How to stop Linux threads cleanly,由 WebHek 分享。