5. 线程与同步机制:std::thread、std::mutex、std::condition_variable 的跨平台封装

多线程编程,说白了就是让程序同时干好几件事。但这件事做起来,坑比想象的多。我记得刚入行那会儿,在 Windows 上用 CreateThread,到了 Linux 上又得换成 pthread_create,光适配平台就够头疼的。后来 C++11 推出了 std::thread,总算有了统一标准。但实际项目里,直接裸用标准库还是不够——你想想看,线程池、超时控制、异常安全,这些标准库都没直接给。

所以这一章,我带你从实战角度出发,把 std::thread、std::mutex、std::condition_variable 封装成一套跨平台、可复用的工具。嗯,这里要注意:封装不是包一层就完事,而是要解决真实项目中的痛点。

5.1 为什么需要封装?

直接使用标准库有什么问题?我列几个亲身踩过的坑:

  • 资源泄漏:std::thread 如果没 join 或 detach,析构时会直接 terminate。我在项目中见过好几次,线程跑着跑着程序就崩了,查了半天才发现是线程对象销毁时没处理。
  • 异常安全:mutex 加锁后如果抛出异常,锁就永远解不开了。标准库没有自动解锁的机制,你得自己用 RAII 包装。
  • 平台差异:虽然 std::thread 是跨平台的,但线程优先级、CPU 亲和性、线程命名这些功能,标准库都没提供。你得自己封装平台 API。
  • 易用性:condition_variable 的 wait 经常需要配合 while 循环检查条件,写起来啰嗦还容易出错。

核心思路:封装的目标不是替代标准库,而是在标准库之上提供更安全、更易用、更强大的接口。说白了,就是让调用者少写代码,少犯错。

5.2 线程封装:从 std::thread 到 Thread 类

我习惯把线程封装成一个类,叫 Thread。它内部持有 std::thread,但提供了更安全的生命周期管理。

class Thread {
public:
    // 启动线程,传入可调用对象
    template<typename Func, typename... Args>
    explicit Thread(Func&& func, Args&&... args)
        : m_thread(std::forward<Func>(func), std::forward<Args>(args)...)
        , m_joinable(true) {}

    // 析构时自动 join,避免 terminate
    ~Thread() {
        if (m_joinable && m_thread.joinable()) {
            m_thread.join();
        }
    }

    // 禁止拷贝,允许移动
    Thread(const Thread&) = delete;
    Thread& operator=(const Thread&) = delete;
    Thread(Thread&& other) noexcept : m_thread(std::move(other.m_thread)) {
        other.m_joinable = false;
    }

    void join() {
        if (m_joinable && m_thread.joinable()) {
            m_thread.join();
            m_joinable = false;
        }
    }

    void detach() {
        if (m_joinable && m_thread.joinable()) {
            m_thread.detach();
            m_joinable = false;
        }
    }

    // 获取线程 ID
    std::thread::id get_id() const { return m_thread.get_id(); }

private:
    std::thread m_thread;
    bool m_joinable = false;
};

我的经验:析构函数里自动 join 这个设计,曾经救过我一命。有一次在异常处理路径上忘了 join 线程,程序直接崩了。加上这个封装后,再也没出过类似问题。不过要注意:如果线程死循环,析构时会卡住,所以最好配合超时机制。

5.3 互斥锁封装:RAII 与超时锁

std::mutex 本身是好的,但 std::lock_guard 和 std::unique_lock 的用法容易混淆。我封装了一个更直观的 LockGuard,同时支持超时。

class Mutex {
public:
    Mutex() = default;
    ~Mutex() = default;

    void lock() { m_mutex.lock(); }
    void unlock() { m_mutex.unlock(); }

    // 尝试加锁,支持超时
    bool try_lock_for(int timeout_ms) {
        std::unique_lock<std::mutex> lock(m_mutex, std::defer_lock);
        if (timeout_ms < 0) {
            lock.lock();
            return true;
        }
        // 使用 timed_mutex 或 condition_variable 实现超时
        // 这里简化处理,实际项目中建议用 std::timed_mutex
        return lock.try_lock_for(std::chrono::milliseconds(timeout_ms));
    }

private:
    std::mutex m_mutex;
};

// RAII 封装
class LockGuard {
public:
    explicit LockGuard(Mutex& mutex) : m_mutex(mutex) {
        m_mutex.lock();
    }
    ~LockGuard() {
        m_mutex.unlock();
    }
private:
    Mutex& m_mutex;
};

避坑指南:我曾经在项目中直接用 std::lock_guard,结果在锁保护区域内调用了回调函数,回调里又尝试加同一把锁——死锁了。后来我改用 std::recursive_mutex 解决了,但更好的做法是设计成不允许重入,并在文档里明确说明。

5.4 条件变量封装:更安全的等待

std::condition_variable 的 wait 需要配合 unique_lock 和 while 循环,写起来很繁琐。我封装了一个 Condition 类,把常见模式简化成一行调用。

