多线程编程指南

第 5 章 使用 Solaris 软件编程

本章介绍多线程与 Solaris 软件的交互方式以及软件经过更改后支持多线程的方式。

进程创建中的 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() 后会共享文件描述符的查找指针,因此在子线程获取数据的同时,父进程会获取不同的数据。由于父线程和子线程将获取不同的数据,因此会给连续读取访问带来间隙。

进程创建:execexit 问题

exec(2) 和 exit(2) 系统调用的工作方式与这些函数在单线程进程中的工作方式相同,但以下情况例外。在多线程应用程序中,这些函数将销毁地址空间中的所有线程。销毁所有执行资源和所有活动线程之前,这两个调用将阻塞。

exec() 重新生成进程时,exec() 将创建单个轻量进程 (lightweight process, LWP)。进程启动代码将生成初始线程。通常,如果初始线程返回,则该线程将调用 exit(),且进程将被销毁。

当进程中的所有线程都退出时,进程将退出。从包含多个线程的进程中调用任何 exec() 函数时将终止所有线程,并装入和执行新的可执行映像。但不会调用 destructor 函数。

计时器、报警与剖析

在 Solaris 2.5 发行版中,已针对每 LWP 计时器和每线程报警声明了“生命周期结束时间”。请参见 timer_create(3RT)、alarm(2) 或 setitimer(2) 手册页。现在,每 LWP 计时器和每线程报警都被替换为每进程变体,这些内容在本节中加以介绍。

最初,每个 LWP 都有唯一的实时时间间隔计时器和报警,与 LWP 绑定的线程可以使用该计时器和报警。当计时器或报警过期时,会向线程传送一个信号。

每个 LWP 还有一个虚拟时间计时器或配置文件时间间隔计时器,与 LWP 绑定的线程可以使用这些计时器。当时间间隔计时器过期时,系统会根据需要将 SIGVTALRMSIGPROF 发送到拥有该时间间隔计时器的 LWP。

每 LWP POSIX 计时器

在 Solaris 2.3 和 2.4 发行版中,timer_create(3RT) 函数返回一个包含计时器 ID 的计时器对象,该 ID 仅在调用 LWP 中有意义。到期信号将被传送到该 LWP。由于返回的计数器对象的行为,只有绑定线程可以使用 POSIX 计时器工具。

即使使用受到限制,Solaris 2.3 和 2.4 发行版中多线程应用程序的 POSIX 计时器也不可靠。这些计时器不能可靠地屏蔽生成的信号,也不能可靠地传送 sigvent 结构中的关联值。

在 Solaris 2.5 发行版中引入的应用程序可以创建每进程计时器。编译应用时定义了 _POSIX_PER_PROCESS_TIMERS 宏,或通过使用大于或等于 199506L 的值定义宏 _POSIX_C_SOURCE 来编译应用程序。

从 Solaris 9 发行版起生效,所有的计时器都针对每个进程,但虚拟时间计时器和配置文件时间间隔计时器除外,它们仍然针对每个 LWP。有关 ITIMER_VIRTUALITIMER_PROF,请参见 setitimer(2)。

每进程计时器的计时器 ID 在任何 LWP 中都可用。系统将针对进程(而非针对特定 LWP)生成到期信号。

只能通过 timer_delete(3RT) 或在进程终止时删除每进程计时器。

每线程报警

在 Solaris 2.3 和 2.4 发行版中,对 alarm(2) 或 setitimer(2) 的调用仅在调用 LWP 中有意义。LWP 创建终止时,将自动删除这类计时器。由于此行为,只有 alarm()setitimer() 可以使用绑定线程。

即使限于使用绑定线程,Solaris 2.3 和 2.4 多线程应用程序中的 alarm()setitimer() 计时器也不可靠。特别是,在从发出这些调用的绑定线程屏蔽信号方面,alarm()settimer() 计时器不可靠。如果不需要这类屏蔽,则这两个系统调用可在绑定线程中可靠地工作。

在 Solaris 2.5 发行版中,调用 alarm() 时,与 -lpthread (POSIX) 线程链接的应用程序将获取每个进程传送的 SIGALRMalarm() 生成的 SIGALRM是针对进程生成,而不是针对特定 LWP 生成。另外,进程终止时,将重置报警。

