跳至内容

内存布局、对齐与对象表示

理解结构体填充和对齐要求,优化成员排序以减小大小,了解缓存行和伪共享,以及使用 C++20 特性如 [[no_unique_address]] 实现紧凑布局。

对齐要求

C++ 中的每个类型都有一个对齐要求 ── 该类型对象必须放置的地址必须是其对齐值的倍数。这是硬件要求:大多数 CPU 无法高效访问未对齐的数据,某些架构(ARM、旧版 SPARC)会在未对齐访问时产生故障。

x86-64 上的常见对齐值:
- char:1 字节
- short:2 字节
- int、float:4 字节
- double、long long、指针:8 字节
- long double:16 字节(在某些平台上)

结构体的对齐值是其任何成员的最大对齐值。编译器在成员之间和结构体末尾插入填充字节以满足对齐要求。

结构体填充实践

编译器插入不可见的填充以对齐每个成员。成员排序可以显著影响结构体大小。

struct_padding.cpp
#include <iostream>
#include <cstddef>  // offsetof

// BAD: wasteful ordering — 24 bytes
struct BadLayout {
    char a;       // offset 0, size 1
    // 7 bytes padding (align double to 8)
    double b;     // offset 8, size 8
    char c;       // offset 16, size 1
    // 3 bytes padding (align int to 4)
    int d;        // offset 20, size 4
    // 0 bytes tail padding
};  // total: 24 bytes

// GOOD: optimized ordering — 16 bytes
struct GoodLayout {
    double b;     // offset 0, size 8
    int d;        // offset 8, size 4
    char a;       // offset 12, size 1
    char c;       // offset 13, size 1
    // 2 bytes tail padding (struct alignment = 8)
};  // total: 16 bytes

int main() {
    std::cout << "sizeof(BadLayout):  " << sizeof(BadLayout)  << '\n'; // 24
    std::cout << "sizeof(GoodLayout): " << sizeof(GoodLayout) << '\n'; // 16

    // Verify offsets
    std::cout << "BadLayout::a offset: " << offsetof(BadLayout, a) << '\n';  // 0
    std::cout << "BadLayout::b offset: " << offsetof(BadLayout, b) << '\n';  // 8
    std::cout << "BadLayout::c offset: " << offsetof(BadLayout, c) << '\n';  // 16
    std::cout << "BadLayout::d offset: " << offsetof(BadLayout, d) << '\n';  // 20

    // Savings: 33% smaller per object
    // With 1 million objects: 8 MB saved
}

alignas 与 alignof

alignof(T) 查询类型的对齐值。alignas(N) 请求变量或类型的特定对齐值。这些对于 SIMD、DMA 缓冲区和缓存行对齐至关重要。

alignas_alignof.cpp
#include <iostream>
#include <new>  // std::hardware_destructive_interference_size

// Query alignment
static_assert(alignof(int) == 4);
static_assert(alignof(double) == 8);

// Force alignment
struct alignas(64) CacheAligned {
    int data[16];  // 64 bytes of data on a 64-byte boundary
};

static_assert(alignof(CacheAligned) == 64);
static_assert(sizeof(CacheAligned) == 64);

// Align a variable
alignas(256) char simd_buffer[256];

// Over-aligned allocation (C++17)
struct alignas(128) BigAlign {
    double values[8];
};

int main() {
    // C++17: hardware_destructive_interference_size
    // Minimum offset to avoid false sharing (typically 64 on x86)
    // NOTE: not available on all compilers
    // std::cout << std::hardware_destructive_interference_size << '\n';

    auto* p = new CacheAligned;  // C++17 guarantees aligned allocation
    std::cout << "alignof(CacheAligned): " << alignof(CacheAligned) << '\n';
    std::cout << "address: " << p << '\n';
    std::cout << "aligned? " << (reinterpret_cast<std::uintptr_t>(p) % 64 == 0
                                  ? "yes" : "no") << '\n';
    delete p;
}

缓存行与伪共享

现代 CPU 不是逐字节访问内存的 ── 它们加载整个缓存行(在 x86 上通常为 64 字节)。当两个线程修改恰好在同一缓存行上的变量时,硬件必须不断在核心之间使该行失效和重新加载。这称为伪共享,它可以将多线程性能降低 10-100 倍。

伪共享发生在以下情况:
- 两个线程写入不同的变量
- 这些变量在同一个 64 字节缓存行上
- CPU 的缓存一致性协议(MESI)导致不断的缓存行弹跳

修复方法:填充或对齐热变量到不同的缓存行。使用 alignas(64) 或 std::hardware_destructive_interference_size(C++17,但并非所有编译器都支持)。

相反,真共享是指多个相关变量一起被访问。将它们放在同一缓存行上可以提高性能 ── 这就是 std::hardware_constructive_interference_size。

