Bạn khai một struct với năm trường có tổng kích thước 15 byte. Nó chiếm bao nhiêu bộ nhớ? Trực giác nói 15 (hoặc làm tròn lên 16). Đo ra: 32 — hơn gấp đôi. Và điều bất ngờ hơn: chỉ cần sắp lại thứ tự đúng năm trường đó, kích thước rơi về 16. Bài này đo cách trình biên dịch bố trí struct trong bộ nhớ, và vì sao sizeof không bằng tổng các trường — một điều tôi đã đoán sai.

Căn lề và đệm struct

Căn lề buộc trình dịch chèn đệm

Mỗi kiểu dữ liệu có một yêu cầu căn lề (alignment): char căn về 1 (đặt ở đâu cũng được), int căn về 4 (phải nằm ở địa chỉ chia hết cho 4), double và con trỏ căn về 8. Yêu cầu này đến từ phần cứng — CPU đọc một int hiệu quả nhất (đôi khi chỉ đọc được) khi nó nằm ở địa chỉ thẳng hàng. Bộ nhớ được truy cập theo từng khối thẳng hàng (thường 8 byte một lần trên máy 64-bit), nên một giá trị nằm gọn trong một khối chỉ tốn một lần đọc, còn một giá trị vắt qua ranh giới khối tốn hai lần — căn lề chính là cách tránh chuyện đó. Khi bố trí một struct, trình biên dịch phải đặt mỗi trường ở một offset là bội của căn lề của trường đó; nếu trường trước kết thúc ở một offset không thẳng hàng, nó chèn vài byte đệm (padding) vào giữa.

Ngoài ra, tổng kích thước struct được đệm lên thành bội của căn lề lớn nhất trong struct, để khi bạn có một mảng struct, phần tử thứ hai cũng bắt đầu ở địa chỉ thẳng hàng. Hai loại đệm này — giữa các trường và ở cuối — khiến sizeof(struct) gần như không bao giờ bằng tổng kích thước các trường. Tôi đo để thấy rõ.

Đo: cùng trường, khác thứ tự, khác kích thước

Bắt đầu với struct { char a; int b; char c; }:

sizeof = 12   (offset a=0, b=4, c=8)

Ba trường tổng cộng 6 byte, nhưng sizeof12. Nhìn offset là hiểu: a ở offset 0 (1 byte), rồi bint phải căn về 4 nên nhảy tới offset 4 — 3 byte đệm chèn vào giữa ab. c ở offset 8. Rồi struct đệm từ 9 lên 12 (bội của 4). Giờ sắp lại struct { int b; char a; char c; }:

sizeof = 8   (offset b=0, a=4, c=5)

Cùng ba trường, nhưng đặt int (lớn nhất) trước: b ở 0-3, a ở 4, c ở 5, đệm 2 byte cuối lên 8. Nhỏ hơn 33% chỉ nhờ đổi thứ tự. Và #pragma pack(1) (ép bỏ mọi đệm) cho sizeof = 6 — sát tổng thật, nhưng đánh đổi bằng truy cập lệch. Con số 6 (thay vì 12) cho thấy một nửa struct "bad" ban đầu chỉ là đệm — một sự lãng phí lặng lẽ mà ít ai để ý khi khai struct. Rồi tôi thử một struct thực tế hơn, và đoán sai.

Một lần tôi đo hớ: 15 byte trường mà chiếm 32

Tôi dựng struct mixed { char f1; double f2; char f3; int f4; char f5; } — trộn nhiều kiểu, đúng kiểu struct người ta hay viết theo trình tự "nghĩ ra trường nào ghi trường đó". Tổng kích thước các trường là 1 + 8 + 1 + 4 + 1 = 15 byte. Tôi đoán sizeof khoảng 15, cùng lắm làm tròn lên 16. Đo ra:

sizeof(struct mixed) = 32

