《多核与GPU编程:工具、方法及实践》----3.2 线程

简介: 线程可以被认为是轻量级进程。更精确的定义是线程是一个执行路径,亦即一个指令序列,可以被操作系统作为整体单元进行管理调度。一个进程中可以有多个线程。 线程可以减轻原有生成进程机制中的开销,仅需要拷贝基本的数据,即运行栈。

本节书摘来自华章出版社《多核与GPU编程:工具、方法及实践》一书中的第3章,第3.2节, 作 者 Multicore and GPU Programming: An Integrated Approach[阿联酋]杰拉西莫斯·巴拉斯(Gerassimos Barlas) 著,张云泉 贾海鹏 李士刚 袁良 等译, 更多章节内容可以访问云栖社区“华章计算机”公众号查看。

3.2 线程

3.2.1 线程的定义

线程可以被认为是轻量级进程。更精确的定义是线程是一个执行路径,亦即一个指令序列,可以被操作系统作为整体单元进行管理调度。一个进程中可以有多个线程。

线程可以减轻原有生成进程机制中的开销,仅需要拷贝基本的数据,即运行栈。由于线程包括被调用函数的动态框架(或动态记录),因此运行栈不能被多个线程共享。共享栈意味着控制权可能返回一个与调用线程不同的位置。

当一个进程的主线程(初始线程)生成一个新线程时,最终的内存布局十分类似于图3-3。这里父线程和子线程的关系也是如此。

保存和拷贝进程映像的额外开销使得通过创建进程来实现并发性的方法成本非常高。利用分离的进程也有一定优势,例如,提供增强的内存保护,或者是更为有弹性的调度,但是总体来说劣势大于优势。


8903a2ac124acb295138f75c82dcb43818f1b48a

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中等价的线程方法。


ed248d051f57d0c17a89af8d335b23732a6d5529

除了第2行中加载合适的类定义外,表3-2中还有两个关键之处:

第14行生成(并启动)一个线程实例,方法是提供一个无返回值和参数的函数作为子线程执行的入口点。

在实践中通常推荐父线程结束之前要等待子线程运行结束(或者强制结束它们)。这样可以保证资源的释放,文件的关闭等。这些功能在第16行通过调用join等待方法实现。

问题是目前大多数编译器仅仅支持部分C++ 11中的线程特征。因此,本章以Qt库为例研究线程以及为了完成同一个目标而使得其协调工作的机制。Qt库是一个跨平台库,除了为管理线程提供一组易于使用的类之外,它还提供UI开发、数据库(DB)互联、网络和可扩展标记语言(XML)处理等。Qt具有开源库和用于需要闭源开发的商业版。目前Qt可以运行在Windows、Mac OS X、Linux和一部分嵌入式操作系统上,提供程序部署的便利。

Qt管理线程的方式类似于Java:线程通过QThread类的子类实例来产生。run方法提供线程的入口点。例如:


80d5a4778fdc0fa35270d053433207cddbb9438e

根据上述类型定义,可以通过下面的语句来启动一个新的线程:


1b6a5628a95679d0484eba55435c33de5f4bab94

注意,run函数并不是直接被调用,而是一个继承的start函数首先创建一个OS线程,

并由该线程调用run方法。这与C++ 11中的线程类操作方法不同,这里QThread对象的创建并不意味着线程的产生,而必须要显式调用start方法来生成线程。

通过调用wait函数,可以使得主线程在结束前等待子线程完成。


1eb94234d923c84d8294684544baf92b9a77256c

为了使线程执行一个特定的任务,特别是当生成多个子线程时更是如此,有三种选择。

使用全局变量:违反了封装性原理,容易导致程序软件错误。

创建多个QThread子类:当需要大量线程时并不实用。

使用线程对象的数据成员:这是推荐的方法,线程对象必须在调用start前初始化,典型的方法是通过构造函数,例如:


c4947f1dedc99bb33f4d60921e23bcff83b43fe1


679fa3229bf6f258b687b0a09f87684abb7d1202

在Qt中完成线程创建和初始化之后,代码清单3-4是一个生成可变数量线程的程序,这些线程从零开始顺序编号。线程数量通过命令行控制。每个线程输出一条简短的消息然后退出:


b91f66e1101a37978f1647952b838756da9ed81e

代码清单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的计算都是并发执行的。


6b52307818ebde3f5035e43b0464e3c664f7fc05


f5fc13f1b212958d7203cf17539efe0ae4e1da66

代码清单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所示。需要强调的是,这只是一种可能的输出结果,也能账户被正确地更新。问题是没有方法来预测按照这种方式维护账户的结果。


1dc4701cb5b96ec0e0fb2655e273d9c946c1d658

这只是一个读写问题的简单例子,3.5.4节将要介绍更多内容。

