C++并发编程实战(第2版)

978-7-115-57355-1
作者: 安东尼·威廉姆斯(Anthony Williams)
译者: 吴天明
编辑: 胡俊英

图书目录:

详情

这是一本介绍C++并发和多线程编程的深度指南。本书从C++标准程序库的各种工具讲起,介绍线程管控、在线程间共享数据、并发操作的同步、C++内存模型和原子操作等内容。同时,本书还介绍基于锁的并发数据结构、无锁数据结构、并发代码,以及高级线程管理、并行算法函数、多线程应用的测试和除错。本书还通过附录及线上资源提供丰富的补充资料,以帮助读者更完整、细致地掌握C++并发编程的知识脉络。 本书适合需要深入了解C++多线程开发的读者,以及使用C++进行各类软件开发的开发人员、测试人员,还可以作为C++线程库的参考工具书。

图书摘要

版权信息

书名:C++并发编程实战(第2版)

ISBN:978-7-115-57355-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


著    [英] 安东尼•威廉姆斯(Anthony Williams)

译    吴天明

责任编辑 胡俊英

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Original English language edition, entitled C++ Concurrency in Action, 2nd Edition by Anthony Williams published by Manning Publications Co., 209 Bruce Park Avenue, Greenwich, CT 06830. Copyright ©2019 by Manning Publications Co.

Simplified Chinese-language edition copyright ©2021 by Posts & Telecom Press. All rights reserved.

本书中文简体字版由Manning Publications Co.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

版权所有,侵权必究。


这是一本介绍C++并发和多线程编程的深度指南。本书从C++标准程序库的各种工具讲起,介绍线程管控、在线程间共享数据、并发操作的同步、C++内存模型和原子操作等内容。同时,本书还介绍基于锁的并发数据结构、无锁数据结构、并发代码,以及高级线程管理、并行算法函数、多线程应用的测试和除错。本书还通过附录及线上资源提供丰富的补充资料,以帮助读者更完整、细致地掌握C++并发编程的知识脉络。

本书适合需要深入了解C++多线程开发的读者,以及使用C++进行各类软件开发的开发人员、测试人员,还可以作为C++线程库的参考工具书。


安东尼·威廉姆斯(Anthony Williams)来自英国,他是开发者、顾问、培训师,积累了超过20年的C++开发经验。从2001年起,他成为英国标准协会 C++标准专家组的成员,独立编写或参与编写了许多 C++标准委员会的文件,使 C++11 标准引入了线程库。现在,他继续致力于开发 C++的新特性,以增强 C++并发工具集的功能,这两者都遵循C++标准和提案。他还扩展了 C++线程库,实现了工具“just::thread Pro”(Just Software Solutions公司的产品)。


“目前,对C++11多线程工具的探讨,本书是有关图书中较好的一本,而且在未来相当一段时期内还会如是。”

—— Effective CMore Effective C的作者Scott Meyers

“本书使C++多线程不那么晦涩难懂。”

——红帽(Red Hat)公司首席高级维护工程师Rick Wagner

“阅读本书让我头痛,然而痛有所得,好事。”

——Ingersoll Rand公司的Joshua Heyer

“Anthony示范了如何将并发用于实践。”

—— OR/2 Limited公司的Roger Orr

“一份关于C++新并发标准的指南,缜密而有深度,由标准制定者亲自编写。”

——瑞士信贷银行总监Neil Horlock

“任何严肃的C++开发者都应该读懂这本重要的书。”

——Pace公司开发总监Jamie Allsop博士

“本书是学习原子操作、内存模型和C++并发的上佳之选。”

——C++标准委员会成员,OpenMP首席执行官Michael Wong


离开大学后的第一份工作,我就与多线程的概念和代码打交道。那时,我们的开发组要实现一个数据处理的应用,需要将传入的数据记录插入数据库。这些数据记录数量庞大,各条记录均没有相互依赖,并且需经过一定步骤的处理才能插入数据库。我们的计算机配备了10个CPU的UltraSPARC处理器,为了完全利用这些算力,我们将代码编写成多线程的形式来运行,每个线程处理自己接收的数据集。我们编写了C++代码,采用了POSIX线程,也犯了不少错误,毕竟,对我们全体成员而言,多线程前所未见。尽管如此,我们最后还是完工了。正是在该项目中,我第一次知悉C++标准委员会和当时新发布的C++标准,我对多线程和并发的浓厚兴趣自此而起。

尽管在别人眼里,这项技术既困难又复杂,还是诸多问题的根源,而我却视之为有效的工具,它使代码能充分利用现有的硬件资源,更快地运行。一些计算机操作耗时长(如I/O)进而导致延迟。为此,我后来学会了即便在单核硬件环境下,依然能利用多线程来减少这些延迟,从而改进应用软件的性能,提高响应速度。我还学习到在操作系统层面上如何进行多线程运作,以及对于英特尔的CPU如何处理任务切换。

同时,得益于对C++的喜爱,我有机会与C/C++用户协会(Association of C and C++ Users,ACCU)交流接触,后来还与英国标准协会(British Standards Institution,BSI)的C++标准专家组和Boost社群交流。出于兴趣,我跟进了Boost线程库的初期开发,当原本的开发者中止了相关工作时,我抓住机会,立即接手[1]。从2007年到2011年,我担任了Boost线程库的主力开发者和维护者,不过后来我将这项工作移交他人负责。

随着 C++标准委员会工作重心的转移——从修补现行标准的缺陷,转变为C++11标准编写提案(新标准最初命名为“C++0x”,本来希望能于2009年完成,但最终到了2011年才发布,于是官方将其正式命名为“C++11”),我更深入地参与了英国标准协会的工作,并开始起草自己的提案。等到多线程被明确提上议程,我马上全力以赴,独自起草并参与编写了许多与多线程和并发相关的提案,这些提案塑造了C++标准的一部分。我持续参与了C++标准委员会并发小组的工作,包括对C++17标准进行改进,制定并发技术规约(Concurrency Technical Specification),以及编写关于C++未来演化发展的提案等。关于计算机,我的兴趣主要有两个——C++和多线程,有幸能将它们以图书的形式结合,我倍感自豪。

