APUE笔记:Thread Control(十二)

本文记录《UNIX环境高级编程》第3版 第12章 Thread Control 的一些知识点。


线程属性

pthread 接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。通常,管理这些属性的函数都遵循相同的模式。

(1)每个对象与它自己类型的属性对象进行关联(线程与线程属性关联,互斥量与互斥量属性关联,等等)。一个属性对象可以代表多个属性。属性对象对应用程序来说是不透明的。这意味着应用程序并不需要了解有关属性对象内部结构的详细细节,这样可以增强应用程序的可移植性。取而代之的是,需要提供相应的函数来管理这些属性对象。

(2)有一个初始化函数,把属性设置为默认值。

(3)还有一个销毁属性对象的函数。如果初始化函数分配了与属性对象关联的资源,销毁函数负责释放这些资源。

(4)每个属性都有一个函数,用于从属性对象中获取该属性的值。由于该函数在成功时返回0,在失败时返回错误编号,因此会通过将值存储在其中一个参数所指定的内存位置,来将值返回给调用者。

(5)每个属性都有一个设置属性值的函数。在这种情况下,属性值作为参数按值传递。

在调用 pthread_create 函数中,可以使用 pthread_attr_t 结构修改线程默认属性,并把这些属性与创建的线程联系起来。可以使用 pthread_attr_init 函数初始化 pthread_attr_t 结构。在调用 pthread_attr_init 以后,pthread_attr_t 结构所包含的就是操作系统实现支持的所有线程属性的默认值。

1
2
3
4
#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

POSIX.1定义的线程属性汇总于下表中。POSIX.1在线程执行调度选项中定义了额外的属性,旨在支持实时应用程序,但在此不对此进行讨论。

Name Description
detachstate detached thread attribute
guardsize guard buffer size in bytes at end of thread stack
stackaddr lowest address of thread stack
stacksize minimum size in bytes of thread stack

如果对现有的某个线程的终止状态不感兴趣的话,可以使用 pthread_detach 函数让操作系统在线程退出时收回它所占用的资源。

如果在创建线程时就知道不需要了解线程的终止状态,就可以修改 pthread_attr_t 结构中的 detachstate 线程属性,让线程一开始就处于分离状态。可以使用 pthread_attr_setdetachstate 函数将分离状态线程属性设置为两个合法值之一:PTHREAD_CREATE_DETACHED(使线程以分离状态启动)或 PTHREAD_CREATE_JOINABLE(使线程正常启动,这样应用程序可以获取其终止状态)。

1
2
3
4
#include <pthread.h>
int pthread_attr_getdetachstate(const pthread_attr_t *restrict attr, int *detachstate);
int pthread_attr_setdetachstate(pthread_attr_t *attr, int *detachstate);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

对于遵循Single UNIX Specification 中 XSI 选项的系统来说,支持线程栈属性就是必需的。可以在编译阶段使用 _POSIX_THREAD_ATTR_STACKADDR_POSIX_THREAD_ATTR_STACKSIZE 符号来检查系统是否支持每一个线程栈属性。如果系统定义了这些符号中的一个,就说明它支持相应的线程栈属性。或者,也可以在运行阶段把 _SC_THREAD_ATTR_ STACKADDR_SC_THREAD_ATTR_STACKSIZE 参数传给 sysconf 函数,检查运行时系统对线程栈属性的支持情况。

可以使用函数 pthread_attr_getstackpthread_attr_setstack 对线程栈属性进行管理。

1
2
3
4
#include <pthread.h>
int pthread_attr_getstack(const pthread_attr_t *restrict attr, void **restrict stackaddr, size_t *restrict stacksize);
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

对于进程来说,虚地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。

如果线程栈的虚拟地址空间耗尽,可以使用 malloc 或者 mmap 来为可替代的栈分配空间,并使用 pthread_attr_setstack 来更改你所创建线程的栈位置。stackaddr 参数指定的地址是用作线程栈的内存范围中可寻址的最低地址,它要按照处理器架构的适当边界对齐。当然,这前提是 mallocmmap 所使用的虚拟地址范围与当前线程栈正在使用的范围不同。

应用程序也可以通过 pthread_attr_getstacksizepthread_attr_setstacksize 函数读取或设置线程属性 stacksize

1
2
3
4
#include <pthread.h>
int pthread_attr_getstacksize(const pthread_attr_t *restrict attr, size_t *restrict stacksize);
int pthread_attr_setstacksize (pthread_attr_t *attr, size_t stacksize);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