另一个说明对共享数据没有读写访问限制的副作用的例子如代码清单3-6所示。这种现象称为竞争条件,线程访问同一数据的竞争。更为正规的术语定义是程序中事件相对时序依赖导致的异常程序行为。换言之,如果存在竞争条件,对同样的输入而言程序的输出不一定永远保持一致。


a9e39f3c0bfe3713f87825507cf07cb8dca1d904

代码清单3-6所示的程序接受子线程个数(N)以及对全局变量的操作次数(runs)作为参数,然后执行全局计数。当所有线程执行完后(通过第39行和第40行的循环检查),主线程输出计数器最终的数值(第42行),该值应为N*runs。这里是一个运行实例:


c9b68e5218e8c62383c1a998b536d9e2a416903b

令人惊讶的是,结果并不是期望的那样。首先,所有的消息都被混合,由于所有线程竞争输出到控制台。其次,最后一行输出的最终结果并非如所期望的那样是1000。这是因为线程在没有同步的情况下更新计数器数值,增加和存储一个不是最新数值的数据(代码清单3-6的第21行)。

但是,实践中竞争条件十分难以探测和修复,由于多线程程序的输出严重依赖时序,而不仅仅是输入数据。在代码清单3-6所示的示例中,时序的影响是强制线程在更新共享数据时(第20行)挂起。否则,可能会在调度程序分配的时间槽中适配每个线程的运行,从而隐藏了问题。

接下来的几节将介绍在共享内存编程中最常用的加锁机制。另外,也可以不使用阻塞或对数据项和结构加锁的方法来消除竞争条件,但我们将看到,这并不是一个简单任务。

相关实践学习
部署Stable Diffusion玩转AI绘画(GPU云服务器)
本实验通过在ECS上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。
相关文章
|
1天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
1天前
|
Java UED
Java中的多线程编程基础与实践
【10月更文挑战第35天】在Java的世界中,多线程是提升应用性能和响应性的利器。本文将深入浅出地介绍如何在Java中创建和管理线程,以及如何利用同步机制确保数据一致性。我们将从简单的“Hello, World!”线程示例出发,逐步探索线程池的高效使用,并讨论常见的多线程问题。无论你是Java新手还是希望深化理解,这篇文章都将为你打开多线程的大门。
|
9天前
|
安全 程序员 API
|
2天前
|
安全 Java 编译器
Java多线程编程的陷阱与最佳实践####
【10月更文挑战第29天】 本文深入探讨了Java多线程编程中的常见陷阱,如竞态条件、死锁、内存一致性错误等,并通过实例分析揭示了这些陷阱的成因。同时,文章也分享了一系列最佳实践,包括使用volatile关键字、原子类、线程安全集合以及并发框架(如java.util.concurrent包下的工具类),帮助开发者有效避免多线程编程中的问题,提升应用的稳定性和性能。 ####
18 1
|
5天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
6天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
32 4
|
6天前
|
消息中间件 供应链 Java
掌握Java多线程编程的艺术
【10月更文挑战第29天】 在当今软件开发领域,多线程编程已成为提升应用性能和响应速度的关键手段之一。本文旨在深入探讨Java多线程编程的核心技术、常见问题以及最佳实践,通过实际案例分析,帮助读者理解并掌握如何在Java应用中高效地使用多线程。不同于常规的技术总结,本文将结合作者多年的实践经验,以故事化的方式讲述多线程编程的魅力与挑战,旨在为读者提供一种全新的学习视角。
29 3
|
7天前
|
安全 Java 调度
Java中的多线程编程入门
【10月更文挑战第29天】在Java的世界中,多线程就像是一场精心编排的交响乐。每个线程都是乐团中的一个乐手,他们各自演奏着自己的部分,却又和谐地共同完成整场演出。本文将带你走进Java多线程的世界,让你从零基础到能够编写基本的多线程程序。
20 1
|
11天前
|
缓存 Java 调度
Java中的多线程编程:从基础到实践
【10月更文挑战第24天】 本文旨在为读者提供一个关于Java多线程编程的全面指南。我们将从多线程的基本概念开始,逐步深入到Java中实现多线程的方法,包括继承Thread类、实现Runnable接口以及使用Executor框架。此外,我们还将探讨多线程编程中的常见问题和最佳实践,帮助读者在实际项目中更好地应用多线程技术。
19 3
|
13天前
|
监控 安全 Java
Java多线程编程的艺术与实践
【10月更文挑战第22天】 在现代软件开发中,多线程编程是一项不可或缺的技能。本文将深入探讨Java多线程编程的核心概念、常见问题以及最佳实践,帮助开发者掌握这一强大的工具。我们将从基础概念入手,逐步深入到高级主题,包括线程的创建与管理、同步机制、线程池的使用等。通过实际案例分析,本文旨在提供一种系统化的学习方法,使读者能够在实际项目中灵活运用多线程技术。

热门文章

最新文章

下一篇
无影云桌面