从线程内分叉是否安全

Is it safe to fork from within a thread?

本文关键字:是否 安全 分叉 线程      更新时间:2023-10-16

让我解释一下:我已经在Linux上开发了一个应用程序,该应用程序分叉并执行外部二进制文件并等待它完成。结果由 fork + 进程独有的 shm 文件传达。整个代码封装在一个类中。

现在我正在考虑线程化该过程以加快速度。具有许多不同的类函数实例并发(使用不同的参数)分叉和执行二进制文件,并使用它们自己唯一的 shm 文件传达结果。

这个线程安全吗?如果我在线程内分叉,除了安全之外,还有什么需要注意的吗?任何建议或帮助将不胜感激!

问题是 fork() 只复制调用线程,子线程中持有的任何互斥锁都将永远锁定在分叉的子线程中。pthread解决方案是pthread_atfork()处理程序。这个想法是你可以注册 3 个处理程序:一个预分叉、一个父处理程序和一个子处理程序。当fork()发生时,在分叉之前调用预分叉,并期望获得所有应用程序互斥锁。父进程和子进程都必须分别释放父进程和子进程中的所有互斥锁。

不过,这还不是故事的结局!库调用 pthread_atfork 来注册特定于库的互斥锁的处理程序,例如 Libc 就是这样做的。这是一件好事:应用程序不可能知道第三方库持有的互斥锁,因此每个库都必须调用pthread_atfork以确保在发生fork()时清理自己的互斥锁。

问题是为不相关的库调用pthread_atfork处理程序的顺序是未定义的(这取决于程序加载库的顺序)。因此,这意味着从技术上讲,由于竞争条件,预分叉处理程序内部可能会发生死锁。

例如,考虑以下序列:

  1. 线程 T1 调用fork()
  2. libc 预分叉处理程序在 T1 中调用(例如,T1 现在持有所有 libc 锁)
  3. 接下来,在线程 T2 中,第三方库 A 获取自己的互斥锁 AM,然后进行需要互斥锁的 libc 调用。这会阻止,因为 libc 互斥体由 T1 持有。
  4. 线程 T1 运行库 A 的预分叉处理程序,该处理程序阻止等待获取由 T2 持有的 AM。

存在您的死锁,它与您自己的互斥锁或代码无关。

这实际上发生在我曾经参与过的一个项目中。我当时发现的建议是选择叉子或线程,但不能两者兼而有之。但对于某些应用程序,这可能不切实际。

只要

非常小心 fork 和 exec 之间的代码,在多线程程序中分叉是安全的。在该范围内,您只能进行重新进入(也称为异步安全)系统调用。从理论上讲,您不允许在那里 malloc 或自由,尽管在实践中默认的 Linux 分配器是安全的,并且 Linux 库开始依赖它 最终结果是您必须使用默认分配器。

回到时间的黎明,我们将线程称为"轻量级进程",因为虽然它们的行为很像进程,但它们并不相同。 最大的区别是,根据定义,线程位于一个进程的同一地址空间中。 这具有优点:从线程切换到线程的速度很快,它们固有地共享内存,因此线程间通信很快,线程的创建和处置速度很快。

这里的区别在于"重量级进程",它们是完整的地址空间。 一个新的重量级进程由 fork(2) 创建。 随着虚拟内存进入 UNIX 世界,vfork(2) 和其他一些内存得到了增强。

