本文翻译自 C++ 之父 Bjarne Stroustrup 的 C++ 之旅(A Tour of C++)一书的第 13 章 Concurrency。用短短数十页,带你一窥现代 C++ 对并发/多线程的支持。原文地址:现代 C++ 对多线程/并发的支持(上) -- 节选自 C++ 之父的 《A Tour of C++》 水平有限,难以用另一种语言原汁原味地还原,有条件的建议直接阅读原版书籍。

13 并发

13.1 介绍

并发,即同时执行多个任务,常用来提高吞吐量(通过利用多处理器进行同一个计算)或者改善响应性(等待回复的时候,允许程序的其他部分继续执行)。所有现代语言都支持并发。C++ 标准库提供了可移植、类型安全的并发支持,经过 20 多年的发展,已经几乎被所有现代硬件所支持。标准库提供的主要是系统级的并发支持,而非复杂的、更高层次的并发模型;其他库可以基于标准库,提供更高级别的并发支持。

C++ 提供了适当的内存模型(memory model)和一组原子操作(atomic operation),以支持在同一地址空间内并发执行多个线程。原子操作使得无锁编程成为可能。内存模型保证了在避免数据竞争(data races,不受控地同时访问可变数据)的前提下,一切按照预期工作。

本章将给出标准库对并发的主要支持示例:threadmutexlock()packaged_task 以及 future。这些特征直接基于操作系统构建,相较于操作系统原生支持,不会带来性能损失,也不保证会有显著的性能提升。

那为什么要用标准库而非操作系统的并发?可移植性。

不要把并发当作灵丹妙药:如果顺序执行可以搞定,通常顺序会比并发更简单、更快速!

13.2 任务和线程

如果一个计算有可能(potentially)和另一个计算并发执行,我们称之为任务(task)。线程是任务的系统级表示。任务可以通过构造一个 std::thread 来启动,任务作为参数。任务是一个函数或者函数对象

void f();              // 函数

struct F {             // 函数对象
void operator()() // F 的调用操作符
}; void user()
{
thread t1 {f}; // f() 在另一个线程中执行
thread t2 {F()}; // F()() 在另一个线程中执行 t1.join(); // 等待 t1
t2.join(); // 等待 t2
}

join() 确保线程完成后才退出 user(),“join 线程”的意思是“等待线程结束”。

一个程序的线程共享同一地址空间。线程不同于进程,进程通常不直接共享数据。线程间可以通过共享对象(shared object)通信,这类通信一般用锁或其他机制控制,以避免数据竞争。

编写并发任务可能会非常棘手:

void f() {cout << "Hello ";}
struct F {
void operator()() {cout << "Parallel World!\n";}
};

上述例子有个错误:fF() 都用到了 cout 对象,却没有任何形式的同步。这会导致输出的结果不可预测,多次执行的结果可能会得到不同的结果:因为两个任务的执行顺序是未定义的。程序可能有诡异的输出,比如:

PaHerallllel o World!

定义一个并发程序中的任务时,我们的目标是保持任务之间的完全独立,除非他们之间仅仅是简单、明显的通信。最简单的方法就是把并发任务看作是一个恰巧可以和调用者同时运行的函数:我们只要传递参数、取回结果,保证该过程中没有使用共享数据(没有数据竞争)即可。

13.3 传递参数

一般来说,任务需要基于数据执行。我们可以通过参数传递数据(或者数据的指针或引用)。

void f(vector<double>& v); // 处理 v 的函数

struct F {                 // 处理 v 的函数对象
vector<double>& v;
F(vector<double>& vv) : v(vv) {}
void operator()();
}; int main()
{
vector<double> some_vec{1,2,3,4,5,6,7,8,9};
vector<double> vec2{10,11,12,13,14}; thread t1{f,ref(some_vec)}; // f(some_vec) 在另一个线程中执行
thread t2{F{vec2}}; // F{vec2}() 在另一个线程中执行 t1.join();
t2.join();
}

F{vec2}F 中保存了参数 vector 的引用。F 现在可以使用这个 vector。但愿在 F 执行时,没有其他任务访问 vec2。如果通过值传递 vec2 就可以消除这个隐患。

