4. 线程同步机制(下):Event事件对象、Condition条件变量、Semaphore信号量、Barrier栅栏

好,咱们接着聊线程同步。上一章我们把互斥锁和读写锁讲透了,这一章我带你看看剩下的四个同步工具:Event、Condition、Semaphore、Barrier。说实话,这几个家伙在工控场景里用得特别多,尤其是多任务协调的时候。

我个人习惯把同步机制分成两类:一类是「保护数据」的,比如互斥锁;另一类是「协调流程」的,就是今天要讲的这些。你想想看,工控机里经常有这种情况:一个线程等着另一个线程干完某件事才能继续,或者多个线程要同时到达某个点才能一起往下走。这时候用锁?不合适。得用事件、条件变量这些东西。

4.1 Event事件对象:最简单的信号通知

Event,说白了就是一个「开关」。一个线程等着,另一个线程一拉开关,等着的那位就醒了。我在项目中遇到过用Event做急停信号处理的场景——一个线程专门监控急停按钮,按下后设置Event,其他所有工作线程看到Event被设置,立刻停止当前动作。

核心概念:

  • signaled(有信号):事件被触发,等待的线程可以继续
  • non-signaled(无信号):事件未触发,线程阻塞等待
  • 手动重置 vs 自动重置:手动重置需要手动恢复无信号状态,自动重置触发后自动恢复

看个C++11的简单例子:

#include <thread>
#include <condition_variable>
#include <mutex>
#include <atomic>

// 用条件变量模拟Event(C++11没有原生Event)
class Event {
public:
    Event(bool manualReset = false) 
        : manualReset_(manualReset), signaled_(false) {}

    void Set() {
        std::lock_guard<std::mutex> lock(mutex_);
        signaled_ = true;
        if (manualReset_) {
            cv_.notify_all();
        } else {
            cv_.notify_one();
        }
    }

    void Reset() {
        std::lock_guard<std::mutex> lock(mutex_);
        signaled_ = false;
    }

    void Wait() {
        std::unique_lock<std::mutex> lock(mutex_);
        cv_.wait(lock, [this]{ return signaled_; });
        if (!manualReset_) {
            signaled_ = false;  // 自动重置
        }
    }

private:
    bool manualReset_;
    bool signaled_;
    std::mutex mutex_;
    std::condition_variable cv_;
};

// 使用示例
Event g_emergencyStop(false);  // 自动重置事件

void workerThread() {
    while (true) {
        // 检查急停信号
        if (g_emergencyStop.Wait()) {
            // 执行急停逻辑
            break;
        }
        // 正常工作...
    }
}

我的经验:工控场景里,Event最适合做「一次性通知」。比如启动完成信号、故障触发信号。别拿它做频繁的同步,那玩意儿效率不高。

4.2 Condition条件变量:更灵活的等待机制

条件变量比Event复杂一点,但功能也更强。它允许线程等待某个条件成立,而不是单纯等一个信号。我曾经在一个多轴运动控制项目里用条件变量做「位置到达通知」——每个轴到位后通知主线程,主线程再决定下一步动作。

条件变量必须和互斥锁配合使用,这是关键。为什么?因为你要检查条件是否满足,这个检查过程必须是原子的,不能被其他线程打断。

#include <thread>
#include <condition_variable>
#include <mutex>
#include <queue>

std::queue<int> dataQueue;
std::mutex mtx;
std::condition_variable cv;

void producer() {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        {
            std::lock_guard<std::mutex> lock(mtx);
            dataQueue.push(i);
            std::cout << "生产: " << i << std::endl;
        }
        cv.notify_one();  // 通知消费者
    }
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        // 等待条件:队列不为空
        cv.wait(lock, []{ return !dataQueue.empty(); });
        
        int data = dataQueue.front();
        dataQueue.pop();
        lock.unlock();  // 提前解锁,提高并发
        
        std::cout << "消费: " << data << std::endl;
        
        if (data == 9) break;
    }
}

