您当前的位置: 首页 >  c++

C++ 并发入门

蔚1 发布时间:2018-11-06 11:50:41 ,浏览量:5

从我的平时搜索来看,并发这个词和 Java 一起出现的频率最高,而 C++ 作为一个古老,繁琐难懂之处非常多的语言,在11中也正式加入了并发相关的内容。虽然我不懂 Java,但是以我个人的感受,作为一个更接近底层的语言,了解和学习 C++ 的并发对于理解计算机本身是有帮助的。本 Chat 希望能给想了解 C++ 并发的人提供一些帮助。

本 Chat 主要分为以下几个部分:

  1. 现代 C++ 多线程简介,
  2. 并发和多线程有啥区别,现代 C++ 为什么要引进这个?
  3. Feature,Promise,Task 相关的在 C++ 中到底是什么,之间有何关系?
前言

这个 chat 从一张图开始吧,这张图第一次看到时候被他形象的描述所折服。

enter image description here

大学时候写的程序,包括多线程的程序大多数都成为图中这个状态,虽然那个时候最多也就只有两个 CPU,但是只要你打开任务管理器(那个时候,我还是一个只知道在 windows 上写程序的懵懂大学生),你会看到两个 CPU 曲线中,一个旋转跳跃不停歇,另一个平坦淡定闭着眼。这样的程序真是多线程程序中的耻辱,很多时候不仅不会比单线程更快,而且还承担了上下文切换,难以看懂和维护的开销。

而随着时代的发展,CPU 数量的增多,并发这个词越来越多的被提及,这里面,JAVA 的并发感觉是涉及书的数量最多的一个,当然,这也可能是因为是在中国的错觉,毕竟这只是一个独立于语言的概念。但是既然这个 chat 的标题是 c++ 并发入门,那么,我就尽我最大的努力从不同的语言的角度瞎扯扯我所知道的并发。既然这一篇是入门,那么我 觉得如果看完这一篇让你知道了什么是并发,可以在别人说到相关概念的时候,知道有那么一回事,我就算成功了。后面我还会介绍更深层次的线程如何同步,c++ 内存模型和现实中我遇到的多线程的实际的坑等等吧,如果有兴趣,可以关注我后续的文章。

一、并发是什么?

既然这个 chat 的题目是并发入门,那么第一部分自然应该介绍下什么是并发。

作为一个程序员,最基本的应该知道你所写的任何语言的程序,最后对于计算机就是 CPU 执行的一条又一条的指令,而如果只有一个 CPU,很明显,在我们这个存在的这个物理世界里,一个时间只能执行一条命令。

而每个程序都是由若干条命令所组成的,而在现代的计算机中,不可能一次只跑一个命令,所以 CPU 会以极快的速度不停的切换不同的程序的命令,注意,是不同程序,这个速度快到你根本感知不到,而以为计算机在同时执行很多程序。比如你可以一边听歌一边打字,在现代计算机中,你不会感受到有任何的卡顿。

说完了同时执行多个程序,或者说进程,随着技术的发展和进步,计算机科学家们就想了,能不能在一个程序中同时执行多个小的部分呢?这些个小的部分虽然共享一样的数据,但是却可以同时做着不同的任务。比如说,在一个带有 UI 的程序中,UI 不应该因为后台的计算而卡住,这样,用户会以为程序已经死机了。于是,就有了线程的概念,在 unix 上甚至就叫做轻量化进程,你可以看到这里有很多的相似性。

于是在一个 CPU 中多线程程序的执行大概模型是这样的:

enter image description here

T1 和 T2 代表两个不同的线程,整个框代表一个 CPU,在执行期间,不同的线程分别占用不同的时间片,然后由操作系统负责调度执行不同的线程。但是很明显,由于内存,寄存器等等都是有限的,所以在执行下一个线程的时候不得不把上一个线程的一些数据先保存起来,这样下一次执行该线程的时候才能继续正确的执行。

这样多线程的好处就是可以更大的利用 CPU 的空闲时间,而缺点就是也要付出一些其他的代价,所以如果有人问你,多线程程序是否一定比单线程程序快,答案是否定的。这个道理就像,如果有 3 个程序员同时编写一个项目,不可避免需要相互的交流,如果这个交流的时间远远大于编码的时间,那么抛开代码质量来说,可能还不如一个程序员来的快。

