linux多线程自己从接触很久也有不少实践,但总是觉得理解不够深刻,不够系统。借这篇文章试着再次系统学习一下linux多线程编程,理解编程的concept,细致看一下POSIX pthread API的实现。还是凭借强大的google search,找到几篇不错的文章和教程附在最后。我在这篇文章中的总结大多都是基于这些材料的学习和自己实践经验的一点总结。
Thread附着在process内部,先看一下process在CPU上是个什么样的吧。启动一个linux process,OS会开辟一块内存用来装载code,保存data和process状态。看一下进程的地址空间。
根据访问权限,进程地址空间分为user space和kernel space。32bit系统中高1G为kernel space,低3G为user space,具体划分为:
Process control block(从高1G kernel space中分配)
stack
memory mapping segment
heap
bss and data
text
1G的kernel space是这台机器上所有processes共享的,每个进程的PCB存在这个空间中,一般应用程序是没有办法直接访问修改的,但是kernel 通过/proc 提供给应用程序一个接口可以查看PCB的信息,部分内容还可以修改,详细可以看一下/proc。剩下的stack/heap/text/...都驻留在process user space,是属于process私有空间。详细的kernel如何管理进程memory还可以再开一篇。
process是个重型的运行实体,以process为单位切分任务和调度,os的开销太大了。我们可以把process这个单位再切小些,thread的概念就诞生了。好,我们来看一下怎样把这个单位切小的。简单来讲,thread共享大部分的process的内容,只维护必需的一小部分作为私有内容。
Thread自己维护的私有内容
Kernel space
Stack pointer
Registers
Scheduling properties (such as policy or priority)
Set of pending and blocked signals
Thread specific data.
User space
stack
其他诸如PCB中进程信息,用户空间中的text/data/heap/...都是同一个process下所有Threads共享的。有了这些thread自己私有的信息,os就可以以thread为单位去调度了。因为它是轻量级的,所以相比process,thread一般具有更好的性能,更快的响应速度。但是thread的稳定性和编程复杂度要比process差些,要考虑的内容比较多。
正因为同一个process内的threads间天然共享了大量的内存,thread间的信息交互要比较高效,同时也增加了复杂度,总要处理好共享内存间的互斥。当然process间也可以共享内存,比如通过进程父子关系,或者通过/dev/shm mmap特定物理内存到进程空间内或者其他。
所有的IPC(inter process communication)方法都适用于thread间的通信。比较全的IPC总结,可以参考IPC。比较常用的我们会涉及到message queue,sharememory,semaphore,socket,signal等。semaphore是共享资源互斥的方法,其他都是冗余的方式进行通信。互斥是个比较复杂的话题,我们单开一节讨论一下。
为什么要保护共享资源做互斥访问,这里不罗嗦了。通过对共享资源(临界区)加锁可以实现互斥访问,互斥锁(mutex)也有多种类型。
simple blocking
一方拿到临界区锁后,其它人再来拿锁都会挂起。
Recursive(递归型)
允许锁的拥有者多次申请锁而不被挂起,对递归调用有用。
Reader/Writer
允许多个reader同时share读锁,如果有reader在读,writer申请锁会block直到所有reader释放。可以理解为一写多读,写时互斥。这种锁有写饿死的风险。
其中POSIX的pthread库支持recursive和reader/writer类型的锁。
共享访问中有写操作,必然要考虑互斥。互斥有风险,使用需谨慎。如果你最终不可避免的要使用互斥锁,要关注互斥锁的这些风险。
deadlock(死锁)
死锁一般发生在双方或者多方在申请两个以上的互斥锁,然后大家各拿了部分,互不相让。开发者要尽量避免这种编程场景发生,如果真的需要可以编程要么同时获得,要么一个都不要,做人要有骨气!
race condition(竞争条件)
共享资源在没有互斥机制保护时,由于线程调度的不确定性会导致共享的资源变化无序无规律,程序的输出也就不确定了。共享资源无互斥保护,线程间竞争访问,输出无法保证。这要求开发者要特别小心识别出程序中的那些共享资源,加锁保护。尤其是第三方的开源软件,多线程调用时要注意是否是线程安全的。
priority reversion(优先级反转)
优先级反转是个很有意思的问题,尤其是在嵌入式实时OS上,进程/线程的调度是抢占式的,高优先级的任务ready时可以直接抢占CPU,这事再加上互斥就容易出问题了。比如三个任务H,M,L,优先级递减,同时H和L共享资源R。当L先申请到互斥锁访问临界区还没释放R的时候,H这时候申请R访问导致自己挂起,这么巧M变ready了,OS调度让M抢占了L的cpu。如果L一直得不到执行并释放R,这样就造成了高优先级的H得不到执行,反而一些比H优先级低的M们能得到CPU。这就是优先级反转。实时OS的高优先级任务一般都是比较重要的任务需要马上处理,得不到处理意味着可能要出大事。所以这个问题的影响还是挺大的,比较著名的例子就是火星探路者的故事,可以参考一下火星探路者故障分析。解决方法也有不少
尽量避免不同优先级的任务共享资源,可以通过信息容易做任务间通信。
访问临界区时关闭中断,保证临界区的代码执行不被强占。嵌入式编程中常用。
优先级继承,当有高优先级任务想要访问共享资源时,提高正在执行的低优先级任务的优先级到高优先级级别直至退出临界区。上面的探路者修正程序使用了该方法。
随机提高ready且持有锁的任务优先级,windows用了该方法。
写了这么多,那到底什么时候可以应用多线程来解决问题呢?根据经验,一般下面一些场景我们可以考虑使用多线程。
多核处理器,任务比较容易切分为并行处理的小块。如果是计算密集型的,线程数量可以考虑跟core的数量相当。
有delay比较多的IO操作,可以考虑将IO的操作分离给单独的线程。
有人机交互和实时响应等实时性要求较高任务,可以考虑分离为优先级较高的线程。
有大量实时要求不高的计算,可以考虑分离为优先级较低的后台任务。
实事求是,具体问题具体分析是放之四海而皆准的问题解决之道,所以没有普适的编程模型。下面列举3种应用比较多的模型以供学习。
Thread Pool (Master/Worker)
通过线程池维护一组可用的线程,master作为主线程负责管理维护worker线程,同时负责对外接口和工作的分发。
Peer (Workcrew)
跟master/worker类似,只是master在启动线程池后退化为普通一员,大家一起分担任务,没有主从的星形拓扑结构。
Pipeline
跟CPU的pipline技术类似,将一个工作流分成很多串行的部分,每一部分都由不同的线程负责,大家各司其职,我做完我的工作就转交给下一个线程,齐心协力最后完成整个工作。流水线如果拍的好可以很好的提高工作效率,但是这种模型风险也比较大,一定要处理好工作的切分,和线程间的交互。
待续。。。
参考文章
POSIX Threads Programming
Multithreaded Programming (POSIX pthreads Tutorial)
Multi-Threaded Programming With POSIX Threads
POSIX thread (pthread) libraries