注意:条件变量的wait()一定要用带谓词的重载版本!我见过太多人直接用wait(lock),结果发生虚假唤醒(spurious wakeup)导致程序崩溃。说白了,就算被唤醒了,也要再检查一次条件是否真的满足。

4.3 Semaphore信号量:控制并发数量

信号量,你可以把它想象成一个「令牌池」。线程要干活,先得拿一个令牌,干完活再还回去。令牌没了,后来的线程就得等着。这在工控里太常见了——比如控制同时访问某个硬件资源的线程数量。

我记得有个项目,一台工控机要控制8个伺服驱动器,但通信总线一次只能处理4个请求。我就用信号量限制并发数,效果很好。

信号量类型 特点 工控应用场景
计数信号量 资源计数,可多次获取 限制并发访问硬件设备
二进制信号量 只有0和1,类似互斥锁 简单互斥,但无所有权概念

C++20终于有了原生信号量,但很多工控项目还在用C++11/14。没关系,我们可以自己封装一个:

#include <mutex>
#include <condition_variable>

class Semaphore {
public:
    Semaphore(int count = 0) : count_(count) {}

    void acquire() {
        std::unique_lock<std::mutex> lock(mutex_);
        cv_.wait(lock, [this]{ return count_ > 0; });
        --count_;
    }

    void release() {
        std::lock_guard<std::mutex> lock(mutex_);
        ++count_;
        cv_.notify_one();
    }

private:
    int count_;
    std::mutex mutex_;
    std::condition_variable cv_;
};

// 使用:限制最多4个线程同时访问硬件
Semaphore g_hwAccess(4);

void accessHardware() {
    g_hwAccess.acquire();
    // 访问硬件...
    g_hwAccess.release();
}

避坑指南:我曾经在信号量的release()里忘记加锁,结果多线程环境下计数乱掉了。记住,信号量的内部计数必须受保护,别偷懒。

4.4 Barrier栅栏:多线程会合点

Barrier,也叫栅栏或屏障。它的作用就是让一组线程都到达某个点之后,再一起往下走。工控里做「同步启动」的时候特别有用——比如4个轴都要准备好,才能开始执行联动轨迹。

C++20提供了std::barrier,但如果你用老版本,可以自己实现一个简单的:

#include <mutex>
#include <condition_variable>

class Barrier {
public:
    explicit Barrier(int count) 
        : threshold_(count), count_(count), generation_(0) {}

    void wait() {
        std::unique_lock<std::mutex> lock(mutex_);
        int gen = generation_;
        if (--count_ == 0) {
            // 最后一个到达的线程,重置并唤醒所有人
            ++generation_;
            count_ = threshold_;
            cv_.notify_all();
        } else {
            // 等待直到generation改变
            cv_.wait(lock, [this, gen]{ return gen != generation_; });
        }
    }

private:
    int threshold_;
    int count_;
    int generation_;
    std::mutex mutex_;
    std::condition_variable cv_;
};

// 使用:4个线程同步启动
Barrier g_startBarrier(4);

void axisControl(int axisId) {
    // 准备阶段...
    std::cout << "轴" << axisId << "准备就绪" << std::endl;
    
    g_startBarrier.wait();  // 等待所有轴准备好
    
    // 所有轴同时开始运动
    std::cout << "轴" << axisId << "开始运动" << std::endl;
}

关键点:Barrier的实现要注意「generation」的概念。如果线程到达后直接重置计数,可能会让下一轮的线程提前通过。用generation来区分轮次,这是标准做法。

4.5 四种同步机制对比

好了,四种工具都讲完了。我最后给你做个对比,方便你选型:

同步机制 核心用途 适用场景 注意事项
Event 信号通知 一次性事件、急停、启动信号 注意手动/自动重置的选择
Condition 条件等待 生产者-消费者、状态变化通知 必须配合互斥锁,用带谓词的wait
Semaphore 资源计数 限制并发数、资源池管理 注意计数保护,防止溢出
Barrier 多线程同步点 同步启动、分阶段任务 注意generation管理,防止ABA问题

嗯,这一章内容不少。你消化一下,下一章我们讲线程池和任务队列,那又是另一个大话题了。