APUE 线程控制

《UNIX环境高级编程》第12章 线程控制 笔记

线程属性

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

  • 每个对象与它自己类型的属性对象进行关联
  • 有一个初始化函数,把属性设置为默认值。
  • 还有一个销毁属性对象的函数。
  • 每个属性都有一个从属性对象中获取属性值的函数
  • 每一个属性都有一个设置属性值的函数

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

1
2
3
4
5
6
#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
// Both return: 0 if OK, error number on failure

POSIX.1定义的线程属性:

detachstate 属性

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

1
2
3
4
5
6
7
#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);
// Both return: 0 if OK, error number on failure

stackaddr 属性

对线程栈属性进行管理的函数:

1
2
3
4
5
6
7
#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);
// Both return: 0 if OK, error number on failure

对于进程来说,虚地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认的线程栈大小。

另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。

如果线程栈的虚地址空间都用完了,那可以使用ma1loc或者mnap来为可替代的栈分配空间,并用pthread _attr_setstack函数来改变新建线程的栈位置。由 tackaddr参数指定的地址可以用作线程栈的内存范围中的最低可寻址地址,该地址与处理器结构相应的边界应对齐。当然,这要假设malloc和mmap所用的虚地址范围与线程栈当前使用的虚地址范围不同。

stacksize 属性

stackaddr线程属性被定义为栈的最低内存地址,但这并不一定是栈的开始位置。对于一个给定的处理器结构来说,如果栈是从高地址向低地址方向增长的,那么 stackaddi线程属性将是栈的结尾位置,而不是开始位置

管理线程属性stacksize函数:

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

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

guardsize 属性

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

管理线程属性guardsize属性的函数如下:

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

如果 guardsize线程属性被修改了,操作系统可能会把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区域,应用程序就可能通过信号接收到出错信息。

同步属性

互斥量属性

互斥量属性是用pthread_mutexattr_t结构表示的,其初始化和销毁函数如下:

1
2
3
4
5
6
#include <pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
// Both return: 0 if OK, error number on failure

值得注意的3个属性是:进程共享属性、健壮属性以及类型属性

存在这样的机制:允许相互独立的多个进程把同一个内存数据块映射到它们各自独立的地址空间中。就像多个线程访问共享数据一样,多个进程访问共享数据通常也需要同步。如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步

获得和修改进程共享属性的函数如下:

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

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

互斥量健壮属性与在多个进程间共享的互斥量有关。这意味着,当持有互斥量的进程终止时需要解决互斥量状态恢复的问题。这种情况发生时,互斥量处于锁定状态,恢复起来很困难。

获取和设置互斥量健壮属性的函数如下:

1
2
3
4
5
6
7
#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);
// Both return: 0 if OK, error number on failure

读写锁属性

读写锁和互斥量类似,都有属性,用数据结构pthread_rwlockattr_t表示,初始化和反初始化函数如下:

1
2
3
4
5
6
7
#include <pthread.h>
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
// Both return: 0 if OK, error number on failure

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

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

条件变量属性

目前定义了条件变量的两个属性:进程共享属性和时钟属性,用数据结构pthread_condattr_t表示,初始化和反初始化函数如下:

1
2
3
4
5
6
#include <pthread.h>
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);
// Both return: 0 if OK, error number on failure

获取和设置进程共享属性的函数如下:

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

时钟属性控制计算pthread_cond_timedwait函数的超时参数(sp)时采用的是哪个时钟,相关函数如下:

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

屏障属性

初始化和反初始化函数:

1
2
3
4
5
6
#include <pthread.h>
int pthread_barrierattr_init(pthread_barrierattr_t *attr);
int pthread_barrierattr_destroy(pthread_barrierattr_t *attr);
// Both return: 0 if OK, error number on failure

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

1
2
3
4
5
6
#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);
// Both return: 0 if OK, error number on failure

重入

多个控制线程在相同的时间有可能调用相同的函数。如果一个函数在相同的时间点可以被多个线程安全地调用,就称该函数是线程安全的。

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

线程特定数据

线程特定数据( thread-specific data),也称为线程私有数据( thread-private data.),是存储和查询某个特定线程相关数据的一种机制。我们把这种数据称为线程特定数据或线程私有数据的原因是,我们希望每个线程可以访问它自己单独的数据副本,而不需要担心与其他线程的同步访问问题

设置线程私有数据的原因:

  1. 有时候需要维护基于每线程(per-thread)的数据,即使线程ID确实是小而连续的整数,我们可能还希望有一些额外的保护,防止某个线程的数据与其他线程的数据相混淆。
  2. 它提供了让基于进程的接口适应多线程环境的机制。一个很明显的例子就是errno,以前的接口(线程出现以前)把errno定义为进程上下文中全局可访问的整数。系统调用和库例程在调用或执行失败时设置把它作为操作失败时的附属结果。为了让线程也能够使用那些原本基于进程的系统调用和库例程, errno被重新定义为线程私有数据。这样,一个线程做了重置 errno的操作也不会影响进程中其他线程的 errno值。

我们知道一个进程中的所有线程都可以访问这个进程的整个地址空间。除了使用寄存器以外,一个线程没有办法阻止另一个线程访问它的数据。线程特定数据也不例外。虽然底层的实现部分并不能阻止这种访问能力,但管理线程特定数据的函数可以提高线程间的数据独立性,使得线程不太容易访问到其他线程的线程特定数据

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