本书基于我研习C++和多线程的全部经验,旨在指导其他C++开发者安全且高效地使用C++线程库和并发技术规约。我也希望将自己对这个主题的兴趣和热忱融入本书,并传递给读者。

——安东尼·威廉姆斯

(Anthony Williams)

[1] 译者注:Boost线程库第1版的作者是William E. Kempf,第2版的作者即本书作者。


首先,我要对爱妻Kim大大地说一声“谢谢”,感谢在本书编写的过程中,她对我的全部关爱和支持。本书第1版出版前的4年中,写书占用了我绝大部分的业余时间,而第2版的编写需要再次投入大量时间。没有我妻子的支持和理解,我不可能完成本书。

其次,我要感谢Manning出版社的团队,没有他们的辛劳,你现在不可能读到本书。

我也要感谢C++标准委员会的其他成员,他们就多线程功能撰写了不少文件;还要感谢对这些文件发表意见并在委员会会议上讨论这些文件的人士。也有人以其他形式提供帮助,让C++11、C++14和C++17标准能够引入对多线程和并发特性的支持,使并发技术规约得以制定,并令其成型,我同样要感谢他们。

最后,我要感谢Jamie Allsop博士、Peter Dimov、Howard Hinnant、Rick Molloy、Jonathan Wakely和Russel Winder博士,他们给出的建议极大地完善了本书;尤其要感谢Russel,他对本书做了细致的审阅;也特别感谢Frédéric Flayol,他作为技术审校,在本书的出版过程中,尽心竭力地查验原稿最终版的全部内容,剔除明显的错误。另外,我要感谢本书第2版的专家审核小组:Al Norman、Andrei de Araújo Formiga、Chad Brewbaker、Dwight Wilkins、Hugo Filipe Lopes、Vieira Durana、Jura Shikin、Kent R. Spillner、Maria Gemini、Mateusz Malenta、Maurizio Tomasi、Nat Luengnaruemitchai、Robert C. Green II、Robert Trausmuth、Sanchir Kartiev、Steven Parr。我还要感谢所有细心的读者为本书指出了错误,并提醒我某些内容需要详加阐释。


译事三难:信、达、雅。而这恰恰是对翻译工作的要求,技术类图书的翻译工作也不例外。

有不少程序员将全部精力倾注到计算机技术之上,但对于外语学习的投入却稍显不足。本人有幸承担本书的翻译任务,深知所肩负的责任之重,希望与大家携手跨越语言的障碍,共同钻研前沿新知。本人坚信,语言转换不应当增加读者学习和理解的负担。如果存在理解上的障碍,则须源于技术本身,而非因语言表达而生。本人为此花费了不少精力,反复推敲语言表达的细节,竭尽所能使其流畅、通顺、易读。

本书作者是并发编程的世界级专家,经验丰富、见多识广。对于本书涉及的各种技术难点,他都驾轻就熟。反之,本书的不少读者仍在学习的艰途上步履蹒跚,若要跟上“巨人”的步伐,恐怕比较吃力。原书中的某些要点在作者眼中理所当然,并没有予以详细介绍,而这些内容很可能令人费解。为此,本人特意插入不少译者注,通过解释细节以补充正文的完整逻辑,帮助读者轻松理解。此外,虽然原书定位的目标读者是已具备相当经验的C++程序员,但是很多C++新手和跨专业的开发者也会对多线程感兴趣。因此,本人还补注了一些计算机软硬件知识和对C++新特性的说明,以尽量降低本书的学习难度。

——吴天明

2021年10月


首先感谢父母的默默支持,家人永远是我坚强的后盾。

感谢先师唐鸿光先生,您传授的对英语文献的研读分析方法让我非常受用;感谢我的硕士论文导师David J. Thornley博士,他总能用三言两语道破技术写作的精髓,让我受益无穷;感谢博览网的李建忠老师,一针见血地指明图书翻译的要领。

感谢我的好朋友——卷积传媒的高博先生,正是他的多次举荐促成了本书的合作事宜;感谢人民邮电出版社信息技术分社的陈冀康社长和编辑胡俊英女士,他们专业的出版服务引领我逐步前进。

感谢中南大学计算机学院邝砾教授、北京电子科技学院的徐莉伟博士以及资深开发者沈晶晶女士,感谢你们细心审阅初稿,提出了宝贵的修改意见;感谢计算机专家叶敏娇博士、IT工程师胡晓文先生、@有个梨UGlee、林俞静先生以及PureCPP社区的一众成员,你们跟我深入探讨书中的诸多细节,为我解答技术疑难。

感谢纬因信息咨询有限公司副总经理蔡国贤先生、织点科技首席执行官梁翰君先生、伊索思能源中国区总经理肖扬先生、珠光汽车总裁劳俊杰先生、天壤智能市场总监徐艺女士以及珠海艺托邦科技的杨洪先生,你们给予我巨大的支援和帮助;还有素未谋面的网络好友——四狼,在疫情期间向我无偿提供了防护装备,不胜感激。

最后特别感谢来自中国香港的蔡怡小姐,感谢你辅助整理了大量文字,并对书稿进行了繁重而琐细的编排,正是得益于你的帮助,才让本书顺利面世。


本书是一份深度指南,内容是 C++新标准中涉及的并发与多线程功能,从std::thread、std::mutex和std::async的基本使用方法开始,一直到复杂的内存模型和原子操作。

第1~4章介绍C++标准程序库提供的各种工具,并说明如何使用。

第5章剖析C++内存模型和原子操作的底层核心细节,包括如何运用原子操作强制约束其他线程代码的执行顺序,这是本书入门部分的最后一章。

第6章和第7章开始进入高级主题,通过范例解释如何使用基础工具构建复杂的数据结构。第6章研究基于锁的并发数据结构,第7章分析无锁数据结构。