t1 通过 {f,ref(some_vec)} 初始化,用到了 thread可变参数模板构造,可以接受任意序列的参数。ref() 是来自 <functional> 的类型函数。为了让可变参数模板把 some_vec 当作一个引用而非对象,ref() 不能省略。编译器检查第一个参数可以通过其后面的参数调用,并构建必要的函数对象,传递给线程。如果 F::operator()()f() 执行了相同的算法,两个任务的处理几乎是等同的:两种情况下,都各自构建了一个函数对象,让 thread 去执行。

可变参数模板需要用 ref()cref() 传递引用

13.4 返回结果

13.3 的例子中,我传了一个非 const 的引用。我只有在期待任务修改引用数据的值时才这么做。尽管这是一种很常见的获取返回结果的方式,但这么做并不能向他人传达清晰、明确的意图,有些偷偷摸摸暗中操作的感觉。一种不那么晦涩的方式是通过 const 引用传递输入数据,通过另外单独的参数传递储存结果的指针。

void f(vector<double>& v, double *res); // 从 v 获取输入; 结果存入 *res

class F {
public:
F(vector<double>& vv, double *p) : v(vv), res(p) {}
void operator()(); // 结果保存到 *res private:
vector<double>& v; // 输入源
double *p; // 输出地址
}; int main()
{
vector<double> some_vec;
vector<double> vec2; double res1;
double res2; thread t1{f,ref(some_vec),&res1}; // f(some_vec,&res1) 在另一个线程中执行
thread t2{F{vec2,&res2}}; // F{vec2,&res2}() 在另一个线程中执行 t1.join();
t2.join();
}

这样行得通,也很常用。但我不觉得通过参数传递返回结果有多优雅,我会在 13.7.1 节再次讨论这个话题。

通过参数(出参)传递结果并不优雅

13.5 共享数据

有时任务需要共享数据,这种情况下,对共享数据的访问需要进行同步,同一时刻只能有一个任务访问数据(但是多任务同时读取不变的数据是没有问题的)。我们要考虑如何保证在同一时刻最多只有一个任务能够访问一组对象。

解决这个问题的基本元素是 mutex(mutual exclusion object,互斥对象)。thread 通过 lock() 获取 mutex

int shared_data;
mutex m; // 用于控制 shared_data 的 mutex void f()
{
unique_lock<mutex> lck{m}; // 获取 mutex
shared_data += 7; // 操作共享数据
} // 离开 f() 作用域,隐式自动释放 mutex

unique_lock 的构造函数通过 m.lock() 获取 mutex。如果另一个线程已经获取这个 mutex,当前线程等待(阻塞)直到另一个线程(通过 m.unlock())释放该 mutex。当 mutex 释放,等待该 mutex 的线程恢复执行(唤醒)。互斥、锁声明在 <mutex> 头文件中。

共享数据和 mutex 之间的关联需要达成一致:程序员需要知道哪个 mutex 对应哪个数据。这样很容易出错,但是我们可以通过一些方式使得他们之间的关系更清晰:

class Record {
public:
mutex rm;
};

不难猜到,对于一个 Record 对象 rec,在访问 rec 其他数据之前,你应该先获取 rec.rm。最好通过注释或者良好的命名让读者清楚地知道 mutex 和数据的关联。

有时执行某些操作需要同时访问多个资源,有可能导致死锁。例如,thread1 已经获取了 mutex1,然后尝试获取 mutex2;与此同时,thread2 已经获取 mutex2,尝试获取 mutex1。在这种情况下,两个任务都无法进行下去。标准库支持同时获取多个锁:

void f()
{
unique_lock<mutex> lck1{m1,defer_lock}; // defer_lock:不立即获取 mutex
unique_lock<mutex> lck2{m2,defer_lock};
unique_lock<mutex> lck3{m3,defer_lock}; lock(lck1,lck2,lck3);
// 操作共享数据
} // 离开 f() 作用域,隐式自动释放所有 mutexes

lock() 只有在获取所有参数里的的 mutex 之后继续执行,并且在其持有 mutex 期间,不会阻塞(go to sleep)。每个 unique_lock 的析构会确保离开作用域时,自动释放所有的 mutex。

