C++ 并发编程总结

本文大部分代码和名次定义来源于 cppreference,并经过作者加以修饰和总结。官方网站的资料犹如一个图书馆,对于初学者很容易迷失在浩瀚的知识中而无法系统总结和快速入门,因此我写了这篇笔记,建议先看个人博客总结再看官方网站资料。

一,并发编程概述

C++ 的并发编程支持库包含了线程、原子操作、互斥、条件变量和 future 的内建支持。

1.1,进程与线程概述

进程与线程是操作系统的基本概念。无论是桌面系统:MacOS,Linux,Windows,还是移动操作系统:Android,iOS,都存在进程和线程的概念。

进程(英语:process),是指计算机中已运行的程序。进程为曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;

线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

对于大部分编程环境和操作系统而言,我们所编译的程序都是在一个进程中进行,一个进程至少包含一个主线程:执行 main() 函数的线程。

1.2,并发系统的性能

对于多个类似的任务我们可以通过多线程的方式实现并发编程从而提高程序性能。但单纯的使用多线程并不一定能提升系统性能(当然,也并非线程越多系统的性能就越好)。比如假设只有一个处理器,那么划分太多线程可能会适得其反,因为很多时间都花在任务切换上了。

因此在设计一个好的并发系统之前,一方便我们需要对硬件性能有足够了解(操作系统与芯片性能),另一方面我们需要对完成的任务有更深的认识(业务理解)。

关于如何做并发系统的性能优化,我们需要了解一下阿姆达尔定律。这个定律的内容,简单来说就是:我们需要预先意识到那些任务是可以并行的,那些是无法并行的。只有明确了任务的性质,才能有的放矢的进行优化。这个定律告诉了我们将系统并行之后性能收益的上限。

1.3,C++与并发编程

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

这个状态在 C++ 11 标准发布之后得到了改变。并且,在 C++ 14 和C++ 17 标准中又对并发编程机制进行了增强。下图是 C++11 标准发展路线图。
在这里插入图片描述
C++ 并发系统的实现是基于线程库 <thread>,而并发有两大关键需求,一是互斥,二是等待。互斥是因为线程间存在共享数据,等待则是因为线程间存在依赖。互斥需求可以通过互斥锁 <mutex> 实现,等待需求可以通过条件变量 <condition_variable> 实现。

1.4,C++标准与编译器

编译器对于编程语言特性的支持是逐步完善的,想要使用新特性一般需要使用新版本的编译器。下面两个表格列出了 C++ 标准和相应编译器的版本对照:

  • C++ 标准与相应的 GCC 版本要求如下:
C++版本GCC版本
C++114.8
C++145.0
C++177.0
  • C++ 标准与相应的 Clang 版本要求如下:
C++版本Clang版本
C++113.3
C++143.4
C++175.0

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

g++ -std=c++11 your_file.cpp -o your_program_name

三,线程

每个程序至少有一个线程:执行 main() 函数的线程。C++ 线程在 std::thread 对象(为线程指定入口函数)创建时启动。

3.1,创建线程

3.2,join 和 detach

3.3,管理线程

3.4,竞争条件与临界区

多线程的使用有两种情况,一是每个线程都是独立的,不存在竞争条件的问题;二是线程之间存在同时访问共享数据的情况。

当程序中的多个进程/线程同时试图修改同一个共享内存的内容(共享数据),在没有并发控制的情况下,最后的结果依赖于两个进程/线程的执行顺序与时机,而且一旦发生了并发访问冲突,则最后的结果是不正确的,这种情况称为竞争条件(race condition);访问共享数据的代码片段称之为临界区(critical section)。具体到上面这个示例,临界区就是读写sum变量的地方。

要避免竞争条件,就需要对临界区进行数据保护。因为发生竞争条件的直接原因是存在多个线程同时修改共享数据的情况,那么解决办法自然就是一次只让一个线程访问共享数据,访问完了再让其他线程接着访问,这样就可以避免问题的发生了。

四,互斥体与锁

std::mutex 与 Lock 详解,这些类和 api 的正确使用可以有效解决竞争条件的问题。加锁是保证线程安全的一种方式。

4.1,互斥体

互斥体(互斥量) mutex 和锁 .lock() 都是非常抽象的概念,想要深入理解建议多看多写应用代码。

互斥算法可以避免多个线程同时访问共享资源(避免数据竞争),从而为线程间的同步提供支持,互斥定义于头文件 中。

