同步操作
C++标准库为线程间同步提供了条件变量(condition variables)和future。并发规范中,为future添加了非常多的操作,并可与新工具锁存器(latches)和栅栏(barries)一起使用。
等待事件或条件#
这节开篇的这个例子我觉得非常好,摘抄如下:
假设你正在一辆在夜间运行的火车上,在夜间如何在正确的站点下车呢?
有一种方法是整晚都要醒着,每停一站都能知道,这样就不会错过你要到达的站点,但会很疲倦。另外,可以看一下时间表,估计一下火车到达目的地的时间,然后在一个稍早的时间点上设置闹铃,然后安心的睡会。
这个方法听起来也很不错,也没有错过你要下车的站点,但是当火车晚点时,就要被过早的叫醒了。当然,闹钟的电池也可能会没电了,并导致你睡过站。
理想的方式是,无论是早或晚,只要当火车到站的时候,有人或其他东西能把你叫醒就好了。
第一种方法就是“忙等待”的方式,使用while一直循环访问,毫无疑问这样会占用大量的资源。设置闹铃就类似于使用sleep的方式,但是这个睡眠的时间往往很难把握,睡短了和“忙等待”就没啥区别,睡长了容易导致任务不能及时处理,放在游戏程序里就会导致卡顿或丢帧。
理想的方法就需要借助标准库中的条件变量来实现了(当然,便准库也是封装的系统底层的接口,这层封装让我们调用时不用去关心是windows还是linux,达到了跨平台的目的)。
标准库中有两个条件变量,std::condition_variable
和std::condition_variable_any
。前者仅能和std::mutex
一起使用,但是更轻量;后者提供了和任意互斥量一起使用的灵活性,但是相对的开销要大一些。
通常将std::condition_variable
作为首选,需要更大的灵活性时,才会考虑std::condition_variable_any
。
使用方式:
std::mutex mtx;
std::condition_variable data_cond;
std::queue<DataPacket> data_queue;
void prepare_thread() {
while(data_to_prepare()) {
DataPacket data = prepare();
std::lock_guard<std::mutex> lg(mtx);
data_queue.push(data);
data_cond.notify_one(); // 1
}
}
void process_thread() {
while(true) {
std:unique_lock<std::mutex> ul(mtx);
data_cond.wait(ul, [] { return !data_queue.emtpy();});
DataPacket data = data_queue.front();
data_queue.pop();
ul.unlock();
process(data);
if(is_last_data(data)) {
break;
}
}
}
有两个函数分别运行在两个线程中,一个负责准备数据,将准备好的数据放入到数据队列中,一个负责处理数据,将数据从队列中取出来。于是我们使用条件变量来进行消息的传递,当数据准备好了,会通知处理线程(1),具体就是条件变量调用notify_one()
,唤醒另一个在等待的线程。
处理数据的线程先是在处理数据,先上了一把锁,保证处理过程中数据不会被修改,然后将这把锁和是否可以处理数据的条件(即谓词,通俗讲就是个判断条件,必须满足这个条件才行)传递给条件变量,条件变量会在调用了wait
后判断谓词是否成立,如果不成立,就把锁释放掉,且将当前线程阻塞等待,直到有另一个线程用这个条件变量来唤醒。
使用future#
future
是c++标准库中的一种抽象,指的是这样一种情形,你需要等待一个特定事件,即一个期望的结果,而又不确定什么时候能狗获得,并且希望在获得结果之前可以去做一些别的事情而不用一直在这傻等。future
就是做这样一件事,在被设置了之后,就会启动一个线程周期性的等待或者检查事件是否触发,在此期间,你可以去做其他的事情,当事件触发后,future
会置为就绪状态,且在就绪了之后就不再能够重置。
标准库中提供了两种future
——unique_future
和shared_future
,正如名称一样,unique_future
只能绑定一个特定的事件,而shared_future
可以由多个事件共享。
简单的使用场景就是当你遇到一个需要较长时间的计算过程,而你又不是很迫切地需要得到这个结果时,就可以交给future
来做。就像这段伪代码一样。
#include <future>
int main() {
std::future<int> answer = std::async(calculate());
do_something();
std::cout << answer.get() << std::endl;
}
和thread
类似,async
也可以传入变参,另外还可以传入std::launch::async
和std::launch::deffer
来影响是否延迟执行。
future和packaged_task#
async
不是与future
关联的唯一方式,还可以通过packaged_task<>
来产生联系。packaged_task
会将future
和函数或可调用对象进行绑定,调用packaged_task
对象时,会调用相应的函数或可调用对象,当future
就绪时,会保存返回值。
有如下的使用场景:图形框架需要一个特定的线程去刷新界面,当需要对界面进行刷新时,就发送一个消息给特定的线程,让它执行刷新。使用packaged_task
就可以很容易的实现这种功能。
std::mutex mtx;
std::queue<std::packaged_task<void()>> tasks;
bool gui_shutdown_received();
void get_and_process_gui_message();
void gui_thread() {
while(!gui_shutdown_received()) {
get_and_process_gui_message();
std::packaged_task<void()> task;
{
std::lock_guard<std::mutex> lg(mtx);
if(tasks.empty()) {
continue;
}
task = std::move(tasks.front());
tasks.pop();
}
task();
}
}
std::thread gui_bg_thread(gui_thread);
template<typename Func>
std::future<void> post_task_for_gui_thread(Func f) {
std::packaged_tast<void()> task(f);
std::future<void> res = task.get_future();
std::lock_guard<std::mutex> lg(mtx);
tasks.push(task);
return res;
}
一个后台线程gui_bg_thread
专门负责刷新界面,它不断从队列中读取消息,如果有的话,就拿出来执行掉。而post_task_for_gui_thread
是其他线程在需要刷新界面时会调用的函数,它将会把任务用packaged_task
打包,然后塞到队列中去,同时,调用get_futrue
给一个future
对象来接收对应的task执行的结果,一旦这个task执行完毕,那么对应的future
对象也就处于了就绪状态。
这个场景下的packaged_task<void()>
接受的是无参无返回值的函数或可调用对象(如果有返回值会被丢弃),在其他场景下可以很容易地扩展为接受参数和返回值,并且可以通过future
获取返回值。
std::promise#
future<>
提供了线程返回值的方式,而promise<>
提供了显式地设置线程中值的方式。
promise/future
构成了这样一种机制:future
可以阻塞等待线程,提供数据的线程可以通过promise
对相关值进行设置,并将future
的状态置为就绪。
也就是说,通过promise
可以手动地实现async
的过程。
void compute(std::promise<int>& prom) {
int value = 42;
prom.set_value(value);
}
void thread() {
std::promise<int> prom;
std::future<int> f = prom.get_future();
std::thread t(compute, std::ref(pom));
// do_something...
int res = f.get();
}
我们想要在一个线程中完成一个计算,在计算完成后得到计算结果,这个计算过程可能很长,而答案也不是很迫切的要得到,我们也希望在计算过程中去做一些其他的工作,这段代码就模拟了这种场景。用promise
来记录计算的结果,一旦计算结束就会通过绑定的future
在另一个线程中得到,在此过程中也可以去做一些其他事情。
但是,上述的代码是不处理异常的,如果在计算过程中出现了预料之外的错误,那可能会导致两个线程都出问题,好在promise
提供了异常处理机制,可以将异常通过绑定的future传递出去。
void compute(std::promise<int>& prom) {
try {
int value = 42;
prom.set_value(value);
}
catch(...) {
prom.set_exception(std::current_exception());
}
}
void thread() {
std::promise<int> prom;
std::future<int> f = prom.get_future();
std::thread t(compute, std::ref(pom));
// do_something...
try {
int res = f.get();
}
catch(std::exception& e) {
std::cerr << e.what() << std::endl;
}
}
多个线程的等待#
正如前面提到的,future
只能get一次,而如果要在多个线程中都获取某个future,就需要使用shared_future
来代替普通的future
。
多线程在没有额外同步的情况下,访问独立的
std::future
对象时,会存在数据竞争和未定义行为。因为std::future
独享同步结果,并且通过get()
函数一次性的获取数据,这就让并发访问变得毫无意义。
std::future
是只可移动的,所以其所有权可以在不同的实例间进行传递,但只能有一个实例可以获得特定的同步结果。而std::shared_future
是可拷贝的,所以多个对象可以引用同一个关联的期望值的结果。
每一个std::shared_future
的独立对象上,成员函数调用返回的结果还是不同步的。如果有多个线程访问同一个std::shared_future
对象时,需要加锁来对访问进行保护。而更好的做法是让每一个线程都拥有一个本地的std::shared_future
拷贝对象,这样每个线程都通过自己本地的拷贝对象来访问共享同步结果就是安全的。
std::promise<int> p;
std::future<int> f = p.get_future(); // 1
std::shared_future<int> sf(std::move(f)); // 2
在(1)中,f仍是合法的,当使用移动语义转移给sf后(2),如果再访问f就是非法的了,这时sf才是合法的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话