多线程编程指南

进程创建中的 fork 问题

Solaris 9 产品和更早 Solaris 发行版中处理 fork() 的缺省方式与在 POSIX 线程中处理 fork() 的方式稍有不同。 对于 Solaris 9 之后的 Solaris 发行版,在所有情况下,fork() 都会按照为 POSIX 线程指定的方式工作。

表 5–1 对在 Solaris 线程和 pthread 中处理 fork() 的相似与不同之处进行了比较。当可比较的接口在 POSIX 线程或 Solaris 线程中不可用时,`—' 字符将出现在表列中。

表 5–1 比较 POSIX 与 Solaris fork() 的处理

 

Solaris 接口 

POSIX 线程接口 

Fork-one 模型 

fork1(2)

fork(2)

Fork-all 模型 

forkall(2)

forkall(2)

fork 安全性 

— 

pthread_atfork(3C)

Fork-One 模型

表 5–1 所示,pthread fork(2) 函数的行为与 Solaris fork1(2) 函数的行为相同。pthread fork(2) 函数和 Solaris fork1(2) 函数都将创建新的进程,并将完整的地址空间复制到子进程中。但是,这两个函数都只将调用线程复制到子进程中。

当子进程直接调用 exec() 时,将调用线程复制到子进程中非常有用,大多数情况下,此操作发生在对 fork() 的调用之后。在这种情况下,子进程不需要复制 fork() 以外的任何线程。

在子进程中,调用 fork() 之后和调用 exec() 之前,请不要调用任何库函数。某个库函数可能会使用父进程在调用 fork() 时所持有的锁。调用某个 exec() 处理程序之前,子进程可能仅执行异步信号安全操作。

Fork-One 安全问题和解决方案

除了通常关注的问题(如锁定共享数据)以外,当只有 fork() 线程处于运行状态时,还应根据 fork 子进程的操作来处理库。问题在于子进程中的唯一线程可能会尝试获取由未复制到子进程中的线程持有的锁定。

大多数程序不可能遇到此问题。从 fork() 返回后,大多数程序都会调用子进程中的 exec()。但是,如果程序在调用 exec() 之前必须在子进程中执行操作,或永远不会调用 exec(),则子进程可能会遇到死锁。每个库编写者都应提供安全的解决方案,尽管提供一个非 fork 安全的库不是一个很大的问题。

例如,假设当 T2 fork 新进程时,T1 在进行打印,且对 printf() 持有锁定。在子进程中,如果唯一的线程 (T2) 调用 printf(),则 T2 将快速死锁。

POSIX fork() 或 Solaris fork1() 函数仅复制用于调用 fork()fork1() 的线程。如果调用 Solaris forkall() 来复制所有线程,则此问题不是要关注的问题。

但是,forkall() 可能会导致其他问题,使用时应小心。 例如,如果一个线程调用 forkall(),则将在子进程中复制对文件执行 I/O 的父线程。线程的两个副本都将继续对同一个文件执行 I/O,一个副本在父进程中,一个副本在子进程中,这将导致出现异常或文件损坏。

要防止在调用 fork1() 时出现死锁,请确保在执行 fork 时任何锁定都未被持有。防止死锁的最有效的方式就是让 fork 线程获取可能由子进程使用的所有锁定。由于无法获取对 printf() 的所有锁定(由于 printf()libc 所有),因此必须确保在使用 fork() 时没有使用 printf()

要管理库中的锁定,应执行以下操作:

在以下示例中,库使用的锁定列表为 {L1,...Ln}。这些锁定的锁定顺序也为 L1...Ln

mutex_lock(L1);

mutex_lock(L2);

fork1(...);

mutex_unlock(L1);

mutex_unlock(L2);

使用 Solaris 线程或 POSIX 线程时,应该在库的 .init() 部分中添加对 pthread_atfork(f1, f2, f3) 的调用。f1()f2()f3() 定义如下:

f1() /* This is executed just before the process forks. */

{

 mutex_lock(L1); |

 mutex_lock(...); | -- ordered in lock order

 mutex_lock(Ln); |

 } V



f2() /* This is executed in the child after the process forks. */

 {

 mutex_unlock(L1);

 mutex_unlock(...);

 mutex_unlock(Ln);

 }



f3() /* This is executed in the parent after the process forks. */

 {

 mutex_unlock(L1);

 mutex_unlock(...);

 mutex_unlock(Ln);

 } 

虚拟 fork -vfork

标准 vfork(2) 函数在多线程程序中并不安全。vfork(2)(与 fork1(2) ¤@样)仅复制子进程中的调用线程。就像在非线程实现中一样,vfork() 不会复制子进程的地址空间。

请注意,子进程中的线程在调用 exec(2) 之前不会更改内存。vfork() 为子进程提供父地址空间。子进程调用 exec() 或退出之后,父进程将取回其地址空间。子进程不得更改父进程的状态。

例如,如果在对 vfork() 的调用与对 exec() 的调用之间创建新的线程,则会出现灾难性问题。

解决方案: pthread_atfork

使用 Fork-One 模型时,请使用 pthread_atfork() 来防止死锁。

#include <pthread.h>



int pthread_atfork(void (*prepare) (void), void (*parent) (void),

    void (*child) (void) );

pthread_atfork() 函数声明了在调用 fork() 的线程的上下文中的 fork() 前后调用的 fork() 处理程序。

可以将任何处理程序参数都设置为 NULL。对 pthread_atfork() 进行连续调用的顺序非常重要。

例如,prepare 处理程序可能会获取所有需要的互斥。然后,parentchild 处理程序可能会释放互斥。获取所有需要的互斥的 prepare 处理程序可确保在对进程执行 fork 之前,所有相关的锁定都由调用 fork 函数的线程持有。此技术可防止子进程中出现死锁。

pthread_atfork 返回值

调用成功完成后,pthread_atfork() 将返回零。其他任何返回值都表示出现了错误。如果检测到以下情况,pthread_atfork() 将失败并返回对应的值。


ENOMEM

描述:

用于记录 fork 处理程序地址的表空间不足。

Fork-all 模型

Solaris forkall(2) 函数可以复制地址空间以及子进程中的所有线程。地址空间复制非常有用,例如,在子进程永远不调用 exec(2) 但会使用其父地址空间的副本时。

当进程中的某个线程调用 Solaris forkall(2) 时,在可中断的系统调用中阻塞的线程将返回 EINTR

请注意,不要创建同时由父进程和子进程持有的锁定。通过调用包含 MAP_SHARED 标志的 mmap() 在共享内存中分配锁定时,会出现父进程和子进程同时持有锁定的情况。如果使用 Fork-One 模型,则不会出现此问题。

选择正确的 Fork

从 Solaris 10 发行版开始,对 fork() 的调用与对 fork1() 的调用相同。具体来说,在子进程中仅复制调用线程。此行为与 POSIX fork() 的行为相同。

在以前的 Solaris 软件发行版中,fork() 的行为取决于应用程序是否与 POSIX 线程库相链接。如果与 -lthread(Solaris 线程)链接,但没有与 -lpthread(POSIX 线程)链接,则 fork()forkall() 相同。如果与 -lpthread 链接,无论 fork() 是否还与 -lthread 链接,fork() 都与 fork1() 相同。

从 Solaris 10 发行版开始,多线程不需要 -lthread-lpthread。标准 C 库为两组应用程序程序接口提供所有的线程支持。需要复制所有 fork 语义的应用程序必须调用 forkall()

调用任何 fork() 函数后,使用全局状态时要非常小心。

例如,当一个线程连续读取文件,而进程中的另一个线程成功 fork 时,每个进程都包含读取该文件的线程。由于在调用 fork() 后会共享文件描述符的查找指针,因此在子线程获取数据的同时,父进程会获取不同的数据。由于父线程和子线程将获取不同的数据,因此会给连续读取访问带来间隙。