如果希望改变默认的栈大小,但又不想自己处理线程栈的分配问题,这时使用 pthread_attr_setstacksize 函数就非常有用。

线程属性 guardsize 控制着线程栈末尾之后用以避免栈溢出的扩展内存的大小。这个属性默认值是由具体实现来定义的,但常用值是系统页大小。可以把guardsize 线程属性设置为0,不允许属性的这种特征行为发生:在这种情况下,不会提供缓冲区。同样,如果修改了线程属性 stackaddr,系统就认为我们将自己管理栈,进而使栈警戒缓冲区机制无效,这等同于把 guardsize 线程属性设置为0。

1
2
3
4
#include <pthread.h>
int pthread_attr_getguardsize(const pthread_attr_t *restrict attr, size_t *restrict guardsize);
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

如果修改了线程属性中的保护大小(guardsize),操作系统可能会将其向上取整为页面大小的整数倍。如果线程的栈指针溢出到保护区域,应用程序会收到错误,可能还会伴有信号。

Single UNIX Specification 定义了其他几个可选的线程属性,旨在供实时应用程序使用。在此不讨论这些属性。线程还有一些未由 pthread_attr_t 结构表示的其他属性:可取消状态和可取消类型。


同步属性

就像线程具有属性一样,线程的同步对象也有属性。本节讨论互斥量属性、读写锁属性、条件变量属性和屏障属性。


互斥量属性

互斥量属性是用 pthread_mutexattr_t 结构表示的。

对于非默认属性,可以用 pthread_mutexattr_init 初始化 pthread_mutexattr_t 结构,用 pthread_mutexattr_destroy 来反初始化。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

pthread_mutexattr_init 函数将用默认的互斥量属性初始化 pthread_mutexattr_t 结构。值得注意的3个属性是:==process-shared属性、 robust属性和type属性==。可以通过检查系统中是否定义了 _POSIX_THREAD_PROCESS_SHARED 符号来判断这个平台是否支持进程共享这个属性,也可以在运行时把 _SC_THREAD_PROCESS_SHARED 参数传给 sysconf 函数进行检查。

在进程中,多个线程可以访问同一个同步对象。这是默认的行为。在这种情况下,process-shared 属性需设置为 PTHREAD_PROCESS_PRIVATE

存在一些机制,能够让独立进程将相同范围的内存映射到它们各自的地址空间中。多个进程对共享数据的访问通常需要同步,就像多个线程对共享数据的访问也需要同步一样。如果进程共享互斥锁属性被设置为 PTHREAD_PROCESS_SHARED,那么从多个进程之间共享的内存区域分配的互斥锁可被这些进程用于同步。

可以使用 pthread_mutexattr_getpshared 函数查询 pthread_mutexattr_t 结构,得到它的进程共享属性,使用 pthread_mutexattr_setpshared 函数修改进程共享属性。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *restrict attr, int *restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr, int pshared);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

进程共享互斥量属性设置为 PTHREAD_PROCESS_PRIVATE 时,允许 pthread 线程库提供更有效的互斥量实现,这在多线程应用程序中是默认的情况。

robust 属性与在多个进程间共享的互斥量有关。它旨在解决当进程持有互斥锁时终止的情况下,互斥锁状态的恢复问题。当这种情况发生时,互斥锁会处于锁定状态,且恢复工作十分困难。其他进程中因该锁而阻塞的线程将无限期阻塞。

可以使用 pthread_mutexattr_getrobust 函数获取健壮的互斥量属性的值。可以调用 pthread_mutexattr_setrobust 函数设置健壮的互斥量属性的值。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_getrobust(const pthread_mutexattr_t *restrict attr, int *restrict robust);
int pthread_mutexattr_setrobust(pthread_mutexattr_t *attr, int robust);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

robust 属性取值有两种可能的情况。默认值是 PTHREAD_MUTEX_STALLED,这意味着持有互斥量的进程终止时不需要采取特别的动作。这种情况下,使用互斥量后的行为是未定义的,等待该互斥量解锁的应用程序会被有效地 “stalled”。另一个取值是 PTHREAD_MUTEX_ROBUST。当持有锁的另一个进程未先解锁就终止时,此值会使在调用 pthread_mutex_lock 时被阻塞的线程获得该锁,但pthread_mutex_lock 的返回值是 EOWNERDEAD 而非0。应用程序可以使用这个特殊的返回值来表明,它们需要恢复互斥锁所保护的任何状态(如果可能的话)。请注意,在这种情况下,返回EOWNERDEAD 错误并非真正的错误,因为调用者将拥有该锁。