通过共享数据通信是相对底层的操作。编程人员要设计一套机制,弄清楚哪些任务完成了哪些工作,还有哪些未完成。从这个角度看, 使用共享数据不如直接调用函数、返回结果。另一方面,有些人认为共享数据比拷贝参数和返回值效率更高。这个观点可能在涉及大量数据的时候成立,但是 locking 和 unlocking 也是相对耗时的操作。不仅如此,现代计算机很擅长拷贝数据,尤其是像 vector 这种连续存储的元素。所以,不要仅仅因为“效率”而选用共享数据进行通信,除非你真正实际测量过。

13.6 等待事件

有时线程需要等待外部事件,比如另一个线程完成了任务或者经过了一段时间。最简单的事件是时间。借助 <chrono>,可以写出:

using namespace std::chrono;
auto t0 = high_resolution_clock::now();
this_thread::sleep_for(milliseconds{20});
auto t1 = high_resolution_clock::now(); cout << duration_cast<nanoseconds>(t1-t0).count() << " nanoseconds passed\b";

注意,我甚至没有启动一个线程;默认情况下,this_thread 指当前唯一的线程。

我用 duration_cast 把时间单位转成了我想要的 nanoseconds。

condition_variable(定义在 <condition_variable>)提供了对通过外部事件通信的支持。condition_variable 允许一个线程等待另一个线程,尤其是允许一个线程等待某个(由于其他线程工作结束)条件/事件发生。

condition_variable 支持很多优雅、高效的共享形式,但也可能会很棘手。考虑一个经典的生产者-消费者例子:两个线程通过一个队列传递消息:

class Message { /**/ }; // 通信的对象

queue<Message> q;       // 消息队列
condition_variable cv; // 传递事件的变量
mutex m; // locking 机制

queuecondition_variable 以及 mutex 由标准库提供。

消费者读取并处理 Message

void consumer()
{
while(true){
unique_lock<mutex> lck{m}; // 获取 mutex m
cv.wait(lck); // 释放 lck,等待
// 唤醒时重新获得 lck
auto m = q.front(); // 取出 Message m
q.pop();
lck.unlock(); // 后续处理消息不再操作队列 q,提前释放 lck
// 处理 m
}
}

这里我显式地用 unique_lock<mutex> 保护 queuecondition_variable 上的操作。condition_variable 上的 cv.wait(lck) 会释放参数中的锁 lck,直到等待结束(队列非空),然后再次获取 lck

相应的生产者代码:

void producer()
{
while(true) {
Message m;
// 填充 m
unique_lock<mutex> lck{m}; // 保护操作
q.push(m);
cv.notify_one(); // 通知
} // 作用域结束自动释放锁
}

到目前为止,不论是 thread、mutex、lock 还是 condition_variable,都还是低层次的抽象。接下来我们马上就能看到 C++ 对并发的高级抽象支持。

13.7 通信任务

标准库还提供了一些机制,能够让程序员在更高的任务的概念层次上工作,而不是直接使用低层的线程、锁:

  1. futurepromise:用于从另一个线程生成的任务中返回值
  2. packaged_task:帮助启动任务,封装了 futurepromise,并且建立两者之间的关联
  3. async():像调用一个函数那样启动一个任务。形式最简单,但也最强大!

上述机制在头文件 <future> 中。

篇幅有点长,先到这里,余下的内容单独写一篇:现代 C++ 对多线程/并发的支持(下) -- 节选自 C++ 之父的 《A Tour of C++》

13.7.1 future 和 promise

13.7.2 packaged_task

13.7.3 async()

13.8 建议