计算机的世界永远发展的很快,慢慢的厂家越来越喜欢使用多核 CPU,这样的好处就是增加里物理空间,可以实现真正的在同一个时间同时跑两条命令,这就是所谓的 “ 并发 ”。

但是这只是硬件层面给你提供了实现并发的机会,在最开始,由于 c++ 缺乏对这方面的支持,你所写的 “ 并发 ” 程序大概率就是想最开始的那张图一样,理想情况下,并发应该是想下图一样:

enter image description here

你看,理想情况下只需要一般的时间就可以完成同样 8 个指令,相对于在一个 CPU 上只需要一半的时间就能解决问题。对于一个程序来说,这简直是巨大的提升。

但是,也说了,是理想情况,因为在现实中你很难找不同任务(线程)间完全独立的情况。不过,也不用灰心,除了一组数据求和这样的例子之外,我至少还能说出两个实际中可以完全应用这个理想状况的例子:

比如,有一组数据需要从 A 端通过网络传送到 B 端,在传送数据上面,可以视为没有依赖,因为完全可以在接收端再按照设计好的方式拼接起来。这里在发送的时候就可以完全并发的执行,绝对大大减少时间。

还有,比如你需要备份数据库的某张表,备份的过程大多数时候也可以完全并发的进行。

而剩下的如何尽量,我说的是尽量,处理不理想的情况,我会在进阶内容一,同步,之中详细描述,因为如果写在一篇里面,也太长了。

二、c++ 与多线程

c++ 这门语言,我觉得可以当得上博大精深四个字,多线程的概念和技术我觉得可以当得上巧妙无比的设计,然而这样看起来都很伟大的东西,却在 c++11 才得到正式的结合,为什么?

如果你是 linux/unix 的程序员或者看过一些 linux/unix 的书籍,你会很好奇,为什么多线程相关的都放在书籍的很后面,而且还有种生怕你看到有这么一种东西的感觉。因为线程一直都不是 linux/unix 所推崇的技术,甚至有传言说 linus 本人就非常不喜欢线程的概念。

而作为一种古老而又强大的语言,c,一直是 linux/unix 的最主流语言,毕竟在那个硬件比程序员还要贵很多的年代,如何在有限的资源上写出靠谱的程序是很重要的。而后来演化而来的 c++(同样也被很多c程序员所不喜欢的),很多时候都多少跟着 c 语言的概念来。加上 linux/unix 奉行着小而精悍的原则,翻译成英文就是那句熟悉的 keep it simple,stupid,简称 KISS 原则。

这个系统的的设计就是走的多进程的路子,在其上面,进程间通信十分的方便。加上进程互相有着独立的空间,不会污染其他进程的数据,天然的隔离性使得很多时候程序的稳定性有很大的保障。所以线程并不是一个很受待见的概念。

但是还是有很多非官方的 “ 官方 ” c++ 库中提供了对线程的良好支持,比如大名鼎鼎的 pthread,以及 windows 上面的 windows thread,给程序员们提供了一个编写多线程程序的接口。后来 c++ 这种传统语言的使用份额被 java,python 这些语言慢慢的蚕食,标准委员会开始考虑让 c++ 更接近于现代的语言,所以在 c++ 11 中加入了很多现代语言基本是标配的东西,比如 lambda 表达式,比如智能指针,比如多线程与并发。

好了,废话差不多扯完了,那么就让我回到正题——多线程上面来。像众多介绍多线程以及并发的文章一样,我也选择求和作为入门的例子,但是我保证后面出现的求和的例子都是我自己写的,虽然很多时候写法不是那么的简洁,但是我都想着要突出重点是多线程。

首先,不考虑多线程的情况下,写一个求和函数很简单:

enter image description here

这个函数使用了 c++ 自带的 vector 数据结构和 accumulate 算法,整个逻辑很简单,就是从迭代器 first 到 last 的数据求和并且返回。如果到这一步你已经看不懂了,那么以下的内容可以大胆的说你也不会能更懂了。我的建议是可以先看看 c++ 语法的书籍或者 google 一下就再回来看看,当然,是你还有兴趣的情况下。

怎么使用这个函数?只要声明好你的向量,然后直接传入迭代器就行了。

enter image description here

这里面除了调用还有其他一些乱七八糟的东西,不用担心,这些前面的后面的只是为了做一个目的,计算函数执行的时间并且打印出来,目的是为了和后面的多线程版本进行对比。那么这个传进去的 largearray 到底有多大呢?我定义的是一个这么大的向量。

enter image description here