使用 Solaris 2.5 之前的发行版编译的应用程序或没有与 -lpthread 链接的应用程序将继续查看每个 LWP 传送的信号,这些信号是由 alarm()setitimer() 生成的。

alarm()setitimer(ITIMER_REAL) 的调用将导致生成的 SIGALRM 信号被发送到进程(从 Solaris 9 发行版开始生效)。

剖析多线程程序

在 Solaris 2.6 以前的 Solaris 发行版中,在多线程程序中调用 profil() 仅影响调用 LWP。创建 LWP 时不会继承配置文件状态。要使用全局配置文件缓冲区来配置多线程程序,线程启动时每个线程都需要调用 profil()。此外,每个线程必须为绑定线程。这些限制很麻烦。它们不能顺利支持动态打开和关闭剖析。

在 Solaris 2.6 以及更高发行版中,对多线程进程的 profil() 系统调用具有全局影响力。对 profil() 的调用将影响进程中的所有 LWP 和线程。profil() 可能会使与以前的每 LWP 语义相关的应用程序中断。但是,预计调用 profil() 可以改进需要在运行时动态打开和关闭剖析的多线程程序。

非本地转向:setjmplongjmp

setjmp()longjmp() 的范围限于一个线程,该线程在大多数时间是可接受的。但是,限制的范围意味着只有在同一个线程中执行 setjmp() 时,处理信号的线程才能执行 longjmp()

资源限制

资源限制是对整个进程设置的,且通过添加进程中所有线程的资源使用来加以确定。超过软资源限制时,会向违例线程发送相应的信号。可通过 getrusage(3C) 使用进程中所使用的所有资源。

LWP 和调度类

Solaris 内核具有三种调度类。优先级最高的调度类是实时 (RT) 类。优先级居中的调度类是 system。不能将 system 类应用于用户进程。优先级最低的调度类为分时 (TS) 类,也是缺省类。

系统将针对每个 LWP 维护调度类。创建进程时,初始 LWP 将继承调度类和在父进程中创建 LWP 的优先级。随着所创建进程数目的增多,其关联的 LWP 也会继承此调度类和优先级。

线程具有其基础 LWP 的调度类和优先级。进程中的每个 LWP 都有内核可见的唯一调度类和优先级。

线程优先级可以控制同步对象的争用情况。缺省情况下,LWP 处于分时类中。对于与计算绑定的多线程,线程优先级不是非常有用。对于使用 MT 库频繁执行同步的多线程应用程序,线程优先级更有意义。

调度类是由 priocntl(2) 设置的。指定前两个参数的方式确定只有调用 LWP 还是一个或多个进程的所有 LWP 受影响。priocntl() 的第三个参数是命令,可以是以下命令之一。

请注意,priocntl() 会影响与调用线程关联的 LWP 的调度。对于未绑定线程,返回对 priocntl() 的调用后,无法保证调用线程与受影响的 LWP 关联。

分时调度

分时调度可以在分时调度类的 LWP 中公平地分布处理资源。内核的其他部分可以在短时间内独占处理器,而不会缩短用户察觉的响应时间。

priocntl(2) 调用可以设置一个或多个进程的 nice(2) 级别。priocntl() 调用还会影响进程中所有分时类 LWP 的 nice() 级别。nice() 级别的范围通常为 0 到 +20,对于具有超级用户权限的进程,该范围为 -20 到 +20。值越低,优先级越高。

分时 LWP 的分发优先级是根据 LWP 的即时 CPU 使用率及其 nice() 级别计算出来的。nice() 级别指示 LWP 相对于分时调度程序的优先级。

nice() 值越大的 LWP 获得的总处理份额越小,但都为非零值。接收处理量较大的 LWP 的优先级与接收处理量很少或没有接收任何处理的 LWP 的优先级要低。

实时调度

可以将实时类 (RT) 应用于整个进程或应用于进程中的一个或多个 LWP。 必须具有超级用户权限才能使用实时类。

与分时类的 nice(2) 级别不同,可以分别或联合为分类为实时类的 LWP 指定优先级。priocntl(2) 调用将影响进程中所有实时 LWP 的属性。