使用 robust 的互斥量改变了使用 pthread_mutex_lock 的方式,因为现在必须检查3个返回值而不是之前的两个:不需要恢复的成功、需要恢复的成功以及失败。

如果应用状态无法恢复,在线程对互斥量解锁以后,该互斥量将处于永久不可用状态。为了避免这样的问题,线程可以调用 pthread_mutex_consistent 函数,指明与该互斥量相关的状态在互斥量解锁之前是一致的。

1
2
3
#include <pthread.h>
int pthread_mutex_consistent(pthread_mutex_t *mutex);
// 返回值:若成功,返回0;否则,返回错误编号

如果线程没有先调用 pthread_mutex_consistent 就对互斥量进行了解锁,那么其他试图获取该互斥量的阻塞线程就会得到错误码 ENOTRECOVERABLE。如果发生这种情况,互斥量将不再可用。线程通过提前调用 pthread_mutex_consistent,能让互斥量正常工作,这样它就可以持续被使用。

type 互斥量属性控制着互斥量的锁定特性。POSIX.1定义了4种类型。

  • PTHREAD_MUTEX_NORMAL 一种标准的互斥锁类型,它不执行任何特殊的错误检查或死锁检测。
  • PTHREAD_MUTEX_ERRORCHECK 一种提供错误检查的互斥锁类型。
  • PTHREAD_MUTEX_RECURSIVE 一种互斥锁类型,允许同一线程多次锁定它,而无需先解锁。递归互斥锁会维护一个锁定计数,并且只有在解锁次数与锁定次数相同时才会被释放。
  • PTHREAD_MUTEX_DEFAULT 一种提供默认特性和行为的互斥锁类型。实现可以自由地将其映射到其他互斥锁类型之一。例如,Linux 3.2.0 将此类型映射为普通互斥锁类型,而 FreeBSD 8.0 则将其映射为错误检查类型。

这四种类型的行为在下表中进行了总结。 | Mutex type | 特性 | |—|—| | PTHREAD_MUTEX_NORMAL | 同一线程再次加锁会死锁 | | PTHREAD_MUTEX_ERRORCHECK | 错误操作会返回 error | | PTHREAD_MUTEX_RECURSIVE | 同一线程可以多次加锁 | | PTHREAD_MUTEX_DEFAULT | 实现相关(通常等同 NORMAL) |

可以用 pthread_mutexattr_gettype 函数得到互斥量类型属性,用 pthread_mutexattr_settype 函数修改互斥量类型属性。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t * restrict attr, int * restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t * attr, int type);
// 两个函数的返回值: 若成功,返回0;否则,返回错误编号

递归互斥锁在需要将现有的单线程接口适配到多线程环境,但由于兼容性限制而无法更改函数接口时非常有用。然而,使用递归锁可能会很棘手,并且只有在没有其他解决方案的情况下才应该使用它们。


读写锁属性

读写锁与互斥量类似,也是有属性的。可以用 pthread_rwlockattr_init 初始化 pthread_rwlockattr_t 结构。

1
2
3
4
#include <pthread.h>
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

读写锁支持的唯一属性是 process-shared 属性。它与互斥量的进程共享属性是相同的。就像互斥量的进程共享属性一样,有一对函数用于读取和设置读写锁的进程共享属性。

1
2
3
4
#include <pthread.h>
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t * restrict attr, int *restrict pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int pshared);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

尽管POSIX只定义了一种读写锁属性,但实现可以自由定义其他非标准的属性。


条件变量属性

Single UNIX Specification目前定义了条件变量的两个属性:process-shared 属性和 colck 属性

1
2
3
4
#include <pthread.h>
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

与其他的同步属性一样,条件变量支持进程共享属性。它控制着条件变量是可以被单进程的多个线程使用,还是可以被多进程的线程使用。要获取进程共享属性的当前值,可以用 pthread_condattr_getpshared 函数。设置该值可以用 pthread_condattr_setpshared 函数。

1
2
3
4
#include <pthread.h>
int pthread_condattr_getpshared(const pthread_condattr_t * restrict attr, int *restrict pshared);
int pthread_condattr_setpshared(pthread_condattr_t *attr, int pshared);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

clock 属性控制计算 pthread_cond_timedwait 函数的超时参数(tsptr)时采用的是哪个时钟。可以使用 pthread_condattr_getclock 函数获取可被用于 pthread_cond_timedwait 函数的时钟ID,在使用 pthread_cond_timedwait 函数前需要用 pthread_condattr_t 对象对条件变量进行初始化。可以用 pthread_condattr_setclock 函数对时钟ID进行修改。

