如何干净地退出一个多线程的C++程序?

35

我在程序中创建多个线程。当按下 Ctrl-C 时,会调用一个信号处理程序。在信号处理程序中,我最后放置了 exit(0)。问题是有时程序会安全地终止,但其他时候,我会收到运行时错误信息

abort() has been called

那么避免这个错误可能有哪些解决方案呢?


你需要“加入”创建的线程。如果不这样做,而只是退出一个线程,则可能会有未结束的线程仍然存在。 - mathreadler
14
一般来说,在信号处理程序中调用exit()是未定义的行为。 - psmears
1
我认为你可以调用 _exit()_Exit() - Zan Lynx
1
@mathreadler: 我不确定这是否是ISO C++的要求,但在现代POSIX系统上,exit()_exit()需要终止整个进程,而不仅仅是当前线程。这就是为什么glibc2.3(多年前)更改了_exit(2),使用Linux的sys_exit_group系统调用而不是sys_exit,终止所有线程(而不调用任何析构函数等)。Linux上的exit(3)也使用sys_exit_group - Peter Cordes
@PeterCordes 很可能你是对的。我没有编写过很多多线程程序。只是在年轻时玩了一下。 - mathreadler
4个回答

32

通常的方式是设置一个原子标志(例如std::atomic<bool>),所有线程(包括主线程)都会检查这个标志。如果设置了该标志,则子线程退出,主线程开始join子线程。然后你可以干净地退出。

如果你在线程中使用了std::thread,那么这可能是导致崩溃的原因之一。你必须在std::thread对象被销毁之前join线程。


6
实际上?对于一个简单的bool标志,没有太多影响。理论上?是的,这会导致数据竞争。但最好始终保持安全,因此您应该使用例如std::atomic<bool> - Some programmer dude
5
有用的额外阅读材料:sig_atomic_t 和 std::atomic<> 可互换使用吗? - user4581301
24
我倾向于说,非原子布尔变量用作线程退出标志时明显会导致问题。主要问题在于线程经常在循环中检查该标志 while(! exitThread() ) { doWork(); }。如果它是一个非原子变量,则可能被提升到循环外部 if (exitThread) return; while(true) { doWork(); } - MSalters
4
@Someprogrammerdude,我想你将bool定义为实际上很安全,因为它是如此小的类型。但原子操作不仅对于其操作的原子性很重要,还涉及内存排序,这使得它们在多线程代码中成为必需。 - phön
5
首先,volatile 并不提供线程安全性。请停止反复强调这一点。第二,标准并没有声明原子操作中“所有核心立即看到新值”的说法。放松的内存顺序在这里已经足够,并且在理论上对例如 x86-64 等系统没有直接开销,除非您使用 RMW(包括 CAS)。事实上,我刚刚在 godbolt 上使用 MSVC 和 GCC 7.0 进行了测试,bool 的放松存储和加载被转换为简单的 MOV 操作。 - Arne Vogel
显示剩余6条评论

19

有人提到了将信号处理程序设置为std::atomic<bool>并让所有其他线程定期检查该值以知道何时退出。

只要您的所有其他线程在合理频率内定期唤醒即可,这种方法就可以很好地工作。

但是,如果其中一个或多个线程纯粹是事件驱动的,则这种方法并不完全令人满意-在事件驱动程序中,线程只应在有任务需要执行时才会唤醒,这意味着它们可能会休眠数天或数周。 如果它们被强制每(若干)毫秒唤醒一次,仅以轮询原子布尔标志,那么这将使本来非常高效的CPU程序变得不那么高效,因为现在每个线程都在短时间内定期唤醒,全年无休,这可能特别问题严重,如果您试图节省电池寿命,因为它可能会阻止CPU进入节能模式。

