《C++并发编程实战》——1.2 为什么使用并发

1.2 为什么使用并发

在应用程序中使用并发的原因主要有两个:关注点分离和性能。事实上,我甚至可以说它们差不多是使用并发的唯一原因;当你观察得足够仔细时,一切其他因素都可以归结到这两者之一(或者可能是二者兼有,当然,除了像“我愿意”这样的原因之外)。

1.2.1 为了划分关注点而使用并发

在编写软件时,划分关注点总是个好主意。通过将相关的代码放在一起并将无关的代码分开,这种方法可以使你的程序更容易理解和测试,从而减少出错的可能性。你可以使用并发来分隔不同的功能区域,即使在这些不同功能区域的操作需要在同一时刻发生的情况下。如果不显式地使用并发,你要么被迫编写任务切换框架,要么在操作中主动地调用不相关的一段代码。

考虑一类带有用户界面的密集处理型应用程序,例如为台式计算机提供的DVD播放程序。这样一个应用程序基本上具备两套职能:它不仅要从光盘中读取数据,解码图像和声音,并把它们及时输出至视频和音频硬件,从而实现DVD的无错播放;它还要接受来自用户的输入,例如当用户单击暂停或返回菜单甚至退出按键的情况。在单个线程中,应用程序须在回放期间定期检查用户的输入,于是将DVD回放代码和用户界面代码合在一起。通过使用多线程来分隔这些关注点,用户界面代码和DVD回放代码不再需要如此紧密地交织在一起。一个线程可以处理用户界面,另一个处理DVD回放,它们之间会有交互,例如用户点击暂停,但现在这些交互直接与眼前的任务有关。

这会带来响应性的错觉,因为用户界面线程通常可以立即响应用户的请求,即使在请求被传达给工作的线程,响应为简单地显示正忙的光标或请等待的消息的情况。类似地,独立的线程常被用于运行必须在后台连续运行的任务,例如在桌面搜索程序中监视文件系统的变化。以这种方式使用线程一般会使每个线程的逻辑更加简单,因为它们之间的交互可以被限制为清晰可辨的点,而不是到处散播不同任务的逻辑。

在这种情况下,线程的数量与CPU可用内核的数量无关,因为对线程的划分是基于概念上的设计而不是试图增加吞吐量。

1.2.2 为了性能而使用并发

多处理器系统已经存在了几十年,但直到最近,他们几乎只能在超级计算机、大型机和大型服务器系统中才能看到。然而芯片制造商越来越倾向于多核芯片的设计,即在单个芯片上集成2、4、16或更多的处理器,从而达到比单核心更好的性能。因此,多核台式计算机,甚至多核嵌入式设备,现在越来越普遍。这些计算机的计算能力的提高不是源自使单一任务运行的更快,而是源自并行运行多个任务。在过去,程序员曾坐等他们的程序随着处理器的更新换代而变得更快,无需他们这边做出任何努力。但是现在,就像Herb Sutter所说的,“免费的午餐结束了[1]”。如果软件想要利用日益增长的计算能力,它必须设计为并发运行多个任务。程序员因此必须留意,而且那些迄今都忽略并发的人们必须注意它并将其加入他们的工具箱中。

有两种方式为了性能使用并发。首先,也是最明显的,是将一个单个任务分成几部分且各自并行运行,从而降低总运行时间,这就是任务并行(taskparallelism)。虽然这听起来很直观,但它可以是一个相当复杂的过程,因为在各个部分之间可能存在很多的依赖。区别可能是在过程方面——一个线程执行算法的一部分而另一个线程执行算法的另一部分——或是在数据方面——每个线程在不同的数据部分上执行相同的操作。后一种方法被称为数据并行(dataparallelism)。

容易受这种并行影响的算法常被称为易并行(embarrassinglyparallel)。抛开你可能会尴尬地面对的很容易并行化的代码这一含义,这是一件好事情。我曾遇到过的关于此算法的别的术语是自然并行(naturallyparallel)和便利并发(convenientlyconcurrent)。易并行算法具有良好的可扩展特性——随着可用硬件线程数量的提升,算法的并行性可以随之增加与之匹配。这样的一个算法是谚语“人多力量大”的完美体现。对于非易并行算法的那一部分,你可以将算法划分为一个固定(因而不可扩展)数量的并行任务。在线程之间划分任务的技巧涵盖在第8章中。

