Go là ngôn ngữ an toàn kiểu và có bộ thu gom rác — bạn không thao tác con trỏ thô như C. Nhưng có một cánh cửa hậu: package unsafe. Nó cho phép chuyển đổi giữa các kiểu con trỏ tùy ý, đọc lại byte của một giá trị dưới kiểu khác, làm số học địa chỉ. Sức mạnh này đi kèm cạm bẫy chết người: chỉ vài mẫu chuyển đổi được ngôn ngữ bảo đảm hợp lệ; đi chệch khỏi chúng là hành vi không xác định, và bug có thể chỉ lộ ra khi GC tình cờ dời một đối tượng. Bài này đo thật các mẫu hợp lệ, chỉ ra mẫu sai mà go vet bắt được, và cái giá trị thật sự khiến người ta chấp nhận rủi ro này.

Ba mẫu chuyển đổi cốt lõi

unsafe.Pointer là kiểu con trỏ "vạn năng": bất kỳ *T nào chuyển sang unsafe.Pointer được, và ngược lại. Tài liệu unsafe liệt kê một số mẫu hợp lệ; ba mẫu quan trọng nhất:

Mẫu 1 — chuyển *T1 thành *T2 khi bố cục bộ nhớ tương đương. Đây là "đọc lại" (reinterpret) cùng vùng byte dưới kiểu khác:

var f float64 = 3.14
bits := *(*uint64)(unsafe.Pointer(&f)) // đọc bit của float64 như uint64

Mẫu 2 — Pointer → uintptr → Pointer, chỉ trong lời gọi syscall. uintptr là số nguyên giữ giá trị địa chỉ, cần để truyền cho syscall.Syscall. Nhưng nó có ràng buộc chặt (nói ở dưới).

Mẫu 3 — số học offset để truy cập trường/phần tử. Điểm sống còn: toàn bộ phép tính phải nằm gọn trong một biểu thức:

// ĐÚNG: chuyển đổi trọn vẹn trong một biểu thức
pY := (*int32)(unsafe.Pointer(
	uintptr(unsafe.Pointer(&d)) + unsafe.Offsetof(d.Y)))

Ảnh chụp đoạn mã Go nền tối minh hoạ unsafe Pointer bốn mẫu chuyển đổi hợp lệ mà runtime bảo đảm, mẫu 1 và 2 đọc lại bit và Pointer uintptr cho syscall, mẫu 1 chuyển sao T1 sang sao T2 khi bố cục bộ nhớ tương đương var f float64 3.14 bits bằng sao sao uint64 unsafe Pointer địa chỉ f đọc bit float như uint64, mẫu 2 Pointer sang uintptr sang Pointer chỉ khi gọi syscall syscall Syscall uintptr unsafe Pointer địa chỉ x, mẫu 3 số học offset phải trong một biểu thức đúng chuyển đổi trọn vẹn trong một biểu thức pY bằng sao int32 unsafe Pointer uintptr unsafe Pointer địa chỉ d cộng unsafe Offsetof d Y sai tách uintptr ra biến riêng u bằng uintptr unsafe Pointer địa chỉ d cộng unsafe Offsetof d Y p bằng sao int32 unsafe Pointer u GC dời d u chết, mẫu 4 helper mới an toàn hơn Go 1.17 và 1.20 sl bằng unsafe Slice địa chỉ arr 0 4 con trỏ cộng len sang slice s bằng unsafe String địa chỉ b 0 len b byte sang string 0 copy hằng biên dịch kích thước căn lề offset trường unsafe Sizeof d unsafe Alignof d unsafe Offsetof d Z, quy tắc vàng uintptr không được lưu vào biến rồi dùng lại nó chỉ là số GC không coi là con trỏ nên có thể dời đối tượng đi mọi phép uintptr phải nằm gọn trong cùng một biểu thức với Pointer go vet bắt được vi phạm possible misuse of unsafe Pointer

Hình 1: Bốn mẫu hợp lệ — reinterpret bit (*T1→*T2), Pointer↔uintptr cho syscall, số học offset trong một biểu thức, và helper unsafe.Slice/String. Quy tắc vàng: không lưu uintptr vào biến rồi dùng lại.