为了不至于求出的和溢出,稍微做了一点点微小的处理,毕竟展示的结果好看才能吸引人的第一注意力嘛。所以,我一直说虽然你是个程序员,别小看 ppt 做的好的,这不仅是一种能力,更是一种能让你的能力得到更好展现的一种方式。

说完单线程的了,那么多线程怎么弄呢?在 c++ 中要使用多线程,需要 #include< thread > 这个头文件。声明一个线程并且让他执行很简单:

enter image description here

我这里用很笨的办法声明了 5 个线程,只要使用 std::thread 的构造函数就能声明 c++ 中的线程,其构造函数里面第一个参数表示一个函数指针,你要实在无法理解函数指针,你就认为是一个函数名字吧。后面是函数的需要的参数,这里头两个参数是迭代器,最后一个是一个值的应用。

至于 std:ref 这里也不好展开说明,但是你可以理解为这是告诉编译器,我传递的是一个对于我声明的变量的引用进这个函数。而线程具体要做的事情就是写在这个线程函数之中。

这里面 GetSumT 具体就是这样的:

enter image description here

这里我觉得需要注意的地方是返回值,c++ 中一个标准的线程函数返回值只能是 void,如果你用其他的会怎么样呢?也能行,但是你拿不到这个返回值。 那么,如果你需要从线程中返回一个值怎么办呢?就目前你知道知识来说,只能采取传递引用的办法了。在这个函数中,我将结果通过最后一个返回值返回。

回到最前面所说的,为什么需要多线程?因为你希望能够把一个任务同时的执行,而不是只能顺序的执行,以达到节省时间的目的。而求和,把所有的数据一起求和与分段求和再加起来没有区别,这是加法的结合律。所以,很自然的,将这样一个大的部分分成好几部分再把最后的结果拼起来,就能得到最后的答案。

听起来是个不错的主意,既然按照上面的代码,看来已经准备好了是划分成 5 个部分,然后再合起来了,而且线程也已经创建好了,剩下来就是把所有的返回值相加就行了。既然有了方向,再不行动那要么就是懒,不然就只能是傻了。同时运行单线程和多线程的代码看看结果:

enter image description here

这是在我机器上运行的结果,我发誓我的代码是按照我上面的逻辑写的,但是多线程版本的结果是 0,运行时间也是 0 ms,最最可怕的是,程序最后崩溃了。为什么?

让我们再次回到多线程本身上面来,操作系统上面学过,一个进程至少都有一个线程,在 c++ 中,你可以认为 main 就是这个至少的线程。在调用 thread 的时候,你就在这个线程之外创建了一个独立的线程,这里的独立是真正的独立。这个线程只要创建了并且开始运行了,你的主线程就完全和他没有什么联系了,你不知道 cpu 会什么时候调度他运行,什么时候会结束运行,一切都是独立,自由而又未知的。

所以说,在上面错误的输出中,a-e 在没有获得任何赋值就被输出,也就是说主线程先执行完了,而 5 个子线程都还没有执行完,同样由于主线程执行完了并退出了,导致了未定义的行为,从而导致了 exception。

说到了这里,就需要有个办法知道子线程执行完了,或者换个角度来说,主线程的汇总任务在子线程都完毕之后再进行。为了达到这个目的,c++ 的 thread 中定义了 join 函数,在 main 中调用 join 函数就表示等待子线程执行完毕,让我们加上这个函数。

enter image description here

这里,等待所有五个线程执行完毕,再执行 a-e 的加法工作,既然新一轮的设计已经出来了,那么下面就看看运行结果:

enter image description here

可以看到,单线程和多线程版本的程序结果一样,但是分为5个同时计算的多线程程序明显比单线程版本要快了不少。这也实力验证了前面所说的多线程的作用。

而 c++ 的线程,除了 join,还有一种叫做 detach,detach 其实就是表示不用等待该线程结束,脱离,完全放飞自我,这种也有很多的用处,比如一个你需要建立一个暗中观察的线程,默默查询程序某种状态的线程,俗名,守护线程。这种线程会在主线程销毁之后自动销毁。具体的就不展开了,因为篇幅真的是不够了,而且这个也不算是进阶的内容,所以有兴趣的可以自己去查查。

如果从入门来说,线程就差不多这些内容,知道线程如何创建,怎样执行,怎样销毁,更多的内容,我会专门写一篇 chat 来说明的。

三、更高端的并发方法

