c++线程--快速上手
线程创建
头文件#include thread 是在 C++11 标准中引入的。
C++11 标准引入了对多线程编程的标准化支持,其中包括了线程的创建、管理和同步机制。
#include <iostream>
#include <thread>
using namespace std;
void hello() {
cout << "Hello World from new thread." << endl;
}
int main() {
thread t(hello);
t.join();
return 0;
}
-
为了使用多线程的接口,我们需要#include
头文件。新建线程的入口是一个普通的函数,它并没有什么特别的地方。 -
创建线程的方式就是构造一个thread对象,并指定入口函数。与普通对象不一样的是,此时编译器便会为我们创建一个新的操作系统线程,并在新的线程中执行我们的入口函数。
int main() {
thread t([] {
cout << "Hello World from lambda thread." << endl;
});
t.join();
return 0;
}
- 也可以直接使用lambda表达式
join & detach
API | 说明 |
---|---|
join | 等待线程完成其执行 |
detach | 不等待,允许线程独立执行 |
-
join:调用此接口时,当前线程会一直阻塞,直到目标线程执行完成(当然,很可能目标线程在此处调用之前就已经执行完成了,不过这不要紧)。因此,如果目标线程的任务非常耗时,你就要考虑好是否需要在主线程上等待它了,因此这很可能会导致主线程卡住。
-
detach:detach是让目标线程成为守护线程(daemon threads)。一旦detach之后,目标线程将独立执行,即便其对应的thread对象销毁也不影响线程的执行。并且,你无法再与之通信。
joinable() 是 C++11 中 std::thread 类的一个成员函数,用于检查一个线程是否可以被 join()。如果线程可以被 join(),则返回 true,否则返回 false。
joinable() 返回 true 的条件:线程对象必须表示一个有效的、尚未被 join() 或 detach() 的线程。
管理当前线程
API | C++标准 | 说明 |
---|---|---|
yield | C++11 | 让出处理器,重新调度各执行线程 |
get_id | C++11 | 返回当前线程的线程 id |
sleep_for | C++11 | 使当前线程的执行停止指定的时间段 |
sleep_until | C++11 | 使当前线程的执行停止直到指定的时间点 |
上面是一些在线程内部使用的API,它们用来对当前线程做一些控制。
- yield 通常用在自己的主要任务已经完成的时候,此时希望让出处理器给其他任务使用。
- get_id 返回当前线程的id,可以以此来标识不同的线程。
- sleep_for 是让当前线程停止一段时间。
- sleep_until 和sleep_for类似,但是是以具体的时间点为参数。这两个API都以chrono API(由于篇幅所限,这里不展开这方面内容)为基础。
下面是一个代码示例:
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(&in_time_t), "%Y-%m-%d %X");
cout << "now is: " << ss.str() << endl;
}
void sleep_thread() {
this_thread::sleep_for(chrono::seconds(3));
cout << "[thread-" << this_thread::get_id() << "] is waking up" << endl;
}
void loop_thread() {
for (int i = 0; i < 10; i++) {
cout << "[thread-" << this_thread::get_id() << "] print: " << i << endl;
}
}
int main() {
print_time();
thread t1(sleep_thread);
thread t2(loop_thread);
t1.join();
t2.detach();
print_time();
return 0;
}
这段代码应该还是比较容易理解的,这里创建了两个线程。它们都会有一些输出,其中一个会先停止3秒钟,然后再输出。主线程调用join会一直卡住等待它运行结束。
这段程序的输出如下:
now is: 2019-10-13 10:17:48
[thread-0x70000cdda000] print: 0
[thread-0x70000cdda000] print: 1
[thread-0x70000cdda000] print: 2
[thread-0x70000cdda000] print: 3
[thread-0x70000cdda000] print: 4
[thread-0x70000cdda000] print: 5
[thread-0x70000cdda000] print: 6
[thread-0x70000cdda000] print: 7
[thread-0x70000cdda000] print: 8
[thread-0x70000cdda000] print: 9
[thread-0x70000cd57000] is waking up
now is: 2019-10-13 10:17:51
线程如何一次调用?
主要API
API | C++标准 | 说明 |
---|---|---|
call_once | C++11 | 即便在多线程环境下,也能保证只调用某个函数一次 |
once_flag | C++11 | 与call_once配合使用 |
在一些情况下,我们有些任务需要执行一次,并且我们只希望它执行一次,例如资源的初始化任务。
这个时候就可以用到上面的接口。这个接口会保证,即便在多线程的环境下,相应的函数也只会调用一次。
下面就是一个示例:有三个线程都会使用init函数,但是只会有一个线程真正执行它。
void init() {
cout << "Initialing..." << endl;
// Do something...
}
void worker(once_flag* flag) {
call_once(*flag, init);
}
int main() {
once_flag flag;
thread t1(worker, &flag);
thread t2(worker, &flag);
thread t3(worker, &flag);
t1.join();
t2.join();
t3.join();
return 0;
}
我们无法确定具体是哪一个线程会执行init。而事实上,我们也不关心,因为只要有某个线程完成这个初始化工作就可以了。
竞争条件与临界区
当多个进程或者线程同时访问共享数据时,只要有一个任务会修改数据,那么就可能会发生问题。
此时结果依赖于这些任务执行的相对时间,这种场景称为竞争条件(race condition)。
访问共享数据的代码片段称之为临界区(critical section)。要避免竞争条件,就需要对临界区进行数据保护。
mutex
mutex
是 mutual exclusion(互斥)的简写。
开发并发系统的目的主要是为了提升性能:将任务分散到多个线程,然后在不同的处理器上同时执行。这些分散开来的线程通常会包含两类任务:
- 独立的对于划分给自己的数据的处理
- 对于处理结果的汇总
第1项任务因为每个线程是独立的,不存在竞争条件的问题。
而第2项任务,由于所有线程都可能往总结果(例如 sum
变量)汇总,这就需要做保护了。
在某一个具体的时刻,只应当有一个线程更新总结果,即:保证每个线程对于共享数据的访问是“互斥”的。mutex
就提供了这样的功能。
mutex
是 mutual exclusion(互斥)的简写。
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。其他类都是在它的基础上的改进。
这些类都提供了下面三个方法,并且它们的功能是一样的:
基本方法
方法 | 说明 |
---|---|
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提供了共享功能。对于这类互斥体,实际上是提供了两把锁:一把是共享锁,一把是互斥锁。一旦某个线程获取了互斥锁,任何其他线程都无法再获取互斥锁和共享锁;但是如果有某个线程获取到了共享锁,其他线程无法再获取到互斥锁,但是还有获取到共享锁。这里互斥锁的使用和其他的互斥体接口和功能一样。而共享锁可以同时被多个线程同时获取到(使用共享锁的接口见下面的表格)。共享锁通常用在读者写者模型上。
方法 | 说明 |
---|---|
lock_shared |
获取互斥体的共享锁,如果无法获取则阻塞 |
try_lock_shared |
尝试获取共享锁,如果不可用,直接返回 |
unlock_shared |
解锁共享锁 |
使用 mutex
的并发系统
通过将锁保护的范围缩小到只在汇总时锁定,可以提升性能:
#include <iostream>
#include <cmath>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
static const int MAX = 10e8;
static double sum = 0;
static std::mutex exclusive;
void concurrent_worker(int min, int max) {
double tmp_sum = 0;
for (int i = min; i <= max; i++) {
tmp_sum += std::sqrt(i); // ① 计算单独结果
}
exclusive.lock(); // ② 只在汇总时加锁
sum += tmp_sum;
exclusive.unlock();
}
输出结果:
hardware_concurrency: 16
Concurrent task finish, 451 ms consumed, Result: 2.10819e+13
可以看到,性能得到了极大的提升。
锁的粒度
- 细粒度(fine-grained):锁保护较小的范围,性能更好。
- 粗粒度(coarse-grained):锁保护较大的范围,容易导致性能瓶颈。
最佳实践:尽量减少锁的范围,不要在持有锁的情况下执行耗时操作。
> In general, a lock should be held for only the minimum possible time needed to perform the required operations.
> –《C++ Concurrency in Action》
通用锁定算法
主要API
API | C++标准 | 说明 |
---|---|---|
lock | C++11 | 锁定指定的互斥体,若任何一个不可用则阻塞 |
try_lock | C++11 | 试图通过重复调用 try_lock 获得互斥体的所有权 |
要避免死锁,需要仔细思考和设计业务逻辑。
有一个比较简单的原则可以避免死锁:对所有的锁进行排序,每次一定要按照顺序来获取锁,不允许乱序。
例如:要获取某个玩具,一定要先拿到锁A,再拿到锁B,才能玩玩具。这样就不会死锁了。
这个原则虽然简单,但却不容易遵守,因为数据常常是分散在很多地方的。
不过好消息是,C++11标准中为我们提供了一些工具来避免因为多把锁而导致的死锁。我们只要直接调用这些接口就可以了。
这个就是上面提到的两个函数。它们都支持传入多个 Lockable 对象。
接下来我们用它来改造之前死锁的转账系统:
// 10_improved_bank_transfer.cpp
bool transferMoney(Account* accountA, Account* accountB, double amount) {
lock(*accountA->getLock(), *accountB->getLock()); // ①
lock_guard lockA(*accountA->getLock(), adopt_lock); // ②
lock_guard lockB(*accountB->getLock(), adopt_lock); // ③
if (amount > accountA->getMoney()) {
return false;
}
accountA->changeMoney(-amount);
accountB->changeMoney(amount);
return true;
}
这里只改动了3行代码。
- 这里通过lock函数来获取两把锁,标准库的实现会保证不会发生死锁。
- lock_guard在下面我们还会详细介绍。这里只要知道它会在自身对象生命周期的范围内锁定互斥体即可。
创建lock_guard的目的是为了在transferMoney结束的时候释放锁,lockB也是一样。
但需要注意的是,这里传递了 adopt_lock表示:现在是已经获取到互斥体了的状态了,不用再次加锁(如果不加adopt_lock就是二次锁定了)。
通用互斥管理
互斥体(mutex相关类)提供了对于资源的保护功能,但手动锁定(调用 lock
或 try_lock
)和解锁(调用 unlock
)互斥体需要耗费较大的精力。
我们需要精心设计代码以确保解锁和加锁配对,因为如果某个路径导致获取锁后没有正常释放,会影响整个系统。此外,异常抛出也会使代码复杂化。
鉴于此,标准库提供了上述这些API。它们使用了RAII编程技巧,简化了手动加锁和解锁的过程。
RAII 可总结如下:
将每个资源封装入一个类,其中:
构造函数请求资源,并建立所有类不变式,或在它无法完成时抛出异常,
析构函数释放资源并决不抛出异常;
始终经由 RAII 类的实例使用满足要求的资源,该资源自身拥有自动存储期或临时生存期,或具有与自动或临时对象的生存期绑定的生存期
API | C++标准 | 说明 |
---|---|---|
lock_guard |
C++11 | 实现严格基于作用域的互斥体所有权包装器 |
unique_lock |
C++11 | 实现可移动的互斥体所有权包装器 |
锁定策略 | C++标准 | 说明 |
---|---|---|
try_to_lock |
C++11 | 类型为 try_to_lock_t ,尝试获得互斥的所有权而不阻塞 |
#include <thread>
#include <mutex>
#include <iostream>
int g_i = 0;
std::mutex g_i_mutex; // ①
void safe_increment()
{
std::lock_guard<std::mutex> lock(g_i_mutex); // ②
++g_i;
std::cout << std::this_thread::get_id() << ": " << g_i << '\n';
// ③
}
int main()
{
std::cout << "main: " << g_i << '\n';
std::thread t1(safe_increment); // ④
std::thread t2(safe_increment);
t1.join();
t2.join();
std::cout << "main: " << g_i << '\n';
}
这段代码中:
全局的互斥体g_i_mutex
用来保护全局变量g_i
这是一个设计为可以被多线程环境使用的方法。因此需要通过互斥体来进行保护。
这里没有调用lock
方法,而是直接使用lock_guard
来锁定互斥体。在方法结束的时候,局部变量std::lock_guard<std::mutex> lock
会被销毁,它对互斥体的锁定也就解除了。
条件变量
API | C++标准 | 说明 |
---|---|---|
condition_variable | C++ 11 | 提供与 std::unique_lock 关联的条件变量 |
条件变量提供了一个可以让多个线程间同步协作的功能。这对于生产者-消费者模型很有意义。在这个模型下:
生产者和消费者共享一个工作区。这个区间的大小是有限的。
生产者总是产生数据放入工作区中,当工作区满了。它就停下来等消费者消费一部分数据,然后继续工作。
消费者总是从工作区中拿出数据使用。当工作区中的数据全部被消费空了之后,它也会停下来等待生产者往工作区中放入新的数据。
从上面可以看到,无论是生产者还是消费者,当它们工作的条件不满足时,它们并不是直接报错返回,而是停下来等待,直到条件满足。
void changeMoney(double amount) {
unique_lock lock(mMoneyLock); // ②
mConditionVar.wait(lock, [this, amount] { // ③
return mMoney + amount > 0; // ④
});
mMoney += amount;
mConditionVar.notify_all(); // ⑤
}
在调用 wait() 函数之前需要加锁,主要是因为:
如果没有加锁,多个线程可能在没有同步的情况下同时检查条件****,从而导致多个线程错误地认为条件已经满足并继续执行。
-
加锁操作 (
unique_lock lock(mMoneyLock);
):
在第②行中,创建了一个std::unique_lock
对象lock
,并且立即对mMoneyLock
进行加锁。这是标准的加锁操作,它确保后续的临界区代码(即对共享资源mMoney
的修改)是线程安全的。 -
wait()
函数 (mConditionVar.wait(lock, ...);
):
在第③行,wait()
函数是用来等待某个条件满足的,在条件满足之前,当前线程会阻塞并释放lock
持有的锁,以允许其他线程获得锁。- 锁释放:
wait()
函数在阻塞当前线程时,会自动释放unique_lock
所持有的mMoneyLock
,从而避免其他线程无法获取锁并造成死锁。 - 重新加锁:一旦条件满足(即 lambda 函数返回
true
,条件为mMoney + amount > 0
),wait()
会重新加锁mMoneyLock
,然后继续执行后续的代码。
- 锁释放:
-
临界区代码:
在条件变量wait()
成功返回之后,unique_lock
再次持有mMoneyLock
,这时线程进入临界区(即对mMoney
的修改),并且执行对mMoney
的操作。 -
通知操作 (
notify_all()
):
在第⑤行,执行mConditionVar.notify_all()
通知其他等待线程,告诉它们某个条件可能已经发生了变化,让它们重新检查条件。
- 加锁解锁的行为:
std::condition_variable::wait()
函数在等待期间会释放锁,只有在条件满足时才会重新加锁并继续执行。因此,不存在重复上锁的情况。 - 锁的管理:
std::unique_lock
管理了整个过程的加锁和解锁行为,避免了手动管理锁的复杂性,并确保在需要时锁是正确的被持有。
异步机制
API | C++标准 | 说明 |
---|---|---|
async |
C++11 | 异步运行一个函数,并返回保有其结果的 std::future |
future |
C++11 | 等待被异步设置的值 |
很多语言都提供了异步的机制。异步使得耗时的操作不影响当前主线程的执行流。 |
std::async
和 线程 (std::thread
) 都是 C++ 中的并发机制,但它们有不同的使用方式、灵活性和处理方式。让我们来详细比较一下它们之间的区别。
- 抽象层级
std::async
:更高层次的抽象。它封装了线程的创建和管理,并且返回一个std::future
,允许用户轻松地获取异步任务的结果。async
不需要直接与线程打交道,主要关注任务的执行和结果。std::thread
:较低层次的抽象。它直接代表一个独立的线程。你需要手动创建线程,并决定如何管理它的生命周期(通过join()
或detach()
等操作)。它更灵活,但需要更多的管理工作。