本节书摘来自华章出版社《多核与GPU编程:工具、方法及实践》一书中的第3章,第3.2节, 作 者 Multicore and GPU Programming: An Integrated Approach[阿联酋]杰拉西莫斯·巴拉斯(Gerassimos Barlas) 著,张云泉 贾海鹏 李士刚 袁良 等译, 更多章节内容可以访问云栖社区“华章计算机”公众号查看。
3.2 线程
3.2.1 线程的定义
线程可以被认为是轻量级进程。更精确的定义是线程是一个执行路径,亦即一个指令序列,可以被操作系统作为整体单元进行管理调度。一个进程中可以有多个线程。
线程可以减轻原有生成进程机制中的开销,仅需要拷贝基本的数据,即运行栈。由于线程包括被调用函数的动态框架(或动态记录),因此运行栈不能被多个线程共享。共享栈意味着控制权可能返回一个与调用线程不同的位置。
当一个进程的主线程(初始线程)生成一个新线程时,最终的内存布局十分类似于图3-3。这里父线程和子线程的关系也是如此。
保存和拷贝进程映像的额外开销使得通过创建进程来实现并发性的方法成本非常高。利用分离的进程也有一定优势,例如,提供增强的内存保护,或者是更为有弹性的调度,但是总体来说劣势大于优势。
3.2.2 线程的作用
线程通常与图形用户界面(GUI)一起编程。如果没有一个用于用户界面(UI)的线程,则不能有效处理用户产生的任务,并且不能有效响应用户界面。但是线程的作用更为丰富,可以用于下述情况。
提高性能:通过将一个进程的加载操作划分为几个线程,我们可以利用系统中的多个处理器内核,从而提高程序性能。
后台任务:交互程序可以利用线程来处理后台任务,亦即不需要用户交互的任务,此时可以同时继续处理用户请求。
异步处理:通过网络向服务器发送请求会导致延迟,通过生成一个线程来使服务器处理这一事务(亦即不需要等待反馈),进程可以继续工作,提高性能和资源利用率(例如处理器时间)。
改进程序结构:游戏程序的一个典型特征是需要并发地处理大量周期性任务,例如屏幕重画、声音播放、用户输入探测,以及生成策略。这些任务并不运行在相同的频率(例如策略生成),将这些任务放置在同一个循环中,并保证其可以按屏幕刷新频率运行,这仅在增加程序复杂性的时候才能满足。将不同的任务分配给不同的线程是一种更好的结构,并且也易于维护。
3.2.3 线程的生成和初始化
线程是C++生态系统中长期被忽视的部分,线程的生成和管理并不是标准C++库的一部分。有许多第三方库弥补了这一空白,其中一些著名库的包括:
pThreads
:基于C语言线程库。
winThreads
:Windows平台上基于C++的库。
Qt threads
:属于Qt(读作“cute”)的一部分,是基于C++的库和工具,其特征是易用以及包括丰富的应用程序编程接口(API)。
这一问题最终在C++ 11标准中解决,其在标准库中引入了一个线程类。代码清单3-2展示了代码清单3-1中等价的线程方法。
除了第2行中加载合适的类定义外,表3-2中还有两个关键之处:
第14行生成(并启动)一个线程实例,方法是提供一个无返回值和参数的函数作为子线程执行的入口点。
在实践中通常推荐父线程结束之前要等待子线程运行结束(或者强制结束它们)。这样可以保证资源的释放,文件的关闭等。这些功能在第16行通过调用join等待方法实现。
问题是目前大多数编译器仅仅支持部分C++ 11中的线程特征。因此,本章以Qt库为例研究线程以及为了完成同一个目标而使得其协调工作的机制。Qt库是一个跨平台库,除了为管理线程提供一组易于使用的类之外,它还提供UI开发、数据库(DB)互联、网络和可扩展标记语言(XML)处理等。Qt具有开源库和用于需要闭源开发的商业版。目前Qt可以运行在Windows、Mac OS X、Linux和一部分嵌入式操作系统上,提供程序部署的便利。
Qt管理线程的方式类似于Java:线程通过QThread类的子类实例来产生。run方法提供线程的入口点。例如:
根据上述类型定义,可以通过下面的语句来启动一个新的线程:
注意,run函数并不是直接被调用,而是一个继承的start
函数首先创建一个OS线程,
并由该线程调用run
方法。这与C++ 11中的线程类操作方法不同,这里QThread
对象的创建并不意味着线程的产生,而必须要显式调用start方法来生成线程。
通过调用wait函数,可以使得主线程在结束前等待子线程完成。
为了使线程执行一个特定的任务,特别是当生成多个子线程时更是如此,有三种选择。
使用全局变量:违反了封装性原理,容易导致程序软件错误。
创建多个QThread
子类:当需要大量线程时并不实用。
使用线程对象的数据成员:这是推荐的方法,线程对象必须在调用start前初始化,典型的方法是通过构造函数,例如:
在Qt中完成线程创建和初始化之后,代码清单3-4是一个生成可变数量线程的程序,这些线程从零开始顺序编号。线程数量通过命令行控制。每个线程输出一条简短的消息然后退出:
代码清单3-3和代码清单3-4中程序的主要区别是线程对象生成和初始化的方法不同。后者中的MyThread
对象指针数组用于按序(第24行)分配和初始化所需线程。使用指针使得在第25行和第29行中强制使用箭头符号。
隐式线程创建
偶尔也会发生这种情况,亦即需要执行一个任务但是并不需要显式地创建、运行一个QThread
对象并等待其结束。Qt的QtConcurrent
名字空间中的一组函数提供此功能。语法如下:
当调用静态QtConcurrent﹕﹕run
函数时,使用一个独立的线程执行提供的函数。该线程从Qt管理且预分配的线程池中选取,可以消除在程序执行中启动一个线程的操作系统开销。QThreadPool
类负责此任务,3.8节将会进行详细讨论。
使用QtConcurrent﹕﹕run
函数时有三个问题。
如何传递参数:由于没有对象可以在线程调用前初始化,因此用于传递参数的方法是将其作为QtConcurrent﹕﹕run
的参数,这些参数被拷贝并传到线程执行的函数中。这也防止函数改变原始参数(除非是传址调用)。
如何检测线程结束:QtConcurrent﹕﹕run
函数返回一个QFuture
对象的引用,可以用来检测执行状态,甚至可以等待线程结束,所以可以进行轮训和等待的检测。一些有用的函数如下。
isStarted()
:如果线程已经开始执行则返回真。如果在QThreadPool
中没有可用线程,则线程可能会推迟执行。
isRunning()
:如果线程正在运行则返回真。
isFinished()
:如果线程已经完成执行则返回真。
如何收集函数结果:QFuture
是一个模板类,它允许检索函数返回值,如下例所示。
让我们考虑一个MD5哈希计算程序,可以对每个参数指定的文件输出一个16字节的校验和。代码清单3-5中每个MD5的计算都是并发执行的。
代码清单3-5中示例的关键在于:
第5行:md5
函数中头文件的inlcude
语句。
第6、7行:Qt类中头文件的include
语句。
第13行:N是文件个数。
第14行:问了访问每个线程运行结果的QFuture<string>
数组对象。
第15行:每个文件在处理前都加载到内存中。指针指向每个使用的块。
第25~27行:在分配缓冲区前检查文件大小(第29行)
第31行:由于md5函数处理的数据类型是string,因此程序对二进制文件可能会生成错误结果。终止缓冲区。
第34行:通过一个独立线程调用md5函数。额外的开销大小依赖可用的线程数量。
第43行:利用QtConcurrent﹕﹕run返回的QFuture对象输出md5函数返回的长度为16的字符串。
3.2.4 在线程间共享数据
图3-3展示了共享全局数据可以简化线程对公共数据池的访问。但是这里有个前提条件,必须是只读访问。否则,必须采用特殊机制来保证每个时刻仅有一个线程写入共享数据,没有其他线程在同一时刻写入数据。当有线程写入时,读取操作必须被挂起,否则将会获取一个不一致的状态。通常情况下仅将写入操作设置为原子操作(不可分、不可中断)是不够的。可能还需要在状态改变的期间内对象或者数据结构加锁(即,完全不可访问)。
作为一个例子,考虑操作同一个银行账户对象的两个线程。一个线程尝试取钱并支付账单,另一个尝试存入月度工资。假设这个账户表示为一个简单的数据结构,包括一个字符串(所有人姓名)以及一个浮点数(账户资金),这样两个线程同时运行的一种可能的输出结果如图3-4所示。需要强调的是,这只是一种可能的输出结果,也能账户被正确地更新。问题是没有方法来预测按照这种方式维护账户的结果。
这只是一个读写问题的简单例子,3.5.4节将要介绍更多内容。
另一个说明对共享数据没有读写访问限制的副作用的例子如代码清单3-6所示。这种现象称为竞争条件,线程访问同一数据的竞争。更为正规的术语定义是程序中事件相对时序依赖导致的异常程序行为。换言之,如果存在竞争条件,对同样的输入而言程序的输出不一定永远保持一致。
代码清单3-6所示的程序接受子线程个数(N)以及对全局变量的操作次数(runs)作为参数,然后执行全局计数。当所有线程执行完后(通过第39行和第40行的循环检查),主线程输出计数器最终的数值(第42行),该值应为N*runs。这里是一个运行实例:
令人惊讶的是,结果并不是期望的那样。首先,所有的消息都被混合,由于所有线程竞争输出到控制台。其次,最后一行输出的最终结果并非如所期望的那样是1000。这是因为线程在没有同步的情况下更新计数器数值,增加和存储一个不是最新数值的数据(代码清单3-6的第21行)。
但是,实践中竞争条件十分难以探测和修复,由于多线程程序的输出严重依赖时序,而不仅仅是输入数据。在代码清单3-6所示的示例中,时序的影响是强制线程在更新共享数据时(第20行)挂起。否则,可能会在调度程序分配的时间槽中适配每个线程的运行,从而隐藏了问题。
接下来的几节将介绍在共享内存编程中最常用的加锁机制。另外,也可以不使用阻塞或对数据项和结构加锁的方法来消除竞争条件,但我们将看到,这并不是一个简单任务。