1
2
3
4
#include <pthread.h>
int pthread_condattr_getclock(const pthread_condattr_t * restrict attr, clockid_t *restrict clock_id);
int pthread_condattr_setclock(pthread_condattr_t *attr, clockid_t clock_id);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

屏障属性

1
2
3
4
#include <pthread.h>
int pthread_barrierattr_init(pthread_barrierattr_t *attr);
int pthread_barrierattr_destroy(pthread_barrierattr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

目前定义的屏障属性只有 process-shared 属性,它控制着屏障是可以被多进程的线程使用,还是只能被初始化屏障的进程内的多线程使用。

1
2
3
4
#include <pthread.h>
int pthread_barrierattr_getpshared(const pthread_barrierattr_t * restrict attr, int *restrict pshared);
int pthread_barrierattr_setpshared(pthread_barrierattr_t *attr, int pshared);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

进程共享属性的值可以是 PTHREAD_PROCESS_SHARED(多进程中的多个线程可用),也可以是 PTHREAD_PROCESS_PRIVATE(只有初始化屏障的那个进程内的多个线程可用)。


可重入性

线程在可重入性方面与信号处理器类似。在这两种情况下,多个控制线程都有可能同时调用同一个函数。

如果一个函数可以被多个线程同时安全地调用,我们就说这个函数是线程安全的。

很多函数并不是线程安全的,因为它们返回的数据存放在静态的内存缓冲区中。通过修改接口,要求调用者自己提供缓冲区可以使函数变为线程安全。

如果一个函数对多个线程来说是可重入的,就说这个函数就是线程安全(thread-safe)的。但这并不能说明对信号处理程序来说该函数也是可重入的。如果函数对异步信号处理程序的重入是安全的,那么就可以说函数是异步信号安全(async-signal safe)的。

POSIX.1还提供了以线程安全的方式管理 FILE 对象的方法。可以使用 flockfileftrylockfile 获取给定 FILE 对象关联的锁。这个锁是递归的:当你占有这把锁的时候,还是可以再次获取该锁,而且不会导致死锁。尽管锁的具体实现方式未作规定,但所有操作FILE对象的标准I/O例程都必须表现得如同在内部调用了 flockfilefunlockfile 函数一样。

1
2
3
4
5
#include <stdio.h>
int ftrylockfile(FILE *fp);
// 返回值:若成功,返回0;若不能获取锁,返回非0数值
void flockfile(FILE *fp);
void funlockfile(FILE *fp);

尽管从自身内部数据结构的角度来看,标准I/O例程可能被实现为线程安全的,但将锁定机制暴露给应用程序仍然是有用的。这使得应用程序能够将对标准I/O函数的多次调用组合成原子序列。当然,在处理多个FILE对象时,需要留意潜在的死锁问题,并谨慎地安排锁的顺序。

如果标准I/O例程获取了它们自己的锁,那么在进行逐字符I/O操作时,可能会遇到严重的性能下降问题。在这种情况下,我们最终会为读取或写入的每个字符都进行一次锁的获取和释放。为了避免这种开销,基于字符的标准I/O例程有非锁定版本可供使用。

1
2
3
4
5
6
7
8
#include <stdio.h>
int getchar_unlocked(void);
int getc_unlocked(FILE *fp);
// 两个函数的返回值: 若成功,返回下一个字符;若遇到文件尾或者出错,返回EOF

int putchar_unlocked(int c);
int putc_unlocked(int c, FILE *fp);
// 两个函数的返回值:若成功,返回c;若出错,返回EOF

除非这四个函数被 flockfile(或 ftrylockfile)和 funlockfile 的调用所包围,否则不应调用它们。否则,可能会出现不可预测的结果(即,由多个控制线程对数据进行非同步访问而导致的各种问题)。

一旦锁定了 FILE 对象,你就可以在释放锁之前多次调用这些函数。这样可以将锁定开销分摊到读取或写入的数据量上。


线程特定数据

线程特定数据,也称为线程私有数据,是一种用于存储和查找与特定线程相关联的数据的机制。我们将这些数据称为线程特定的或线程私有的,原因是我们希望每个线程都能访问自己单独的数据副本,而不必担心与其他线程的访问同步问题。

许多人煞费苦心设计了一个线程模型,该模型有助于共享进程数据和属性。那么,为什么会有人想要推广在这种模型中阻止共享的接口呢?原因有两个。

首先,有时我们需要基于每个线程来维护数据。由于无法保证线程ID是小的、连续的整数,我们不能简单地分配一个包含每个线程数据的数组,并将线程ID用作索引。即便我们可以依赖小的、连续的线程ID,我们也希望能有一些额外的保护措施,以防止一个线程干扰另一个线程的数据。

线程私有数据的第二个原因是提供一种机制,使基于进程的接口能够适应多线程环境。一个明显的例子就是 errno。较旧的接口(在线程出现之前)将 errno 定义为一个整数,在进程的上下文中可以全局访问。系统调用和库例程会在失败时将 errno 设为一种副作用。为了让线程也能够使用那些原本基于进程的系统调用和库例程,errno 被重新定义为线程私有数据。因此,一个线程调用函数设置 errno 时,不会影响进程中其他线程的 errno 值。

回想一下,一个进程中的所有线程都可以访问该进程的整个地址空间。除了使用寄存器之外,一个线程无法阻止另一个线程访问其数据。即使是线程特定数据也是如此。尽管底层实现不会阻止这种访问,但所提供的用于管理线程特定数据的函数通过增加线程从其他线程获取线程特定数据的难度,促进了线程之间的数据分离。

在分配线程特定数据之前,需要创建与该数据关联的键。这个键将用于获取对线程特定数据的访问。使用 pthread_key_create 创建一个键。

1
2
3
#include <pthread.h>
int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void *));
// 返回值:若成功,返回0;否则,返回错误编号