避免轮询的替代方法是:

  1. 启动时,使你的主线程通过调用pipe()socketpair() 创建fd-pipe或套接字对。
  2. 使主线程(或可能是其他负责线程)将接收套接字包含在其可读准备选择() fd_set中(或者对于poll()或任何阻塞的等待IO功能采取类似的操作)
  3. 执行信号处理程序时,让它写入一个字节(任何字节都可以,不需要管它)到发送套接字中。
  4. 这将导致主线程的select()调用立即返回,并且FD_ISSET(receivingSocket)会因为收到的字节而变为true。
  5. 此时,您的主线程知道该退出进程了,所以它可以开始指示所有子线程开始关闭(通过方便的机制;原子布尔或管道或其他东西)
  • 当所有子线程开始关闭时,主线程应该调用每个子线程的join()方法,以确保在main()函数返回之前所有子线程都已经退出。这是必要的,因为否则存在竞态条件的风险——例如,后续的清理代码可能会偶尔释放一个资源,而仍在执行的子线程仍在使用它,从而导致崩溃。

  • 5
    使用std::condition_variable<>比使用套接字更具可移植性,而且不会占用太多系统资源。 - AnotherParker
    1
    @AnotherParker 如果你有一个在 select() 中被阻塞的线程,当条件变量被发出信号时,你如何让该线程唤醒并从 select() 中返回? - Jeremy Friesner
    "select()" 不具备可移植性。不幸的是,来自 Boost 的异步 IO 库尚未被纳入标准 C 标准中,但最终会有所改变... 例如参考 https://www.boost.org/doc/libs/1_55_0/doc/html/boost_asio/examples/cpp11_examples.html - AnotherParker
    select() 不是 C++ 语言标准的一部分,但尽管如此,在我使用过的每个平台上它都得到了很好的支持。(当然,我主要使用的是桌面平台,在低级嵌入式软件领域可能会有所不同)。顺便说一下,如果您可以使用 Boost 异步 I/O 库和条件变量来描述或演示解决此问题的解决方案,您可以将其作为单独的答案发布;这可能会对某些人有所帮助。 - Jeremy Friesner

    14

    首先你必须接受的是,线程很难处理。

    "使用线程的程序"与 "使用内存的程序" 一样通用,因此你的问题类似于 "如何在使用内存的程序中不破坏内存?"

    解决线程问题的方法是限制如何使用线程和线程的行为。

    如果你的线程系统是由多个小操作组成的数据流网络,并且隐含保证如果一个操作太大,它将被拆分成更小的操作并/或者在系统中做检查点,那么关闭看起来就会非常不同,而如果你有一个线程加载外部DLL文件,然后运行它,时间从1秒到10小时甚至无限长,则情况就完全不同了。

    像C++中的大多数东西一样,解决你的问题将涉及所有权、控制和(最后的手段)黑科技。

    像C++中的数据一样,每个线程都应该有主人。线程的所有者应该对该线程有重要的控制力,并能够告诉它应用程序正在关闭。关闭机制应该是强大且经过测试的,并理想情况下连接到其他机制(如早期终止推测任务)。

    你调用exit(0)的事实是一个不好的信号。这意味着你的执行线程没有清洁的关闭路径。从那里开始;中断处理程序应通知线程开始关闭,然后你的主线程应该优雅地关闭。所有堆栈帧都应该解开,数据应该被清理掉等等。

    然后,应将允许清洁而快速关闭的相同逻辑应用于你的线程处理代码。

    任何告诉你只需要使用条件变量/原子布尔值和轮询就可以解决问题的人都是在欺骗你。如果你幸运的话,它只能在简单情况下工作,并且确定它是否可靠将非常困难。


    线程在某些语言中很难。 - Boppity Bop
    @BoppityBop 大多数是图灵完备的语言。有许多语言伪装线程操作很简单。它们制作的应用程序不可靠,但通常能正常运行,所以就发布了。据我所知,试图实现稳健易用的线程操作最终会涉及到从Go/Rust到Javascript等各种语言,其中人们都会发现线程操作具有挑战性。C#和Java是两个设计上存在问题的线程操作的例子,如果你正在考虑的话。诚然,许多这些语言支持更容易的关闭:但是终止C++进程也很容易。 - undefined

    3

    除了Some programmer dude的答案和评论区的讨论,你需要将控制线程终止的标志设置为atomic类型。

    考虑以下情况:

    bool done = false;
    void pending_thread()
    {
        while(!done)
        {
            std::this_thread::sleep(std::milliseconds(1));
        }
        // do something that depends on working thread results
    }
    
    void worker_thread()
    {
        //do something for pending thread
        done = true;
    }
    

    这里的工作线程可以是你的main线程,done是你线程的终止标志,但是待处理的线程需要在退出之前处理工作线程提供的数据。

    这个例子存在竞争条件和未定义行为,真实世界中很难找到实际问题所在。

    现在使用std::atomic进行了修正:

    std::atomic<bool> done(false);
    void pending_thread()
    {
        while(!done.load())
        {
            std::this_thread::sleep(std::milliseconds(1));
        }
        // do something that depends on working thread results
    }
    
    void worker_thread()
    {
        //do something for pending thread
        done = true;
    }
    

    您可以退出线程而不必担心竞争条件或未定义行为。


    2
    除了这个例子使用轮询,这是一个众所周知的反模式。这将破坏您的操作系统线程调度程序,导致系统性能不佳并浪费电力。 - AnotherParker
    1
    @AnotherParker 没错,这种情况应该使用 mutexcondition_variable 来处理。但是这个例子只是为了演示在没有 atomic 的情况下可能会出现问题的情况。 - HMD
    @AnotherParker 另一件事是,在某些情况下仍然可以使用这种方法。考虑有一个渲染线程等待另一个线程生成数据帧以进行渲染,您可能希望在未提供数据时显示其他信息或加载动画。我知道您可以使用条件变量的超时来实现这一点,但这将具有与此相同的效果。这并不总是系统性能差或浪费电力的情况,有时您的需求是不同的。 - HMD

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接