调度程序始终会分发优先级最高的实时 LWP。当优先级较高的 LWP 可以运行时,优先级高的实时 LWP 优先于优先级较低的 LWP。优先的 LWP 置于其级别队列的开头。

实时 LWP 始终控制着处理器,直到优先处理了 LWP、LWP 暂停或更改了其实时优先级为止。RT 类的 LWP 绝对优先于 TS 类中的进程。

新的 LWP 将继承父进程或 LWP 的调度类。RT 类 LWP 将继承父进程的时间¤ù,无论是有限的还是无限的。

有限的时间片 LWP 将始终运行,直到 LWP 终止、中断了 I/O 事件、优先级较高的可运行实时进程优先于该 LWP 执行或时间片到期为止。

只有在 LWP 终止、中断或其他实时进程优先于该 LWP 执行,时间片无限的 LWP 才停止执行操作。

公平共享调度程序

公平共享调度程序 (fair share scheduler, FSS) 调度类允许根据份额来分配 CPU 时间。

缺省情况下,FSS 调度类与 TS 和交互式 (interactive, IA) 调度类使用相同的优先级范围(0 到 59)。进程中的所有 LWP 必须在同一调度类中运行。FSS 类将调度单个 LWP,而不是整个进程。因此,混合使用 FSS 和 TS/IA 类中的进程可能会导致在这两种情况下出现意外的调度行为。

TS/IA 或 FSS 调度类进程不会争用相同的 CPU。处理器集可以在系统中混合 TS/IA 与 FSS。但是,每个处理器集中的所有进程都必须属于 TS/IA 调度类或 FSS 调度类。

固定优先级调度

FX(固定优先级)调度类可以指定没有为适应资源占用而调整的固定优先级和时间量程。进程优先级只能由指定优先级的进程或具有适当权限的进程进行更改。有关 FX 的更多信息,请参见 priocntl(1) 和 dispadmin(1M) 手册页。

此类中的线程与 TS 和交互式 (interactive, IA) 调度类共享相同的优先级范围(0 到 59)。TS 通常为缺省调度类。FX 通常与 TS 结合使用。

扩展传统信号

传统的 UNIX 信号模型通过相当自然的方式扩展到线程。关键特征是信号是在进程范围内部署的,而信号掩码是针对每个进程部署的。信号的进程范围部署是使用传统的机制(signal(3C)、sigaction(2) 等)建立的。

当信号处理程序标记为 SIG_DFLSIG_IGN 时,将对整个接收进程执行信号接收操作。 这些信号包括退出、核心转储、停止、继续和忽略。系统将针对进程中的所有线程执行这些信号的接收操作。因此,不存在哪个线程拾取信号的问题。退出、核心转储、停止、继续和忽略信号都没有处理程序。有关信号的基本信息,请参见 signal(5)

每个线程都有自己的信号掩码。当线程使用的内存或状态同时也被信号处理程序使用时,可通过信号掩码来阻塞某些信号。进程中的所有线程都共享由 sigaction(2) 及其变体设置的一组信号处理程序。

一个进程中的线程不能将信号发送到另一个进程中的特定线程。通过 kill(2)、sigsend(2) 或 sigqueue(3RT) 发送到进程的信号由该进程中任何接收线程来处理。

信号被分为以下类别:陷阱、异常和中断。异常是以同步方式生成的信号。陷阱和中断是以异步方式生成的信号。

就像在传统的 UNIX 中一样,如果信号处于暂挂状态,则该信号的其他实例通常没有其他影响。暂挂信号由位表示,而不是由计数器表示。但是,通过 sigqueue(3RT) 接口发送的信号允许在进程中对同一信号的多个实例排队。

对于单线程进程而言,线程接收信号时如果被阻塞在系统调用中,则该线程可能很早就会返回。如果线程很早就返回,则该线程会返回 EINTR 错误代码,或在 I/O 调用中传输的字节数比请求的字节数要少。

对多线程程序特别重要的一点就是信号对 pthread_cond_wait(3C) 产生的影响。此调用通常仅针对 pthread_cond_signal(3C)pthread_cond_broadcast(3C) 返回零,而不会出现任何错误。但是,如果等待线程接收传统的 UNIX 信号,则 pthread_cond_wait() 将返回零,即使唤醒是虚假的也是如此。在这种情况下,Solaris 线程 cond_wait(3C) 函数将返回 EINTR。有关更多信息,请参见中断对条件变量的等待