创建的键存储在 keyp 指向的内存单元中,这个键可以被进程中的所有线程使用,但每个线程把这个键与不同的线程特定数据地址进行关联。创建新键时,每个线程的数据地址设为空值。

除了创建键以外,pthread_key_create 可以为该键关联一个可选择的析构函数。当线程退出时,如果数据地址已被设置为非空值,析构函数会以该数据地址作为唯一参数被调用。如果析构函数为null,则没有析构函数与该键相关联。当线程正常退出时,无论是通过调用pthread_exit 还是通过返回,析构函数都会被调用。此外,如果线程被取消,析构函数也会被调用,但仅在最后一个清理处理程序返回之后。但是,如果线程调用 exit_exit_Exitabort,或者以其他方式异常退出,则不会调用析构函数。

线程通常使用 malloc 为线程特定数据分配内存。析构函数通常释放已分配的内存。如果线程在没有释放内存之前就退出了,那么这块内存就会丢失,即线程所属进程就出现了内存泄漏。

线程可以为线程特定数据分配多个键,每个键都可以有一个析构函数与它关联。每个键的析构函数可以互不相同,当然所有键也可以使用相同的析构函数。每个操作系统实现可以对进程可分配的键的数量进行限制。

当一个线程退出时,其线程特定数据的析构函数会按实现定义的顺序被调用。析构函数有可能调用另一个函数,该函数会创建新的线程特定数据并将其与键相关联。所有析构函数调用完毕后,系统会检查是否有任何非空的线程特定值与这些键相关联,若有,则会再次调用析构函数。此过程会重复进行,直到该线程的所有键都具有空的线程特定数据值,或者已进行了最多PTHREAD_DESTRUCTOR_ITERATIONS 次尝试为止。

对所有的线程,可以通过调用 pthread_key_delete 来取消键与线程特定数据值之间的关联关系。

1
2
3
#include <pthread.h>
int pthread_key_delete(pthread_key_t key);
// 返回值:若成功,返回0;否则,返回错误编号

请注意,调用 pthread_key_delete 不会触发与该键相关联的析构函数。要释放与该键的线程特定数据值相关的任何内存,我们需要在应用程序中采取额外的步骤。

需要确保分配的键并不会由于在初始化阶段的竞争而发生变动。下面的代码会导致两个线程都调用 pthread_key_create

1
2
3
4
5
6
7
8
9
10
void destructor(void *);
pthread_key_t key;
int init_done = 0;
int threadfunc(void *arg)
{
if (!init_done) {
init_done = 1;
err = pthread_key_create(&key, destructor);
}
}

有些线程可能看到一个键值,而其他的线程看到的可能是另一个不同的键值,这取决于系统是如何调度线程的(这里代码的目标是只创建一个全局key)。解决这种竞争的办法是使用 pthread_once

1
2
3
4
#include <pthread.h>
pthread_once_t initflag = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *initflag, void (*initfn)(void));
// 返回值:若成功,返回0;否则,返回错误编号

initflag 必须是一个非本地变量(如全局变量或静态变量),而且必须初始化为 PTHREAD_ONCE_INIT