1
2
3
4
5
#include <pthread.h>
int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void *));
// Returns: 0 if OK, error number on failure

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

除了创建键以外, pthreadkey create可以为该键关联一个可选择的析构函数。当这个线程退出时,如果数据地址已经被置为非空值,那么析构函数就会被调用,它唯一的参数就是该数据地址。如果传入的析构函数为空,就表明没有析构函数与这个键关联。

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

线程退出时,线程特定数据的析构函数将按照操作系统实现中定义的顺序被调用。析构函数可能会调用另一个函数,该函数可能会创建新的线程特定数据,并且把这个数据与当前的键关联起来。当所有的析构函数都调用完成以后,系统会检查是否还有非空的线程特定数据值与键关联,如果有的话,再次调用析构函数。

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

1
2
3
4
5
#include <pthread.h>
int pthread_key_delete(pthread_key_t key);
// Returns: 0 if OK, error number on failure

上述函数不会激活与键关联的析构函数

需要确保分配的键并不会由于在初始化阶段的竞争而发生变动。解决这种竞争的方法是使用pthread_once

1
2
3
4
5
6
7
#include <pthread.h>
pthread_once_t initflag = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *initflag, void (*initfn)(void));
// Returns: 0 if OK, error number on failure

initflag必须是一个非本地变量(如全局变量或静态变量),而且必须初始化为PTHREAD_ONCE_INIT。如果每个线程都调用 pthread_once,系统就能保证初始化例程initfn只被调用一次,即系统首次调用 pthread_once时。

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

1
2
3
4
5
6
7
8
9
#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);
// Returns: thread-specific data value or NULL if no value has been associated with the key
int pthread_setspecific(pthread_key_t key, const void *value);
// Returns: 0 if OK, error number on failure

取消选项

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

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

1
2
3
4
5
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
// Returns: 0 if OK, error number on failure

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

pthread_cancel调用并不等待线程终止。在默认情况下,线程在取消请求发出以后还是继续运行,直到线程到达某个取消点。取消点是线程检查它是否被取消的一个位置,如果取消了,则按照请求行事。

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

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

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

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

1
2
3
4
5
#include <pthread.h>
int pthread_setcanceltype(int type, int *oldtype);
// Returns: 0 if OK, error number on failure

有两种取消类型:推迟取消和异步取消,异步取消与推迟取消不同,因为使用异步取消时,线程可以在任意时间撤消,不是非得遇到取消点才能被取消

线程和信号

每个线程都有自己的信号屏蔽字,但是信号的处理是进程中所有线程共享的。这意味着单个线程可以阻止某些信号,但当某个线程修改了与某个给定信号相关的处理行为以后,所有的线程都必须共享这个处理行为的改变。这样,如果一个线程选择忽略某个给定信号,那么另一个线程就可以通过以下两种方式撤消上述线程的信号选择:恢复信号的默认处理行为,或者为信号设置个新的信号处理程序

进程使用sigprocmask函数来阻止信号发送。线程使用的函数是pthread_sigmask

1
2
3
4
5
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
// Returns: 0 if OK, error number on failure

pthread_sigmask函数与sigprocmask函数基本相同,不过pthread_sigmask工作在线程中,而且失败时返回错误码,不再像sigprocmask中那样设置errno并返回-1。set参数包含线程用于修改信号屏蔽字的信号集。how参数可以取下列3个值之一: SIG_BLOCK,把信号集添加到线程信号屏蔽字中, SIG_SETMASK,用信号集替换线程的信号屏蔽字;SIG_UNBLOCK,从线程信号屏蔽字中移除信号集。如果oset参数不为空,线程之前的信号屏蔽字就存储在它指向的siget_t结构中。线程可以通过把set参数设置为NULL,并把oser参数设置为si gset_t结构的地址,来获取当前的信号屏蔽字。这种情况中的how参数会被忽略

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

1
2
3
4
5
#include <signal.h>
int sigwait(const sigset_t *restrict set, int *restrict signop);
// Returns: 0 if OK, error number on failure

set参数指定了线程等待的信号集。返回时, signop指向的整数将包含发送信号的数量

为了避免错误行为发生,线程在调用sigwait之前,必须阻塞那些它正在等待的信号

sigwait函数会原子地取消信号集的阻塞状态,直到有新的信号被递送

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

1
2
3
4
5
#include <signal.h>
int pthread_kill(pthread_t thread, int signo);
// Returns: 0 if OK, error number on failure

线程和fork

当线程调用fork时,就为子进程创建整个进程地址空间的副本

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

在子进程内部,只存在一个线程,它是由父进程中调用fork的线程的副本构成的。

在多线程的进程中,为了避免不一致状态的问题, POSIX.1声明,在fork返回和子进程调用其中一个exec函数之间,子进程只能调用异步信号安全的函数。这就限制了在调用exec之前子进程能做什么,但不涉及子进程中锁状态的问题。

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

1
2
3
4
5
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
// Returns: 0 if OK, error number on failure

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

线程和I/O

在多线程环境下,使用的都是pread和pwrite函数,这些函数都是源自操作,使得多线程下所有线程可以共享相同的文件描述符。