如何干净地终止 Linux 线程
假设你在 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 <thread>
#include <atomic>
#include <stdio.h>
#include <unistd.h>
static std::atomic<bool> stop = false;
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。其工作原理可归纳为:
虽然存在其他细节,但核心机制就是如此。然而问题即将显现。
资源管理 + 线程取消 = 😢 #
需注意信号可能在代码任意位置触发。例如当存在如下代码时:
lock();
// critical work here
unlock();
信号可能在临界区内触发。若发生线程取消,线程可能在持有锁(如上例)、释放内存或持有未释放资源时被中断,导致清理代码永远无法执行——这显然不可取。
虽然存在缓解措施,但均非万全之策:
- 线程取消功能可临时禁用。因此我们可在任何关键段内禁用该功能。然而某些“关键段”持续时间极长(例如某些分配内存的生命周期),且必须确保在所有相关代码中适时启用/禁用取消功能。
- Linux线程提供
pthread_cleanup_push和pthread_cleanup_pop接口,用于添加/移除全局清理处理程序。这些处理程序在线程被取消时确实会被执行。然而要确保安全使用这些函数,必须再次为每个关键段添加装饰:不仅需要推入/弹出清理句柄,还需在设置清理句柄时临时禁用取消机制以避免竞争条件。这种做法同样极易出错,且会显著降低代码运行效率。 - 默认情况下,线程取消发送的信号仅在“取消点”接收,这些点近似可理解为可能阻塞的系统调用——详见
pthreads(7)。因此实际问题仅出现在关键段内存在此类系统调用时。但此时仍需手动确保:关键段不包含取消点,或通过其他方式(如前文所述两种措施)实现安全防护。
线程取消机制与现代C++存在兼容性问题 #
如果你是C++/Rust程序员,或许会对上述显式锁定嗤之以鼻——毕竟你们有RAII机制来处理这类场景:
{
const std::lock_guard<std::mutex> lock(mutex);
// 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<std::mutex> lock(mutex);
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<bool> stop = false;
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 <sig>(%%rip), %%edi`, so that objdump will print it
.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

这让我想起雷蒙德·陈关于为何不该使用TerminateThread的诸多博文[1][2][3](其实还有更多)。毫不意外,其他地方也存在同样问题。就我自己的代码而言,这正是我倾向于使用可取消且可唤醒的系统调用的原因。这样线程就能被唤醒,检查是否需要终止,然后立刻撤离。
[1] https://devblogs.microsoft.com/oldnewthing/20150814-00/?p=91…
[2] https://devblogs.microsoft.com/oldnewthing/20191101-00/?p=10…
[3] https://devblogs.microsoft.com/oldnewthing/20140808-00/?p=29…
还有很多其他内容,这里就不一一列举了。
我在Windows上遇到的一个令人恼火的陷阱是:尽管这条建议听起来很合理,但运行时本身(我认为实际发生在内核中)会在执行全局析构函数和atexit钩子之前,对所有子线程调用TerminateThread。当内核在系统关闭时主动与你作对时,祝你好运能遵循这条建议。
这篇文章很好地解释了为什么pthread取消是徒劳的。
> 如果我们能确保在标志检查和系统调用之间没有信号处理程序运行,那么我们就安全了。
如果你愿意编写汇编代码,可以不用rseq实现这个功能。多年前我在多个平台上成功实现了这个方案[1] 其原理类似本文所述方案:在初始标志检查与实际系统调用间定义“临界区”。若信号在此触发,则确保指令指针被调整为跳过系统调用并立即返回EINTR。该方案无需依赖当时尚未存在的Linux专属内核支持,仅需异步信号处理程序即可实现。
(顺带一提,rseq机制非常酷炫,但此处并非必需。)
[1] 以下是Linux/x86_64系统调用封装器:https://github.com/scottlamb/sigsafe/blob/master/src/x86_64-… 及信号处理程序:https://github.com/scottlamb/sigsafe/blob/master/src/x86_64-…
> 既然线程取消是通过异常实现的,且线程取消可能发生在任意位置
不,线程取消不会发生在任意位置。或者说不必如此。
取消机制分为两种类型:异步取消和延迟取消。
POSIX 提供了动态配置线程取消类型的 API:pthread_setcanceltype。
此外,取消功能还可被启用或禁用。
不言而喻,线程仅应在安全环境中启用异步取消功能——例如在资源分配过程中不会被中断,或避免操作可能导致数据结构损坏的场景。
关于可取消状态及其作用,我在以下声明后稍作阐述:https://mazzo.li/posts/stopping-linux-threads.html#controlle…。事后看来,我在讨论C++时本应提前提及该章节。我的核心观点是:将C++异常与线程取消结合使用风险极高,个人认为最好避免。
遗憾得知他们至今仍未解决这个问题。二十多年前我在Linux领域非常活跃,参与过glibc等项目的开发。当时C++尚未支持线程功能。曾有一段时期,取消操作无法触发C++异常展开机制,仅能通过PTHREAD_CLEANUP_PUSH处理器实现。因此当时取消操作与C++异常的组合堪称灾难性组合。
针对中断长期运行的系统调用,另有解决方案:
安装一个空的SIGINT信号处理程序(不带SA_RESTART),然后运行循环。
当线程需要停止时:
* 设置停止标志
* 使用pthread_kill或tgkill向线程发送SIGINT信号
* 系统调用将以EINTR错误失败
* 检查EINTR错误和停止标志,确认需要清理并终止
当然多数代码在遇到EINTR时会重试,这要求对所有系统调用代码进行管控——在使用库文件时这几乎不可行。
编辑:原帖恰恰描述了此方法及其缺陷,只是我之前未注意到。
该方案在博客文章中有详细说明,包括其相关问题,详见此章节:https://mazzo.li/posts/stopping-linux-threads.html#homegrown…。
啊,明白了,之前阅读时忽略了这点,因为该方案看起来更复杂。
若条件允许(无需无限期阻塞IO),建议直接采用简单协调模型:
终止线程时,设置停止标志并向条件变量发送信号。(Linux 底层实现使用 futex。)
过度依赖原子布尔值检查易引发竞争条件。我认为将事件循环设计为消息队列更为优雅,通过队列消息指示停止时机。
> 过度依赖原子布尔值检查容易引发竞争条件。
实际上并非如此。这种极简协议本身是无竞争的。
采用队列机制意味着必须处理完队列才能停止。这确实能实现干净停止,但若因队列过长导致任务请求过时而需终止线程时,此方案效果有限。
或许可为停止消息添加队列跳过功能…但若仅针对停止消息,直接设置原子布尔值stop后发送停止消息即可。若线程刚好错过stop布尔值而等待消息,则会收到停止消息;若队列过长,则会收到stop布尔值。
ps,你好
每个事件循环都面临因长期计算导致阻塞的问题。这很棘手…
若在事件循环中反复轮询原子布尔值,同样会遇到此问题。
为何如此?轮询布尔值仅需几个机器周期。
(除原子布尔外还有其他类型吗?值要么为真要么为假,若真值生效后无人能重置为假,我看不见风险。这是CPU而非FPGA。)
该类型虽名为原子类型,但原子性并非其唯一特性。原子类型还提供了内存顺序控制,默认采用顺序一致性(seq_cst,最高级别)。
若无内存屏障强制内存顺序,线程A对布尔值的写入无法保证被线程B观察到。这在初始化后尤为关键——线程A将布尔值设为false时,线程B可能观察到true、false或无效值;在状态转换后同样重要——线程B可能无法察觉布尔值已从false翻转为true。
[编辑:上述推论实际意义存疑;正如楼主所言“这是CPU而非FPGA”;现代多核共享内存CPU均具备一致性缓存]
那么在下一次循环迭代时就会被观察到。若这点至关重要,那么此技术确实不可取。
若无原子操作,编译器根本不会考虑下一次循环,直接进入无限循环(过去通常用volatile标记替代)
确实如此,但volatile依然有效。
在x86架构上,松弛排序是免费的,这足以满足此场景需求。
不同意。我认为这会诱使后续开发者添加阻塞处理的消息。
采用简单清晰的循环机制:检测请求停止标志的同时确认停止标志,效果相当理想。可将其封装为同步“停止”函数:调用方设置标志后,通过条件变量配合pthread_cond_timedwait(Windows平台可用waitforxxxobject)进行定时等待确认。
降低检查稳定性并不能解决这个问题。
记得本文示例代码类似这样:
若 do_stuff()(或其深层调用链中的某个函数)发生延迟,或 sleep 调用本身延迟,你仍将遭遇任意延迟。
若无法接受这种情况,或许不该碰线程——它们很危险。
关键就在于此。使用非阻塞IO配合带超时的事件轮询机制来监控退出标志——这便是实现干净关机的全部所需。
在Windows系统中,可通过相同的waitforxxxobject阻塞机制同时等待套接字/文件描述符和条件变量。Linux系统则可采用libevent、epoll、select或pthread_cond_timedwait。这些机制均具备“事件触发或超时后释放”的语义特性,并可通过eventfd进行组合使用。
我绝不会建议依赖信号并为其编写自定义清理处理程序(!)。
除非它们因等待外部事件而阻塞,否则大多数系统调用通常会在合理时间内返回。只要处理好外部事件阻塞场景(即select等待的对象),问题基本就解决了。更何况,若追求干净退出,你本就不该冒险用信号中断系统调用(!)。
> 若无法接受此观点,或许别碰线程——它们很危险。
为时已晚。我初涉线程时,Linux 尚未真正支持线程。
> 使用非阻塞IO配合事件轮询机制
这与我之前的说法并不矛盾。
> 设置超时机制监控退出标志
这才是愚蠢之处。你将浪费CPU周期在无事可做时因超时而错误唤醒进程。设置标志并不会在超时前唤醒事件循环,反而造成无谓延迟。
你需要让退出信号真正唤醒事件循环。这样也就无需设置超时。
即:你的“请求退出”代码应采用与工作队列相同的唤醒机制——这正是我最初的建议。而非在内存中耗费CPU周期轮询一个易失性布尔值。
> 这才是愚蠢之处。你将因无谓的超时唤醒消耗CPU周期,且无实际工作可执行。设置标志不会在超时前唤醒事件循环,徒增无意义延迟。
这才是精妙之处。以50Hz或100Hz唤醒几乎不耗资源,即使操作系统漏洞或其他竞争条件导致单次“唤醒退出”事件丢失,系统仍能以几乎不可察觉的延迟实现干净关闭。这也意味着它可移植到不支持条件变量/文件描述符组合语义的系统。
> 你需要让退出信号真正唤醒事件循环。
这 恰恰 是condwait + condsignal的功能。
真正棘手的是第二点,其实现难度常超出预期(例如简单文件I/O也可能涉及网络驱动器)。异步IO在此场景大有可为,不过设计异步取消机制也绝非易事。
libcurl数月前处理过类似问题,结论基本一致:glibc中的线程取消机制相当棘手。简要总结(我认为准确)是:通过libnss进行的主机名查询最终需读取配置文件,而glibc的
open函数是线程取消点,若被取消则无法释放open前分配的内存。相关处理方案详见https://eissing.org/icing/posts/pthread_cancel/文档。
需注意libcurl的特殊性:其通过libnss进行的查找仅支持同步调用。而其他系统调用均可采用异步API实现,这类调用无需采用本文所述技巧即可轻松取消。
这篇读来很有趣,直到今天我才知道rseq的存在!此前我合理地认为,在多数情况下线程通常会采用简单的忙等待机制,或者至少大多数线程会以这种方式循环。我知道信号等问题很棘手,但没想到仅仅想停止一个线程竟如此困难!:)
希望未来能改进?谁知道呢?
据我所知,rseq最初由谷歌提出,用于支持其纯用户空间读取-复制-更新(RCU)实现,该方案依赖于按CPU而非按线程的数据管理。
确实令人着迷,正如我所说,直到今天才知道rseq的存在。
此前讨论:https://news.ycombinator.com/item?id=38908556
而就在一天前竟变成这样:https://news.ycombinator.com/item?id=45589156
啊,异步展开的永恒难题!
…而取消时:
我认为真正需要的只是异常(展开清理)机制和一种廉价的中断屏蔽方式。信号延迟机制恰好实现了这一点——因此with(out)-interrupts只需设置变量,无需调用系统调用。
在 Linux 环境下最简便的方法是使用 signalfd。无需处理不安全的异步信号,仅需通过文件描述符读取信号即可。
停止线程并非易事…
这不过是错误方法的变本加厉。
正确的做法是避免使用sleep()或recv()这类简单的系统调用,转而采用多路复用调用如epoll()或io_uring()。这些调用原生支持被其他线程中断,因为至少可向其传递两项等待对象:实际关注的操作,以及可由其他线程发送信号的标记。例如,你可以启动一对Unix套接字:主线程执行读取等待操作,同时在另一个线程中向套接字写入数据以触发取消信号。当然,当你采用这种方案时,其实也完全可以复用其他有用的I/O操作。
即使执行的是CPU密集型任务,也需要定期手动检查该机制的运行状态。
若使用Python的asyncio/Trio或C++的ASIO等异步框架,可请求在其他线程执行回调(这是真正的突破口,因为它能有效中断长时睡眠/接收等操作,让线程执行其他任务),此时即可对未完成的IO调用取消操作(例如调用task. cancel())。这样你就能在每个 await 点实现真正的取消机制。
(C# 中可传递 CancellationToken 对象,直接在其他线程取消即可省去额外间接操作。)
博客中已提及此问题,但关键在于有时你无法自由实现这种机制。参见此旁注及其相邻章节:https://mazzo.li/posts/stopping-linux-threads.html#fn3。
我承认:我之前没注意到这点。
但我也不认同这种观点。没错,走这条路最终必然导向全面使用协程和IO框架(虽然我认为这本身没问题)。但相比博文中提到的任何方案,为单个调用提供recv+cancel的封装(而非单纯recv等操作)显然更优。
关键在于,若要在系统调用层同时等待多项操作(此处指IO+线程间取消),正确做法是使用select、poll等专为此设计的机制。
我曾遇到此问题,解决方案是将已知阻塞的系统调用移交至独立线程池处理。这样调用线程可直接放弃等待。为优化方案,可通过SO_TIMEOUT机制为某些调用(如recvfrom())设置有界超时(约1-2秒)并支持重试,从而使终止时间可控。
这可能是最简洁且可移植的解决方案。
既然已有signalfd接口,这样做是否过于复杂?结合异步和非阻塞I/O特性,本应能构建出基础的线程取消机制,实现近乎即时的退出,不是吗?
正如我在博文中多次提及,若能将代码组织为显式取消机制,实现确实更简单。我同样引用eventfd作为实现方案之一。我的核心观点是:安全取消任意代码并无捷径可走。
我一直在使用signalfd + epoll,现在似乎可以用eventfd替代(或直接用epoll_pwait)。两种方案有何显著差异?我怀疑eventfd可能更高效(且不占用信号处理程序…SIGUSR3到底什么时候才能用上?!)。
我实在不明白。这里存在两种可能:
* 你掌控所有I/O操作,此时可通过协作机制向线程发送取消信号。
* 你无法在系统调用层级控制I/O代码(例如使用底层调用套接字的库,如数据库客户端库)… 但这种情况显然没辙。若强行终止线程会导致资源泄漏(如你所说可能锁死互斥量),若用错误码中断系统调用则库无法理解。这种基础问题根本不值得写博客大谈信号机制。
关于线程取消的唯一有价值讨论,在于能否实现协作式取消。因此我认为否定这种讨论并不公平。
若只想停止或终止所有子线程,可从/proc/pid/task读取线程ID列表,再用tgkill()向它们发送信号。
没错,这样会导致互斥锁无限期锁定。
有时这并不重要——比如你只是想让进程正常退出,避免因运行线程访问即将消失的资源而产生核心转储。
若面对创建线程却不提供关闭API的库,我认为这已是最佳方案。
题外话:这个网站设计出乎意料地合我胃口,尤其字体选得妙。
我也是。能如此细致的人实属罕见。此刻我能想到的另一人只有Gwern Branwen。
这套东西向来乱七八糟。实际操作中我始终采用异步I/O(非阻塞)配合带关闭标志的条件变量。
在Linux系统中尝试可靠地预先终止线程,始终像是在做无用功。
顺便说一句,这其实没那么重要,它们在退出时都会被清理掉。(而且不应该依赖操作系统的线程终止机制来处理这类事情。)
我认为这个问题已通过非序列化方式解决。
1. 应用程序中的任意线程先等待“目标事件”,再基于这些事件执行计算(即占用CPU一段时间),随后继续等待新事件。
2. 事件通常分为两类:一类可通过 ppoll/pselect 无限等待(涵盖信号、文件描述符和计时事件),另一类可通过 pthread_cond_wait(甚至 pthread_cond_timedwait)无限等待。pthread_cond_wait 设计上不会被信号中断,这是有益的特性。第一类事件通常用于通过非阻塞系统调用与环境交互(甚至可在子进程退出时捕获 SIGCHLD 信号,并通过 WNOHANG 模式的 waitpid() 回收进程);第二类事件则用于在核心间分配计算任务。
3. 同一线程通常不会同时使用两种等待机制,因为当线程被某类等待阻塞时,就无法执行另一类等待(例如在 ppoll() 阻塞期间无法进入 pthread_cond_wait())。换言之,应用程序设计之初就应确保线程遵循此类等待模式。
4. 特别需要说明的是,pthread_mutex_lock 设计上不可被信号中断,这并不构成问题——因为任何线程都不应无限期阻塞在互斥锁上(更严格地说:互斥锁争用应保持在较低水平)。
5. 在通过 ppoll/pselect 等待事件的线程中,使用信号指示停止需求。若此类线程的 CPU 处理耗时较长,可将其拆分为若干块,并在计算密集型操作期间定期检查 sigpending()(甚至可定期解除线程的信号阻塞,使其能接收信号——你也可以对信号采取相应措施)。
6. 在通过 pthread_cond_wait 等待事件的线程中,将条件变量关联的逻辑条件“C”放宽为((C) || stop),其中“stop”是受条件变量关联互斥锁保护的新变量。若此类线程的CPU处理耗时较长,则将其拆分为若干处理块,并定期检查“stop”状态(通过获取/释放互斥锁进行保护)。
7. 中断 ppoll/pselect 类型线程时,使用 pthread_kill 发送信号(编辑:或通过专为此目的监控的管道发送单字节;但该线程的周期性检查必须使用非阻塞读取或独立的 ppoll 处理该管道)。中断其他类型线程时,获取互斥锁→设置“停止”→调用pthread_cond_signal或pthread_cond_broadcast→释放互斥锁。
8. (补充说明)两种类型均可通过pthread_join分层回收已停止的线程。
pthread 取消机制虽非最优解,但准确描述至关重要。其包含两种模式:异步与延迟。异步模式下,线程可在任何时刻被取消,即便处于持有锁的关键区段中亦然。但在延迟模式下,线程取消操作会被推迟至下一个取消点(基本是 POSIX 函数调用的子集),因此理论上可通过在锁定状态下执行操作后再取消来实现流程安全。
但这绝不意味着人们会这样做,更不建议尝试这种做法。
文中讨论了取消点与可取消状态。在完全可控的C代码库中,pthread取消机制确实能实现,但若能掌控整个代码库,我认为更优方案是通过频繁协作式让步来确保程序及时终止。
我并非反对该观点,只是指出帖子中“线程取消与现代C++不兼容”的论断需要更细致的阐述。
> 如何干净地终止Linux线程
kill -HUP ?
while (true) { if (stop) { break; } }
要是能不用额外的break条件就终止while循环就好了…
评论前请先阅读原文。
我读过原文,但没找到支持这段代码的依据。能否解释一下?
while循环包裹整个线程,该线程执行多项任务。条件语句的作用是确保某些工作能在合理时间内完成。至少我是这么理解的。
我认为这并不清晰。若属实,应通过更多伪代码说明。此外还需考虑最终可能出现的多个退出点…
这段代码:
是否可以简化为:
无论如何,最后部分:
>> 令人沮丧的是,Linux线程中断与栈展开机制缺乏统一规范,关键区域也无法有效抵御此类展开操作。虽然技术上不存在障碍,但干净的资源释放往往被软件开发者忽视。
我认为这是“设计特性”。C语言中一切皆低级,因此我对“停止线程并清理残局”这类高级特性不抱期待——在我看来,这如同要求C语言实现垃圾回收。
是的,除非你的代码不存在单一紧循环,且停止检查不仅在循环体内执行一次,而是手动散布在代码各处(例如:将长期计算任务拆分为1、2(紧循环)、3(循环)、4等部分时,你可能需要在每个部分之间以及3的每次内部迭代中添加停止检查,但2的内部迭代中可能不需要)。(因每次检查都是原子加载操作)。
或许吧。但在我看来应该有更优的代码组织方式。你提到的场景会导致大量清理操作(这正是文章讨论的核心),使得代码调试变得极其困难:多线程环境下每个线程都存在多个退出点…… 我经手过海量多线程项目,从未需要过如此复杂的方案。通常并行执行的代码要么是管理单一资源类型,要么是无需资源分配的数值计算……若你创建的线程涉及大量资源分配,要么是架构设计存在问题,要么是在解决极其小众的特殊场景。
若线程采用“协作式多线程”机制(如Rust Tokio运行时、通用JS等),此类问题本就不存在。
由于任务频繁返回调度器,调度器可在此时执行“应停止”检查(且该逻辑可压缩至原子状态位图中,几乎不产生性能开销——仅需一次位设置检测)。随后即可正确关闭任务。所谓“正确关闭任务”并非如“清理本地资源”般简单,优雅关闭通常还需允许清理远程资源(如事务状态)。这源于“强制关闭”与“优雅关闭”的本质差异。在绝大多数情况下,我们应优先选择“优雅关闭”,仅在不可行时才强制关闭。这也是避免采用“单纯强制关闭”策略的另一原因…
解释型语言可实现类似功能且过程高度透明(若其愿意),但同样会遭遇与C语言类似的锁定问题,以及来自任意位置的强制展开/恐慌异常。
当然,严重损坏的任务可能长期阻塞。但这种情况下,通常更优解是在进程终止时将其杀死。若因“容错性”考量无法采用此方案,我认为此时更应采用“多进程容错”策略(可能跨服务器部署)。
因此尽管强制线程终止看似诱人,我发现每次需要它时,往往意味着其他地方存在严重设计缺陷。
用户空间线程与内核线程具有完全不同的语义。两者各有用途,但通常不应混为一谈。
协同多线程、协程等概念并非用户空间专属。
事实上它们早于整个“异步”运动(或任何你愿意称之为的运动)出现。
此外,本文讨论的是用户空间线程(即操作系统线程),而非内核空间线程(后者使用kthread_*而非pthread_*,且kthread停止机制是通过设置标志位指示停止,唤醒线程后等待其退出。其工作原理更接近
if(stop) exit示例,而非任何信号机制)。