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.

Ảnh chụp đoạn mã Go nền tối minh hoạ string internals header 16 byte bất biến chi phí chuyển đổi, giống slice string là một header nhỏ trỏ vào dữ liệu nhưng chỉ hai trường và dữ liệu bất biến không sửa được, string header 16 byte trên máy 64-bit bằng 2 từ máy ptr con trỏ tới byte đầu của dữ liệu chuỗi 8 byte len số byte không có cap string bất biến không tăng trưởng dữ liệu chuỗi literal nằm trong vùng chỉ đọc rodata bất biến, đọc 2 trường bằng unsafe type shdr struct ptr uintptr len int, substring chia sẻ cùng dữ liệu không copy chỉ ptr cộng len mới sub bằng s 7 13 ptr bằng ptr_s cộng 7 len bằng 6 chia sẻ backing, bất biến không sửa được s 0 bằng X 0 lỗi biên dịch cannot assign to s 0, chuyển string byte phải copy byte sửa được string thì không b bằng byte s cấp mảng mới cộng copy toàn bộ s2 bằng string b cấp backing mới cộng copy, vì sao chuyển string byte phải copy string bất biến byte sửa được nếu chia sẻ sửa byte sẽ làm hỏng string bất biến

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.

Ảnh chụp bảng kết quả đo thật nền tối string internals Go 1.23 arm64 64-bit, header và substring chia sẻ không copy kích thước string header bằng 16 byte 2 word ptr cộng len s ptr 0xd2a2e len 25 s 7 13 bằng Golang ptr 0xd2a35 len 6 ptr sub cách ptr s bằng 7 byte chia sẻ backing không copy, chuyển string byte copy vs substring không copy BenchmarkStringToByte 21.43 ns/op 80 B/op 1 allocs/op BenchmarkByteToString 13.75 ns/op 80 B/op 1 allocs/op BenchmarkSubstring 0.73 ns/op 0 B/op 0 allocs/op chuyển đổi phải copy 1 cấp phát mỗi lần substring chia sẻ miễn phí, cốt lõi 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 substring s 7 13 chỉ tạo header mới trỏ vào cùng dữ liệu ptr lệch 7 byte 0 cấp phát nên miễn phí nhưng chuyển string byte phải copy toàn bộ 1 cấp phát vì byte sửa được còn string thì không

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ó ptr lệch đúng 7 byte so với s, 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ề

  1. 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).
  2. 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).
  3. 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.

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.