String là kiểu ai cũng dùng hàng ngày, nhưng ít người biết nó là gì ở tầng bộ nhớ. Vì sao không sửa được s[0]? Vì sao []byte(s) bỗng tốn thời gian và bộ nhớ? Vì sao cắt chuỗi s[a:b] lại miễn phí? Mọi câu trả lời nằm ở hai đặc điểm: string là một header 16 byte và dữ liệu của nó bất biến. Bài này đo trực tiếp cấu trúc string và chi phí ẩn của việc chuyển đổi.
String header: hai từ máy, dữ liệu bất biến
String trên máy 64-bit là một struct 16 byte gồm hai từ máy:
ptr: con trỏ tới byte đầu tiên của dữ liệu chuỗi.len: số byte.
Chú ý: không có cap (khác slice có ba trường). Vì string bất biến — nó không bao giờ tăng trưởng tại chỗ, nên không cần cap. Dữ liệu của chuỗi literal nằm trong vùng nhớ chỉ-đọc (rodata) của chương trình, đó là lý do string bất biến ở tầng vật lý: bộ nhớ đó không cho ghi.

Hình 1: String header 16 byte {ptr, len}, không có cap. Dữ liệu bất biến ở vùng chỉ-đọc. Substring chia sẻ backing; chuyển string↔[]byte phải copy.
Đo thật: substring miễn phí, chuyển đổi tốn kém
Đo cấu trúc và chi phí ba thao tác: substring, string→[]byte, []byte→string.

