11 线程
11.1 简介
线程:只要讨论如何在单进程环境中执行多任务(即使用多个控制线程),以及单个资源在多个用户间共享的一致性问题,就涉及到多线程在共享资源时的同步机制。
11.2 线程概念
典型的UNIX进程可以看成只有一个控制线程:一个进程在某一时刻只能做一件事情。有了多个控制线程以后,在程序设计时就可以把进程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任务。其好处如下:
1) 通过为每件事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进程事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。
2) 多个进程必须使用操作系统提供的复制机制才能实现内存和文件描述符的共享,而多个线程自动地可以访问相同的存储地址空间和文件描述符。
3) 有些问题可以分解从而提高整个程序的吞吐量。在只有一个控制线程的情况下,一个单线程进程要完成多个任务,只需要把这些任务串行化。但有多个控制线程时,相互独立的任务的处理就可以交叉进行,此时只需要为每个任务分配一个单独的线程。当然只有在两个任务的处理过程互不依赖的情况下,两个任务才可以交叉执行。
4) 交互的程序同样可以通过使用多线程来改善响应时间,多线程可以把程序中处理用户的输入输出的部分与其他部分分开。
有些人把多线程的程序设计与多处理器或多核系统联系起来。但是即使程序运行在单处理器上,也能得到多线程编程模型的好处。处理器的数量并不影响程序结构,所以不管处理器的个数多少,程序都可以通过使用线程得以简化。而且,使用多线程程序在串行化任务时不得不阻塞,由于某些线程在阻塞的时候还有另一些线程可以运行,所以多线程程序在单处理器上运行还是可以改善响应时间和吞吐量。
每个线程都包含有表示执行环境所必须的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程的是共享的,包括执行程序的代码,程序的全局内存和堆内存、栈以及文件描述符。
11.3 线程标识
就像每个进程都由一个进程ID一样,每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。在程序调试过程中,打印线程ID是非常有用的,其他情况下通常不需要打印线程ID。
11.4 线程创建
线程创建时并不能保证哪个线程会先运行:是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
11.5 线程终止
如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么发送到线程的信号就会终止整个进程。
单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流。
1) 线程可以简单地从启动例程中返回,返回值是线程的退出码
2) 线程可以被同一个进程中的其他线程取消
3) 线程调用pthread_exit
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit函数安排退出时类似的。这样的函数称为线程清理处理程序。一个线程可以建立多个清理处理程序。处理程序记录在栈中,即它们的执行顺序与它们注册时相反。
线程函数与进程函数之间有很多的相似性,其具体如下:
进程原语 |
线程原语 |
描述 |
fork |
pthread_create |
创建新的控制流 |
exit |
pthread_exit |
从现有的控制流中退出 |
waitpid |
pthread_join |
从控制流中得到退出状态 |
atexit |
pthread_cancel_push |
注册在退出控制流时调用的函数 |
getpid |
pthread_selef |
获取控制流的ID |
abort |
pthread_cancel |
请求控制流的非正常退出 |
11.6 线程同步
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程使用的变量都是其他线程不会读取和修改的,那么就不存在一致性问题。同样,如果变量是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步,确保他们在访问变量的存储内容时不会访问到无效的值。
当一个线程修改变量时,其他线程在读取这个变量时可能会看到一个不一致的值。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。当然,这种行为是与处理器体系结构相关的,但是可移植的程序并不能对使用何种处理器体系结构做出任何假设。
为解决这种不一致问题,线程可以使用锁来保证同一时间只允许一个线程访问该变量。
两个或多个线程试图在同一时间修改同一变量时,也需要进行同步。考虑变量增量操作的情况,增量操作通常分解为以下3步:
1)从内存单元读入寄存器
2)在寄存器中对变量做增量操作
3)把新的值写回内存单元
如果两个线程试图几乎在同一时间对同一变量做增量操作而不进行同步的话,结果就可能出现不一致,变量可能比原来增加1,也可能比原来增加2,具体增加1还是增加2要取决于第二个线程开始操作时获取的数值。如果第二个线程执行第1步要比第一个线程执行第3步要早,第二线程读到的值与第一线程一样,为变量加1,然后写回,事实上没有实际的效果,总的说,变量只增加了1。
如果修改操作的是原子操作,就不存在竞争。如果数据总是以顺序一致出现的,就不需要额外的同步。当多个线程观察不到数据的不一致时,那么操作就是顺序一致的。在现代计算机系统中,存储访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以不能保证数据是顺序一致的。除了计算机体系结构以外,程序使用变量的方式也会引起竞争,也会导致不一致的情况发生。
互斥量
可以使用pthread的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上说就是一把锁,在访问共享资源前,对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。对互斥量进行加锁以后,任何其它试图再次对互斥量加锁的线程都会阻塞直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前直行。
只有将所有线程都设计成遵守相同的数据访问规则,互斥机制才能正常工作。操作系统并不会为我们做数据访问的串行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。
避免死锁
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,但是使用互斥量时,还有其他不太明显的方式也可能产生死锁。如程序中使用一个以上的互斥量时,如果允许一个线程一直占有第一个互斥量,并且试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量。因为两个线程都在相互请求另一个线程拥有的资源,所以这个线程都无法向前运行,于是就产生死锁。可以通过仔细控制互斥量加锁的顺序来避免死锁的发生。可能出现的死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。
在同时需要两个互斥量时,总是让它们以相同的顺序加锁,这样可以避免死锁。
多线程的软件设计涉及这两者之间的折衷。如果锁的粒度太粗,就会出现很多线程阻塞等待相同的锁,这可能并不能改善并发性。如果锁的粒度太细,那么过多的锁开销会使系统性能受到影响,而且代码变得复杂。故需在满足锁的要求下,在代码复杂性和性能之间找到正确的平衡。
读写锁
读写锁(read-writer-lock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。虽然各操作系统对读写锁实现各不相同,但当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。
读写锁非常适合于数据结构读的次数远大于写的情况。当读写锁在写模式下,它所保护的数据结构就可以安全地修改,因为一次只有一个线程可以在写模式下拥有这个锁。在读写锁的读模式下,只要线程先获取了读模式下的读写锁,该锁锁保护的数据结构就可以被多个获得读写锁的线程读取。
读写锁也叫共享互斥锁(shared-exclusivelock)。当读写锁是读模式锁住时,就可以说成是以共享模式锁住。当它是写模式锁住的时候,就可以说成是互斥模式锁住。
条件变量
条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量和互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
条件本身有互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。
自旋锁
自选锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。
自旋锁通常作为底层原语用于实现其他类型的锁。根据它们所基于的系统结构,可以通过使用测试并设置指令有效地实现。当然这里说的有效也还是会导致CPU资源的浪费:当线程自旋等待锁变为可用时,CPU不能做其他的事情。这也是自旋锁只能被持有一小段时间的原因。
当自旋锁用在非抢占式内核时非常有用:除了提供互斥机制外,它们会阻塞中断,这样中断处理程序就不会让系统陷入死锁状态,因为它需要获取已被加锁的自旋锁(把中断想成是另一种抢占)。在这种类型的内核中,中断处理程序不能休眠,因此它们能用的同步原语只能是自旋锁。
但是在用户层,自旋锁并不是非常有用,除非运行在不允许抢占的实时调度类中。运行在分时调度类中的用户层线程在两种情况下可以被取消调度:当他们的时间片到期时,或者具有更高调度优先级的线程就绪变成可运行时。在这些情况下,如果线程拥有自旋锁,它就会进入休眠状态,阻塞在锁上的其他线程自旋的时间可能会比预期的时间更长。
很多互斥量的实现非常高效,以至于应用程序采用互斥锁的性能与曾经采用过自旋锁的性能基本是相同的。实时上,有些互斥量的实现在试图获取互斥量的时候会自旋一小段时间,只有在自旋计数达到某一阈值的时候才会休眠。这些因素,加上现代处理器的进步,使得上下文切换越来越快,也使得自旋锁只能在某些特定的情况下使用。
屏障
屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。目前看到的一种屏障:pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出。
但是屏障对象的概念更广,他们允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以接着工作。
参看文献:Unix环境高级编程
注:本博客为本人学习线程相关知识的总结。