第29章 继续前行
您学习了C++编程的基本知识。事实上,您已经知道使用标准模板库(STL)、模板和标准库有助于编写高效而紧凑的代码了。现在该考虑考虑性能,并了解最佳编程实践了。
在本章中,您将学习:
• 当今的处理器有何不同;
• C++应用程序如何充分利用处理器的功能;
• 线程和多线程技术;
• C++编程最佳实践;
• 利用其他资源提高C++技能。
29.1 当今的处理器有何不同
就在不久前,计算机还通过利用处理速度更快的处理器来提高速度;处理速度的度量单位为赫兹(Hz)、兆赫(MHz)或吉赫(GHz)。例如,Intel 8086(如图 29.1所示)是 1978年发布的一款 16位微处理器,时钟频率大约为10MHz。
那时候,处理器的速度得到了极大的提高,C++应用程序的速度也得到了极大的提高。当时大家都采取伺机而动的策略,利用改进的硬件性能来提高软件的响应速度。虽然当今的处理器越来越快,但真正的创新在于处理器包含的内核数。编写本书期间,Intel正销售一款64位微处理器,它内嵌6个3.2GHz内核,如图29.2所示;当前的发展趋势时,内核数量将不断增多。事实上,智能手机都装备了多核处理器。
图29.1 Intel8086微处理器
图29.2 Intel多核处理器
可将多核处理器视为一块包含多个处理器的芯片。这些处理器并行地运行,每个处理器都有独立的一级缓存,能够彼此独立地工作。
处理器速度越快,应用程序的性能越高,这合乎逻辑。多核处理器对应用程序性能有何帮助呢?显然,每个内核都能并行地运行应用程序,但这并不能提高应用程序的速度。本书前面介绍的 C++应用程序都是单线程的,不能充分利用多核处理能力。这些应用程序运行在一个线程中,因此只能利用一个内核,如图29.3所示。
图29.3 多核处理器中的单线程应用程序
如果应用程序依次执行所有的任务,操作系统(OS)分配给它的时间可能与队列中的其他应用程序一样多,而且它只占用处理器的一个内核。换句话说,在多核处理器中,这种应用程序的运行方式与多年前没什么不同。
29.2 如何更好地利用多个内核
关键在于创建多线程应用程序。所有的线程都并行地运行,操作系统可让它们在多个内核中运行。详细讨论线程和多线程技术超出了本书的范围,这里只简要地介绍这个主题,让您对高性能编程有大致认识。
应用程序代码总是运行在线程中。线程是一个同步执行实体,其中的语句依次执行。可将 main()的代码视为在应用程序的主线程中执行。在这个主线程中,可以创建并行运行的线程。如果应用程序除主线程外,还包含一个或多个并行运行的线程,则被称为多线程应用程序。
线程的创建方式由操作系统决定,您可直接调用操作系统提供的API来创建线程。
C++11规定由线程函数负责为您调用操作系统 API,这提高了多线程应用程序的可移植性。
如果您编写的应用程序将在特定操作系统上运行,请了解该操作系统提供的用于编写多线程应用程序的API。
创建线程的方式随操作系统而异,C++11在头文件<thread>中提供了std::thread,它隐藏了与平台相关的细节。
编写本书时,主要编译器对这种功能的支持都不太完美。如果您针对特定平台编写应用程序,最好只使用针对该操作系统的线程函数。
编写C++应用程序时,如果您希望其中的线程是可移植的,请务必了解Boost线程库,其网址为www.boost.org。
使用多线程技术的应用程序并行地执行特定任务的多个会话(session)。假设有 10000 名用户在Amazon购物,您是其中的一员。Amazon的Web服务器当然不会其他9999位用户都等待,而是创建多个同时为用户服务的线程。如果该 Web 服务器运行在多核处理器上(我敢打赌,肯定是这样),这些线程将能够充分利用内核,向用户提供最佳的性能。
另一个常见的多线程示例是,与用户交互(例如,通过进度条)的同时做其他工作的应用程序。这样的应用程序通常包含用户界面线程和工作线程,其中前者负责显示和更新用户界面以及接受用户输入,而后者在后台完成其任务。磁盘碎片整理工具就是一个这样的应用程序。用户单击“开始”按钮后,将创建一个工作线程,负责扫描和整理磁盘碎片;与此同时,用户界面线程将显示进度,并提供取消碎片整理的选项。为让用户界面线程显示进度,整理碎片的工作线程需要定期地提供进度;同样,为让工作线程在用户撤销时停止工作,用户界面线程需要提供这种信息。
多线程应用程序常常要求线程彼此通信,这样应用程序才能成为一个整体,而不是一系列互不关心、各自为政的线程。
另外,顺序也很重要,您不希望用户界面线程在负责整理碎片的工作线程之前结束。在有些情况下,一个线程需要等待另一个线程。例如,读取数据库的线程应等待写入数据库的线程结束。让一个线程等待另一个线程被称为线程同步。
线程可共享变量,可访问全局数据。创建线程时,可给它提供一个指向共享对象(结构或类)的指针,如图29.4所示。
图29.4 工作线程和用户界面线程共享数据
线程将数据写入其他线程能够存取的内存单元,这让线程能够共享数据,从而彼此进行通信。在磁盘碎片整理工具中,工作线程知道进度,而用户界面线程需要获悉这种信息;工作线程定期地存储进度(用整数表示的百分比),而用户界面线程可使用它来显示进度。
这种情形非常简单:一个线程创建信息,另一个线程使用它。如果多个线程读写相同的内存单元,结果将如何呢?有些线程开始读取数据时,其他线程可能还未结束写入操作,这将给数据的完整性带来威胁。
这就是需要同步线程的原因所在。
线程是操作系统级实体,而用来同步线程的对象也是操作系统提供的。大多数操作系统都提供了信号量(semaphore)和互斥量(mutex),供您用来同步线程。
互斥量通常用于避免多个线程同时访问同一段代码。换句话说,互斥量指定了一段代码,其他线程要执行它,必须等待当前执行它的线程结束并释放该互斥量。接下来,下一个线程获取该互斥量,完成其工作,并释放该互斥量。
通过使用信号量,可指定多少个线程可同时执行某个代码段。只允许一个线程访问的信号量被称为二值信号量(binary semaphore)。
除这些同步对象外,可能还有其他同步对象可供使用,这取决于您使用的操作系统。例如,Windows支持临界区,临界区指定了不允许多个线程同时执行的代码。
要使用多线程技术,必须妥善地同步线程,否则,您将有大量的无眠之夜。多线程应用程序面临的问题很多,下面是最常见的两个。
• 竞争状态:多个线程试图写入同一项数据。哪个线程获胜?该对象处于什么状态?
• 死锁:两个线程彼此等待对方结束,导致它们都处于“等待”状态,而应用程序被挂起。
妥善地同步可避免竞争状态。一般而言,线程被允许写入共享对象时,您必须格外小心,确保:
• 每次只能有一个线程写入;
• 在当前执行写入的线程结束前,不允许其他线程读取该对象。
通过确保任何情况下都不会有两个线程彼此等待,可避免死锁。为此,可使用主线程同步工作线程,也可在线程之间分配任务时,确保工作负荷分配明确。可以让一个线程等待另一个线程,但绝不要同时让后者也等待前者。
编写多线程应用程序本身是个专题,详细介绍这个引人注目且激动人心的主题超出了本书的范围。要学习多线程编程,可参阅大量有关该主题的在线文档,也可亲自动手实践。一旦掌握了这个主题,就能让C++应用程序充分利用未来将发布的多核处理器。
29.3 编写杰出的C++代码
相比于面世之日,C++发生了巨大变化,主要的编译器厂商在标准化方面做出了巨大努力,还有大量工具和函数,这些都有助于编写简洁的 C++代码。编写可靠且易于理解的 C++应用程序真的很容易。
下面的一些最佳实践可帮助您创建优质的C++应用程序。
• 给变量指定(无论是对您还是其他人来说都)有意义的名称。值得多花点时间给变量取个好名。
• 对于int、float等变量,务必进行初始化。
• 务必将指针初始化为NULL或有效的地址———如运算符new返回的地址。
• 使用数组时,绝不要跨越其边界。跨越数组边界被称为缓冲区溢出,可导致安全漏洞。
• 不要使用C风格字符串(char*),也不要使用strelen()和strcopy()等函数。std::string更安全,还提供了很多有用的方法,如获取长度、进行复制和附加的方法。
• 仅当确定要包含的元素数时才使用静态数组。如果不确定,应使用std::vector等动态数组。
• 声明和定义接受非 POD 类型作为输入的函数时,应考虑将参数声明为引用,以免调用函数时执行不必要的复制步骤。
• 如果类包含原始指针成员,务必考虑如何在复制或赋值时管理内存资源所有权,即应考虑编写复制构造函数和赋值运算符。
• 编写管理动态数组的实用类时,务必实现移动构造函数和移动赋值运算符,以改善性能。
• 务必正确地使用const。理想情况下,get()函数不应修改类成员,因此应将其声明为const函数。同样,除非要修改函数参数包含的值,否则应将其声明为const引用。
• 不要使用原始指针,而应尽可能使用合适的智能指针。
• 编写实用类时,务必花精力实现让它使用起来更容易的运算符。
• 在有选择余地的情况下,务必使用模板而不是宏。模板不但是通用的,还是类型安全的。
• 编写类时,如果其对象将存储在诸如vector和list等容器中,或者被用作映射中的键,务必实现运算符<,它将用作默认排序标准。
• 如果您编写的lambda表达式很长,应考虑转而使用函数对象,即实现了operator()的类,因为函数对象可重用,且只有一个地方需要维护。
• 绝不要认为运算符new肯定会成功。对于分配资源的代码,务必处理其可能引发的异常,即将其放在try块中,并编写相应的catch()块。
• 绝不要在析构函数中引发异常。
这个清单并非包罗万象,但涵盖了一些最重要的要点,有助于编写杰出且易于维护的C++代码。
29.4 更深入地学习C++
祝贺您在学习C++方面取得了巨大进步,要沿这条道路继续前行,最佳的方式是动手编写大量代码!
C++是一种复杂的语言,越多动手编程,您对幕后的情况就了解得越深入。诸如Visual Studio等开发环境提供了智能感知功能,可助您一臂之力,还可满足您的好奇心,如显示 string 类中您从未见过的成员。现在该开始在实践中学习了!
要更详细地了解 STL 容器及其方法、算法和功能,请访问 MSDN(http://msdn.microsoft.com/),它非常深入地介绍了标准模板库。
阅读MSDN中的STL文档时,别忘了选择正确的Visual Studio版本,因为从Visual Studio 2010开始才支持C++11。
编写本书时,主流C++编译器都未全面支持所有的C++11功能。例如,Visual Studio 2010不支持可变参数模板。在GNU GCC编译器 4.6版中,std::thread的实现存在问题。一般而言,了解编译器接下来将支持哪些C++11功能是个不错的主意,为此可参阅其在线文档。Visual Studio开发小组维护着一个名为C++11 Core Language Feature Support的博客,其网址为http://blogs.msdn.com/b/vcblog/archive/2010/04/06/c-0x-core-language-features-in-vc10-the-table.aspx;而 GCC 提供了一个支持页面,其网址为http://gcc.gnu.org/projects/cxx0x.html。
编写本书时,这两个编译器都支持C++11推荐的大部分功能。另外,本书的代码都使用了这两个编译器进行了测试。
有很多活跃的C++社区。在CodeGuru(www.CodeGuru.com)或CodeProject(www.CodeProject.com)网站注册后,您就可询问自己遇到的技术问题,并获得社区的帮助。
如果您对自己的水平有自信,可为这些社区的其他成员提供帮助。您将发现,在解答难题的过程中,您将学到很多。
29.5 总结
这是本书的最后一章,实际上是您探索C++的开篇!在此之前,您学习了C++基本知识和高级概念;而在本章中,您学习了多线程编程的理论基础。您了解到,要充分利用多核处理器,唯一的途径就是将逻辑放在多个线程中,并支持并行处理。您知道了如何避开多线程编程面临的陷阱。最后,您学习了一些基本的C++编程最佳实践。您知道,要编写优质的C++代码,不仅要使用高级概念,还应给变量指定别人能够明白的名称,处理异常以应付意外情况,并使用智能指针等实用类(而不是原始指针)。您已为进入专业C++编程领域做好了充分准备。
29.6 问与答
问:我编写了一个应用程序,对其性能很满意,还应考虑在其中实现多线程功能吗?
答:根本不用考虑。并非所有应用程序都需要是多线程的,仅当应用程序需要并行地执行任务或同时为众多用户提供服务时,才需要使用多线程技术。
问:既然主流编译器还未全面支持C++11,我为何不采用老式编程风格呢?
答:首先,两款主流编译器(Microsoft Visual C++和GNU GCC)都支持大部分C++11功能,只有少数功能除外。另外,C++11让编程更容易。使用关键字auto可简化迭代器声明,而lambda表达式让for_each()结构非常紧凑,无需编写函数对象。因此,使用C++11编程的好处已非常明显。
29.7 作业
作业包括测验,帮助读者加深对所学知识的理解。请尽量先完成测验,再对照附录D的答案。
测验
1.我编写了一个图像处理应用程序,它在校正对比度时没有响应,我该如何办?
2.我编写了一个多线程应用程序,能够以极快的速度访问数据库,但有时取回的数据不完整,请问是哪里出了问题?