如果每个线程都调用 pthread_once,系统就能保证初始化例程 initfn 只被调用一次,即系统首次调用 pthread_once 时。创建键时避免出现冲突的一个正确方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
void destructor(void *);
pthread_key_t key;
pthread_once_t init_done = PTHREAD_ONCE_INIT;
void thread_init(void)
{
err = pthread_key_create(&key, destructor);
}
int threadfunc(void *arg)
{
pthread_once(&init_done, thread_init);
...
}

键一旦创建以后,就可以通过调用 pthread_setspecific 函数把键和线程特定数据关联起来。可以通过 pthread_getspecific 函数获得线程特定数据的地址。

1
2
3
4
5
#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);
// 返回值:线程特定数据地址;若没有值与该键关联,返回NULL
int pthread_setspecific(pthread_key_t key, const void *value);
// 返回值:若成功,返回0;否则,返回错误编号

如果没有线程特定数据值与键关联,pthread_getspecific 将返回一个空指针,可以用这个返回值来确定是否需要调用 pthread_setspecific


取消选项

有两个线程属性没有包含在 pthread_attr_t 结构中,它们是可取消状态和可取消类型。这两个属性影响着线程在响应 pthread_cancel 函数调用时所呈现的行为。

可取消状态属性可以是 PTHREAD_CANCEL_ENABLE,也可以是 PTHREAD_CANCEL_DISABLE。线程可以通过调用 pthread_setcancelstate 修改它的可取消状态。

1
2
3
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
// 返回值:若成功,返回0;否则,返回错误编号

pthread_setcancelstate 把当前的可取消状态设置为state,把原来的可取消状态存储在由 oldstate 指向的内存单元,这两步是一个原子操作。

调用 pthread_cancel 函数并不会等待线程终止。在默认情况下,线程在收到取消请求后会继续执行,直到到达一个取消点。取消点是线程检查自身是否已被取消的地方,如果已被取消,线程就会响应这个请求。POSIX.1标准保证,当线程调用图12.14中列出的任何一个函数时,都会出现取消点。

取消点函数

线程启动时默认的可取消状态是 PTHREAD_CANCEL_ENABLE。当状态设为 PTHREAD_CANCEL_DISABLE 时,对 pthread_cancel 的调用并不会杀死线程。相反,取消请求对这个线程来说还处于挂起状态,当取消状态再次变为 PTHREAD_CANCEL_ENABLE 时,线程将在下一个取消点上对所有挂起的取消请求进行处理。

可以调用 pthread_testcancel 函数在程序中添加自己的取消点。

1
2
#include <pthread.h>
void pthread_testcancel(void);

调用 pthread_testcancel 时,如果有某个取消请求正处于挂起状态,而且取消并没有禁用,那么线程就会被取消。但是,如果取消被禁用, pthread_testcancel 调用就没有任何效果了。

我们所描述的默认的取消类型也称为推迟取消。调用pthread_cancel以后,在线程到达取消点之前,并不会出现真正的取消。可以通过调用pthread_setcanceltype 来修改取消类型。

1
2
3
#include <pthread.h>
int pthread_setcanceltype(int type, int *oldtype);
// 返回值:若成功,返回0;否则,返回错误编号

pthread_setcanceltype 函数把取消类型设置为 type(类型参数可以是 PTHREADCANCEL_DEFERRED,也可以是 PTHREAD_CANCEL_ASYNCHRONOUS),把原来的取消类型返回到 oldtype 指向的整型单元。

异步取消与延迟取消的不同之处在于,线程可以在任何时候被取消。线程不一定需要到达取消点才能被取消。


线程和信号

即使采用基于进程的范式,处理信号也可能很复杂。引入线程会让情况变得更加复杂。

每个线程都有自己的信号掩码,但信号处置方式由进程中的所有线程共享。因此,单个线程可以阻塞信号,但当一个线程修改与特定信号相关的操作时,所有线程都会共享该操作。所以,如果一个线程选择忽略某个特定信号,另一个线程可以通过恢复默认处置方式或为该信号安装信号处理程序来撤销这一选择。

信号会传递给进程中的单个线程。如果信号与硬件故障相关,该信号通常会发送给其操作导致该事件发生的线程。而其他信号则会传递给任意一个线程。

进程可以使用 sigprocmask 函数来阻止信号发送。然而,sigprocmask 的行为在多线程的进程中并没有定义,线程必须使用 pthread_sigmask

1
2
3
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
// 返回值:若成功,返回0;否则,返回错误编号

