C++ 信号量(Semaphore)详解与编程实践 信号量是多线程同步与互斥的核心工具之一,它能有效解决多线程间的资源竞争、任务协同问题。本文将从信号量的核心概念出发,逐步讲解其在 C++ 中的实现与使用,帮助你快速掌握这一关键技术。 一、信号量的核心概念 1. 什么是信号量? 信号量(Semaphore)本质上是一个计数器+等待 / 唤醒机制,用于控制多个线程对共享资源的访问权限,或实现线程间的有序协同。 它由荷兰计算机科学家 Dijkstra 于 1965 年提出,核心有两个操作(原子操作,不可被中断): P 操作(申请资源):也叫wait操作,将信号量计数器减 1。如果减 1 后计数器≥0,说明申请资源成功,线程继续执行;如果计数器<0,说明资源耗尽,线程被阻塞(进入等待队列),直到有其他线程释放资源。 V 操作(释放资源):也叫post操作,将信号量计数器加 1。如果加 1 后计数器≤0,说明有线程正在等待该资源,会唤醒一个等待队列中的线程,让其继续执行;如果计数器>0,说明没有线程等待,仅完成计数器更新。 2. 信号量的两种核心类型 (1) 互斥信号量(二进制信号量) 计数器的值只能是0或1,等价于一个 “互斥锁”,用于解决临界区资源的互斥访问(同一时间只有一个线程能访问共享资源)。 初始值为1,表示资源可用。 线程访问资源前执行 P 操作,计数器变为0(资源被占用)。 线程释放资源后执行 V 操作,计数器变回1(资源释放)。 如果有其他线程此时申请资源,会因计数器变为-1而被阻塞,直到持有资源的线程释放。 (2) 计数信号量(通用信号量) 计数器的值可以是任意非负整数,用于控制有限个共享资源的并发访问(同一时间允许 N 个线程访问共享资源)。 初始值为N(N 为共享资源的数量,例如线程池的最大并发数、缓冲区的容量)。 每个线程申请资源时执行 P 操作,计数器减 1;释放资源时执行 V 操作,计数器加 1。 当计数器变为0时,后续申请资源的线程会被阻塞,直到有线程释放资源。 二、C++ 中的信号量实现 C++ 标准库在C++20中才正式引入了信号量相关接口(位于<semaphore>头文件),在此之前,开发者通常使用第三方库(如 Boost)或操作系统提供的接口(如 Linux 的<semaphore.h>、Windows 的CreateSemaphore)。 本文将讲解两种常用实现: C++20 标准信号量(推荐,跨平台) Linux 系统信号量(兼容旧版本 C++,仅适用于 Linux/Unix) 前置说明 本文所有示例均基于多线程环境,需包含<thread>头文件,编译时需链接线程库(GCC 编译器添加-pthread参数)。 示例代码注重可读性,简化了部分异常处理,生产环境中需补充完善。 三、C++20 标准信号量实战 C++20 提供了两种标准信号量: std::counting_semaphore<>:计数信号量,模板参数为计数器的最大值(需为非负整数)。 std::binary_semaphore:二进制信号量,是std::counting_semaphore<1>的别名,等价于互斥信号量。 核心成员函数 函数 功能 对应操作 void acquire() 申请资源(P 操作),若资源不足则阻塞线程 P 操作 bool try_acquire() 尝试申请资源,成功返回true,失败返回false(不阻塞) 非阻塞 P 操作 void release(ptrdiff_t update = 1) 释放资源(V 操作),update为计数器增加的值(默认 1) V 操作 示例 1:二进制信号量实现互斥访问 需求:两个线程同时对同一个全局变量进行累加操作,使用std::binary_semaphore保证操作的原子性,避免数据竞争。 cpp 运行 #include <iostream> #include <thread> #include <semaphore> // C++20 标准信号量头文件 // 全局共享资源 int g_shared_count = 0; // 二进制信号量,初始值为1(资源可用) std::binary_semaphore g_binary_sem(1); // 累加任务函数 void increment_count(int times) { for (int i = 0; i < times; ++i) { // P操作:申请资源,进入临界区 g_binary_sem.acquire(); // 临界区:修改共享变量(原子操作,避免数据混乱) g_shared_count++; // 打印当前线程ID和累加后的值(仅用于演示) std::cout << "Thread " << std::this_thread::get_id() << " : g_shared_count = " << g_shared_count << std::endl; // V操作:释放资源,退出临界区 g_binary_sem.release(); } } int main() { // 创建两个线程,每个线程累加10次 std::thread t1(increment_count, 10); std::thread t2(increment_count, 10); // 等待两个线程执行完毕 t1.join(); t2.join(); // 打印最终结果 std::cout << "Final g_shared_count = " << g_shared_count << std::endl; return 0; } 编译与运行(GCC) bash 运行 # 需开启C++20标准,链接线程库 g++ -std=c++20 semaphore_binary.cpp -o semaphore_binary -pthread ./semaphore_binary 运行结果说明 两个线程不会同时修改g_shared_count,最终结果稳定为20,不会出现数据丢失(若不使用信号量,最终结果可能小于20)。 示例 2:计数信号量实现资源限流 需求:创建 5 个线程,模拟访问一个最多允许 2 个线程同时访问的共享资源,使用std::counting_semaphore实现限流。 cpp 运行 #include <iostream> #include <thread> #include <semaphore> #include <chrono> // 计数信号量,初始值为2(最多允许2个线程同时访问资源) std::counting_semaphore<10> g_counting_sem(2); // 最大值设为10,满足需求即可 // 资源访问任务函数 void access_resource(int thread_id) { // P操作:申请访问资源 g_counting_sem.acquire(); std::cout << "Thread " << thread_id << " : 成功获取资源,开始访问..." << std::endl; // 模拟资源访问耗时(休眠2秒) std::this_thread::sleep_for(std::chrono::seconds(2)); std::cout << "Thread " << thread_id << " : 资源访问完毕,释放资源..." << std::endl; // V操作:释放资源 g_counting_sem.release(); } int main() { const int thread_num = 5; std::thread threads[thread_num]; // 创建5个线程 for (int i = 0; i < thread_num; ++i) { threads[i] = std::thread(access_resource, i + 1); } // 等待所有线程执行完毕 for (int i = 0; i < thread_num; ++i) { threads[i].join(); } std::cout << "所有线程均完成资源访问" << std::endl; return 0; } 运行结果说明 运行后会发现,同一时间只有 2 个线程在访问资源,其余线程处于阻塞状态,直到有线程释放资源后,才会有新的线程获取资源并执行。 四、Linux 系统信号量(兼容旧版本 C++) 对于不支持 C++20 的环境,Linux/Unix 系统提供了<semaphore.h>头文件,实现了 POSIX 标准信号量,常用的有命名信号量(用于进程间通信)和无名信号量(用于线程间通信),本文重点讲解线程间通信的无名信号量。 核心函数 初始化信号量 c 运行 int sem_init(sem_t *sem, int pshared, unsigned int value); 参数 1:sem:指向要初始化的信号量对象。 参数 2:pshared:是否用于进程间共享,0表示仅用于线程间共享,非0表示用于进程间共享。 参数 3:value:信号量初始值(计数器初始值)。 返回值:成功返回0,失败返回-1。 P 操作(申请资源) c 运行 int sem_wait(sem_t *sem); 阻塞式申请资源,计数器减 1,资源不足则阻塞线程。 成功返回0,失败返回-1。 V 操作(释放资源) c 运行 int sem_post(sem_t *sem); 释放资源,计数器加 1,唤醒等待队列中的线程。 成功返回0,失败返回-1。 销毁信号量 c 运行 int sem_destroy(sem_t *sem); 释放信号量占用的资源,仅能销毁sem_init初始化的无名信号量。 成功返回0,失败返回-1。 示例:Linux 无名信号量实现线程协同 需求:实现 “生产者 - 消费者” 简单模型,生产者线程生产数据(存入全局变量),消费者线程消费数据,使用两个二进制信号量实现线程间协同。 cpp 运行 #include <iostream> #include <thread> #include <chrono> #include <semaphore.h> // Linux 信号量头文件 // 全局共享数据 int g_data = 0; // 两个二进制信号量 sem_t g_producer_sem; // 生产者信号量,初始值1(允许生产) sem_t g_consumer_sem; // 消费者信号量,初始值0(无数据可消费) // 生产者线程函数 void producer() { for (int i = 0; i < 5; ++i) { // P操作:申请生产权限 sem_wait(&g_producer_sem); // 生产数据 g_data = i + 1; std::cout << "生产者:生产数据 " << g_data << std::endl; // V操作:通知消费者可以消费 sem_post(&g_consumer_sem); // 模拟生产间隔 std::this_thread::sleep_for(std::chrono::seconds(1)); } } // 消费者线程函数 void consumer() { for (int i = 0; i < 5; ++i) { // P操作:申请消费权限(无数据则阻塞) sem_wait(&g_consumer_sem); // 消费数据 std::cout << "消费者:消费数据 " << g_data << std::endl; // V操作:通知生产者可以继续生产 sem_post(&g_producer_sem); // 模拟消费间隔 std::this_thread::sleep_for(std::chrono::seconds(1)); } } int main() { // 初始化信号量 sem_init(&g_producer_sem, 0, 1); sem_init(&g_consumer_sem, 0, 0); // 创建生产者和消费者线程 std::thread t_producer(producer); std::thread t_consumer(consumer); // 等待线程执行完毕 t_producer.join(); t_consumer.join(); // 销毁信号量 sem_destroy(&g_producer_sem); sem_destroy(&g_consumer_sem); return 0; } 编译与运行(GCC) bash 运行 # 无需C++20标准,链接线程库即可 g++ semaphore_linux.cpp -o semaphore_linux -pthread ./semaphore_linux 运行结果说明 生产者和消费者线程有序执行,生产者生产一个数据后,必须等待消费者消费完毕才能继续生产,消费者也只能在生产者生产后才能消费,实现了完美的线程协同。 五、信号量的常见应用场景 临界区资源互斥访问:使用二进制信号量替代互斥锁(std::mutex),实现共享资源(如全局变量、文件句柄)的原子操作。 资源限流:使用计数信号量控制并发访问数(如线程池最大并发数、接口限流、连接池最大连接数)。 线程间协同:实现 “生产者 - 消费者”“读者 - 写者” 等模型,解决线程间的等待 / 通知问题。 进程间通信:使用 Linux 命名信号量或 Windows 系统信号量,实现不同进程间的同步与互斥。 六、注意事项 避免死锁:申请信号量后,必须保证在所有分支(包括异常分支)中释放信号量,否则会导致其他线程永久阻塞。 C++20 兼容性:std::semaphore仅在 C++20 及以上标准支持,使用前需确认编译器版本(GCC 10+、Clang 11+、MSVC 2019+ 支持 C++20)。 信号量与互斥锁的区别: 互斥锁(std::mutex)只能由持有锁的线程释放,信号量可以由任意线程释放。 信号量支持计数限流,互斥锁仅支持单线程互斥访问。 互斥锁的性能通常优于二进制信号量,简单互斥场景优先使用互斥锁。 Linux 信号量初始化:无名信号量用于线程间通信时,pshared参数必须设为0。 总结 信号量是由 “计数器 + 等待 / 唤醒机制” 组成的同步工具,核心操作是 P(acquire/wait)和 V(release/post),且这两个操作均为原子操作。 信号量分为二进制信号量(互斥,0/1)和计数信号量(限流,非负整数),C++20 提供了标准实现,Linux 提供了<semaphore.h>兼容旧版本。 信号量可用于解决资源互斥、限流、线程协同等问题,使用时需避免死锁,简单互斥场景优先选择互斥锁以获得更好性能。