如何干净地终止 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] 无论如何,当某个线程需要处理信号时:

  1. 若接收线程已屏蔽该信号,则信号将等待至屏蔽解除后处理;
  2. 若信号未被屏蔽,则可能:
    1. 被忽略;
    2. 以“默认”方式处理;
    3. 通过自定义信号处理程序处理。

信号屏蔽机制通过修改 信号屏蔽位 实现,具体操作参见sigprocmask/ pthread_sigmask 修改 信号屏蔽 实现;若线程未被阻塞,具体处理方式则由sigaction控制。

假设信号未被阻塞,路径2.a和2.b将完全由内核管理,而路径2.c将导致内核将控制权移交至用户空间信号处理程序,由其执行信号相关操作。

需要注意的是,若某个线程正在执行系统调用(例如读取套接字时被阻塞),且需要处理信号,则系统调用将在信号处理程序运行后提前返回错误代码EINTR

信号处理程序代码需遵守多种限制,但除此之外可自由执行任意操作,包括决定不将控制权交还给先前执行的代码。默认情况下,多数信号会导致程序突然终止,可能伴随核心转储。接下来我们将探讨多种利用信号终止线程的方法。

线程取消——虚妄的希望 #

首先考察一种看似完美实现目标的信号式线程终止方案:线程取消。

线程取消的API看似充满希望。pthread_cancel(tid)将“取消”线程tid。其工作原理可归纳为:

  1. 向线程tid发送特殊信号;
  2. 你使用的库(如glibcmusl)设置处理程序,使接收取消信号时线程逐步终止。

虽然存在其他细节,但核心机制就是如此。然而问题即将显现。

资源管理 + 线程取消 = 😢 #

需注意信号可能在代码任意位置触发。例如当存在如下代码时:

lock();
// critical work here
unlock();

信号可能在临界区内触发。若发生线程取消,线程可能在持有锁(如上例)、释放内存或持有未释放资源时被中断,导致清理代码永远无法执行——这显然不可取。

虽然存在缓解措施,但均非万全之策:

  • 线程取消功能可临时禁用。因此我们可在任何关键段内禁用该功能。然而某些“关键段”持续时间极长(例如某些分配内存的生命周期),且必须确保在所有相关代码中适时启用/禁用取消功能。
  • Linux线程提供pthread_cleanup_pushpthread_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机制的工作原理如下:

  • 编写需要在抢占或信号作用下原子执行的代码——即临界区。
  • 在进入关键段前,通过向内核与用户空间共享的内存位写入数据,告知内核关键段即将运行。
  • 该内存位包含:
    1. start_ip:标记关键段起始位置的指令指针;
    2. post_commit_offset:关键段的长度;
    3. abort_ip,当内核需要抢占临界区时跳转的指令指针。
  • 若内核抢占了线程,或需向线程传递信号,则检查该线程是否处于rseq临界区内。若处于该状态,则将线程的程序计数器设置为abort_ip

