C++的并发世界(零)——何为并发

0.并发

最简单和最基本的并发,是指两个或更多独立的活动同时发生。并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时作不同的动作,还 有我们每个人都过着相互独立的生活——当我在游泳的时候,你可以看球赛,等等。计算机领域的并发指的是在单个系统里同时执行多个独立的任务,而非顺序的进行一些活动。

以前,大多数计算机只有一个处理器,具有单个处理单元(processing unit)或核心(core),如今 还有很多这样的台式机。这种机器只能在某一时刻执行一个任务,不过它可以每秒进行多次 任务切换。通过“这个任务做一会,再切换到别的任务,再做一会儿”的方式,让任务看起来是 并行执行的。这种方式称为“任务切换(task switching)”。如今,我们仍然将这样的系统称为并发 (concurrency):因为任务切换得太快,以至于无法感觉到任务在何时会被暂时挂起,而切换另一个任务。任务切换会给用户和应用程序造成一种“并发的假象”。因为这种假象,当应用在任务切换的环境下和真正并发环境下执行相比,行为还是有着微妙的不同。特别是对内存模型不正确的假设在多线程环境中可能不会出现。

多处理器计算机用于服务器和高性能计算已有多年。基于单芯多核处理器(多核处理器)的台式 机,也越来越大众化。无论拥有几个处理器,这些机器都能够真正的并行多个任务。我们称 其为“硬件并发(hardware concurrency)”。

下图显示了一个计算机处理恰好两个任务时的理想情景,每个任务被分为10个相等大小的块。在一个双核机器(具有两个处理核心)上,每个任务可以在各自的处理核心上执行。在单核机器上做任务切换时,每个任务的块交织进行。但它们中间有一小段分隔(图中所示灰色分隔条的厚度大于双核机器的分隔条);为了实现交织进行,系统每次从一个任务切换到另一个时都 需要切换一次上下文(context switch),任务切换也有时间开销。进行上下文的切换时,操作 系统必须为当前运行的任务保存CPU的状态和指令指针,并计算出要切换到哪个任务,并 即将切换到的任务重新加载处理器状态。然后,CPU可能要将新任务的指令和数据的内存载入到缓存中,这会阻止CPU执行任何指令,从而造成的更多的延迟。

有些处理器可以在一个核心上执行多个线程,但硬件并发在多处理器或多核系统上效果更加显著。硬件线程(hardware threads)最重要的因素是数量,也就是硬件上可以并发运行多少独 立的任务。即便是具有真正硬件并发的系统,也很容易拥有比硬件“可并行最大任务数”还要多 的任务需要执行,所以任务切换在这些情况下仍然适用。例如,在一个典型的台式计算机上 可能会有成百上千个的任务在运行,即便是在计算机处于空闲时,还是会有后台任务在运 行。正是任务切换使得这些后台任务可以运行,并使得你可以同时运行文字处理器、下图显示了四个任务在双核处理器上的任务切 换,仍然是将任务整齐地划分为同等大小块的理想情况。实际上,许多因素会使得分割不均 和调度不规则。部分因素影响并行代码性能的因素。 无论应用程序在单核处理器,还是多核处理器上运行;也不论是任务切换还是真正的硬件并发,这里提到的技术、功能和类都能使用得到。如何使用并发,将很大程度上取决于可用的硬件并发。

每个开发人员代表一个线程,每个办公室代表一个处理器。第一种途径是有多个单线程的进程,这就类似让每个开发人员拥有自己的办公室,而第二种途径是在单一进程里有多个线程,如同一个办公室里有两个开发人员。让我们 在一个应用程序中简单的分析一下这两种途径。注意:不要把办公室当成进程了,进程可以当成是开发项目。

新C++线程库很大程度上,特别是,Boost线程库作为新类库的主要模型,很多类与Boost库中的相关类有着相同名称和结构。随着C++标准的进 步,Boost线程库也配合着C++标准在许多方面做出改变,因此之前使用Boost的用户将会发 现自己非常熟悉C++11的线程库。

1.多进程

多进程并发是并发的第一种方法,是将应用程序分为多个独立的进程,它们在同一时刻运行,就像同 时进行网页浏览和文字处理一样,独立的进程可以通过进程间常规的通信渠道 传递讯息(信号、套接字、文件、管道等等)。不过,这种进程之间的通信通常不是设置复杂, 就是速度慢,这是因为操作系统会在进程间提供了一定的保护措施,以避免一个进程去修改 另一个进程的数据。还一个缺点是,运行多个进程所需的固定开销:需要时间启动进程,操 作系统需要内部资源来管理进程等等。当然,以上的机制也不是一无是处:操作系统在进程间提供的附加保护操作和更高级别的通 信机制,意味着可以更容易编写安全(safe)的并发代码。实际上,在类似于Erlang编程环境 中,将进程作为并发的基本构造块。 使用独立的进程,实现并发还有一个额外的优势———可以使用远程连接(可能需要联网)的方 式,在不同的机器上运行独立的进程。虽然,这增加了通信成本,但在设计精良的系统上, 这可能是一个提高并行可用行和性能的低成本方式。

2.多线程