mutexmutual exclusion(互斥)的简写,C++11-C++17 标准中,Mutex 系列类有如下七种:

APIC++标准说明
mutex(类)C++11提供基本互斥设施
timed_mutex(类)C++11提供互斥设施,带有超时功能
recursive_mutex(类)C++11提供能被同一线程递归锁定的互斥设施
recursive_timed_mutex(类)C++11提供能被同一线程递归锁定的互斥设施,带有超时功能
shared_timed_mutex(类)C++14提供共享互斥设施并带有超时功能
shared_mutex(类)C++17提供共享互斥设施

很明显,在这些类中,mutex是最基础的API,其他类都是在它的基础上的改进。所以以上这些类都提供了下面三个方法。

方法说明
构造函数std::mutex不允许拷贝构造和 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
lock(成员函数)锁定互斥体,如果不可用,则阻塞
try_lock(成员函数)尝试锁定互斥体,如果不可用,直接返回
unlock(成员函数)解锁互斥体,释放对互斥量的所有权

这三个方法只提供了基础的锁定和解除锁定的功能。使用lock意味着你有很强的意愿一定要获取到互斥体,而使用try_lock则是进行一次尝试。这意味着如果失败了,你通常还有其他的路径可以走。

在这些基础功能之上,其他的类分别在下面三个方面进行了扩展:

  • 超时timed_mutexrecursive_timed_mutexshared_timed_mutex的名称都带有timed,这意味着它们都支持超时功能。它们都提供了try_lock_fortry_lock_until方法,这两个方法分别可以指定超时的时间长度和时间点。如果在超时的时间范围内没有能获取到锁,则直接返回,不再继续等待。
  • 可重入recursive_mutexrecursive_timed_mutex的名称都带有recursive,叫做可重入或者可递归,**指在同一个线程中,同一把锁可以锁定多次。**这就避免了一些不必要的死锁。
  • 共享shared_timed_mutexshared_mutex提供了共享功能。对于这类互斥体,实际上是提供了两把锁:一把是共享锁,一把是互斥锁。一旦某个线程获取了互斥锁,任何其他线程都无法再获取互斥锁和共享锁;但是如果有某个线程获取到了共享锁,其他线程无法再获取到互斥锁,但是还有获取到共享锁。这里互斥锁的使用和其他的互斥体接口和功能一样。而共享锁可以同时被多个线程同时获取到(使用共享锁的接口见下面的表格)。共享锁通常用在读者写者模型上。
4.1.1,std::mutex 类

std::mutex 对象不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。

mutex** 类是能用于保护共享数据免受从多个线程同时访问的同步原语。**std::mutex 是 C++11 中最基本的互斥量,提供排他性和非递归所有权语义:

  • 调用方线程从它成功调用 lock 或 try_lock 开始,到它调用 unlock 为止占有 mutex 。
  • 线程占有 mutex 时,所有其他线程若试图要求 mutex 的所有权,则将阻塞(对于 lock 的调用)或收到 false 返回值(对于 try_lock ).
  • 调用方线程在调用 lock 或 try_lock 前必须不占有 mutex 。

std::mutex** 既不可复制亦不可移动。**

实际项目当中通常不直接使用 std::mutex,而是通过 std::unique_lock, std::lock_guard, or std::scoped_lock (since C++17),以更安全的方式管理锁。

4.2,死锁

死锁是进程死锁的简称,是由Dijkstra于1965年研究银行家算法时首先提出来的。它是计算机系统乃至并发程序设计中最难处理的问题之一。

死锁的抽象解释是,两个或以上的运算单元,每一方都在等待其他方释放资源,但是所有方都不愿意释放资源。结果是没有任何一方能继续推进下去,于是整个系统无法再继续运转,这种状况,就称为死锁。

死锁的现实例子见文章-用个通俗的例子讲一讲死锁

一般来说死锁的出现必须满足以下四个必要条件:

  1. 互斥条件:指线程(进程)对所分配到的资源进行排它性使用,即在一段时间内一个资源只能由一个线程(进程)占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的线程(进程)用毕释放。(——只有一副钥匙)
  2. 请求和保持条件:指线程(进程)已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其它线程(进程)占有,此时请求进程阻塞,同时又对自己已获得的资源不释放。(——拿着红钥匙的人在没有归还红钥匙的情况下,又提出要蓝钥匙)
  3. 不剥夺条件:指线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只能在自己使用完毕后释放。(——人除非归还了钥匙,不然一直占用着钥匙)
  4. 环路等待条件:指在发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞,即线程(进程)集合{P0,P1,P2,···,Pn}中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。(——拿着红钥匙的人在等蓝钥匙,同时那个拿着蓝钥匙的人在等红钥匙)