第8章继续高级主题,涉及多线程代码设计的指导原则、影响性能的各种因素,还有并行算法函数的实现范例。

第9章探讨高级线程管理,包括线程池、工作队列和中断线程。

第10章探讨C++17所支持的新引入的并行特性,它们以重载的形式实现了许多标准库算法函数。

第11章探讨测试和除错,包括错误的类型、定位错误的技法、如何测试等。

附录包含以下内容:附录A简单介绍新标准引入的与多线程相关的新特性;附录B是几个并发程序库之间的简要对比;附录C是消息传递程序库的实现细节,该库最先在第4章中提及;附录D是一份完整的C++线程库参考名录(作为电子资源配套提供,读者可从异步社区下载)。

如果你要用C++编写多线程代码,就应该阅读本书;如果你想使用C++标准程序库中的多线程工具,那么本书可作为基础指南;如果你要用到其他线程库,本书后面的章节也给出了指导原则和技巧,仍会让你获益。

我假设读者已具备了良好的C++实操知识,却不太熟悉C++的新特性。为此,附录A将补充相关内容。

如果读者以前没有编写过多线程代码,我建议按顺序从头到尾阅读本书。

假若读者之前没使用过C++11的新功能,那就需要先浏览一下附录A,再开始阅读正文,这将有助于透彻理解本书的代码示例。正文中已经标注出用到C++新特性的地方,尽管如此,一旦你遇到任何从未见过的内容,也可以随时翻查附录。

如果读者已经编写过多线程代码,并且经验丰富,前几章会让你知晓已经熟知的工具与新标准的C++工具是怎样对应的。倘若读者要进行任何底层工作,涉及原子变量,则第5章必不可少。为了确保读者真正熟知C++多线程编程的各种细节,例如异常安全(exception safety),那么,第8章值得好好学习。如果读者肩负某种特定的编码任务,索引和目录会帮你迅速定位到有关章节。

即便你已经掌握了C++线程库的使用方法,附录D(可从异步社区下载)依然有用,例如可供你查阅各个类和函数调用的精准细节。你也可以考虑时不时地回顾一下主要章节,或强化记忆某个特定的模型,或重温示例代码。

代码清单都采用等宽字体(示例:fixed-width font)以区分于普通文本。许多代码清单都附有代码注解,标记出重要的概念。在一些代码清单中,代码通过有编号的圆形标志与随后正文的解释相对应。

为了能够原封不动地使用本书中的代码,读者需要安装新近发布的C++编译器,以支持示例中C++11的语言特性(见附录A),另外还需要C++标准线程库。

我在编写本书的时候,g++、Clang++和Visual Studio发布的新版本全都实现了C++标准线程库的支持。它们同样支持附录列出的绝大部分语言特性,而未获支持的特性也将很快得到支持。

我的公司Just Software Solutions出售C++11标准线程库的完整实现[1],也出售符合并发技术规约的程序库实现。前者适用于好几个相对较旧的编译器,后者则适用于较新版本的Clang、GCC和Visual Studio,后者也可用于测试本书的代码。

Boost线程库[2]提供了一套API,这套API以C++11标准线程库的提案为依据,可以移植到许多平台上。本书的绝大多数示例代码稍作改动就能使用Boost线程库,这些改动包括将std::with全部替换为boost::and,以及用#include预处理指令包含恰当的头文件。Boost线程库内有少部分功能未获支持(如std::async)或者名字不同(如boost::unique_ future)。

[1] C++11标准线程库的just::thread实现。

[2] Boost C++程序库。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供配套资源,请在异步社区本书页面中单击“配套资源”,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

如果您是教师,希望获得教学配套资源,请在社区本书页面中直接联系本书的责任编辑。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线投稿(直接访问www.epubit.com/ selfpublish/submission即可)。

如果您所在的学校、培训机构或企业想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近 40 年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、人工智能、测试、前端、网络技术等。

异步社区

微信服务号


本章内容:

对C++程序员来说,现在是激动人心的新时代。初版的C++标准于1998年发布,历经13年,C++标准委员会对语言本身及其标准库做出重大修整。经过大幅度变革,新标准(以下简称C++11或C++0x)于2011年发布,使C++用起来更得心应手,事半功倍。委员会遵守“班车模式”的新式发布规则,每隔3年发布一版新的C++标准。目前,已经有两版标准依次发布——C++14和C++17,还有几份技术规约[1]作为C++标准的扩充。

C++11标准最重要的新特性之一是支持多线程。这是标准首次接纳原生语言层面的多线程应用,并在标准库中为之提供组件。这使得多线程C++程序的编写无须依赖平台专属的扩展(platform-specific extension),因而我们得以写出可移植的、行为确定的多线程代码。并且,新标准的发布正当其时:为改进应用程序的性能,程序员普遍日益寄望于并发技术,特别是多线程编程。以C++11为基础,委员会相继发布了C++14标准、C++17标准和一些技术规约,进一步为编写多线程程序提供支持。在这些技术规约中,其中一份针对并发特性的扩展,而针对并行特性的扩展另有一份,后者已被正式纳入C++17标准。

本书的主旨是介绍运用多线程编写C++并发程序,还有使之得以实现的C++特性和标准库工具。开宗明义,我会先解释自己所理解的并发和多线程;然后“走马观花”,分析应用程序为什么要采用并发技术,以及什么情况下不适合采用并发技术;接着,我们将大致了解C++如何支持并发特性;最后,以一个C++并发程序作为实例,结束本章。某些读者或许已具有开发多线程应用的经验,可以略过前面的章节。后续章节中,我们将学习更多实例,涉及范围更广,我们还会更深入地探究标准库工具。

那么,我所说的并发和多线程,确切的含义分别是什么呢?

按最简单、最基本的程度理解,并发(concurrency)是两个或多个同时独立进行的活动。并发现象遍布日常生活,我们时常接触:我们可以边走路边说话;或者,左右手同时做出不一样的动作;我们每个人也都可以独立行事——当我游泳时,你可以观看足球比赛;诸如此类。

