第4章 线程间同步-信号量:从机制到实战

信号量,说白了就是操作系统里用来管理资源的“令牌”。谁拿到令牌,谁就能干活。拿不到的,乖乖排队等着。

我在做第一个RT-Thread项目时,对信号量的理解就停留在“嗯,大概是个计数器”。结果调试的时候,两个线程抢串口打印,打印出来的数据乱成一锅粥。从那以后,我才真正重视起信号量的用法。

4.1 信号量的工作机制

信号量的核心就是一个整数 + 一个等待队列。这个整数代表可用资源的数量。

  • 获取信号量(take):如果计数值 > 0,减1,继续执行。如果计数值 = 0,线程挂起,进入等待队列。
  • 释放信号量(release):计数值加1。如果有线程在等待,唤醒其中一个。

你想想看,这像不像食堂打饭?窗口有空位(资源 > 0),你就直接去打饭。没空位了,你就得排队等着。有人吃完走了(释放资源),排在最前面的人就能上去。

关键点:信号量的操作是原子的。RT-Thread内部会关中断或使用锁来保护这个计数值的修改。你不用担心两个线程同时take会导致数据错乱。

4.2 二值信号量与计数信号量

信号量分两种:二值信号量和计数信号量。名字听着玄乎,其实区别很简单。

类型 计数值范围 典型用途 我个人的理解
二值信号量 0 或 1 互斥访问、中断与线程同步 就是一个“是否可用”的开关
计数信号量 0 ~ 最大值 管理多个同类资源、生产者-消费者 就是一个“还剩几个”的计数器

二值信号量,说白了就是一把锁。要么有人用着(0),要么空闲(1)。我在项目中常用它来做中断与线程的同步——中断里释放信号量,线程里等待信号量,这样中断服务程序就能轻量快速地退出。

计数信号量,可以管理N个相同的资源。比如你有3个DMA通道,初始计数值设为3。每个线程用之前take一下,用完了release。这样最多允许3个线程同时使用DMA。

我曾经踩过的坑:把二值信号量当互斥量用,结果出现了优先级反转。后来才换成互斥量(mutex)。记住:二值信号量适合“通知”,互斥量适合“保护共享资源”。别搞混了。

4.3 经典生产者-消费者问题实战

生产者-消费者问题是多线程同步的经典考题。一个线程生产数据,一个线程消费数据。中间有个缓冲区。

问题在于:

  • 缓冲区满了,生产者不能再生产,得等着。
  • 缓冲区空了,消费者不能再消费,也得等着。

用信号量来解决,思路非常清晰:

  1. 用一个计数信号量 empty 表示空位数量,初始值 = 缓冲区大小。
  2. 用一个计数信号量 full 表示数据数量,初始值 = 0。
  3. 用一个互斥量 mutex 保护缓冲区的读写操作。

下面是我在STM32上跑过的代码,你可以直接参考:

#include <rtthread.h>

#define BUFFER_SIZE 10
static rt_uint8_t buffer[BUFFER_SIZE];
static rt_uint8_t write_index = 0, read_index = 0;

/* 信号量和互斥量控制块 */
static rt_sem_t empty_sem;
static rt_sem_t full_sem;
static rt_mutex_t mutex;

/* 生产者线程 */
static void producer_entry(void *parameter)
{
    rt_uint8_t data = 0;
    while (1)
    {
        /* 等待空位 */
        rt_sem_take(empty_sem, RT_WAITING_FOREVER);

        /* 加锁保护缓冲区 */
        rt_mutex_take(mutex, RT_WAITING_FOREVER);
        buffer[write_index] = data;
        write_index = (write_index + 1) % BUFFER_SIZE;
        rt_mutex_release(mutex);

        /* 通知消费者有数据了 */
        rt_sem_release(full_sem);

        data++;
        rt_thread_mdelay(50); /* 模拟生产耗时 */
    }
}

/* 消费者线程 */
static void consumer_entry(void *parameter)
{
    rt_uint8_t data;
    while (1)
    {
        /* 等待数据 */
        rt_sem_take(full_sem, RT_WAITING_FOREVER);

        /* 加锁保护缓冲区 */
        rt_mutex_take(mutex, RT_WAITING_FOREVER);
        data = buffer[read_index];
        read_index = (read_index + 1) % BUFFER_SIZE;
        rt_mutex_release(mutex);

        /* 通知生产者有空位了 */
        rt_sem_release(empty_sem);

        rt_kprintf("消费: %d\n", data);
        rt_thread_mdelay(100); /* 模拟消费耗时 */
    }
}

/* 初始化 */
int semaphore_producer_consumer_init(void)
{
    empty_sem = rt_sem_create("empty", BUFFER_SIZE, RT_IPC_FLAG_FIFO);
    full_sem = rt_sem_create("full", 0, RT_IPC_FLAG_FIFO);
    mutex = rt_mutex_create("mutex", RT_IPC_FLAG_FIFO);

    rt_thread_t producer = rt_thread_create("producer",
        producer_entry, RT_NULL, 1024, 5, 10);
    rt_thread_t consumer = rt_thread_create("consumer",
        consumer_entry, RT_NULL, 1024, 5, 10);

    rt_thread_startup(producer);
    rt_thread_startup(consumer);

    return 0;
}
/* 使用 MSH 命令调用 */
MSH_CMD_EXPORT(semaphore_producer_consumer_init, 
    "start producer-consumer demo");

我的调试习惯:先用 rt_kprintf 打印每个信号量的当前值,确认take和release的配对是否正确。曾经有一次我忘了release empty信号量,结果生产者生产了10个数据后就永远停住了。打印一看,empty一直为0,问题瞬间定位。

4.4 信号量的核心流程

为了让你更直观地理解信号量的工作流程,我画了一张图:

信号量工作机制流程图 线程调用 rt_sem_take() 检查信号量计数值 值 > 0? 计数值减1,继续执行 线程挂起,进入等待队列 线程调用 rt_sem_release() 计数值加1 有线程等待? 唤醒一个线程 无 关键说明 take和release必须配对使用。多take一次,资源就少一个。 忘记release会导致线程永远挂起——我吃过这个亏。

4.5 避坑指南与调试技巧

信号量用起来简单,但坑也不少。我总结了几条经验:

  • 初始化值要算清楚:计数信号量的初始值代表“一开始有多少资源可用”。生产者-消费者里,empty初始值是缓冲区大小,full初始值是0。搞反了,程序直接跑飞。
  • 中断里只能release,不能take:中断服务程序里调用 rt_sem_take 会导致线程挂起,但中断里不允许阻塞。RT-Thread会断言报错。
  • 超时时间别设成永久等待:除非你100%确定信号量一定会被释放。否则一旦出bug,线程就永远卡死了。我习惯在调试阶段设一个有限超时,比如1000个tick,配合打印定位问题。
  • 优先级反转:用二值信号量保护共享资源时,低优先级线程拿着信号量,高优先级线程等着,中优先级线程抢占了低优先级线程的CPU——高优先级反而跑不过中优先级。解决方案是用互斥量(mutex),它内置了优先级继承机制。

我曾经犯过的错:在一个中断里释放了信号量,但中断频率太高,导致信号量计数值溢出。RT-Thread默认会检查最大值,但如果你创建时设了 RT_IPC_FLAG_FIFO 没设上限,计数值会一直涨。后来我加了限流,确保release的频率不超过take的频率。

信号量是RT-Thread里最常用的同步手段之一。理解它的工作机制,掌握二值和计数的区别,再亲手跑一遍生产者-消费者的例子,后面遇到复杂的同步场景就不会慌了。