问题详情:
std::thread创建出来一个线程对象之后,如果不进行join
如果 std::thread 创建出来后既不 join(),也不 detach(),那么程序最终会调用 std::terminate(),直接异常终止。
这是 C++ 标准规定的行为。
例如:
#include <thread>
#include <iostream>
void func() {
std::cout << "thread running\n";
}
int main() {
std::thread t(func);
// 什么都不做
} // t 在这里析构
程序运行到 main 结束时,t 会析构。
而 std::thread 的析构函数大致等价于:
~thread() {
if (joinable())
std::terminate();
}
因为线程仍然是 joinable 状态,所以直接终止程序。
很多人误以为线程运行结束了,就不是 joinable 了。
实际上不是。
例如:
std::thread t([]{
// 很快结束
});
std::this_thread::sleep_for(std::chrono::seconds(1));
这时候线程函数早就执行完了。
但是:
t.joinable(); // 仍然是 true
因为:
joinable 表示是否拥有一个线程句柄,而不是线程是否仍在运行。
线程结束以后:
t.join();
去完成"收尸(reap)"。
t.join();
作用:
之后:
t.joinable() == false
再次调用 join 会抛异常:
std::system_error
另一种方式:
t.detach();
表示:
我不要再管理这个线程了。
之后:
例如:
std::thread([]{
std::this_thread::sleep_for(std::chrono::seconds(5));
std::cout << "done\n";
}).detach();
后台继续执行。
如果允许:
std::thread t(func);
// 什么都不写
离开作用域自动 detach,
那么很容易出现:
void foo()
{
int x = 10;
std::thread t([&]{
std::cout << x;
});
} // x 已经销毁
如果自动 detach:
后台线程继续运行:
访问已经释放的 x
产生悬空引用(Undefined Behavior)。
因此 C++ 标准选择:
宁可直接 terminate,也不要偷偷 detach。
这样程序员必须明确表达意图:
join()detach()必须。
例如:
std::thread t([]{
std::cout << "hello\n";
});
std::this_thread::sleep_for(std::chrono::seconds(2));
// 线程肯定结束了
// 仍然必须:
t.join();
否则:
// t 析构
std::terminate();
因为:
线程是否结束 joinable
-------------------------
正在运行 true
已经结束 true
join之后 false
detach之后 false
线程结束并不会自动把 joinable() 变成 false。
对于 std::thread,应确保每个线程对象最终都执行以下二者之一:
join():等待线程完成并回收资源,适用于绝大多数场景。detach():让线程独立运行,仅在明确知道其生命周期和资源管理不会出问题时使用。为了避免因异常或提前返回导致遗漏 join(),C++20 引入了 std::jthread:
#include <thread>
void work() {}
int main() {
std::jthread t(work);
} // 自动 join,不会 terminate
std::jthread 在析构时会自动请求停止(如果线程支持)并执行 join(),因此在现代 C++ 中通常比 std::thread 更安全、更推荐使用。
当然可以。std::atomic 可以说是 C++ 并发里另一个非常重要的工具,它和 std::mutex 的定位完全不同。
很多人学到这里都会有一个疑问:
既然 mutex 能保证线程安全,为什么还要有 atomic?
答案就在于它们解决的问题不同。
假设有一个计数器:
int count = 0;
两个线程同时执行:
count++;
你可能觉得:
count = count + 1
很简单。
其实 CPU 执行的是:
① 从内存读取 count
② count + 1
③ 写回内存
例如:
开始:
count = 0
线程A:
读取
count = 0
与此同时:
线程B:
读取
count = 0
然后:
线程A:
+1
写回
count = 1
线程B:
+1
写回
count = 1
最终:
count == 1
实际上应该:
count == 2
这就是:
Data Race(数据竞争)
可以这样:
std::mutex mtx;
void add()
{
std::lock_guard lock(mtx);
++count;
}
这样:
线程A
lock
++
unlock
-------------
线程B
lock
++
unlock
正确。
但是:
每次:
lock
unlock
都有一定开销。
假设:
我只是:
++count;
为了:
这一行。
结果:
还要:
mutex
↓
操作系统调度
↓
线程切换
是不是有点重?
于是:
CPU 提供了:
原子指令(Atomic Instruction)
例如:
x86:
LOCK XADD
CPU 能保证:
读取
+
写回
作为:
一个不可分割的整体。
别人:
永远插不进去。
于是:
C++ 提供:
std::atomic<int> count{0};
以后:
count++;
就是:
原子的。
多个线程:
count++;
不会冲突。
例如:
std::atomic<int> count{0};
它和:
int count;
最大的区别:
所有读写都是线程安全的。
例如:
线程A:
count++;
线程B:
count++;
最终:
count == 2
一定成立。
这是最重要的区别。
mutex:
保护一段代码
例如:
lock();
a++;
b++;
c++;
unlock();
整个:
a
b
c
都是:
一个整体。
atomic:
保护的是:
一个变量
例如:
atomic<int> x;
只能保证:
x++;
安全。
不能保证:
x++;
y++;
作为:
一个整体。
例如:
std::atomic<int> x;
std::atomic<int> y;
线程A:
x++;
y++;
线程B:
x++;
y++;
中间:
可能:
A:
x++
↓
B:
x++
↓
A:
y++
↓
B:
y++
所以:
atomic:
不能代替 mutex。
例如:
网站:
在线人数
std::atomic<int> online{0};
online++;
不用 mutex。
例如:
线程退出。
std::atomic<bool> running{true};
工作线程:
while (running)
{
work();
}
主线程:
running = false;
工作线程:
马上:
退出。
如果:
不用 atomic:
bool running;
可能:
CPU:
一直缓存:
running == true
工作线程:
永远:
看不到:
false
例如:
std::atomic<int> state;
状态:
0
↓
1
↓
2
多个线程:
读写。
很多初学者:
喜欢:
volatile bool running;
这是:
错误的。
因为:
volatile
不是:
线程同步。
它:
只是:
告诉编译器:
不要优化
不能保证:
原子性
可见性
同步
现代 C++:
线程通信:
应该:
std::atomic
而不是:
volatile
不能。
例如:
银行账户:
balance -= money;
log.push_back(...);
saveToFile();
这三步:
必须:
一起完成。
atomic:
做不到。
必须:
std::mutex
如果:
你的代码:
只是:
++
--
=
load()
store()
针对:
一个变量。
优先:
std::atomic
如果:
涉及:
多个变量
多个对象
容器
复杂逻辑
使用:
std::mutex
你可以把 std::atomic 和 std::mutex 用一句话区分开:
std::mutex:保护一段代码(临界区),保证同一时刻只有一个线程执行这段代码,适合多个共享变量或复杂操作。std::atomic:保护一个变量的读写操作,保证这个变量的操作是不可分割的,不需要加锁,因此通常性能更高。一个简单的经验法则是:
std::atomic。std::mutex。所以,std::atomic 并不是 std::mutex 的替代品,而是针对简单共享状态提供的一种更轻量、更高效的线程同步方式。
问题详情:
std::atomic 的fetch_add
问题详情:
join的话,主线程会停留在join那一行的语句,直到线程执行完毕然后退出是吗
是的,理解正确。
join() 的语义就是:
当前调用
join()的线程会阻塞,直到被join的线程执行结束。
例如:
#include <iostream>
#include <thread>
#include <chrono>
void worker() {
std::cout << "worker start\n";
std::this_thread::sleep_for(std::chrono::seconds(3));
std::cout << "worker end\n";
}
int main() {
std::thread t(worker);
std::cout << "before join\n";
t.join(); // 主线程阻塞在这里
std::cout << "after join\n";
}
可能输出:
before join
worker start
(等待 3 秒)
worker end
after join
时间线如下:
主线程 工作线程
------------------------------------------------
创建 thread -----------> 开始执行 worker()
before join
join() <--阻塞------------ worker 继续执行
sleep(3s)
worker end
线程退出
join() 返回
after join
所以在 join() 返回之前:
join() 后面的代码;例如:
std::thread t([] {
std::cout << "done\n";
});
std::this_thread::sleep_for(std::chrono::seconds(5));
t.join();
这里线程早在 5 秒前就结束了。
此时:
t.join();
几乎会立即返回,因为它发现线程已经结束,只需要回收线程相关资源即可,不需要等待。
很多人认为:
join()= "结束线程"
实际上不是。
join() 不会结束线程,它只是:
std::thread 对象变为不可再 join 的状态(joinable() == false)。线程的结束完全由线程函数自己决定,例如:
void worker() {
// 做完工作
return; // 线程结束
}
或者:
while (running) {
// 一直工作
}
如果 running 一直是 true,那么:
t.join();
就会一直阻塞,因为线程永远没有退出。
一句话总结:
join()不会让线程退出,它只是等待线程退出。调用join()的线程(通常是主线程)会阻塞,直到目标线程执行完毕,然后join()返回,后续代码继续执行。
fetch_add() 是 std::atomic 最经典的成员函数之一。它的特点可以用一句话概括:
原子地"先取值,再加值",并返回修改前的旧值。
很多人第一次看到它都会疑惑:既然有 ++ 和 +=,为什么还要 fetch_add()?
答案就在于:它会返回操作之前的值。
std::atomic<int> count{0};
int old = count.fetch_add(1);
执行完成后:
old == 0
count == 1
再执行一次:
old = count.fetch_add(1);
结果:
old == 1
count == 2
所以:
fetch_add(n)
可以理解成:
old = count;
count += n;
return old;
但是:
整个过程是原子的,不会被其他线程打断。
++ 的区别例如:
std::atomic<int> count{10};
++count;
结果:
count == 11
返回的是:
11
(前置 ++ 返回新值)
count++;
结果:
count == 11
返回的是:
10
(后置 ++ 返回旧值)
count.fetch_add(1);
结果:
count == 11
返回:
10
可以发现:
fetch_add(1)的返回值和后置count++一样,都是旧值。
这是它最有价值的地方。
多个线程同时申请编号:
std::atomic<int> id{0};
int myId = id.fetch_add(1);
假设:
线程 A:
myId = 0
线程 B:
myId = 1
线程 C:
myId = 2
最终:
id == 3
整个过程:
这就是很多服务器生成唯一编号的方式。
例如:
int myId = id;
id++;
假设:
id = 5
线程 A:
读取 id
myId = 5
线程 B:
读取 id
myId = 5
然后:
A:
id++
B:
id++
两个线程都拿到了:
myId == 5
编号重复了。
而:
id.fetch_add(1);
CPU 保证:
读取旧值
↓
加一
↓
写回
↓
返回旧值
整个过程不可分割。
fetch_add,还有很多 fetch_xxx例如:
fetch_sub()
原子减法:
count.fetch_sub(1);
fetch_or()
按位 OR:
flags.fetch_or(FLAG_READ);
fetch_and()
按位 AND:
flags.fetch_and(~FLAG_READ);
fetch_xor()
按位异或。
例如:
有一个数组:
std::vector<Task> tasks(1000);
std::atomic<int> index{0};
多个线程:
while (true)
{
int i = index.fetch_add(1);
if (i >= tasks.size())
break;
process(tasks[i]);
}
时间线:
线程A:
fetch_add()
得到0
处理tasks[0]
----------------
线程B:
fetch_add()
得到1
处理tasks[1]
----------------
线程C:
fetch_add()
得到2
处理tasks[2]
所有线程:
自动分配任务。
完全不用:
std::mutex
这在线程池、任务调度器等场景中非常常见。
fetch_add() 的语义可以牢牢记成一句话:
以原子的方式执行"返回旧值,然后加指定值"。
也就是:
int old = counter.fetch_add(1);
等价于逻辑上的:
int old = counter;
counter += 1;
但整个过程不可被其他线程打断,因此非常适合实现全局唯一编号、多线程任务分配索引、无锁计数器等场景。
另外可以记住一个小技巧:
fetch_add(1) 返回旧值(类似后置 counter++)。++counter 返回新值。理解了这一点,再看到 fetch_sub()、fetch_or() 等其他 fetch_xxx 接口,它们的语义也就很自然了。
问题详情:
线程局部存储,thread_local,本身适用于,同一个外部数据,不同线程有自己的一份拷贝,彼此不受互相修改的影响,对吗
问题详情:
既然会阻塞的话,为什么还要单独开一个线程?
对,而且你的理解已经抓住了 thread_local 最核心的思想。
可以用一句话概括:
thread_local变量对于每个线程来说都是"全局可见",但每个线程拥有自己的独立副本,互不影响。
例如:
int count = 0;
两个线程:
count++;
实际上操作的是:
count
│
┌─────┴─────┐
│ │
线程A 线程B
只有一份数据。
所以:
例如:
thread_local int count = 0;
虽然只有这一句定义,但是运行时实际上变成了:
线程A
count = 0
------------------
线程B
count = 0
------------------
线程C
count = 0
注意:
它们不是:
一个count
而是:
count(A)
count(B)
count(C)
每个线程都有自己的一份。
#include <iostream>
#include <thread>
thread_local int value = 0;
void work()
{
value++;
std::cout
<< std::this_thread::get_id()
<< " : "
<< value
<< std::endl;
}
int main()
{
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
}
可能输出:
Thread1 : 1
Thread2 : 1
为什么不是:
1
2
因为:
线程1:
自己的 value
0→1
线程2:
自己的 value
0→1
互不影响。
如果:
线程1:
work();
work();
线程2:
work();
输出:
线程1
1
2
------------
线程2
1
说明:
线程1:
一直使用:
自己的 value
线程2:
一直使用:
自己的 value
很多人容易混淆。
目标:
多个线程
↓
共同访问
同一份数据
例如:
int balance;
只有:
一份
所以:
需要:
mutex
目标:
多个线程
↓
每人一份数据
例如:
thread_local int buffer;
实际上:
线程A
buffer
----------------
线程B
buffer
----------------
线程C
buffer
根本:
没有共享。
所以:
不需要 mutex。
例如:
thread_local std::string buffer;
线程:
一直:
buffer.clear();
不用:
每次:
std::string buffer;
重新申请。
例如:
thread_local Database db;
每个线程:
维护:
自己的连接。
不用:
多个线程:
竞争:
一个连接。
例如:
thread_local std::mt19937 rng;
否则:
大家:
共用:
一个:
std::mt19937
需要 mutex。
例如:
thread_local int requestId;
线程:
处理:
请求A:
requestId = 100
线程:
处理:
请求B:
requestId = 200
打印日志:
自动:
带:
自己的 requestId。
很多人第一次学还容易误解一点:
thread_local 不是每次进入函数都会创建。
例如:
void work()
{
thread_local int x = 0;
x++;
std::cout << x << std::endl;
}
线程A:
work();
work();
work();
输出:
1
2
3
说明:
不是:
每次:
重新创建。
而是:
每个线程第一次执行到这里时创建一次,以后一直使用自己的那份,直到线程结束才销毁。
你的理解可以整理成下面这句话:
thread_local适用于"逻辑上是同一个变量,但每个线程都需要拥有自己的独立状态"。每个线程第一次使用该变量时都会创建自己的副本,此后该线程始终访问自己的那份数据,不会与其他线程共享,因此也不需要使用std::mutex或std::atomic来进行同步。
所以可以记一个非常简单的判断原则:
std::mutex / std::atomicthread_local这两类工具解决的是完全不同的问题。
这是一个非常好的问题,也是很多人刚接触多线程时都会产生的疑问。
关键在于:join() 会阻塞,但你可以选择什么时候调用它,而不是必须创建线程后立刻调用。
std::thread t(worker);
t.join();
执行过程:
主线程:
创建线程
↓
join() 等待
↓
线程结束
↓
继续执行
这和直接调用
worker();
几乎没有区别。
甚至更慢,因为:
所以这种写法一般没有意义(除非是为了统一接口)。
例如:
std::thread t(worker);
// 主线程继续干自己的事
doSomething1();
doSomething2();
doSomething3();
// 最后需要结果时再等待
t.join();
时间线:
主线程 工作线程
------------------------------------------------
创建线程 -------------> worker()
doSomething1()
worker()
doSomething2()
worker()
doSomething3()
join() 等待 ---------> worker结束
继续执行
这时候:
worker 和主线程是同时工作的。
假设:
worker 5 秒
doSomething 5 秒
单线程:
5 + 5 = 10 秒
多线程:
同时进行
≈5 秒
这就是并发带来的收益。
例如读取文件:
void loadImage();
void loadAudio();
单线程:
loadImage(); // 3 秒
loadAudio(); // 2 秒
总共:
5 秒
多线程:
std::thread t(loadImage);
loadAudio();
t.join();
时间:
Image : 3 秒
Audio : 2 秒
总共 ≈ 3 秒
因为:
Image █████
Audio ███
是重叠执行的。
游戏启动时:
主线程:
初始化窗口
初始化输入
初始化声音
后台线程:
加载地图
加载纹理
读取配置
最后:
loader.join();
表示:
"如果资源还没加载完,我就在这里等一下。"
而不是:
创建线程
立即 join
否则后台线程根本没有发挥作用。
因为你最终还是要保证:
线程执行完成以后才能退出程序,或者使用线程计算出来的结果。
例如:
std::vector<int> data;
std::thread t([&]{
data.push_back(100);
});
// ...
t.join();
std::cout << data[0];
如果不 join():
std::cout << data[0];
有可能:
所以需要在真正依赖线程结果的时候同步。
std::thread t1(task1);
std::thread t2(task2);
std::thread t3(task3);
// 主线程继续工作
mainTask();
// 等待所有线程结束
t1.join();
t2.join();
t3.join();
时间线:
主线程 ─────────────── mainTask ──────────────┐
线程1 ───── task1 ──────────────────────────┘
线程2 ───────────── task2 ──────────────────┘
线程3 ─────── task3 ────────────────────────┘
最后 join() 等待还没结束的线程
这才是 join() 的典型用法:不是为了让线程串行,而是为了在需要的时候进行同步(等待所有工作完成)。
所以可以把 join() 理解成一种同步点(synchronization point),而不是“创建线程之后马上就应该调用的函数”。真正的价值在于:线程在 join() 之前的那段时间里,可以和主线程并发执行,从而提高整体效率。
对,detach() 就是不等了,也不管了。
调用 detach() 后,相当于告诉 C++ 运行时:
这个线程从现在开始独立运行,它的生命周期不再由这个
std::thread对象管理。
例如:
#include <thread>
#include <iostream>
#include <chrono>
void worker() {
std::this_thread::sleep_for(std::chrono::seconds(3));
std::cout << "worker finished\n";
}
int main() {
std::thread t(worker);
t.detach();
std::cout << "main continue\n";
return 0;
}
执行过程可能是:
main continue
程序直接结束了,甚至看不到:
worker finished
为什么?
因为:
detach() 并不会让主线程等待。main() 返回后,整个进程结束。调用:
t.detach();
之后:
t.joinable() == false
此时:
t.join(); // ❌ 抛 std::system_error
t.detach(); // ❌ 也会抛异常
因为 t 已经不再关联任何线程。
真正的线程还在运行,但 std::thread 对象已经"放手"了。
例如:
std::thread t(worker);
t.detach();
doSomething();
时间线:
主线程 worker线程
--------------------------------------------
创建线程 -------------> 开始执行
detach()
继续执行 doSomething()
main结束
可能还在运行
如果 worker 比 main 更快:
worker结束
没问题。
如果 main 更快:
main结束
整个进程退出
worker被操作系统终止
来看一个例子:
void foo() {
int x = 10;
std::thread([&] {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << x << '\n';
}).detach();
}
时间线:
foo()
x 创建
创建线程
detach()
foo 返回
x 销毁
----------------1秒后----------------
worker:
cout << x // 悬空引用(Undefined Behavior)
因为:
线程比 foo() 活得更久。
但是:
x
已经不存在了。
这就是为什么很多 C++ 老程序员会说:
detach()很危险。
一般要求:
线程不依赖已经销毁的局部对象,并且即使主线程不等待它,也不会影响程序正确性。
例如:
std::thread([] {
while (true) {
flushLog();
}
}).detach();
不过现代 C++ 更推荐用专门的线程管理方式,而不是简单 detach()。
客户端连接:
accept();
std::thread(handleClient, socket).detach();
每个客户端一个线程。
不过现在更多使用:
std::async而不是大量 detach()。
| join() | detach() |
| --- | --- |
| 当前线程等待目标线程结束 | 当前线程立即继续执行 |
| 回收线程资源 | 线程结束时由系统自动回收资源 |
| 可以确保线程完成 | 不保证线程完成 |
| 适合需要结果、需要同步的任务 | 适合真正独立的后台任务 |
| 生命周期容易管理 | 生命周期容易失控,使用需谨慎 |
假设你请了一个工人去装修房子:
join():你一直在现场等,工人干完了,你再离开。detach():你把钥匙交给工人,自己走了,以后也不再联系他。第二种只有在你非常确定工人能独立完成工作,而且房子不会提前拆掉(即线程不会访问已销毁的资源)时才安全。
因此,在实际工程中,join() 是默认选择;只有在线程确实应该独立于创建者的生命周期时,才考虑 detach()。现代 C++ 中,很多场景也会优先考虑 std::jthread、线程池或异步框架,而不是直接使用 detach()。
问题详情:
join适用于先异步然后同步,相当于分开最后在某个位置进行收束。detach则最后不进行同步了,直接分散开不进行收束。这样的话, 如果我在Qt的主线程里面使用join的话,是不是也有可能在join的位置发生阻塞GUI线程
完全正确,而且Qt 开发中这是一个非常经典的坑。
你的理解可以稍微提升一个层次:
join 是一个同步点(Synchronization Point),谁调用 join,谁就会阻塞。
这里并没有"主线程"这个特殊概念。
例如:
// 线程A
t.join();
那么:
线程A阻塞。
如果:
// 主线程
t.join();
那么:
GUI线程阻塞。
Qt 的 GUI 线程负责:
例如:
void MainWindow::onButtonClicked()
{
std::thread t(worker);
t.join(); // 阻塞GUI线程
ui->label->setText("Done");
}
时间线:
GUI线程 worker线程
---------------------------------------------------
按钮点击
创建线程 -----------------> worker()
join() 阻塞
工作3秒
worker结束
join返回
更新Label
这 3 秒期间:
GUI线程一直停在:
t.join();
因此:
用户看到的就是:
窗口卡死(Not Responding)
其实程序没有死,只是 Event Loop 没人在执行。
Qt 推荐的模型其实是:
GUI线程
│
│ 发信号
▼
Worker Thread
│
│ 工作完成
▼
emit finished()
│
▼
GUI线程收到 signal
更新UI
整个过程:
GUI线程从来没有 join。
例如:
GUI线程
启动线程
↓
继续处理事件
↓
鼠标还能点
↓
窗口还能刷新
↓
Worker完成
↓
signal通知GUI
↓
更新UI
所以 Qt 的思想不是:
创建线程
↓
join
而是:
创建线程
↓
忘掉它(继续EventLoop)
↓
完成以后通知我
这是典型的异步编程模型。
当然可以。
例如:
std::thread t([]{
doHeavyWork();
});
// GUI继续运行
...
// 程序退出前
t.join();
但是:
不要在 UI 回调里立即 join。
例如不要这样:
void MainWindow::on_btn_clicked()
{
std::thread t(doHeavyWork);
t.join(); // ❌ UI直接卡住
}
Qt 有很多替代方案。
例如:
Worker完成
↓
emit finished()
↓
GUI收到finished()
↓
更新UI
不用 join。
QtConcurrent:
QFuture<void> future = QtConcurrent::run(doHeavyWork);
QFutureWatcher watcher;
connect(&watcher,
&QFutureWatcher::finished,
this,
&MainWindow::updateUI);
watcher.setFuture(future);
GUI不会阻塞。
auto future = std::async(std::launch::async, doHeavyWork);
以后:
future.wait();
或者:
future.get();
但是:
如果在 GUI 线程调用:
future.get();
一样会卡 GUI。
所以问题不是 join()。
而是:
任何等待操作(Blocking Call)放在 GUI 线程都会冻结界面。
包括:
join();
wait();
get();
sleep();
recv();
accept();
read();
WaitForSingleObject();
QThread::wait();
只要它们阻塞当前线程,而当前线程又是 GUI 线程,就会造成界面无响应。
在 Qt 中,可以把 GUI 线程 看成一个事件循环线程(Event Loop)。
它最重要的职责不是做计算,而是持续处理事件。因此:
join())。QMetaObject::invokeMethod()(队列连接)、或其他异步通知机制把结果发回 GUI 线程更新界面。这样才能保持窗口始终流畅响应。
问题详情:
所以是不是QThread有点类似于detach,但是同时,在worker函数中的最结尾会发出一个信号,这样主线程/调用线程就能接收到通知,也能做出最后的收束,同时还不会被阻塞
你的理解已经非常接近了,不过有一点需要修正:
QThread 的思想更接近于"异步 + 回调(通知)",而不是
detach()。
可以对比一下:
| 特性 | std::thread + join() | std::thread + detach() | QThread |
| --- | --- | --- | --- |
| 创建工作线程 | ✔ | ✔ | ✔ |
| GUI线程阻塞 | 会(调用 join() 时) | 不会 | 不会 |
| 能知道什么时候结束 | ✔(join() 返回) | ❌ 默认不知道 | ✔(finished() 信号) |
| 生命周期容易管理 | ✔ | 较难 | ✔(QObject机制) |
所以 QThread 更像是:
既不阻塞,又不像
detach()那样"彻底失联"。
detach 可以理解成:
主线程:
创建线程
│
▼
detach()
│
├──────── 我不管你了
│
继续执行
(线程什么时候结束,我不知道)
线程结束以后:
没人通知你。
你甚至不知道它是不是已经结束了。
QThread 更像:
GUI线程
创建Worker
│
▼
Worker开始工作
│
▼
GUI继续EventLoop
│
▼
Worker结束
│
emit finished()
│
▼
GUI收到信号
更新UI
释放资源
下一步操作
这里没有任何地方阻塞 GUI。
但是:
GUI 又知道线程什么时候结束。
这就是 Qt Event-Driven(事件驱动)的思想。
你前面说:
join 相当于最后收束。
这个说法很好。
Qt 只是把:
t.join();
afterWork();
改成了:
connect(worker,
&Worker::finished,
this,
&MainWindow::afterWork);
也就是说:
以前:
join()
↓
继续执行
现在:
finished()
↓
槽函数执行
收束仍然存在。
只是:
同步收束(Blocking)
变成了
事件收束(Event Driven)
假设下载一个文件:
GUI
开始下载
↓
join()
↓↓↓↓↓↓↓↓↓↓↓↓↓
窗口卡住
↓↓↓↓↓↓↓↓↓↓↓↓↓
下载完成
↓
更新UI
GUI
开始下载
↓
继续处理鼠标
↓
继续刷新窗口
↓
继续响应菜单
↓
Worker完成
↓
finished()
↓
更新UI
用户体验完全不同。
如果非要对应 std::thread 的世界,其实 QThread 更接近下面这种写法:
std::thread t([] {
doWork();
// 工作完成
postEventToMainThread();
});
// 主线程继续运行
...
// 主线程收到事件
updateUI();
Qt 把这个"通知主线程"的过程封装成了:
所以你不用自己写消息队列。
实际上,QThread 最核心的价值并不是 finished() 信号,而是它和 Qt 事件循环的深度集成。
例如,工作线程中的对象可以:
QTimer;moveToThread() 把 QObject 放到指定线程运行;这些都是普通 std::thread 本身没有提供的能力。
你的理解可以稍微调整成下面这句话,就比较准确了:
QThread 不像
join()那样阻塞等待,也不像detach()那样彻底失去联系。它采用事件驱动的方式:工作线程独立运行,GUI 线程继续处理事件;当工作完成时,线程通过finished()或自定义信号通知 GUI 线程进行后续处理,从而实现"非阻塞的收束"。
这也是 Qt 整个框架的设计哲学:不要等待(wait),而是通知(notify)。
问题详情:
qt除了进行GUI绘制,其实也是一个很好用的工具库
是的,而且很多有经验的 C++ 开发者对 Qt 的评价就是:
Qt 首先是一个非常完整的跨平台 C++ 应用框架,其次才是 GUI 框架。
很多人一开始接触 Qt 是为了做界面,但用久了会发现,即使没有 GUI,Qt 也提供了大量高质量的基础设施。
例如:
Qt 自己构建了一套对象系统:
例如:
QObject *parent = new QObject;
QObject *child = new QObject(parent);
不用手动:
delete child;
删除 parent 时,所有子对象都会自动释放。
你前面提到的就是这一部分。
Qt 提供:
QThreadQThreadPoolQtConcurrentQFutureQPromise(Qt 6)很多时候比直接使用 std::thread 更符合 GUI 程序的开发模式。
Qt 最大的优势之一就是 Event Loop。
不仅 GUI 靠它:
几乎都是事件驱动。
所以很多服务器程序虽然没有界面,也会使用:
QCoreApplication app(argc, argv);
而不是:
QApplication
仅仅为了使用 Qt 的事件循环。
Qt 自带:
例如:
QTcpSocket
QUdpSocket
QNetworkAccessManager
比直接使用 BSD Socket 更现代,也更容易和事件循环结合。
Qt 的文件 API 很统一:
QFile
QDir
QFileInfo
QTemporaryFile
QSaveFile
跨 Windows、Linux、macOS 基本不需要改代码。
虽然现在很多人更倾向于 STL,但 Qt 仍然提供:
QString
QByteArray
QList
QVector
QMap
QHash
其中:
QString 至今仍然是 Qt 最重要的类之一。
它对 Unicode 的支持一直很好,在文本处理方面比直接使用 std::string 更方便。
Qt 内置:
QJsonObject
QJsonArray
QJsonDocument
不需要再引入第三方 JSON 库就能完成很多常见工作。
支持:
统一接口:
QSqlDatabase
QSqlQuery
QRegularExpression
现代 Qt 使用的是 PCRE2,功能比较完整。
同一份代码可以编译到:
以及:
很多平台差异都被 Qt 屏蔽了。
因为它覆盖了桌面应用开发的大部分需求。
一个典型的 Qt 项目可能同时用到:
GUI
↓
Signal / Slot
↓
QThread
↓
QNetwork
↓
QJson
↓
QFile
↓
QSql
↓
QTimer
↓
QSettings
这些模块都是同一个框架提供的,API 风格统一,彼此配合也比较自然。
近年来,Qt 也越来越多地拥抱现代 C++:
std::unique_ptrstd::thread、std::chrono 等标准库一起使用因此现在不少项目会采用一种混合风格:
std::thread、std::optional 等)。这种组合既能享受现代 C++ 的标准化优势,也能利用 Qt 在应用框架方面的成熟能力。
所以,把 Qt 仅仅看作一个"画界面的库"其实低估了它。更准确地说,它是一个完整的跨平台应用开发框架,GUI 只是其中最显眼、也最广为人知的一部分。
问题详情:
关于std::mutex mtx,首先通常来说会声明在全局,或者在几个作用域都能访问到的地方,这样才方便生效;其次,mtx有上锁和解锁,通常将可能被竞争或者死锁的数据读写就放在上锁和解锁之间的这段空间里;如果mtx会在不同的地方被上锁,此时有一个锁已经上上了,那么另一个想再次上锁的地方就会阻塞住,直到上一个锁被解开
你的理解大方向是正确的,不过有几个地方需要纠正一下,我按你的每一句来分析。
你说:
首先通常来说会声明在全局,或者在几个作用域都能访问到的地方,这样才方便生效。
基本正确,但更准确的说法是:
mutex 必须和它所保护的数据拥有相同的生命周期和可见范围。
例如:
std::vector<int> g_data;
std::mutex g_mtx;
所有线程都访问:
g_mtx.lock();
g_data.push_back(1);
g_mtx.unlock();
这是最常见的。
但是很多工程并不会放全局,而是封装到类里面:
class DataManager
{
public:
void add(int x)
{
std::lock_guard<std::mutex> lock(mtx_);
data_.push_back(x);
}
private:
std::vector<int> data_;
std::mutex mtx_;
};
这里:
data_
mtx_
是一一对应的。
这是现代 C++ 更推荐的写法。
所以不是:
mutex 一定放全局。
而是:
谁拥有数据,谁拥有 mutex。
你说:
mtx 有上锁和解锁,通常将可能被竞争的数据读写放在上锁和解锁之间。
完全正确。
例如:
mtx.lock();
count++;
mtx.unlock();
其中:
count++;
就是:
Critical Section(临界区)
只有一个线程能进入。
现代 C++ 更推荐:
{
std::lock_guard<std::mutex> lock(mtx);
count++;
}
离开作用域:
自动unlock
避免忘记解锁。
你说:
如果 mtx 会在不同地方被上锁,一个已经锁住了,另一个 lock 就阻塞。
完全正确。
例如:
线程A:
mtx.lock();
sleep(5);
mtx.unlock();
线程B:
mtx.lock(); // 阻塞5秒
时间线:
线程A
-------------------
lock()
工作5秒
unlock()
-------------------
线程B
-------------------
lock()
^^^^^^
这里一直等待
得到锁
继续执行
这就是 mutex 最基本的工作方式。
你说:
放在上锁和解锁之间的是可能被竞争或者死锁的数据。
这里有一点概念混淆。
应该区分:
mutex 防止的是:
count++;
多个线程同时修改。
例如:
线程A:
count++
线程B:
count++
如果没有 mutex:
最终count可能错误
这叫:
数据竞争(Data Race)
死锁不是数据导致的。
死锁通常是:
两个 mutex。
例如:
线程A:
mtx1.lock();
mtx2.lock();
线程B:
mtx2.lock();
mtx1.lock();
时间线:
线程A:
得到 mtx1
等待 mtx2
---------------------
线程B:
得到 mtx2
等待 mtx1
结果:
A 等 B
B 等 A
永远等下去。
这叫:
Deadlock(死锁)
所以:
mutex 是为了避免 Data Race。
但是:
如果使用不好,
mutex 自己反而可能制造 Deadlock。
很多初学者认为:
mutex = 给代码上锁
其实更准确的是:
mutex 不是保护代码,而是保护共享资源。
例如:
std::vector<int> data;
std::mutex mtx;
真正保护的是:
data
而不是:
push_back()
所以通常看到:
mtx.lock();
data.push_back(1);
data.pop_back();
data.clear();
mtx.unlock();
这些操作都在保护同一个对象:
data
你的理解已经有 90% 是正确的,可以整理成下面这套更标准的描述:
std::mutex 应该和它保护的数据具有相同的作用域和生命周期,常见做法是作为共享对象的成员,而不是一律放全局。std::mutex。其他线程调用 lock() 时,会阻塞等待,直到持有锁的线程执行 unlock()(或通过 RAII 自动释放锁)。std::mutex 的目的是防止数据竞争(Data Race),而不是防止死锁。相反,如果多个互斥锁使用不当,还可能引发死锁(Deadlock)。建议从现在开始养成一个习惯:尽量不要直接写 lock() / unlock(),而是使用 std::lock_guard 或 std::unique_lock。 这是现代 C++ 的推荐写法,因为它能保证即使发生异常或提前 return,锁也一定会被正确释放。
问题详情:
std::mutex是互斥量,本身可以独立使用,而锁,则是为了简化std::mutex的使用,例如能够简化之前手动控制mutex上锁和解锁,可以使用std::lock_guard,上锁,然后出生命周期/作用域直接析构解锁
你的理解是正确的,而且已经抓住了 std::mutex 和 std::lock_guard 的关系。可以再完善一点,形成更准确的认识。
std::mutex 是真正的互斥量std::mutex mtx;
它本身就是一个同步原语(Synchronization Primitive)。
它提供最基本的接口:
mtx.lock(); // 加锁
mtx.unlock(); // 解锁
mtx.try_lock(); // 尝试加锁
它可以独立使用,例如:
mtx.lock();
++count;
mtx.unlock();
这里没有任何问题。
std::lock_guard 不是锁,而是"锁的管理器"很多初学者会说:
lock_guard是锁。
实际上更准确的说法是:
std::lock_guard是一个 RAII 对象,用来管理 mutex。
它内部其实就是:
class lock_guard {
public:
explicit lock_guard(std::mutex& m)
: mtx(m)
{
mtx.lock();
}
~lock_guard()
{
mtx.unlock();
}
private:
std::mutex& mtx;
};
(这是简化后的示意代码。)
所以:
std::lock_guard<std::mutex> lock(mtx);
实际上做了两件事:
构造
↓
mtx.lock()
......
析构
↓
mtx.unlock()
lock_guard?因为手写:
mtx.lock();
doSomething();
mtx.unlock();
容易出问题。
例如:
mtx.lock();
if (error)
return; // 忘记 unlock
锁永远不会释放。
或者:
mtx.lock();
throw std::runtime_error("xxx");
mtx.unlock(); // 永远执行不到
同样会导致锁一直被占用。
而使用:
{
std::lock_guard<std::mutex> lock(mtx);
doSomething();
if (error)
return;
}
即使:
returnbreakcontinuethrow都会自动调用析构函数:
~lock_guard()
最终:
mtx.unlock();
这就是 RAII 的威力。
标准库还有很多类似的类。
例如:
std::lock_guard功能最简单:
std::lock_guard<std::mutex> lock(mtx);
特点:
std::unique_lock更灵活:
std::unique_lock<std::mutex> lock(mtx);
可以:
lock.unlock();
...
lock.lock();
还能配合:
std::condition_variable
一起使用。
std::scoped_lock(C++17)一次锁多个 mutex:
std::scoped_lock lock(mtx1, mtx2);
内部自动避免死锁。
你说:
锁,是为了简化 std::mutex 的使用。
这个说法在日常交流中没问题。
但严格一点来说:
std::mutex 本身就是一种锁(互斥锁,Mutex)。
而:
std::lock_guard
std::unique_lock
std::scoped_lock
不是新的锁。
它们只是:
锁管理器(Lock Wrapper)
负责管理 mutex。
所以可以理解为:
std::mutex
↑
真正负责互斥
──────────────
std::lock_guard
std::unique_lock
std::scoped_lock
↑
负责自动管理 mutex
你的理解可以整理成下面这句话:
std::mutex是真正实现线程互斥的同步对象,可以直接调用lock()和unlock()独立使用;而std::lock_guard、std::unique_lock等并不是新的锁,而是基于 RAII 思想对std::mutex的封装,它们在构造时自动加锁,在析构时自动解锁,从而避免因异常、提前返回等情况导致忘记释放锁,是现代 C++ 推荐的使用方式。
问题详情:
std::unique_lock能够lock和unlock,mutex自己也能做到啊,std::unique_lock的好出在哪儿
这是一个非常好的问题,也是很多人第一次接触 std::unique_lock 时都会有的疑惑。
确实,乍一看:
std::mutex mtx;
mtx.lock();
...
mtx.unlock();
和
std::unique_lock<std::mutex> lock(mtx);
...
lock.unlock();
...
lock.lock();
都能实现加锁和解锁。
那 unique_lock 到底多了什么?
答案是:它不仅能手动控制锁,还保留了 RAII 自动释放的能力。
例如:
void func()
{
mtx.lock();
doSomething();
if (error)
return; // 忘记 unlock()
mtx.unlock();
}
这里就死锁了。
或者:
mtx.lock();
doSomething();
throw std::runtime_error("error");
mtx.unlock();
异常之后也不会执行 unlock()。
std::lock_guard<std::mutex> lock(mtx);
...
// 想提前释放?
做不到。
例如:
{
std::lock_guard<std::mutex> lock(mtx);
prepareData();
// 这里数据已经准备好了
// 后面的计算不需要锁
heavyCalculation(); // ❌ 锁一直占着
}
整个 heavyCalculation() 都持有锁。
如果:
prepareData() 5ms
heavyCalculation() 500ms
别人要等:
505ms
其实:
真正需要保护的数据只有:
prepareData()
std::unique_lock<std::mutex> lock(mtx);
prepareData();
lock.unlock(); // 提前释放
heavyCalculation(); // 不占锁
别人现在:
只等待5ms
性能明显更好。
例如:
std::unique_lock<std::mutex> lock(mtx);
modify();
lock.unlock();
// 做一些不用锁的事情
lock.lock();
modifyAgain();
lock_guard 根本做不到。
这是 unique_lock 最重要的用途。
例如:
std::mutex mtx;
std::condition_variable cv;
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock);
为什么不用:
std::lock_guard
因为:
wait() 内部实际上会:
lock
↓
wait()
↓
自动 unlock
↓
线程睡眠
↓
收到通知
↓
重新 lock
↓
wait 返回
它需要:
unlock()
lock()
这两个操作。
而:
std::lock_guard
没有:
unlock()
lock()
接口。
所以:
cv.wait(lock);
要求参数必须是:
std::unique_lock
例如:
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
此时:
没有加锁
以后:
lock.lock();
再真正加锁。
这个在多个 mutex 协调的时候非常常见。
| 功能 | mutex | lock_guard | unique_lock |
| --- | --- | --- | --- |
| 手动 lock/unlock | ✅ | ❌ | ✅ |
| 自动析构释放 | ❌ | ✅ | ✅ |
| 可以提前 unlock | —— | ❌ | ✅ |
| 可以重新 lock | —— | ❌ | ✅ |
| 支持 condition_variable | ❌ | ❌ | ✅ |
| 支持延迟加锁 | ❌ | ❌ | ✅ |
一般遵循一个简单原则:
std::lock_guard:简单、安全、开销最小,适用于绝大多数临界区。std::unique_lock:例如提前释放锁、重新加锁、延迟加锁、配合 std::condition_variable。mutex.lock() / mutex.unlock():除非是在实现底层同步组件,或者确实有特殊需求,否则直接操作 mutex 更容易因为异常或提前返回而导致忘记解锁。所以,unique_lock 的优势不在于它也能 lock() 和 unlock(),而在于它把 "灵活控制" 和 "RAII 自动管理" 两者结合了起来。你既可以像操作 mutex 一样控制加锁、解锁,又不用担心作用域结束时忘记释放锁,这正是它相比直接操作 mutex 的最大价值。
问题详情:
条件变量就是和std::unique_lock配合使用的对吗,例如一个std::unique_lock上锁或者解锁,可以作为条件变量生效的条件,然后需执行对应的代码段/方法
你的理解已经开始接近条件变量(std::condition_variable)的核心了,但有一个比较重要的误区需要纠正:
条件变量等待的不是"锁的状态",而是"某个条件(共享状态)是否满足"。
std::unique_lock 只是条件变量工作的工具,不是条件本身。
假设有一个队列:
std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;
消费者:
void consumer()
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] {
return !q.empty();
});
int value = q.front();
q.pop();
}
生产者:
void producer()
{
{
std::lock_guard<std::mutex> lock(mtx);
q.push(100);
} // 解锁
cv.notify_one();
}
很多初学者会以为:
等待 mutex 解锁
其实不是。
真正等待的是:
q.empty() == false
也就是:
队列里有数据了。
锁只是为了保证检查这个条件时不会发生数据竞争。
例如:
cv.wait(lock);
它内部大致可以理解成:
1. 当前已经持有 mutex
↓
2. 自动 unlock()
↓
3. 当前线程睡眠
↓
4. notify_one()
↓
5. 被唤醒
↓
6. 自动 lock()
↓
7. wait() 返回
这里就能看出来:
为什么必须是:
std::unique_lock
因为:
它需要:
lock.unlock();
...
lock.lock();
而:
std::lock_guard
没有这两个接口。
假设:
消费者:
std::unique_lock lock(mtx);
cv.wait(lock);
如果:
等待的时候一直拿着锁。
那么:
生产者:
std::lock_guard lock(mtx);
q.push(100);
永远:
拿不到 mutex
于是:
消费者:
等数据
↓
生产者:
拿不到锁
↓
放不了数据
↓
消费者继续等
死锁了。
所以:
wait() 必须:
等待之前
↓
自动释放 mutex
这样:
生产者才能:
获得 mutex
↓
放数据
↓
notify
因为:
可能:
线程A:
notify()
↓
线程B:
醒了
但是:
在线程B重新获得 mutex 之前:
线程C:
把数据拿走了。
所以:
线程B重新获得锁以后:
还必须:
if (!条件成立)
{
// 继续等待
}
因此现代 C++ 推荐:
cv.wait(lock, [] {
return !q.empty();
});
而不是:
cv.wait(lock);
因为:
前者内部其实就是:
while (!条件)
{
wait();
}
能正确处理虚假唤醒(spurious wakeup)和竞争。
可以总结为:
mutex
│
▼
保护共享数据
condition_variable
│
▼
等待共享数据满足某个条件
unique_lock
│
▼
配合 condition_variable
负责自动 unlock / lock
注意:
真正的"条件"通常是:
queue.empty()
count > 0
finished == true
ready == true
bufferFull == false
而不是:
mutex 是否锁住
mutex 只是保证这些变量不会被多个线程同时修改。
你可以把它们三者理解成这样的分工:
std::mutex:保护共享数据,防止多个线程同时访问。std::unique_lock:负责管理 std::mutex,并提供 lock() / unlock() 能力,以满足 condition_variable 的工作要求。std::condition_variable:让线程在某个共享条件不满足时睡眠,条件满足后再被唤醒继续执行。所以,条件变量等待的是"条件",不是"锁";std::unique_lock 只是它实现等待机制所必须依赖的锁管理对象。
问题详情:
所以如果q.empty()的话,cv.wait会一直等待下去吗
是的,但准确地说:
如果
q.empty()一直为true,那么cv.wait(lock, [] { return !q.empty(); })就会一直等待。
不过这个"等待"不是一直占着 CPU 循环检查,而是睡眠(阻塞)等待。
消费者:
std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;
void consumer()
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] {
return !q.empty();
});
std::cout << q.front() << std::endl;
}
假设此时:
q.empty() == true
那么执行过程是:
consumer:
获得 mutex
↓
检查 !q.empty()
↓
false
↓
自动释放 mutex
↓
线程睡眠(不占CPU)
↓
等待 notify()
此时消费者线程就"睡着了"。
生产者:
void producer()
{
{
std::lock_guard<std::mutex> lock(mtx);
q.push(100);
}
cv.notify_one();
}
执行过程:
获得 mutex
↓
q.push(100)
↓
释放 mutex
↓
notify_one()
然后:
消费者:
收到通知
↓
重新竞争 mutex
↓
重新获得 mutex
↓
再次检查:
!q.empty()
↓
true
↓
wait() 返回
↓
继续执行
所以:
不是 notify 就一定继续执行。
而是:
notify
↓
重新检查条件
↓
条件成立
↓
继续执行
例如:
cv.notify_one();
但是:
q.push();
根本没有执行。
那么:
消费者:
醒来
↓
重新获得 mutex
↓
检查 q.empty()
↓
还是 true
↓
继续睡眠
这就是为什么推荐写:
cv.wait(lock, [] {
return !q.empty();
});
它内部其实等价于:
while (q.empty())
{
cv.wait(lock);
}
而不是:
if (q.empty())
{
cv.wait(lock);
}
因为线程可能:
都需要重新检查条件。
例如:
consumer();
但是:
producer();
永远没人调用。
那么:
wait()
↓
睡眠
↓
一直睡眠
↓
永远不会返回
这就是:
一直等待条件满足。
所以:
cv.wait(lock, [] {
return !q.empty();
});
实际上就是:
"一直等,直到队列里面有数据。"
把它想象成外卖柜:
消费者:
"柜子里没外卖,我就在大厅坐着等。"
于是:
柜子空
↓
坐下等待
外卖员(生产者):
放入外卖
↓
按门铃(notify)
消费者:
听到门铃
↓
打开柜子
↓
真的有外卖?
↓
有
↓
拿走
如果:
有人恶作剧:
按门铃
↓
柜子还是空的
消费者:
再坐回去继续等
这就是:
wait(lock, predicate)
的完整工作流程。
cv.wait(lock, [] { return !q.empty(); }) 的含义就是:
只要
q.empty()为true,当前线程就会释放互斥锁并进入睡眠;每次被notify唤醒后,它都会重新获得互斥锁并再次检查q.empty(),只有当队列非空时,wait()才真正返回,后续代码才开始执行。
问题详情:
也就是说,cv并不是一直等着,而是类似睡眠(阻塞),别的地方调一下cv.notify,就会把cv叫醒一下,然后cv里面ublock std::unique_lock的mtx,然后进行条件是否满足的判断,如果满足则往下执行,否则还是阻塞在那一行,等下次notify的时候再次被叫醒然后判断条件是否满足
完全正确! 你的理解已经非常接近 condition_variable 的底层工作机制了,我帮你把几个细节补充得更准确一些。
你描述的流程可以整理成下面这样:
消费者线程:
获得 mutex
│
▼
cv.wait(lock, 条件)
│
▼
条件满足?
│
┌──┴──┐
│ │
否 是
│ │
▼ ▼
自动释放mutex wait返回
│ │
▼ ▼
线程睡眠 继续执行下面代码
│
▼
等待notify
│
▼
收到notify
│
▼
重新竞争mutex(不是立即获得)
│
▼
获得mutex
│
▼
再次检查条件
│
├──满足──► wait返回
│
└──不满足► 再次释放mutex并睡眠
你说:
notify 后,cv 里面 unlock
std::unique_lock的 mutex。
实际上顺序正好相反。
当线程进入等待时,就已经执行了:
lock.unlock();
也就是:
wait()
↓
自动 unlock()
↓
睡眠
等收到 notify 后:
notify
↓
线程被唤醒
↓
重新 lock()
↓
检查条件
所以应该是:
wait 在睡眠之前自动 unlock,醒来之后自动 lock。
而不是 notify 的时候才 unlock。
你说:
notify 会把 cv 叫醒一下。
这个说法很好理解,不过更准确一点是:
notify 唤醒的是等待在 condition_variable 上的线程,而不是 condition_variable 自己。
因为:
std::condition_variable cv;
它本身只是一个同步对象。
真正睡觉的是:
线程A
所以:
cv.notify_one();
实际上就是:
把一个等待中的线程唤醒
而不是:
把 cv 唤醒
当然,平时交流这么说别人也都能理解。
很多人第一次看到这里都会疑惑:
为什么不是:
notify
↓
继续执行
而要:
notify
↓
重新 lock
↓
继续执行
原因很简单。
因为:
queue.push(100);
和:
queue.pop();
都需要访问:
queue
所以:
消费者醒来以后,
必须先重新拿到 mutex。
否则:
多个消费者可能同时:
queue.pop();
数据又乱了。
所以:
notify
↓
重新竞争mutex
↓
拿到mutex
↓
检查条件
↓
继续执行
这个步骤绝对不能少。
例如:
两个消费者:
消费者A
消费者B
队列:
queue里面只有一个元素
生产者:
push
↓
notify_all()
于是:
A 和 B 都醒了。
但是:
最终:
A
获得mutex
↓
pop()
↓
queue空了
然后:
B:
获得mutex
↓
检查queue
↓
发现又空了
↓
继续wait
所以:
notify ≠ 条件满足。
notify 只是:
"你起来看看条件是不是满足了。"
真正决定是否继续执行的是:
!q.empty()
其实整个 condition_variable 就一句话:
线程在条件不满足时释放 mutex 并睡眠;每次收到
notify后重新获得 mutex,再检查条件,满足则继续执行,不满足则继续睡眠。
所以:
cv.wait(lock, [] {
return !q.empty();
});
可以直接翻译成人话:
"如果队列为空,我就把锁放下睡觉;每次有人叫醒我,我先把锁重新拿回来,再看看队列有没有数据,有就继续,没有就继续睡。"
这个理解已经非常接近 C++ 标准库对 condition_variable 的实际工作方式了。
问题详情:
奥,因为进入到wait的时候,就应该解锁,这样别人才能访问到共享资源,而被叫醒的时候,要拿着共享资源进行条件判断了,所以要锁上
对!你已经理解到 condition_variable 最关键的地方了。
你说的这句话:
进入到 wait 的时候,就应该解锁,这样别人才能访问到共享资源;而被叫醒的时候,要拿着共享资源进行条件判断了,所以要锁上。
这几乎就是 condition_variable 的设计思想。
我们把整个过程串起来看:
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] {
return !q.empty();
});
假设:
q.empty() == true
那么:
拿到 mutex
│
▼
检查 q.empty()
│
▼
为空
│
▼
自动 unlock(mtx)
│
▼
线程睡眠
为什么要 unlock?
因为:
我要睡觉了,但是别人(生产者)还要往队列里放数据。
如果我一直拿着 mutex:
消费者:
拿着 mutex 睡觉
↓
生产者:
想 lock()
↓
永远拿不到 mutex
↓
永远 push 不进去数据
这就是死锁。
生产者:
{
std::lock_guard<std::mutex> lock(mtx);
q.push(100);
}
cv.notify_one();
因为消费者已经释放了 mutex,所以:
生产者:
获得 mutex
↓
push(100)
↓
unlock()
↓
notify_one()
收到:
cv.notify_one();
以后:
不是立刻执行下面代码。
而是:
醒来
↓
重新 lock(mtx)
↓
拿到 mutex
↓
再次检查 q.empty()
↓
false
↓
wait 返回
为什么要重新 lock?
因为:
现在:
q.front();
q.pop();
马上要访问共享资源:
queue
如果:
没有 mutex:
可能:
消费者A
↓
pop()
消费者B
↓
pop()
两个线程一起操作 queue。
又产生数据竞争了。
这是很多人第一次学的时候忽略的一个细节。
例如:
假设:
queue里面只有一个元素
两个消费者:
消费者A
消费者B
都被 notify。
如果:
判断:
!q.empty()
的时候:
没有 mutex。
可能:
A:
看到 queue非空
↓
准备 pop
----------------
B:
看到 queue非空
↓
准备 pop
两个都认为:
queue有数据
结果:
一起:
pop()
程序就乱了。
所以:
条件判断本身也是共享资源访问的一部分。
因此:
重新 lock
↓
判断条件
↓
条件满足
↓
继续执行
整个过程必须是互斥的。
你现在可以把 condition_variable 理解成下面这个口诀:
睡觉之前放锁(unlock),醒来之后拿锁(lock),拿到锁再检查条件,条件满足继续执行,不满足继续睡。
这也是为什么 wait(lock, predicate) 设计得这么巧妙:它把 "释放锁 → 睡眠 → 被唤醒 → 重新获得锁 → 检查条件" 这一整套容易出错的流程全部封装好了,程序员只需要关心条件是什么,不用自己处理这些复杂的同步细节。
问题详情:
能讲一下std::atomic吗,原子操作,我不知道在这个是怎么用、干什么的