C++ 对多线程/并发的支持(上)

目录
  • 1、 并发介绍
  • 2、 任务和线程
  • 3、传递参数
  • 4、返回结果
  • 5、共享数据
  • 6、等待事件
  • 7、通信任务

前言:

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

1、 并发介绍

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

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

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

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

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

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)通信,这类通信一般用锁或其他机制控制,以避免数据竞争。

编写并发任务可能会非常棘手,假如上述例子中的 f 和 F 实现如下:

void f() {cout << "Hello ";}

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

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

PaHerallllel o World!

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

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() 传递引用

4、返回结果

3 的例子中,我传了一个非 const 的引用。只有在希望任务修改引用数据时我才这么做。这是一种很常见的获取返回结果的方式,但这么做并不能清晰、明确地向他人传达你的意图。稍好一点的方式是通过 const 引用传递输入数据,通过另外单独的参数传递储存结果的指针。

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

class F {
public:
    F(const vector<double>& vv, double *p) : v(vv), res(p) {}
    void operator()();  // 结果保存到 *res

private:
    const vector<double>& v;  // 输入源
    double *p;                // 输出地址
};

int main()
{
    vector<double> some_vec;
    vector<double> vec2;

    double res1;
    double res2;

    thread t1{f,cref(some_vec),&res1}; // f(some_vec,&res1) 在另一个线程中执行
    thread t2{F{vec2,&res2}};          // F{vec2,&res2}() 在另一个线程中执行

    t1.join();
    t2.join();
}

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

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

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 这种元素连续存储的结构。所以,不要仅仅因为“效率”而选用共享数据进行通信,除非你真正实际测量过。

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\n";

注意,我甚至没有启动一个线程;默认情况下,this_thread 指当前唯一的线程。我用 duration_cast 把时间单位转成了我想要的 nanoseconds

condition_variable 提供了对通过外部事件通信的支持,允许一个线程等待另一个线程,比如等待另一个线程(完成某个工作,然后)触发一个事件/条件。

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

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

queue<Message> q;       // 消息队列
condition_variable cv;  // 传递事件的变量
mutex m;                // locking 机制
queue、condition_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> 保护 queue condition_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();           // 通知/唤醒等待中的 condition_variable
    } // 作用域结束自动释放锁
}

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

7、通信任务

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

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