上述过程强制关键区成为单个连续区块(从start_ipstart_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üchenAlexandru SçvortovAlex Sayers及Alex Appetiti审阅本文草稿。

  1. [^1]本文大部分内容同样适用于进程——在Linux系统中,进程与线程的本质区别仅在于线程共享虚拟内存。本文内容同样适用于除C/C++之外直接使用Linux线程的编程语言,例如Rust的thread::spawn和zig的std::Thread::spawn↩︎
  2. [^2]在C++中,清理工作通常通过析构函数实现。此时目标是确保线程终止前完成所有未处理的析构函数调用。若 不执行清理 直接终止线程,可使用pthread_kill(tid, SIGKILL)实现。↩︎
  3. [^3]若在完全非阻塞的运行时环境中编写所有代码,最终可能与多数运行时实现相同:采用协程抽象来表达可能需要等待的操作,并自行执行所有调度。你选择的语言可能已为此提供现成框架(如C++的Seastarasync Rust等)。这种方法虽有诸多优点,但需要整个应用程序遵循特定框架进行结构化设计。本文关注的是直接使用Linux线程编写的程序,由内核负责调度。↩︎
  4. [^4]需注意:即使采用准忙循环(实际上任何软件开发场景),都可能需要处理信号机制。例如,任何使用缓冲打印(如printf)的应用程序,若被信号中断且未安装信号处理程序来刷新标准输入输出缓冲区,都可能丢失输出。↩︎
  5. [^5]恭喜你,你一定累坏了。↩︎
  6. [^6]除以零可能引发SIGFPE异常,访问未映射内存将触发SIGSEGV异常,以此类推。↩︎
  7. [^7]信号可指向线程(例如通过pthread_kill),也可指向进程(例如通过kill)。线程导向信号将直接传递给目标线程。进程导向信号发送时,内核会在进程中随机选择一个线程进行处理,且无法保证选择哪个线程。↩︎
  8. [^8]通常这种情况发生在两种情形:系统调用执行完毕时,或内核调度线程时。↩︎
  9. [^9]请注意,这种展开机制并非由任何标准强制要求,而是glibc/libstdc++特有的功能。例如使用musl时就不会进行展开,析构函数也不会运行。依赖此展开机制时还需注意其他异常现象↩︎
  10. [^10]在处理C语言而非C++时,可通过[__attribute__((cleanup))]实现清理机制。其工作原理与析构函数极为相似,却能规避noexcept带来的困扰。理论上这套方案相当完善,但实践中C语言编程习惯截然不同。若试图完全采用此风格编写C项目,无异于逆水行舟。况且这种方案本身存在缺陷,详见下一小节的阐述。↩︎
  11. [^11]Rust会在恐慌期间对持有锁进行污染处理,这至少能在类似场景中维持安全性(但无法保证程序正常运行),前提是Rust运行时将线程取消等操作等效视为恐慌处理。↩︎
  12. [^12]请注意,只要启用取消机制,并在每个循环迭代中至少设置一个启用取消的区段和取消点,循环中可包含任意数量可能无限阻塞的系统调用。↩︎
  13. [^13]USR1默认未被占用,因此可便捷地用于此目的。我们还可屏蔽SIGINT/SIGTERM信号,仅在主线程启用这些信号以协调子进程的终止。↩︎
  14. [^14]不过存在一种硬核实现方式,详见最后一节↩︎
  15. [^15]建议在处理程序中设置停止变量,以便区分因处理程序运行引发的中断与其他中断。为简洁起见,代码中省略了该变量。↩︎
  16. [^16]需注意:若能完全依赖基于文件描述符的系统调用,则可完全省略信号机制,转而使用eventfd来传递终止信号。↩︎
  17. [^17]有趣的是,FUTEX_FD本可让我们在ppoll中使用futex,但因其存在竞争条件问题,该功能在Linux 2.6.25中被移除——这实属Linux罕见地破坏用户可见API的案例。↩︎
  18. [^18]此技巧的构思源自 Peter Cawley。↩︎
  19. [^19]关于rseq的文档仍相当匮乏,但可在此处查阅包含简明示例的入门指南。↩︎
  20. [^20]PhantomZorba 在 lobste.rs 上的评论 指出,无需 rseq 支持即可实现本文所述技巧的一种变体——通过在信号处理程序内部检查程序计数器来实现。不仅如此,musl正是通过这种方式实现了无竞争线程取消机制。相关讨论可参见LKMLlwn.net 的相关讨论。↩︎
  21. [^21]请注意,rseq 关键区不能包含系统调用。然而,若临界区末条指令恰为系统调用,则线程不可能同时处于系统调用与临界区中:一旦syscall指令执行并启动系统调用,程序计数器已越过临界区边界。↩︎

本文文字及图片出自 How to stop Linux threads cleanly

共有 67 条评论

  1. 这让我想起雷蒙德·陈关于为何不该使用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。当内核在系统关闭时主动与你作对时,祝你好运能遵循这条建议。

  2. 这篇文章很好地解释了为什么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-

  3. > 既然线程取消是通过异常实现的,且线程取消可能发生在任意位置

    不,线程取消不会发生在任意位置。或者说不必如此。

    取消机制分为两种类型:异步取消和延迟取消。

    POSIX 提供了动态配置线程取消类型的 API:pthread_setcanceltype。

    此外,取消功能还可被启用或禁用。

      int pthread_setcancelstate(int state, int *oldstate); // PTHREAD_CANCEL_ENABLE, PTHREAD_CANCEL_DISABLE
      int pthread_setcanceltype(int type, int *oldtype);    // PTHREAD_CANCEL_DEFERRED, PTHREAD_CANCEL_ASYNCHRONOUS
    

    不言而喻,线程仅应在安全环境中启用异步取消功能——例如在资源分配过程中不会被中断,或避免操作可能导致数据结构损坏的场景。

    • 关于可取消状态及其作用,我在以下声明后稍作阐述:https://mazzo.li/posts/stopping-linux-threads.html#controlle…。事后看来,我在讨论C++时本应提前提及该章节。我的核心观点是:将C++异常与线程取消结合使用风险极高,个人认为最好避免。

      • 遗憾得知他们至今仍未解决这个问题。二十多年前我在Linux领域非常活跃,参与过glibc等项目的开发。当时C++尚未支持线程功能。曾有一段时期,取消操作无法触发C++异常展开机制,仅能通过PTHREAD_CLEANUP_PUSH处理器实现。因此当时取消操作与C++异常的组合堪称灾难性组合。

  4. 针对中断长期运行的系统调用,另有解决方案:

    安装一个空的SIGINT信号处理程序(不带SA_RESTART),然后运行循环。

    当线程需要停止时:

    * 设置停止标志

    * 使用pthread_kill或tgkill向线程发送SIGINT信号

    * 系统调用将以EINTR错误失败

    * 检查EINTR错误和停止标志,确认需要清理并终止

    当然多数代码在遇到EINTR时会重试,这要求对所有系统调用代码进行管控——在使用库文件时这几乎不可行。

    编辑:原帖恰恰描述了此方法及其缺陷,只是我之前未注意到。

  5. 若条件允许(无需无限期阻塞IO),建议直接采用简单协调模型:

      * 通过原子布尔变量控制线程是否停止;
      * 线程不执行任何无限等待的系统调用;
      * 线程在空闲时使用 pthread_cond_wait(或等效的 C++ 标准库封装)替代睡眠。
    

    终止线程时,设置停止标志并向条件变量发送信号。(Linux 底层实现使用 futex。)

    • 过度依赖原子布尔值检查易引发竞争条件。我认为将事件循环设计为消息队列更为优雅,通过队列消息指示停止时机。

      • > 过度依赖原子布尔值检查容易引发竞争条件。

        实际上并非如此。这种极简协议本身是无竞争的。

      • 采用队列机制意味着必须处理完队列才能停止。这确实能实现干净停止,但若因队列过长导致任务请求过时而需终止线程时,此方案效果有限。

        或许可为停止消息添加队列跳过功能…但若仅针对停止消息,直接设置原子布尔值stop后发送停止消息即可。若线程刚好错过stop布尔值而等待消息,则会收到停止消息;若队列过长,则会收到stop布尔值。

        ps,你好

      • 每个事件循环都面临因长期计算导致阻塞的问题。这很棘手…

        • 若在事件循环中反复轮询原子布尔值,同样会遇到此问题。

          • 为何如此?轮询布尔值仅需几个机器周期。

            (除原子布尔外还有其他类型吗?值要么为真要么为假,若真值生效后无人能重置为假,我看不见风险。这是CPU而非FPGA。)

            • 该类型虽名为原子类型,但原子性并非其唯一特性。原子类型还提供了内存顺序控制,默认采用顺序一致性(seq_cst,最高级别)。

              若无内存屏障强制内存顺序,线程A对布尔值的写入无法保证被线程B观察到。这在初始化后尤为关键——线程A将布尔值设为false时,线程B可能观察到true、false或无效值;在状态转换后同样重要——线程B可能无法察觉布尔值已从false翻转为true。

              [编辑:上述推论实际意义存疑;正如楼主所言“这是CPU而非FPGA”;现代多核共享内存CPU均具备一致性缓存]

              • 那么在下一次循环迭代时就会被观察到。若这点至关重要,那么此技术确实不可取。

                • 若无原子操作,编译器根本不会考虑下一次循环,直接进入无限循环(过去通常用volatile标记替代)

            • 在x86架构上,松弛排序是免费的,这足以满足此场景需求。

      • 不同意。我认为这会诱使后续开发者添加阻塞处理的消息。

        采用简单清晰的循环机制:检测请求停止标志的同时确认停止标志,效果相当理想。可将其封装为同步“停止”函数:调用方设置标志后,通过条件变量配合pthread_cond_timedwait(Windows平台可用waitforxxxobject)进行定时等待确认。

        • 降低检查稳定性并不能解决这个问题。

          记得本文示例代码类似这样:

             while (check_flag())
             {
                 do_stuff();
                 sleep_like_a_moron_instead_of_proper_blocking_mechanism(1second);
             }
          

          若 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在此场景大有可为,不过设计异步取消机制也绝非易事。

  6. libcurl数月前处理过类似问题,结论基本一致:glibc中的线程取消机制相当棘手。简要总结(我认为准确)是:通过libnss进行的主机名查询最终需读取配置文件,而glibc的open函数是线程取消点,若被取消则无法释放open前分配的内存。

    相关处理方案详见https://eissing.org/icing/posts/pthread_cancel/文档。

    • 需注意libcurl的特殊性:其通过libnss进行的查找仅支持同步调用。而其他系统调用均可采用异步API实现,这类调用无需采用本文所述技巧即可轻松取消。

  7. 这篇读来很有趣,直到今天我才知道rseq的存在!此前我合理地认为,在多数情况下线程通常会采用简单的忙等待机制,或者至少大多数线程会以这种方式循环。我知道信号等问题很棘手,但没想到仅仅想停止一个线程竟如此困难!:)

    希望未来能改进?谁知道呢?

    • 据我所知,rseq最初由谷歌提出,用于支持其纯用户空间读取-复制-更新(RCU)实现,该方案依赖于按CPU而非按线程的数据管理。

      • 确实令人着迷,正如我所说,直到今天才知道rseq的存在。

  8. 啊,异步展开的永恒难题!

      (without-interrupts 
        (acquire-resource)
        (unwind-protect
            (with-local-interrupts
              (do-jobs-might-block-or-whatever))
          (release-resource)))
    

    …而取消时:

      (interrupt-thread thread (lambda () (abort-thread)))
    

    我认为真正需要的只是异常(展开清理)机制和一种廉价的中断屏蔽方式。信号延迟机制恰好实现了这一点——因此with(out)-interrupts只需设置变量,无需调用系统调用。

  9. 在 Linux 环境下最简便的方法是使用 signalfd。无需处理不安全的异步信号,仅需通过文件描述符读取信号即可。

  10. 停止线程并非易事…

  11. 这不过是错误方法的变本加厉。

    正确的做法是避免使用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秒)并支持重试,从而使终止时间可控。

        这可能是最简洁且可移植的解决方案。

  12. 既然已有signalfd接口,这样做是否过于复杂?结合异步和非阻塞I/O特性,本应能构建出基础的线程取消机制,实现近乎即时的退出,不是吗?

    • 正如我在博文中多次提及,若能将代码组织为显式取消机制,实现确实更简单。我同样引用eventfd作为实现方案之一。我的核心观点是:安全取消任意代码并无捷径可走。

      • 我一直在使用signalfd + epoll,现在似乎可以用eventfd替代(或直接用epoll_pwait)。两种方案有何显著差异?我怀疑eventfd可能更高效(且不占用信号处理程序…SIGUSR3到底什么时候才能用上?!)。

      • 我实在不明白。这里存在两种可能:

        * 你掌控所有I/O操作,此时可通过协作机制向线程发送取消信号。

        * 你无法在系统调用层级控制I/O代码(例如使用底层调用套接字的库,如数据库客户端库)… 但这种情况显然没辙。若强行终止线程会导致资源泄漏(如你所说可能锁死互斥量),若用错误码中断系统调用则库无法理解。这种基础问题根本不值得写博客大谈信号机制。

        关于线程取消的唯一有价值讨论,在于能否实现协作式取消。因此我认为否定这种讨论并不公平。

  13. 若只想停止或终止所有子线程,可从/proc/pid/task读取线程ID列表,再用tgkill()向它们发送信号。

    • 没错,这样会导致互斥锁无限期锁定。

      • 有时这并不重要——比如你只是想让进程正常退出,避免因运行线程访问即将消失的资源而产生核心转储。

        若面对创建线程却不提供关闭API的库,我认为这已是最佳方案。

  14. 题外话:这个网站设计出乎意料地合我胃口,尤其字体选得妙。

    • 我也是。能如此细致的人实属罕见。此刻我能想到的另一人只有Gwern Branwen。

  15. 这套东西向来乱七八糟。实际操作中我始终采用异步I/O(非阻塞)配合带关闭标志的条件变量。

    在Linux系统中尝试可靠地预先终止线程,始终像是在做无用功。

    顺便说一句,这其实没那么重要,它们在退出时都会被清理掉。(而且不应该依赖操作系统的线程终止机制来处理这类事情。)

  16. 我认为这个问题已通过非序列化方式解决。

    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分层回收已停止的线程。

  17. pthread 取消机制虽非最优解,但准确描述至关重要。其包含两种模式:异步与延迟。异步模式下,线程可在任何时刻被取消,即便处于持有锁的关键区段中亦然。但在延迟模式下,线程取消操作会被推迟至下一个取消点(基本是 POSIX 函数调用的子集),因此理论上可通过在锁定状态下执行操作后再取消来实现流程安全。

    但这绝不意味着人们会这样做,更不建议尝试这种做法。

    • 文中讨论了取消点与可取消状态。在完全可控的C代码库中,pthread取消机制确实能实现,但若能掌控整个代码库,我认为更优方案是通过频繁协作式让步来确保程序及时终止。

      • 我并非反对该观点,只是指出帖子中“线程取消与现代C++不兼容”的论断需要更细致的阐述。

  18. > 如何干净地终止Linux线程

    kill -HUP ?

  19. while (true) { if (stop) { break; } }

    要是能不用额外的break条件就终止while循环就好了…

    • 评论前请先阅读原文。

      • 我读过原文,但没找到支持这段代码的依据。能否解释一下?

        • while循环包裹整个线程,该线程执行多项任务。条件语句的作用是确保某些工作能在合理时间内完成。至少我是这么理解的。

          • 我认为这并不清晰。若属实,应通过更多伪代码说明。此外还需考虑最终可能出现的多个退出点…

  20. 这段代码:

      while (true) {
        if (stop) { break; }
        // 执行合理时间内完成的工作
      }
    

    是否可以简化为:

      While(!stop){
        执行操作;
      }
    

    无论如何,最后部分:

    >> 令人沮丧的是,Linux线程中断与栈展开机制缺乏统一规范,关键区域也无法有效抵御此类展开操作。虽然技术上不存在障碍,但干净的资源释放往往被软件开发者忽视。

    我认为这是“设计特性”。C语言中一切皆低级,因此我对“停止线程并清理残局”这类高级特性不抱期待——在我看来,这如同要求C语言实现垃圾回收。

    • 是的,除非你的代码不存在单一紧循环,且停止检查不仅在循环体内执行一次,而是手动散布在代码各处(例如:将长期计算任务拆分为1、2(紧循环)、3(循环)、4等部分时,你可能需要在每个部分之间以及3的每次内部迭代中添加停止检查,但2的内部迭代中可能不需要)。(因每次检查都是原子加载操作)。

      • 或许吧。但在我看来应该有更优的代码组织方式。你提到的场景会导致大量清理操作(这正是文章讨论的核心),使得代码调试变得极其困难:多线程环境下每个线程都存在多个退出点…… 我经手过海量多线程项目,从未需要过如此复杂的方案。通常并行执行的代码要么是管理单一资源类型,要么是无需资源分配的数值计算……若你创建的线程涉及大量资源分配,要么是架构设计存在问题,要么是在解决极其小众的特殊场景。

  21. 若线程采用“协作式多线程”机制(如Rust Tokio运行时、通用JS等),此类问题本就不存在。

    由于任务频繁返回调度器,调度器可在此时执行“应停止”检查(且该逻辑可压缩至原子状态位图中,几乎不产生性能开销——仅需一次位设置检测)。随后即可正确关闭任务。所谓“正确关闭任务”并非如“清理本地资源”般简单,优雅关闭通常还需允许清理远程资源(如事务状态)。这源于“强制关闭”与“优雅关闭”的本质差异。在绝大多数情况下,我们应优先选择“优雅关闭”,仅在不可行时才强制关闭。这也是避免采用“单纯强制关闭”策略的另一原因…

    解释型语言可实现类似功能且过程高度透明(若其愿意),但同样会遭遇与C语言类似的锁定问题,以及来自任意位置的强制展开/恐慌异常。

    当然,严重损坏的任务可能长期阻塞。但这种情况下,通常更优解是在进程终止时将其杀死。若因“容错性”考量无法采用此方案,我认为此时更应采用“多进程容错”策略(可能跨服务器部署)。

    因此尽管强制线程终止看似诱人,我发现每次需要它时,往往意味着其他地方存在严重设计缺陷。

    • 用户空间线程与内核线程具有完全不同的语义。两者各有用途,但通常不应混为一谈。

      • 协同多线程、协程等概念并非用户空间专属。

        事实上它们早于整个“异步”运动(或任何你愿意称之为的运动)出现。

        此外,本文讨论的是用户空间线程(即操作系统线程),而非内核空间线程(后者使用kthread_*而非pthread_*,且kthread停止机制是通过设置标志位指示停止,唤醒线程后等待其退出。其工作原理更接近if(stop) exit示例,而非任何信号机制)。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号