《UNIX环境高级编程》第11章 线程 笔记
线程概念
在程序设计时就可以把进程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任务。这种方法有很多好处。
- 通过为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。
- 多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享,而多个线程自动地可以访问相同的存储地址空间和文件描述符
- 有些问题可以分解从而提高整个程序的吞吐量。在只有一个控制线程的情况下,一个单线程进程要完成多个任务,只需要把这些任务串行化。但有多个控制线程时,相互独立的任务的处理就可以交叉进行,此时只需要为每个任务分配一个单独的线程。
- 交互的程序同样可以通过使用多线程来改善响应时间,多线程可以把程序中处理用户输入输出的部分与其他部分分开。
处理器的数量并不影响程序结构,所以不管处理器的个数多少,程序都可以通过使用线程得以简化。
每个线程都包含有表示执行环境所必需的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、 errno变量以及线程私有数据
一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符
线程标识
就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。
线程ID是用pthread_t数据类型来表示的,实现的时候可以用一个结构来代表pthread_t数据类型,所以可移植的操作系统实现不能把它作为整数处理。因此必须使用一个函数来对两个线程ID进行比较:
1 |
|
线程可以通过调用 pthread_self函数获得自身的线程ID
1 |
|
当线程需要识别以线程ID作为标识的数据结构时, pthread_self函数可以与pthread_equal函数一起使用。例如,主线程可能把工作任务放在一个队列中,用线程ID来控制每个工作线程处理哪些作业。如图所示,主线程把新的作业放到一个工作队列中,由3个工作线程组成的线程池从队列中移出作业。主线程不允许每个线程任意处理从队列顶端取出的作业,而是由主线程控制作业的分配,主线程会在每个待处理作业的结构中放置处理该作业的线程ID,每个工作线程只能移出标有自己线程ID的作业。
线程创建
新增的线程可以通过调用 pthread_create函数创建。
1 |
|
当pthread_create成功返回时,新创建线程的线程ID会被设置成tidp
指向的内存单元,attr
参数用于定制各种不同的线程属性。新创建的线程从start_rtn
函数的地址开始运行,该函数只有一个无类型指针参数arg
。如果需要向start_run
函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg
参数传入
线程创建时并不能保证哪个线程会先运行:是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
注意, pthread函数在调用失败时通常会返回错误码,它们并不像其他的POSIX函数一样设置 errno。每个线程都提供 errno的副本,这只是为了与使用 errno的现有函数兼容。
在使用pthread_create函数的时候有一些问题是需要注意的:
- 在创建多线程程序的时候,有的时候主线程需要休眠,如果主线程不休眠,它就可能会退出,这样新线程还没有机会运行,整个进程可能就已经终止了。这种行为特征依赖于操作系统中的线程实现和调度算法
- 第二个特别之处在于新线程是通过调用 pthread_se1f函数获取自己的线程ID的,而不是从共享内存中读出的,或者从线程的启动例程中以参数的形式接收到。我们知道pthread_create会通过第一个参数(tidp)返回新建线程的线程ID。但是新建的线程并不能安全地使用它,如果新线程在主线程调用pthread_create返回之前就运行了,那么新线程看到的是未经初始化的ntid的内容,这个内容并不是正确的线程
线程终止
如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止
单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流。
- 线程可以简单地从启动例程中返回,返回值是线程的退出码。
- 线程可以被同一进程中的其他线程取消。
- 线程调用 pthread_ex1t
1 |
|
rval_ptr
参数是一个无类型指针,与传给启动例程的单个参数类似。进程中的其他线程也可以通过调用 pthread_join
函数访问到这个指针。
1 |
|
调用线程将一直阻塞,直到指定的线程调用pthread_exit
,从启动例程中返回或者被取消。如果线程简单地从它的启动例程返回, rval_ptr
就包含返回码。如果线程被取消,由rval_pir
指定的内存单元就设置为PTHREAD_CANCELED
可以通过调用pthread_join
自动把线程置于分离状态(马上就会讨论到),这样资源就可以恢复。如果线程已经处于分离状态, pthread_join
调用就会失败,返回EINVAL
,尽管这种行为是与具体实现相关的。
如果对线程的返回值并不感兴趣,那么可以把 rval_ptr设置为NULL。在这种情况下,调用pthread_join函数可以等待指定的线程终止,但并不获取线程的终止状态。
pthread_create和 pthread_exit函数的无类型指针参数可以传递的值不止一个,这个指针可以传递包含复杂信息的结构的地址,但是注意,这个结构所使用的内存在调用者完成调用以后必须仍然是有效的。
为了解决这个问题,可以使用全局结构,或者用mal1oc函数分配结构
线程可以通过调用pthread_cancel
函数来请求取消同一进程中的其他线程
1 |
|
在默认情况下, pthread_cancel函数会使得由tid标识的线程的行为表现为如同调用了参数为 PTHREAD_CANCELED的 pthread_exit函数,但是,线程可以选择忽略取消或者控制如何被取消。注意 pthread_cancel并不等待线程终止,它仅仅提出请求
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit
函数安排退出是类似的。这样的函数称为线程清理处理程序( thread cleanup handler)。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反
1 |
|
需要注意的是:如果线程是通过从它的启动例程中返回而终止的话,它的清理处理程序就不会被调用。还要注意,清理处理程序是按照与它们安装时相反的顺序被调用的
进程原语和线程原语的比较:
线程的分离和结合
在任何一个时间点上,线程是可结合的(joinable),或者是分离的(detached)。一个可结合的线程能够被其他线程收回其资源和杀死;在被其他线程回收之前,它的存储器资源(如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放
在默认情况下,线程的终止状态会保存直到对该线程调用 pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程被分离后,我们不能用pthread_join函数等待它的终止状态,因为对分离状态的线程调用 pthread_join会产生未定义行为。可以调用 pthread_detach分离线程。
1 |
|
线程同步
当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步,确保它们在访问变量的存储内容时不会访问到无效的值。
当一个线程修改变量时,其他线程在读取这个变量时可能会看到一个不一致的值。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。
为了解决这个问题,线程不得不使用锁,同一时间只允许一个线程访问该变量
两个或多个线程试图在同一时间修改同一变量时,也需要进行同步
如果修改操作是原子操作,那么就不存在竞争。当多个线程观察不到数据的不一致时,那么操作就是顺序一致的。在现代计算机系统中,存储访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以我们并不能保证数据是顺序一致的。
互斥量
可以使用 pthread的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。
互斥变量是用pthread_mutex_t
数据类型表示的,使用互斥量之前必须要初始化,动态分配的互斥量在释放内存之前要调用pthread_mutex_destory
1 |
|
要用默认的属性初始化互斥量,只需把attr设为NULL。
对互斥量进行加锁,需要调用pthread_mutex_lock
。如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用pthread_mutex_un1ock
。
如果线程不希望被阻塞,它可以使用pthread_mutex_try1ock
尝试对互斥量进行加锁。如果调用 pthread_mutex_try1ock
时互斥量处于未锁住状态,那么pthread_mutex_try1ock
将锁住互斥量,不会出现阻塞直接返回0,否则pthread_mutex_try1ock
就会失败,不能锁住互斥量,返回 EBUSY。
1 |
|
避免死锁
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,可以通过仔细控制互斥量加锁的顺序来避免死锁的发生。例如,假设需要对两个互斥量A和B同时加锁。如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量就不会产生死锁(当然在其他的资源上仍可能出现死锁)。
上面讲的是采用顺序加锁的形式避免死锁的方式。
有时候,应用程序的结构使得对互斥量进行排序是很困难的。如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。在这种情况下,可以先释放占有的锁,然后过一段时间再试。这种情况可以使用 pthread_mutex_trylock接口避免死锁。如果已经占有某些锁而且 pthread_mutex_try1ock接口返回成功,那么就可以前进。但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间再重新试。
函数pthread_mutex_timelock
当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock
互斥量原语允许绑定线程阻塞时间。 pthread_mutex_timedlock
函数与pthread_mutex_timedlock
是基本等价的,但是在达到超时时间值时,pthread_mutex_timedlock
不会对互斥量进行加锁,而是返回错误码 ETIME_DOUT。
1 |
|
读写锁
读写锁( reader-writer lock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁
读写锁非常适合于对数据结构读的次数远大于写的情况。读写锁也叫做共享互斥锁( shared-exclusive lock)。
初始化和销毁读写锁的函数和互斥量类似1
2
3
4
5
6
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
// Both return: 0 if OK, error number on failure
要在读模式下锁定读写锁,需要调用pthread_rwlock_rdlock
。要在写模式下锁定读写锁,需要调用 pthread_rwlock_wrlock
。不管以何种方式锁住读写锁,都可以调用pthread_rw1ock_unlock
进行解锁
1 |
|
读写锁原语的条件版本:
1 |
|
带有超时的读写锁
与互斥量一样,读写锁也有带有超时条件的加锁函数:
1 |
|
条件变量
条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
条件本身是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。
条件变量使用的数据结构pthread_cond_t
,同理也有初始化和销毁的函数:
1 |
|
我们使用pthread_cond_wait
等待条件变量变为真。如果在给定的时间内条件不能满足那么会生成一个返回错误码的变量
1 |
|
传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。 pthread_cond_wait返回时,互斥量再次被锁住。
有两个函数可以用于通知线程条件已经满足。pthread_cond_signal
函数至少能唤醒一个等待该条件的线程,而pthread_cond_broadcast
函数则能唤醒等待该条件的所有线程
1 |
|
自旋锁
自旋锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本
自旋锁通常作为底层原语用于实现其他类型的锁。
当自旋锁用在非抢占式内核中时是非常有用的:除了提供互斥机制以外,它们会阻塞中断这样中断处理程序就不会让系统陷入死锁状态,因为它需要获取已被加锁的自旋锁(把中断想成是另一种抢占)。在这种类型的内核中,中断处理程序不能休眠,因此它们能用的同步原语只能是自旋锁。
初始化和销毁函数如下:
1 |
|
加锁解锁函数:
1 |
|
屏障
屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。我们已经看到一种屏障,pthread_jo1n
函数就是一种屏障,允许一个线程等待,直到另一个线程退出。
但是屏障对象的概念更广,它们允许任意数量的线程等待,直到所有的线程完成处理工作而线程不需要退出。所有线程达到屏障后可以接着工作。
初始化和销毁函数:
1 |
|
初始化屏障时,可以使用count
参数指定在允许所有线程继续运行之前,必须到达屏障的线程数目。使用attr
参数指定屏障对象的属性,设置atmr为NULL,用默认属性初始化屏障。
可以使用pthread_barrier_wait
函数来表明,线程已完成工作,准备等所有其他线程赶上来。
1 |
|
一旦达到屏障计数值,而且线程处于非阻塞状态,屏障就可以被重用。