12.1 引言
上一章讲了线程以及线程同步的基础知识。
本章将讲解控制线程的行为方面的详细内容,介绍线程属性和同步原语属性。前面的章节中使用的都是它们的默认行为,没有进行详细的介绍。
还将介绍同一进程的多个线程之间如何保持数据的私有性。最后讨论基于进程的系统调用如何与线程进行交互。
12.2 线程限制
SUS定义了线程操作有关的一些限制。于其他的系统限制一样,这些限制也可以通过sysconf函数进行查询。
限制名称 | name参数 | 描述 | linux限制 |
---|---|---|---|
PTHREAD_DESTRUCTOR_ITERATIONS | _SC_PTHREAD_DESTRUCTOR_ITERATIONS | 线程退出时操作系统实现试图销毁线程特定数据的最大次数 | 4 |
PTHREAD_KEYS_MAX | _SC_PTHREAD_KEYS_MAX | 进程可以创建的键的最大数目 | 1024 |
PTHREAD_STACK_MIN | _SC_PTHREAD_STACK_MIN | 一个线程的栈可用的最小字节数 | 16384 |
PTHREAD_THREADS_MAX | _SC_PTHREAD_THREADS_MAX | 进程可以创建的最大线程数 | 没有确定的限制 |
12.3 线程属性
pthread接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。
- 每个对象与它自己类型的属性对象进行关联。一个属性对象可以代表多个属性。
- 有一个初始化函数,把属性设置为默认值。
- 还有一个销毁属性对象的函数。如果初始化函数分配了与属性对象关联的资源,销毁函数负责释放这些资源
- 每个属性都有一个从属性对象中获取属性值的函数。
- 每个属性都一个设置属性值的函数。
可以使用pthread_attr_init
函数初始化pthread_attr_t
结构。在调用pthread_attr_init
以后,pthread_attr_t
结构所包含的就是操作系统实现支持的所有线程属性的默认值。
#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
下面列出了一些常用属性:
名称 | 描述 |
---|---|
detachstate | 线程的分离状态属性 |
guardsize | 线程栈末尾的警戒缓冲区大小(字节数) |
stacksddr | 线程栈的最低地址 |
stacksize | 线程栈的最小长度(字节数) |
11.5节介绍了分离线程的概念。如果对现有的某个线程的终止状态不感兴趣的话,可以使用pthread_detach函数让操作系统在线程退出时收回它所占用的资源。
如果在创建线程时就知道不需要了解线程的终止状态,就可以修改结构中的detachstate属性,让线程一开始就处于分离状态。
可以使用pthread_attr_setdetachstate函数把线程属性detachstate设置成以下两个合法值之一:PTHREAD_CREATE_DETACHED以分离方式启动线程;或者PTHREAD_CREATE_JOINABLE,正常启动线程,应用程序可以获取线程的终止状态。
#include <pthread.h>
int pthread_attr_getdetachstate(pthread_attr_t *restrict attr,int *detachstate);
int pthread_attr_destroy(pthread_attr_t *attr,int *detachstate);
可以调用pthread_attr_getdetachstate函数获取当前的detachstate线程属性。
对于POSIX标准的操作系统,并不一定要支持线程栈属性,但是对于SUS标准的操作系统来说,支持线程栈属性就是必须的。可以在编译阶段使用_POSIX_THREAD_ATTR_STACKADDR和_POSIX_THREAD_ATTR_STACKSIZE符号来检查系统是否支持每一个线程栈属性。如果系统定义了这些符号中的一个就说明它支持相应的线程栈属性。或者也可以在运行阶段把_SC_POSIX_THREAD_ATTR_STACKADDR和_SC__POSIX_THREAD_ATTR_STACKSIZE参数传递给sysconf函数,检查运行时系统对线程栈属性的支持情况。
可以使用函数pthread_attr_getstack和pthread_attr_setstack对线程栈属性进行管理。
#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);
对于进程来说,虚拟地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚拟地址空间必须被所以的线程栈共享。如果应用程序使用了许多线程,以至这些线程栈的累积大小超过了可用的虚拟地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能比默认的大。
如果线程的虚拟地址空间都用完了,那可以使用malloc或者mmap来为可替代的栈分配空间,并用*pthread_attr_setstack函数来改变新建线程栈的位置*。
应用程序也可以通过pthread_attr_getstacksize
和pthread_attr_setstacksiz
e函数读取或设置线程属性stacksize。
#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);
如果希望改变栈的大小,但又不想自己处理线程栈的分配问题(即线程栈的首地址),这时使用pthread_attr_setstacksiz
函数就非常有用。设置stacksize
属性时,选择stacksize
不能小于PTHREAD_STACK_MIN
.
线程属性guardsize控制着线程栈末尾之后用以避免栈溢出的扩展内存大小。这个属性默认值是由具体实现来定义的,但常用值是系统页大小。可以把guardsize线程属性设置为0,不允许属性的这种特征行为发生:在这种情况下,不会提供警戒缓冲区。同样,如果修改了线程属性stackaddr,系统就认为我们将自己管理栈,进而使栈警戒区机制无效,这等同于把guardsize线程属性设置为0。
#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);
如果guardsize线程属性被修改了,操作系统可能会把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区域,应用程序就可能通过信号接收到出错信息。
线程还有一些其他的pthread_attr_t结构中没有表示的属性:可撤销状态和可撤销类型。
12.4 同步属性
就像线程具有属性一样,线程的同步对象也有属性。
12.4.1 互斥量属性
互斥量属性是用pthread_mutexattr_t
结构表示的。上一章中每次对互斥量进行初始化时,都是通过PTHREAD_MUTEX_INITIALZER常量或使用指向互斥量属性结构的空指针作为参数调用pthread_mutex_init函数,得到互斥量的默认属性。
对于非默认属性,可以用pthread_mutexattr_init
初始化pthread_mutexattr_t结构,用pthread_mutexattr_destory
来反初始化。
#include <pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destory (pthread_mutexattr_t *attr);
int pthread_mutexattr_init函数将用默认的互斥量属性初始化pthread_mutexattr_t结构。值得注意的3个属性是:
- 进程共享属性
- 健壮属性
- 类型属性
进程共享属性可以通过符号_POSIX_THREAD_PROCESS_SHARED
符号来判断平台是否支持这个属性。也可以通过_SC_POSIX_THREAD_PROCESS_SHARE
参数传递给sysconf函数进行检查。
在进程中,多个线程可以访问同一个同步对象。这时进程共享互斥量属性设置为PTHREAD_PROCESS_PRIVATE
。
在14和15章中将会看到:允许相互独立的多个进程把同一个内存数据块映射到它们各自独立的地址空间中。进程间共享数据也需要同步。这时进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED
,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。
#include <pthread.h>
int pthread_mutexattr_getshared(const pthread_mutexattr_t *restrict attr,int *restrict pshared);
int pthread_mutexattr_setshared(pthread_mutexattr_t *attr,int *pshared);
以上两函数用于设置和读取进程互斥量共享属性。
互斥量健壮属性与在多个进程间共享互斥量有关。
这里仅了解一下,APUE Pg 346.
类型互斥量属性控制着互斥量的锁定特性。
互斥量类型 | 含义 |
---|---|
PTHREAD_MUTEX_NORMAL | 一种标准互斥量类型,不做任何特殊的错误检查或死锁检测。 |
PTHREAD_MUTEX_ERRORCHECK | |
PTHREAD_MUTEX_RECURSIVE | |
PTHREAD_MUTEX_DEFAULT |
#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);
12.4.2读写锁属性
读写锁与互斥量类似,也是有属性的。以下是初始化与反初始化属性结构的函数:
#include <pthread.h>
int pthread_rwlockattr_init(const pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(const pthread_rwlockattr_t *attr);
读写锁唯一支持的属性是进程共享属性。它与互斥量的进程共享属性是相同的。就像互斥量的进程共享属性一样,有一对函数用于读取和设置读写锁的进程共享属性。
#include <pthread.h>
int pthread_rwlockattr_getshared(const pthread_rwlockattr_t *restrict attr,int *restrict pshared);
int pthread_rwlockattr_setshared(pthread_rwlockattr_t *attr,int pshared);
虽然POSIX标准只定义了一个读写锁属性,但不同平台的实现可以自由地定义额外的、非标准的属性。
12.4.3条件变量属性
SUS定义了条件变量的两个属性:进程共享属性和时钟属性。
#include <pthread.h>
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);
与其他同步属性一样,条件变量支持进程共享属性。它控制着条件变量是可以被单进程的多个线程使用,还是可以被多进程的线程使用。以下函数用于操作进程共享属性的值。
#include <pthread.h>
int pthread_rwlockattr_getshared(const pthread_rwlockattr_t *restrict attr,int *restrict pshared);
int pthread_rwlockattr_setshared(pthread_rwlockattr_t *attr,int pshared);
时钟属性控制pthread_cond_timeout函数的超时参数采用的是哪个时钟。以下函数用于设置和获取超时时钟:
#include <pthread.h>
int pthread_condattr_getclock(pthread_condattr_t *attr,clock_t *restrict clock_id);
int pthread_condattr_setclock(pthread_condattr_t *attr,clock_t clock_id);
12.4.4 屏障属性
屏障也有属性。使用以下函数进程初始化和反初始化:
#include <pthread.h>
int pthread_barrierattr_init(pthread_barrierattr_t *attr);
int pthread_barrierattr_destroy(pthread_barrierattr_t *attr);
目前定义的屏障属性只有进程共享属性,它控制着屏障是可以被多进程线程使用,还是只能被初始化屏障的进程内的多线程使用。以下函数用于获取或设置屏障共享属性。
#include <pthread.h>
int pthread_barrierattr_getshared(const pthread_barrierattr_t *restrict attr,int *restrict pshared);
int pthread_barrierattr_setshared(pthread_barrierattr_t *attr,int pshared);
进程共享属性的值可以是PTHREAD_PROCESS_SHARED
(多进程中的多个线程可用),也可以是PTHREAD_PROCESS_PRIVATE
(只有初始化屏障的那个进程内的多个线程可以)。
12.5 重入
多个控制线程在相同的时间点有可能调用相同的函数。如果一个函数在相同的时间点可以被多个线程安全地调用,就称该函数是线程安全的。
如果一个函数多个线程来说是可重入的,就说这个函数就是线程安全的。但这并不能说明对信号处理程序来说该函数也是可重入的。如果函数对异步信号处理程序的重入是安全的,那么就说函数是异步信号安全的。
(异步信号安全和线程安全的区别???)
可能是因为函数内有加锁操作。
- 情况1:比如一个函数对资源加锁了,在调用这个函数时信号发生了,信号处理程序中又试图对资源加锁,这时就发生了死锁。
- 情况2:如果一个线程调用的函数对资源加锁,此时内核调度了另一个线程,函数对此资源再次加锁,会阻塞。待到前一个线程解锁时线程二回获得该锁。
POSIX还提供了以线程安全的方式管理FILE对象的方法。可以使用flockfile和ftrylockfile获取给定FILE对象关联的锁。这个锁是递归的:当占有这把锁的时候,还是可以再次获得该锁,而且不会导致死锁。
#include <stdio.h>
int ftrylockfile(FILE *fp);
void flockfile(FILE *fp);
void funlockfile(FILE *fp);
如果标准IO例程都要获得他们各自的锁,那么在做一次一个字符的IO时就会出现严重的性能下降。为了避免这种开销,出现了不加锁版本的基于字符的标准IO例程。
#include <stdio.h>
int getchar_unlocked(void);
void getc_unlocked(FILE *fp);
int putchar_unlocked(int c);
int putc_unlocked(int c,FILE *fp);
除非被flockfile或(ftrylockfile)和funlockfile的调用包围,否则尽量不要调用这4个函数,因为他们会导致不可预期的结果。
一旦对FILE对象进行加锁,就可以在释放锁之前对这些函数进行多次调用。这样就可以在多次的数据读写上分摊总的加解锁的开销。
12.6 线程特定数据
线程特定数据(hread_specific data),也称线程私有数据(thread_private data),是存储和查询某个特定线程相关数据的一种机制。我们把这种数据称为线程特定数据或线程私有数据的原因是,我们希望每个线程可以访问它自己单独的数据副本,而不需要担心与其他线程的同步访问问题。
线程模型促进了进程中数据和属性的共享,那么为什么又需要促进阻止共享的接口呢?这有两个原因:
- 第一,有时候需要维护基于每个线程(per-thread)的数据。这些数据是独立于某个线程的。
- 第二,它提供了让基于进程的接口适应多线程环境的机制。一个很明显的实例就是errno。以前基于进程的接口errno定义为进程上下文中全局可访问的整数。系统调用和库例程在调用或执行失败时设置errno,把它作为操作失败的附属结果。为了让线程也能使用哪些原本基于进程的系统调用和库例程,errno被重新定义为线程私有数据。这样,一个线程做了重置errno的操作也不会影响进程中其他线程的errno值。
我们知道一个进程中的所有线程都可以访问这个进程的整个地址空间。除了使用寄存器以外,一个线程没有办法阻止另一个线程访问它的数据。线程特定数据也不例外。虽然底层的实现部分并不能阻止这种访问能力,但管理线程特定数据的函数可以提高线程间的数据独立性,使得线程不太容易访问到其他线程的线程特定数据。
在分配线程特定数据之前,需要创建该数据关联的键(key)。这个键将用于获取对线程特定数据的访问。
#include <pthread.h>
int pthread_key_create(pthread_key_t *keyp,void (*destructor)(void *));
创建的键存储在keyp指向的内存单元中,这个键可以被进程中的所有线程使用,每个线程被这个键与不同的线程特定数据地址进行关联。创建新键时,每个线程的数据地址设为空值。
除了创建键以外,还可以为键关联一个可选的析构函数。当这个线程退出时,如果数据地址已经被置为非空值,那么析构函数就会被调用,它唯一的参数就是该数据地址。线程正常退出时该析构函数被调用,若非正常退出则不被调用。
线程通常使用malloc为线程特定数据分配内存。析构函数通常释放已经分配的内存。如果线程没有释放内存之前就退出了,那么这块内存会丢失,即线程所属的进程出现了内存泄漏。
对所有的线程,我们都可以通过调用pthread_key_delete
来取消键与线程特定数据之间的关联关系。
#include <pthread.h>
int pthread_key_delete(pthread_key_t *keyp);
注意调用pthread_key_delete并不会激活与键关联的析构函数。要释放任何与键关联的线程特定数据值的内存,需要在应用程序中采取额外的步骤。
一旦创建键以后,就可以通过调用pthread_setspecific函数把键和线程特定数据关联起来。可以通过pthread_getspecific函数获得线程特定数据的地址。
#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);//若没有线程特定数据值,则返回NULL;
int pthread_setspecific(pthread_key_t key,const void *value);
12.7 取消选项
有两个线程属性并没有包含在pthread_attr_t结构中,它们是可取消状态和可取消类型。这两个属性影响着线程在响应pthread_cancel函数调用时所呈现的行为。
线程可以通过调用pthread_setcancelstate
修改它的可取消状态。
#include <pthread.h>
int pthread_setcancelstate(int state,int *oldstate);
state可设置为PTHREAD_CANCEL_ENABLE
,也可以是PTHREAD_CANCEL_DISABLE.
pthread_cancel调用并不等待线程终止。在默认情况下,线程在取消请求发出以后还是继续运行,直到线程到达某个取消点(就是一些函数)。
如果应用程序很长时间都会不会调用以上函数(如数学计算),那么可以调用pthread_testcancel
函数在程序中添加自己的取消点。
#include <pthread.h>
void pthread_testcancel(void);
调用pthread_testcancel
时,如果某个取消请求正处于挂起状态,而且取消并没有置为无效,那么线程就被取消。但是如果取消被置为无效,pthread_testcancel
调用就没有任何效果了。
我们所描述的默认的取消类型也称为推迟取消。调用pthread_cancel以后,在线程到达取消点之前,并不会出现真正的取消。可以通过调用pthread_setcanceltype 来修改取消类型。
#include <pthread.h>
int pthread_setcanceltype(int type,int *oldtype);
pthread_setcanceltype函数把取消类型设置为type,其类型可以是PTHREAD_CANCEL_DEFERRED或PTHREAD_CANCEL_ASYNCHRONOUS(异步取消)。
异步取消与推迟取消不同,因为使用异步取消时,线程可以在任意时刻撤销,不是非得遇到取消点才能被取消。
12.8 线程和信号
每个进程都有自己的信号屏蔽字,但是信号的处理是进程中所有线程共享的。
进程中的信号是递送到单个线程的。如果一个信号与硬件故障有关,那么该信号一般会被发送到引起该事件的线程中去,而其他信号则被发送到任意一个线程。
线程中需要使用pthread_sigmask来阻止信号发送。
#include <signal.h>
int pthread_sigmask(int how,const sigset_t *restrict set,sigset_t *restrict oset);
线程可以通过调用sigwait等待一个或多个信号的出现:
#include <signal.h>
int sigwait(const sigset_t *restrict set,int *restrict signop);
set参数指定了线程等待的信号集。返回时signop指向的整数包含发送的信号。
如果信号集中的某个信号在sigwait调用的时候处于挂起状态,那么sigwait将无阻塞地返回,在返回之前,sigwait将从进程中移除那些处于挂起等待状态的信号。如果具体实现支持排队信号,并且信号的多个实例被挂起,那么sigwait将会移除该信号的一个实例,其他实例还要继续排队。
使用sigwait的好处在于他可以简化信号处理,允许把异步产生的信号用同步的方式处理。为了防止信号中断线程,可以把信号加到每个线程的信号屏蔽字中。然后可以安排专用的线程处理信号。!!!
- 如果多个线程在sigwait的调用中因等待同一个信号而阻塞,那么在信号递送的时候,就只有一个线程可以从sigwait中返回。
- 如果一个信号被捕获,而且一个线程正在sigwait调用中等待同一信号,那么这时将由操作系统实现来决定以何种方式递送信号。操作系统可以让sigwait返回,也可以激活信号处理程序,但这两者不会同时发生。
要想把信号发送给进程,可以调用kill。要把信号发送给线程,可以调用pthread_kill
。
#include <pthread.h>
int pthread_kill(pthread_t thread,int signo);
12.9 线程和fork
当线程调用fork时,就为子进程创建了整个进程地址空间的副本。子进程通过继承整个地址空间的副本,还从父进程那继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的进程,子进程在fork返回后,如果紧接着不是马上调用exec的话,就需要清理锁状态。
在子进程内部,只存在一个线程,它是由父进程中调用fork线程的副本构成的。如果父进程中的线程占用锁,子进程将同样占有这些锁。问题是子进程并不包含占有锁线程的副本,所以子进程就没办法知道它占有了哪些锁,需要释放哪些锁。如果fork之后立即使用exec替换地址空间,就不会有这个问题。
要清除锁状态,可以通过调用pthread_atfork函数建立fork处理程序。
#include <pthread.h>
int pthread_atfork(void(*prepare)(void),void(*parent)(void),void(*child)(void));
- prepare fork处理程序由父进程在fork创建子进程前调用,其任务是获得父进程所有锁。
- parent 此fork处理程序是在fork创建子进程以后、返回之前在父进程上下文中调用的,其任务是对prepare处理程序中获得的锁进行解锁。
- child 这个处理程序是在fork返回之前在子进程上下文中调用的。其作用也是释放prepare获得的锁。
12.10 线程和IO
3.11节介绍了pread和pwrite函数。这些函数在多线程环境下是非常有用的,因为进程中的所以线程共享相同的文件描述符。
考虑以下情况:
线程A:
lseek(fd,300,SEEK_SET);
read(fd,buf1,100);
线程B:
lseek(fd,700,SEEK_SET);
read(fd,buf2,100);
如果线程A执行lsek之后线程B在线程A调用read之前调用lseek,那么两个线程最终会读取同一条记录。这显然不是我们希望的。
位解决这个问题,可以使用pread,使偏移量的设定和读取称为一个原子操作。
pread(fd,buf,100,300);
12.11 小结
在UNIX系统中,线程提供了分解并发任务的另一种模型。
线程促进了独立控制线程之间的共享,但也出现了它特有的同步问题。
本章中:
- 我们了解了如何调整线程和它们的同步原语;
- 讨论了线程的可冲入性;
- 学习了线程如何与其他面向进程的系统调用进行交互。