Go Zero to Hero - Bài 35: Con trỏ và Struct - Kết hợp để tối ưu bộ nhớ
Khi làm việc với các hệ thống backend chịu tải cao, việc lạm dụng truyền tham trị (Pass-by-Value) cho các cấu trúc dữ liệu lớn sẽ làm suy giảm hiệu năng nghiêm trọng. Sử dụng con trỏ Struct là giải pháp tối ưu để giải quyết bài toán này.
1. Nỗi đau mang tên "Struct khổng lồ"
Như đã phân tích ở các bài trước, Go mặc định sử dụng cơ chế truyền tham trị (Pass-by-Value). Khi bạn truyền một biến vào hàm, Go sẽ copy toàn bộ giá trị của biến đó.
Hãy tưởng tượng một Struct ghi nhận log giao dịch qua cổng xoay chứa vô số thông tin:
package main
import (
"fmt"
"time"
)
// Một Struct rất nặng, chứa hàng tá chuỗi string và mảng byte
type GateTransaction struct {
TxnID string
CardUID string
StationID int
GateID string
RawPayload []byte // Dữ liệu thô từ phần cứng
ErrorCode int
ErrorMessage string
Timestamp time.Time
}
// Hàm xử lý nhận vào tham trị (Copy toàn bộ Struct)
func processLog(txn GateTransaction) {
// Mỗi lần gọi hàm này, Go phải vác toàn bộ dữ liệu của txn copy ra một vùng nhớ mới
fmt.Println("Đang xử lý giao dịch:", txn.TxnID)
}
Nếu một phút hệ thống có 10,000 lượt quẹt thẻ, và Struct GateTransaction này bị truyền qua lại giữa 4-5 hàm khác nhau (từ Controller -> Service -> Repository -> Logger), máy chủ của bạn sẽ phải thực hiện hàng chục ngàn phép copy dữ liệu rườm rà, đẩy CPU và RAM lên mức báo động.
2. Giải pháp: Khởi tạo và truyền Con trỏ Struct
Thay vì vác cả một "ngôi nhà" (Struct) chạy vòng vòng qua các hàm, chúng ta chỉ cần tạo ra ngôi nhà đó 1 lần duy nhất, lấy địa chỉ của nó (Pointer), và truyền cái địa chỉ đó đi. Một địa chỉ con trỏ luôn có kích thước cố định là 8 bytes (trên máy 64-bit), bất chấp Struct gốc nặng bao nhiêu Megabytes.
Bạn có 2 cách để khởi tạo ngay một con trỏ Struct:
Cách 1: Dùng từ khóa new (Ít dùng)
Hàm new(Type) sẽ cấp phát vùng nhớ, điền Zero Value cho tất cả các field, và trả về con trỏ *Type.
pTxn := new(GateTransaction)
pTxn.TxnID = "TXN-001" // Gán giá trị sau
Cách 2: Dùng cú pháp &Struct{} (Chuẩn thực chiến)
Cách này cho phép bạn vừa lấy được con trỏ, vừa gán giá trị khởi tạo (Named Initialization) ngay trong một nốt nhạc.
// pTxn lúc này mang kiểu *GateTransaction
pTxn := &GateTransaction{
TxnID: "TXN-002",
CardUID: "CARD-999",
StationID: 101,
}
// Truyền con trỏ (8 bytes) vào hàm siêu nhẹ nhàng
// fastProcessLog(pTxn)
💡 Điều kỳ diệu của Go: Bạn không cần dùng dấu sao
*để giải tham chiếu khi gọi các field (không cần viết(*pTxn).TxnID). Trình biên dịch của Go tự động hiểu dấu chấm.trên một con trỏ Struct chính là hành động truy xuất vào vùng nhớ gốc.
3. Pattern Constructor trong Go (Hàm khởi tạo)
Vì Go không có khái niệm Class, nó cũng không có các hàm Constructor có sẵn để thiết lập giá trị mặc định cho đối tượng.
Giới Backend Gopher tự quy ước một Pattern: Viết một hàm bắt đầu bằng chữ New... và trả về một con trỏ Struct.
func generateUUID() string {
return "UUID-12345"
}
// Hàm khởi tạo chuẩn mực
func NewGateTransaction(cardUID string, station int) *GateTransaction {
// Khởi tạo Struct và lấy địa chỉ
return &GateTransaction{
TxnID: generateUUID(), // Giả lập hàm tạo ID
CardUID: cardUID,
StationID: station,
Timestamp: time.Now(), // Thiết lập thời gian mặc định ngay lúc khởi tạo
}
}
func main() {
// Sử dụng Constructor
txn := NewGateTransaction("CARD-ABCD", 1)
fmt.Println("Giao dịch mới tạo:", txn.TxnID, "lúc", txn.Timestamp)
}
4. Escape Analysis: Phép màu quản lý bộ nhớ của Go
Hãy nhìn kỹ lại hàm NewGateTransaction ở trên. Nếu bạn đến từ ngôn ngữ C/C++, bạn sẽ ngay lập tức giật mình thảng thốt: "Struct được tạo ra bên TRONG hàm, nó là biến cục bộ. Khi hàm kết thúc, biến đó sẽ bị xóa. Vậy trả về con trỏ trỏ tới một vùng nhớ đã bị xóa thì sẽ gây ra lỗi sập nguồn (Dangling Pointer / Segfault) chứ!"
Nhưng bạn đang viết Go, và Go sở hữu một phép thuật mang tên Escape Analysis (Phân tích vòng đời biến) lúc biên dịch.
Ketika trình biên dịch Go đọc code của bạn, nó nhận ra: "Chà, cái Struct này được tạo trong hàm NewGateTransaction, nhưng địa chỉ của nó lại bị return ra bên ngoài để hàm main() xài. Tức là nó đã 'đào tẩu' (escape) ra khỏi phạm vi hàm này."
Ngay lập tức, Go sẽ quyết định: Không lưu Struct này ở bộ nhớ tạm (Stack) nữa, mà đẩy nó lên bộ nhớ dài hạn (Heap). Nhờ vậy, con trỏ trả về vẫn hoàn toàn an toàn và hợp lệ! Bạn cứ thoải mái return con trỏ từ các hàm khởi tạo mà không bao giờ sợ lỗi Dangling Pointer.
Tổng kết
Khi làm việc với Struct, hãy nằm lòng các chiến thuật sau:
- Dùng
&Struct{}: Đây là cách nhanh nhất và sạch nhất để vừa khởi tạo dữ liệu, vừa lấy con trỏ. - Viết hàm
New...: Tạo ra các hàm Constructor trả về con trỏ (*Struct) để chuẩn hóa việc thiết lập các giá trị mặc định. - An tâm với Escape Analysis: Trình biên dịch Go đủ thông minh để biết khi nào cần tự động chuyển vùng nhớ từ Stack sang Heap để giữ cho con trỏ của bạn không bị "treo".
All rights reserved