APUE笔记:Threads(十一)
本文记录《UNIX环境高级编程》第3版 第11章 Threads 的一些知识点。
线程概念
典型的UNIX进程可以看成只有一个控制线程:一个进程在某一时刻只能做一件事情。有了多个控制线程以后,在程序设计时就可以把进程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任务。这种方法有很多好处。
通过为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。
多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享。而多个线程自动地可以访问相同的存储地址空间和文件描述符。
有些问题可以分解从而提高整个程序的吞吐量。
交互的程序同样可以通过使用多线程来改善响应时间,多线程可以把程序中处理用户输入输出的部分与其他部分分开。
每个线程都包含有表示执行环境所必需的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的正文段、程序的全局内存和堆内存、栈以及文件描述符。
线程标识
就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进程 ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。
回忆一下进程ID,它是用 pid_t
数据类型来表示的,是一个非负整数。线程ID是用 pthread_t
数据类型来表示的,实现的时候可以用一个结构来代表pthread_t
数据类型,所以可移植的操作系统实现不能把它作为整数处理。因此必须使用一个函数来对两个线程ID进行比较。
1 |
|
Linux 3.2.0使用无符号长整型表示
pthread_t 数据类型。Solaris 10把 pthread_t
数据类型表示为无符号整型。FreeBSD 8.0和Mac OS X 10.6.8用一个指向
pthread 结构的指针来表示 pthread_t
数据类型。
线程可以通过调用 pthread_self
函数获得自身的线程ID。
1 |
|
线程创建
新增的线程可以通过调用 pthread_create 函数创建。
1 |
|
当 pthread_create
成功返回时,新创建线程的线程ID会被设置成 tidp
指向的内存单元。attr
参数用于定制各种不同的线程属性。置为NULL,创建一个具有默认属性的线程。
新创建的线程从 start_rtn
函数的地址开始运行,该函数只有一个无类型指针参数
arg。如果需要向 start_rtn
函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为
arg 参数传入。
线程创建时并不能保证哪个线程会先运行:是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
线程终止
如果进程中的任意线程调用了 exit、_Exit
或者_exit,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。
单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流。
(1)线程可以简单地从启动例程中返回,返回值是线程的退出码。
(2)线程可以被同一进程中的其他线程取消。
(3)线程调用 pthread_exit。
1 |
|
rval_ptr
参数是一个无类型指针,与传给启动例程的单个参数类似。
进程中的其他线程也可以通过调用 pthread_join
函数访问到这个指针。
1 |
|
调用线程将一直阻塞,直到指定的线程调用
pthread_exit、从启动例程中返回或者被取消。如果线程简单地从它的启动例程返回,rval_ptr
就包含返回码。如果线程被取消,由 rval_ptr
指定的内存单元就设置为 PTHREAD_CANCELED。
如果对线程的返回值并不感兴趣,那么可以把 rval_ptr 设置为
NULL。在这种情况下,调用 pthread_join
函数可以等待指定的线程终止,但并不获取线程的终止状态。
线程可以通过调用 pthread_cancel
函数来请求取消同一进程中的其他线程。
1 |
|
在默认情况下,pthread_cancel 函数会使得由
tid 标识的线程的行为表现为如同调用了参数为
PTHREAD_ CANCELED 的 pthread_exit
函数,但是,线程可以选择忽略取消或者控制如何被取消。注意
pthread_cancel 并不等待线程终止,它仅仅提出请求。
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用
atexit
函数安排退出是类似的。这样的函数称为线程清理处理程序(thread cleanup
handler)。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反。
1 |
|
pthread_cleanup_push 函数会安排清理函数 rtn
在线程执行以下操作之一时,以单个参数 arg 被调用:
调用
pthread_exit时;响应取消请求时;
用非零
execute参数调用pthread_cleanup_pop时。
如果 execute 参数设置为
0,清理函数将不被调用。不管发生上述哪种情况,pthread_cleanup_pop
都将删除上次 pthread_cleanup_push
调用建立的清理处理程序。
这些函数有一个限制,由于它们可以实现为宏,所以必须在与线程相同的作用域中以匹配对的形式使用。pthread_cleanup_push
的宏定义可以包含字符{这种情况下,在
pthread_cleanup_pop
的定义中要有对应的匹配字符}。
下图总结了线程函数和进程函数之间的相似的函数。
| Process primitive | Thread primitive | Description |
|---|---|---|
fork |
pthread_create |
create a new flow of control |
exit |
pthread_exit |
exit from an existing flow of control |
waitpid |
pthread_join |
get exit status from flow of control |
atexit |
pthread_cleanup_push |
register function to be called at exit from flow of control |
getpid |
pthread_self |
get ID for flow of control |
abort |
pthread_cancel |
request abnormal termination of flow of control |
在默认情况下,线程的终止状态会保存直到对该线程调用
pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程被分离后,我们不能用
pthread_join 函数等待它的终止状态,因为对分离状态的线程调用
pthread_join
会产生未定义行为。可以调用pthread_detach 分离线程。
1 |
|
线程同步
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。
互斥量
可以通过使用pthreads互斥接口来保护数据,并确保每次只有一个线程能够访问数据。互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
互斥变量是用 pthread_mutex_t
数据类型表示的。在使用互斥变量前,必须首先对它进行初始化,可以把它设置为常量
PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量),也可以通过调用
pthread_mutex_init
函数进行初始化。如果动态分配互斥量(例如,通过调用malloc函数),在释放内存前需要调用
pthread_mutex_destroy。
1 |
|
要用默认的属性初始化互斥量,只需把 attr 设为
NULL。
对互斥量进行加锁,需要调用
pthread_mutex_lock。如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用
pthread_mutex_unlock。
1 |
|
如果线程不希望被阻塞,它可以使用 pthread_mutex_trylock
尝试对互斥量进行加锁。如果调用 pthread_mutex_trylock
时互斥量处于未锁住状态,那么 pthread_mutex_trylock
将锁住互斥量,不会出现阻塞直接返回0,否则
pthread_mutex_trylock 就会失败,不能锁住互斥量,返回
EBUSY。
避免死锁
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,但是使用互斥量时,还有其他不太明显的方式也能产生死锁。例如,程序中使用一个以上的互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。
可以通过仔细控制互斥量加锁的顺序来避免死锁的发生。例如,假设需要对两个互斥量A和B同时加锁。如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量就不会产生死锁(当然在其他的资源上仍可能出现死锁)。类似地,如果所有的线程总是在锁住互斥量A之前锁住互斥量B,那么也不会发生死锁。可能出现的死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。
有时候,应用程序的结构使得对互斥量进行排序是很困难的。如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。在这种情况下,可以先释放占有的锁,然后过一段时间再试。这种情况可以使用
pthread_mutex_trylock
接口避免死锁。如果已经占有某些锁而且pthread_mutex_trylock
接口返回成功,那么就可以前进。但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间再重新试。
多线程的软件设计涉及这两者之间的折中。如果锁的粒度太粗,就会出现很多线程阻塞等待相同的锁,这可能并不能改善并发性。如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,而且代码变得复杂。作为一个程序员,需要在满足锁需求的情况下,在代码复杂性和性能之间找到正确的平衡。
函数pthread_mutex_timedlock
当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock
互斥量原语允许绑定线程阻塞时间。pthread_mutex_timedlock
函数与 pthread_mutex_lock
是基本等价的,但是在达到超时时间值时,pthread_mutex_timedlock
不会对互斥量进行加锁,而是返回错误码 ETIMEDOUT。
1 |
|
超时指定愿意等待的绝对时间。这个超时时间是用 timespec
结构来表示的,它用秒和纳秒来描述时间。
读写锁
读写锁(reader-writer lock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。
读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为一次只有一个线程可以在写模式下拥有这个锁。当读写锁在读模式下时,只要线程先获取了读模式下的读写锁,该锁所保护的数据结构就可以被多个获得读模式锁的线程读取。
读写锁也叫做共享互斥锁(shared-exclusive lock)。当读写锁是读模式锁住时,就可以说成是以共享模式锁住的。当它是写模式锁住的时候,就可以说成是以互斥模式锁住的。
与互斥量一样,读写锁在使用之前必须初始化,在释放它们底层的内存之前必须销毁。
1 |
|
Single UNIX Specification在XSI扩展中定义了
PTHREAD_RWLOCK_INITIALIZER
常量。如果默认属性就足够的话,可以用它对静态分配的读写锁进行初始化。
在释放读写锁占用的内存之前,需要调用
pthread_rwlock_destroy 做清理工作。如果
pthread_rwlock_init
为读写锁分配了资源,pthread_rwlock_destroy
将释放这些资源。如果在调用 pthread_rwlock_destroy
之前就释放了读写锁占用的内存,那么分配给这个锁的资源就会丢失。
1 |
|
Single UNIX Specification还定义了读写锁原语的条件版本。
1 |
|
可以获取锁时,这两个函数返回0。否则,它们返回错误
EBUSY。
带有超时的读写锁
与互斥量一样,Single UNIX Specification提供了带有超时的读写锁加锁函数,使应用程序在获取读写锁时避免陷入永久阻塞状态。
1 |
|
这两个函数的行为与它们“不计时的”版本类似。tsptr 参数指向
timespec
结构,指定线程应该停止阻塞的时间。如果它们不能获取锁,那么超时到期时,这两个函数将返回
ETIMEDOUT 错误。与 pthread_mutex_timedlock
函数类似,超时指定的是绝对时间,而不是相对时间。
条件变量
条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
条件本身受互斥锁保护。线程必须先锁定互斥锁才能更改条件状态。其他线程在获取互斥锁之前不会察觉到这种变化,因为必须锁定互斥锁才能评估该条件。
在使用条件变量之前,必须先对它进行初始化。由
pthread_cond_t
数据类型表示的条件变量可以用两种方式进行初始化,可以把常量
PTHREAD_COND_INITIALIZER
赋给静态分配的条件变量,但是如果条件变量是动态分配的,则需要使用
pthread_cond_init 函数对它进行初始化。
在释放条件变量底层的内存空间之前,可以使用
pthread_cond_destroy
函数对条件变量进行反初始化(deinitialize)。
1 |
|
使用 pthread_cond_wait
来等待某个条件变为真。还提供了一个变体,如果在指定时间内该条件未得到满足,它会返回一个错误代码。
1 |
|
传递给 pthread_cond_wait
的互斥量对条件进行保护。调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait
返回时,互斥量再次被锁住。
pthread_cond_timedwait 函数的功能与
pthread_cond_wait
函数相似,只是多了一个超时。超时值指定了我们愿意等待多长时间。这个时间值是一个绝对数而不是相对数。例如,假设愿意等待3分钟。那么,并不是把3分钟转换成
timespec 结构,而是需要把当前时间加上3分钟再转换成
timespec 结构。
可以使用 clock_gettime 函数获取 timespec
结构表示的当前时间。但是目前并不是所有的平台都支持这个函数,因此,也可以用另一个函数
gettimeofday 获取 timeval
结构表示的当前时间,然后把这个时间转换成 timespec
结构。要得到超时值的绝对时间,可以使用下面的函数:
1 |
|
如果超时到期时条件还是没有出现,pthread_cond_timewait
将重新获取互斥量,然后返回错误 ETIMEDOUT。从
pthread_cond_wait 或者 pthread_cond_timedwait
调用成功返回时,线程需要重新计算条件,因为另一个线程可能已经在运行并改变了条件。
有两个函数可以用于通知线程条件已经满足。pthread_cond_signal
函数至少能唤醒一个等待该条件的线程,而
pthread_cond_broadcast
函数则能唤醒等待该条件的所有线程。
1 |
|
在调用 pthread_cond_signal 或者
pthread_cond_broadcast
时,我们说这是在给线程或者条件发信号。必须注意,一定要在改变条件状态以后再给线程发信号。
自旋锁
自旋锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。
自旋锁通常作为底层原语用于实现其他类型的锁。
自旋锁的接口与互斥量的接口类似,这使得它可以比较容易地从一个替换为另一个。
1 |
|
pshared
参数表示进程共享属性,表明自旋锁是如何获取的。如果将其设置为
PTHREAD_PROCESS_SHARED,那么只要线程能够访问该自旋锁的底层内存,即使这些线程来自不同的进程,也可以获取该自旋锁。否则,pshared
参数会被设置为
PTHREAD_PROCESS_PRIVATE,此时该自旋锁只能由初始化它的进程内的线程访问。
可以用 pthread_spin_lock 或
pthread_spin_trylock
对自旋锁进行加锁,前者在获取锁之前一直自旋,后者如果不能获取锁,就立即返回
EBUSY 错误。注意, pthread_spin_trylock
不能自旋。不管以何种方式加锁,自旋锁都可以调用
pthread_spin_unlock 函数解锁。
1 |
|
注意,如果自旋锁当前在解锁状态的话,pthread_spin_lock
函数不要自旋就可以对它加锁。如果线程已经对它加锁了,结果就是未定义的。调用pthread_spin_lock
会返回 EDEADLK
错误(或其他错误),或者调用可能会永久自旋。具体行为依赖于实际的实现。试图对没有加锁的自旋锁进行解锁,结果也是未定义的。
不管是 pthread_spin_lock还是
pthread_spin_trylock,返回值为0的话就表示自旋锁被加锁。需要注意,不要调用在持有自旋锁情况下可能会进入休眠状态的函数。如果调用了这些函数,会浪费CPU资源,因为其他线程需要获取自旋锁需要等待的时间就延长了。
屏障
屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。我们已经见过一种形式的屏障——
pthread_join
函数就起到了屏障的作用,它能让一个线程等待另一个线程退出。
但是屏障对象的概念更广,它们允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以接着工作。
可以使用 pthread_barrier_init 函数对屏障进行初始化,用
thread_barrier_destroy 函数反初始化。
1 |
|
初始化屏障时,可以使用 count
参数指定,在允许所有线程继续运行之前,必须到达屏障的线程数目。使用
attr 参数指定屏障对象的属性。
可以使用 pthread_barrier_wait
函数来表明,线程已完成工作,准备等所有其他线程赶上来。
1 |
|
调用 pthread_barrier_wait 的线程在屏障计数(调用
pthread_barrier_init
时设定)未满足条件时,会进入休眠状态。如果该线程是最后一个调用
pthread_barrier_wait
的线程,就满足了屏障计数,所有的线程都被唤醒。
pthread_barrier_wait() 会随机选择一个线程返回
PTHREAD_BARRIER_SERIAL_THREAD,其余线程返回
0,这样可以让一个线程作为 master 在所有线程同步后执行必要的串行操作
一旦达到屏障计数值,而且线程处于非阻塞状态,屏障就可以被重用。但是除非在调用了
pthread_barrier_destroy 函数之后,又调用了
pthread_barrier_init
函数对计数用另外的数进行初始化,否则屏障计数不会改变。