使用并发来提升性能的第二种方法是使用可用的并行方式来解决更大的问题。与其同时处理一个文件,不如酌情处理2个或10个或20个。虽然这实际上只是数据并行的一种应用,通过对多组数据同时执行相同的操作,但还是有不同的重点。处理一个数据块仍然需要同样的时间,但在相同的时间内却可以处理更多的数据。当然,这种方法也存在限制,且并非在所有情况下都是有益的,但是这种方法所带来的吞吐量提升可以让一些新玩意变得可能。例如,如果图片的各部分可以并行处理,就能提高视频处理的分辨率。

1.2.3 什么时候不使用并发

知道何时不使用并发与知道何时要使用它同等重要。基本上,不使用并发的唯一原因就是在收益比不上成本的时候。使用并发的代码在很多情况下难以理解,因此编写和维护的多线程代码就有直接的脑力成本,同时额外的复杂性也可能导致更多的错误。除非潜在的性能增益足够大或关注点分离得足够清晰,能抵消确保其正确所需的额外的开发时间以及与维护多线程代码相关的额外成本,否则不要使用并发。

同样地,性能增益可能不会如预期的那么大。在启动线程时存在固有的开销,因为操作系统必须分配相关的内核资源和堆栈空间,然后将新线程加入调度器中,所有这一切都要占用时间。如果在线程上运行的任务完成得很快,那么任务实际上占据的时间与启动线程的开销时间相比就显得微不足道,可能会导致应用程序的整体性能还不如通过产生线程直接执行该任务。

此外,线程是有限的资源。如果让太多的线程同时运行,则会消耗操作系统资源,并且使得操作系统整体上运行得更缓慢。不仅如此,运行太多的线程会耗尽进程的可用内存或地址空间,因为每个线程都需要一个独立的堆栈空间。对于一个可用地址空间限制为4GB的扁平架构的32位进程来说,这尤其是个问题。如果每个线程都有一个1MB的堆栈(对于很多系统来说是典型的),那么4096个线程将会用尽所有地址空间,不再为代码、静态数据或者堆数据留有空间。虽然64位(或者更大)的系统不存在这种直接的地址空间限制,它们仍然只具备有限的资源:如果你运行太多的线程,最终会导致问题。尽管线程池(参见第9章)可以用来限制线程的数量,但这并不是灵丹妙药,它们也有自己的问题。

如果客户端/服务器应用程序的服务器端为每一个链接启动一个独立的线程,对于少量的链接是可以正常工作的,但当同样的技术用于需要处理大量链接的高需求服务器时,就会因为启动太多线程而迅速耗尽系统资源。在这种场景下,谨慎地使用线程池可以提供优化的性能(参见第9章)。

最后,运行越多的线程,操作系统就需要做越多的上下文切换。每个上下文切换都需要耗费本可以花在有价值工作上的时间,所以在某些时候,增加一个额外的线程实际上会降低而不是提高应用程序的整体性能。为此,如果你试图得到系统的最佳性能,考虑可用的硬件并发(或缺乏之)并调整运行线程的数量是必需的。

为了性能优化而使用并发就像所有其他优化策略一样,它拥有极大提高应用程序性能的潜力,但它也可能使代码复杂化,使其更难理解和更容易出错。因此,只有对应用程序中的那些具有显著增益潜力的性能关键部分才值得这样做。当然,如果性能收益的潜力仅次于设计清晰或关注点分离,可能也值得使用多线程设计。

假设你已经决定确实要在应用程序中使用并发,无论是为了性能、关注点分离,或是因为“多线程星期一”,对于C++程序员来说意味着什么?

时间: 2024-10-29 17:31:29

《C++并发编程实战》——1.2 为什么使用并发的相关文章

《GO并发编程实战》—— 原子操作

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 我们已经知道,原子操作即是进行过程中不能被中断的操作.也就是说,针对某个值的原子操作在被进行的过程当中,CPU绝不会再去进行其它的针对该值的操作.无论这些其它的操作是否为原子操作都会是这样.为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成.只有这样才能够在并发环境下保证原子操作的绝对安全. Go语言提供的原子操作都是非侵入式的.它们由标准库代码包sync/atomic中的众多函

《Java 7并发编程实战手册》第六章并发集合

由人民邮电出版社出版的<Java 7并发编程实战手册>终于出版了,译者是俞黎敏和申绍勇,该书将于近期上架.之前并发编程网组织翻译过此书,由于邮电出版社在并发网联系他们之前就找到了译者,所以没有采用并发网的译稿,但邮电出版社将于并发网展开合作,发布该书的样章(样章由并发网挑选,你也可以回帖告诉我们你想看哪一章的样章),并组织赠书活动回馈给活跃读者.活动详情请时刻关注并发网的微博和微信(微信号:ifeves),最后祝各位用餐愉快!:) 本章将介绍下列内容: 使用非阻塞式线程安全列表 使用阻塞式线程

