多线程编程课程.docx
《多线程编程课程.docx》由会员分享,可在线阅读,更多相关《多线程编程课程.docx(95页珍藏版)》请在冰点文库上搜索。
多线程编程课程
多线程编程
第1章多线程基本概念
1.1线程的定义
线程(thread)是一些相关指令的离散序列。
线程与其他指令序列的执行相互独立。
每个程序至少包含一个线程,那就是主线程。
主线程负责程序的初始化工作,并且执行初始指令。
随后,主线程会为执行各种不同任务而分别创建其他线程,或者主线程根本不创建任何其他线程,而将所有任务都独立承担。
但是,不管那种情况,每个程序至少都包含有一个线程,并且每个线程都会维持自己当前的机器状态。
进程(Process)是离散的程序任务集合,每个进程都拥有独立的地址空间。
一个进程可以包含多个线程,一个进程中的所有线程共享同一个地址空间,因此线程间的通信变得非常简单。
操作系统不会为每个进程都创建相应的线程列表,而只是在内核中保存一个包含所有线程信息的线程表。
操作系统会为每个进程指定一个进程控制块(ProcessControlBlock,PCB),进程控制块中包含了相应进程的标识(唯一的)、当前状态、进程优先级以及驻留的虚存地址等信息。
本节重点
本节的重要知识点包括:
●线程和进程的基本概念。
讲义中未列入重要知识点的内容仅要求一般性了解,不做考察。
1.2线程的层次
图1-1给出了线程计算模型的图示。
如图所示,线程有三个层次的表现形式:
●用户级线程在应用软件中所创建和操纵的线程。
由用户级操作系统处理。
●内核级线程操作系统实现大多数线程的方式。
由内核级操作系统处理。
●硬件线程线程在硬件执行资源上的表现形式。
由每个处理器使用。
用户级线程
由可执行应用程序使用,同时由用户级操作系统处理
内核级线程
由操作系统内核使用,同时由内核级操作系统处理
硬件线程
由每个处理器使用
执行流程
图11线程计算模型
单个程序线程一般都包括上述三个层次的表现:
程序线程被操作系统作为内核级线程实现,进而作为硬件线程来执行。
线程的三个层次之间有相应的接口,这些接口一般都由执行系统自动完成。
1.2.1操作系统之上的线程
对于那些不依赖运行时架构的应用程序来讲,创建线程只需要直接调用系统API即可。
这些系统调用在运行时就被转化为一系列对操作系统内核的调用,从而完成创建线程的工作。
接下来,一些能够完成线程活动的指令序列就被发送到处理器上执行。
图1-2给出了在典型系统上执行传统应用程序时线程的执行流程。
在线程定义和准备阶段,程序设计环境完成对线程的指定,编译器完成对线程的编译工作。
在运行阶段,操作系统完成对线程的创建和管理。
最后,在执行阶段,处理器对线程指令序列进行实际的执行。
图12执行环境中的线程执行流程
依赖于运行时环境的应用程序代码称为托管代码(managedcode)。
托管代码在托管环境中运行,托管环境执行一些应用程序函数并将这些函数转化为对底层操作系统的调用。
托管环境包括Java虚拟机(JavaVirtualMachine,JVM)以及微软的通用语言运行时环境(CommonLanguageRuntime,CLR)。
托管环境本身不提供任何调度功能,而是依赖于操作系统的调度。
线程被传递给操作系统的调度程序之后,调度程序完成剩下的线程行为。
一般来讲,应用程序线程可以采用内建的API调用来实现,这就是线程在应用程序的实现方式。
最常用的API是Windows线程库和Pthreads(POSIX标准线程,通常用于linux)库。
具体选择哪种API,由具体的需求和系统平台来决定。
1.2.2操作系统内部的线程
图1-3给出了用户级和内核级两个线程层次之间的划分,并给出了系统其它组件在其中的位置。
在图1-3中,应用程序层和操作系统内核之间的接口称为系统库。
操作系统与处理器之间的接口称为硬件抽象层(HardwareAbstractionLayer,HAL)。
图13操作系统的层次结构
内核(kernel)是操作系统的核心,维护着大量用于追踪进程和线程的表格,绝大多数的线程级行为都依赖于内核级线程。
很多线程库,如Pthreads库,都使用内核级线程。
Windows线程库则既支持内核级线程也支持用户级线程。
Windows平台上的用户级线程称为纤程(fiber)。
纤程需要程序员为线程创建所有的底层管理架构,并且需要人工调度线程的执行。
这种方法的好处就是使得开发人员能够对线程的某些行为细节进行操控,而这些细节在内核级线程中通常都是非常隐蔽的。
但是,由于存在人工介入所带来的开销以及一些额外的限制,纤程对于设计良好的多线程应用程序来讲则没有很大价值。
内核级线程能够提供更高的性能。
并且同一进程中的多个内核级线程能够同时在不同处理器或者执行核上执行。
当然,内核级线程相关的开销也比用户级线程要高得多。
因此,经常采用重用内核级线程的策略来降低开销。
也就是说,可以在内核级线程完成原工作之后,继续采用它来完成接下来的任务。
每个线程都被操作系统的调度程序映射到处理器上执行。
一种称为处理器亲和(Processoraffinity)的概念允许程序员向操作系统请求将特定的线程映射到特定的处理器上去。
虽然当前大多数的操作系统都努力支持处理器亲和的请求,但是这些操作系统并不能保证这些请求都完全得到满足。
处理器亲和性(affinity)是线程要在某个给定的处理器上尽量长时间地运行而不被迁移到其他处理器的倾向性。
软亲和性(affinity)意味着线程并不会在处理器之间频繁迁移,而硬亲和性(affinity)则意味着线程需要在您指定的处理器上运行。
一方面,线程迁移的频率小就意味着由于线程迁移产生的负载会较少。
另一方面,不当的设置处理器亲和性可能导致处理器的负载不均衡。
Linux内核进程调度器天生就具有软亲和性,并可以通过下面的函数提供硬亲和性:
sched_set_affinity()(用来修改当前线程处理器亲和性的位掩码)
sched_get_affinity()(用来查看当前线程处理器亲和性的位掩码)
voidCPU_ZERO(cpu_set_t*set)
voidCPU_SET(intcpu,cpu_set_t*set)
voidCPU_CLR(intcpu,cpu_set_t*set)
intCPU_ISSET(intcpu,constcpu_set_t*set)
Windows内核同样提供了设置硬亲和性的函数:
SetThreadAffinityMask()(用来修改当前线程处理器亲和性的位掩)
GetThreadAffinityMask()(用来查看当前线程处理器亲和性的位掩)
目前存在多种线程到处理器的映射模型:
一对一(1:
1)映射、多对一(M:
1)映射和多对多(M:
N)映射。
在一对一映射模型中,不存在线程库调度程序的开销,因为操作系统会负责线程的调度工作。
这也称为抢占式多线程模型。
Linux、Windows2000以及WindowsXP等操作系统都采用这种抢占式多线程模型。
在多对一映射模型中,由线程库调度程序负责决定哪个线程得到优先执行权,这也称为协同式多线程模型。
在多对多映射模型中,映射规则比较灵活。
1.2.3硬件上的线程
硬件上的多线程技术包括两种:
超线程技术和多核体系结构。
超线程技术支持在单个CPU上同时执行两个或者多个线程,多个线程共享大部分的执行资源,这种方法称为同时多线程技术(SimultaneousMuti-Threading,SMT)。
SMT采用硬件调度器对需要不同资源的各硬件线程进行管理。
多核CPU则提供了两个或者更多的执行核,能够支持真正的硬件多线程技术,因为两个线程在同一个处理器(多核CPU)上执行,将这种设计称为芯片多线程技术(ChipMulti-Threading,CMT)。
在设计软件的时候必须对能够同时执行的硬件线程数量加以重点考虑,要实现真正的并行,活动的程序线程数量必须与可用硬件线程的数量相等。
在大多数的情况下,程序线程的数量都比硬件线程的数量更大。
但是,过多的软件线程反而会降低程序的执行性能。
因此,在软件线程和硬件线程数量之间保持合理的平衡将有利于提供程序的性能。
本节重点
本节的重要知识点包括:
●理解线程的三个层次。
●理解硬件线程与软件线程的区别,以及它们之间的联系。
讲义中未列入重要知识点的内容仅要求一般性了解,不做考察。
1.3创建线程
所有的进程开始都是由单个线程执行的,该线程就是主线程。
为了编写多线程程序,开发人员必须创建新的线程。
微软所提供的最基本的线程创建机制就是调用CreateThread():
HANDLECreateThread(LPSECURITY_ATTRIBUTESlpThreadAttributes,
SIZE_TdwStackSize,
LPTHREAD_START_ROUTINElpStartAddress,
LPVOIDlpParameter,
DWORDdwCreationFlags,
LPDWORDlpThreadId);
参数涵义:
●LPSECURITY_ATTRIBUTESlpThreadAttributes是一个数据结构,该数据结构用于指定一些不同的安全参数。
当前进程所创建的进程(子进程)是否继承该句柄也由此参数定义。
换句话说,这个参数主要对线程句柄在系统中的使用方式进行高级控制,如果程序员不需要对这些属性进行控制,那么可以将该参数指定为NULL。
●SIZE_TdwStackSize,指定了线程堆栈的大小(以字节为单位),该值上取整到与其最接近的页面大小;
●LPTHREAD_START_ROUTINElpStartAddress,定义了一个指向线程实际运行代码的函数指针。
该函数的指针原型如下:
DWORDWINAPIThreadFunc(LPVOIDdata);
从线程函数的定义可以看出,线程在终止时将返回一个状态信息,并且线程可能有一个指向某些数据值或结构的void指针作为参数。
这样就为线程和外部世界进行交互提供了一个基本的通信机制。
●LPVOIDlpParameter,是传递给线程函数的数据值。
换句话说,对于上述线程函数ThreadFunc(),lpParameter参数所指定的数据值将被传递给线程函数ThreadFunc()的data参数;
●DWORDdwCreationFlags,指定了多个配置项。
例如,使用该标志,程序员可以指定新创建的线程立即进入挂起状态,从而使得程序员能够对新创建线程的状态进行控制;
●LPDWORDlpThreadId,是一个指针,指向在系统中存放的线程的唯一标志:
线程ID值的地址,该全局ID在调试时很有用。
如果CreateThread()执行成功,将为新线程返回一个句柄。
需要注意的是,CreateThread()有两个不同的返回值,一个是线程句柄,一个是线程ID,这两个返回值是为了不同的线程API调用使用的。
线程被创建后,程序员可能会在某个时刻希望终止线程。
这项工作可以通过调用ExitThread()函数来完成:
VOIDExitThread(DWORDdwExitCode);
ThreadFunc()通过在退出前调用ExitThread()来进行终止该线程的操作。
需要注意的是,并不是非要显式调用ExitThread(),简单地返回退出就隐含了对ExitThread()函数的调用:
DWORDWINAPIThreadFunc(LPVOIDdata)
{
//这里做一些处理工作
……
//准备好退出线程
return0;//将隐式调用ExitThread(0);
}
注意:
在C++中调用ExitThread()会导致线程在清除构造器/自动变量之前就终止,因此,微软推荐在编写程序时直接从ThreadFunc()返回,而不是显式调用ExitThread();
CreateThread()函数和ExitThread()函数在Windows应用程序中创建线程提供了一种灵活、易用的机制。
但是,CreateThread()函数并不会执行C运行时数据块和变量的每线程初始化(per-threadinitialization)。
因此,在任何使用C运行时库的应用程序中,不能使用CreateThread()函数和ExitThread()函数。
微软提供了另外的两个方法:
_beginthreadex()和_endthreadex()。
这两个方法在调用CreateThread()之前完成必要的初始化工作。
CreateThread()函数和ExitThread()函数足够满足只使用Win32API开发应用程序的需要;但是,对于大多数情况,还是推荐使用_beginthreadex()和_endthreadex()来创建线程。
_beginthreadex()的定义和CreateThread()类似,唯一的区别是其中的一个参数。
unsignedlong_beginthreadex(//返回无符号长整数而不是HANDLE
//从技术上说是一样的
void*security,//和CreateThread()一样
unsignedstack_size,//和CreateThread()一样
unsigned(__stdcall*start_address)(void*),//函数指针。
//返回无符号整数,而不是void
void*arglist,//和CreateThread()一样
unsignedinitflag,//和CreateThread()一样
unsigned*thrdaddr);//和CreateThread()一样
同样地,_endthreadex()的定义也与ExitThread()类似:
void_endthreadex(unsignedretval);
对采用MFC并使用C++编写的应用程序,微软还提供了另外一个函数来实现线程的创建——AfxBeginThread():
CWinThread*AfxBeginThread(AFX_THREADPROCpfnThreadProc,
LPVOIDpParam,
intnPriority=THREAD_PRIORITY_NORMAL,
UINTnStackSize=0,
DWORDdwCreateFlags=0,
LPSECURITY_ATTRIBUTESlpSecurityAttrs=NULL);
AfxBeginThread()和CreateThread()在以下几个方面有所不同:
●AfxBeginThread()为CWinThread对象返回一个指针,而不是返回一个线程句柄。
●AfxBeginThread()调整了参数的顺序,并且用参数nPriority替代了参数threadid,参数nPriority用来指定线程的优先级。
●AfxBeginThread()中ThreadFunc()函数的原型定义如下:
UINTThreadFunc(LPVOIDpParam);
●AfxBeginThread()将调用_beginthreadex(),这样可以保证与C运行库安全地结合使用。
从用途和目标上来讲,AfxBeginThread和CreateThread是相同的,MFC也为ExitThread提供了同样的对等函数:
voidAFXAPIAfxEndThread(UINTnExitCode,
BOOLbDelete=TRUE);
bDelete参数指定在线程终止时运行环境是否自动清除相关的线程对象。
若程序需要检查线程的退出码,则需要将该参数设置为FALSE。
这样的话,将由程序来完成释放CWinThread对象的工作。
本节重点
本节的重要知识点包括:
●如何创建和退出线程。
●线程创建和销毁时,需要注意的事项。
讲义中未列入重要知识点的内容仅要求一般性了解,不做考察。
1.4线程创建后的注意事项
一个进程可以包含多个线程。
虽然一个进程的所有线程共享同一个地址空间和特定的资源,但是每个线程的运行却是相互独立的。
除此之外,每个线程都有自己的栈空间。
栈空间通常是由操作系统进行管理的。
应用程序开发人员一般不需要了解栈空间管理的细节,但是对于系统级的开发人员则必须了解操作系统在栈空间分配上的限制。
对于一些应用程序来讲,这些限制可能就不能满足应用程序的需求,这种时候,程序员必须绕过系统的默认设置,自行管理线程的栈空间。
一般的操作系统对每个进程和线程可以使用的资源数都有限制,比如一个进程可以创建的线程数,一个进程可以打开的文件描述符的数量,进程和线程栈大小的限制和默认值等。
针对这些问题,首先要分析和考虑你的系统是一个什么样的规模,会不会收到这些限制的影响,如果需求大于系统的限制,可以通过适当的调整系统参数来解决,如果还不能解决,就得考虑采用多进程的方式来解决。
对于进程和线程的栈空间大小的限制,主要是线程栈空间的问题。
一般的系统都有默认的线程栈空间大小,而且不同操作系统的默认值可能不同。
在通常情况下,这些对程序没有影响,但是当程序的层次结构比较复杂,使用了过多的本地变量,这个限制可能就会对程序产生影响,导致栈空间溢出,这是一个比较严重的问题。
不能通过调整系统参数来解决这个问题,但是可以通过相应的函数,在程序里面指定创建线程的栈空间的大小。
但是具体该调整的数值应该适可而止,而不是越大越好。
因为线程的栈空间过大的时候,就会影响到可创建线程的数量,虽然远没有达到系统多线程数的限制,但却可能因为系统资源占用过多导致分配内存失败。
在Windows中,CreateThread函数的参数dwStackSize是将要分配给新线程的以字节为单位的栈大小。
栈大小应该是4KB的非零整数倍,最小为8KB。
堆栈默认的大小1MB。
在Windows中,受可用虚拟内存的限制,一个进程可以创建的线程数目是有限的。
默认情况下,每个线程有1M栈空间。
因此,最多可以创建2028个线程。
如果减小默认栈大小,那么可以创建更多线程。
创建线程栈时,只是一个预留的虚拟地址区域,默认是1MB(此大小可在CreateThread或在链接时通过链接选项修改),初始时只有前两页是提交的。
当线程栈因为函数的嵌套调用需要更多的提交页时,虚拟内存管理器会动态地提交该虚拟地址区域中的后续页以满足其需求,直到到达1MB的上限。
当到达此预留区域大小的上限(默认1MB)时,虚拟内存管理器不会增加预留区域大小,而是在提交最后一页时抛出一个栈溢出异常,抛出栈溢出异常时该栈还有一页空间可用,程序仍可正常运行。
而当程序继续使用栈空间,用完最后一页后,还继续需要存储空间,这时就超过了上限,会直接导致进程退出。
所以为防止线程栈溢出导致整个程序退出,应该注意尽量控制栈的使用大小。
比如减少函数的嵌套层数,减少递归函数的使用,尽量不要在函数中使用太大的局部变量(大的对象可以从堆中开辟空间存放,因为堆会动态扩大,而线程栈的可用内存区域在线程创建时就已固定,之后在整个线程生命期间无法扩展)。
另外为了防止因为一个线程栈的溢出导致整个进程退出,可以对可能会产生线程栈溢出的线程体函数加异常处理,捕获在提交最后一页时抛出的溢出异常,并做出相应处理。
本节重点
本节的重要知识点包括:
●操作系统对于进程、线程栈空间分配的限制;
●默认线程栈的大小
讲义中未列入重要知识点的内容仅要求一般性了解,不做考察。
1.5多线程使用策略
只要运用恰当,多线程技术就能使硬件资源得到更加充分的利用,从而达到提高计算性能的目的。
反之,如果运用不当,多线程技术不但会降低计算性能,还可能导致应用程序发生一些不可预测的行为,甚至出现难以解决的故障。
要想知道一个应用程序是否适合采用多线程技术,就必须从以下几个方面进行考虑:
●应用程序所采用的设计方法和结构
●多线程应用程序编程接口
●应用程序的编译器或者运行时环境
●应用程序的运行平台
只要从以上四个方面进行考虑,就能够形成一个明确的多线程策略。
第2章多线程管理
2.1管理线程
对线程的管理,就是如何对线程的执行进行控制和操作。
Windows允许程序员使新创建的线程进入挂起或者运行状态。
接下来,我们采用Win32的定义来阐述Windows程序设计的概念。
大多数MFC调用和Win32相同,唯一的区别在于MFC调用是作为MFC类的方法而出现的,而不是作为C函数调用出现的。
下面几个函数为程序员提供了控制线程执行的功能:
DWORDSuspendThread(HANDLEhThread);
DWORDResumeThread(HANDLEhThread);
BOOLTerminateThread(HANDLEhThread,DWORDdwExitCode);
SuspendThread()允许开发人员将HANDLE参数指定的线程的执行挂起。
线程在其数据结构中保存一个挂起计数,该计数被内核监视,用于控制线程的状态转换。
挂起计数为0时,表示线程正准备运行,若该值大于0,则表示线程处于挂起状态。
若SuspendThread()被调用,挂起计数会递增,函数将返回调用前的挂起计数值。
ResumeThread()则会递减有HANDLE值所指定线程的挂起计数,并返回调用前的挂起计数。
这意味着,若线程从挂起状态将要转换到运行状态的时候,ResumeThread()将得到返回值1。
对于一个正在运行的线程调用ResumeThread()将得到返回值0;而对于一个挂起计数为0的线程,使用ResumeThread()则不会报错,因为ResumeThread()对该线程是无效的。
TerminateThread()函数强制终止HANDLE参数所指定的线程。
此时,用户代码不再执行,线程被立即终止。
若TerminateThread()函数执行成功,则返回一个非0值。
注意:
开发人员在调用SuspendThread()函数时必须非常小心,因为线程可能在挂起后处于非常危险的状态。
例如,假设线程正持有一个信号量,该线程在挂起之前将不会释放该信号量,其它线程要等到该挂起的线程恢复,并释放所占有的信号量资源之后才能够访问临界段。
这可能会导致程序性能低下,甚至有可能导致发生死锁。
注意:
TerminateThread()函数更加危险,被终止的线程没有任何机会做清理工作,由此可能会带来一系列非常麻烦的副作用。
例如,假设线程正持有一个同步对象,突然该线程被TerminateThread()终止,同步对象会继续处于已加锁状态。
但是,由于为其加锁的线程不存在了,所占有的资源也就不能释放,所以就会发生死锁。
因此我们强烈建议避免使用该函数。
为了安全地对线程进行挂起或终止操作,我们需要一种信号机制使一个线程(例如主线程)可以通知目标线程进行挂起/终止操作。
Windows的事件机制给开发人员提供了一种可以完成上述通知操作的方法。
本节重点
本节的重要知识点包括:
●线程的挂起和恢复,终止线程。
●特别注意使用挂起和终止线程时的危险。
讲义中未列入重要知识点的内容仅要求一般性了解,不做考察。
2.2使用Windows事件进行线程通信
微软提供了事件对象(Eventobjects)用来进行线程间的通信。
要使用事件机制,必须首先完成事件的创建工作。
使用CreateEvent()方法来完成创建事件的工作。
HANDLECreateEvent(
LPSECRITY_ATTRIBUTE