源码仓库: mccc-bus | 本文代码引用基于 mccc-bus v2.0.0
MCCC 的设计决策后来被 newosp 框架采纳并演化。
背景: C++14 版本留下的四个堆分配瓶颈
在 前篇 中,我们用 C++14 实现了一个正确的消息总线 (锁外回调、单 mutex、joinable 线程),但性能测试暴露了根本性瓶颈:
| 瓶颈 | C++14 实现 | 问题 |
|---|---|---|
| 回调存储 | std::function | SBO 仅 16B,超出则堆分配 |
| 消息路由 | std::map<int, vector> | O(log N) 查找,节点分散堆上 |
| 订阅管理 | shared_ptr<SubscriptionItem> | 原子引用计数,cache line bouncing |
| 数据容器 | std::string / std::vector | 动态分配,长度运行时才知道 |
多线程 (8 线程) 吞吐量仅 0.36 M/s,比单线程还低 36%。本文逐项展示 C++17 如何消除这些瓶颈,最终将吞吐量提升到 27-33 M/s。
一、std::function -> FixedFunction: 栈上类型擦除
1.1 问题: std::function 的隐式堆分配
C++14 版本的每次 publishMessage 都可能触发堆分配:
// C++14 版本 -- 两处潜在堆分配
std::vector<MessageCallback> pendingCallbacks; // vector 扩容
pendingCallbacks.push_back(item->messageCallback); // function 拷贝
std::function 的 SBO (Small Buffer Optimization) 阈值在 libstdc++ 中仅 16 字节。一个捕获了 this 加两个成员变量的 lambda 就可能超出,静默触发 malloc。
1.2 方案: FixedFunction 编译期容量保证
mccc-bus 实现了 FixedFunction<Sig, Capacity>,将 SBO 容量提升到 64 字节,超容量在编译期直接拒绝:
// mccc.hpp -- FixedFunction 核心结构
template <typename Sig, uint32_t Capacity = 64U>
class FixedFunction;
template <typename R, typename... Args, uint32_t Capacity>
class FixedFunction<R(Args...), Capacity> {
// 栈上存储,永不堆分配
alignas(std::max_align_t) uint8_t storage_[Capacity]{};
// 函数指针三元组替代虚函数表
using InvokeFn = R (*)(void*, Args&&...);
using DestroyFn = void (*)(void*);
using MoveFn = void (*)(void*, void*);
InvokeFn invoke_fn_{nullptr};
DestroyFn destroy_fn_{nullptr};
MoveFn move_fn_{nullptr};
public:
template <typename F>
FixedFunction(F&& f) noexcept {
using Decayed = std::decay_t<F>;
// 编译期拒绝超容量 callable
static_assert(sizeof(Decayed) <= Capacity,
"Callable exceeds FixedFunction capacity");
static_assert(alignof(Decayed) <= alignof(std::max_align_t),
"Callable alignment exceeds max_align_t");
new (storage_) Decayed(std::forward<F>(f));
// ... 设置函数指针三元组
}
};
关键设计:
- 编译期容量检查:
static_assert(sizeof(Decayed) <= Capacity)确保永不堆分配 - 函数指针 Ops 表:
invoke/destroy/move三个函数指针替代虚基类,消除 vtable 间接寻址 -fno-exceptions兼容: 不依赖std::bad_function_call,空调用返回默认值
1.3 对比
| 特性 | std::function | FixedFunction<Sig, 64> |
|---|---|---|
| 堆分配 | 可能 (>16B) | 永不 |
| 超容量行为 | 运行时 malloc | 编译期报错 |
| 异常路径 | bad_function_call | 无 |
| 间接调用 | vtable | 函数指针 |
-fno-rtti | 不兼容 | 兼容 |
1.4 C++17 特性支撑
std::decay_t<F>(C++14 引入,C++17 广泛使用)if constexpr用于编译期分支选择不同的 invoke 路径std::invoke_result_t替代 C++14 的std::result_of
二、unordered_map -> VariantIndex + 固定数组
2.1 问题: 哈希表的不确定延迟
C++14 版本用 std::map (红黑树) 做消息路由:
// C++14 版本
std::map<int32_t, std::vector<SubscriptionItemPtr>> callbackMap_;
auto it = callbackMap_.find(messageId); // O(log N),节点分散堆上
即使换成 std::unordered_map,哈希冲突时仍退化为链表遍历,延迟不可预测。两者都依赖堆分配。
2.2 方案: 编译期类型索引 + std::array
mccc-bus 利用 std::variant 在编译期将类型映射为固定索引:
// 编译期递归: 将类型 T 映射为 variant 中的索引
template <typename T, size_t I, typename First, typename... Rest>
struct VariantIndexImpl<T, I, std::variant<First, Rest...>> {
static constexpr size_t value =
std::is_same_v<T, First>
? I
: VariantIndexImpl<T, I + 1U, std::variant<Rest...>>::value;
};
// 类型不在 variant 中 -> 编译失败
template <typename T, typename... Types>
struct VariantIndex<T, std::variant<Types...>> {
static constexpr size_t value =
detail::VariantIndexImpl<T, 0U, std::variant<Types...>>::value;
static_assert(value != static_cast<size_t>(-1),
"Type not found in PayloadVariant");
};
回调表从哈希表变为固定大小数组:
// 运行时分发退化为数组下标访问 -- O(1) 且完全确定
std::array<CallbackSlot, MCCC_MAX_MESSAGE_TYPES> callback_table_;
template <typename T, typename Func>
SubscriptionHandle Subscribe(Func&& func) {
constexpr size_t type_idx = VariantIndex<T, PayloadVariant>::value;
static_assert(type_idx < MCCC_MAX_MESSAGE_TYPES,
"Type index exceeds MCCC_MAX_MESSAGE_TYPES");
// callback_table_[type_idx] -- 一次数组下标访问
}
2.3 overloaded + std::visit: 分支遗漏编译期报错
std::variant 配合 std::visit 实现穷举检查:
template <class T, class... Ts>
struct overloaded<T, Ts...> : T, overloaded<Ts...> {
using T::operator();
using overloaded<Ts...>::operator();
explicit overloaded(T t, Ts... ts)
: T(std::move(t)), overloaded<Ts...>(std::move(ts)...) {}
};
新增消息类型后,所有 std::visit 点如果未补全分支,编译器直接拒绝。C++14 的 switch(messageId) 缺少 case 只是 -Wswitch 警告,不是错误。
2.4 对比
| 特性 | std::map / unordered_map | VariantIndex + std::array |
|---|---|---|
| 查找复杂度 | O(log N) / O(1) 平均 | O(1) 确定 |
| 堆分配 | 节点/桶分配 | 零 (栈上固定数组) |
| 类型安全 | 运行时 int key | 编译期类型索引 |
| 新增类型 | 运行时发现遗漏 | 编译期报错 |
三、shared_ptr -> Envelope 内嵌 Ring Buffer
3.1 问题: shared_ptr 的原子计数开销
C++14 版本用 shared_ptr 管理订阅生命周期:
// C++14 版本
using SubscriptionItemPtr = std::shared_ptr<SubscriptionItem>;
// 每次拷贝/销毁: atomic fetch_add/fetch_sub -> cache line bouncing
在高频发布路径上,shared_ptr 的拷贝和销毁产生大量原子操作,多核间的缓存行乒乓严重影响吞吐量。
3.2 方案: Envelope 直接内嵌到 Ring Buffer 槽位
mccc-bus 将消息封装 (MessageEnvelope) 直接内嵌到 Ring Buffer 中:
// 消息信封 -- 内嵌在 Ring Buffer 槽位中
template <typename PayloadVariant>
struct MessageEnvelope {
MessageHeader header; // ID, 时间戳, 优先级
PayloadVariant payload; // std::variant<SensorData, MotorCmd, ...>
// defaulted move,零拷贝发布
};
// Ring Buffer 槽位 -- envelope 直接内嵌,非指针
struct MCCC_ALIGN_CACHELINE RingBufferNode {
std::atomic<uint32_t> sequence{0U};
MessageEnvelope<PayloadVariant> envelope; // 内嵌,非 shared_ptr
};
发布路径零堆分配:
// 生产者直接写入预分配槽位
auto& node = ring_buffer_[prod_pos & (QueueDepth - 1U)];
node.envelope.payload = std::move(payload); // move 到预分配内存
node.sequence.store(prod_pos + 1U, std::memory_order_release);
3.3 对比
| 特性 | shared_ptr 管理 | Envelope 内嵌 |
|---|---|---|
| 每次发布的堆分配 | 1-2 次 (make_shared + vector 扩容) | 零 |
| 引用计数开销 | atomic fetch_add/sub | 无 |
| 数据局部性 | 指针追踪,缓存不友好 | 连续内存,Cache 友好 |
| 生命周期 | 运行时引用计数 | Ring Buffer 槽位复用 |
四、std::string/vector -> FixedString/FixedVector
4.1 问题: 动态容器在热路径上的堆分配
C++14 版本用标准容器存储消息数据:
// C++14 版本
std::vector<uint8_t> messageContent; // 堆分配
std::vector<int32_t> subscribedMessageIds; // 堆分配
每次构造和销毁都可能触发 malloc/free,在高频路径上不可接受。
4.2 方案: 编译期固定容量的栈上容器
mccc-bus 实现了 FixedString<N> 和 FixedVector<T, N>:
// FixedString -- 编译期字面量长度检查
template <uint32_t Capacity>
class FixedString {
char buf_[Capacity + 1U]{};
uint32_t size_{0U};
public:
// 模板参数 N 在编译期获取字符串字面量长度
template <uint32_t N,
typename = std::enable_if_t<(N <= Capacity + 1U)>>
FixedString(const char (&str)[N]) noexcept : size_(N - 1U) {
static_assert(N > 0U, "String literal must include null terminator");
static_assert(N - 1U <= Capacity, "String literal exceeds capacity");
std::memcpy(buf_, str, N);
}
};
// FixedVector -- 栈上固定容量
template <typename T, uint32_t Capacity>
class FixedVector {
alignas(T) uint8_t storage_[sizeof(T) * Capacity]{};
uint32_t size_{0U};
public:
bool push_back(const T& value) noexcept {
if (size_ >= Capacity) return false; // 容量满返回 false,不抛异常
new (&data()[size_]) T(value);
++size_;
return true;
}
// move 构造/赋值: defaulted
};
4.3 编译期长度检查的价值
FixedString 通过模板参数 N 在编译期获取字符串字面量的长度:
FixedString<8> topic("sensor"); // OK: 6 <= 8
FixedString<4> topic("sensor"); // 编译失败: "String literal exceeds capacity"
C 的 strncpy(buf, "sensor", sizeof(buf)) 在超长时静默截断,不报任何错误。
4.4 对比
| 特性 | std::string / std::vector | FixedString / FixedVector |
|---|---|---|
| 内存分配 | 堆 (SSO 仅 15-22B) | 栈 (编译期固定) |
| 超容量行为 | 运行时扩容或抛异常 | 编译期报错 / 返回 false |
| 类型安全 | FixedString<32> 和 <64> 是不同类型 | 不同容量可混用 |
| 拷贝优化 | 运行时 memcpy | 编译器已知长度,可替换为 mov 指令序列 |
五、C++17 特性在四项改造中的作用
上述四项改造不是孤立的替换,它们依赖 C++17 的几个关键特性协同工作:
5.1 std::variant -- 编译期类型路由的基础
std::variant (C++17) 替代 C++14 的 union + 手动标签:
// C++14: 手动标签 + union,运行时才发现类型错误
struct Message { int tag; union { SensorData s; MotorCmd m; }; };
// C++17: variant,编译期类型安全
using Payload = std::variant<SensorData, MotorCmd>;
// 访问错误类型 -> 编译期报错或运行时 bad_variant_access
5.2 if constexpr -- 编译期分支消除
FixedFunction 内部使用 if constexpr 选择不同的调用路径:
template <typename F>
void assign(F&& f) noexcept {
using Decayed = std::decay_t<F>;
if constexpr (std::is_trivially_copyable_v<Decayed>) {
std::memcpy(storage_, &f, sizeof(Decayed));
// trivially copyable: 不需要 destroy/move 函数
} else {
new (storage_) Decayed(std::forward<F>(f));
destroy_fn_ = &destroy_impl<Decayed>;
move_fn_ = &move_impl<Decayed>;
}
}
C++14 需要 SFINAE + 两个重载函数实现同样的效果,代码量翻倍。
5.3 std::is_same_v / std::enable_if_t -- 简化模板元编程
C++17 的变量模板和别名模板减少了样板代码:
// C++14
std::is_same<T, First>::value
typename std::enable_if<condition>::type
// C++17
std::is_same_v<T, First>
std::enable_if_t<condition>
5.4 enum class + static_assert -- 编译期约束
enum class MessagePriority : uint8_t { LOW, MEDIUM, HIGH };
enum class BusError : uint8_t { QUEUE_FULL, INVALID_MESSAGE };
// 禁止隐式转整型,禁止不同枚举混用
六、RAII 与所有权管理
C++17 的改造不仅是数据结构替换,还依赖 RAII 保证资源安全:
6.1 Component 自动退订
// component.hpp -- RAII 自动退订
virtual ~Component() {
for (const auto& handle : handles_) {
BusType::Instance().Unsubscribe(handle);
}
}
// 禁止拷贝
Component(const Component&) = delete;
Component& operator=(const Component&) = delete;
6.2 锁外析构防死锁
// mccc.hpp -- 锁外析构保证顺序
bool Unsubscribe(const SubscriptionHandle& handle) noexcept {
CallbackType old_callback; // 在锁外析构
{
std::unique_lock<std::shared_mutex> lock(callback_mutex_);
old_callback = std::move(slot.entries[i].callback);
}
// old_callback 在锁释放后才析构,避免析构函数内获锁导致死锁
return static_cast<bool>(old_callback);
}
七、性能实测: 四项改造的综合效果
测试环境: Ubuntu 24.04, Intel Xeon, GCC 13.3,
-O3 -march=native
| 指标 | C++14 mutex 版本 | MCCC (FULL) | MCCC (BARE) | 提升倍数 |
|---|---|---|---|---|
| 单线程吞吐量 | 0.56 M/s | 27.7 M/s | 33.0 M/s | 49-59x |
| 多线程吞吐量 (8T) | 0.36 M/s | 20.6 M/s | 31.1 M/s | 57-86x |
| 热路径堆分配 | 2-4 次/publish | 零 | 零 | -- |
| P50 延迟 | 不可预测 | 585 ns | -- | -- |
| P99 延迟 | 不可预测 | 933 ns | -- | -- |
多线程场景下 MCCC 吞吐量是 C++14 版本的 57-86 倍。四项改造的各自贡献:
| 改造 | 消除的瓶颈 | 估算收益 |
|---|---|---|
| FixedFunction | std::function 堆分配 | 每次 publish 省 1-2 次 malloc |
| VariantIndex + array | map 查找 + 堆节点 | O(log N) -> O(1),消除堆分配 |
| Envelope 内嵌 | shared_ptr 原子计数 | 消除 cache line bouncing |
| FixedString/Vector | 动态容器堆分配 | 全部栈上,编译器可优化 memcpy |
总结: 从 C++14 到 C++17 的演进路径
三篇文章构成了一条完整的演进路径:
| 阶段 | 文章 | 核心方案 | 吞吐量 |
|---|---|---|---|
| C++11 | 从零实现线程安全消息总线 | mutex + std::function + std::map | -- |
| C++14 | 工程优化与性能瓶颈分析 | 锁外回调 + 单 mutex + joinable 线程 | 0.36 M/s (8T) |
| C++17 | 本文 | FixedFunction + VariantIndex + Envelope 内嵌 | 31.1 M/s (8T) |
每一步都有明确的问题驱动:
- C++11 -> C++14: 解决正确性问题 (重入死锁、锁序、资源泄漏)
- C++14 -> C++17: 解决性能问题 (堆分配、锁竞争、缓存不友好)
C++17 提供的 std::variant、if constexpr、std::is_same_v 等特性,使得编译期类型路由、栈上类型擦除和固定容量容器成为可能。这些能力在 C++14 中要么无法实现 (std::variant),要么需要大量样板代码 (SFINAE 替代 if constexpr)。
延伸阅读
| 主题 | 文章 |
|---|---|
| 设计决策与架构 | Lock-free MPSC 消息总线的设计与实现 |
| 性能对比评测 | 6 个开源方案的吞吐量、延迟与嵌入式适配性对比 |
| API 参考文档 | MCCC 消息总线 API 全参考 |
文件索引
| 文件 | 行数 | 核心内容 |
|---|---|---|
include/mccc/mccc.hpp | 1097 | FixedString, FixedVector, FixedFunction, VariantIndex, AsyncBus |
include/mccc/component.hpp | 129 | Component RAII, SubscribeSafe/SubscribeSimple |
CMakeLists.txt | 40 | header-only INTERFACE library, C++17 |
代码仓库: mccc-bus | 前身项目: message_bus | 后继项目: newosp