到此这篇关于C++ 对多线程/并发的支持(上)的文章就介绍到这了,更多相关C++ 对多线程并发的支持内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • C++11 并发指南之多线程初探

    C++11 自2011年发布以来已经快两年了,之前一直没怎么关注,直到最近几个月才看了一些 C++11 的新特性,今后几篇博客我都会写一些关于 C++11 的特性,算是记录一下自己学到的东西吧,和大家共勉. 相信 Linux 程序员都用过 Pthread, 但有了 C++11 的 std::thread 以后,你可以在语言层面编写多线程程序了,直接的好处就是多线程程序的可移植性得到了很大的提高,所以作为一名 C++ 程序员,熟悉 C++11 的多线程编程方式还是很有益处的. 如果你对 C++11

  • C++ 多线程编程建议之 C++ 对多线程/并发的支持(下)

    前言: 本文承接前文  C++ 对多线程/并发的支持(上) ,翻译自 C++ 之父 Bjarne Stroustrup 的 C++ 之旅(A Tour of C++)一书的第 13 章 Concurrency.本文将继续介绍 C++ 并发中的 future/promise,packaged_task 以及 async() 的用法. 1.通信任务 标准库还在头文件 <future> 中提供了一些机制,能够让编程人员基于更高的抽象层次任务来开发,而不是直接使用低层的线程.锁: future 和 p

  • C++11并发编程:多线程std::thread

    一:概述 C++11引入了thread类,大大降低了多线程使用的复杂度,原先使用多线程只能用系统的API,无法解决跨平台问题,一套代码平台移植,对应多线程代码也必须要修改.现在在C++11中只需使用语言层面的thread可以解决这个问题. 所需头文件<thread> 二:构造函数 1.默认构造函数 thread() noexcept 一个空的std::thread执行对象 2.初始化构造函数 template<class Fn, class... Args> explicit th

  • C++ 对多线程/并发的支持(上)

    目录 1. 并发介绍 2. 任务和线程 3.传递参数 4.返回结果 5.共享数据 6.等待事件 7.通信任务 前言: 本文翻译自 C++ 之父 Bjarne Stroustrup 的 C++ 之旅( A Tour of C++ )一书的第 13 章 Concurrency.作者用短短数十页,带你一窥现代 C++ 对并发/多线程的支持.原文地址:现代 C++ 对多线程/并发的支持(上) -- 节选自 C++ 之父的 < A Tour of C++ > 水平有限,有条件的建议直接阅读原版书籍. 1

  • Java 多线程并发编程_动力节点Java学院整理

    一.多线程 1.操作系统有两个容易混淆的概念,进程和线程. 进程:一个计算机程序的运行实例,包含了需要执行的指令:有自己的独立地址空间,包含程序内容和数据:不同进程的地址空间是互相隔离的:进程拥有各种资源和状态信息,包括打开的文件.子进程和信号处理. 线程:表示程序的执行流程,是CPU调度执行的基本单位:线程有自己的程序计数器.寄存器.堆栈和帧.同一进程中的线程共用相同的地址空间,同时共享进进程锁拥有的内存和其他资源. 2.Java标准库提供了进程和线程相关的API,进程主要包括表示进程的jav

  • 基于多线程并发的常见问题(详解)

    一 概述 1.volatile 保证共享数据一旦被修改就会立即同步到共享内存(堆或者方法区)中. 2.线程访问堆中数据的过程 线程在栈中建立一个数据的副本,修改完毕后将数据同步到堆中. 3.指令重排 为了提高执行效率,CPU会将没有依赖关系的指令重新排序.如果希望控制重新排序,可以使用volatile修饰一个变量,包含该变量的指令前后的指令各自独立排序,前后指令不能交叉排序. 二 常见问题及应对 1.原子性问题 所谓原子性,指的是一个操作不可中断,即在多线程并发的环境下,一个操作一旦开始,就会在

  • Python多进程并发与多线程并发编程实例总结

    本文实例总结了Python多进程并发与多线程并发.分享给大家供大家参考,具体如下: 这里对python支持的几种并发方式进行简单的总结. Python支持的并发分为多线程并发与多进程并发(异步IO本文不涉及).概念上来说,多进程并发即运行多个独立的程序,优势在于并发处理的任务都由操作系统管理,不足之处在于程序与各进程之间的通信和数据共享不方便:多线程并发则由程序员管理并发处理的任务,这种并发方式可以方便地在线程间共享数据(前提是不能互斥).Python对多线程和多进程的支持都比一般编程语言更高级

  • spring-boot 多线程并发定时任务的解决方案

    刚刚看了下Spring Boot实现定时任务的文章,感觉还不错.Spring Boot 使用Spring自带的Schedule来实现定时任务变得非常简单和方便.在这里个大家分享下. 开启缓存注解 @SpringBootApplication @EnableScheduling //开启定时任务 public class Application { public static void main(String[] args) { SpringApplication.run(Application.

  • python多线程并发及测试框架案例

    这篇文章主要介绍了python多线程并发及测试框架案例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 1.循环创建多个线程,并通过循环启动执行 import threading from datetime import * from time import sleep # 单线程执行 def test(): print('hello world') t = threading.Thread(target=test) t.start() # 多线

  • Java多线程并发编程和锁原理解析

    这篇文章主要介绍了Java多线程并发编程和锁原理解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 一.前言 最近项目遇到多线程并发的情景(并发抢单&恢复库存并行),代码在正常情况下运行没有什么问题,在高并发压测下会出现:库存超发/总库存与sku库存对不上等各种问题. 在运用了 限流/加锁等方案后,问题得到解决. 加锁方案见下文. 二.乐观锁 & 悲观锁 1.乐观锁 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁

  • 在IntelliJ IDEA中多线程并发代码的调试方法详解

    通常来说,多线程的并发及条件断点的debug是很难完成的,或许本篇文章会给你提供一个友好的调试方法.让你在多线程开发过程中的调试更加的有的放矢. 我们将通过一个例子来学习.在这里,我编写了一个多线程程序来计算此数学问题:100! + 100000!.即:100的阶乘 + 100000的阶乘. 数学不好的同学看这里,100 阶乘就是:1 * 2 * 3 * -- * 100 = ? ,简写为100! import java.math.BigInteger; public class MathPro

  • Java多线程并发与并行和线程与进程案例

    目录 一.并发与并行 二.线程与进程 三.创建线程类 前言: 程序在没有跳转语句的前提下,都是由上至下依次执行,那现在想要设计一个程序,边打游戏边听歌,怎么设计? 要解决上述问题,咱们得使用多进程或者多线程来解决. 一.并发与并行 并发:指两个或多个事件在同一个时间段内发生. 并行:指两个或多个事件在同一时刻发生(同时发生). 在操作系统中,安装了多个程序,并发指的是在一段时间内宏观上有多个程序同时运行,这在单 CPU 系统中,每一时刻只能有一道程序执行,即微观上这些程序是分时的交替运行,只不过

随机推荐