本文大部分代码和名次定义来源于 cppreference,并经过作者加以修饰和总结。官方网站的资料犹如一个图书馆,对于初学者很容易迷失在浩瀚的知识中而无法系统总结和快速入门,因此我写了这篇笔记,建议先看个人博客总结再看官方网站资料。
C++
的并发编程支持库包含了线程、原子操作、互斥、条件变量和 future
的内建支持。
进程与线程是操作系统的基本概念。无论是桌面系统:MacOS,Linux,Windows,还是移动操作系统:Android,iOS,都存在进程和线程的概念。
进程(英语:process),是指计算机中已运行的程序。进程为曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;
线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
对于大部分编程环境和操作系统而言,我们所编译的程序都是在一个进程中进行,一个进程至少包含一个主线程:执行 main()
函数的线程。
对于多个类似的任务我们可以通过多线程的方式实现并发编程从而提高程序性能。但单纯的使用多线程并不一定能提升系统性能(当然,也并非线程越多系统的性能就越好)。比如假设只有一个处理器,那么划分太多线程可能会适得其反,因为很多时间都花在任务切换上了。
因此在设计一个好的并发系统之前,一方便我们需要对硬件性能有足够了解(操作系统与芯片性能),另一方面我们需要对完成的任务有更深的认识(业务理解)。
关于如何做并发系统的性能优化,我们需要了解一下阿姆达尔定律。这个定律的内容,简单来说就是:我们需要预先意识到那些任务是可以并行的,那些是无法并行的。只有明确了任务的性质,才能有的放矢的进行优化。这个定律告诉了我们将系统并行之后性能收益的上限。
并非所有的编程语言都提供了多线程的环境,C++ 直到 C++11 标准之前,也是没有多线程支持的。在这种情况下,Linux/Unix平台下的开发者通常会使用POSIX Threads,Windows上的开发者也会有相应的接口。但很明显,这些 API 都只针对特定的操作系统平台,可移植性较差。如果要同时支持 Linux 和 Windows 系统,你可能要写两套代码。
这个状态在 C++ 11 标准发布之后得到了改变。并且,在 C++ 14 和C++ 17 标准中又对并发编程机制进行了增强。下图是 C++11 标准发展路线图。
C++
并发系统的实现是基于线程库
,而并发有两大关键需求,一是互斥,二是等待。互斥是因为线程间存在共享数据,等待则是因为线程间存在依赖。互斥需求可以通过互斥锁
实现,等待需求可以通过条件变量
实现。
编译器对于编程语言特性的支持是逐步完善的,想要使用新特性一般需要使用新版本的编译器。下面两个表格列出了 C++ 标准和相应编译器的版本对照:
C++版本 | GCC版本 |
---|---|
C++11 | 4.8 |
C++14 | 5.0 |
C++17 | 7.0 |
C++版本 | Clang版本 |
---|---|
C++11 | 3.3 |
C++14 | 3.4 |
C++17 | 5.0 |
默认情况下编译器是以较低的标准来进行编译的,如果希望使用新的标准,你需要通过编译参数-std=c++xx
告知编译器,例如:
g++ -std=c++11 your_file.cpp -o your_program_name
每个程序至少有一个线程:执行
main(
) 函数的线程。C++
线程在std::thread
对象(为线程指定入口函数)创建时启动。
多线程的使用有两种情况,一是每个线程都是独立的,不存在竞争条件的问题;二是线程之间存在同时访问共享数据的情况。
当程序中的多个进程/线程同时试图修改同一个共享内存的内容(共享数据),在没有并发控制的情况下,最后的结果依赖于两个进程/线程的执行顺序与时机,而且一旦发生了并发访问冲突,则最后的结果是不正确的,这种情况称为竞争条件(race condition);访问共享数据的代码片段称之为临界区(critical section)。具体到上面这个示例,临界区就是读写sum
变量的地方。
要避免竞争条件,就需要对临界区进行数据保护。因为发生竞争条件的直接原因是存在多个线程同时修改共享数据的情况,那么解决办法自然就是一次只让一个线程访问共享数据,访问完了再让其他线程接着访问,这样就可以避免问题的发生了。
std::mutex 与 Lock 详解,这些类和 api 的正确使用可以有效解决竞争条件的问题。加锁是保证线程安全的一种方式。
互斥体(互斥量)
mutex
和锁.lock()
都是非常抽象的概念,想要深入理解建议多看多写应用代码。
互斥算法可以避免多个线程同时访问共享资源(避免数据竞争),从而为线程间的同步提供支持,互斥定义于头文件 中。
mutex
是 mutual exclusion(互斥)的简写,C++11-C++17 标准中,Mutex 系列类有如下七种:
API | C++标准 | 说明 |
---|---|---|
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_mutex
,recursive_timed_mutex
,shared_timed_mutex
的名称都带有timed
,这意味着它们都支持超时功能。它们都提供了try_lock_for
和try_lock_until
方法,这两个方法分别可以指定超时的时间长度和时间点。如果在超时的时间范围内没有能获取到锁,则直接返回,不再继续等待。recursive_mutex
和recursive_timed_mutex
的名称都带有recursive
,叫做可重入或者可递归,**指在同一个线程中,同一把锁可以锁定多次。**这就避免了一些不必要的死锁。shared_timed_mutex
和shared_mutex
提供了共享功能。对于这类互斥体,实际上是提供了两把锁:一把是共享锁,一把是互斥锁。一旦某个线程获取了互斥锁,任何其他线程都无法再获取互斥锁和共享锁;但是如果有某个线程获取到了共享锁,其他线程无法再获取到互斥锁,但是还有获取到共享锁。这里互斥锁的使用和其他的互斥体接口和功能一样。而共享锁可以同时被多个线程同时获取到(使用共享锁的接口见下面的表格)。共享锁通常用在读者写者模型上。std::mutex 对象不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock
则可以递归地对互斥量对象上锁。
mutex** 类是能用于保护共享数据免受从多个线程同时访问的同步原语。**std::mutex
是 C++11 中最基本的互斥量,提供排他性和非递归所有权语义:
std::mutex** 既不可复制亦不可移动。**
实际项目当中通常不直接使用 std::mutex,而是通过 std::unique_lock, std::lock_guard, or std::scoped_lock (since C++17),以更安全的方式管理锁。
死锁是进程死锁的简称,是由Dijkstra于1965年研究银行家算法时首先提出来的。它是计算机系统乃至并发程序设计中最难处理的问题之一。
死锁的抽象解释是,两个或以上的运算单元,每一方都在等待其他方释放资源,但是所有方都不愿意释放资源。结果是没有任何一方能继续推进下去,于是整个系统无法再继续运转,这种状况,就称为死锁。
死锁的现实例子见文章-用个通俗的例子讲一讲死锁。
一般来说死锁的出现必须满足以下四个必要条件:
要避免出现死锁的问题,只需要破坏四个条件中的任何一个就可以了。
互斥体的出现虽然提供了资源保护的功能,但是通过调用 lock
和 unlock
这种手动的锁定和解锁方法是比较容易出错的,一旦一个线程获取锁之后没有正常释放,就会影响整个系统。(互斥体的加锁和解锁必须成对出现,这类似于智能指针出现之前,new
和 delete
必须成对出现,否则会导致内存泄露)。鉴于这个原因,标准库就提供了下面的这些类和 API。它们都使用了叫做 RAII
的编程技巧,来简化我们手动加锁和解锁的“体力活”。
通用互斥管理定义于头文件
中,主要类模板定义如下:
API | C++标准 | 说明 |
---|---|---|
lock_guard (类模板) | C++11 | 实现严格基于作用域的互斥体所有权包装器 |
unique_lock (类模板) | C++11 | 实现可移动的互斥体所有权包装器 |
shared_lock (类模板) | C++14 | 实现可移动的共享互斥体所有权封装器 |
scoped_lock (类模板) | C++17 | 用于多个互斥体的免死锁 RAII 封装器 |
锁定策略 | C++标准 | 说明 |
---|---|---|
defer_lock | C++11 | 类型为 defer_lock_t ,不获得互斥的所有权 |
try_to_lock | C++11 | 类型为try_to_lock_t ,尝试获得互斥的所有权而不阻塞 |
adopt_lock | C++11 | 类型为adopt_lock_t ,假设调用方已拥有互斥的所有权 |
1,类 lock_guard
是一个互斥体包装器,它提供了一种方便的 RAII 风格机制,用于在作用域块的持续时间内拥有互斥体。当一个 lock_guard
对象被创建时,它会尝试获取给它的互斥锁的所有权。当控制离开创建 lock_guard 对象的范围时, lock_guard 被销毁并且互斥体被释放。
lock_guard** 类不可复制。**lock_guard 的示例代码如下:
#include
#include
#include
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 lock会被销毁,它对互斥体的锁定也会解除
t1.join();
t2.join();
std::cout << "main: " << g_i << '\n';
}
编译运行以上代码后,结果如下:
2,上面的几个类(lock_guard
,unique_lock
,shared_lock
,scoped_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;
};
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
过程可以总结为以下四个步骤:
最开始我们使用 lock
和 unlock
函数进行资源的获取和手动释放,而后面的 lock_guard
类模板则是借助 RAII
编程技术实现了资源的自动获取和释放。同样我们可以借助 lock
和 unlock
函数实现一个自定义的 AutoLock
类(功能和 lock_guard 类似),其代码示例如下。
1,自定义的 AutoLock
类放在 autolock.hpp 文件中,代码如下:
#include
#include
#include
#include
#include
#include
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
#include
#include
#include
#include
#include
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
主要 API 如下:
API | C++标准 | 说明 |
---|---|---|
lock (函数模板) | C++11 | 锁定指定的互斥体,若任何一个不可用则阻塞 |
try_lock (函数模板) | C++11 | 试图通过重复调用 try_lock 获得互斥体的所有权 |
1,锁定给定的可锁定 (Lockable) 对象 lock1
、 lock2
、 ...
、 lockn
,用免死锁算法避免死锁。
2,尝试锁定每个给定的可锁定 (Lockable) 对象 lock1
、 lock2
、 ...
、 lockn
,通过以从头开始的顺序调用 try_lock
。
下列示例代码用 std::lock
锁定互斥对,而不死锁。
#include
#include
#include
#include
#include
#include
#include
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 lk1(e1.m, std::defer_lock);
// std::unique_lock 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
**条件变量是一种允许多个线程相互通信的同步原语。**它允许一些线程等待(可能超时)另一个线程的通知,然后再继续。条件变量始终关联到一个互斥体。
常用的条件变量类如下表所示,定义于头文件
API | C++标准 | 说明 |
---|---|---|
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 | 列出条件变量上定时等待的可能结果 |
条件变量的意义在于提供了一个可以让多个线程间同步协作的功能,这对于生产者-消费者模型很有意义。在这个模型下:
从上面可以看到,无论是生产者还是消费者,当它们工作的条件不满足时,它们并不是直接报错返回,而是停下来等待,直到条件满足。