同步信号

陷阱(如 SIGILLSIGFPESIGSEGV)是由于对线程执行操作引起的,如除以零或引用不存在的内存。陷阱仅由导致陷阱的线程处理。进程中的多个线程可以同时生成和处理同种类型的陷阱。

可以很容易地针对同时生成的信号将信号扩展到各个线程。可以针对生成同步信号的线程调用处理程序。

但是,如果进程选择不建立相应的信号处理程序,则出现陷阱时将执行缺省操作。即使针对生成的信号阻塞违例线程,也会执行缺省操作。这类信号的缺省操作是终止进程,可能还会进行核心转储。

这类同步信号通常意味着整个进程出现严重问题,而不仅仅是线程出现问题。在这种情况下,终止进程通常是很好的选择。

异步信号

中断(如 SIGINTSIGIO)与任何线程都是异步的,而且是由进程外的某些操作引起的。这些中断可能是其他进程显式发送的信号,也可能代表外部操作(如用户键入 Ctrl-C 组合键)。

中断可由信号掩码允许中断的任何线程来处理。即使有多个线程可以接收中断,也只能选择一个线程。

将同一信号的多个实例发送到进程时,每个实例都可由单独的线程来处理。但是,可用线程不得屏蔽信号。当所有的线程都屏蔽信号时,信号将被标记为暂挂,而由取消屏蔽信号的第一个线程来处理信号。

延续语义

延续语义是传统的处理信号的方式。信号处理程序返回时,将控制恢复进程在中断时所处的位置。此控制恢复非常适合于单线程进程中的异步信号,如示例 5–1 所示。

在其他编程语言(如 PL/1)中,此控制恢复还用作异常处理机制。


示例 5–1 延续语义

unsigned int nestcount;



unsigned int A(int i, int j) {

    nestcount++;



    if (i==0)

        return(j+1)

    else if (j==0)

        return(A(i-1, 1));

    else

        return(A(i-1, A(i, j-1)));

}



void sig(int i) {

    printf("nestcount = %d\n", nestcount);

}



main() {

    sigset(SIGINT, sig);

    A(4,4);

}

对信号执行的操作

本节介绍对信号执行的操作。

设置线程的信号掩码

将信号发送到特定线程

等待指定信号

在给定时间内等待指定的信号

设置线程的信号掩码

pthread_sigmask(3C) 对线程执行 sigprocmask(2) 对进程所执行的操作。pthread_sigmask() 可以设置 thread 的信号掩码。创建新的线程时,其初始掩码是从其创建者继承的。

在多线程进程中对 sigprocmask() 执行调用等效于对 pthread_sigmask() 执行调用。有关更多信息,请参见 sigprocmask(2) 手册页。

将信号发送到特定线程

pthread_kill(3C)kill(2) 的线程模拟。pthread_kill() 可以将信号发送到特定线程。发送到指定线程的信号不同于发送到进程的信号。将信号发送到进程时,信号可由该进程中的任何线程来处理。通过 pthread_kill() 发送的信号只能由指定线程来处理。

可以使用 pthread_kill() 将信号仅发送到当前进程中的线程。由于 thread_t 类型的线程标识符的范围是本地,因此不能指定当前进程范围以外的线程。

通过目标线程接收信号时,调用的操作(处理程序 SIG_DFLSIG_IGN)通常为全局操作。如果将 SIGXXX 发送到线程,且 SIGXXX 的作用是中止进程,则目标线程接收信号时将中止整个进程。

等待指定信号

对于多线程程序,sigwait(2) 是可供使用的首选接口,因为 sigwait() 可以很好地处理异步生成的信号。

sigwait() 将导致调用线程等待,直到由其设置参数标识的任何信号被传送到该线程为止。线程等待的同时,系统将取消屏蔽由设置参数标识的信号,但调用返回时将恢复原始掩码。

由设置参数标识的所有信号必定会在所有线程(包括调用线程)上受到阻塞。否则,sigwait() 可能无法正常工作。

使用 sigwait() 将线程与异步信号分离。创建一个侦听异步信号的线程时,可同时创建其他线程来阻塞为此进程设置的任何异步信号。