现代 C++ 对多线程/并发的支持(上) -- 节选自 C++ 之父的 《A Tour of C++》的更多相关文章

  1. Java面试题整理一(侧重多线程并发)

    1..是否可以在static环境中访问非static变量? 答:static变量在Java中是属于类的,它在所有的实例中的值是一样的.当类被Java虚拟机载入的时候,会对static变量进行初始化.如 ...

  2. HashMap多线程并发问题分析

    转载: HashMap多线程并发问题分析 并发问题的症状 多线程put后可能导致get死循环 从前我们的Java代码因为一些原因使用了HashMap这个东西,但是当时的程序是单线程的,一切都没有问题. ...

  3. java多线程并发编程与CPU时钟分配小议

    我们先来研究下JAVA的多线程的并发编程和CPU时钟振荡的关系吧 老规矩,先科普 我们的操作系统在DOS以前都是单任务的 什么是单任务呢?就是一次只能做一件事 你复制文件的时候,就不能重命名了 那么现 ...

  4. 50个Java多线程面试题(上)

    Java 语言一个重要的特点就是内置了对并发的支持,让 Java 大受企业和程序员的欢迎.大多数待遇丰厚的 Java 开发职位都要求开发者精通多线程技术并且有丰富的 Java 程序开发.调试.优化经验 ...

  5. Java多线程并发工具类

    Semaphore-信号灯机制 当我们创建一个可扩展大小的线程池,并且需要在线程池内同时让有限数目的线程并发运行时,就需要用到Semaphore(信号灯机制),Semaphore 通常用于限制可以访问 ...

  6. python多进程并发和多线程并发和协程

    为什么需要并发编程? 如果程序中包含I/O操作,程序会有很高的延迟,CPU会处于等待状态,这样会浪费系统资源,浪费时间 1.Python的并发编程分为多进程并发和多线程并发 多进程并发:运行多个独立的 ...

  7. Java多线程并发编程一览笔录

    线程是什么? 线程是进程中独立运行的子任务. 创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run 方法 方式二:声明实现 Runnable 接口的类.该 ...

  8. Java 多线程并发编程面试笔录一览

    知识体系图: 1.线程是什么? 线程是进程中独立运行的子任务. 2.创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run 方法 方式二:声明实现 Runn ...

  9. python 多进程并发与多线程并发

    本文对python支持的几种并发方式进行简单的总结. Python支持的并发分为多线程并发与多进程并发(异步IO本文不涉及).概念上来说,多进程并发即运行多个独立的程序,优势在于并发处理的任务都由操作 ...

随机推荐

  1. Java集合的使用

    创建和使用集合 定义:集合是一个可变数组 List集合存储有序可重复序列 点击查看详细代码 import java.util.*; public class List01 { public stati ...

  2. Ubuntu 配置、使用samba共享文件夹

    安装库 sudo apt install smbclient samba samba-common 启动samba sudo /etc/init.d/samba start 备份配置文件 sudo c ...

  3. redux的使用

    1.redux的使用 核心概念 action 动作的对象 包含2个属性 type:标识属性, 值为字符串, 唯一, 必要属性 data:数据属性, 值类型任意, 可选属性 例子:{ type: 'AD ...

  4. Nginx对代理HTTP资源的限制访问

    为了限制连接的数量,首先,使用指令来定义密钥并设置共享内存区域的参数(工作进程将使用该区域来共享键值的计数器).作为第一个参数,指定作为关键字计算的表达式.在第二个参数区域中,指定区域的名称及其大小. ...

  5. 小程序 mpvue page "xxx" has not been registered yet

    新增了几个页面,改了下目录结构,就开始报这个错. 重启了几次不管用,google 一番也无果. 灵机一动试一下 build npm run build build 版本没报错,OK 然后 $ rm - ...

  6. SDOI2021集训 R1 半夜 题解

    先贴两个博客:ajthreac yspm,建议结合起来看 \(O(n^3)\):对 \(XX\) 每个长度为 \(n\) 的字串与 \(Y\) 跑 LCS.设 \(f[i,j,k]\) 表示 \(X[ ...

  7. docker部署rabbitmq集群

    docker版本:18.09.6 或以上 rabbitmq镜像版本:rabbitmq:3.9.5-management 一.拉镜像: docker pull rabbitmq:3.9.5-manage ...

  8. Vue项目-初始化之 vue-cli

    1.初始化项目 a.Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统,提供: 通过 @vue/cli 搭建交互式的项目脚手架. 通过 @vue/cli + @vue/cli-servi ...

  9. Lua io.lines()

    前言# 从文章的题目可以看出,今天的内容是和文件的行相关的,其实这个函可以看成是一个文件读取函数,只不过文件读取的形式固定了,就是只能一行一行的读,接下来我们就一起来看看这个函数究竟要怎么使用. 内容 ...

  10. Maven项目之间关系介绍

    Maven项目之间的关系 依赖关系 单纯的项目A中需要项目B中的资源,将项目B打成Jar包被A依赖,此时项目A直接调用项目B中资源即可. 项目A和项目B此时形成最基本的依赖关系. 继承关系 需要场景: ...