如何干净地终止 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

#include

#include

#include

static std::atomic 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。其工作原理可归纳为:

向线程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 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 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 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 (%%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,由 WebHek 分享。

Copyright © 2022 星辰幻想游戏活动专区 All Rights Reserved.