若我们谈及计算机系统中的并发,则是指同一个系统中,多个独立活动同时进行,而非依次进行。这不足为奇。多年来,多任务操作系统可以凭借任务切换,让同一台计算机同时运行多个应用软件,这早已稀松平常,而高端服务器配备了多处理器,实现了“真并发”(genuine concurrency)。大势所趋,主流计算机现已能够真真正正地并行处理多任务,而不再只是制造并发的表象。

很久之前,大多计算机都仅有一个处理器,处理器内只有单一处理单元或单个内核,许多台式计算机至今依旧如此。这种计算机在同一时刻实质上只能处理一个任务,不过,每秒内,它可以在各个任务之间多次切换,先处理某任务的一小部分,接着切换任务,同样只处理一小部分,然后对其他任务如法炮制。于是,看起来所有任务都正在同时执行。因此其被称为任务切换。至此,我们谈及的并发都基于这种模式。由于任务飞速切换,我们难以分辨处理器到底在哪一刻暂停某个任务而切换到另一个。任务切换对使用者和应用软件自身都制造出并发的表象。由于是表象,因此对比真正的并发环境,当应用程序在进行任务切换的单一处理器环境下运行时,其行为可能稍微不同。具体而言,如果就内存模型(见第5章)做出不当假设,本来会导致某些问题,但这些问题在上述环境中却有可能不会出现。第10章将对此深入讨论。

多年来,配备了多处理器的计算机一直被用作服务器,它要承担高性能的计算任务;现今,基于一芯多核处理器(简称多核处理器)的计算机日渐普及,多核处理器也用在台式计算机上。无论是装配多个处理器,还是单个多核处理器,或是多个多核处理器,这些计算机都能真正并行运作多个任务,我们称之为硬件并发(hardware concurrency)。

图1.1所示为理想化的情景。计算机有两个任务要处理,将它们进行十等分。在双核机(具有两个处理核)上,两个任务在各自的核上分别执行。另一台单核机则切换任务,交替执行任务小段,但任务小段之间略有间隔。在图1.1中,单核机的任务小段被灰色小条隔开,它们比双核机的分隔条粗大。为了交替执行,每当系统从某一个任务切换到另一个时,就必须完成一次上下文切换(context switch),于是耗费了时间。若要完成一次上下文切换,则操作系统需保存当前任务的CPU状态和指令指针[2],判定需要切换到哪个任务,并为之重新加载CPU状态。接着,CPU有可能需要将新任务的指令和数据从内存加载到缓存,这或许会妨碍CPU,令其无法执行任何指令,加剧延迟。

图1.1 两种并发方式:双核机上的并发执行与单核机上的任务切换

尽管多处理器或多核系统明显更适合硬件并发,不过有些处理器也能在单核上执行多线程。真正需要注意的关键因素是硬件支持的线程数(hardware threads),也就是硬件自身真正支持同时运行的独立任务的数量。即便是真正支持硬件并发的系统,任务的数量往往容易超过硬件本身可以并行处理的数量,因而在这种情形下任务切换依然有用。譬如,常见的台式计算机能够同时运行数百个任务,在后台进行各种操作,表面上却处于空闲状态。正是由于任务切换,后台任务才得以运作,才容许我们运行许多应用软件,如文字处理软件、编译器、编辑软件,以及浏览器等。图1.2展示了双核机上4个任务的相互切换,这同样是理想化的情形,各个任务都被均匀切分。实践中,许多问题会导致任务切分不均匀或调度不规则。我们将在第8章探究影响并发代码性能的因素,将解决上述某些问题。

图1.2 4个任务在双核机上切换

本书涉及的技术、函数和类适用于各种环境:无论负责运行的计算机是配备了单核单处理器,还是多核多处理器;无论其并发功能如何实现,是凭借任务切换,还是真正的硬件并发,一概不影响使用。然而,也许读者会想到,应用软件如何充分利用并发功能,很大程度上取决于硬件所支持的并发任务数量。我们将在第8章讲述设计并发的C++代码的相关议题,也会涉及这点。

设想两位开发者要共同开发一个软件项目。假设他们处于两间独立的办公室,而且各有一份参考手册,则他们可以静心工作,不会彼此干扰。但这令交流颇费周章:他们无法一转身就与对方交谈,遂不得不借助电话或邮件,或是需起身离座走到对方办公室。另外,使用两间办公室有额外开支,还需购买多份参考手册。

现在,如果安排两位开发者共处一室,他们就能畅谈软件项目的设计,也便于在纸上或壁板上作图,从而有助于交流设计的创意和理念。这样,仅有一间办公室要管理,并且各种资源通常只需一份就足够了。但缺点是,他们恐怕难以集中精神,共享资源也可能出现问题。

这两种安排开发者的办法示意了并发的两种基本方式。一位开发者代表一个线程,一间办公室代表一个进程。第一种方式采用多个进程,各进程都只含单一线程,情况类似于每位开发者都有自己的办公室;第二种方式只运行单一进程,内含多个线程,正如两位开发者同处一间办公室。我们可以随意组合这两种方式,掌控多个进程,其中有些进程包含多线程,有些进程只包含单一线程,但基本原理相同。接着,我们来简略看看应用软件中的这两种并发方式。

1.多进程并发

在应用软件内部,一种并发方式是,将一个应用软件拆分成多个独立进程同时运行,它们都只含单一线程,非常类似于同时运行浏览器和文字处理软件。这些独立进程可以通过所有常规的进程间通信途径相互传递信息(信号、套接字、文件、管道等),如图1.3所示。这种进程间通信普遍存在短处:或设置复杂,或速度慢,甚至二者兼有,因为操作系统往往要在进程之间提供大量防护措施,以免某进程意外改动另一个进程的数据;还有一个短处是运行多个进程的固定开销大,进程的启动花费时间,操作系统必须调配内部资源来管控进程,等等。