从 Solaris 2.5 发行版开始,可以使用 sigwait() 的两个版本:Solaris 2.5 版本和 POSIX 标准版本。新的应用程序和新的库应该使用 POSIX 标准接口,因为 Solaris 版本在未来的发行版中可能不可用。

以下示例显示了 sigwait() 的两个版本的语法:

#include <signal.h>



/* the Solaris 2.5 version*/

int sigwait(sigset_t *set);



/* the POSIX standard version */

int sigwait(const sigset_t *set, int *sig);

传送信号时,POSIX sigwait() 将清除暂挂信号,并将信号数字置于 sig 中。许多线程可以同时调用 sigwait(),但是针对每个接收的信号仅返回一个线程。

借助 sigwait(),可以同时处理异步信号。信号到达后,处理这类信号的线程将立即调用 sigwait() 并返回。通过确保所有线程(包括 sigwait() 的调用程序)都屏蔽异步信号,可确保信号仅由预期处理程序处理,且安全地进行处理。

通过始终屏蔽所有线程中的所有信号并在必要时调用 sigwait(),可以使应用程序中依赖于信号的线程的安全性大大提高。

通常,可以创建一个或多个为等待信号而调用 sigwait() 的线程。由于 sigwait() 甚至会检索屏蔽的信号,因此一定要阻塞所有其他线程中的重要信号,以便不会意外传送这些信号。

信号到达时,线程将从 sigwait() 返回,处理信号,并再次调用 sigwait() 以等待更多信号。信号处理线程并不限于使用异步信号安全函数。信号处理线程可采用通常的方式与其他线程同步。MT 接口安全级别定义了异步信号安全类别。


注 –

sigwait() 不能接收同步生成的信号。


在给定时间内等待指定的信号

sigtimedwait(3RT) 类似于 sigwait(2),但在指定的时间内没有收到信号时,sigtimedwait() 将失败并返回错误。

定向于线程的信号

借助定向于线程的信号的概念,对 UNIX 信号机制得到了扩展。定向于线程的信号就像普通的异步信号一样,但定向于线程的信号是被发送到特定线程,而不是进程。

与安装用于处理信号的信号处理程序相比,使用等待异步信号的单独线程可能更安全且更简单。

一种处理异步信号的更好方式是同步处理这些信号。通过调用 sigwait(2),线程可以一直等待,直到信号出现为止。请参见等待指定信号


示例 5–2 异步信号和 sigwait(2)

main() {

    sigset_t set;

    void runA(void);

    int sig;



    sigemptyset(&set);

    sigaddset(&set, SIGINT);

    pthread_sigmask(SIG_BLOCK, &set, NULL);

    pthread_create(NULL, 0, runA, NULL, PTHREAD_DETACHED, NULL);



    while (1) {

        sigwait(&set, &sig);

        printf("nestcount = %d\n", nestcount);

        printf("received signal %d\n", sig);

    }

}



void runA() {

    A(4,4);

    exit(0);

}

本示例将修改示例 5–1 的代码。主例程将屏蔽 SIGINT 信号,创建一个子线程(用于调用前一个示例的函数 A),并发出 sigwait() 来处理 SIGINT 信号。

请注意,信号在计算线程中将被屏蔽,因为计算线程将从主线程继承其信号掩码。当且仅当主线程在 sigwait() 内部不受阻塞时,才能受到保护,而不去处理 SIGINT

另外,请注意,使用 sigwait() 时不存在中断系统调用的危险。

完成语义

另一种处理信号的方式是使用完成语义。

当信号指明发生灾难性情况,导致没有理由继续执行当前代码块时,请使用完成语义。信号处理程序将代替其余有问题的块运行。换句话说,信号处理程序将完成该块。

示例 5–3 中,所讨论的块是 if 语句的 then 部分的主体。对 setjmp(3C) 的调用会在 jbuf 中保存程序的当前寄存器状态并返回 0,从而执行块。


示例 5–3 完成语义

sigjmp_buf jbuf;