pthread_sigmask 函数与 sigprocmask 函数基本相同,不过 pthread_sigmask 工作在线程中,而且失败时返回错误码,不再像 sigprocmask 中那样设置 errno 并返回−1。

set 参数包含线程用于修改信号屏蔽字的信号集。how参数可以取下列3个值之一:SIG_BLOCK 把信号集添加到线程信号屏蔽字中,SIG_SETMASK 用信号集替换线程的信号屏蔽字;SIG_UNBLOCK 从线程信号屏蔽字中移除信号集。如果oset 参数不为空,线程之前的信号屏蔽字就存储在它指向的 sigset_t 结构中。线程可以通过把 set 参数设置为 NULL,并把 oset 参数设置为 sigset_t 结构的地址,来获取当前的信号屏蔽字。这种情况中的how参数会被忽略。

线程可以通过调用 sigwait 等待一个或多个信号的出现。

1
2
3
#include <signal.h>
int sigwait(const sigset_t *restrict set, int *restrict signop);
// 返回值:若成功,返回0;否则,返回错误编号

set 参数指定了线程等待的信号集。返回时, signop 指向一个整数的指针,用于存储接收到的信号值。

如果在调用 sigwait 时,指定集中的某个信号处于未决状态,那么 sigwait 将立即返回而不会阻塞。返回之前,sigwait 会从进程的未决信号集中移除该信号。如果实现支持排队信号,且某个信号有多个实例处于未决状态,sigwait只会移除该信号的一个实例,其他实例仍会保持排队状态。

为避免错误行为,线程在调用 sigwait 之前必须阻塞它正在等待的信号。sigwait 函数会自动解除这些信号的阻塞,并等待其中一个信号被送达。在返回之前,sigwait 会恢复线程的信号掩码。如果在调用 sigwait 时信号未被阻塞,那么就会出现一个时间窗口,其中一个信号可能在线程完成对 sigwait 的调用之前被送达该线程。

使用 sigwait 的优势在于,它能够通过允许我们以同步方式处理异步生成的信号来简化信号处理。我们可以通过将信号添加到每个线程的信号掩码中,防止这些信号中断线程。然后,我们可以专门指定某些线程来处理这些信号。这些专用线程可以进行函数调用,而不必担心哪些函数从信号处理器中调用是安全的,因为它们是从正常的线程上下文中被调用的,而不是从中断正常线程执行的传统信号处理器中调用的。

如果多个线程在调用 sigwait 以等待同一个信号时被阻塞,那么当该信号被传递时,只有其中一个线程会从 sigwait返回。如果一个信号被捕获(例如进程通过使用 sigaction 建立了一个信号处理程序),而一个线程正在 sigwait 调用中等待同一信号,那么这时将由操作系统实现来决定以何种方式递送信号。操作系统实现可以让 sigwait 返回,也可以调用信号处理程序,但这两种情况不会同时发生。

要把信号发送给进程,可以调用 kill。要把信号发送给线程,可以调用 pthread_kill

1
2
3
#include <signal.h>
int pthread_kill(pthread_t thread, int signo);
// 返回值:若成功,返回0;否则,返回错误编号

可以传一个0值的signo来检查线程是否存在。如果信号的默认处理动作是终止该进程,那么把信号传递给某个线程仍然会杀死整个进程。

注意,闹钟定时器是进程资源,并且所有的线程共享相同的闹钟。所以,进程中的多个线程不可能互不干扰(或互不合作)地使用闹钟定时器。


线程和fork

当线程调用 fork 时,就为子进程创建了整个进程地址空间的副本。回忆之前讨论的写时复制,子进程与父进程是完全不同的进程,只要两者都没有对内存内容做出改动,父进程和子进程之间还可以共享内存页的副本。

子进程通过继承整个地址空间的副本,还从父进程那儿继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的线程,子进程在fork返回以后,如果紧接着不是马上调用 exec 的话,就需要清理锁状态。

在子进程内部,只存在一个线程。它是由父进程中调用fork的线程复制而来的。如果父进程中的线程持有任何锁,那么子进程中也会持有相同的锁。问题在于,子进程中并没有持有这些锁的线程的副本,因此子进程无法知道哪些锁被持有以及需要被解锁。

如果子进程在从fork返回后直接调用某个exec函数,这个问题就可以避免。在这种情况下,旧的地址空间会被丢弃,所以锁的状态无关紧要。然而,这并非总能实现,因此如果子进程需要继续处理,我们就需要采用不同的策略。

