33、多线程编程(上):线程创建、join与detach、互斥锁与锁管理

多线程编程,说白了就是让程序同时干好几件事。我刚开始接触这个概念时,觉得挺神奇的——一个程序怎么能同时跑多个任务呢?后来才明白,这其实是操作系统在快速切换,让我们感觉像是「同时」在执行。

嗯,咱们今天要聊的,就是C++11标准引入的那套多线程工具。说实话,在C++11之前,想写跨平台的多线程代码,那叫一个痛苦——Windows用CreateThread,Linux用pthread_create,API完全不同。现在好了,std::thread一套搞定。

线程创建:std::thread

创建线程其实很简单。你只需要告诉它:嘿,去执行这个函数。

#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t(hello);  // 创建线程,执行hello函数
    t.join();              // 等待线程结束
    return 0;
}

我个人习惯把线程对象看作一个「执行者」。你创建它的时候,它就开始跑了。不需要手动调用start之类的函数。

你也可以传参数给线程函数:

void print_sum(int a, int b) {
    std::cout << "Sum: " << (a + b) << std::endl;
}

int main() {
    std::thread t(print_sum, 3, 4);  // 传参
    t.join();
    return 0;
}

这里有个坑——参数是按值传递的。如果你想传引用,得用std::ref包装一下。我在项目中遇到过因为忘记用std::ref,导致数据没同步的bug,排查了半天。

注意:线程函数可以是普通函数、lambda表达式、函数对象,甚至是成员函数。但不管哪种,都要确保线程执行期间,函数和参数都还活着。

join与detach:线程的两种归宿

创建线程后,你必须决定它的「结局」。有两种选择:

  • join():主线程等着,直到子线程结束。就像你叫了个外卖,一直等到外卖送到才继续干活。
  • detach():主线程不管了,子线程在后台自己跑。就像你叫了个外卖,然后该干嘛干嘛,外卖到了放门口就行。

来看个例子:

#include <iostream>
#include <thread>
#include <chrono>

void worker() {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Worker done" << std::endl;
}

int main() {
    std::thread t(worker);
    
    // 选择一:join
    // t.join();  // 主线程会等2秒
    
    // 选择二:detach
    t.detach();  // 主线程不等待,直接继续
    
    std::cout << "Main continues..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(3));
    return 0;
}

你想想看,如果既没join也没detach,会发生什么?程序会直接崩溃!std::thread的析构函数会调用std::terminate。我曾经在写一个网络库时,忘了处理某个线程的join,结果程序退出时总是异常终止,查了好久才发现。

我的建议:能用join就别用detach。detach后的线程就像脱缰的野马,你很难控制它的生命周期。除非你非常清楚自己在做什么,否则老老实实join。

互斥锁:std::mutex

多线程最大的问题是什么?数据竞争。多个线程同时读写同一个变量,结果就乱套了。

举个例子:

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();      // 加锁
        ++counter;       // 临界区
        mtx.unlock();    // 解锁
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    
    t1.join();
    t2.join();
    
    std::cout << "Counter: " << counter << std::endl;
    return 0;
}

没有锁的话,counter最终结果肯定不是200000。为什么?因为++counter不是原子操作,它包含读、改、写三步。两个线程同时读到了同一个值,各自加1再写回去,结果只加了1次。

核心原则:只要多个线程访问同一个可变数据,就必须用锁保护起来。这是多线程编程的第一条铁律。

锁管理:lock_guard与unique_lock

手动lock/unlock有个大问题——容易忘记unlock。如果函数中间抛出异常,unlock可能根本执行不到。这时候就需要RAII(资源获取即初始化)风格的锁管理器。

std::lock_guard:简单粗暴

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);
    // 这里随便写,不用担心解锁
    ++counter;
    // lock_guard析构时自动解锁
}

lock_guard在构造时加锁,析构时解锁。就这么简单。我个人的习惯是,只要不需要手动控制锁的粒度,就用lock_guard。

std::unique_lock:更灵活

unique_lock比lock_guard多了几个功能:可以延迟加锁、提前解锁、转移所有权。来看个例子:

std::mutex mtx;

void flexible_lock() {
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);  // 先不加锁
    // 做一些不需要锁的操作...
    lock.lock();   // 需要时再加锁
    // 临界区操作...
    lock.unlock(); // 提前解锁
    // 再做一些不需要锁的操作...
    // unique_lock析构时,如果还持有锁,会自动释放
}

我在项目中遇到过需要「条件变量」的场景,那时候unique_lock就是必须的了。因为条件变量的wait函数需要unique_lock,它能在等待期间暂时释放锁,唤醒后再重新获取。

特性 lock_guard unique_lock
构造时加锁 可配置
手动解锁
转移所有权
与条件变量配合
性能开销 极小 略大
选型建议:能用lock_guard就用lock_guard。需要灵活性时再升级到unique_lock。别为了「万一以后要用」而过度设计。

知识体系总览

下面这张图,把今天讲的内容串起来了:

C++多线程编程(上)知识体系 std::thread 线程创建 函数 / lambda / 函数对象 参数传递(std::ref) 生命周期管理 join() detach() 同步机制 std::mutex lock_guard unique_lock 核心原则:保护共享数据,避免数据竞争 RAII管理锁资源,防止死锁和忘记解锁

嗯,今天的内容就到这里。多线程编程是个大话题,今天我们只开了个头。线程创建、join/detach、互斥锁、锁管理器——这些是基础中的基础。你想想看,掌握了这些,你已经能写出基本正确的多线程程序了。

不过要提醒一句:多线程的坑远不止这些。死锁、活锁、伪共享、ABA问题……每个都够你喝一壶的。但别怕,先把今天的基础打牢,后面遇到问题才能从容应对。

一句话总结:创建线程用std::thread,管理生命周期用join/detach,保护共享数据用mutex+lock_guard/unique_lock。记住这个组合拳,多线程编程就入门了。


专注资料整理