当前位置:首页 > 公众号精选 > CPP开发者
[导读]↓推荐关注↓为什么要并发编程大型的软件项目常常包含非常多的任务需要处理。例如:对于大量数据的数据流处理,或者是包含复杂GUI界面的应用程序。如果将所有的任务都以串行的方式执行,则整个系统的效率将会非常低下,应用程序的用户体验会非常的差。另一方面,自上个世纪六七十年代英特尔创始人之...


推荐关注↓


为什么要并发编程


大型的软件项目常常包含非常多的任务需要处理。例如:对于大量数据的数据流处理,或者是包含复杂GUI界面的应用程序。如果将所有的任务都以串行的方式执行,则整个系统的效率将会非常低下,应用程序的用户体验会非常的差。
另一方面,自上个世纪六七十年代英特尔创始人之一 Gordon Moore 提出 摩尔定义 以来,CPU频率以每18个月翻一番的指数速度增长。但这一增长在最近的十年已经基本停滞,大家会发现曾经有过一段时间CPU的频率从3G到达4G,但在这之后就停滞不前了。因此最近的新款CPU也基本上都是3G左右的频率。相应的,CPU以更多核的形式在增长。目前的Intel i7有8核的版本,Xeon处理器达到了28核。并且,最近几年手机上使用的CPU也基本上是4核或者8核的了。
由此,掌握并发编程技术,利用多处理器来提升软件项目的性能将是软件工程师的一项基本技能。
本文以C 语言为例,讲解如何进行并发编程。并尽可能涉及C 11,C 14以及C 17中的主要内容。

并发与并行


并发(Concurrent)与并行(Parallel)都是很常见的术语。
Erlang之父Joe Armstrong曾经以人们使用咖啡机的场景为例描述了这两个术语。如下图所示:

  • 并发:如果多个队列可以交替使用某台咖啡机,则这一行为就是并发的。
  • 并行:如果存在多台咖啡机可以被多个队列交替使用,则就是并行。

这里队列中的每个人类比于计算机的任务,咖啡机类比于计算机处理器。因此:并发和并行都是在多任务的环境下的讨论。
更严格的来说:如果一个系统支持多个动作同时存在,那么这个系统就是一个并发系统。如果这个系统还支持多个动作(物理时间上)同时执行,那么这个系统就是一个并行系统。
你可能已经看出,“并行”其实是“并发”的子集。它们的区别在于是否具有多个处理器。如果存在多个处理器同时执行多个线程,就是并行。
在不考虑处理器数量的情况下,我们统称之为“并发”。

进程与线程


进程与线程是操作系统的基本概念。无论是桌面系统:MacOS,Linux,Windows,还是移动操作系统:Android,iOS,都存在进程和线程的概念。
进程(英语:process),是指计算机中已运行的程序。进程为曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。-- 维基百科

关于这两个概念在任何一本操作系统书上都可以找到定义。网上也有很多文章对它们进行了解释。因此这里不再赘述,这里仅仅提及一下它们与编程的关系。
对于绝大部分编程语言或者编程环境来说,我们所写的程序都会在一个进程中运行。一个进程至少会包含一个线程。这个线程我们通常称之为主线程。
在默认的情况下,我们写的代码都是在进程的主线程中运行,除非开发者在程序中创建了新的线程。
不同编程语言的线程环境会不一样,Java语言在很早就支持了多线程接口。(Java程序在Java虚拟机中运行,虚拟机通常还会包含自己特有的线程,例如垃圾回收线程。)。而对于JavaScript这样的语言来说,它就没有多线程的概念。
当我们只有一个处理器时,所有的进程或线程会分时占用这个处理器。但如果系统中存在多个处理器时,则就可能有多个任务并行的运行在不同的处理器上。
下面两幅图以不同颜色的矩形代表不同的任务(可能是进程,也可能是线程)来描述它们可能在处理器上执行的顺序。
下图是单核处理器的情况:

下面是四核处理器的情况:

任务会在何时占有处理器,通常是由操作系统的调度策略决定的。在《Android系统上的进程管理:进程的调度》一文中,我们介绍过Linux的调度策略。
当我们在开发跨平台的软件时,我们不应当对调度策略做任何假设,而应该抱有“系统可能以任意顺序来调度我的任务”这样的想法。

并发系统的性能


开发并发系统最主要的动机就是提升系统性能(事实上,这是以增加复杂度为代价的)。
但我们需要知道,单纯的使用多线程并不一定能提升系统性能(当然,也并非线程越多系统的性能就越好)。从上面的两幅图我们就可以直观的感受到:线程(任务)的数量要根据具体的处理器数量来决定。假设只有一个处理器,那么划分太多线程可能会适得其反。因为很多时间都花在任务切换上了。
因此,在设计并发系统之前,一方面我们需要做好对于硬件性能的了解,另一方面需要对我们的任务有足够的认识。
关于这一点,你可能需要了解一下阿姆达尔定律了。对于这个定律,简单来说:我们想要预先意识到那些任务是可以并行的,那些是无法并行的。只有明确了任务的性质,才能有的放矢的进行优化。这个定律告诉了我们将系统并行之后性能收益的上限。
关于阿姆达尔定律在Linux系统监测工具sysstat介绍一文中已经介绍过,因此这里不再赘述。

C 与并发编程


前面我们已经了解到,并非所有的语言都提供了多线程的环境。
即便是C 语言,直到C 11标准之前,也是没有多线程支持的。在这种情况下,Linux/Unix平台下的开发者通常会使用POSIX Threads,Windows上的开发者也会有相应的接口。但很明显,这些API都只针对特定的操作系统平台,可移植性较差。如果要同时支持Linux和Windows系统,你可能要写两套代码。
相较而言,Java自JDK 1.0就包含了多线程模型。

这个状态在C 11标准发布之后得到了改变。并且,在C 14和C 17标准中又对并发编程机制进行了增强。
下图是最近几个版本的C 标准特性的线路图。

编译器与C 标准


编译器对于语言特性的支持是逐步完成的。想要使用特定的特性你需要相应版本的编译器。
  • GCC对于C 特性的支持请参见这里:C Standards Support in GCC。
  • Clang对于C 特性的支持请参见这里:C Support in Clang。

下面两个表格列出了C 标准和相应编译器的版本对照:
  • C 标准与相应的GCC版本要求如下:
  • C 标准与相应的Clang版本要求如下:

默认情况下编译器是以较低的标准来进行编译的,如果希望使用新的标准,你需要通过编译参数-std=c xx告知编译器,例如:
g -std=c 17 your_file.cpp -o your_program

测试环境


本文的源码可以到下载我的github上获取,地址:paulQuei/cpp-concurrency。你可以直接通过下面这条命令获取源码:
git clone https://github.com/paulQuei/cpp-concurrency.git
源码下载之后,你可以通过任何文本编辑器浏览源码。如果希望编译和运行程序,你还需要按照下面的内容来准备环境。
本文中的源码使用cmake编译,只有cmake 3.8以上的版本才支持C 17,所以你需要安装这个或者更新版本的cmake。
另外,截止目前(2019年10月)为止,clang编译器还不支持并行算法。
但是gcc-9是支持的。因此想要编译和运行这部分代码,你需要安装gcc 9.0或更新的版本。并且,gcc-9还要依赖Intel Threading Building Blocks才能使用并行算法以及头文件。
具体的安装方法见下文。
具体编译器对于C 特性支持的情况请参见这里:C compiler support。

安装好之后运行根目录下的下面这个命令即可:
./make_all.sh
它会完成所有的编译工作。
本文的源码在下面两个环境中经过测试,环境的准备方法如下。

MacOS


在Mac上,我使用brew工具安装gcc以及tbb库。
考虑到其他人与我的环境可能会有所差异,所以需要手动告知tbb库的安装路径。读者需要执行下面这些命令来准备环境:

rew install gccbrew insbtall tbb
export tbb_path=/usr/local/Cellar/tbb/2019_U8/./make_all.sh
注意,请通过运行g -9命令以确认gcc的版本是否正确,如果版本较低,则需要通过brew命令将其升级到新版本:
brew upgrade gcc

Ubuntu


Ubuntu上,通过下面的命令安装gcc-9。
sudo add-apt-repository ppa:ubuntu-toolchain-r/testsudo apt-get updatesudo apt install gcc-9 g -9
但安装tbb库就有些麻烦了。这是因为Ubuntu 16.04默认关联的版本是较低的,直接安装是无法使用的。我们需要安装更新的版本。联网安装的方式步骤繁琐,所以可以通过下载包的方式进行安装,我已经将这需要的两个文件放到的这里:
  • libtbb2_2019~U8-1_amd64.deb
  • libtbb-dev_2019~U8-1_amd64.deb

如果需要,你可以下载后通过apt命令安装即可:
sudo apt install ~/Downloads/libtbb2_2019~U8-1_amd64.deb sudo apt install ~/Downloads/libtbb-dev_2019~U8-1_amd64.deb

线程


创建线程


创建线程非常的简单的,下面就是一个使用了多线程的Hello World示例:
// 01_hello_thread.cpp
#include #include // ①
using namespace std; // ②
void hello() { // ③ cout << "Hello World from new thread." << endl;}
int main() { thread t(hello); // ④ t.join(); // ⑤
return 0;}
对于这段代码说明如下:
  1. 为了使用多线程的接口,我们需要#include 头文件。
  2. 为了简化声明,本文中的代码都将using namespace std;。
  3. 新建线程的入口是一个普通的函数,它并没有什么特别的地方。
  4. 创建线程的方式就是构造一个thread对象,并指定入口函数。与普通对象不一样的是,此时编译器便会为我们创建一个新的操作系统线程,并在新的线程中执行我们的入口函数。
  5. 关于join函数在下文中讲解。

thread可以和callable类型一起工作,因此如果你熟悉lambda表达式,你可以直接用它来写线程的逻辑,像这样:
// 02_lambda_thread.cpp
#include #include
using namespace std;
int main() { thread t([] { cout << "Hello World from lambda thread." << endl; });
t.join();
return 0;}
为了减少不必要的重复,若无必要,下文中的代码将不贴出include指令以及using声明。

当然,你可以传递参数给入口函数,像下面这样:
// 03_thread_argument.cpp
void hello(string name) { cout << "Welcome to " << name << endl;}
int main() { thread t(hello, "https://paul.pub"); t.join();
return 0;}
不过需要注意的是,参数是以拷贝的形式进行传递的。因此对于拷贝耗时的对象你可能需要传递指针或者引用类型作为参数。但是,如果是传递指针或者引用,你还需要考虑参数对象的生命周期。因为线程的运行长度很可能会超过参数的生命周期(见下文detach),这个时候如果线程还在访问一个已经被销毁的对象就会出现问题。

join与detach


  • 主要API

一旦启动线程之后,我们必须决定是要等待直接它结束(通过join),还是让它独立运行(通过detach),我们必须二者选其一。如果在thread对象销毁的时候我们还没有做决定,则thread对象在析构函数出将调用std::terminate()从而导致我们的进程异常退出。
请思考在上面的代码示例中,thread对象在何时会销毁。

需要注意的是:在我们做决定的时候,很可能线程已经执行完了(例如上面的示例中线程的逻辑仅仅是一句打印,执行时间会很短)。新的线程创建之后,究竟是新的线程先执行,还是当前线程的下一条语句先执行这是不确定的,因为这是由操作系统的调度策略决定的。不过这不要紧,我们只要在thread对象销毁前做决定即可。
  • join:调用此接口时,当前线程会一直阻塞,直到目标线程执行完成(当然,很可能目标线程在此处调用之前就已经执行完成了,不过这不要紧)。因此,如果目标线程的任务非常耗时,你就要考虑好是否需要在主线程上等待它了,因此这很可能会导致主线程卡住。
  • detach:detach是让目标线程成为守护线程(daemon threads)。一旦detach之后,目标线程将独立执行,即便其对应的thread对象销毁也不影响线程的执行。并且,你无法再与之通信。

对于这两个接口,都必须是可执行的线程才有意义。你可以通过joinable()接口查询是否可以对它们进行join或者detach。

管理当前线程


  • 主要API

上面是一些在线程内部使用的API,它们用来对当前线程做一些控制。
  • yield 通常用在自己的主要任务已经完成的时候,此时希望让出处理器给其他任务使用。
  • get_id 返回当前线程的id,可以以此来标识不同的线程。
  • sleep_for 是让当前线程停止一段时间。
  • sleep_until 和sleep_for类似,但是是以具体的时间点为参数。这两个API都以chrono API(由于篇幅所限,这里不展开这方面内容)为基础。

下面是一个代码示例:
// 04_thread_self_manage.cpp
void print_time() { auto now = chrono::system_clock::now(); auto in_time_t = chrono::system_clock::to_time_t(now);
std::stringstream ss; ss << put_time(localtime(
本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除。
换一批
延伸阅读

业内消息,近日日本软件银行集团(SoftBank Group)旗下安谋国际科技公司(Arm)计划研发人工智能(AI)芯片,先成立一个AI芯片部门,目标是明年春季建立AI芯片原型产品,然后将量产工作交由代工厂制造,预估20...

关键字: ARM AI芯片

援引彭博社消息,近日新当选的熊本县知事木村隆(Takashi Kimura)表示,他已准备好确保获得广泛的支持,以吸引台积电在当地建立第三家日本芯片工厂。

关键字: 日本 台积电 芯片工厂

5 月 13 日消息,从“上海临港”微信公众号获悉,特斯拉上海储能超级工厂建设项目已完成施工许可证核发。这是特斯拉在美国本土以外的首个储能超级工厂项目,工厂计划于今年 5 月开工,明年一季度实现量产。

关键字: 特斯拉 储能

据消息源 jasonwill101 透露,高通公司目前正在重新设计骁龙 8 Gen 4 处理器,新的目标频率为 4.26GHz,这一变化主要是为了应对苹果 M4 / A18 / Pro 处理器。

关键字: 高通 骁龙 8 Gen 4 芯片

最新消息,今天凌晨 OpenAI 在春季更新直播官宣发布最新旗舰生成式 AI 模型 GPT-4o,GPT-4o 将 ChatGPT 变成一名带有文本、「视觉」与语音互动的实时语音助手。OpenAI 表示升级版的 Chat...

关键字: OpenAI 生成式 AI大模型 GPT-4o

三星电子最近进行了重大的组织重组,以增强其在下一代机器人业务方面的能力,并将其视为关键增长领域。作为重组的一部分,该公司解散了负责开发三星首款可穿戴机器人“Bot Fit”的机器人业务团队。

关键字: 三星电子 解散 Bot Fit 机器人

央视《今日说法》栏目近期报道了一名90后程序员通过开发非法视频搬运软件在不到一年的时间里获利超700万,最终获刑的案例。

关键字: 程序员 软件

业内消息,近日美国麦肯锡公司的一份报告强调了芯片行业的劳动力挑战,在美国寻求吸引更多技术工人从事半导体制造之际,许多现有员工正在重新考虑是否要留下来。

关键字: 芯片

业内消息,近日美国商务部工业与安全局(BIS)根据出口管理条例(EAR)将中国电子科技集团旗下多个研究所、中电科芯片技术(集团)有限公司、中国科学技术大学,以及北京量子信息科学研究院、本源量子等 37 个中国实体添加到实...

关键字: 实体清单

5 月 11 日消息,鲲鹏昇腾开发者大会昨日在北京中关村国际创新中心举办,主题为“心怀挚爱,共绽光芒”,会上推出了原生使能计划、启动鲲鹏昇腾科教创新卓越中心、鲲鹏昇腾原生创新汇等。

关键字: 华为 鲲鹏昇腾
关闭
关闭