32 — hơn gấp đôi tổng thật. Vì f2double (căn về 8), sau f1 (1 byte) phải chèn 7 byte đệm để f2 nằm ở offset 8; rồi f3, f4 cũng cần đệm để căn lề; và struct đệm cuối lên bội của 8. Đệm ăn hết 17 byte "vô hình" cho 15 byte dữ liệu thật. Sắp lại năm trường đó theo thứ tự lớn tới nhỏstruct { double f2; int f4; char f1, f3, f5; }:

sizeof = 16

Đúng năm trường, không đổi một chút ngữ nghĩa nào, mà kích thước giảm một nửa (32 → 16). Bài học đo lường: sizeof một struct không phải tổng kích thước các trường, mà là tổng cộng thêm đệm căn lề — và lượng đệm phụ thuộc THỨ TỰ khai báo trường, không chỉ phụ thuộc tập trường. Tôi đã đem trực giác "cộng các phần lại" áp lên một bố cục mà căn lề chèn những khoảng trống vô hình vào giữa. Chỉ khi in sizeofoffsetof mới thấy các byte đệm đó — chúng không xuất hiện trong mã nguồn, chỉ hiện ra trong bố cục bộ nhớ thật.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên, thực dụng nhất: sắp trường struct từ lớn tới nhỏ để bớt đệm. Đặt double/con trỏ trước, rồi int, rồi short, rồi char — cách sắp này gom các trường nhỏ lại cuối, giảm đệm giữa các trường. Quy tắc ngón tay cái: sắp giảm dần theo căn lề thì đệm giữa các trường gần như biến mất, chỉ còn (nếu có) một chút đệm ở cuối. Với một struct dùng vài lần thì 32 hay 16 byte không quan trọng; nhưng với một mảng hàng triệu struct (bảng bản ghi, hạt trong game, điểm dữ liệu), giảm một nửa kích thước nghĩa là một nửa bộ nhớ và — quan trọng hơn — gấp đôi số struct vừa trong một dòng cache, tức nhanh hơn đáng kể khi duyệt.

Hệ quả thứ hai là cẩn thận với #pragma pack(1) và struct "packed". Bỏ đệm làm struct nhỏ nhất có thể, hữu ích khi bạn cần bố cục khớp chính xác một định dạng nhị phân (header giao thức mạng, cấu trúc file trên đĩa). Nhưng nó khiến các trường nằm lệch căn lề, và truy cập một int lệch có thể chậm (trên x86, CPU âm thầm làm hai lần đọc) hoặc crash (trên một số kiến trúc ARM/MIPS cũ đòi căn lề nghiêm ngặt). Chỉ pack khi thật cần khớp bố cục ngoài; đừng pack chỉ để tiết kiệm vài byte trong bộ nhớ.

Hệ quả thứ ba là đọc sizeofoffsetof để hiểu bố cục thật, đừng đoán. Khi một struct to hơn bạn tưởng, in sizeofoffsetof từng trường ra là thấy ngay các byte đệm nằm đâu. Con số mang theo: sizeof(struct) = tổng các trường CỘNG đệm căn lề; căn lề buộc mỗi trường nằm ở offset là bội của căn lề của nó và đệm struct lên bội của căn lề lớn nhất; nên cùng một tập trường, thứ tự lớn->nhỏ cho struct nhỏ hơn (đo được 32 xuống 16) — và pack(1) bỏ đệm được nhưng đổi bằng truy cập lệch. Bố cục struct là một thứ vô hình trong mã nguồn nhưng rất thật trong bộ nhớ; một dòng đổi thứ tự trường có thể cắt một nửa dung lượng của cả một mảng khổng lồ.

Thử ba mươi giây

Khai struct A { char a; double b; char c; }; và in sizeof(struct A) — bạn sẽ thấy 24 (không phải 10). Rồi khai struct B { double b; char a; char c; }; với đúng ba trường đó và in sizeof — 16. Cuối cùng thêm #include <stddef.h> và in offsetof(struct A, b) để thấy các byte đệm trước b. Chỉ vài dòng là bạn thấy tận mắt căn lề chèn đệm ra sao, và vì sao sắp trường lớn-tới-nhỏ là một mẹo tiết kiệm bộ nhớ gần như miễn phí.