为避免多线程进程中出现状态不一致的问题,POSIX.1规定,子进程在fork返回后到调用某个exec函数前的这段时间内,只能调用异步信号安全的函数。这限制了子进程在调用exec之前可以执行的操作,但并未解决子进程中的锁状态问题。

要清除锁状态,可以通过调用 pthread_atfork 函数建立fork处理程序。

1
2
3
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
// 返回值:若成功,返回0;否则,返回错误编号

pthread_atfork 函数最多可以安装3个帮助清理锁的函数。prepare fork 处理程序由父进程在fork创建子进程前调用。这个fork处理程序的任务是获取父进程定义的所有锁。parent fork 处理程序是在fork 创建子进程以后、返回之前在父进程上下文中调用的。这个fork处理程序的任务是对 prepare fork 处理程序获取的所有锁进行解锁。 child fork 处理程序在fork返回之前在子进程上下文中调用。与 parent fork 处理程序一样,child fork 处理程序也必须释放 prepare fork 处理程序获取的所有锁。

需要注意的是,这些锁并非像看起来那样被锁定一次、解锁两次。当子地址空间创建时,它会获得父进程定义的所有锁的副本。由于 prepare fork 获取了所有的锁,父进程和子进程中的内存一开始就具有完全相同的内容。当父进程和子进程解锁它们各自的锁“副本”时,会为子进程分配新的内存,并且父进程的内存内容会被复制到子进程的内存中(写时复制),因此我们会面临这样一种情况:看起来就像父进程锁定了其所有的锁副本,而子进程也锁定了其所有的锁副本。父级和子级最终解开了存储在不同内存位置的重复锁,就好像发生了以下一系列事件:

(1)父进程获取所有的锁。

(2)子进程获取所有的锁。

(3)父进程释放它的锁。

(4)子进程释放它的锁。

可以多次调用 pthread_atfork 函数从而设置多套fork处理程序。如果不需要使用其中某个处理程序,可以给特定的处理程序参数传入空指针,它就不会起任何作用了。使用多个fork处理程序时,处理程序的调用顺序并不相同。parentchild fork 处理程序是以它们注册时的顺序进行调用的,而 prepare fork 处理程序的调用顺序与它们注册时的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次。

我们可以多次调用 pthread_atfork 来安装多组fork处理程序。如果我们不需要使用其中某个处理程序,可以为该特定的处理程序参数传递一个空指针,这样它就不会产生任何效果。当使用多个fork处理程序时,处理程序的调用顺序会有所不同。parentchild 的fork处理程序会按照它们注册的顺序被调用,而 prepare 的fork处理程序则会按照与注册顺序相反的顺序被调用。这种顺序安排使得多个模块能够注册各自的fork处理程序,同时仍然遵循锁定层次结构。

例如,假设模块A调用模块B中的函数,而且每个模块有自己的一套锁。如果锁的层次是A在B之前,模块B必须在模块A之前设置它的fork处理程序。当父进程调用fork时,就会执行以下的步骤,假设子进程在父进程之前运行:

(1)调用模块A的prepare fork处理程序获取模块A的所有锁。

(2)调用模块B的prepare fork处理程序获取模块B的所有锁。

(3)创建子进程。

(4)调用模块B中的child fork处理程序释放子进程中模块B的所有锁。

(5)调用模块A中的child fork处理程序释放子进程中模块A的所有锁。

(6)fork函数返回到子进程。

(7)调用模块B中的parent fork处理程序释放父进程中模块B的所有锁。

(8)调用模块A中的parent fork处理程序来释放父进程中模块A的所有锁。

(9)fork函数返回到父进程。


线程和I/O

preadpwrite 函数在多线程环境下是非常有用的,因为进程中的所有线程共享相同的文件描述符。

考虑两个线程,在同一时间对同一个文件描述符进行读写操作。

1
2
3
线程A                   线程B
lseek(fd, 300, SEEK_SET);        lseek(fd, 700, SEEK_SET);
read(fd, buf1, 100);           read(fd, buf2, 100);

如果线程A执行 lseek 然后线程B在线程A调用 read 之前调用 lseek,那么两个线程最终会读取同一条记录。很显然这不是我们希望的。

为了解决这个问题,可以使用 pread,使偏移量的设定和数据的读取成为一个原子操作

1
2
线程A                   线程B
pread(fd, buf1, 100, 300);        pread(fd, buf2, 100, 700);

使用 pread 可以确保线程A读取偏移量为300的记录,而线程B读取偏移量为700的记录。可以使用 pwrite 来解决并发线程对同一文件进行写操作的问题。