void mult_divide(void) {

    int a, b, c, d;

    void problem();



    sigset(SIGFPE, problem);

    while (1) {

        if (sigsetjmp(&jbuf) == 0) {

            printf("Three numbers, please:\n");

            scanf("%d %d %d", &a, &b, &c);

            d = a*b/c;

            printf("%d*%d/%d = %d\n", a, b, c, d);

        }

    }

}



void problem(int sig) {

    printf("Couldn't deal with them, try again\n");

    siglongjmp(&jbuf, 1);

}

如果出现 SIGFPE 浮点异常,则系统将调用信号处理程序。

信号处理程序将调用 siglongjmp(3C)(用于恢复 jbuf 中保存的寄存器状态),进而导致程序再次从 sigsetjmp() 返回。保存的寄存器包括程序计数器和栈指针。

但是,此时 sigsetjmp(3C) 将返回 siglongjmp() 的第二个参数(值为 1)。请注意,块将被跳过,而仅在下一次迭代 while 循环期间执行。

可以在多线程程序中使用 sigsetjmp(3C)siglongjmp(3C)。请注意,一个线程永远不会执行使用另一个线程的 sigsetjmp() 结果的 siglongjmp()

此外,sigsetjmp()siglongjmp() 可以恢复和保存信号掩码,而 setjmp(3C)longjmp(3C) 不会执行这些操作。

使用信号处理程序时,请使用 sigsetjmp()siglongjmp()

完成语义通常用于处理异常。需要特别指出的是,Sun AdaTM 编程语言就使用此模型。


注 –

请记住,不得sigwait(2) 与同步信号一同使用。


信号处理程序和异步信号安全

与线程安全类似的概念就是异步信号安全。异步信号安全操作可保证不会干扰正被中断的操作。

当信号处理程序操作干扰正被中断的操作时,就会引发异步信号安全问题。

例如,假设程序正在调用 printf(3S),且其调用程序调用 printf() 时出现了信号。在这种情况下,两个 printf() 语句的输出彼此关联。要避免关联输出,当 printf() 可能被信号中断时,处理程序不应直接调用 printf()

无法使用同步元语来解决此问题。 在信号处理程序与正被同步的操作之间执行的任何同步尝试都将立即产生死锁现象。

假设 printf() 借助互斥来保护自身。现在,假设调用 printf() 进而通过互斥锁持有锁定的线程被信号中断。

如果处理程序调用 printf(),则通过互斥锁持有锁定的线程将尝试再次利用互斥锁。尝试利用互斥锁将导致瞬间死锁。

为避免处理程序与操作之间出现干扰,请确保这种情况永远不会发生。或许您可以在关键时候屏蔽信号,或从内部信号处理程序中仅调用异步信号安全操作。

表 5–2 中列出了 POSIX 可确保异步信号安全的仅有例程。任何信号处理程序都可以安全地调用这些函数之一。

表 5–2 异步信号安全函数

_exit()

fstat()

read()

sysconf()

access()

getegid()

rename()

tcdrain()

alarm()

geteuid()

rmdir()

tcflow()

cfgetispeed()

getgid()

setgid()

tcflush()

cfgetospeed()

getgroups()

setpgid()

tcgetattr()

cfsetispeed()

getpgrp()

setsid()

tcgetpgrp()

cfsetospeed()

getpid()

setuid()

tcsendbreak()

chdir()

getppid()

sigaction()

tcsetattr()

chmod()

getuid()

sigaddset()

tcsetpgrp()

chown()

kill()

sigdelset()

time()

close()

link()

sigemptyset()

times()

creat()

lseek()

sigfillset()

umask()

dup2()

mkdir()

sigismember()

uname()

dup()

mkfifo()

sigpending()

unlink()

execle()

open()

sigprocmask()

utime()

execve()

pathconf()

sigsuspend()

wait()

fcntl()

pause()

sleep()

waitpid()

fork()

pipe()

stat()

write()

中断对条件变量的等待

将捕获到的已取消屏蔽的信号传送到等待条件变量的线程时,线程将从虚假唤醒的信号处理程序中返回。 虚假唤醒是指不是由其他线程中的条件信号调用导致的唤醒。在这种情况下,Solaris 线程接口 cond_wait()cond_timedwait() 将返回 EINTR,而 POSIX 线程接口 pthread_cond_wait()pthread_cond_timedwait() 将返回 0。在所有情况下,从条件等待返回之前都将重新获取关联的互斥锁定。