图1.3 两个进程并发运行并相互通信

进程间通信并非一无是处:通常,操作系统在进程间提供额外保护和高级通信机制。这就意味着,比起线程,采用进程更容易编写出安全的并发代码。某些编程环境以进程作为基本构建单元,其并发效果确实一流,譬如为Erlang编程语言准备的环境。

运用独立的进程实现并发,还有一个额外优势——通过网络连接,独立的进程能够在不同的计算机上运行。这样做虽然增加了通信开销,可是只要系统设计精良,此法足以低廉而有效地增强并发力度,改进性能。

2.多线程并发

另一种并发方式是在单一进程内运行多线程。线程非常像轻量级进程:每个线程都独立运行,并能各自执行不同的指令序列。不过,同一进程内的所有线程都共用相同的地址空间,且所有线程都能直接访问大部分数据。全局变量依然全局可见,指向对象或数据的指针和引用能在线程间传递。尽管进程间共享内存通常可行,但这种做法设置复杂,往往难以驾驭,原因是同一数据的地址在不同进程中不一定相同。图1.4展示了单一进程内的两个线程借共享内存通信。

图1.4 单一进程内的两个线程借共享内存通信

我们可以启用多个单线程的进程并在进程间通信,也可以在单一进程内发动多个线程而在线程间通信,后者的额外开销更低。因此,即使共享内存带来隐患,主流语言大都青睐以多线程的方式实现并发功能,当中也包括C++。再加上C++本身尚不直接支持进程间通信,所以采用多进程的应用软件将不得不依赖于平台专属的应用程序接口(Application Program Interface,API)。鉴于此,本书专攻多线程并发,后文再提及并发,便假定采用多线程实现。

提到多线程代码,还常常用到一个词——并行。接下来,我们来厘清并发与并行的区别。

就多线程代码而言,并发与并行(parallel)的含义很大程度上相互重叠。确实,在多数人看来,它们就是相同的。二者差别甚小,主要是着眼点和使用意图不同。两个术语都是指使用可调配的硬件资源同时运行多个任务,但并行更强调性能。当人们谈及并行时,主要关心的是利用可调配的硬件资源提升大规模数据处理的性能;当谈及并发时,主要关心的是分离关注点或响应能力。这两个术语之间并非泾渭分明,它们之间仍有很大程度的重叠,知晓这点会对后文的讨论有所帮助,两者的范例将穿插本书。

至此,我们已明晰并发的含义,现在来看看应用软件为什么要使用并发技术。

应用软件使用并发技术的主要原因有两个:分离关注点与性能提升。据我所知,实际上这几乎是仅有的用到并发技术的原因。如果寻根究底,任何其他原因都能归结为二者之一,也可能兼有(除非硬要说“因为我就是想并发”)。

一直以来,编写软件时,分离关注点(separation of concerns)几乎总是不错的构思:归类相关代码,隔离无关代码,使程序更易于理解和测试,因此所含缺陷很可能更少。并发技术可以用于隔离不同领域的操作,即便这些不同领域的操作需同时进行;若不直接使用并发技术,我们将不得不编写框架做任务切换,或者不得不在某个操作步骤中,频繁调用无关领域的代码。

考虑一个带有用户界面的应用软件,需要由CPU密集处理,如台式计算机上的DVD播放软件。本质上,这个应用软件肩负两大职责:既要从碟片读取数据,解码声音影像,并将其及时传送给图形硬件和音效硬件,让DVD顺畅放映,又要接收用户的操作输入,譬如用户按“暂停”“返回选项单”“退出”等键。假若采取单一线程,则该应用软件在播放过程中,不得不定时检查用户输入,结果会混杂播放DVD的代码与用户界面的代码。改用多线程就可以分离上述两个关注点,一个线程只负责用户界面管理,另一个线程只负责播放DVD,用户界面的代码和播放DVD的代码遂可避免紧密纠缠。两个线程之间还会保留必要的交互,例如按“暂停”键,不过这些交互仅仅与需要立即处理的事件直接关联。

如果用户发送了操作请求,而播放DVD线程正忙,无法马上处理,那么在请求被传送到该线程的同时,代码通常能令用户界面线程立刻做出响应,即便只是显示光标或提示“请稍候”。这种方法使得应用软件看起来响应及时。类似地,某些必须在后台持续工作的任务,则常常交由独立线程负责运行,例如,让桌面搜索应用软件监控文件系统变动。此法基本能大幅简化各线程的内部逻辑,原因是线程间交互得以限定于代码中可明确辨识的切入点,而无须将不同任务的逻辑交错散置。

这样,线程的实际数量便与CPU既有的内核数量无关,因为用线程分离关注点的依据是设计理念,不以增加运算吞吐量为目的。

多处理器系统已存在数十年,不过一直以来它们大都只见于巨型计算机、大型计算机和大型服务器系统。但是,芯片厂家日益倾向设计多核芯片,在单一芯片上集成2个、4个、16个或更多处理器,从而使其性能优于单核芯片。于是,多核台式计算机日渐流行,甚至多核嵌入式设备亦然。不断增强的算力并非得益于单个任务的加速运行,而是来自多任务并行运作。从前,处理器更新换代,程序自然而然随之加速,程序员可以“坐享其成,不劳而获”。但现在,正如Herb Sutter指出的“免费午餐没有了![3]”,软件若要利用增强的这部分算力,就必须设计成并发运行任务。所以程序员必须警觉,特别是那些踌躇不前、忽视并发技术的同业,有必要注意熟练掌握并发技术,储备技能。

增强性能的并发方式有两种。第一种,最直观地,将单一任务分解成多个部分,各自并行运作,从而节省总运行耗时。此方式即为任务并行。尽管听起来浅白、直接,但这却有可能涉及相当复杂的处理过程,因为任务各部分之间也许存在纷繁的依赖。任务分解可以针对处理过程,调度某线程运行同一算法的某部分,另一线程则运行其他部分;也可以针对数据,线程分别对数据的不同部分执行同样的操作,这被称为数据并行。