《GO并发编程实战》—— 条件变量

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 我们在第6章讲多线程编程的时候详细说明过条件变量的概念.原理和适用场景.因此,我们在本小节仅对sync代码包中与条件变量相关的API进行简单的介绍,并使用它们来改造我们之前实现的*myDataFile类型的相关方法. 在Go语言中,sync.Cond类型代表了条件变量.与互斥锁和读写锁不同,简单的声明无法创建出一个可用的条件变量.为了得到这样一个条件变量,我们需要用到sync.NewCond函数

《GO并发编程实战》—— 锁的使用

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 在本节,我们对Go语言所提供的与锁有关的API进行说明.这包括了互斥锁和读写锁.我们在第6章描述过互斥锁,但却没有提到过读写锁.这两种锁对于传统的并发程序来说都是非常常用和重要的. 互斥锁 互斥锁是传统的并发程序对共享资源进行访问控制的主要手段.它由标准库代码包sync中的Mutex结构体类型代表.sync.Mutex类型(确切地说,是*sync.Mutex类型)只有两个公开方法--Lock和U

《GO并发编程实战》—— Concurrent Map

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 我们在本章前面的部分中对Go语言提供的各种传统同步工具和方法进行了逐一的介绍.在本节,我们将运用它们来构造一个并发安全的字典(Map)类型. 我们已经知道,Go语言提供的字典类型并不是并发安全的.因此,我们需要使用一些同步方法对它进行扩展.这看起来并不困难.我们只要使用读写锁将针对一个字典类型值的读操作和写操作保护起来就可以了.确实,读写锁应该是我们首先想到的同步工具.不过,我们还不能确定只使用

《GO并发编程实战》—— 只会执行一次

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 现在,让我们再次聚焦到sync代码包.除了我们介绍过的互斥锁.读写锁和条件变量,该代码包还为我们提供了几个非常有用的API.其中一个比较有特色的就是结构体类型sync.Once和它的Do方法. 与代表锁的结构体类型sync.Mutex和sync.RWMutex一样,sync.Once也是开箱即用的.换句话说,我们仅需对它进行简单的声明即可使用,就像这样: var once sync.Once o

《GO并发编程实战》—— WaitGroup

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 我们在第6章多次提到过sync.WaitGroup类型和它的方法.sync.WaitGroup类型的值也是开箱即用的.例如,在声明 var wg sync.WaitGroup 之后,我们就可以直接正常使用wg变量了.该类型有三个指针方法,即Add.Done和Wait. 类型sync.WaitGroup是一个结构体类型.在它之中有一个代表计数的字段.当一个sync.WaitGroup类型的变量被声

《GO并发编程实战》—— 临时对象池

声明:本文是<Go并发编程实战>的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文. 本章要讲解的是sync.Pool类型.我们可以把sync.Pool类型值看作是存放可被重复使用的值的容器.此类容器是自动伸缩的.高效的,同时也是并发安全的.为了描述方便,我们也会把sync.Pool类型的值称为临时对象池,而把存于其中的值称为对象值.至于为什么要加"临时"这两个字,我们稍后再解释. 我们在用复合字面量初始化一个临时对象池的时候可以为它唯一的公开字段New赋值.

《C++并发编程实战》——第1章 你好,C++并发世界

第1章 你好,C++并发世界 C++并发编程实战本章主要内容 何谓并发和多线程为什么要在应用程序中使用并发和多线程C++并发支持的发展历程一个简单的C++多线程程序是什么样的这是令C++用户振奋的时刻.距1998年初始的C++标准发布13年后,C++标准委员会给予程序语言和它的支持库一次重大的变革.新的C++标准(也被称为C++11或C++0x)于2011年发布并带来了很多的改变,使得C++的应用更加容易并富有成效. 在C++11标准中一个最重要的新特性就是支持多线程程序.这是C++标准第一次在

《Java 7并发编程实战手册》第四章线程执行器

感谢人民邮电大学授权并发网发布此书样章,新书购买传送门=>当当网 本章将介绍下列内容: 创建线程执行器 创建固定大小的线程执行器 在执行器中执行任务并返回结果 运行多个任务并处理第一个结果 运行多个任务并处理所有结果 在执行器中延时执行任务 在执行器中周期性执行任务 在执行器中取消任务 在执行器中控制任务的完成 在执行器中分离任务的启动与结果的处理 处理在执行器中被拒绝的任务 4.1 简介 通常,使用Java来开发一个简单的并发应用程序时,会创建一些 Runnable 对象,然后创建对应的 Th