重新获取关联的互斥锁定并不暗示线程在执行信号处理程序的同时,互斥处于锁定状态。未定义信号处理程序中的互斥状态。

由于在 Solaris 9 发行版之前的 Solaris 软件发行版中实现了 libthread,因而保证了在处于信号处理程序中时保留了互斥。依赖此原有行为的应用程序需要修改 Solaris 9 发行版以及后续发行版。

示例 5–4 将对处理程序清除加以说明。


示例 5–4 条件变量和中断的等待

int sig_catcher() {

    sigset_t set;

    void hdlr();



    mutex_lock(&mut);



    sigemptyset(&set);

    sigaddset(&set, SIGINT);

    sigsetmask(SIG_UNBLOCK, &set, 0);



    if (cond_wait(&cond, &mut) == EINTR) {

        /* signal occurred and lock is held */

        cleanup();

        mutex_unlock(&mut);

        return(0);

    }

    normal_processing();

    mutex_unlock(&mut);

    return(1);

}



void hdlr() {

    /* state of the lock is undefined */

    ...

}

假设 SIGINT 信号在进入 sig_catcher() 时在所有线程中受到阻塞。此外,还假设已通过调用 sigaction(2) 建立了 hdlr()(作为 SIGINT 信号的处理程序)。如果在线程处于 cond_wait() 中时将捕获到的已取消屏蔽的 SIGINT 信号实例传送到该线程,该线程将调用 hdlr()。然后,线程将返回到 cond_wait() 函数(如有必要,将在此处重新获取互斥锁定),并从 cond_wait() 返回 EINTR

是否已针对 sigaction()SA_RESTART 指定为标志在此处无影响。cond_wait(3C) 不是系统调用,也不会自动重新启动。如果在 cond_wait() 中阻塞线程时出现捕获的信号,则调用将始终返回 EINTR

I/O 问题

多线程编程的最大优点之一就是可以提升 I/O 性能。传统的 UNIX API 在这方面给您提供的帮助极少。要么就使用文件系统的功能,要么就整个跳过文件系统。

本节说明如何使用线程通过 I/O 并发性和多缓冲来获得更多灵活性,此外,本节还论述了包含线程的同步 I/O 与包含和不包含线程的异步 I/O 的各种方式之间的差异和相似之处。

I/O 作为远程过程调用

在传统的 UNIX 模型中,I/O 看上去是同步的,就像对 I/O 设备进行远程过程调用一样。调用返回后,I/O 即完成,或者至少看上去已完成。例如,写入请求可能仅导致将数据传输到操作环境中的缓冲区。

此模型的优点在于过程调用的概念是为用户所熟知的。

在传统 UNIX 系统中未使用的一种替代方法是异步模型,在此模型中,I/O 请求仅启动操作。程序必须以某种方式了解操作完成的时间。

异步模型不像同步模型那样简单。但是,异步模型的优点是允许并发 I/O 和在传统单线程 UNIX 进程中的处理。

人为的异步性

通过在多线程程序中使用同步 I/O,可以获得异步 I/O 的大多数优势。使用异步 I/O 时,可以发出请求并随后检查以确定 I/O 完成的时间。可以改用单独的线程同步执行 I/O。随后,主线程或许会在以后的某个时间通过调用 pthread_join(3C) 来检查是否完成了操作。

异步 I/O

在多数情况下,不需要异步 I/O,因为其效果可借助线程得以实现,每个线程执行同步 I/O。但是,在少数情况下,线程不能实现异步 I/O 可以实现的效果。

最直观的示例就是写入磁带机以形成磁带机流。流形式可防止在向磁带机写入内容的同时磁带机停止运行。磁带将高速向前移动,同时提供写入磁带的连续不断的数据流。

要支持流形式,内核中的磁带机应使用线程。内核中的磁带机对中断做出响应时,该磁带机一定会发出排队的写入请求。中断指示以前的磁带写入操作已完成。

线程不能保证会对异步写入进行排序,因为线程执行的顺序是不确定的。例如,您无法指定写入磁带的顺序。

异步 I/O 操作

#include <sys/asynch.h>