易于采用上述并行方式的算法常常被称为尴尬并行[4]算法。其含义是,将算法的代码并行化实在简单,甚至简单得会让我们尴尬,实际上这是好事。我还遇见过用其他术语描述这类算法,叫“天然并行”(naturally parallel)与“方便并发”(conveniently concurrent)。尴尬并行算法具备的优良特性是可按规模伸缩——只要硬件支持的线程数目增加,算法的并行程度就能相应提升。这种算法是成语“众擎易举”的完美体现。算法中除尴尬并行以外的部分,可以另外划分成一类,其并行任务的数目固定(所以不可按规模伸缩)。第8章和第10章将涵盖按线程分解任务的方法。

第二种增强性能的并发方式是利用并行资源解决规模更大的问题。例如,只要条件适合,便同时处理2个文件,或者10个,甚至20个,而不是每次1个。同时对多组数据执行一样的操作,实际上是采用了数据并行,其着眼点有别于任务并行。采用这种方式处理单一数据所需的时间依旧不变,而同等时间内能处理的数据相对更多。这种方式明显存在局限,虽然并非任何情形都会因此受益,但数据吞吐量却有所增加,进而带来突破。例如,若能并行处理视频影像中不同的区域,就会提升视频处理的解析度。

知道何时避免并发,与知道何时采用并发同等重要。归根结底,不用并发技术的唯一原因是收益不及代价。多数情况下,采用了并发技术的代码更难理解,编写和维护多线程代码会更劳心费神,并且复杂度增加可能导致更多错误。编写正确运行的多线程代码需要额外的开发时间和相关维护成本,除非潜在的性能提升或分离关注点而提高的清晰度值得这些开销,否则别使用并发技术。

此外,性能增幅可能不如预期。线程的启动存在固有开销,因为系统须妥善分配相关的内核资源和栈空间,然后才可以往调度器添加新线程,这些都会耗费时间。假如子线程上运行的任务太快完成,处理任务本身的时间就会远短于线程启动的时间,结果,应用程序的整体性能很可能还不如完全由主线程直接执行任务的性能。

再者,线程是一种有限的资源。若一次运行太多线程,便会消耗操作系统资源,可能令系统整体变慢。而且,由于每个线程都需要独立的栈空间[5],如果线程太多,就可能耗尽所属进程的可用内存或地址空间。在采用扁平模式内存架构的32位进程中,可用的地址空间是4GB,这很成问题:假定每个线程栈的大小都是1MB(这个大小常见于许多系统),那么4096个线程即会把全部地址空间耗尽,使得代码、静态数据和堆数据无地立足。尽管64位系统(或指令集宽度更大的系统)对地址空间的直接限制相对宽松,但其资源依旧有限,运行太多线程仍将带来问题。虽然线程池可用于控制线程数量(见第9章),但也非万能妙法,它自身也有局限。

假设,在服务器端,客户端/服务器(Client/Server,C/S)模式的应用程序为每个连接发起一个独立的线程。如果只有少量连接,这尚能良好工作。不过,请求量巨大的服务器需要处理的连接数目庞大,若采用同样的方法,就会发起过多线程而很快耗尽系统资源。针对这一情形,如果要达到最优性能,便须谨慎使用线程池(见第9章)。

最后,运行的线程越多,操作系统所做的上下文切换就越频繁,每一次切换都会减少本该用于实质工作的时间。结果,当线程数目达到一定程度时,再增加新线程只会降低应用软件的整体性能,而不会提升性能。正因如此,若读者意在追求最优系统性能,则须以可用的硬件并发资源为依据(或反之考虑其匮乏程度),调整运行线程的数目。

为了提升性能而使用并发技术,与其他优化策略相仿:它极具提升应用程序性能的潜力,却也可能令代码复杂化,使之更难理解、更容易出错。所以,对于应用程序中涉及性能的关键部分,若其具备提升性能的潜力,收效可观,才值得为之实现并发功能。当然,如果首要目标是设计得清楚明晰或分离关注点,而提升性能居次,也值得采用多线程设计。

倘若读者已决意在应用软件中使用并发技术,不论是为了性能,还是为了分离关注点,或是因为“多线程的良辰吉日已到”,那么这对C++程序员意义何在?

以标准化形式借多线程支持并发是C++的新特性。C++11标准发布后,我们才不再依靠平台专属的扩展,可以用原生C++直接编写多线程代码。标准C++线程库的成型历经种种取舍,若要掌握其设计逻辑,则知晓其历史渊源颇为重要。

1998年发布的C++标准并没有采纳线程,许多语言要素在设定其操作效果之时,考虑的依据是抽象的串行计算机(sequential abstract machine)[6]。不仅如此,内存模型亦未正式定义,若不依靠编译器相关的扩展填补C++98标准的不足,就无法写出多线程程序。

为了支持多线程,编译器厂商便自行对C++进行扩展;广泛流行的C语言的多线程API,如符合POSIX规范的C语言多线程接口[7]和微软Windows系统的多线程API,使得许多C++厂商借助各种平台专属的扩展来支持多线程。这种来自编译器的支持普遍受限,在特定平台上只能使用相应的C语言API,并且须确保C++运行库(譬如异常处理机制的代码)在多线程场景下可以正常工作。尽管甚少编译器厂商给出了正规的多线程内存模型,但编译器和处理器运作优良,使数量庞大的多线程程序得以用C++写就。

C++程序员并不满足于使用平台专属的C语言API处理多线程,他们更期待C++类库提供面向对象的多线程工具。应用程序框架(如微软基础类库[8])和通用的C++程序库(如Boost和自适配通信环境[9])已累积开发了多个系列的C++类,封装了平台专属的底层API,并提供高级的多线程工具以简化编程任务。尽管C++类库的具体细节千差万别,特别是在启动新线程这一方面,但这些C++类的总体特征有很多共同之处。例如,通过资源获取即初始化(Resource Acquisition Is Initialization,RAII)的惯用手法进行锁操作,它确保了一旦脱离相关作用域,被锁的互斥就自行解开。这项设计特别重要,为许多C++类库所共有,使程序员受益良多。

