原以为学了很多东西,停止一个多线程程序很容易,信号什么的一顿上,仔细思考发现并不简单。

背景

  • 现在多线程应用非常常见,功能强大的同时也有很多新问题,为了便于描述,虚构一个具体场景。
  • 假设有这样一个程序:
    • 一个main thread,三个worker thread。
    • main thread负责初始化配置,创建其他线程,并join等待。
    • worker thread中各自有自己的运行逻辑,以轮询的方式长期运行。

终止信号

  • 在main thread中,程序初始化时,注册signal handler,即可捕捉到SIGTERM。
  • 但需要明确一点,信号处理函数中,只能调用可重入函数,自身修改其他状态时也要格外小心。

方案一

  • 在signal handler中,flush进程所有状态,最后调用exit退出。
  • 优点
    • 基本没什么优点,除了想法简单。
  • 缺点
    • 需要记住所有线程的工作逻辑,知道如何flush。
    • flush是否可重入?是否有race condition。这一点上无法预测,很多逻辑是调用的第三方库。

方案二

  • signal handler调用pthread_cancel。
  • 优点
    • 将exit过程交给目标线程处理。
  • 缺点
    • 引入了pthread库(这可能也不算缺点,但总的来说依赖越单一、越少,也意味着越简洁安全)
    • 目标线程仍然只能使用pthread_cleanup_push来预置handler。
    • cancelpoint的设定较为复杂,它大量考虑了程序状态,但真正重要的是业务逻辑状态是否处于可以cancel的位置。

方案三

  • signal handler修改各个线程可见的状态量,比如一个全局数组。
  • 优点
    • exit过程由目标线程处理。
    • 语义更强大,数组中的变量可以表示更多状态,不仅仅是cancel。
    • cancelpoint交给了线程自己处理,在可以退出的位置,判断状态量。
  • 缺点
    • 全局变量就是有害的(迫真。
    • 可扩展性虽然有,但编码难度很高。如果要增加一种状态,比如暂停线程,那么所有工作线程的代码都需要改动,或者就得容忍,不是所有线程都支持所有状态。不支持所有状态是正常的,但全局数组的设计,让程序员难以控制这个缺陷,留下了容易犯错的可能性。

方案四

  • std::thread以某个对象的方法为主体,那么,让该对象提供run方法的同时,也提供一个cancel方法。内部两个函数的交互则任由线程自己实现,可以是对象私有变量,也可以是thread_local。run方法在固定的点去检查状态。
  • 优点
    • stop由目标线程处理。
    • 支持多种状态,比如stop,restart等。
    • 支持不同线程不同实现,某些线程可能不支持stop,这无所谓,直接从类的接口上就能看出。也可以用一个父类,强制子类都实现这些方法。这取决于业务逻辑和代码组织的需求。
  • 缺点
    • main thread需要保留其他线程的对象。
    • 状态变更函数都得是可重入函数(只写不读,或者直接取消多次进入即可,感觉问题也不大)。

后记

  • 起初想了很多关于Exception的问题,在信号处理函数中写代码需要非常谨慎,怕引入并发错误。
  • 最后的几个实现写起来,发现要么是我处理不掉的问题,要么就简单到不会出exception。
  • RAII可能还是更多用在资源安全上,线程状态维护是另一个问题了。

附录

Pthreads Cancel Coint

pthreads标准指定了几个取消点,其中包括:

  • 通过pthread_testcancel调用以编程方式建立线程取消点。
  • 线程等待pthread_cond_wait或pthread_cond_timewait()中的特定条件。
  • 被sigwait(2)阻塞的函数。
  • 一些标准的库调用。通常,这些调用包括线程可基于阻塞的函数。