Go Zero to Hero - Bài 18: Khám phá Internals của Slice: Length vs Capacity
Khi bạn sử dụng Slice, việc chỉ hiểu append là chưa đủ. Để viết được các hệ thống backend chịu tải cao (High-Concurrency), bạn cần phải thấu hiểu tường tận cách mà bộ nhớ RAM được quản lý đằng sau các con trỏ len và cap.
1. Length (len) và Capacity (cap): Ranh giới bộ nhớ
Khi bạn tạo một Slice, có hai con số bạn phải luôn kiểm soát:
- Length (
len): Số lượng phần tử thực tế đang có mặt trong Slice mà bạn có thể truy cập hợp lệ. - Capacity (
cap): Sức chứa tối đa của mảng ẩn bên dưới. Nó tính từ phần tử đầu tiên của Slice cho đến điểm kết thúc của mảng ẩn đó.
package main
import "fmt"
func main() {
// Tạo một slice có len = 3, nhưng cap = 5
// Bản chất Go sẽ tạo một mảng ẩn 5 phần tử [1 2 3 0 0]
nums := make([]int, 3, 5)
nums[0] = 10
nums[1] = 20
nums[2] = 30
fmt.Printf("len=%d cap=%d data=%v\n", len(nums), cap(nums), nums)
// BÁO LỖI (Panic: index out of range) vì bạn đang truy cập vượt quá chiều dài (len)
// Dù mảng ẩn vẫn còn 2 ô trống, nhưng bạn chưa "kích hoạt" chúng qua hàm append
// nums[3] = 40
}
2. Bí mật đằng sau hàm append (Memory Reallocation)
Điều gì sẽ xảy ra khi bạn gọi hàm append nhồi thêm dữ liệu vào một Slice đã đầy sức chứa (len == cap)?
Hãy xem xét quy trình "thay máu" (Reallocation) cực kỳ tốn kém mà Go phải âm thầm làm phía sau:
- Cấp phát mới: Go phải yêu cầu hệ điều hành (OS) cấp cho nó một vùng nhớ mới (một Underlying Array mới) to hơn vùng nhớ cũ.
- Copy dữ liệu: Go chép từng byte dữ liệu từ mảng cũ sang mảng mới.
- Đổi trỏ: Cập nhật con trỏ trong cái Slice Header (24 bytes) để trỏ sang mảng mới, đồng thời cập nhật lại
lenvàcap. - Dọn rác: Mảng cũ bị bỏ vơ vơ trong RAM và chờ Garbage Collector (GC) đến dọn dẹp.
func main() {
// Slice có len=2, cap=2 (ĐÃ ĐẦY)
logs := []string{"LOG_01", "LOG_02"}
fmt.Printf("Trước khi append: cap=%d, pointer=%p\n", cap(logs), logs)
// Nhồi thêm phần tử thứ 3
logs = append(logs, "LOG_03")
// Lúc này, cap đã tăng lên, và địa chỉ vùng nhớ (pointer) ĐÃ BỊ THAY ĐỔI
fmt.Printf("Sau khi append: cap=%d, pointer=%p\n", cap(logs), logs)
}
Bài học xương máu cho hệ thống High-Concurrency: Nếu bạn đọc 100,000 bản ghi từ PostgreSQL và đẩy vào một Slice bằng một vòng lặp
for, mà bạn khởi tạo slice rỗng (var records []Record), hàmappendsẽ kích hoạt quy trình Reallocation hàng chục lần. Việc liên tục tạo mảng mới, copy dữ liệu, rồi vứt mảng cũ sẽ khiến CPU bị vắt kiệt và GC phải chạy điên cuồng, làm tăng độ trễ (Latency) của API. Giải pháp: Luôn sử dụngmake([]Record, 0, 100000)nếu bạn có thể ước lượng trước (hoặc chính xác) số lượng bản ghi!
3. Thuật toán tăng trưởng (Slice Growth Algorithm)
Khi Slice bị đầy, Go sẽ cấp phát mảng mới to hơn bao nhiêu?
- Trước Go 1.18:
- Nếu Capacity < 1024, Go sẽ nhân đôi (gấp 2 lần) sức chứa.
- Nếu Capacity >= 1024, Go sẽ tăng thêm 25% mỗi lần.
- Từ Go 1.18 trở đi: Đội ngũ phát triển đã tinh chỉnh lại thuật toán. Để tránh việc tăng vọt kích thước đột ngột gây lãng phí RAM, sự chuyển tiếp mượt mà hơn đã được áp dụng. Công thức nội bộ (nằm trong file
runtime/slice.go) sẽ giảm dần tỷ lệ tăng trưởng từ 2x xuống 1.25x một cách từ từ dựa trên một ngưỡng mềm (thường là 256).
Biết được điều này, bạn sẽ hiểu vì sao đôi khi cap nhảy ra những con số rất kỳ lạ (như 3, 6, 12, 24 thay vì lũy thừa của 2).
4. Cái bẫy chết người: Rò rỉ bộ nhớ (Memory Leak) do Slicing
Đây là lỗi kinh điển mà hầu như kỹ sư Backend nào cũng dính một lần khi thao tác với file lớn hoặc chuỗi byte stream (như Kafka message).
Như đã nói ở các bài trước, khi bạn cắt một Slice nhỏ từ một Slice to (ví dụ small := big[10:15]), con trỏ của small vẫn trỏ thẳng vào mảng ẩn của big.
Kịch bản rò rỉ:
Giả sử bạn load một file Log hệ thống nặng 1GB vào một Slice tên là hugeData. Sau khi phân tích, bạn chỉ cần lấy đúng 1 dòng lỗi dài 100 bytes để lưu vào biến errorLine (bằng cách cắt errorLine = hugeData[x:y]).
Sau đó, tiến trình tiếp tục chạy. Bạn nghĩ rằng hugeData sẽ bị GC dọn dẹp vì bạn không dùng nó nữa?
KHÔNG! Vì biến errorLine (chỉ 100 bytes) vẫn đang trỏ ngầm vào mảng Underlying Array 1GB đó, Garbage Collector sẽ không bao giờ dám xóa mảng 1GB kia đi. Hệ thống của bạn chính thức bị Memory Leak và chôn chân 1GB RAM vô ích!
Cách khắc phục:
Nếu muốn lấy một phần nhỏ từ một mảng khổng lồ và giải phóng mảng khổng lồ đó, bạn phải chủ động copy dữ liệu sang một vùng nhớ mới tinh.
func getCriticalError(hugeLog []byte) []byte {
// Cắt ra đoạn lỗi (Vẫn dính liền với mảng ẩn 1GB)
errSegment := hugeLog[10500 : 10600]
// TẠO MỘT VÙNG NHỚ MỚI HOÀN TOÀN
safeCopy := make([]byte, len(errSegment))
// Dùng hàm copy tích hợp sẵn để chép dữ liệu vật lý
copy(safeCopy, errSegment)
return safeCopy // Trả về slice mới. Lúc này hugeLog 1GB có thể bị GC dọn đi.
}
Mẹo nhỏ: Kể từ Go 1.21, bạn có thể dùng gói tiêu chuẩn
slices.Clone()để làm việc này một cách thanh lịch mà không cần tự viết lệnhmakevàcopy.
Tổng kết
Bạn đã nắm được bí mật sâu thẳm nhất của Slice:
- Len là hiện tại, Cap là giới hạn: Luôn chủ động tính toán
capbằngmakeđể tránh việcappendbắt hệ thống phải reallocation (cấp phát lại) liên tục. - Nguyên lý cấp phát: Go sẽ tạo mảng mới, copy dữ liệu cũ sang và bỏ mảng cũ cho GC dọn dẹp mỗi khi Slice vượt quá sức chứa.
- Tránh bẫy Memory Leak: Tuyệt đối không giữ lại một Slice cắt nhỏ từ một nguồn dữ liệu khổng lồ trong thời gian dài. Hãy chủ động
copy()ra một vùng nhớ độc lập!
All rights reserved