C++ 无锁编程:单停多发送场景高性能方案
引言
在多线程编程中,一个常见的场景是:多个线程并发地向某个资源或服务发送数据,而在某个时刻,需要优雅地停止所有发送操作,并等待已经开始的发送完成后再进行资源清理。如果使用互斥锁(mutex)来同步,虽然能保证正确性,但会引入锁竞争,降低并发性能,尤其在发送操作非常频繁时,锁的开销会成为瓶颈。
本文将介绍一种基于原子变量(std::atomic)和位域的无锁实现方案,它能够在不使用互斥锁的前提下,安全地处理"单停、多发送"的场景,既保证了高性能,又确保了线程安全。
问题背景
假设我们有一个网络发送器(Sender),它允许多个线程同时调用 send() 方法发送数据。当需要停止发送器(例如关闭连接或销毁资源)时,我们必须:
- 阻止新的发送操作开始。
- 等待所有已经开始的发送操作完成。
- 最后安全地释放资源。
如果直接在发送过程中强行停止,可能会导致访问已释放的内存或其它资源,引发崩溃。传统做法是引入一个全局的互斥锁,每个发送线程先加锁,检查停止标志,然后发送,最后解锁;停止线程也需要加锁来修改停止标志并等待条件变量。这种方案虽然正确,但锁的争用会严重影响吞吐量。
那么,有没有一种无锁的方法呢?答案是肯定的。
思路
我们的核心想法是利用一个 std::atomic<uint32_t> 变量,将其划分为两个区域:
- 最高位(第31位) 作为"停止标志"(
STOP_BIT),当该位为1时表示已经请求停止。 - 低31位 作为"正在发送的任务计数"(
count),每个发送线程在开始前原子递增计数,结束后原子递减计数。
关键操作如下:
-
发送线程:
- 原子递增计数(
fetch_add)。 - 检查递增前的旧值,如果停止标志已经设置,则原子递减计数(回退)并放弃发送。
- 执行实际发送逻辑。
- 发送完成后,原子递减计数。
- 原子递增计数(
-
停止线程:
- 原子设置停止标志(
fetch_or)。 - 自旋等待计数变为0,期间让出CPU(
yield)。 - 此时所有发送都已结束,可以安全清理资源。
- 原子设置停止标志(
整个过程仅使用了原子操作和自旋等待,完全避免互斥锁。
核心代码
常量定义
cpp
#include <atomic>
#include <thread>
#include <cstdint>
class Sender {
std::atomic<uint32_t> state_{0};
static constexpr uint32_t STOP_BIT = 1u << 31; // 最高位
static constexpr uint32_t COUNT_MASK = STOP_BIT - 1; // 低31位掩码
public:
// ... 方法实现
};
发送尝试
cpp
bool try_send() {
// 1. 增加计数,并获取旧值
uint32_t old = state_.fetch_add(1, std::memory_order_acq_rel);
if (old & STOP_BIT) { // 检查停止标志
// 已停止,回退计数。使用 relaxed 即可,因为无需同步其他数据
state_.fetch_sub(1, std::memory_order_relaxed);
return false; // 发送被拒绝
}
// 2. 执行实际发送操作(模拟耗时)
// ... 这里可以安全地访问共享资源
std::this_thread::sleep_for(std::chrono::milliseconds(10));
// 3. 发送完成,减少计数。使用 release 确保之前的写入对停止线程可见
state_.fetch_sub(1, std::memory_order_release);
return true;
}
停止请求
cpp
void stop() {
// 1. 设置停止标志,若已设置则忽略重复设置
uint32_t old = state_.fetch_or(STOP_BIT, std::memory_order_acq_rel);
// 无论是否已经设置过停止标志,都需要等待计数归零(因为可能仍有发送未完成)
// 若计数已归零,循环条件为假,直接返回
while ((state_.load(std::memory_order_acquire) & COUNT_MASK) != 0) {
std::this_thread::yield(); // 让出CPU,避免空转
}
// 2. 此时所有发送都已结束,可以安全清理资源
}
内存序(Memory Order)
C++ 原子操作的内存序是多线程正确性的关键。让我们逐一分析每个原子操作的选择理由。
发送线程的 fetch_add
cpp
uint32_t old = state_.fetch_add(1, std::memory_order_acq_rel);
- 为什么用
acq_rel?
fetch_add是一个"读-改-写"操作,它返回修改前的值。我们既需要读取旧值(检查停止标志),又需要确保后续的写操作(比如实际发送中的写入)不会重排到这次加法之前,否则可能导致停止线程在计数减之前看不到这些写入。
memory_order_acq_rel提供了"获取-释放"语义:- 获取(acquire)部分保证之后的所有读写操作不会重排到此操作之前。
- 释放(release)部分保证之前的所有读写操作不会重排到此操作之后。
这样,当发送线程看到停止标志未设置时,可以安全地开始发送,并且发送中的写入在fetch_sub之前对所有线程可见。
发送线程的 fetch_sub(回退时)
cpp
state_.fetch_sub(1, std::memory_order_relaxed);
- 为什么用
relaxed?
此时已经确定停止标志已设置,我们只是简单地减少计数,并且不会有任何后续需要同步的共享数据访问。relaxed保证了原子性,而停止线程通过load(acquire)最终会看到这个修改,因为原子变量的修改对所有线程是可见的(尽管没有同步关系,但最终会传播)。使用relaxed避免了不必要的内存屏障,性能最优。
发送线程的 fetch_sub(正常完成时)
cpp
state_.fetch_sub(1, std::memory_order_release);
- 为什么用
release?
我们需要确保本次发送中对共享资源的所有写入在计数减少之前对其他线程可见。release语义保证了在它之前的所有写入不会被重排到它之后,因此停止线程如果通过acquire看到计数减少,也就一定能看到这些写入。
停止线程的 fetch_or
cpp
uint32_t old = state_.fetch_or(STOP_BIT, std::memory_order_acq_rel);
- 为什么用
acq_rel?- 设置停止标志之前,需要看到之前发送线程的
fetch_sub的release效果(尽管此时可能计数还未归零),使用acquire部分可以保证。 - 设置停止标志后,需要确保后续的
load能看到这个标志,使用release部分可以保证。
- 设置停止标志之前,需要看到之前发送线程的
停止线程的 load(自旋等待)
cpp
while ((state_.load(std::memory_order_acquire) & COUNT_MASK) != 0) {
...
}
- 为什么用
acquire?
每次读取计数时,我们希望看到发送线程在完成时对计数的fetch_sub的release效果。acquire与release配对,能够正确同步。此外,acquire还保证了在循环中不会将后续操作重排到加载之前。
自旋等待的优化
自旋等待(spin-wait)如果设计不当,会导致 CPU 空转,浪费资源。我们的代码使用了 std::this_thread::yield(),它将当前线程的时间片让给其他可运行的线程,适合等待时间稍长的场景。
如果预期等待时间极短(比如几个 CPU 周期),可以使用 _mm_pause() 或 std::this_thread::sleep_for 的短时间休眠来降低功耗。通常,yield 已经足够平衡响应性与资源占用。
异常安全与 RAII 封装
如果在发送过程中抛出异常,必须确保计数被正确递减,否则计数会永远无法归零,导致停止线程死锁。可以使用 RAII 技术封装计数管理:
cpp
class SendGuard {
std::atomic<uint32_t>& state_;
bool active_;
public:
SendGuard(std::atomic<uint32_t>& s) : state_(s), active_(false) {
uint32_t old = state_.fetch_add(1, std::memory_order_acq_rel);
if (!(old & STOP_BIT)) {
active_ = true; // 成功,需要负责析构时递减
} else {
state_.fetch_sub(1, std::memory_order_relaxed);
}
}
~SendGuard() {
if (active_) {
state_.fetch_sub(1, std::memory_order_release);
}
}
explicit operator bool() const { return active_; }
};
使用方式:
cpp
bool try_send() {
SendGuard guard(state_);
if (!guard) return false; // 已停止
// 执行发送
return true;
}
这样即使发送过程中抛出异常,SendGuard 的析构函数也会自动减少计数,确保状态一致。
示例
下面是一个完整的可运行示例,演示了多个发送线程和一个停止线程的协作:
cpp
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
#include <chrono>
class Sender {
std::atomic<uint32_t> state_{0};
static constexpr uint32_t STOP_BIT = 1u << 31;
static constexpr uint32_t COUNT_MASK = STOP_BIT - 1;
public:
bool try_send() {
uint32_t old = state_.fetch_add(1, std::memory_order_acq_rel);
if (old & STOP_BIT) {
state_.fetch_sub(1, std::memory_order_relaxed);
return false;
}
// 模拟发送操作(耗时)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
state_.fetch_sub(1, std::memory_order_release);
return true;
}
void stop() {
uint32_t old = state_.fetch_or(STOP_BIT, std::memory_order_acq_rel);
// 等待所有发送完成
while ((state_.load(std::memory_order_acquire) & COUNT_MASK) != 0) {
std::this_thread::yield();
}
std::cout << "All sending completed, stopped.\n";
}
};
int main() {
Sender sender;
std::vector<std::thread> threads;
// 启动 10 个发送线程
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&sender] {
if (sender.try_send()) {
std::cout << "Send succeeded\n";
} else {
std::cout << "Send rejected (stopped)\n";
}
});
}
// 等待一小段时间,让部分发送线程开始
std::this_thread::sleep_for(std::chrono::milliseconds(5));
// 停止发送器
sender.stop();
// 等待所有线程结束
for (auto& t : threads) {
t.join();
}
return 0;
}
竞态测试
为了验证实现的正确性,我们需要进行更严格的并发测试。以下测试将:
- 启动多个发送线程,每个线程尝试发送多次。
- 在某个时间点启动停止线程。
- 使用原子计数器记录成功发送的次数,并验证停止后的状态。
测试代码
cpp
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
#include <chrono>
#include <cassert>
#include <random>
class Sender {
std::atomic<uint32_t> state_{0};
static constexpr uint32_t STOP_BIT = 1u << 31;
static constexpr uint32_t COUNT_MASK = STOP_BIT - 1;
public:
bool try_send() {
uint32_t old = state_.fetch_add(1, std::memory_order_acq_rel);
if (old & STOP_BIT) {
state_.fetch_sub(1, std::memory_order_relaxed);
return false;
}
// 模拟发送操作(随机耗时)
std::this_thread::sleep_for(std::chrono::microseconds(rand() % 100));
state_.fetch_sub(1, std::memory_order_release);
return true;
}
void stop() {
uint32_t old = state_.fetch_or(STOP_BIT, std::memory_order_acq_rel);
while ((state_.load(std::memory_order_acquire) & COUNT_MASK) != 0) {
std::this_thread::yield();
}
}
// 用于测试:检查停止标志和计数
bool is_stopped() const {
return (state_.load(std::memory_order_relaxed) & STOP_BIT) != 0;
}
uint32_t active_count() const {
return state_.load(std::memory_order_relaxed) & COUNT_MASK;
}
};
int main() {
constexpr int NUM_SENDERS = 10;
constexpr int SENDS_PER_THREAD = 1000;
Sender sender;
std::vector<std::thread> threads;
std::atomic<int> success_count{0};
std::atomic<int> reject_count{0};
// 启动发送线程
for (int i = 0; i < NUM_SENDERS; ++i) {
threads.emplace_back([&sender, &success_count, &reject_count] {
for (int j = 0; j < SENDS_PER_THREAD; ++j) {
if (sender.try_send()) {
success_count.fetch_add(1, std::memory_order_relaxed);
} else {
reject_count.fetch_add(1, std::memory_order_relaxed);
}
}
});
}
// 让发送线程运行一段时间
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 停止
sender.stop();
// 等待所有发送线程结束
for (auto& t : threads) {
t.join();
}
// 验证最终状态
std::cout << "Success count: " << success_count.load() << std::endl;
std::cout << "Reject count: " << reject_count.load() << std::endl;
std::cout << "Active count after stop: " << sender.active_count() << std::endl;
std::cout << "Stop flag: " << sender.is_stopped() << std::endl;
// 断言:停止后没有活跃计数
assert(sender.active_count() == 0);
assert(sender.is_stopped() == true);
// 可选:再尝试发送,应该被拒绝
assert(sender.try_send() == false);
return 0;
}
说明
- 10个发送线程,每个发送1000次,总发送尝试次数为10000。
- 在发送过程中随机延迟,模拟真实负载。
- 停止线程在100ms后调用
stop(),等待所有活跃发送完成。 - 使用
success_count和reject_count分别记录成功和失败的发送次数。 - 最终检查:计数应为0,停止标志为true,且再尝试发送会失败。
运行多次,观察输出,应该始终满足断言条件,证明实现正确。
性能
- 无锁 :所有操作均为原子指令(
fetch_add、fetch_sub、fetch_or),在多核 CPU 上通常比互斥锁快很多,尤其是在高并发时。 - 内存开销:仅使用一个 32 位原子变量,非常轻量。
- 扩展性:发送线程之间完全并行,没有共享锁竞争。停止线程只在设置停止标志和自旋等待时短暂活跃。
- 自旋等待 :使用
yield()避免了忙等,但停止线程仍会消耗少量 CPU 时间。如果停止操作非常罕见,这种开销可以忽略。
注意
- 计数溢出 :低31位的最大值为
(1<<31)-1,约21亿。如果并发发送数超过这个值,计数会溢出到停止位,导致逻辑错误。实际应用中,并发发送数通常远小于此,所以安全。若需要更大计数,可改用uint64_t。 - 内存序选择 :错误的内存序可能导致可见性问题。例如,若发送线程的
fetch_add使用relaxed,停止线程可能看不到发送线程对共享资源的写入,造成数据竞争。务必理解每种内存序的含义。 - 自旋等待的公平性 :如果停止线程长时间等待(例如发送操作耗时很长),
yield()会不断让出 CPU,但该线程仍会频繁被调度,增加系统开销。此时可考虑使用条件变量与原子变量结合,但会引入锁,降低无锁性。 - 异常安全:务必确保计数在任何退出路径下都被正确递减,推荐使用 RAII。
- 多次停止 :
stop()方法的设计允许多次调用,每次调用都会等待计数归零,确保资源安全。如果希望只有第一次调用有效,可以增加额外的状态,但通常不需要。
标注
本文介绍了一种在 C++ 中实现"单停、多发送"场景的无锁编程技术。通过将原子变量拆分为停止标志和发送计数,并合理使用内存序,我们成功地在不使用互斥锁的情况下保证了线程安全。该方案性能优异,实现简洁,适用于网络框架、线程池、消息队列等需要高效停止机制的场景。
无锁编程虽然强大,但也需要开发者对原子操作和内存模型有深入理解。希望本文能为您的并发编程实践提供有价值的参考。
参考:
- C++ 标准库文档:
std::atomic - 《C++ Concurrency in Action》by Anthony Williams
- Intel 开发者手册:内存屏障与原子操作