Mẫu 4: helper mới an toàn hơn

Go 1.17 thêm unsafe.Slice, Go 1.20 thêm unsafe.String/unsafe.StringData — các hàm tiện ích thay cho việc dựng reflect.SliceHeader thủ công (dễ sai và không an toàn với GC):

sl := unsafe.Slice(&arr[0], 4)      // con trỏ + độ dài -> []int32
s  := unsafe.String(&b[0], len(b))  // []byte -> string, KHÔNG sao chép

Cùng nhóm là ba hàm trả hằng số biên dịch: unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof. Đo thật (Go 1.23): tất cả các mẫu trên chạy đúng — float64 3.14 cho bits 0x40091eb851eb851f, đọc d.Y qua offset ra 20, Sizeof(Diem)=16 Alignof=8 Offsetof(Z)=8, unsafe.Slice và unsafe.String cho kết quả đúng. Và go vet sạch — nó công nhận đây là các mẫu hợp lệ.

Quy tắc sống còn: đừng lưu uintptr vào biến

Đây là bug kinh điển và nguy hiểm nhất khi dùng unsafe. uintptr chỉ là một số nguyên — không phải con trỏ. Bộ thu gom rác của Go không coi một uintptr là tham chiếu sống, nên nó có thể dời (hoặc thu hồi) đối tượng mà uintptr đang trỏ tới. Nếu bạn tách phép tính offset thành hai bước qua một biến trung gian:

u := uintptr(unsafe.Pointer(t)) + unsafe.Offsetof(t.B) // u là số
p := (*int64)(unsafe.Pointer(u))                       // u đã "chết"!

Giữa hai dòng đó, GC có thể chạy và dời t sang địa chỉ mới — khiến u trỏ vào vùng nhớ cũ, không còn hợp lệ. p giờ là con trỏ hỏng. Bug này rất khó tái hiện vì phụ thuộc thời điểm GC. May mắn là go vet bắt được:

Ảnh chụp bảng kết quả đo thật nền tối mẫu hợp lệ chạy sạch vet bắt mẫu sai 0 copy nhanh 136x, go run cộng go vet cộng go test bench Go 1.23 arm64 10 core, các mẫu hợp lệ chạy thật mẫu 1 float64 3.14 sang bits 0x40091eb851eb851f mẫu 3 đọc d Y qua offset bằng 20 số học offset đúng Sizeof Diem bằng 16 Alignof bằng 8 Offsetof Z bằng 8 unsafe Slice 1 2 3 4 unsafe String không sao chép Xin chào go vet mẫu hợp lệ sạch không cảnh báo, go vet bắt mẫu sai uintptr tách ra biến u bằng uintptr unsafe Pointer t cộng unsafe Offsetof t B p bằng sao int64 unsafe Pointer u go vet ba chấm bad go dòng 10 16 possible misuse of unsafe Pointer u là địa chỉ chết GC có thể dời t giữa hai dòng vet cứu, payoff unsafe String 0 copy vs string b sao chép 1 KB unsafe String không sao chép 0,7799 ns 0 byte 0 alloc string b sao chép an toàn 105,8 ns 1024 byte 1 alloc 0 copy nhanh hơn 136x và 0 cấp phát nó chia sẻ cùng backing array thay vì copy 1 KB đổi lại string phải bất biến sau đó, cốt lõi mẫu 1 sao T1 sang sao T2 khi bố cục tương đương đọc lại bit mẫu 2 Pointer sang uintptr sang Pointer chỉ trong lời gọi syscall mẫu 3 số học offset phải gọn trong một biểu thức mẫu 4 unsafe Slice String helper mới an toàn hơn quy tắc không lưu uintptr vào biến go vet bắt vi phạm

Hình 2: Các mẫu hợp lệ chạy sạch (offset đọc đúng d.Y=20), go vet không cảnh báo. Mẫu sai (uintptr tách ra biến) bị vet bắt: possible misuse of unsafe.Pointer. Payoff: unsafe.String (0,7799 ns, 0 cấp phát) so với string(b) (105,8 ns, 1024 B) — nhanh hơn ~136x.

