C++counting_semaphore通用计数信号

2026-03-22 20:15:33 1114阅读

C++20 counting_semaphore:通用计数信号量的原理与实践

在多线程编程中,同步原语是保障数据一致性和执行时序的关键工具。C++20 标准正式引入了 <semaphore> 头文件,其中 std::counting_semaphore 作为首个标准化的无锁(lock-free)计数信号量,为开发者提供了比互斥量更轻量、比条件变量更直接的资源协调机制。它不依赖于任何特定线程的“所有权”,仅通过原子计数器管理许可(permits)的获取与释放,适用于生产者-消费者、限流控制、任务配额分配等多种并发场景。

counting_semaphore 的核心语义简洁而强大:它维护一个非负整型计数器(value),初始值由模板参数 LeastMaxValue 和构造函数参数共同约束;调用 acquire() 会阻塞直至计数器大于 0,随后原子地将其减 1;调用 release() 则原子地将计数器加 1(可指定增量,默认为 1)。值得注意的是,该类型要求底层计数器支持无锁操作,因此其 max() 值受平台原子操作能力限制(通常为 std::numeric_limits<difference_type>::max() 的合理子集)。

以下是一个典型的生产者-消费者模型示例,展示如何使用 counting_semaphore 控制缓冲区访问:

#include <semaphore>
#include <vector>
#include <thread>
#include <iostream>
#include <chrono>
#include <random>

// 固定大小的线程安全缓冲区(仅作示意,不包含完整边界检查)
struct BoundedBuffer {
    static constexpr size_t CAPACITY = 4;
    std::vector<int> data;
    std::counting_semaphore<> slots{CAPACITY};   // 空闲槽位数,初始=容量
    std::counting_semaphore<> items{0};           // 已填充项数,初始=0

    BoundedBuffer() : data(CAPACITY) {}

    void produce(int value) {
        slots.acquire();  // 等待空槽
        // 写入临界区(此处简化,实际需配合 mutex 或无锁结构)
        size_t idx = (CAPACITY - slots.try_acquire_until(
            std::chrono::steady_clock::now() + 10ms)) % CAPACITY;
        data[idx] = value;
        items.release();  // 通知有新项
    }

    int consume() {
        items.acquire();  // 等待可用项
        // 读取临界区(同上)
        size_t idx = (items.try_acquire_until(
            std::chrono::steady_clock::now() + 10ms) ? 0 : 0) % CAPACITY;
        int val = data[idx];
        slots.release();  // 归还空槽
        return val;
    }
};

上述代码虽为示意,但清晰体现了信号量的职责分离:slots 控制写入许可,items 控制读取许可。二者协同消除了忙等与死锁风险,且无需显式锁竞争。相比 std::mutex + std::condition_variable 组合,代码行数减少约 40%,逻辑路径更线性。

counting_semaphore 还支持超时等待,增强程序鲁棒性。try_acquire_for()try_acquire_until() 在指定时间内未获得许可时返回 false,避免无限阻塞。例如,在网络请求限流器中可这样使用:

#include <semaphore>
#include <thread>
#include <chrono>

class RateLimiter {
    std::counting_semaphore<> permits;

public:
    explicit RateLimiter(size_t max_concurrent) : permits(max_concurrent) {}

    bool try_acquire(std::chrono::milliseconds timeout) {
        return permits.try_acquire_for(timeout);
    }

    void release() {
        permits.release();
    }
};

// 使用示例:模拟并发请求处理
void handle_request(RateLimiter& limiter, int id) {
    if (!limiter.try_acquire(500ms)) {
        std::cout << "Request " << id << " timed out\n";
        return;
    }
    // 模拟耗时操作
    std::this_thread::sleep_for(200ms);
    std::cout << "Request " << id << " completed\n";
    limiter.release();
}

此限流器无需额外状态管理,所有同步逻辑内聚于信号量实例。当 max_concurrent 设为 3 时,任意时刻最多 3 个请求并行执行,其余等待或超时退出,天然满足令牌桶语义。

需特别注意几个关键约束:首先,counting_semaphore 不可拷贝、不可移动,仅支持默认构造(若 LeastMaxValue > 0)和带参构造;其次,release(n)n 必须非负,且累加后不得超出 max(),否则行为未定义;最后,其 try_acquire_* 系列函数不抛异常,适合对异常敏感的实时系统。

相较于 POSIX sem_t 或 Windows Semaphorestd::counting_semaphore 的优势在于标准化接口、零成本抽象(编译期确定内存序)、以及与 C++ 并发库(如 std::jthread, std::stop_token)的无缝集成。它不替代互斥量——不保护数据本身,只协调访问时机;也不替代 std::barrier——不用于多阶段同步,而专注资源配额。

在工程实践中,建议将 counting_semaphore 用于明确存在“许可池”语义的场景:如数据库连接池、GPU 显存页分配、api 调用频次控制等。避免将其用于需要复杂状态判断的同步(此时应选用 std::condition_variable 或更高级的同步原语如 std::latch / std::barrier)。

总之,std::counting_semaphore 是 C++20 并发演进的重要里程碑。它以极简 api 封装了经久验证的计数信号量模型,兼顾性能、安全与可维护性。掌握其原理与适用边界,有助于构建更清晰、更可靠的高并发系统。随着 C++23 对 std::binary_semaphore 的补充及更多同步设施的完善,现代 C++ 的并发表达能力正持续走向成熟与统一。

文章版权声明:除非注明,否则均为Dark零点博客原创文章,转载或复制请以超链接形式并注明出处。

目录[+]