深度下潜――多线程篇
作者:杨小华
计算机系统变得越来越复杂,多线程机制给我们带来了能够继续管理它们的希望。
――Andrew Koening and Barbara Moo
一、 线程的基本概念
进程(process)和文件(files)是UNIX/Linux操作系统两个最基本的抽象。进程是处于执行期的程序和它所包含的资源的总和,也就是说一个进程就是处于执行期的程序。一个线程(thread)就是运行在一个进程上下文中的一个逻辑流,不难看出,线程是进程中最基本的活动对象。
在传统的系统中,一个进程只包含一个线程。但在现代操作系统中,允许一个进程里面可以同时运行多个线程,这类程序就被称为多线程程序。所有的程序都有一个主线程(main thread),主线程是进程的控制流或执行线程,见图1。在多线程程序中,主线程可以创建一个或多个对等线程(peer thread),从这个时间点开始,这些线程就开始并发执行,见图2。主线程和对等线程的区别仅在于主线程总是进程中第一个运行的线程。从某种程度上看,线程可以看作是轻量级的进程(lightweight process)。在Linux操作系统中,内核调度的基本对象是线程,而不是进程,所以进程中的多个线程将由内核自动调度。
每个线程都拥有独立的线程上下文(thread context),线程ID(Thread ID,TID),程序计数器(pc),线程栈(stack),一组寄存器(register)和条件码。其中,内核正是通过线程ID(TID)来识别线程,进行线程调度的。
图 1多线程进程的控制流
图 2并发线程执行模型
二、 线程与进程的异同点
线程和进程在很多方面是相似的。相同点主要表现在如下几方面:
1) 比如都具有ID,一组寄存器,状态,优先级以及所要遵循的调度策略。
2) 每个进程都有一个进程控制块,线程也拥有一个线程控制块(在Linux内核,线程控制块与进程控制块用同一个结构体描述,即struct task_struct),这个控制块包含线程的一些属性信息,操作系统使用这些属性信息来描述线程。
3) 线程和子进程共享父进程中的资源。
4) 线程和子进程独立于它们的父进程,竞争使用处理器资源。
5) 线程和子进程的创建者可以在线程和子进程上实行某些控制,比如,创建者可以取消、挂起、继续和修改线程和子进程的优先级。
6) 线程和子进程可以改变其属性并创建新的资源
除了这些相同点,在很多方面也存在着差异:
1) 主要区别:每个进程都拥有自己的地址空间,但线程没有自己独立的地址空间,而是运行在一个进程里的所有线程共享该进程的整个虚拟地址空间
2) 线程的上下文切换时间开销比进程上下文切换时间开销要小的多
3) 线程的创建开销远远小于进程的创建
4) 子进程拥有自己的地址空间和数据段的拷贝,因此当子进程修改它的变量和数据时,它不会影响父进程中的数据,但线程可以直接访问它进程中的数据段
5) 进程之间通讯必须使用进程间通讯机制,但线程可以与进程中的其他线程直接通讯
6) 线程可以对同一进程中的其他线程实施大量控制,但进程只能对子进程实施控制
7) 改变主线程的属性可能影响进程中其他的线程,但对父进程的修改不影响子进程
三、 深入剖析线程
3.1 线程优缺点
线程的优点
线程的缺点
上下文切换需要更少的系统资源
并发读/写需要同步
增加了应用程序的吞吐量
很容易破坏它进程的地址空间
任务间的通讯不需要特殊机制
只存在于单个进程中,因此不能被重用
简化了程序结构
表 1线程优缺点对比表
3.1 线程属性
POSIX线程库定义了线程属性对象,它封装了线程的创建者可以访问和修改的线程属性。线程属性主要包括如下属性:
1) 作用域(scope)
2) 栈尺寸(stack size)
3) 栈地址(stack address)
4) 优先级(priority)
5) 分离的状态(detached state)
6) 调度策略和参数(scheduling policy and parameters)
线程属性对象可以与一个线程或多个线程相关联。当使用线程属性对象时,它是对线程和线程组行为的配置。使用属性对象的所有线程都将具有由属性对象所定义的所有属性。虽然它们共享属性对象,但它们维护各自独立的线程ID和寄存器。
线程可以在两种竞争域内竞争资源:
1) 进程域(process scope):与同一进程内的其他线程
2) 系统域(system scope):与系统中的所有线程
作用域属性描述特定线程将与哪些线程竞争资源。一个具有系统域的线程将与整个系统中所有具有系统域的线程按照优先级竞争处理器资源,进行调度。
分离线程是指不需要和进程中其他线程同步的线程。也就是说,没有线程会等待分离线程退出系统。因此,一旦该线程退出,它的资源(如线程ID)可以立即被重用。
线程的布局嵌入在进程的布局中。进程有代码段、数据段和栈段,而线程与进程中的其他线程共享代码段和数据段,每个线程都有自己的栈段,这个栈段在进程地址空间的栈段中进行分配。线程栈的尺寸在线程创建时设置。如果在创建时没有设置,那么系统将会指定一个默认值,缺省值的大小依赖于具体的系统。
POSIX线程属性对象中可设置的线程属性及其含义参见表二:
函数
属性
含义
int pthread_attr_setdetachstate
(pthread_attr_t* attr ,int detachstate)
detachstate
detachstate属性控制一个线程是否是可分离的
int pthread_attr_setguardsize
(pthread_attr_t* attr ,size_t guardsize)
guardsize
guardsize属性设置新创建线程栈的保护区大小
int pthread_attr_setinheritsched
(pthread_attr_t* attr, int inheritsched)
inheritsched
inheritsched决定怎样设置新创建线程的调度属性
int pthread_attr_setschedparam
(pthread_attr_t* attr ,
const struct sched_param* restrict param)
param
param用来设置新创建线程的优先级
int pthread_attr_setschedpolicy
(pthread_attr_t* attr, int policy)
policy
Policy用来设置先创建线程的调度策略
int pthread_attr_setscope
(pthread_attr_t* attr ,
int contentionscope)
contentionscope
contentionscope用于设置新创建线程的作用域
int pthread_attr_setstack
(pthread_attr_t* attr, void* stackader,
size_t stacksize)
stackader
stacksize
两者共同决定了线程栈的基地址以及堆栈的最小尺寸(以字节为单位)
int pthread_attr_setstackaddr
(pthread_attr_t* attr , void* stackader)
stackader
stackader决定了新创建线程的栈的基地址
int pthread_attr_setstacksize
(pthread_attr_t* attr, size_t stacksize)
stacksize
stacksize决定了新创建线程的栈的最小尺寸(以字节为单位)
表 2线程属性及其含义
3.2 线程调度和优先级
进程的调度策略和优先级属于主线程,换句话说就是设置进程的调度策略和优先级只会影响主线程的调度策略和优先级,而不会改变对等线程的调度策略和优先级(注:这句话不完全正确)。每个对等线程可以拥有它自己的独立于主线程的调度策略和优先级。
在Linux系统中,进程有三种调度策略:SCHED_FIFO、SCHED_RR和SCHED_OTHER,线程也不例外,也具有这三种策略。
在pthred库中,提供了一个函数,用来设置被创建的线程的调度属性:是从创建者线程继承调度属性,还是从属性对象设置调度属性。该函数就是:
int pthread_attr_setinheritsched (pthread_attr_t *__attr, int __inherit)
其中,inherit的值为下列值中的其一:
enum
{
PTHREAD_INHERIT_SCHED, //线程调度属性从创建者线程继承
PTHREAD_EXPLICIT_SCHED //线程调度属性设置为attr设置的属性
};
如果在创建新的线程时,调用该函数将参数设置为PTHREAD_INHERIT_SCHED时,那么当修改进程的优先级时,该进程中继承这个优先级并且还没有改变其优先级的所有线程也将会跟着改变优先级(现在应该明白我刚才为什么说那句话部正确的原因了吧)。
3.3 线程状态
线程具有进程所拥有的相同状态和状态转换。如图3所示:
图 3线程状态及其转换(摘自《pthread prime》)
四、 线程模型
线程的目的就是代表进程执行工作,如果一个进程拥有多个线程,进程就可以根据一定的策略和方法来管理这些线程,并进行相关工作。先辈们通过总结与抽象,创造了线程模型的概念,这里的线程模型就是我们常说的架构,设计模式等,为我们这些后辈们提供了借鉴的意义。
4.1 线程模型总览
前人经过摸索,总结与抽象,创造了一些通用的模型:
1) 委托模型(delegation),又称为boss-worker模型
2) 对等模型(peer-to-peer)
3) 管道模型
4) 生产者-消费者模型(producer-consumer)
各个模型的含义,如表三所示:
线程模型
含义
委托模型
Boss线程创建其他线程(worker线程),并给每个worker线程分配任务。Boss线程可能在每个线程完成它们的任务之前一直等待。
对等模型
所有的线程都具有相同的工作状态。对等线程创建执行任务所需要的所有线程,但不执行委托职责。对等线程可以从单个输入流处理请求,这些输入流被所有线程共享,或者每个线程都有其自己的输入流。
管道模型
类似于装配线流程,分阶段处理输入流,然后传给下一个线程进行处理
生-消模型
生产者线程生产数据给消费者线程使用,数据存储在生产者和消费者共享的存储块中。
表 3线程模型及其含义
4.2 委托模型
Boss线程创建其他线程(worker线程),并给每个worker线程分配任务。Boss线程可能在每个线程完成它们的任务之前一直等待。Boss线程将任务委托给每个worker线程是通过指定一个函数来完成的。由于每个worker线程被分配了任务,所以每个worker线程的职责就是执行指定的任务。
基于以上原理,可以演变出两类委托模型:
浪费型:当系统有请求任务时,boss线程创建worker线程。对每一个请求的处理就能够委托给一个worker线程。在这种情况下,boss线程执行事件循环,当有事件发生时,就创建一个worker线程,并给它们指派职责。如图4所示。
图 4委托模型――浪费型
这类应用实际意义不大,可能常见于试验型或者连接请求不多的情况。因为线程不可能无止境的创建下去,更何况创建太多的线程,势必会影响到系统的性能,这样就造成了性能瓶颈。
节约型:boss线程创建一个线程池,可以为池中的线程指派任务。当有任务到达时,boss线程发信号通知worker线程进行处理。该worker线程处理完成之后,接着处理下一个请求。如果没有请求,那么就挂起自身,直到收到boss的通知或者等待一个特定的时间,再次读取请求。如图5所示。
图 5委托模型――节约型
其中节约型中的boss线程的主要目的就是创建所有的worker线程,将请求放入队列中,然后唤醒worker线程。Worker线程检查队列中的请求,执行指派的任务。如果没有可供处理的任务,便挂起自己。所有的boss线程和worker线程并发执行。
大名鼎鼎的IOCP(IO完成端口)就是这类模型。
4.3 对等模型
4.4 管道模型
4.5 生产者-消费者模型
五、 线程同步机制
5.1 POSIX标准定义的类型
5.2 互斥信号量
5.3 读写锁
5.4 条件变量
5.5 多重条件变量
5.6 面向对象的同步方法
六、 线程调试
6.1 调试工具
6.2 调试策略
6.3 关于多线程调试的一些文章
七、 参考文献
正在进行时,待续,敬请关注......