2. C++性能基础:值语义与引用语义、栈与堆分配、虚函数开销、内存对齐与缓存行
各位同学,欢迎来到第二章。这一章我们聊点实在的——C++里那些直接影响延迟的底层机制。说白了,就是搞清楚你的代码到底是怎么在内存里跑起来的。
我见过太多人,写了几年C++,却连值传递和引用传递的区别都说不清楚。更别提什么缓存行、内存对齐了。但在低延迟交易系统里,这些细节就是你的命根子。一个字节的对齐错误,可能让你的延迟从微秒级跳到毫秒级。嗯,这不是夸张。
2.1 值语义 vs 引用语义
先问个问题:你写函数参数时,习惯用 T 还是 T&?
这背后就是值语义和引用语义的区别。值语义,就是拷贝。你传一个对象进去,函数里拿到的是它的副本。引用语义,就是别名。你传一个引用进去,函数里操作的是原来的对象。
我个人的习惯是:对于小对象(比如int、double、指针),用值传递。对于大对象(比如一个包含几百个字段的Order结构体),用const引用传递。为什么?因为拷贝有开销。
核心原则:值语义保证独立性,引用语义避免拷贝。在低延迟场景下,优先考虑引用语义来减少不必要的内存操作。
来看个例子:
// 值语义:每次调用都会拷贝整个Order对象
void processOrder(Order order) {
// 处理订单...
}
// 引用语义:只传递一个指针大小的引用
void processOrder(const Order& order) {
// 处理订单...
}
你想想看,如果Order有100个字段,每次调用都拷贝一遍,那延迟能不高吗?
我的经验:在交易引擎的核心路径上,我几乎只用引用语义。只有在需要修改副本且不影响原对象时,才用值语义。
2.2 栈与堆分配
接下来聊内存分配。栈和堆,这两个概念你肯定不陌生。但它们的性能差异,你可能没真正体会过。
栈分配,说白了就是移动一下栈指针。一条指令,几纳秒的事。堆分配呢?需要调用malloc,需要查找空闲内存块,可能需要系统调用。几十纳秒到几微秒不等。
为什么会这样?因为栈是线程私有的,分配和释放都是自动的。堆是全局共享的,需要加锁、需要管理碎片。
我曾在项目中遇到过一个问题:一个高频交易策略,每次收到行情都要new一个对象。结果呢?延迟抖动特别大,有时候几十微秒,有时候几百微秒。后来改成栈分配,延迟稳定在个位数微秒。
避坑指南:我曾经在核心路径上用了std::vector,以为它是在栈上分配的。结果一查,vector的数据是在堆上分配的。嗯,那一次教训很深刻。
来看个对比:
// 堆分配:慢,且可能产生碎片
Order* order = new Order();
// ... 使用 ...
delete order;
// 栈分配:快,自动管理
Order order;
// ... 使用 ...
// 离开作用域自动释放
在低延迟场景下,我的建议是:能用栈就别用堆。如果非要用堆,用对象池或者预分配内存池。
2.3 虚函数开销
虚函数,C++面向对象的核心特性。但你知道吗?虚函数是有代价的。
这个代价来自两个方面:一是虚函数表(vtable)的查找,二是虚函数通常不能被内联。每次调用虚函数,都要通过对象的虚指针找到虚函数表,再从表中找到函数地址。这比直接调用多了一次间接寻址。
我个人的习惯是:在性能关键路径上,尽量避免虚函数。用模板、用std::function、或者直接用if-else分支。
来看个例子:
// 虚函数版本
class OrderHandler {
public:
virtual void handle(const Order& order) = 0;
};
// 模板版本
template<typename Handler>
void processOrder(const Order& order, Handler& handler) {
handler.handle(order);
}
模板版本在编译时就确定了函数调用,没有虚函数开销。当然,代价是代码膨胀。但为了性能,这点代价值得。
性能数据:虚函数调用比普通函数调用慢大约10-20纳秒。在单次调用中微不足道,但在每秒百万次调用的交易引擎中,这就是毫秒级的差距。
2.4 内存对齐与缓存行
最后聊一个很多人忽略的话题:内存对齐和缓存行。
先说说内存对齐。CPU读取内存不是按字节读的,而是按字(通常是4字节或8字节)读的。如果你的数据没有对齐,CPU就需要多次读取,然后拼接。这很慢。
我曾在项目中遇到过一个问题:一个结构体里的字段顺序没排好,导致某些字段跨缓存行。结果呢?每次访问那个字段,都要加载两个缓存行。延迟直接翻倍。
来看个例子:
// 不好的对齐:浪费空间,可能跨缓存行
struct BadOrder {
char type; // 1字节
int64_t id; // 8字节,需要对齐到8字节边界
double price; // 8字节
char symbol[8]; // 8字节
};
// sizeof(BadOrder) 可能是 32 字节,有7字节的填充
// 好的对齐:按大小降序排列
struct GoodOrder {
int64_t id; // 8字节
double price; // 8字节
char symbol[8]; // 8字节
char type; // 1字节
};
// sizeof(GoodOrder) 可能是 25 字节,只有3字节的填充
再说缓存行。现代CPU的缓存行通常是64字节。也就是说,CPU一次从内存加载64字节到缓存。如果你的数据分散在不同的缓存行里,访问它们就需要多次加载。
我个人的习惯是:把经常一起访问的字段放在同一个缓存行里。把不常访问的字段放在另一个缓存行里。这样可以最大化缓存命中率。
我的经验:在交易引擎中,我会用 alignas(64) 来强制结构体对齐到缓存行边界。这样能保证一个结构体不会跨两个缓存行。
// 强制对齐到缓存行边界
struct alignas(64) CacheLineAlignedOrder {
int64_t id;
double price;
char symbol[8];
char type;
};
知识体系总览
下面这张图,是我画的本章节知识体系。你可以看到,值语义、栈堆分配、虚函数、内存对齐,这四块内容最终都指向同一个目标:降低延迟。
好了,这一章的内容就到这里。记住,性能优化不是玄学,而是对底层机制的深刻理解。你掌握了这些基础,后面的章节才能游刃有余。