Đo thật: go vet báo ./bad.go:10:16: possible misuse of unsafe.Pointer. Luôn chạy go vet khi dùng unsafe — nó là lưới an toàn quan trọng nhất bạn có.

Cái giá trị thật: chuyển đổi không sao chép

Vì sao chấp nhận rủi ro này? Câu trả lời là hiệu năng ở đường nóng. Chuyển []byte thành string bằng cách thường (string(b)) sao chép toàn bộ dữ liệu — vì string bất biến còn []byte thì không, Go phải tạo bản sao để bảo toàn tính bất biến. Với unsafe.String, bạn chia sẻ cùng backing array, không sao chép. Đo thật trên slice 1 KB:

  • unsafe.String (không sao chép): 0,7799 ns/op, 0 B, 0 cấp phát.
  • string(b) (sao chép an toàn): 105,8 ns/op, 1024 B, 1 cấp phát.

Nhanh hơn ~136 lần và 0 cấp phát. Với một máy chủ parse hàng triệu request, chuyển đổi []byte↔string không sao chép ở biên giới I/O tiết kiệm khổng lồ cả CPU lẫn áp lực GC. Đây là lý do các thư viện hiệu năng cao (JSON parser, router) dùng unsafe ở những chỗ nóng nhất.

Đánh đổi cần cân nhắc

unsafe.String đòi hỏi string phải bất biến sau đó. Vì nó chia sẻ backing array với []byte gốc, nếu bạn sửa slice sau khi tạo string, string cũng đổi theo — phá vỡ bất biến mà cả runtime lẫn code khác trông cậy. Chỉ dùng khi bạn chắc chắn []byte không bị sửa nữa (ví dụ buffer đọc-một-lần). Sai lầm ở đây cho bug cực khó lần.

Đừng dùng unsafe nếu không đo thấy nút cổ chai. unsafe phá vỡ mọi bảo đảm của Go: an toàn kiểu, an toàn bộ nhớ, tính di động (bố cục struct có thể khác giữa kiến trúc). Package unsafe cũng không được bảo đảm tương thích như phần còn lại của Go 1. Chỉ dùng sau khi profiling chỉ ra đúng chỗ, và cô lập nó vào một lớp nhỏ có test kỹ.

Kết quả unsafe.Sizeof/Alignof/Offsetof phụ thuộc kiến trúc. Chúng là hằng biên dịch nhưng giá trị đổi theo nền tảng (con trỏ 4 byte trên 32-bit, 8 byte trên 64-bit; padding khác nhau). Đừng hard-code offset — luôn tính qua unsafe.Offsetof để code đúng trên mọi kiến trúc.

Ba ý mang về

  1. Chỉ vài mẫu chuyển đổi unsafe.Pointer là hợp lệ và được bảo đảm: đọc lại bit (*T1→*T2 bố cục tương đương), Pointer↔uintptr cho syscall, số học offset trong một biểu thức, và helper mới unsafe.Slice/unsafe.String — đo thật tất cả chạy đúng và go vet sạch.
  2. Không bao giờ lưu uintptr vào biến rồi dùng lại: GC không coi uintptr là con trỏ nên có thể dời đối tượng, khiến địa chỉ "chết" — đo thật, go vet bắt mẫu sai với possible misuse of unsafe.Pointer, luôn chạy vet khi dùng unsafe.
  3. Cái giá trị thật là chuyển đổi không sao chép: unsafe.String (0,7799 ns, 0 cấp phát) nhanh hơn string(b) (105,8 ns, 1024 B) ~136 lần — nhưng đòi hỏi []byte bất biến sau đó, và chỉ nên dùng sau khi profiling chỉ ra nút cổ chai thật.

Phần sau ta rời tầng con trỏ để về với công cụ quản lý mã đa module: Phần sau mổ xẻ go.work — cách làm việc với nhiều module cùng lúc trong một workspace mà không cần replace thủ công.