对C++使用者来说这是令人兴奋的时刻,自从1998年C++标准发行13年来,C++标准委员会对其语言和支持库进行了大改造。新的C++标准(C++11或者C++0x)在2011年发布。这次做了一系列改变使得C++变得更容易使用、效率更高。
C++11标准中最大的一个新特性就是支持多线程编程。这是第一次C++标准承认语言中的多线程应用的存在,并且在库中提供了多线程应用的支持。这使得在写多线程C++程序时不需要依赖指定平台的扩展模块,并且使多线程的代码得到保证。同时这使得程序员愈发觉得编写多线程代码是稀松平常的事,使得应用的性能表现得到了提升。
这本书是关于如何写并发的C++多线程代码和介绍C++语言特性和库支持的。我将从解释并发和多线程是什么开始,解释如何在你的应用中使用并发。有可能你不需要使用并发编程,这是个题外话。解释完之后,我会实际写一段C++并发代码。有写并发代码经验的读者可能希望跳过前面几个章节。之后的几个章节,我会涉及更多的例子,展示使用库支持。本书会比较深入地介绍所有C++标准的库给予多线程和并发的支持。
那么并发和多线程是什么意思呢?
1.1 什么是并发?
简单来说,并发就是多个及以上的独立的行为在同一时间发生。生活中我们经常碰到并发事件:我们一边走,一边说话或者两个手做不同的事情。我们不同的人也在做不同的事情—你看足球赛而我在游泳,等等。
1.1.1 计算机科学中的并发
计算机中的并发是指一个系统并行地而不是顺序地处理多个独立的行为。这是一个新的现象:多任务操作系统允许一个计算机在同一时间运行多个应用。这个技术被使用了很多年了,叫做任务切换。高端服务器拥有多个处理器可以做到真正的并发,这个技术更为久远。现在比较流行的新技术是真正地并行运行多个任务而不是给人以并行运行的假象。
很久之前,大多数的计算机只有一个处理器,一个处理器单元或者核心。即使在今天很多的桌面机仍是如此。这种机器实际上同一时间只能运行一个任务。不过它能在一秒钟内在几个任务之间来回切换。做任务A一段时间,然后切换到任务B,来回反复。这让人看上去它是并发执行的。这就是任务切换技术。我们说的并发是指这样的系统;因为任务切换很快,你很难说在哪个时间点上任务被挂起,处理器去运行其它任务了。任务切分提供给了用户和应用程序一种并发的假象。这种并发的表现无论是任务切换技术提供的还是真的并发,对我们来说区别并不大。实际上对于内存模型的错误估计(第五章涉及)不会在这个环境中显现出来。这个区别会在第十章深入谈到。
计算机带有多个处理器的做法已经在很多年前就被服务器用在高性能计算任务上了。现在,在一个芯片上拥有多个核心(多核处理器)的多处理器计算机作为桌面机变得越来越普遍。这种机器无论是多处理器还是一个处理器上有多个核心,都能够做真正的并发。这种硬件支持的并发被称为硬件并发。
图1.1显示了一个典型的场景:一个计算机运行两个任务,每个任务被切分成10个等大的块。在一个双核机器上(有两个处理器核心),每个任务可以在各自的核心上运行。单核机器使用任务切换技术,每个任务的块是间隔运行的。但他们还是会有多的一小块(途中灰色表示的块),这是切换任务时系统需要内容切换。这是在切换任务时产生的,这也消耗时间。操作系统需要保存CPU状态和当前运行的任务的指令指针,确定要切换到哪个新的任务并且重载(reload)这个任务的CPU状态。CPU将不得不加载新任务的指令和数据到cache内存中,这会组织CPU运行任何指令,造成延迟。
尽管硬件并发对于多核或者多处理器来说很明显,一些处理器可以在一个核上执行多个线程。重要的因素是考虑实际的硬件线程的数量:实际上硬件能够并发运行的独立的任务的数量。即使一个系统是硬件并发的,支持比硬件更多的任务并发执行也不是难事。这里也会用到任务切换技术。举个例子,桌面机可能会运行上百个任务,有些在后台运行,即使在计算机明显处在待机(idle)状态。这是任务接环技术让这些任务在后台运行并且还能支持你同时运行word、编译器、编辑器和浏览器(或者其它应用的组合)。图1.2展示了任务切换技术在双核机器上跑4个任务的情况。我们仍用理想的同尺寸分块来表示。实际上很多情况下这个分块会是不平均的,分配也是不规则的。部分造成不平均的情况的原因会在第八章中谈到,一些因素会影响并发代码的性能表现。
所有这本书中涉及的技术、函数和类不管你的应用是在单核机器或者多核机器上跑都同样有效。是的,不管是任务切分技术还是硬件同步技术都一样。当然,你会想到实际的表现情况和你硬件同步有很大关系。第八章会有更详细的讨论。
1.1.2 并发的方法
想象一下两个程序员共同开发一个软件项目。如果他们在不同的办公室,他们能够互补影响地工作,他们使用各自的指导手册。然而,通信变得不那么直接,不能回头交流而是要少用电话和邮件或者起身去对方的办公室。同时你有办公室的开销、要买多份指导手册。
现在想象把他们移到同一个办公室。现在他们可以方便地交流、设计、绘图等。你只要一个办公室,一份资源就够了。缺点是他们变得难以集中精神,共享资源也会有新的问题(“我的指导手册去哪儿了?”)
这两种组织你的程序员的方法展示了并发情况。每个程序员代表一个线程,每个办公室代表一个进程。第一种方法是多个进程,每个进程一个线程。另一个是一个进程里有多个线程。
多进程并发
第一种使用并发的方法是把应用分成多个进程,每个进程一个线程。进程同时运行,就像你可以同时打开多个网页或者多个word文档一样。这些分开的进程能通过惯常的进程间通信渠道(信号、套接字、文件、管道等)相互传输。如图1.3。它的缺点是这种通信往往比较复杂或者传输慢。这是因为操作系统一般会提供一系列的保护措施防止一个进程错误地修改了另一个进程的数据。另一个缺点是多进程运行的继承开销:开启一个进程耗时,操作系统要消耗内部资源管理进程。
当然,这也不完全都是缺点。额外的保护措施保证了进程间通信和高级通信代码比较容易编写而且安全。为像ErLang编程语言这样提供的编程环境使用进程作为基本的并发编程模块效果非常好。
使用分开的进程来做并发还有一个额外的好处—你可以在用网络通信连接的不同的计算机上运行不同的进程。这虽然增加了网络通信的成本,但在一个仔细设计的系统上,这会是一个提高系能和并发效果的有效率的方式。
多线程并发
另外一个方法就是多线程并发。线程可以认为是轻量级的进程:每个线程独立运行、运行自己不同的一套指令。但是进程中的线程共享相同的地址空间,而且几乎所有的数据可以被所有的线程所获取—全局变量还是全局的,指向对象或数据的指针或引用可以在线程间传递。虽然进程间共享内存是可能的,但是设置复杂而且管理困难,因为不同进程的相同数据的内存地址很可能不一样。图1.4展示了两个线程通过共享内存实现通信的方法。
共享的地址空间和没有数据保护的限制,线程间通信的开销远远小于进程间通信。因为操作系统不太关心这些数据。但是灵活的共享数据也是有代价的:数据在线程间共享了,程序员必须保证数据在被访问时对于每个线程来说是一致的。这个关于共享数据的相关问题在本书的第3,4,5,8章有涉及。这个问题并非无法解决,写代码时需要注意,但的确需要花费比较多的精力来考虑。
因为相对来说的通信低开小,多线程编程方式是C++编程的主流方式。另外,C++标准并没有进程间通信的支持,所以这种应用就要依赖平台相关的API来做。所以这本书重点关注多线程的并发编程。
说明了并发的意义,我们来看为什么我们要在应用中使用并发编程。continuously
1.2 为什么使用并发?
主要是两个原因:分离功能和提升性能。其实我可以说只有一个原因:任何你觉得太复杂可以区分的功能都可以用
1.2.1 使用并发来分离功能
对于软件行业来说分离功能总是一个好的理由,通过聚合相关代码,分离不相关代码,你可以使你的程序便于理解、测试、更少出现bug。用并发可以分割不同的功能区,即使当这些不同区的操作同时发生。不使用并发,你要么需要写任务切换框架,要么在操作中调用非相关区的代码。
想一想带有用户界面的频发获取应用,例如桌面机的DVD播放应用。这种应用有两个职责:从碟片上读取数据、解码图片声音并且传输给图片和声音的处理硬件。同时速度要足够快放置卡顿。而且同时需要接受用户的输入,例如用户按下“暂停”或者“返回到菜单”甚至“退出”。在一个线程中,应用不得不每过一段时间检查用户输入,造成DVD播放的代码和用户界面代码混杂在一起。使用多线程就可以不再让他们混一起了。一个线程管理用户输入,另一个处理DVD播放。线程间通信需要处理,但现在这些交互就变得容易多了。
这种响应错觉,是因为用户接口线程通常可以立刻响应一个用户的请求,就像和显示一个忙碌的光标或者是向正在工作的线程发送“Please Wait”消息。同样的,分离线程也经常用来执行那些必须跑在后台的任务,例如监视修改桌面文件系统的搜素应用程序。通常用这种方式使用线程会使得每个线程的逻辑变得简单,因为这样线程之间的交互被限制到统一的点上,而不是逻辑分散到不同的任务上。
这时候,线程的数量是独立于cpu可提供的数量,因为线程的分配是以概念设计为基础,而不是尝试增加吞吐量。
1.2.2 使用并发提升性能
多处理器系统已经存在了好多年,但是直到最近它们也只现于超级计算机、大型主机和大的服务器系统。但是芯片制造商更多地热衷于多核设计,2,4,16或更多处理器在一个芯片上性能提升大得多。于是多核桌面计算机和多核嵌入设备变得流行了起来。它们的威力不是运行一个单一任务速度更快而是能够并行运行更多的任务。过去程序员的程序运行得更快依赖于新一代的处理器。但是现在,速度来自于多核技术。所以程序员也需要留意,之前被忽略的并发编程现在需要多考虑了。
使用并发提升性能有两个办法:一个是把单一任务拆分成多个并行的子任务。这样就减少了运行时间。这个叫任务并行。这个看上去直观但实现复杂,因为各个部分之间可能存在依赖关系。划分可能按照处理顺序—一个线程处理一个算法的一部分,另一个处理另一部分或数据—每个线程处理数据的某一不同部分。后一种方法叫做数据并行。
那些很容易受并行影响的算法通常被成为易并行,尽管受影响,你无妨使代码轻易的并行化,这是一件好事:我遇到的其他算法是自然并行和便捷并发。易并行算法有很好的伸缩性,这使得硬件线程可提供的数量在上升,算法中的并行化也在高度匹配。这样的算法是完美的化身,“众人拾柴火焰高”(Many hands make light work)。对于那些不是易并行的部分算法,你也可以把他们(因此它们不是可伸缩的)分配到固定数量的并行任务中。设计并发代码的内容在第8章
第二种使用并发提升性能是使用可提供并行化去解决更大的事情;而不是立刻处理一个文件,处理2个或者10个或者20个才是合适的。尽管这是一个数据并行的应用,但是在多组数据并发中,同一个操作也会有不同的焦点。它仍然会花费等量的时间去执行一个数据块,但是现在,更多的 数据可以在等量的时间内被处理。很明显,这种方法也并不是在所有情况中都是有益的,但是在这种情况下的吞吐量确实可以让新事情变的可能,举个例子,如果不同的区域的图片可以并行处理,那么就可以提高分辨率。
1.2.3 什么时候不需要并行
知道什么时候不使用并发就像是知道什么时候要使用并发一样重要,从根本上说,不使用并发只有一个理由:收益低于成本。在很多情况下,并发代码很难理解,所以会有一个直观的写多线程代码和维护多线程代码的知识成本,并且传统的复杂性也会导致更多的bug,除非潜在的增益足够大,或者分离点足够清晰证明额外开发时间被要求这么做,并且能够有相关维护多线程代码的额外代码,那么不要使用并发。
当然,增益也许没有期盼中的那么大,这里有一个更新线程的固有开销,因为OS必须去分配相关的内核资源和栈空间,然后把线程添加到调度器里面,这些都需要时间。如果线程中的任务很快就完成了,那么它实际执行任务的时间和更新线程的时间相形见绌,这样或许使得应用的整体性能比直接用增殖线程去处理任务造成的后果更加糟糕。
此外,线程是一种有限资源。如果你同时有很多线程在运行,他们会消耗系统资源,使得系统整体运行变慢。不仅是这样,使用很多线程也会使得可提供的内存或者地址空间被泄漏掉,因为每一个线程都需要在栈上面分配空间。在32位进程中或者是一个平面架构,只有4GB可以提供的地址空间:如果每个线程有1MB的栈(在许多系统上是典型的),地址空间会被4096个线程消耗殆尽,就不会允许分配给任何代码或者静态数据或者堆数据空间。
尽管64位(或者更大)的系统没有一个直接的地址空间限制,他们也会有资源的限制:如果你运行很多线程,他们最后也会产生问题。尽管线程池(第9章)可以使用有限的线程,他们也有他们自己的问题。
如果cs模式的服务段程序为每一个connect分配一个单独的连接,连接数量少会工作的很好,如果为了一个必须管理很多连接的高需求的服务端程序也如此设计的话,那么频繁的分配线程会很快的消耗系统资源。在这种场景中,小心的使用线程池会是最好的办法(看第9章)
最后,你运行更多的线程,就会有更多的切换操作系统的上下文,每一个上下文切换花费的时间都用在有用的任务上面,所有,在某种程度上,增加额外的线程会减少整体系统的整体性能,而不是增加性能。这时候,如果你想实现系统的最佳性能,那么很有必要去调整线程的数量并且考虑硬件并发的可能性。
使用并发性能就像任何一个优化策略:它可以更大的提升应用程序的性能,但是,也会使你的代码更加复杂,更加难懂,更多bug。因此,只适合在应用程序中那些拥有潜在可衡量的收益的部分使用。当然,如果潜在的性能收益只是次要的清晰设计或者是分离点,仍然值得使用多线程设计。
假设,你已经决定你做的事情,想要用在你的程序中使用并发,无论是为了性能,分离点,或者是多线程设计,这对c++ 程序员意味着什么?
1.3 C++中的并发和多线程
C++对于多线程的支持是一件新事物,只有在C++11标准下才支持不依赖指定平台的扩展写通用的多线程程序。为了理解新标准的C++线程库所做的一系列行为决策,首先要了解其历史背景。
1.3.1 C++多线程的历史
1998年C++标准不承认多线程,内存模型也没有被正式定义,所以只能写平台相关的多线程应用。
当然,编译器商可以对语言做自己的扩展,于是POSIX C标准和和微软的多线程API变得流行起来,这也导致了编译器商开始支持各种平台相关的多线程扩展。这些扩展限制了C API与平台的联系也限制了C++运行时库在多线程情况下的表现(例如异常处理机制)。尽管少数的编译器商提供了正式的考虑了多线程的内存模型,编译器和处理器在处理多线程代码时已经表现得很好了。
C++ 程序员不满意需要依赖平台API来处理多线程,他们希望能够使用面向对象的多线程工具。应用框架如MFC和Boost和ACE等积累了一套C++类来封装底层依赖平台的多线程API实现,提供了高端的工具、简化了多线程编码工作。虽然不同的类库的实现有所区别,例如开启一个新的线程的方式,但总体上的功能是一致的。最重要的一致点是贯彻了RAII原则,这保证了可以使用mutex来锁定数据,在作用域外则释放资源。这便利了程序员。
在很多案例中,因为有Boost和ACE的底层支持,我们可以写出不错的多线程代码。但是缺少标准的支持意味着有时候因为没有关注线程的内存模型(thread-aware momory model)导致了问题。这里有两个例子,一个是当希望获取更好的性能而使用硬件相关知识代码;另一个是希望写出跨平台的代码但是实际上编译器的行为又根据平台不同表现会有差异的情况。
1.3.2 新标准中的并发支持
C++11标准的出现改变了这一切。不只是因为它有了一个全新的关注线程的内存模型,而且还因为C++标准库已经扩展出了包含管理线程的类(参见第二章),保护共享数据(参见第三章),同步线程操作行为(参见第四章),和低级的原子操作(参见第五章)。
新的C++线程库很大程度上参考了之前提到的哪些库积累的经验,Boost库作为标准库的基础,类和结构体的名字都和Boost一样。所以使用Boost的人转到使用标准库上很容易。
并发只是新C++标准中的一小部分,其它的改进不是本书讨论的范围。其它的一些改动会对并发编程有一些些影响,这些特性写在附录A中了。
对于原子操作的直接支持使得程序员可以写出高效的代码而不用平台相关的汇编语言。这对于那些想要写出高效、可移植的代码的程序员来说是一大福利。不只是编译器会考虑平台相关代码,优化器也可以考虑这些操作的语义(semantics),从而使代码在总体上被优化。
1.3.3 C++线程库的效率
对于C++来说,开发者比较关心的是高性能计算。对于封装了底层工具—例如新的C++标准线程库—的类来说,效率是有保证的。如果你非常关心性能问题,那么使用底层工具或者上层工具会有很大的性能差距,这个差距就是抽象惩罚(abstraction penalty)。
在设计C++标准库的时候标准委员会非常关注这一点。所以标准库的运行开销总是最小的。所以在大多数主流的平台上,库的实现总是最有效率的(也就是抽象惩罚很小)。
效率上的保证还包括新的内存模型、得到广泛支持的原子操作库。这些原子类型和对应的操作可以使用在很多地方,此前开发者只能用平台相关的汇编语言来替代。使用新的类型和操作的可移植性更好且易于维护。
C++标准库也提供了一些上层的抽象和工具使得写多线程代码更容易,不易出错。有时候这些工具的确会产生性能损失的代价,因为有额外的代码需要被执行。但是这个性能损失并不必然导致更高的抽象惩罚。总体来说这个代价不会比随手写出的同类函数功能的代价更高。并且编译器可能会内联大多数的导致性能损失的额外代码。
有时候上层的工具提供了额外的功能,超出了我们的需要。大多数时候这不成问题,我们不会为没有使用的功能承担性能损失。在极少的情况下,这些没有使用的功能会影响到其它代码。如果你关注性能而代价很高时,你最好手动修改你的功能实现。绝大多数情况下,额外的复杂度和出错的可能性比潜在的性能提升更需要被优先考虑。即使分析显示的确是C++标准库工具的使用导致了性能瓶颈,这更有可能是糟糕的设计而不是糟糕的库实现造成的。举例来说,如果很多个线程竞争一个mutex,这一定会极大地影响性能。解决的方式当然是要考虑如何减少竞争mutex而不是考虑如何削减对于mutex的操作的耗时。设计如何减少竞争在第八章中讨论。
如果有些地方没有C++标准库的支持,那还是需要使用平台相关工具的。
1.3.4 平台相关工具
虽然C++线程库提供了广泛的并发多线程支持工具,但是还是会有一些情况没有覆盖到。为了得到这种平台相关的工具的支持,同时又不丢失标准库带来的好处,C++线程库提供了成员函数 native_handle() 允许底层的平台相关的API实现。很明显 native_handle()相关的内容是平台相关的,所以超出了本书要讨论的范围。
当然,在考虑使用平台相关工具之前,重要的是先了解标准库提供的功能,举个例子先。
1.4 开始编码
好了,首先你需要有一个支持C++11的编译器(例如vs2015)。然后呢?一个多线程的C++程序长什么样子?它和C++的其它程序差不多,使用普通的变量、类和函数。唯一的区别是一些函数可能并发运行,所以你需要保证共享数据在同步读取时是安全的。当然,为了并行运行函数,特定的函数和对象必须设计成可以管理不同线程。
1.4.1 并行编程的hello world
从一个经典例子开始吧:一个会打印“Hello World”的程序。一个简单的程序运行在一个线程中:
让我们比较一下并发的hello world是怎样的。
第一个区别是多了 #include <thread> (1).多线程C++标准库的头文件,声明支持多线程。保护共享数据的相关类等声明在其它头文件中。
第二个区别是输出消息的代码被移到了一个独立函数中(2)。这是因为每一个线程必须要有一个初始函数(initial function),它是新线程执行的起点。应用的初始线程是从main()函数开始的,其它线程是从std::thread对象的构造函数开始的。在这个例子中,std::thread对象是t (3)。hello()函数是他的初始函数。
第三个区别是:不同于直接把标准输出或者说在main()函数中调用hello()函数,这个程序起了一个新的线程做这个事。两个不同的线程做不同的事情,一个从main()函数开始,一个从hello()函数开始。
子线程启动之后(3),初始函数继续执行。如果主线程不等待新线程结束,那只要main()函数结束,整个程序就结束了—此时,子线程可能还没开始运行。这就是为什么我们调用join() (4),它会导致调用线程(main()函数所在线程)等待线程对象,也就是t。
看上去我们只是在等待一个消息输出到标准输出窗口,那我们不需要使用多线程来实现这么简单的一个任务啊。稍后我们会有例子展示真正需要使用多线程完成任务的场景。
1.5 总结
这个章节我们解释了什么是并发和多线程,为什么我们要使用(或者不使用)多线程。我们也谈到了多线程在C++编程中的历史,在1998版标准到C++11版之间,我们一般使用平台相关的API来实现多线程。新标准的这种多线程支持来得非常及时,因为芯片制造商在芯片、处理器上的技术进步可以让硬件在硬件层面实现并发处理。有了新的标准程序员就可以利用硬件特性来做并发应用实现。这是硬件的并发而不是以前提升单个核心的执行速度。
我也展示了简单的使用标准库的类和函数的多线程案例。经过了这个小案例,我们接下来在第二章将讨论管理线程的类和函数。