int aioread(int fildes, char *bufp, int bufs, off_t offset,

    int whence, aio_result_t *resultp);



int aiowrite(int filedes, const char *bufp, int bufs,

    off_t offset, int whence, aio_result_t *resultp);



aio_result_t *aiowait(const struct timeval *timeout);



int aiocancel(aio_result_t *resultp);

aioread(3AIO)aiowrite(3AIO) 在格式上类似于 pread(2)pwrite(2),但是添加了最后一个参数。对 aioread()aiowrite() 的调用导致启动 I/O 操作或将该操作排入队列。

调用将顺利返回,而不被阻塞,而且调用的状态将在 resultp 所指向的结构中返回。resultpaio_result_t 类型的项,其中包含以下值:

int aio_return;

int aio_errno;

当调用立即失败时,可以在 aio_errno 中找到失败代码。否则,此字段将包含 AIO_INPROGRESS,意味着已成功地将操作排入队列。

等待 I/O 操作完成

通过调用 aiowait(3AIO),可以等待未完成的异步 I/O 操作完成。aiowait() 将返回指向 aio_result_t 结构(随原始 aioread(3AIO) 或原始 aiowrite(3) 调用一同提供)的指针。

此时,aio_result_t 将包含 read(2)write(2) ?òa?|^ao?H?§,前提是要调用其中一个函数而不是异步版本。如果 read()write() 成功,则 aio_return 包含已读取或写入的字节数。如果 read()write() 不成功,则 aio_return 为 -1,且 aio_errno 包含错误代码。

aiowait() 将使用 timeout 参数,该参数指示调用程序将等待的时间。此处的 NULL 指针表示调用程序将无限期等下去。指向包含零值的结构的指针表示调用程序根本不会等待。

您可能会启动异步 I/O 操作,执行某项工作,然后调用 aiowait() 以等待请求完成。或者,可以使用 SIGIO 在操作完成时以异步方式得到通知。

最后,可通过调用 aiocancel() 来取消暂挂的异步 I/O 操作。此例程是使用结果区域地址作为参数来进行调用的。此结果区域标识哪项操作将被取消。

共享的 I/O 和新的 I/O 系统调用

多个线程执行具有相同文件描述符的并发 I/O 操作时,您可能会发现传统的 UNIX I/O 接口不是线程安全的。在 lseek(2) 系统调用设置了文件偏移的位置,会出现不连续 I/O 问题。随后将在接下来的 read(2)write(2) 调用中使用该文件偏移,以指示操作应在文件中的哪个位置开始。当两个或更多线程向同一文件描述符发出 lseeks() 时,将产生冲突。

为避免此冲突,请使用 pread(2)pwrite(2) 系统调用。

#include <sys/types.h>

#include <unistd.h>



ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);



ssize_t pwrite(int filedes, void *buf, size_t nbyte,

    off_t offset);

pread(2) 和 pwrite(2) 的行为方式与 read(2) 和 write(2) 非常类似,但是 pread(2) 和 pwrite(2) 多使用了一个参数(即文件偏移)。可以使用此参数来指定偏移,而无需使用 lseek(2),因此多个线程可以安全地使用这些例程来处理对同一文件描述符的 I/O。

getcputc 的替代项

标准 I/O 也会出现问题。程序员习惯使用例程(如 getc(3C)putc(3C)),这些例程以宏方式实现,且速度非常快。由于 getc(3C)putc(3C) 的速度较快,因此可以在程序的内部循环中使用这些宏,而不必担心效率。

但是,将 getc(3C)putc(3C) 设为线程安全时,宏的使用代价会突然变高。现在,宏至少需要两个内部子例程调用来锁定和解除锁定互斥。

为避开此问题,提供了这些例程的替代版本 getc_unlocked(3C)putc_unlocked(3C)

getc_unlocked(3C)putc_unlocked(3C) 不会获得对互斥的锁定。这些宏的速度像原始非线程安全版本的 getc(3C)putc(3C) 一样快。

但是,要采用线程安全方式使用这些宏,必须使用 flockfile(3C)funlockfile(3C) 显式锁定和释放保护标准 I/O 流的互斥。对其中靠后例程的调用是在循环外进行的。对 getc_unlocked()putc_unlocked() 的调用是在循环内进行的。