如果要我说,线程算是一个底层的,传统的并发实现方法,新的 c++ 除了提供了 thread 库,还提供了一套更加封装好并发编程方法,在这个入门的文章里,我觉得如果能介绍清楚 asyncfeaturepackaged_task 和 promise 我就算成功了。

在介绍所谓更高端的方法之前,我觉得首先要阐述两个重要的概念,异步调用和并发执行。由于我以前做的最多的一个方面是网络方面的变成,这两个概念犹如阻塞非阻塞和同步异步一样。如果面试的时候能回答的清楚,一般面试官会对你有更好的评价,至少我是这样的。废话不再多说了。

所谓异步调用指的是某个函数在某个其他的地方被调用,而并发执行是说在执行的时候一起执行,一个是编译时的概念,一个是运行时。这两个是完全不相关的事情。为什么要强调这个,等介绍所谓的高端并发方法你就明白了。

让我们回到最初的起点,再回想一下普通情况下如何调用函数,一般来说只要有基础的人都知道,直接写下函数的名字,直接调用就好了。 而新的 c++ 中提供了一个新的关键字,叫做 async,如果你使用该方法调用函数的话,比如说:

auto asyncCall = std::async(GetSum,largeArray.begin(),largeArray.end())

有点像启动一个新的线程,使用 async 关键字,然后第一个参数是调用的函数的名字,后面是要传进去的参数。这里唯一的不同的是我调用的是非多线程版本的函数,没有传递一个 ref 的参数进去。

这个就叫做异步调用,但是这个 GetSum 函数到底是怎么执行的,很高兴告诉你,未知。这个函数真正的执行既可以是在你写了上面一段话后,也可以在你的程序在 main 里执行了很久以后,但是必须是在被需要之前执行。换句话说,执行既可以是并发的,也可以不是的,并没有什么联系。

如果你还记得 thread 是没有办法读到返回值的,那么你就会有一个疑问,这个函数怎么取得返回值?答案是,c++ 提供了另外一个关键字,feature,上面的 auto 的类型推断就是 std::feature< int >,如果你需要取得返回值,只要调用:

    asyncCall.get()

得到的就是一个正确的累加值,就是这么简单。至少你可以看到,这里比 thread 的优势是你不用维护一个对象再通过 ref 的方法传递进去。

所以对于 feature 这个关键词的取名,可以理解为,你想要调用一个函数,但是你并不马上需要他的返回值,而是在未来的可能某个时候需要这个返回值,当你需要的时候,你只要调用某个函数就能拿到这个返回值。所以使用 feature 表示未来,当然,这是我自己的理解。

下面更深层次的一个概念是 packaged_task,这个最简单的理解就是对于 async 的实现,可以进行更进一步的细粒度的控制。

enter image description here

我觉得结合代码更容易阐述明白我想要说明的意思,首先,定义一个 packaged_task,这个 task 是需要执行 GetSum 函数,而我们可以从这个 task 中获得一个 future。

再联想一下前面所说的异步调用和并发执行没有什么关系,那么下面这一步就是很好的说明了,在这里我是想要有一个并发执行,所以使用了一个线程,然后移入需要的 task 与参数。线程会立马开始执行,至于怎么抢占 CPU 就不是我们关心的问题了,然后一如上面所说的,在需要的时候调用 get 就能获得需要的返回值。

这里,完全可以不用线程来执行这个 task,你可以修改 thread 哪一行而直接使用:

enter image description here

一样会执行,只是,现在不是并发调用了。再一次印证了和前面所说的两个概念并没有什么关联。

说完 task,主要的就只剩 promise了,要说详细的,task 可以使用 promise 来实现。通俗的说,你可以从 promise 中导出一个 feature,然后通过 promise 来传递信号。

具体怎么做的,类似于下面的代码:

enter image description here

那么你肯定要问了,promise 怎么实现一个 task,我觉得这个作为入门可能不合适了。

好了,入门的内容就写到这里了,感觉已经很长了,如果你对这个话题还有兴趣,请继续关注我后面的两篇文章吧。

本文首发于GitChat,未经授权不得转载,转载需与GitChat联系。

阅读全文: http://gitbook.cn/gitchat/activity/5b6d4cded0ff8f2e71c3d5dd

您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。

FtooAtPSkEJwnW-9xkCLqSTRpBKX

关注
打赏
1688896170
查看更多评论

蔚1

暂无认证

  • 5浏览

    0关注

    4645博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文
立即登录/注册

微信扫码登录

0.0775s