要避免出现死锁的问题,只需要破坏四个条件中的任何一个就可以了。

4.3,通用互斥管理

互斥体的出现虽然提供了资源保护的功能,但是通过调用 lockunlock 这种手动的锁定和解锁方法是比较容易出错的,一旦一个线程获取锁之后没有正常释放,就会影响整个系统。(互斥体的加锁和解锁必须成对出现,这类似于智能指针出现之前,newdelete 必须成对出现,否则会导致内存泄露)。鉴于这个原因,标准库就提供了下面的这些类和 API。它们都使用了叫做 RAII 的编程技巧,来简化我们手动加锁和解锁的“体力活”

通用互斥管理定义于头文件 <mutex> 中,主要类模板定义如下:

APIC++标准说明
lock_guard (类模板)C++11实现严格基于作用域的互斥体所有权包装器
unique_lock (类模板)C++11实现可移动的互斥体所有权包装器
shared_lock (类模板)C++14实现可移动的共享互斥体所有权封装器
scoped_lock (类模板)C++17用于多个互斥体的免死锁 RAII 封装器
锁定策略C++标准说明
defer_lockC++11类型为 defer_lock_t,不获得互斥的所有权
try_to_lockC++11类型为try_to_lock_t,尝试获得互斥的所有权而不阻塞
adopt_lockC++11类型为adopt_lock_t,假设调用方已拥有互斥的所有权

1,类 lock_guard 是一个互斥体包装器,它提供了一种方便的 RAII 风格机制,用于在作用域块的持续时间内拥有互斥体。当一个 lock_guard 对象被创建时,它会尝试获取给它的互斥锁的所有权。当控制离开创建 lock_guard 对象的范围时, lock_guard 被销毁并且互斥体被释放。

lock_guard** 类不可复制。**lock_guard 的示例代码如下:

#include <thread>
#include <mutex>
#include <iostream>
 
int g_i = 0;
std::mutex g_i_mutex;  // 全局的互斥体g_i_mutex用来保护全局变量g_i
 
void safe_increment()
{
    std::lock_guard<std::mutex> lock(g_i_mutex); // 没有调用lock方法,而是直接使用lock_guard来锁定互斥体
    ++g_i;
    std::cout << std::this_thread::get_id() << ": " << g_i << '\n';
    //g_i_mutex 在锁离开作用域时自动释放
}
 
int main()
{
    std::cout << "main: " << g_i << '\n';
 
    std::thread t1(safe_increment);
    std::thread t2(safe_increment);
    //  线程函数safe_increment调用结束后,局部变量std::lock_guard<std::mutex> lock会被销毁,它对互斥体的锁定也会解除
    t1.join();
    t2.join(); 
 
    std::cout << "main: " << g_i << '\n';
}

编译运行以上代码后,结果如下:
在这里插入图片描述
2,上面的几个类(lock_guardunique_lockshared_lockscoped_lock)都使用了一个叫做 RAII 的编程技巧,其中 lock_guard 其代码定义如下:

template<typename _Mutex> class lock_guard
{
public:
    typedef _Mutex mutex_type;

    explicit lock_guard(mutex_type& __m) : _M_device(__m)
    { _M_device.lock(); }

    lock_guard(mutex_type& __m, adopt_lock_t) noexcept : _M_device(__m)
    { } // calling thread owns mutex

    ~lock_guard()
    { _M_device.unlock(); }

    lock_guard(const lock_guard&) = delete;
    lock_guard& operator=(const lock_guard&) = delete;

private:
    mutex_type&  _M_device;
};

4.4,RAII

4.4.1,RAII 原理介绍

RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization),它是在一些面向对象语言中的一种惯用法(英语:Programming idiom),源于 C++,在其他语言 JAVA、Rust 等中也有应用。RAII 由 C++之父 Bjarne Stroustrup 在设计 C++ 异常时,为解决资源管理时的异常安全而提出的一种C++编程技术

