C 并发编程(C 11 到 C 17 )
扫描二维码
随时随地手机看文章
↓推荐关注↓
为什么要并发编程
大型的软件项目常常包含非常多的任务需要处理。例如:对于大量数据的数据流处理,或者是包含复杂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 gcc
brew 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/test
sudo apt-get update
sudo 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;
}
对于这段代码说明如下:
- 为了使用多线程的接口,我们需要#include
头文件。 - 为了简化声明,本文中的代码都将using namespace std;。
- 新建线程的入口是一个普通的函数,它并没有什么特别的地方。
- 创建线程的方式就是构造一个thread对象,并指定入口函数。与普通对象不一样的是,此时编译器便会为我们创建一个新的操作系统线程,并在新的线程中执行我们的入口函数。
- 关于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(