Hình 2: Header = 16 byte. Substring s[7:13] có ptr lệch đúng 7 byte (chia sẻ backing). Substring: 0,73 ns, 0 cấp phát. Chuyển string→[]byte: 21,43 ns, 1 cấp phát; []byte→string: 13,75 ns, 1 cấp phát.
Kết quả cho thấy hai hành vi trái ngược:
- Substring miễn phí:
s[7:13]="Golang"cóptrlệch đúng 7 byte so vớis, chia sẻ cùng dữ liệu — chỉ tạo header 16 byte mới. Benchmark: 0,73 ns, 0 cấp phát. Cắt chuỗi không copy vì cả hai đều bất biến, chia sẻ an toàn. - Chuyển string↔[]byte phải copy:
[]byte(s)tốn 21,43 ns và 1 cấp phát,string(b)tốn 13,75 ns và 1 cấp phát. Mỗi lần chuyển phải cấp mảng mới và copy toàn bộ dữ liệu.
Vì sao chuyển đổi phải copy
Đây là điểm mấu chốt. string bất biến, []byte sửa được. Nếu []byte(s) chia sẻ dữ liệu với string thay vì copy, thì sửa []byte sẽ làm thay đổi string bất biến — phá vỡ đảm bảo an toàn cốt lõi của Go (string không bao giờ đổi). Nên Go luôn copy khi chuyển giữa hai kiểu, đảm bảo mỗi bên có bản riêng. Đây là chi phí ẩn của mọi lần []byte(s) hay string(b) — một cấp phát + một lần copy toàn bộ.
Ứng dụng thực tế
Tránh chuyển string↔[]byte trong vòng lặp nóng. Nếu bạn xử lý chuỗi trong vòng lặp và mỗi vòng làm []byte(s), đó là một cấp phát + copy mỗi vòng. Với đường nóng, giữ nguyên một kiểu (làm việc với []byte từ đầu, hoặc string từ đầu) tránh chuyển qua lại.
Cắt chuỗi thoải mái — nó miễn phí. s[a:b], strings.Split, và các thao tác cắt chuỗi chỉ tạo header mới, không copy. Xử lý văn bản bằng cách cắt substring rất hiệu quả. (Nhưng nhớ: substring giữ cả backing lớn sống — như slice, một substring nhỏ của chuỗi 1GB giữ cả 1GB.)
Nhiều thao tác chuỗi nhận string để dùng được cả literal. Vì string bất biến và literal là string, API nhận string (như strings.Contains) dùng được trực tiếp với "...". API nhận []byte (như bytes.Contains) cần chuyển. Chọn kiểu theo nguồn dữ liệu chính của bạn.
Đánh đổi cần cân nhắc
Bất biến đổi lấy an toàn và chia sẻ, trả giá bằng copy khi cần sửa. String bất biến nên chia sẻ vô tư (nhiều biến trỏ cùng dữ liệu, không lo ai sửa), truyền rẻ (16 byte), và an toàn đồng thời (đọc string từ nhiều goroutine không cần khóa). Đổi lại, khi cần sửa nội dung, phải chuyển sang []byte (copy), sửa, rồi chuyển lại (copy nữa).
Copy có thể tránh bằng unsafe — nhưng nguy hiểm. Có kỹ thuật chuyển string↔[]byte không copy bằng unsafe (chủ đề bài sau). Nó nhanh (0 cấp phát) nhưng phá vỡ đảm bảo bất biến — nếu sửa []byte "chia sẻ" với string, bạn làm hỏng string, gây bug khủng khiếp. Chỉ dùng khi chắc chắn không sửa.
String không phải lúc nào cũng ở rodata. Chuỗi literal ở vùng chỉ-đọc, nhưng string tạo lúc chạy (string(b), nối chuỗi) nằm trên heap. Chúng vẫn bất biến (Go đảm bảo qua ngôn ngữ), nhưng không phải vật lý read-only. Đừng dựa vào việc string luôn ở rodata.
Ba ý mang về
- String là header 16 byte {ptr, len} trỏ vào dữ liệu bất biến: không có cap vì string không tăng trưởng — dữ liệu literal ở vùng chỉ-đọc, đó là lý do vật lý của bất biến; không sửa được
s[0](lỗi biên dịch). - Substring miễn phí, chuyển string↔[]byte tốn kém: đo thật
s[7:13]chỉ tạo header mới chia sẻ backing (0,73 ns, 0 cấp phát), nhưng[]byte(s)/string(b)phải copy toàn bộ (21/14 ns, 1 cấp phát mỗi lần). - Chuyển đổi copy vì bất biến vs sửa được: string không đổi, []byte sửa được — chia sẻ sẽ để sửa []byte làm hỏng string, nên Go luôn copy; tránh chuyển đổi trong vòng lặp nóng, và cắt chuỗi thoải mái vì nó không copy.
Bài tập làm thử
Bài 1 (đọc hiểu). String header có hai trường ptr và len, nhưng khác slice header, nó không có trường cap. Vì sao string không cần cap?
Đáp án
Vì string bất biến — nó không bao giờ tăng trưởng tại chỗ (mọi thao tác "sửa đổi" chuỗi đều tạo ra một string mới), nên không cần một trường cap để theo dõi dung lượng còn thừa như slice.
Bài 2 (sửa lỗi). Đoạn code sau không biên dịch được. Giải thích vì sao, dựa vào đặc điểm cốt lõi của string trong bài.
s := "Golang"
s[0] = 'X'
Đáp án
Lỗi biên dịch "cannot assign to s[0]" vì string bất biến — dữ liệu của nó (đặc biệt là chuỗi literal) nằm trong vùng nhớ chỉ-đọc (rodata), và ngôn ngữ Go không cho phép ghi vào từng byte của string. Muốn sửa nội dung, phải chuyển sang []byte, sửa, rồi chuyển lại thành string (mỗi bước đều copy).
Bài 3 (đọc hiểu số liệu). Benchmark trong bài cho: BenchmarkSubstring 0,73 ns/op, 0 B/op, 0 allocs/op; BenchmarkStringToByte 21,43 ns/op, 80 B/op, 1 allocs/op. Giải thích vì sao hai thao tác này (đều "tạo ra dữ liệu mới" theo cách nhìn hời hợt) lại có chi phí chênh lệch đến vậy.
Đáp án
Substring (s[7:13]) không thực sự tạo dữ liệu mới — nó chỉ tạo một string header 16 byte mới với ptr lệch đi và len mới, chia sẻ cùng backing data với string gốc, nên không cần cấp phát hay copy gì (0 cấp phát). Ngược lại, []byte(s) phải cấp một mảng byte mới rồi copy toàn bộ nội dung sang, vì []byte có thể bị sửa còn string thì bất biến — nếu chia sẻ dữ liệu, sửa []byte sẽ làm hỏng string gốc, nên Go bắt buộc copy.
Bài 4 (vận dụng thực tế). Bạn viết một hàm xử lý log chạy trong vòng lặp nóng (hot path), mỗi vòng lặp nhận một string rồi gọi []byte(s) để truyền cho một hàm ghi log nhận []byte. Dựa vào bài học, đây có phải là điểm cần tối ưu không, và hướng khắc phục là gì?
Đáp án
Có, đây là điểm cần chú ý: mỗi lần []byte(s) là một lần cấp phát + copy toàn bộ dữ liệu (đo được 21,43 ns/op, 1 allocs/op), lặp lại trong vòng lặp nóng sẽ cộng dồn thành chi phí đáng kể. Hướng khắc phục là giữ nguyên một kiểu xuyên suốt đường nóng — ví dụ làm việc với []byte ngay từ đầu (nếu nguồn dữ liệu cho phép) để tránh phải chuyển đổi qua lại giữa string và []byte.
Bài 5 (bẫy/đánh đổi). Một đồng nghiệp đề xuất dùng unsafe để chuyển string sang []byte mà không copy, nhằm tăng tốc. Theo bài viết, kỹ thuật này nguy hiểm ở điểm gì, và khi nào mới an toàn để dùng?
Đáp án
Kỹ thuật unsafe cho []byte "chia sẻ" cùng backing data với string thay vì copy — nhanh (0 cấp phát) nhưng phá vỡ đảm bảo bất biến của Go: nếu ai đó sửa []byte đó, string gốc (vốn được coi là bất biến ở khắp nơi trong chương trình) sẽ bị thay đổi ngầm, gây bug rất khó lần ra. Kỹ thuật này chỉ an toàn khi chắc chắn tuyệt đối rằng []byte thu được sẽ không bao giờ bị sửa sau đó (ví dụ chỉ dùng để đọc/truyền cho hàm chỉ đọc).
Phần sau ta xét chính kỹ thuật vừa cảnh báo: Phần sau mổ xẻ cách chuyển string ↔ []byte không copy bằng unsafe — khi nào an toàn dùng, đo mức tiết kiệm thật, và những cạm bẫy khiến nó phá hỏng chương trình.