RAII 要求,资源的有效期与持有资源的对象的生命期(Object lifetime)严格绑定,即由对象的构造函数完成资源的分配(Resource allocation (computer)(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露(Resource leak)问题。

RAII 原理介绍来源维基百科

整个 RAII 过程可以总结为以下四个步骤:

  1. 设计一个类封装资源
  2. 在构造函数中初始化
  3. 在析构函数中执行销毁操作
  4. 使用时声明一个该对象的类
4.4.2,RAII 应用

最开始我们使用 lockunlock 函数进行资源的获取和手动释放,而后面的 lock_guard 类模板则是借助 RAII 编程技术实现了资源的自动获取和释放。同样我们可以借助 lockunlock 函数实现一个自定义的 AutoLock 类(功能和 lock_guard 类似),其代码示例如下。

1,自定义的 AutoLock 类放在 autolock.hpp 文件中,代码如下:

#include <chrono>
#include <cmath>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

using namespace std;

class AutoLock {
public:
    explicit AutoLock(mutex *mu): mu_(mu) {
        this->mu_->lock();
        // std::cout << "init mutex and get lock" << std::endl;
        
    }
    ~AutoLock() {
        this->mu_->unlock();
        // std::cout << "destroy mutex and release lock" << std::endl;
    }
private:
    mutex *mu_;
    // No copying allowed
    AutoLock(const AutoLock&);
    void operator=(const AutoLock&);
};

自定义 AutoLock 类的测试代码放在另一个文件 test_autolock.cpp 中,代码如下:

#include "autolock.hpp"
#include <chrono>
#include <cmath>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

using namespace std;

static const int MAX = 10e8;
static double sum=0;

static mutex exclusive;

void concurrent_add(int min, int max) {
    double tmp_sum = 0;
    for (int i = min; i <= max; i++)
    {
        tmp_sum += sqrt(i);
    }
    AutoLock lock(&exclusive);
    sum += tmp_sum;
}

void concurrent_task(int min, int max)
{
    auto start_time = chrono::steady_clock::now();

    unsigned concurrent_count = thread::hardware_concurrency();
    cout << "hardware_concurrency: " << concurrent_count << endl;
    vector<thread> threads;
    min = 0;
    sum = 0;
    for (int t = 0; t < concurrent_count; t++)
    {
        int range = max / concurrent_count * (t + 1);
        threads.push_back(thread(concurrent_add, min, range));
        min = range + 1;
    }
    for (int i = 0; i < threads.size(); i++)
    {
        threads[i].join();
    }

    auto end_time = chrono::steady_clock::now();
    auto ms = chrono::duration_cast<chrono::milliseconds>(end_time - start_time).count();
    cout << "Concurrent task finish, " << ms << " ms consumed, Result: " << sum << endl;
}

int main()
{
    concurrent_task(0, MAX);
    return 0;
}

编译程序命令和运行结果如下。

(base) [cpp_learn]$ g++ -std=c++11 -lpthread test_autolock.cpp 
(base) [cpp_learn]$ ./a.out                                    
hardware_concurrency: 16
Concurrent task finish, 1186 ms consumed, Result: 2.10819e+13

4.5,通用锁定算法

主要 API 如下:

APIC++标准说明
lock (函数模板)C++11锁定指定的互斥体,若任何一个不可用则阻塞
try_lock (函数模板)C++11试图通过重复调用 try_lock 获得互斥体的所有权

1,锁定给定的可锁定 (Lockable) 对象 lock1lock2...lockn ,用免死锁算法避免死锁。

2,尝试锁定每个给定的可锁定 (Lockable) 对象 lock1lock2...lockn ,通过以从头开始的顺序调用 try_lock

下列示例代码用 std::lock 锁定互斥对,而不死锁。

#include <mutex>
#include <thread>
#include <iostream>
#include <vector>
#include <functional>
#include <chrono>
#include <string>
 
struct Employee {
    Employee(std::string id) : id(id) {}
    std::string id;
    std::vector<std::string> lunch_partners;
    std::mutex m;
    std::string output() const
    {
        std::string ret = "Employee " + id + " has lunch partners: ";
        for( const auto& partner : lunch_partners )
            ret += partner + " ";
        return ret;
    }
};
 
void send_mail(Employee &, Employee &)
{
    // 模拟耗时的发信操作
    std::this_thread::sleep_for(std::chrono::seconds(1));
}
 
void assign_lunch_partner(Employee &e1, Employee &e2)
{
    static std::mutex io_mutex;
    {
        std::lock_guard<std::mutex> lk(io_mutex);
        std::cout << e1.id << " and " << e2.id << " are waiting for locks" << std::endl;
    }
 
    // 用 std::lock 获得二个锁,而不担心对 assign_lunch_partner 的其他调用会死锁我们
    {
        std::lock(e1.m, e2.m);
        std::lock_guard<std::mutex> lk1(e1.m, std::adopt_lock);
        std::lock_guard<std::mutex> lk2(e2.m, std::adopt_lock);
// 等价代码(若需要 unique_locks ,例如对于条件变量)
//        std::unique_lock<std::mutex> lk1(e1.m, std::defer_lock);
//        std::unique_lock<std::mutex> lk2(e2.m, std::defer_lock);
//        std::lock(lk1, lk2);
// C++17 中可用的较优解法
//        std::scoped_lock lk(e1.m, e2.m);
        {
            std::lock_guard<std::mutex> lk(io_mutex);
            std::cout << e1.id << " and " << e2.id << " got locks" << std::endl;
        }
        e1.lunch_partners.push_back(e2.id);
        e2.lunch_partners.push_back(e1.id);
    }
    send_mail(e1, e2);
    send_mail(e2, e1);
}
 
int main()
{
    Employee alice("alice"), bob("bob"), christina("christina"), dave("dave");
 
    // 在平行线程指派,因为发邮件给用户告知午餐指派,会消耗长时间
    std::vector<std::thread> threads;
    threads.emplace_back(assign_lunch_partner, std::ref(alice), std::ref(bob));
    threads.emplace_back(assign_lunch_partner, std::ref(christina), std::ref(bob));
    threads.emplace_back(assign_lunch_partner, std::ref(christina), std::ref(alice));
    threads.emplace_back(assign_lunch_partner, std::ref(dave), std::ref(bob));
 
    for (auto &thread : threads) thread.join();
    std::cout << alice.output() << '\n'  << bob.output() << '\n'
              << christina.output() << '\n' << dave.output() << '\n';
}

程序运行后可能的输出:

alice and bob are waiting for locks
alice and bob got locks
christina and bob are waiting for locks
christina and bob got locks
christina and alice are waiting for locks
christina and alice got locks
dave and bob are waiting for locks
dave and bob got locks
Employee alice has lunch partners: bob christina 
Employee bob has lunch partners: alice christina dave 
Employee christina has lunch partners: bob alice 
Employee dave has lunch partners: bob

4.6,条件变量

**条件变量是一种允许多个线程相互通信的同步原语。**它允许一些线程等待(可能超时)另一个线程的通知,然后再继续。条件变量始终关联到一个互斥体。

常用的条件变量类如下表所示,定义于头文件 <condition_variable> 中。

APIC++标准说明
condition_variable (类)C++ 11提供与 std::unique_lock 关联的条件变量
condition_variable_any (类)C++ 11提供与任何锁类型关联的条件变量
notify_all_at_thread_exit (类)C++ 11安排到在此线程完全结束时对 notify_all 的调用
cv_status (枚举)C++ 11列出条件变量上定时等待的可能结果

条件变量的意义在于提供了一个可以让多个线程间同步协作的功能,这对于生产者-消费者模型很有意义。在这个模型下:

  • 生产者和消费者共享一个工作区。这个区间的大小是有限的。
  • 生产者总是产生数据放入工作区中,当工作区满了。它就停下来等消费者消费一部分数据,然后继续工作。
  • 消费者总是从工作区中拿出数据使用。当工作区中的数据全部被消费空了之后,它也会停下来等待生产者往工作区中放入新的数据。

从上面可以看到,无论是生产者还是消费者,当它们工作的条件不满足时,它们并不是直接报错返回,而是停下来等待,直到条件满足

参考资料

  1. C++ 并发编程(从C++11到C++17)
  2. cppreference: Concurrency support library (since C++11)
  3. C++11 并发指南系列
  4. 《C++ 并发编程实战-第二版》
  5. 如何用 C++ 实现一个阻塞队列
  6. Modern C++ Parallel Task Programming
  7. C++ 多线程编程
  8. 用个通俗的例子讲一讲死锁
  9. C+±线程支持库
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

嵌入式视觉

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值