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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho đoạn mã sau:

var f float64 = 3.14
bits := *(*uint64)(unsafe.Pointer(&f))

Đoạn mã này thuộc mẫu chuyển đổi unsafe.Pointer hợp lệ nào trong bốn mẫu đã học? bits chứa gì — giá trị 3.14 được ép sang uint64, hay điều gì khác?

Đáp án

Đây là Mẫu 1 — chuyển *T1 thành *T2 khi bố cục bộ nhớ tương đương (đọc lại/reinterpret cùng vùng byte dưới kiểu khác). bits không phải là 3.14 được ép kiểu số học sang uint64 (không phải uint64(3.14) = 3); nó là các bit thô biểu diễn số thực dấu phẩy động 3.14 theo chuẩn IEEE 754, đọc lại như một số nguyên 64-bit không dấu (theo bài, giá trị đó là 0x40091eb851eb851f).

Bài 2 (sửa lỗi). Đoạn mã sau vi phạm quy tắc sống còn của unsafe.Pointer. Xác định lỗi và sửa lại theo mẫu đúng.

u := uintptr(unsafe.Pointer(t)) + unsafe.Offsetof(t.B)
p := (*int64)(unsafe.Pointer(u))
Đáp án

Lỗi: tách phép tính offset thành hai bước qua biến trung gian u kiểu uintptr. uintptr chỉ là một số nguyên, không phải con trỏ — GC không coi nó là tham chiếu sống, nên có thể dời đối tượng t giữa hai dòng lệnh, khiến u trỏ tới địa chỉ cũ đã "chết". go vet sẽ báo possible misuse of unsafe.Pointer. Sửa lại bằng cách gộp toàn bộ phép tính vào một biểu thức duy nhất (Mẫu 3):

p := (*int64)(unsafe.Pointer(uintptr(unsafe.Pointer(t)) + unsafe.Offsetof(t.B)))

Vì toàn bộ phép chuyển đổi nằm gọn trong một biểu thức, không có "khoảng hở" nào để GC chen vào và dời t trước khi con trỏ cuối cùng được tạo ra.

Bài 3 (vận dụng thực tế). Bạn đang viết một hàm hiệu năng cao nhận []byte (một buffer đọc từ socket, không bao giờ sửa sau đó) và cần trả về dưới dạng string để truyền cho một API chỉ nhận string. Đề xuất cách dùng unsafe để tránh sao chép, và nêu điều kiện bắt buộc để làm điều này an toàn.

Đáp án

Dùng unsafe.String(&b[0], len(b)) (Mẫu 4, helper mới từ Go 1.20) để tạo string chia sẻ trực tiếp backing array của []byte, không sao chép — theo đo thật trong bài, cách này nhanh hơn string(b) khoảng 136 lần và không cấp phát. Điều kiện bắt buộc: phải chắc chắn []byte gốc không bị sửa đổi sau khi tạo string, vì string trong Go được coi là bất biến — nếu b bị ghi đè sau đó, string cũng "đổi theo" một cách âm thầm, phá vỡ giả định bất biến mà runtime và code khác dựa vào, gây ra bug cực khó lần ra.

Bài 4 (đọc số liệu đo thật). Bài đo unsafe.String (0,7799 ns, 0 cấp phát) so với string(b) (105,8 ns, 1024 byte, 1 cấp phát) cho slice 1 KB — nhanh hơn ~136 lần. Giải thích bằng cơ chế tại sao cách chuyển đổi thông thường string(b) lại chậm hơn nhiều và phải cấp phát.

Đáp án

Trong Go, string là bất biến còn []byte thì không (có thể bị sửa sau khi tạo). Vì vậy khi chuyển []byte sang string bằng cú pháp thông thường string(b), runtime phải sao chép toàn bộ dữ liệu sang một vùng nhớ mới để đảm bảo string tạo ra không bao giờ bị thay đổi bởi việc sửa slice gốc sau đó — đây chính là nguồn gốc của 1 lần cấp phát 1024 byte và chi phí sao chép 105,8 ns. unsafe.String bỏ qua bước sao chép này bằng cách trực tiếp diễn giải cùng vùng bộ nhớ của []byte như một string, không tạo bản sao — nhanh và không cấp phát, nhưng đánh đổi lấy trách nhiệm tự đảm bảo []byte gốc không bị sửa sau đó.

Bài 5 (bẫy/đánh đổi). Một lập trình viên viết code dùng unsafe.Offsetof để tính offset một trường trong struct, rồi hard-code con số đó (ví dụ ghi cứng 8) trong một hàm khác để tránh gọi lại unsafe.Offsetof. Nêu rủi ro của cách làm này theo bài học.

Đáp án

Rủi ro: kết quả của unsafe.Sizeof/Alignof/Offsetof phụ thuộc kiến trúc — dù chúng là hằng số được tính lúc biên dịch, giá trị cụ thể thay đổi theo nền tảng (ví dụ con trỏ chiếm 4 byte trên kiến trúc 32-bit nhưng 8 byte trên 64-bit; padding/căn lề cũng khác nhau giữa các kiến trúc). Hard-code một con số offset cụ thể sẽ làm code hoạt động đúng trên kiến trúc mà nó được viết/test, nhưng có thể sai (đọc nhầm vùng nhớ, gây hành vi không xác định) khi biên dịch cho một kiến trúc khác. Bài học khuyến nghị: "đừng hard-code offset — luôn tính qua unsafe.Offsetof để code đúng trên mọi kiến trúc."

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.