现有的C++编译器在许多情况下都能支持多线程,再结合平台专属的API以及平台无关的类库,如Boost和ACE,为编写多线程的C++代码奠定了坚实的基础。于是,无数多线程应用的组件由C++写成,代码量庞大,以百万行计。不过它们缺乏统一标准的支持,这意味着,由于欠缺多线程内存模型,因此在某些情形下程序会出现问题,下面两种情形尤甚:依赖某特定的处理器硬件架构来获得性能提升,或是编写跨平台代码,但编译器的行为却因平台而异。

随着C++11标准的发布,上述种种弊端被一扫而空。C++标准库不仅规定了内存模型,可以区分不同线程,还扩增了新类,分别用于线程管控(见第2章)、保护共享数据(见第3章)、同步线程间操作(见第4章)以及底层原子操作(见第5章)等。

前文提及的几个C++类库在过往被使用过程中积累了很多经验,C++11线程库对它们颇为倚重。具体而言,新的C++库以Boost线程库作为原始范本,其中很多类在Boost线程库中存在对应者,名字和结构均一致。另外,Boost线程库自身做了多方面改动,以遵循C++标准。因此,原来的Boost使用者应该会对标准C++线程库深感熟悉。

正如本章开篇所述,C++11标准进行了多项革新,支持并发特性只是其中之一,语言自身还有很多的改进,以便程序员挥洒自如。虽然这些改进普遍超出本书范围,但其中一部分直接影响了C++线程库本身及其使用方式。附录A将简要介绍这些C++新特性。

C++14进一步增添了对并发和并行的支持,具体而言,是引入了一种用于保护共享数据的新互斥(见第3章)。C++17则增添了一系列适合新手的并行算法函数(见第10章)。这两版标准都强化了C++的核心和标准程序库的其他部分,简化了多线程代码的编写。

如前文所述,C++标准委员会还发布了并发技术规约,详述了对C++标准提供的类和函数所做的扩展,特别是有关线程间的同步操作。

C++明确规定了原子操作的语义,并予以直接支持,使开发者得以摆脱平台专属的汇编语言,仅用纯C++即可编写出高效的代码。对于力求编写高效且可移植的代码的开发者,这简直如有神助:不但由编译器负责处理平台的底层细节,还能通过优化器把操作语义也考虑在内,两者联手改进性能,使程序的整体优化效果更上一层楼。

通常,对于从事高性能计算工作的开发者,无论是从整体上考量C++,还是就封装了底层工具的C++类而言(具体来说,以新的标准C++线程库中的类为例),他们最在意的因素通常是运行效率。若要实现某项功能,代码可以借助高级工具,或者直接使用底层工具。两种方式的运行开销不同,该项差异叫作抽象损失[10]。如果读者追求极致性能,清楚这点便尤为重要。

在设计C++标准库和标准C++线程库时,C++标准委员会对此非常注意。其中一项设计目标是,假定某些代码采用了C++标准库所提供的工具,如果改换为直接使用底层API,应该不会带来性能增益,或者收效甚微。因此,在绝大多数主流平台上,C++标准库得以高效地实现(低抽象损失)。

总有开发者追求性能极限,恨不得下探最底层,亲手掌控半导体器件,以穷尽计算机的算力。C++标准委员会的另一个目标是,确保C++提供充足的底层工具来满足需求。为此,新标准带来了新的内存模型,以及全方位的原子操作库,其能直接单独操作每个位、每个字节,还能直接管控线程同步,并让线程之间可以看见数据变更。过去,开发者若想深入底层,就得选用平台专属的汇编语言;现在,在许多场合,这些原子型别和对应的操作都足以取而代之。只要代码采用了新标准的数据型别与操作,便更具可移植性,且更容易维护。

C++标准库还提供了高级工具,抽象程度更高,更易于编写多线程代码,出错机会更少。使用这些工具必须执行额外的代码[11],所以有时确实会增加性能开销,但这种性能开销不一定会引发更多抽象损失。与之相比,实现同样的功能,手动编写代码所产生的开销往往更高。此外,对于上述绝大部分额外的代码,编译器会妥善进行内联。

针对某种特定的使用需求,一些高级工具提供了所需功能,有时还给出了额外功能,超出了原本的需求。在大多情况下,这都不成问题:未被使用的功能完全不产生开销。只有在极少数情况下,这些未被使用的功能会影响其他代码的性能。若读者追求卓越性能,无奈高级工具的开销过大,那最好还是利用底层工具亲自编写所需功能。在绝大部分情况中,这将导致复杂度和出错的可能性同时大增,而性能提升却十分有限,得益远远不偿所失。有时候,即便性能剖析表明瓶颈在于C++标准库的工具,但根本原因还是应用程序设计失当,而非类库的实现欠佳。譬如,如果太多线程争抢同一个互斥对象,就会严重影响性能。与其试图压缩互斥操作以节省琐碎时间,不如重新构建应用程序,从根本上减少对互斥对象的争抢,收效很可能更明显。第8章会涵盖上述议题:如何设计并发应用,减少资源争抢。

C++标准库还是有可能无法达到性能要求,无法提供所需的功能,但这种情况非常少,一旦出现这种情况,就似乎有必要使用平台专属的工具了。

虽然标准C++线程库给出了相当全面的多线程和并发工具,但在任何特定的平台上,总有平台专属的工具超额提供标准库之外的功能。为了可以便捷利用这些工具,同时又能照常使用标准C++线程库,C++线程库的某些型别有可能提供成员函数native_handle(),允许它的底层直接运用平台专属的API。因其本质使然,任何采用native_handle()的操作都完全依赖于特定平台,这也超出了本书的范围(以及C++标准库自身的范围)。