多线程并发是并发的另一个途径,在单个进程中运行多个线程。线程很像轻量级的进程:每个线程相互独 立运行,且线程可以在不同的指令序列中运行。但是,进程中的所有线程都共享地址空间, 并且所有线程访问到大部分数据———全局变量仍然是全局的,指针、对象的引用或数据可 以在线程之间传递。虽然,进程之间通常共享内存,但这种共享通常也是难以建立,且难以 管理。因为,同一数据的内存地址在不同的进程中是不相同。地址空间共享,以及缺少线程间数据的保护,使得操作系统的记录工作量减小,所以使用多 线程相关的开销远远小于使用多个进程。不过,共享内存的灵活性是有代价的:如果数据要被多个线程访问,那么程序员必须确保每个线程所访问到的数据是一致的,只要在编写代码时适当地注意即可。多个单线程/进程间的通信(包含启动)要比单一进程中的多线程间的通信(包括启动)的开销大, 若不考虑共享内存可能会带来的问题,多线程将会成为主流语言(包括C++)更青睐的并发途 径。此外,C++标准并未对进程间通信提供任何原生支持,所以使用多进程的方式实现,这会依赖与平台相关的API。

3.如何使用并发?

关注点分离(SOC)和性能。事实上,它们应该是使用并发的唯一原因;如果 你观察的足够仔细,所有因素都可以归结到其中的一个原因。编写软件时,分离关注点是个好主意;通过将相关的代码与无关的代码分离,可以使程序更 容易理解和测试,从而减少出错的可能性。这会给响应性带来一些错觉,因为用户界面线程通常可以立即响应用户的请求,在当请求传 达给忙碌线程,这时的相应可以是简单地显示代表忙碌的光标或“请等待”字样的消息。类似 地,独立的线程通常用来执行那些必须在后台持续运行的任务,例如,桌面搜索程序中监视文件系统变化的任务。因为它们之间的交互清晰可辨,所以这种方式会使每个线程的逻辑变的更加简单。

多处理器系统已经存在了几十年,但直到最近,它们也只在超级计算机、大型机和大型服务 器系统中才能看到。然而,芯片制造商越来越倾向于多核芯片的设计,即在单个芯片上集成 2、4、16或更多的处理器,从而获取更好的性能。因此,多核台式计算机、多核嵌入式设 备,现在越来越普遍。它们计算能力的提高不是源自使单一任务运行的更快,而是并行运行多个任务。两种方式利用并发提高性能:第一,将一个单个任务分成几部分,且各自并行运行,从而降 低总运行时间。这就是任务并行(task parallelism)。虽然这听起来很直观,但它是一个相当 复杂的过程,因为在各个部分之间可能存在着依赖。区别可能是在过程方面——一个线程执 行算法的一部分,而另一个线程执行算法的另一个部分——或是在数据方面——每个线程在 不同的数据部分上执行相同的操作(第二种方式)。后一种方法被称为数据并行(data parallelism)。易受这种并行影响的算法常被称为易并行(embarrassingly parallel)。尽管你会受到易并行化 代码影响,但这对于你来说是一件好事:我曾遇到过自然并行(naturally parallel)和便利并发 (conveniently concurrent)的算法。易并行算法具有良好的可扩展特性——当可用硬件线程的 数量增加时,算法的并行性也会随之增加。这种算法能很好的体现人多力量大。如果算法中 有不易并行的部分,你可以把算法划分成固定(不可扩展)数量的并行任务。

4.什么时候不使用并发?

(1)收益比不上成本:使用并发的代码在很多情况下难以理解,因此编写和维护的多线程代码就会 产生直接的脑力成本,同时额外的复杂性也可能引起更多的错误。除非潜在的性能增益足够 大或关注点分离地足够清晰,能抵消所需的额外的开发时间以及与维护多线程代码相关的额 外成本(代码正确的前提下);否则,别用并发。

(2)性能增益可能会小于预期:因为操作系统需要分配内核相关资源和堆栈空间,所以 在启动线程时存在固有的开销,然后才能把新线程加入调度器中,所有这一切都需要时间。 如果在线程上的任务完成得很快,那么任务实际执行的时间要比启动线程的时间小很多,这 就会导致应用程序的整体性能还不如直接使用“产生线程”的方式。此外,线程是有限的资源。如果让太多的线程同时运行,则会消耗很多操作系统资源,从而 使得操作系统整体上运行得更加缓慢。不仅如此,因为每个线程都需要一个独立的堆栈空 间,所以运行太多的线程也会耗尽进程的可用内存或地址空间。尽管线程池可以用来限制线程的数量,但这并不是灵丹妙药,它也有自己的问题。如果性能收益的潜力仅次于设计清晰或关注点分离,可 能也值得使用多线程设计。

5.第一个多线程代码

依然是熟悉的hello world!

#include <iostream>
#include <thread>

void hello()
{
	std::cout << "hello world!" << std::endl;
}

void main()
{
	std::thread first(hello);
	first.join();
}

第一个区别是增加了 #include <thread>,标准C++库中对多线程支持的声明在新的头文件 中:管理线程的函数和类在中声明,而保护共享数据的函数和类在其他头文件中声明。其次,写信息的代码被移动到了一个独立的函数中。因为每个线程都必须具有一个初始函数 (initial function),新线程的执行在这里开始。对于应用程序来说,初始线程是main(),但是对于其他线程,可以在 std::thread 对象的构造函数中指定,std::thread 对象拥有新函数hello()作为其初始函数。

下一个区别:与直接写入标准输出或是从main()调用hello()不同,该程序启动了一个全新的线 程来实现,将线程数量一分为二——初始线程始于main(),而新线程始于hello()。 新的线程启动之后③,初始线程继续执行。如果它不等待新线程结束,它就将自顾自地继续运 行到main()的结束,从而结束程序——有可能发生在新线程运行之前。

 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值