class Condition {
public:
    Condition() = default;

    // 等待条件成立,支持超时
    template<typename Predicate>
    bool wait_for(Mutex& mutex, int timeout_ms, Predicate pred) {
        std::unique_lock<std::mutex> lock(m_mutex);
        if (timeout_ms < 0) {
            m_cv.wait(lock, std::move(pred));
            return true;
        }
        return m_cv.wait_for(lock, 
            std::chrono::milliseconds(timeout_ms), 
            std::move(pred));
    }

    // 通知一个等待线程
    void notify_one() { m_cv.notify_one(); }
    // 通知所有等待线程
    void notify_all() { m_cv.notify_all(); }

private:
    std::condition_variable m_cv;
    std::mutex m_mutex;  // 内部锁,用于保护条件变量自身
};

使用示例

Mutex mutex;
Condition cond;
bool ready = false;

// 生产者线程
void producer() {
    LockGuard lock(mutex);
    ready = true;
    cond.notify_one();
}

// 消费者线程
void consumer() {
    cond.wait_for(mutex, 5000, [&]() { return ready; });
    // 如果 5 秒内 ready 变为 true,继续执行;否则超时返回
}

5.5 跨平台封装:线程优先级与命名

标准库不提供线程优先级和命名功能,但实际项目中经常需要。我封装了平台相关的实现:

class ThreadUtil {
public:
    // 设置线程名称(调试时很有用)
    static void set_name(const std::string& name) {
#ifdef _WIN32
        // Windows: 使用 SetThreadDescription
        std::wstring wname(name.begin(), name.end());
        SetThreadDescription(GetCurrentThread(), wname.c_str());
#elif defined(__linux__)
        // Linux: 使用 prctl
        prctl(PR_SET_NAME, name.c_str(), 0, 0, 0);
#elif defined(__APPLE__)
        // macOS: 使用 pthread_setname_np
        pthread_setname_np(name.c_str());
#endif
    }

    // 设置线程优先级(0-99,99 最高)
    static bool set_priority(int priority) {
#ifdef _WIN32
        int win_priority = THREAD_PRIORITY_NORMAL;
        if (priority > 80) win_priority = THREAD_PRIORITY_HIGHEST;
        else if (priority > 60) win_priority = THREAD_PRIORITY_ABOVE_NORMAL;
        else if (priority < 20) win_priority = THREAD_PRIORITY_LOWEST;
        return SetThreadPriority(GetCurrentThread(), win_priority) != 0;
#elif defined(__linux__)
        // Linux: 使用 pthread_setschedparam
        struct sched_param param;
        param.sched_priority = priority;
        return pthread_setschedparam(pthread_self(), SCHED_OTHER, &param) == 0;
#else
        // macOS 对优先级支持有限,直接返回 true
        return true;
#endif
    }
};

我的建议:线程优先级是个双刃剑。我在一个音视频项目中把解码线程优先级设得过高,结果 UI 线程卡顿严重。后来我改用线程池 + 任务优先级,效果反而更好。所以,除非你非常清楚自己在做什么,否则别轻易动优先级。

5.6 知识体系总览

下面这张图总结了本章的核心封装逻辑:

线程与同步机制封装体系 Thread 封装 自动 join/detach RAII 生命周期 移动语义支持 Mutex 封装 RAII LockGuard 超时加锁支持 异常安全 Condition 封装 超时等待 谓词简化 通知接口 跨平台适配层 Windows (SetThreadDescription) | Linux (prctl) | macOS (pthread_setname_np) C++ 标准库 (std::thread / std::mutex / std::condition_variable)

5.7 实战中的注意事项

封装完了,但真正用好还需要注意几点:

  • 避免死锁:多把锁时,始终按相同顺序加锁。我习惯用 std::lock 同时锁多个 mutex,或者封装一个 ScopedLock 类来管理多锁。
  • 条件变量的虚假唤醒:wait 返回后,条件不一定成立。所以一定要用 while 循环检查谓词,而不是 if。我的封装里已经帮你做了这件事。
  • 性能考量:锁的粒度要适中。太粗了并发度低,太细了锁开销大。我在一个网络库中曾经把锁拆到每个连接上,性能提升了 3 倍。
  • 调试支持:给线程起个有意义的名字,崩溃时能快速定位。我习惯在构造函数里自动调用 set_name。

我曾经踩过的坑:有一次在析构函数里加锁,结果锁被另一个线程持有着,析构线程直接卡死。后来我改用 try_lock_for 加超时,超时后打印警告日志,问题才暴露出来。所以,析构函数里尽量别加锁,如果非要加,一定要有超时机制。

好了,这一章的内容就到这里。线程封装看似简单,但每个细节都关系到程序的稳定性和性能。希望这些封装能帮你少走弯路,写出更健壮的多线程代码。