伪共享示例与修复

这个示例展示了伪共享:同一缓存行上的两个计数器在被不同线程递增时会导致巨大的性能下降。

false_sharing.cpp
#include <atomic>
#include <thread>
#include <chrono>
#include <iostream>

constexpr int ITERATIONS = 100'000'000;

// BAD: counters on the same cache line — false sharing
struct BadCounters {
    std::atomic<int> counter1{0};
    std::atomic<int> counter2{0};  // likely on same 64-byte cache line
};

// GOOD: each counter on its own cache line
struct GoodCounters {
    alignas(64) std::atomic<int> counter1{0};
    alignas(64) std::atomic<int> counter2{0};  // guaranteed separate cache lines
};

template <typename Counters>
void benchmark(const char* label) {
    Counters c;
    auto start = std::chrono::high_resolution_clock::now();

    std::thread t1([&] { for (int i = 0; i < ITERATIONS; ++i) ++c.counter1; });
    std::thread t2([&] { for (int i = 0; i < ITERATIONS; ++i) ++c.counter2; });
    t1.join();
    t2.join();

    auto elapsed = std::chrono::high_resolution_clock::now() - start;
    std::cout << label << ": "
              << std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count()
              << " ms\n";
}

int main() {
    std::cout << "sizeof(BadCounters):  " << sizeof(BadCounters) << '\n';   // 8
    std::cout << "sizeof(GoodCounters): " << sizeof(GoodCounters) << '\n';  // 128
    benchmark<BadCounters>("False sharing  ");
    benchmark<GoodCounters>("No false sharing");
}

位字段与 [[no_unique_address]](C++20)

位字段指定成员占用的精确位数 ── 对硬件寄存器和协议头部很有用,但其布局是实现定义的。[[no_unique_address]](C++20)允许空成员(无状态删除器、分配器)通过与其他成员重叠来占用零字节,取代了旧的空基类优化模式。

bit_fields_no_unique_address.cpp
#include <iostream>
#include <cstdint>

// Bit fields: pack flags tightly
struct TCPFlags {
    uint8_t fin : 1;
    uint8_t syn : 1;
    uint8_t rst : 1;
    uint8_t psh : 1;
    uint8_t ack : 1;
    uint8_t urg : 1;
    uint8_t ece : 1;
    uint8_t cwr : 1;
};
static_assert(sizeof(TCPFlags) == 1);  // 8 flags in 1 byte

// [[no_unique_address]]: eliminate empty member overhead
struct Empty {};
struct Before { int x; Empty e; };           // sizeof == 8 (padding)
struct After  { int x; [[no_unique_address]] Empty e; }; // sizeof == 4
static_assert(sizeof(Before) == 8);
static_assert(sizeof(After) == 4);

int main() {
    TCPFlags tcp{};
    tcp.syn = 1;
    tcp.ack = 1;
    std::cout << "SYN: " << tcp.syn << ", ACK: " << tcp.ack << '\n';
    // Cannot take address of bit field: &tcp.syn is a compiler error

    std::cout << "Before: " << sizeof(Before) << '\n';  // 8
    std::cout << "After:  " << sizeof(After)  << '\n';  // 4
}
常见陷阱

1. 永远不要使用 memcpy 跨平台序列化结构体 ── 填充和成员对齐在不同编译器和架构之间不同。

2. 位字段的可移植性:字节内位的顺序和位字段之间的填充是实现定义的。不要对跨平台线格式使用位字段 ── 使用显式位操作。

3. #pragma pack 是非标准的:压缩结构体以移除填充可能导致未对齐访问(在严格对齐架构如 ARM 上造成性能损失或崩溃)。

4. 按从大到小排列成员:double、指针(8 字节),然后 int(4 字节),然后 short(2 字节),然后 char(1 字节)。这最小化了内部填充。对于热点结构体,使用 static_assert(sizeof(MyStruct) <= 64) 来强制缓存行友好的大小。

关键要点
  • 每个类型都有对齐要求 ── 编译器插入填充以满足它
  • 按从大到小的对齐值排列成员以最小化填充
  • x86 上缓存行为 64 字节 ── 线程之间的伪共享是主要的性能陷阱
  • 使用 alignas(64) 将每线程数据放在不同的缓存行上
  • [[no_unique_address]](C++20)消除了空成员的开销
  • 位字段紧密打包数据但布局是实现定义的 ── 不要用于线格式

测验 — 检验你的知识

(15 经验值)

1. 给定 `struct S { char a; double b; char c; };`,在 64 位平台上 sizeof(S) 可能是多少?

2. 什么是伪共享?

3. `[[no_unique_address]]`(C++20)有什么作用?