在考虑使用平台专属的工具之前,有必要了解标准库提供的功能。我们从一个实例开始。

假定我们已经拥有一个优良的编译器,其符合C++11、C++14、C++17标准。

从何入手?

多线程C++程序长什么样子?它看起来非常像其他C++程序,由常见的变量、类和函数组成。他们之间真正的区别仅仅在于某些函数可能并发运行,因而必须保证共享数据能安全、可靠地被并发访问,第3章将对此展开介绍。为了并发运行函数,我们必须使用特定的函数和对象,以管控不同的线程。

我们从经典的程序范例起步,输出“Hello World”。简单的单线程“Hello World”程序示例如下,它是多线程版本的基础。

#include <iostream>
int main()
{
    std::cout<<"Hello World\n";
}

这个程序的全部动作就是将“Hello World”写到标准输出。作为对照,简单的“Hello Concurrent World”并发程序示例如代码清单1.1所示,它启动一个独立的线程来显示这条消息。

代码清单1.1 一个简单的“Hello Concurrent World”并发程序

#include <iostream>
#include <thread>

void hello()
{
    std::cout<<"Hello Concurrent World\n";
} 

int main()
{
    std::thread t(hello);  
    t.join();
}

两个程序的第一个不同之处是代码清单1.1增加了头文件<thread>。C++标准库引入了多个新的头文件,它们包含支持多线程的相关声明:管控线程的函数和类在<thread>中声明,而有关共享数据保护的声明则位于别的头文件之中。

第二个不同之处是代码清单1.1中写消息的代码被安置到一独立函数内。这是因为每个线程都需要一个起始函数(initial function),新线程从这个函数开始执行。就应用程序的起始线程(initial thread)而言,该函数是main()。然而对于别的线程,其起始函数需要在std::thread对象的构造函数中指明。本例中,变量名为t的std::thread对象以新引入的hello()作为起始函数。

第三个不同之处是代码清单 1.1 并没有在main()中直接向标准输出写数据,或在main()中调用hello(),而是启动一个新线程来执行输出,于是就有两个线程存在,起始线程从main()开始执行,新线程则从hello()开始执行。

新线程启动后,起始线程继续执行。如果起始线程不等待新线程结束,就会一路执行,直到main()结束,甚至很可能直接终止整个程序,新线程根本没有机会启动[12]。这正是要调用join()的原因(见第2章),该调用会令主线程等待子线程,前者负责执行main()函数,后者则与std::thread对象关联,即本例中的变量t。

如此看来,仅仅将一条消息写到标准输出,便要下许多功夫。1.2.3节已有说明,完成这种简单任务往往不值得大费周章动用多线程,特别是当起始线程“无所事事”,别的线程却“疲于奔命”时。后文我们将借多个范例展现各种场景,说明什么情况下使用多线程会有明显收益。

我们在这一章解释了并发和多线程的含义,以及应用软件为什么选择采纳并发技术(或选择避免)。我们还回顾了C++多线程简史:先指出了1998年版的C++标准完全欠缺对多线程的支持,接着说明了存在各种平台专属的扩展来补足,然后介绍了新的C++11标准正式支持多线程,以及C++14、C++17标准和并发技术规约。为增强处理能力,当今的芯片厂家选择以多核的方式同时执行更多任务,而非维持单核的CPU架构来提升执行速度。于是,新时代CPU的硬件并发能力日益强大。新标准对多线程的支持正逢天时地利,让程序员得以物尽其用。

我们在1.4节中以实例示范,轻松使用了C++标准库中的多线程相关的类和函数。在C++环境下,多线程本身和多线程的使用都不复杂,复杂之处在于设计代码,令其行为与设计意图相符。

通过1.4节的实例小试牛刀后,是时候进一步实战了。我们会在第2章探究线程管控的类和函数。

[1] 译者注:即Technical Specification,简称TS。这是由C++标准委员会官方发布的文件,独立于正式标准之外。技术规约详述程序库的某一完整的类或某一完整的语言特性,未完全成熟,尚不能成为正式标准。它意在鼓励编译器厂商和C++社群做前期试验,自行实现,获取经验或达成共识,调整、修补原有设计。从2012年开始,委员会采取技术规约和正式标准并举的分支模式:各种技术规约的提案和正式标准分别独立演化,同时进行,异步交付:新标准定期发布;技术规约则一路演化,直到成熟,委员会才会批准将其合并入正式标准。

[2] 译者注:指令指针(Instruction Pointer,IP),又称为Program Counter,简称PC,是CPU硬件的一部分,用于记录即将执行的下一条指令的地址。它不同于C++/C语言中的指针。

[3] 出自文章“免费午餐没有了:软件从根本上转向并发”,作者Herb Sutter,原文题目为“The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software”,刊于Dr. Dobb’s Journal, 30(3), March 2005。

[4] 译者注:即embarrasingly parallel,又名pleasingly parallel,也有人译成“易并行”。

[5] 译者注:指可执行程序在系统上运行时,各线程自身专有的内存区域,也分别称为栈数据段和堆数据段,它们有别于数据结构中的栈概念和std栈容器,详见《程序员的自我修养》。

[6] 译者注:指单一CPU环境下,程序默认按单线程运行。

[7] 译者注:即POSIX线程库(POSIX threads),简称pthreads。

[8] 译者注:微软基础类库(Microsoft Foundation Classes,MFC)。

[9] 译者注:自适配通信环境(ADAPTIVE Communication Environment,ACE),是一个主要针对通信功能的开源软件框架。

[10] 译者注:即abstraction penalty,也有人按字面意思译成“抽象惩罚”。

[11] 译者注:相比直接调用底层接口,C++标准库的类涉及封装、继承、多态、聚合或维护线程相关信息等额外工作。

[12] 译者注:假若没有调用join()而引发这种情况,程序会报错并退出,而非正常终止。


相关图书

代码审计——C/C++实践
代码审计——C/C++实践
CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程