fork(2) 复制进程的整个地址空间,包括所有的寄存器,并将该进程置于操作系统调度程序的控制之下;下次调度器出现时,指令计数器在下一条指令上拾取 -- 分叉的子进程是父进程的克隆。 (如果你想运行另一个程序,比如因为你正在编写一个 shell,你可以在 fork 后面用一个 exec(2) 调用,用一个新程序加载新的地址空间,替换克隆的程序。

基本上,你的答案就埋藏在这个解释中:当你有一个具有许多 LWP 线程的进程并且你分叉了该进程时,你将有两个独立的进程,其中包含许多线程,并发运行。

这个技巧甚至很有用:在许多程序中,你有一个父进程,它可能有许多线程,其中一些线程会分叉新的子进程。 (例如,HTTP 服务器可能会这样做:与端口 80 的每个连接都由线程处理,然后可以分叉 CGI 程序等子进程;然后调用 exec(2) 来运行 CGI 程序来代替父进程关闭。

虽然你可以使用Linux的NPTL pthreads(7)支持你的程序,但线程在Unix系统上是一个尴尬的契合,正如你在fork(2)问题中所发现的那样。

由于fork(2)在现代系统上是一种非常便宜的操作,因此当您有更多的处理要执行时,最好只fork(2)流程。这取决于您打算来回移动多少数据,fork ed进程的无共享理念有利于减少共享数据错误,但确实意味着您需要创建管道以在进程之间移动数据或使用共享内存(shmget(2)shm_open(3))。

但是,如果选择使用线程处理,则可以fork(2)一个新进程,fork(2)手册页中的以下提示:

   *  The child process is created with a single thread — the
      one that called fork().  The entire virtual address space
      of the parent is replicated in the child, including the
      states of mutexes, condition variables, and other pthreads
      objects; the use of pthread_atfork(3) may be helpful for
      dealing with problems that this can cause.

只要您快速调用exec()或在分叉子进程中_exit(),您在实践中就可以了。

您可能希望改用posix_spawn(),这可能会做正确的事情。

我在线程中fork()的经历真的很糟糕。该软件通常很快就会失败。

我已经找到了几种解决方案,尽管您可能不太喜欢它们,但我认为这些通常是避免接近不可调试错误的最佳方法。

  1. 分叉优先

    假设您在开始时就知道所需的外部进程数量,您可以预先创建它们,并让它们坐在那里等待事件(即从阻塞管道读取,等待信号量等)。

    一旦你分叉

    了足够多的子进程,你就可以自由地使用线程并通过管道、信号量等与这些分叉的进程进行通信。从创建第一个线程的那一刻起,就不能再调用 fork 了。请记住,如果您使用的是可能创建线程的第三方库,则必须在fork()调用发生后使用/初始化这些库。

    请注意,然后您可以开始在主进程和fork()进程中使用线程。

  2. 了解您的状态

    在某些情况下,您可以停止所有线程以启动进程,然后重新启动线程。这有点类似于第 (1) 点,因为您不希望线程在调用 fork() 时运行,尽管它需要一种方法让您了解当前在软件中运行的所有线程(这并不总是可能的第三方库)。

    请记住,使用等待"停止线程"是行不通的。您必须与线程一起加入才能完全退出,因为等待需要互斥锁,并且在调用fork()时需要解锁这些互斥锁。您只是无法知道等待何时会解锁/重新锁定互斥锁,这通常是您卡住的地方。

  3. 选择一个或另一个

    另一个

    明显的可能性是选择一个或另一个,而不在乎你是否会干扰其中一个。这是迄今为止在您的软件中尽可能最简单的方法。

  4. 仅在必要时创建线程

    在某些软件中,人们在一个函数中创建一个或多个线程,使用所述线程,然后在退出函数时连接所有线程。这在某种程度上等同于上面的第 (2) 点,只有您(微)根据需要管理线程,而不是创建坐在那里并在必要时使用的线程。这也将起作用,请记住,创建线程是一个昂贵的调用。它必须分配一个带有堆栈和自己的寄存器集的新任务......这是一个复杂的功能。但是,这使您可以轻松知道线程何时运行,并且除了从这些函数中之外,您可以自由调用fork()

在我的编程中,我使用了所有这些解决方案。我使用点(2)是因为log4cplus的线程版本,我需要对软件的某些部分使用fork()

正如其他人提到的,如果您使用fork()然后调用execve()那么这个想法是在两个调用之间尽可能少地使用。这可能在 99.999% 的时间内有效(许多人使用system()popen()也取得了相当不错的成功,这些也做了类似的事情)。事实是,如果您没有命中其他线程持有的任何互斥锁,那么这将毫无问题地工作。

另一方面,如果像我一样,您想做一个fork()并且永远不要调用execve(),那么当任何线程运行时,它都不太可能正常工作。

<小时 />

到底发生了什么?

问题是fork()只创建当前任务的单独副本(Linux 下的进程在内核中称为任务)。

每次创建新线程(pthread_create() ),您也会创建一个新任务,但在同一进程中(即新任务共享进程空间:内存、文件描述符、所有权等)。但是,fork()在复制当前正在运行的任务时会忽略这些额外的任务。

+-----------------------------------------------+
|                                     Process A |
|                                               |
| +----------+    +----------+    +----------+  |
| | thread 1 |    | thread 2 |    | thread 3 |  |
| +----------+    +----+-----+    +----------+  |
|                      |                        |
+----------------------|------------------------+
                       | fork()
                       |
+----------------------|------------------------+
|                      v              Process B |
|               +----------+                    |
|               | thread 1 |                    |
|               +----------+                    |
|                                               |
+-----------------------------------------------+

因此,在进程 B 中,我们从进程 A 中丢失了线程 1 和线程 3。这意味着,如果其中一个或两个都锁定了互斥体或类似的东西,那么进程 B 将很快锁定。锁是最差的,但在fork()发生时任一线程仍具有的任何资源都将丢失(套接字连接、内存分配、设备句柄等)。这就是上面第(2)点的用武之地。您需要在fork()之前知道您的状态。如果您在一个位置定义了非常少量的线程或工作线程,并且可以轻松停止所有线程或工作线程,那么这将很容易。

如果您使用的是 unix 'fork()' 系统调用,那么从技术上讲,您不是在使用线程 - 您正在使用进程 - 它们将拥有自己的内存空间,因此不能相互干扰。

只要每个进程使用不同的文件,就应该没有任何问题。