线程安全
如果多线程程序每一次运行结果和单线程运行结果始终都是一样的,那么线程就是安全的。
涉及共享区域时,先用mutex保护共享区域;如果当前没有可处理的新数据,就使用condition_variable的
wait/wait_for + predicate释放锁并等待;等条件满足后重新获得锁,快速把共享数据复制到局部变量,然后退出作用域释放mutex,最后在锁外完成耗时操作。同时为了避免多个线程对"启动、停止、是否启用识别"等简单状态产生数据竞争,可以用原子变量在每个线程或功能模块的外层做状态判断。
而共享区域可以通过结构体来构建一个缓存区,该结构体除了包含本身需要的数据以外,还有需要包括锁(mutex)、条件变量(condition_variable)、原子锁(atomic)等所需的多线程参数。
创建线程并开始执行--thread
通过thread库中thread类创建线程并开始执行。
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
f:可调用对象,可以是普通函数、函数指针、lambda 表达式、仿函数(重载了operator()的类)、成员函数指针。
args...:传递给可调用对象f的参数列表,支持任意数量、任意类型的参数。
#include <iostream>
#include <thread>
#include <string>
void printHello(std::string stg){
std::cout << stg << std::endl;
}
int main(){
// 创建线程
std::thread thread(printHello, "Hello");
return 0;
}
但是直接使用上述代码程序会报错,因为当你运行时,系统在编译完成创建线程后,会打印"Hello",同时继续向下执行,因此在打印"Hello"的进程中会触发return 0从而导致层序报错。
注意项目:参数传递机制:参数默认以值拷贝的方式传递给线程函数。若需传递引用,必须使用
std::ref(可修改引用)或std::cref(const 引用)包装。详情看functional部分。异常安全:若线程启动失败(如系统资源不足),会抛出
std::system_error异常。线程所有权:每个
std::thread对象唯一关联一个执行线程,所有权只能通过移动语义转移。
// 移动构造函数:转移线程所有权,原对象变为空 thread(thread&& other) noexcept;
获取并发运行的线程数量--std::thread::hardware_currency()
获取当前系统支持的硬件并发线程数的提示值。
#include <iostream> #include <thread> int main() { unsigned int n = std::thread::hardware_concurrency(); std::cout << "Supported threads: " << n << '\n'; }如果无法确定并发线程数时则直接返回0。
标识线程--std::thread::id
如果标识符返回为std::thread::id的默认构造则表示"没有线程",同时线程之间的标识符也可以对比,相同的则表示为同一个线程,否则为不同线程。
对std::thread使用get_id()调用某个线程的标识符
std::thread t1(func); std::thread::id id1 = t1.get_id();
使用std::this_thread::get_id()获取当前线程的标识符
std::thread::id id2 = std::this_thread::get_id();
检查线程是否关联--joinable
joinable状态:表示一个std::thread对象是否关联着一个实际的、可运行的执行线程。
其为true时为std::thread对象成功关联了执行线程,同时尚未调用join()或detach()。
其为false时有三种情况:1、有默认构造函数创建的空thread对象
2、已经调用了join()或detach()对象
3、被移动构造或者移动赋值后的原对象
std::thread t(func); if (t.joinable()) { // 线程对象关联着执行线程,可以调用 join() 或 detach() t.join(); }
主程序等待线程执行完毕--join
通过thread类中的成员函数join解决上面的问题,使用join成员函数会使主程序等待thread类执行完毕,再向下进行,但是join成员函数前面的主程序代码仍然会继续执行。
#include <iostream>
#include <thread>
void printHello(){
std::cout << "Hello" << std::endl;
}
int main(){
// 创建线程
std::thread thread(printHello);
thread.join();
return 0;
}
同步机制:
join()是最常用的线程同步方式。它保证了在join()返回之后,被等待线程的所有操作都已完成。换句话说join()也会导致主线程的阻塞。一次性操作:只能对一个
joinable的线程调用一次join()。调用后,线程对象变为非joinable。异常安全:如果在
join()之前发生了异常,程序可能会跳过join()调用,导致析构时终止程序。因此针对于异常安全的问题,我们一般需要使try-catch进行辅助判断。或者直接使用RAII锁守卫。
std::thread t(func); try{ do_something_in_current_thread(); } catch(...){ t.join(); throw; } t.join();
分离线程无需等待直接向下执行--detach()
通过thread类中的成员函数detach也可以解决上面的问题,但是其不需要和主线程同步,自己保证运行。
#include <iostream>
#include <thread>
void printHello(){
std::cout << "Hello" << std::endl;
}
int main(){
// 创建线程
std::thread thread(printHello);
thread.detach();
return 0;
}
失去控制权:一旦调用
detach(),你就再也无法与该线程交互。不能再对它调用join(),也无法知道它何时结束。自动回收:当分离的线程执行完毕时,操作系统会自动回收它的资源,无需手动干预。
生命周期独立:分离线程的生命周期不再受创建它的
std::thread对象影响。即使原对象被销毁,线程仍会继续运行。
互斥锁
互斥锁(mutex)是一种用于实现多线程同步的机制,用于确保多个线程之间对共享资源的访问互斥。互斥锁通常用于保护共享数据的访问,以避免多个线程同时访问同一个变量或者数据结构而导致的数据竞争问题。
互斥量类型
基础互斥量--std::mutex
核心特性
非递归:同一个线程不能连续多次调用
lock(),否则会导致未定义行为(通常是死锁)不可超时:调用
lock()会无限期阻塞,直到获取锁性能最优:所有互斥量中开销最小,实现最简单
异常安全:如果
lock()抛出异常,互斥量状态保持不变
注意事项
std::mutex通常使用再绝大多数简单的单锁场景,当不确定使用那个互斥锁便直接优先使用std::mutex。
绝对不要递归加锁。
永远配合
std::lock_guard或std::unique_lock使用,不要手动管理lock()/unlock(),容易出现死锁等错误。
递归互斥量--std::recursive_mutex
核心特性
支持递归:同一个线程可以多次调用
lock(),锁内部维护一个引用计数解锁要求:调用
unlock()的次数必须等于调用lock()的次数,锁才会真正释放性能略差:比
std::mutex多了引用计数的维护开销
注意事项
std::recursive_mutex通常在递归函数中需要保护共享资源。
但是其设计存在一定的缺陷,通常都可以通过重构代码来避免使用它:
首先将递归函数拆分为两个函数:一个公共接口函数加锁,一个私有递归函数不加锁。
其次再将共享资源作为参数传递给递归函数。
带超时的互斥量--std::timed_mutex
核心特性
继承了
std::mutex的所有特性新增两个超时等待方法,因此导致其性能有些许的下降,但是不影响整体的性能:
try_lock_for(duration):等待指定的时间段,如果超时仍未获取锁则返回false``try_lock_until(time_point):等待到指定的时间点,如果超时仍未获取锁则返回false
注意事项
std::timed_mutex通常用于避免无限期阻塞、死锁检测与恢复和实现非阻塞操作。
避免无限期阻塞:防止程序因某个线程持有锁时间过长而完全卡住
死锁检测与恢复:作为最后的安全网,在发生死锁时能够超时退出并进行恢复
实现非阻塞操作:如果无法立即获取锁,可以先去做其他事情
超时时间不宜过短,否则会导致频繁的重试和上下文切换
超时机制不能从根本上解决死锁问题,只能作为最后的防御手段
递归超时互斥量--std::recursive_timed_mutex
核心特性
结合了
std::recursive_mutex和std::timed_mutex的所有特性支持同一个线程多次加锁
支持超时等待获取锁
但是因此性能也是所有互斥锁类型中最为低效的
注意事项
std::recursive_timed_mutex很少使用,首先性能较低,它通常需要同时递归加锁和超时等待时才会用到,单个情景下一般都有更好的选择。
读写锁--std:shared_mutex(c++17)
核心特性
目前最为强大的互斥锁类型,性能也和递归互斥量、带超时互斥量相同,它支持两种访问模式:
共享模式(读模式):多个线程可以同时持有共享锁,适用于读取共享资源
独占模式(写模式):同一时间只能有一个线程持有独占锁,适用于修改共享资源
读写规则
读 - 读:不互斥,多个线程可以同时读
读 - 写:互斥,读操作会阻塞写操作,写操作也会阻塞读操作
写 - 写:互斥,多个写操作之间互相阻塞
注意事项
std:shared_mutex通常使用在读多写少的场景,在这种场景中它可以带来巨大的性能提升。
绝对不要递归加锁。
不要在持有共享锁的情况下升级为独占锁,这会导致死锁
不要在持有独占锁的情况下降级为共享锁
如果读写频率相当,
std::shared_mutex的性能可能不如std::mutex
加锁(lock())、解锁(unlock())
其需要包含头文件<mutex>,其主要有两个方面的作用函数--加锁(lock())、解锁(unlock())。
加锁相当于就就是获取这个共享资源的所有权,解锁相当于是释放这个共享资源的所有权。
#include <iostream> #include <thread> #include <mutex> std::mutex mtx; void func1(int& a) { for (int i = 0; i < 1000; ++i) { mtx.lock(); a += i; mtx.unlock(); } } void func2(int& a) { for (int i = 0; i < 1000; ++i) { mtx.lock(); a += i; mtx.unlock(); } } int main() { int a = 0; std::thread t1(func1, std::ref(a)); std::thread t2(func2, std::ref(a)); t1.join(); t2.join(); std::cout << a << std::endl; return 0; }如上述代码所示,通过加锁和解锁操作将结果正确输出,消除了进程之间共享资源的访问互斥问题。
但是上述代码也存在一个比较严重的隐患,当加锁和解锁之间存在一个错误,或者抛出了任何的异常,所有等待这个锁的线程都会无限阻塞,整个程序陷入死锁,直接导致程序的完全停滞。
同时添加多个互斥锁
std::lock--c++11
上述已经介绍了这个类,不再过多赘述。
但是该函数不包含解锁功能,需要对加锁的互斥量逐个解锁,因此一般配合RAII锁守卫来自动释放互斥锁。
std::scoped_lock--c++17
其相当于就是std::lock和RAII锁守卫的结合,会自动原子性获取所有锁,同时也不需要关心加锁的顺序以及解锁。
std::scoped_lock lock(m1, m2);
死锁
概念
死锁是指两个或多个线程互相等待对方释放已经持有的锁,导致所有线程都永远阻塞,无法继续执行的状态。
死锁发生时,程序会完全卡住,没有任何报错,也不会崩溃,只能强制终止。
满足条件
要发生互斥量死锁必须满足以下四个条件,缺一不可:
互斥条件:资源同一时间只能被一个线程占用
持有并等待:线程已经持有至少一个资源,同时又请求其他被其他线程持有的资源
不可剥夺:资源只能由持有者主动释放,不能被其他线程强行剥夺
循环等待:线程之间形成循环等待资源的关系(A 等 B,B 等 A)
#include <iostream> #include <thread> #include <mutex> std::mutex m1; std::mutex m2; void thread1() { for (int i = 0; i < 1000; ++i) { m1.lock(); m2.lock(); m1.unlock(); m2.unlock(); } } void thread2() { for (int i = 0; i < 1000; ++i) { m2.lock(); m1.lock(); m2.unlock(); m1.unlock(); } } int main() { std::thread t1(thread1); std::thread t2(thread2); t1.join(); t2.join(); std::cout << "程序正常结束" << std::endl; return 0; }
解决方法
按相同的顺序加锁
void thread1() { for (int i = 0; i < 1000; ++i) { m1.lock(); m2.lock(); m1.unlock(); m2.unlock(); } } void thread2() { for (int i = 0; i < 1000; ++i) { m1.lock(); m2.lock(); m2.unlock(); m1.unlock(); } }
使用std::lock一次性加多个锁(c++11)
void thread1() { for (int i = 0; i < 1000; ++i) { std::lock(m1, m2); m1.unlock(); m2.unlock(); } } void thread2() { for (int i = 0; i < 1000; ++i) { std::lock(m2, m1); // std::lock(m1, m2);也可以 m1.unlock(); m2.unlock(); } }std::lock与mutex::lock之间的区别
特性 mutex::lock()std::lock()处理数量 只能处理1个互斥量 可以处理任意数量的互斥量 死锁风险 多锁场景下极高 无(专门解决多锁死锁问题) 原子性 无(逐个加锁,可能部分成功) 有(要么全成功,要么全失败) 异常安全 差(获取第二个锁失败时,第一个锁会泄漏) 强(任何异常都会释放所有已获取的锁) 使用方式 成员函数调用 全局函数调用 返回值 voidvoid适用场景 只需要获取一个锁 需要同时获取多个锁
RAII锁守卫
RAII 锁守卫是为了解决手动管理lock()/unlock()带来的死锁和异常安全问题。其核心思想就是:
将资源的生命周期与对象的生命周期绑定
在对象的构造函数中获取资源
在对象的析构函数中释放资源
换句话说,其实就是智能指针,只不过RAII锁守卫管理锁资源,但是智能指针管理内存资源
需要注意的是锁守卫的生命周期必须严格小于等于它保护的临界区的范围
std::lock_guard
核心特性
构造时自动加锁,析构时自动解锁
不可手动解锁:一旦创建,就会持有锁直到离开作用域
不可复制、不可移动:锁的所有权不能转移
零开销:性能和手动调用
lock()/unlock()完全相同
注意事项
std::lock_guard适用于绝大多数简单的单锁场景,当不需要任何额外功能时,都可以优先使用std::lock_guard
#include <mutex> std::mutex mtx; int counter = 0; void increment() { // 构造时自动加锁 std::lock_guard<std::mutex> lock(mtx); counter++; // 离开作用域自动解锁 }
std::adopt_lock
注意事项
std::adopt_lock是在共享资源已经被当前线程获取时,可以使用std::adopt_lock来接管锁的所有权,其没有加锁功能,只负责离开作用域时解锁,因此一般配合std::lock()来使用
void func() { mtx.lock(); // 已经手动加锁 // 锁已经被持有,只负责解锁 std::lock_guard<std::mutex> lock(mtx, std::adopt_lock); // 离开作用域自动解锁 }
std::unique_lock
核心特性
拥有
std::lock_guard的所有功能支持延迟加锁:构造时可以不加锁,后续手动加锁
支持手动解锁和重新加锁:可以在需要时提前释放锁
支持转移所有权:可以通过
std::move()将锁的所有权转移给另一个std::unique_lock支持配合条件变量使用:这是条件变量唯一能使用的锁守卫
注意事项
std::unique_lock适用于需要上述特性的额外功能时的场景,其一般配合std::condition_variable使用,通过std::condition_variable来设置条件变量
延迟加锁--std::defer_lock/std::unique_lock.lock()
void func() { // 构造时不加锁 std::unique_lock<std::mutex> lock(mtx, std::defer_lock); ... // 手动加锁 lock.lock(); ... }
手动解锁--std::unique_lock.unlock()
void func() { std::unique_lock<std::mutex> lock(mtx); ... lock.unlock(); // 提前解锁,不再持有锁 ... }
转移所有权--std::move()
std::unique_lock<std::mutex> get_lock() { std::unique_lock<std::mutex> lock1(mtx); // ... return lock1; // 自动 move,返回锁所有权 } void func() { ... // 转移lock1所有权,lock1变为空状态,再离开作用域后自动解析 auto lock2 = get_lock(); // 同一作用域下可以使用auto lock2 = std::move(lock1); ... }
尝试加锁
std::unique_lock.lock()
尝试对互斥量进行加锁操作,如果互斥量已经被其他线程持有所有权,则当前线程会被阻塞,直到互斥量被成功加锁。
std::unique_lock.try_lock()
尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有所有权,则函数返回false,否则为true
std::unique_lock.try_lock_for(const std::chrono::duration<Rep, Period>& rel_time)
尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有所有权,则当前线程会被阻塞,直到互斥量被成功加锁或者超过指定的时间。类似于带超时的互斥量--std:timed_mutex。
std::unique_lock.try_lock_until()
尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有所有权,则当前线程会被阻塞,直到互斥量被成功加锁或者超过指定的时间点。类似于带超时的互斥量--std:timed_mutex。
std::shared_lock--c++14
核心特性
专门用于配合
std::shared_mutex使用构造时获取共享锁(读锁),析构时释放共享锁
支持延迟加锁、手动解锁、转移所有权
多个线程可以同时持有同一个
std::shared_mutex的std::shared_lock
注意事项
和
std::shared_mutex搭配使用从而完成读操作
#include <shared_mutex> std::shared_mutex smtx; std::string config; // 读操作:使用共享锁 std::string get_config() { std::shared_lock<std::shared_mutex> lock(smtx); return config; } // 写操作:使用独占锁 void set_config(const std::string& new_config) { std::unique_lock<std::shared_mutex> lock(smtx); config = new_config; }
std::scoped_lock--c++17
核心特性
天生支持同时管理多个互斥量
内部自动调用
std::lock(),原子性地获取所有锁,从根本上避免死锁构造时自动加锁,析构时自动解锁
不可复制、不可移动
零额外开销
注意事项
std::scoped_lock适用于需要同时获取多个锁的所有场景。
std::mutex m1, m2, m3; void safe_func() { // 自动同时获取三个锁,不会死锁 std::scoped_lock lock(m1, m2, m3); // 离开作用域自动解锁所有锁 }
强制一次执行--std::call_once
std::call_once专门用于解决"多线程环境下"某段代码只需要执行一次的问题。
关键特性
线程安全:多个线程同时调用
call_once时,只有一个线程会执行函数,其他线程会等待直到执行完成异常安全:如果执行函数时抛出异常,
once_flag不会被标记为 "已完成",其他线程会继续尝试执行内存可见性:函数执行完成后,所有等待的线程都能看到函数产生的所有副作用
不可复制:
std::once_flag对象不能被复制或赋值
#include <iostream> #include <thread> #include <mutex> std::once_flag init_flag; void initialize() { std::cout << "初始化操作,只会执行一次!" << std::endl; } void worker_thread() { std::call_once(init_flag, initialize); std::cout << "线程执行其他任务" << std::endl; } int main() { std::thread t1(worker_thread); std::thread t2(worker_thread); std::thread t3(worker_thread); t1.join(); t2.join(); t3.join(); return 0; }可以看出多个线程都调用了woker_thread线程,但是其中的函数initialize只执行了一次。因此其常用于日志系统、配置文件等的初始化。
同时还需要注意的是主函数中不可以调用call_once函数。
条件变量--std::condition_variable
condition_variable不是一个变量,而是一个库,其专门用于实现线程之间的"等待-通知"机制。解决互斥锁中让一个线程等待另一个线程完成某个操作后再继续执行该线程的性能协调问题。
std::condition_variable通常与std::mutex以及std::unique_lock同时使用。
步骤
1、创建一个std::condition_variable对象
2、创建一个互斥锁std::mutex对象,用于保护共享资源的访问
3、在需要等待条件变量的地方,使用std::unique_lock<std::mutex>对象锁定互斥锁,并调用std::condition_variable::wait()、std::condition_variable::wait_for()或std::condition_variable::wait_until()函数等待条件变量。
4、在其他线程中需要通知等待的线程时,调用std::condition_variable::notify_one()或者std::condition_variable::notify_all()函数通知等待的线程。
核心成员函数
| 函数 | 作用 |
|---|---|
wait(lock) |
使当前线程进入阻塞状态,直到被唤醒 |
wait(lock, predicate) |
带谓词的等待,只有当谓词返回true时才会继续执行(自动处理虚假唤醒) |
wait_for(lock, duration) |
等待指定的时间长度,超时后自动返回 |
wait_until(lock, time_point) |
等待直到指定的时间点,超时后自动返回 |
notify_one() |
唤醒一个正在等待的线程 |
notify_all() |
唤醒所有正在等待的线程 |
生产者 - 消费者模型
#include <iostream> #include <thread> #include <mutex> #include <string> #include <condition_variable> #include <queue> std::queue<int> g_queue; std::condition_variable g_cv; std::mutex mtx; void Producer() { for (int i = 0; i < 10; ++i) { { std::unique_lock<std::mutex> lock(mtx); g_queue.push(i); // 加任务时需要通知消费者来取任务 g_cv.notify_one(); std::cout << "添加的任务" << i << std::endl; } //休眠100ms std::this_thread::sleep_for(std::chrono::microseconds(100)); } } void Consumer() { while (true) { std::unique_lock<std::mutex> lock(mtx); // 如果队列为空,则需要等待 g_cv.wait(lock, [&]() {return !g_queue.empty();}); int value = g_queue.front(); g_queue.pop(); std::cout << "取出的任务" << value << std::endl; } } int main() { std::thread t1(Producer); std::thread t2(Consumer); t1.join(); t2.join(); return 0; }
获取返回值类型
std::invoke_result(c++17)
// 推导 add(int, int) 的返回值类型 using ResultType = typename std::invoke_result<decltype(add), int, int>::type; // 推导 Person类中成员函数add(int, int)返回类型,使用类作为传用时,第一个传入的参数需要是类 using ResultType = typename std::invoke_result<decltype(&Person::add), Person, int, int>::type; // 返回函数模板的任意参数 template<class F, class... Args> typename std::invoke_result<F, Args...>::type
函数别名--std::invoke_result_t
定义
template <class F, class... Args> using invoke_result_t = typename invoke_result<F, Args...>::type;
使用
// 推导 add(int, int) 的返回值类型 using ResultType = typename std::invoke_result_t<decltype(add), int, int>; // 推导 Person类中成员函数add(int, int)返回类型,使用类作为传用时,第一个传入的参数需要是类 using ResultType = typename std::invoke_result_t<decltype(&Person::add), Person, int, int>; // 返回函数模板的任意参数 template<class F, class... Args> typename std::invoke_result_t<F, Args...>
std::result_of(c++11)
最新的c++版本已经弃用,作用和invoke_result相同。
与invoke_result的不同
它接受单个模板参数,这个参数必须是一个函数类型
F(Args...)其中
F是可调用对象的类型,Args...是调用时传入的参数类型列表本质上是将 "可调用对象 + 参数" 打包成一个函数类型来进行推导
// 推导 add(int, int) 的返回值类型 using ResultType = typename std::result_of<decltype(add)(int, int)>::type; // 推导 Person类中成员函数add(int, int)返回类型,使用类作为传用时,第一个传入的参数需要是类 using ResultType = typename std::invoke_result<decltype(&Person::add) (Person, int, int)>::type; // 同时如果成员函数被const修饰,必须使用const修饰传入的第一个参数 using ResultType = typename std::invoke_result<decltype(&Person::add) (const Person, int, int)>::type; // 无法返回函数模板的任意参数 // template<class F, class... Args> // typename std::invoke_result<F, Args...>::type
函数别名--std::invoke_result_t
定义
template <class F> using result_of_t = typename result_of<F>::type;
使用
// 推导 add(int, int) 的返回值类型 using ResultType = typename std::result_of_t<decltype(add)(int, int)>; // 推导 Person类中成员函数add(int, int)返回类型,使用类作为传用时,第一个传入的参数需要是类 using ResultType = typename std::invoke_result_t<decltype(&Person::add) (Person, int, int)>; // 同时如果成员函数被const修饰,必须使用const修饰传入的第一个参数 using ResultType = typename std::invoke_result_t<decltype(&Person::add) (const Person, int, int)>; // 无法返回函数模板的任意参数 // template<class F, class... Args> // typename std::invoke_result<F, Args...>::type
functional库
通用函数包装器--std::function
std::function是一个多态函数包装器,可以存储、复制和调用任何可调用对象。它实现了 "类型擦除" 技术,将不同类型的可调用对象统一为相同的接口。
// 包装int add(int, int) std::function<double(double, int)> func1 = add; std::cout << std::is_same_v<decltype(func1(2, 3)), double> << std::endl; // 1 // 包装lambda表达式 std::function<double(double, int)> func2 = [](int a, int b){a - b}; std::cout << std::is_same_v<decltype(func2(3, 2)), double> << std::endl; // 1
参数绑定器--std::bind
std::bind是一个参数绑定器,其可以将可调用对象和部分参数相绑定。
其常常与std::placeholders命名空间连用,该命名空间中将_1,_2等表示未绑定的参数,分别对应第1个参数、第2个参数等,其中第0个参数为最左边的参数。
using namespace std::placeholders; // 将add(int, int, int)绑定部分参数 auto add5 = std::bind(add, 5, _2, _1); std::cout << add5(3, 2) << std::endl; // 等价于add(5,2,3),输出:10 // 绑定成员函数 Person alice{"Alice"}; auto alice_says = std::bind(&Person::say_hello, &alice, _1); alice_says("Hello World!"); // 输出:Hello World!但是由于bind只能绑定参数,同时性能与使用invoke转化的lambda表示相同,因此更多的在c++版本较高时使用lambda来直接传递较好。
引入包装器--std::ref/std::cref
这就是前文中提过的要给std::thread传入一个引用的参数时,需要使用std::ref/std::cref修饰。
std::ref(t):创建对t的非 const 引用包装器
std::cref(t):创建对t的 const 引用包装器
通用调用工具--std::invoke
std::invoke是一个通用的函数调用工具,可以同一调用任何可调用对象。
// 调用add(int, int, int) std::cout << std::invoke(add, 5, 3, 2) << std::endl; // 等价于add(5,3,2),输出:10 // 调用lambda表达式 auto subtract = [](int a, int b) { return a - b; }; std::cout << std::invoke(subtract, 5, 3) << std::endl;
future库
作用
future库是用来处理可能执行在另一个线程上的操作的异步结果。
future只能移动,不可以构造,同时需要确保同一时间只有一个future对象持有异步结果的所有权。
创建future对象
每个future对象只能取一次结果值,再次取会导致对象报错,因此我们通常可以建立一个共享结构体来解决只能取一次值的问题,但是一个共享结构体通常都会由许多数据类型构成,不仅包含缓存区、还有锁、条件变量和原子锁等,较为复杂。
std::async
std::async是最常用的创建future的方式,它会自动创建线程来执行任务。其内部封装好了std::thread、std::packaged_task和std::invoke逻辑。
但是这也是在封装线程池时不使用std::async的原因,因此其会自动执行任务,而在创建线程池是只需要将任务封装进入任务队列,然后再线程池中才开始执行任务。同时如果自动执行任务,可能会导致线程创建和销毁的开销极大,加入了多少个任务,就会出现多少个线程,严重影响了线程池的运行。
auto f = std::async(flags, 可调用对象, 对象参数)
flags:
std::launch::async:立即创建新线程执行任务
std::launch::deferred:延迟执行,直到调用get()或wait()时才在当前线程执行
std::launch::async | std::launch::deferred:默认值,由系统决定使用哪种策略
#include <iostream> #include <future> #include <thread> #include <chrono> void task(const std::string& name) { std::cout << name << " 开始执行,线程ID: " << std::this_thread::get_id() << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << name << " 执行完成" << std::endl; } int main() { std::cout << "主线程ID: " << std::this_thread::get_id() << std::endl; // 立即启动新线程 auto f1 = std::async(std::launch::async, task, "任务1"); // 延迟执行 auto f2 = std::async(std::launch::deferred, task, "任务2"); std::this_thread::sleep_for(std::chrono::milliseconds(500)); std::cout << "主线程等待了0.5秒" << std::endl; f1.wait(); f2.wait(); // 此时任务2才开始执行,且在主线程中执行 return 0; }
std::promise
std::promise是最为基础的结果传递原语,用于实现线程间单向数据传递和同步。其和future是最为底层的类,future负责获取结果或者异常,而promise负责设置或者传递结果或者异常。
std::promise使用通常需要自己通过set_value来设置结果,以唤醒等待的future。
线程间传值/异常
#include <iostream> #include <future> #include <thread> #include <chrono> // 计算结果并传递到promise void compute_task(std::promise<int> result_prom, int a, int b) { int sum = a + b; result_prom.set_value(sum); // 设置结果,唤醒等待的future } int main() { // 创建promise对象 std::promise<int> sum_prom; // 获取关联的future对象 std::future<int> sum_fut = sum_prom.get_future(); // 将promise移动到线程中 std::thread t(compute_task, std::move(sum_prom), 10, 20); // 主线程等待并获取结果(结果正常) std::cout << "等待计算结果..." << std::endl; std::cout << "计算结果:" << sum_fut.get() << std::endl; // 阻塞直到结果就绪 // 主线程等待并获取结果(结果异常) std::cout << "等待计算结果..." << std::endl; try { sum_fut.get(); // 重新抛出子线程中的异常 } catch (const std::exception& e) { std::cerr << "捕获异常:" << e.what() << std::endl; } t.join(); return 0; }这是子线程中的值传递给主线程,将主线程的值传递给子线程或者子线程中相互传递也是可以的。将值传递给多个线程时,不会造成堵塞,多个线程同时进行。
std::packaged_task
std::packaged_task是一个可调用对象包装器,实现任务与结果传递的解耦。但是不负责运行线程。其最大的特点便是如此--任务创建和执行完全分离,这也是线程池使用std::packaged_task的重要原因。
template <class R, class... Args>
class packaged_task<R(Args...)>;
#include <iostream> #include <future> #include <thread> #include <chrono> int compute_sum(int a, int b) { std::this_thread::sleep_for(std::chrono::seconds(1)); return a + b; } int main() { // 创建packaged_task std::packaged_task<int(int, int)> task(compute_sum); // 获取关联的future std::future<int> fut = task.get_future(); // 将task移动到新线程中执行 std::thread t(std::move(task), 10, 20); // 主线程等待并获取结果 std::cout << "计算中..." << std::endl; std::cout << "结果:" << fut.get() << std::endl; t.join(); return 0; }
三种方式的区别
特性 std::packaged_task std::async std::promise 核心功能 包装可调用对象,自动设置结果 一键启动异步任务 手动设置结果 / 异常 任务创建与执行 完全分离 绑定在一起 完全分离 线程管理 无,需手动创建线程 自动创建 / 复用线程 无,需手动创建线程 结果设置 调用 operator () 时自动设置 任务完成时自动设置 手动调用 set_value () 可复用性 支持(通过 reset ()) 不支持 不支持 灵活性 高 低 最高 易用性 中等 最高 最低 适用场景 线程池、任务队列、延迟执行 简单异步任务 自定义异步逻辑 总的来说,最为底层的便是promise,因此promise仅能设置或者传递结果/异常。
而promise和future构成了packaged_task,因此其在设置或传递结果/异常的基础上,多了future的功能--获取结果/异常。
packaged_task又和invoke、thread构成了async,因此其又多出了封装可调用对象并创建线程开始执行的功能。
获取future对象--get_future()
通过上面三种方法创建的不同类,都可以使用get_future()来得到future对象。具体用法在上述的例子中有所提及,不再赘述。
获取future对象的结果--futrue.get()
该函数也在上面的例子中出现,函数的作用是为了获取future对象的结果,无论这个结果是异常还是正确结果。
但是该函数只能调用一次,调用过后future对象变为空状态,再次使用时会导致未定义行为。
等待任务完成
由于多个线程同时使用一个std::future是一个未定义行为,会导致报错。同时这三个方法只会阻塞线程的调用,而不会报错,报错仍需通过future.get()来获取。
共享状态
返回值 含义 后续操作 std::future_status::ready任务已完成,共享状态就绪 可以安全调用 get()获取结果std::future_status::timeout等待超时,任务仍在运行 可以继续等待或执行其他逻辑 std::future_status::deferred任务被延迟执行(使用了 std::launch::deferred策略)任务尚未启动,只有调用 get()才会执行
检查future是否关联了有效的共享状态--future.vaild()
当future没有关联有效的共享状态时,使用wait/get等函数会导致未定义行为。因此需要提前对future对象进行是否关联有效的共享状态的判断。
std::packaged_task<int()> task([]() { return 42; }); std::future<int> fut2 = task.get_future();
不去获取结果,仅获取共享状态--future.wait()
如果共享状态已经是
ready,立即返回如果共享状态是
pending,阻塞当前线程直到状态变为ready如果共享状态是
deferred,不会阻塞,立即返回
等待一定时间,返回共享状态--future.wait_for(duration)/future.wait_until(time_point)
如果共享状态已经是
ready,立即返回ready如果共享状态是
deferred,不会阻塞,立即返回deferred如果共享状态是
pending,阻塞当前线程最多timeout_duration时长/到time_point时间如果在等待期间任务完成,返回
ready如果超时任务仍未完成,返回
timeout
共享对象--std::shared_future/future.share()
通过上一部分的介绍,我们知道future对象不可以同时被多个线程调用,这样会导致未定义行为。因此shared_future作为future的扩展版本,主要用于解决该问题。
std::shared_future采用共享所有权模型:多个shared_future对象可以指向同一个共享状态,共享状态通过引用计数管理生命周期。当最后一个shared_future对象被销毁时,共享状态才会被释放。
std::promise<int> prom; std::future<int> fut = prom.get_future(); std::shared_future<int> sfut = fut.share(); // 转换为shared_future通过share转移所有权后,原先的fut状态变为false,而sfut状态变为true。
和std::future的区别
特性 std::future<T>::get() std::shared_future<T>::get() 调用次数 只能调用一次 可以调用任意多次 返回值 T&&(右值引用,转移所有权) const T&(常量引用,不转移所有权) 线程安全 非线程安全,只能被一个线程调用 线程安全,多个线程可以同时调用 对 valid () 的影响 调用后 valid () 变为 false 调用后 valid () 仍然为 true 异常处理 每次调用都会重新抛出异常 每次调用都会重新抛出异常
时钟库--chrono
这是c++标准库的函数
现实世界当前时间:std::chrono::system_clock::now()
时间段:std::chrono::duration<T, P>
其中第一个模板参数指该时间段所代表的类型,如int、short、double等
其中第二个模板参数指该时间段单位表示多少秒,通过使用std::ration表示,std::ration<60, 1>表示60/1秒也就是1分钟,std::ration<1,1000>表示1/1000秒也就是1毫秒
具体时间段:std::chrono::seconds(x)(x秒)、std::chrono::milliseconds(x)(x毫秒)、std::chrono::micromilliseconds(x)(x微秒)、std::chrono::nanoseconds(x)(x纳秒)
时间点:std::chrono::steady_clock::now(),该参数常被用来测时间间隔
高精度时间点:std::chrono::high_resolution_clock::now(),该参数常被用来测需要高精度的时间间隔
原子锁
原子的意思便是一个操作不可再被分割,要么完整发生,要么完全没有发生,其他线程看不到它执行到一半的中间状态。简单来说就是将一个线程作为一个整体,当该线程在运行时,你无法从这个线程中取得任何的值,只有在这个线程还没有开始运行或者已经运行结束后,才能从线程中取得值。
原子变量--std::atomic<T>
其作用便是让多个线程对同一个简单变量进行读写时,不发生数据竞争。其常用的模板参数类型有bool型和int型,其中bool型为最为基础的原子变量。
bool型
创建--atomic<bool>{}
std::atomic<bool> running{true};表示初始化running=true
读取--load()
bool value = running.load();将running的值赋值给value
写入--store()
running.store(false);将false赋值给running
交换--exchange()
bool stopping = running.exchange(false);将running的值赋值给stopping,同时将stopping的值赋值给running
比较后交换--compare_exchange_strong()
std::atomic<bool> flag{false}; bool expected = false; bool success = flag.compare_exchange_strong(expected, true);flag和expected相比较,相等则将flag赋值为true,不相等则flag不变,但是expected会被赋值为flag的值。
T型
T型的原子变量同样具有bool型原子变量的所有成员函数,除此之外,其还具备指针算术运算。其由fetch_add()和fetch_sub()两个成员函数提供。
算术运算
算术运行其实类似于之前的exchange交换,在进行了算术运算后,源元素会按照算术运算进行计算,但是在运算之前,源元素的值会被赋值给接收元素。
#include<iostream> #include<cassert> #include<atomic> class Foo{}; int main() { Foo some_array[5]; std::atomic<Foo*> p(some_array); assert(p == some_array); assert(p.load() == &some_array[0]); Foo* x = p.fetch_add(2); assert(x == some_array); assert(p.load() == &some_array[2]); x = (p -= 1); assert(x == &some_array[1]); assert(p.load() == &some_array[1]); return 0; }从这里可以看出,最开始的原子指针变量保存的值为数组第一个元素,而且由于这个原子锁仅是对指针进行原子化,因此添加原子锁不会增加数据的内存。
但是在通过算术运算进行加2后,原子锁原子化的元素就会想要的增加,同时其会将自己原本的值赋值给接收的元素x。
fetch_sub()和上述用法相同,其主要通过减法进行运算。
这些运算还有复合赋值形式,如+=、-=、&=、|=和^=,同时这个也需要区分前缀/后缀赋值,与c++的语法相同。
布尔标志原子锁--std::atomic_flag
该原子锁是最为简单的标志原子类型,其仅代表一个布尔标志,其对象主要是两中状态即设置和清除。通常我们并不使用这个原子锁。



