第3章 ◄多线程编程►
3.1 多线程编程的基本概念
3.1.1 为何要用多线程
前面的绝大多数程序都是单线程程序,如果程序中有多个任务,比如读写文件、更新用户界面、网络连接、打印文档等操作,比如按照先后次序,先完成前面的任务才能执行后面的任务。如果某个任务持续的时间较长,比如读写一个大文件,那么用户界面也无法及时更新,这样看起来程序像死掉一样,用户体验很不好。怎么解决这个问题呢?人们提出了多线程编程技术。在采用多线程编程技术的程序中,多个任务由不同的线程去执行,不同线程各自占用一段CPU时间,即使线程任务还没有完成,也会让出CPU时间给其他线程有机会去执行。这样在用户角度看起来,好像是几个任务同时进行的,至少界面上能得到及时更新了,大大改善了用户对软件的体验,提高了软件的友好度。
3.1.2 操作系统和多线程
要在应用程序中实现多线程,必须要有操作系统的支持。Windows 32位或64位操作系统对应用程序提供了多线程的支持,所以Windows NT/2000/XP/7/8/10是一个多线程操作系统。根据进程与线程的支持情况,可以把操作系统大致分为如下几类:
(1)单进程、单线程,MS-DOS大致是这种操作系统。
(2)多进程、单线程,多数UNIX(及类UNIX的Linux)是这种操作系统。
(3)多进程、多线程,Win32(Windows NT/2000/XP/7/8/10等)、Solaris 2.x和OS/2都是这种操作系统。
(4)单进程、多线程,VxWorks是这种操作系统。
具体到VC2017++开发环境,它提供了一套Win32 API函数来管理线程。用户既可以直接使用这些Win32 API函数,也可以通过MFC类的方式来使用,只不过MFC把这些API函数进行了简单的封装。
3.1.3 进程和线程
在了解线程之前,首先要理解进程的概念。简单地说,进程就是正在运行的程序。比如邮件程序正在接收电子邮件就是一个进程,杀毒软件正在杀毒就是一个进程,病毒软件正在传播病毒、破坏系统也是一个进程。程序是指计算机质量的静态集合,是一个静态的概念,而进程是一个动态的概念。Windows操作系统中能同时运行多个进程,比如正在使用Word软件在打字的同时,又用语音聊天工具在聊着天,等等。每个进程都有自己的内存地址空间和CPU运行时间等一系列资源。进程有3种状态:
(1)运行态:正在CPU中运行。
(2)就绪态:运行准备就绪,但其他进程正在运行,所以只能等待。
(3)阻塞态:不能得到所需要的资源而不能运行。
现代操作系统大多支持多线程概念,每个进程中至少有一个线程,所以即使没有使用多线程编程技术,进程也含有一个主线程,所以也可以说,CPU中执行的是线程,线程是程序的最小执行单位,是操作系统分配CPU时间的最小实体。一个进程的执行说到底就是从主线程开始的,如果需要,可以在程序任何地方开辟新的线程,其他线程都是由主线程创建的。一个进程正在运行,也可以说是一个进程中的某个线程正在运行。一个进程的所有线程共享该进程的公共资源,比如虚拟地址空间、全局变量等。每个线程也可以拥有自己私有的资源,如堆栈、在堆栈中定义的静态变量和动态变量、CPU寄存器的状态等。
线程总是在某个进程环境中创建的,并且会在这个进程内部销毁,正所谓生于进程而挂于进程。线程和进程的关系是:线程是属于进程的,线程运行在进程空间内,同一进程所产生的线程共享同一内存空间,当进程退出时该进程所产生的线程都会被强制退出并清除。线程可与属于同一进程的其他线程共享进程所拥有的全部资源,但是其本身基本上不拥有系统资源,只拥有一点在运行中必不可少的信息(如程序计数器、一组寄存器和线程栈,线程栈用于维护线程在执行代码时需要的所有函数参数和局部变量)。
相对于进程来说,线程所占用资源更少,比如创建进程,系统要为它分配进程很大的私有空间,占用的资源较多,而对多线程程序来说,由于多个线程共享一个进程地址空间,因此占用资源较少。此外,进程间切换时,需要交换整个地址空间,而线程之间切换时只是切换线程的上下文环境,因此效率更高。在操作系统中引入线程带来的主要好处是:
(1)在进程内创建、终止线程比创建、终止进程要快。
(2)同一进程内的线程间切换比进程间的切换要快,尤其是用户级线程间的切换。
(3)每个进程具有独立的地址空间,而该进程内的所有线程共享该地址空间。因此,线程的出现可以解决父子进程模型中子进程必须复制父进程地址空间的问题。
(4)线程对解决客户/服务器模型非常有效。
虽然多线程给应用开发带来了不少好处,但是并不是所有情况下都要去使用多线程,要具体问题具体分析,通常在下列情况下可以考虑使用:
(1)应用程序中的各任务相对独立。
(2)某些任务耗时较多。
(3)各任务有不同的优先级。
(4)一些实时系统应用。
值得注意的是,一个进程中的所有线程共享它们父进程的变量,但同时每个线程可以拥有自己的变量。
3.1.4 线程调度
进程中有了多个线程后,就要管理这些线程如何去占用CPU,这就是线程调度。线程调度通常由操作系统来安排,不同的操作系统其调度方法不同,比如有的操作系统采用轮询法来调度。Windows NT以后的操作系统是一个优先级驱动、抢占式操作系统,也就是说线程具有优先级,具有高优先级的可运行的(就绪状态下的)线程总是先运行。如果出现一个更高优先级的线程就绪,正在运行的这个线程就可能在未完成其时间片前被抢占;甚至一个线程可能在未开始其时间片前就被抢占了,而要等待下一次被选择运行。
Windows调度线程是在内核中进行的。当发生下面这些事件时将触发内核进行线程调度:
(1)线程的状态变成就绪状态,例如一个新创建的线程或者从等待状态释放出来的线程。
(2)线程的时间片结束而离开运行状态。它可能运行结束了,或者进入等待状态。
(3)线程的优先级改变了。
(4)出现了其他更高优先级的线程。
当Windows系统进行线程切换的时候,将执行一个上下文转换的操作,即保存正在运行的线程的相关状态,装载另一个线程的状态,开始新线程的执行。
每个线程都被赋予了一个优先级,优先级的取值范围从0(最低)到31(最高),并且规定只有0页线程(一个系统线程)可以拥有0优先级。
线程最初的优先级(值)也称为基础优先级(值),由两个因素决定:进程的优先级类别和线程所处的优先级层次。每个进程都属于某个优先级类别,进程的优先级类别可以分为以下几类(按照从低到高):
(1)IDLE_PRIORITY_CLASS
该类别被称为空闲优先级类别,该类别的进程中的线程只在系统处于空闲的时候才运行,并且这些线程会被更高优先类别的进程中的线程抢占。屏幕保护程序就是拥有该类别优先级的典型例子。空闲优先级类别能被子进程继承,即拥有空闲优先级类别的进程所创建的子进程也具有空闲优先级类别。该类别定义如下:
#define IDLE_PRIORITY_CLASS 0x00000040
(2)BELOW_NORMAL_PRIORITY_CLASS
该类别比空闲优先级类别高,但比正常优先级类别低。Windows 2000以下操作系统不支持该级别。该类别定义如下:
#define BELOW_NORMAL_PRIORITY_CLASS 0x00004000
(3)NORMAL_PRIORITY_CLASS
该类别被称为正常优先级类别,是进程默认的优先级类别。该类别定义如下:
#define NORMAL_PRIORITY_CLASS 0x00000020
(4)ABOVE_NORMAL_PRIORITY_CLASS
该类别比正常优先级类别高,但低于高优先级类别。Windows 2000以下操作系统不支持该级别。该类别定义如下:
#define ABOVE_NORMAL_PRIORITY_CLASS 0x00008000
(5)HIGH_PRIORITY_CLASS
该类别被称为高优先级类别。拥有该类别的进程通常要完成实时性的任务,即比如必须要立即执行的任务。该进程中的线程可以抢占正常优先级类别进程和空闲优先级类别进程中的线程。使用该优先级别应该特别慎重,因为一个拥有高优先级类别的进程几乎可以使用所有CPU能提供的运行时间,如果该优先级别的进程长时间运行,那么其他线程很可能一直得不到处理器时间。如果在同一时间设置了多个高优先级别的进程,那么它们的线程效率将降低。该类别定义如下:
#define HIGH_PRIORITY_CLASS 0x00000080
(6)REALTIME_PRIORITY_CLASS
该类别被称为实时优先级类别,是最高的优先级类别。拥有该类别的进程中的线程能抢占其他所有进程中的线程,包括正在完成重要工作的操作系统进程。比如,该类别的进程在执行过程中可能会能让磁盘缓存不刷新或者鼠标出现停顿没反映。对于该优先级类别,或许应该永远不去使用,因为它会中断操作系统的工作,只有在直接和硬件打交道或完成的任务非常简短时才适合用该优先级类别。该类别定义如下:
#define REALTIME_PRIORITY_CLASS 0x00000100
上面这些宏都定义在WinBase.h中。在用函数CreateProcess创建进程的时候,可以指定其优先级类别。此外,还可以通过函数GetPriorityClass来获取某个进程的优先级类别,并能通过函数SetPriorityClass来改变某个进程的优先级类别。
在进程的每个优先级类别中,不同的线程属于不同优先级层次。从低到高有如下优先级层次:
#define THREAD_PRIORITY_IDLE -15 #define THREAD_PRIORITY_LOWEST -2 #define THREAD_PRIORITY_BELOW_NORMAL -1 #define THREAD_PRIORITY_NORMAL 0 #define THREAD_PRIORITY_ABOVE_NORMAL 1 #define THREAD_PRIORITY_HIGHEST 2 #define THREAD_PRIORITY_TIME_CRITICAL 15
所有线程在创建(使用函数CreateThread)的时候都属于THREAD_PRIORITY_NORMAL优先级层次,如果要修改优先级层次,可以在调用CreateThread时传入CREATE_SUSPENDED标志,让线程创建不马上执行。此时,我们再调用函数SetThreadPriority修改线程优先级层次,接着调用函数ResumeThread让线程变为可调度。通常,对于进程中用于接收用户输入的线程,建议使用THREAD_PRIORITY_ABOVE_NORMAL或者THREAD_PRIORITY_HIGHEST优先级层次,这样可以保证即时响应用户。对于那些后台工作的线程,尤其是密集使用处理器的线程,可以使用THREAD_PRIORITY_BELOW_NORMAL或者THREAD_PRIORITY_LOWEST优先级层次,这样可以确保必要的时候能被其他线程抢占,不至于它们老是占用处理器。如果低优先级层次的线程在等待高优先级层次的线程,为了让低优先级层次的线程能得到执行,可以在高优先级层次的线程中使用等待函数Sleep或SleepEx,或者线程切换函数SwitchToThread。
有了进程的优先级类别和线程的优先级层次,就可以确定一个线程的基础优先级了,具体数值见表3-1。数值部分就是某个线程的基础优先级值。
表3-1 线程基础优先级
表3-1中的数值是线程的基础优先级值,是线程开始时拥有的优先级。线程的优先级可以是动态变化的,后来系统可能升高或降低线程的优先级,以确保没有线程处于饥饿状态(好久没有运行)。对于基础优先级处于16到31之间的线程,系统不会再提高这些线程的优先级,只有基础优先级在0到15之间的线程才会被系统动态地提高优先级。
系统公平地对待同一优先级的所有线程。比如,对应最高优先级的所有线程,系统将以轮询的方式为这些线程分配时间片,如果这些线程一个都没有准备好运行,那么系统会对下一个最高优先级的所有线程采取轮询的方式分配时间片。如果后来更高优先级的线程运行准备就绪了,那么系统会停止运行低优先级的线程,即使该线程的时间片还没用完也会被停止运行,同时会为高优先级的线程分配完整的时间片。每个线程的优先级取决于两个因素:进程的优先级类别和线程的优先级层次。
线程调度程序不会考虑线程所属的进程,比如进程A有8个可运行的线程,进程B有3个可运行的线程,而且这11个线程的优先级别相同,那么每一个线程将会使用1/11的CPU时间,而不是将CPU的一半时间分配给进程A,另一半时间分配给进程B。
3.1.5 线程函数
线程函数就是线程创建后要执行的函数。执行线程,说到底就是执行线程函数。这个函数是我们自定义的,然后在创建线程的函数时把函数名作为参数传入线程创建函数。
同理,中断线程的执行就是中断线程函数的执行,以后再恢复线程的时候就会在前面线程函数暂停的地方开始继续执行下面的代码。结束线程也就不再运行线程函数了。线程的函数可以是一个全局函数或类的静态函数,通常这样声明:
DWORD WINAPI ThreadProc( LPVOID lpParameter);
其中,参数lpParameter指向要传给线程的数据,这个参数是在创建线程的时候作为参数传入线程创建函数中的。函数的返回值应该表示线程函数运行的结果:成功还是失败。注意,函数名ThreadProc可以是自定义的函数名,这个函数是用户先定义好再由系统来调用的。
线程函数必须返回一个值,这个返回值会成为该线程的退出代码。
3.1.6 线程对象和句柄
为了方便操作系统对线程进行管理,在创建线程时,系统会开辟一小块内存数据结构来存放线程统计信息,这块数据结构就是线程对象。由于它存在于内核中,因此线程对象是一个内核对象。线程内核对象不是线程本身,而是操作系统用来管理线程的一个小的数据结构。为了引用该对象,系统使用线程句柄来代表线程对象。句柄就是一个32位整数值,操作系统会通过这个句柄值来找到所需的内核对象。
内核对象是操作系统创建和管理的,比如创建线程的同时,系统在内核中就创建了一个线程对象。既然线程对象这个数据结构存在于内核中,那么应用程序就不能在内存中直接访问这个数据结构,也不能改变它们的内容,而只能通过Win32 API函数来操作,比如关闭线程对象可以用函数CloseHandle。
另外需要注意的是,这里所说的对象的含义和C++中面向对象的对象概念不同,这里的对象可以理解为操作系统在内核中的一块数据结构,存放一些管理和统计所需的信息。除了线程对象外,内核对象还包括进程对象、文件对象、事件对象、临界区对象、互斥对象和信号量对象等,也有句柄标识。
知道了线程对象的概念,我们就应该知道线程对象句柄的关闭函数CloseHandle并不能用来结束线程。
3.1.7 线程对象的安全属性
线程对象是一个内核对象,内核中的东西非常重要,系统通常会为内核对象指定一个安全属性。安全属性是在创建时指定的,主要描述这个对象的访问权限,比如谁可以访问该对象,谁不能访问该对象。系统会在线程对象创建的时候用一个结构体SECURITY_ATTRIBUTES来描述其安全性,通常会把这个结构体作为参数传入创建线程对象的函数(也就是创建线程的函数)中。该结构体定义如下:
typedef struct _SECURITY_ATTRIBUTES { DWORD nLength; LPVOID lpSecurityDescriptor; BOOL bInheritHandle; } SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES;
其中,字段nLength表示该结构体的大小,单位是字节;lpSecurityDescriptor指向线程对象的安全描述符,用来控制该线程对象是否能共享访问,如果该字段为NULL,则内核对象被赋予一个默认的安全描述符;bInheritHandle表示内核对象创建函数返回的句柄能否被新创建的进程所继承,如果该字段为TRUE,则新的进程可以继承线程句柄。
3.1.8 线程标识
既然句柄是用来标识线程对象的,那么线程本身用什么来标识呢?在创建线程的时候,系统会给线程分配一个唯一的ID作为线程的标识,这个ID号从线程创建开始存在,一直伴随着线程的结束才消失。线程结束后该ID就会自动消失,我们不需要显式清除它。
通常线程创建成功后会返回一个线程ID。
3.1.9 多线程编程的3种库
在VC2017开发环境中,通常有3种方式来开发多线程程序,分别是利用Win32 API函数来开发多线程程序、利用CRT库(C Runtime Library)函数来开发多线程程序和利用MFC库来开发多线程程序。这3种方式各有利弊,但有一点要注意,在Win32 API创建的线程(函数)中最好不要使用CRT库函数,因为这会引起少许的内存泄漏,原因是当Win32 API创建的线程在终止时不能正确地清理由CRT函数为静态数据和静态缓冲区分配的内存,对长时间运行的线程会引起不可预测的结果。CRT库函数要用在CRT库函数创建的线程中。或许有人要说,要在Win32 API创建的线程中写控制台或开辟内存怎么办呢?答案是都用相应的Win32 API函数来代替,无论是读写控制台或者是内存管理,Win32 API完全可以替代CRT。这里讲的不要混用,是指不要在线程函数中混用,主线程中还是可以使用CRT函数的。
大家要知道,CRT问世的时候,当时还没有多线程的概念,CRT库函数都是针对单线程版本的。后来多线程出来了,微软和其他开发工具公司都针对CRT进行了多线程版本的改造。单线程版本的CRT在现在的VC2017中已经不用了。
这3种开发方式只是利用的库不同而已,但它们都可以用在不同类型的程序中,比如MFC程序或非MFC程序。