23. 移动语义与多线程:移动资源到线程,避免数据竞争
多线程编程里,数据竞争是个老生常谈的问题。你想想看,两个线程同时读写同一块内存,轻则程序崩溃,重则数据错乱,排查起来简直让人头秃。我个人习惯的做法是:能移动就别共享。移动语义恰好给了我们一把利器——把资源直接“转交”给线程,从根源上消除竞争。
为什么移动能避免数据竞争?
数据竞争的本质是“多个执行流同时访问同一块内存,且至少有一个是写操作”。如果我们把资源的所有权完整地移交给某个线程,其他线程压根儿就碰不到它了——这不就天下太平了吗?
我在项目中遇到过这样一个场景:主线程需要把一个巨大的字符串交给工作线程去处理。如果按老办法,要么传指针(然后加锁),要么深拷贝(性能灾难)。用移动语义,一行代码搞定,零拷贝,零锁。
std::thread 与移动构造
std::thread 本身是不可拷贝的,但它是可移动的。这意味着你可以把线程对象“塞”进容器,或者从函数返回。更重要的是,传递给线程的可调用对象及其参数,默认是按值拷贝的。要想移动,得用 std::move 显式告诉编译器。
来看一个例子:
#include <thread>
#include <vector>
#include <string>
#include <iostream>
void process_data(std::string data) {
// 这里 data 是线程私有的,随便改
std::cout << "处理: " << data << std::endl;
}
int main() {
std::string big_str = "这是一段非常非常长的字符串...";
// 错误示范:拷贝
// std::thread t(process_data, big_str); // big_str 被拷贝一次
// 正确示范:移动
std::thread t(process_data, std::move(big_str));
// 此时 big_str 为空,资源已转移到线程内部
t.join();
// 主线程再也碰不到那个资源了
return 0;
}
嗯,这里要注意:移动之后,原对象处于“有效但未指定”的状态。你不能再假设它还有原来的值。我见过有人移动完还去读原对象,结果 debug 半天才发现问题。
std::async 与移动语义
std::async 的行为和 std::thread 类似,参数默认也是拷贝。但有个小坑:如果参数是临时对象,编译器会自动选择移动。如果是左值,你得手动 std::move。
#include <future>
#include <vector>
std::vector<int> compute_something(std::vector<int> data) {
// 对 data 进行复杂计算
for (auto& v : data) v *= 2;
return data; // 返回值也是移动的
}
int main() {
std::vector<int> big_vec(1000000, 42);
// 必须显式移动,否则拷贝
auto fut = std::async(std::launch::async,
compute_something,
std::move(big_vec));
auto result = fut.get(); // result 也是移动构造的
return 0;
}
移动 unique_ptr 到线程:最安全的资源传递
std::unique_ptr 是移动语义的典型代表。它只能移动,不能拷贝。把它传给线程,意味着资源的所有权彻底转移。这是我最喜欢的方式——编译器帮你保证了线程安全。
#include <thread>
#include <memory>
#include <iostream>
class ExpensiveResource {
public:
ExpensiveResource() { std::cout << "创建资源\n"; }
~ExpressiveResource() { std::cout << "销毁资源\n"; }
void do_work() { std::cout << "工作中...\n"; }
};
void thread_func(std::unique_ptr<ExpensiveResource> res) {
res->do_work();
// 函数结束,资源自动销毁
}
int main() {
auto res = std::make_unique<ExpensiveResource>();
// 移动 unique_ptr 到线程
std::thread t(thread_func, std::move(res));
// 此时 res 是 nullptr
if (!res) {
std::cout << "主线程已失去资源所有权\n";
}
t.join();
return 0;
}
你看,unique_ptr 的移动是编译期强制检查的。你想拷贝?编译器直接报错。这种“编译期线程安全”比任何运行时锁都可靠。
移动语义与锁的取舍
是不是有了移动语义,就完全不需要锁了?当然不是。有些场景下,资源必须共享(比如多个线程读取同一个配置表)。但我的原则是:能移动就不共享,必须共享才加锁。
我画了一张图,帮你理清决策逻辑:
实战:移动大数据到线程池
假设你有一个线程池,需要处理大量数据块。每个数据块都很大,拷贝代价极高。用移动语义,你可以这样设计:
#include <thread>
#include <vector>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <memory>
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
bool stop = false;
public:
ThreadPool(size_t n) {
for (size_t i = 0; i < n; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock lock(mtx);
cv.wait(lock, [this] {
return stop || !tasks.empty();
});
if (stop && tasks.empty()) return;
task = std::move(tasks.front());
tasks.pop();
}
task(); // 执行任务
}
});
}
}
// 接受可移动的任务
template<typename F>
void enqueue(F&& f) {
{
std::lock_guard lock(mtx);
tasks.emplace(std::forward<F>(f));
}
cv.notify_one();
}
~ThreadPool() {
{
std::lock_guard lock(mtx);
stop = true;
}
cv.notify_all();
for (auto& w : workers) w.join();
}
};
// 使用示例
void process_large_data(std::vector<int> data) {
// 处理 data,这里是线程安全的,因为 data 是独享的
}
int main() {
ThreadPool pool(4);
std::vector<int> big_data(1000000);
// 移动大数据到线程池任务
pool.enqueue([data = std::move(big_data)]() mutable {
process_large_data(std::move(data));
});
// big_data 已空,主线程安全
return 0;
}
性能对比:移动 vs 拷贝 vs 共享指针
为了让你有个直观感受,我整理了一张对比表:
| 方式 | 性能 | 线程安全 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 深拷贝 | O(n),慢 | 安全(独立副本) | 低 | 小数据,或必须保留原数据 |
| 移动语义 | O(1),极快 | 安全(所有权转移) | 中 | 大数据,不需要原数据 |
| shared_ptr + 锁 | O(1) + 锁开销 | 需小心设计 | 高 | 必须共享,且生命周期不确定 |
| 原始指针 + 锁 | O(1) + 锁开销 | 极易出错 | 高 | 不推荐,除非你很清楚在做什么 |
从表里能看出来,移动语义在性能和安全性上取得了很好的平衡。它不像拷贝那样浪费内存带宽,也不像共享指针那样需要操心引用计数和锁。
总结
移动语义和多线程的结合,说白了就是一句话:把资源的所有权完整地交给线程,而不是让线程共享它。这样做的好处是显而易见的:
- 零数据竞争:每个资源只有一个所有者,天然线程安全
- 零拷贝开销:移动只是指针交换,不涉及内存复制
- 编译期保证:unique_ptr 等类型强制移动,杜绝误用
我个人习惯在项目里定一条规矩:所有传给线程的大对象,优先考虑移动语义。只有确实需要共享的场景,才退而求其次用锁。这条规矩帮我避免了很多线上事故。
嗯,最后再啰嗦一句:移动语义不是银弹。如果你的数据确实需要被多个线程同时访问,该加锁还是得加。但如果你能通过设计把“共享”变成“独享”,那移动语义就是最好的选择。