7 Thủ thuật Golang hiệu quả mà tài liệu chính thức không nói kỹ
Cú pháp của Go thực sự đơn giản, nhưng để viết được code hiệu năng cao cho môi trường production thì chỉ dựa vào "syntax sugar" (cú pháp ngọt) là chưa đủ. Nhiều khi, viết code "chạy được" chỉ là mức điểm đạt; viết code hiệu năng cao, thân thiện với bộ nhớ và dễ bảo trì mới là ngưỡng cửa thực sự của một Senior.

Để đỡ tốn công sức cấu hình, gần đây mình đã chuyển môi trường local sang dùng ServBay. Lợi ích lớn nhất là cài đặt một chạm cho tất cả các phiên bản từ Go 1.11 đến Go 1.24. Các phiên bản này được cách ly vật lý và cùng tồn tại hòa bình. Bạn không cần phải hì hục sửa biến môi trường Go thủ công nữa; thích dùng bản nào switch bản đó, thậm chí mở nhiều terminal chạy nhiều phiên bản cùng lúc. Đặc biệt tiện nếu bạn là Full-stack developer, vừa phải code Go vừa phải quản lý một môi trường Node.js phức tạp cho frontend.

Môi trường đã xong, giờ hãy quay lại với code và bàn về vài kỹ thuật Go thực dụng nhưng hay bị bỏ qua nhé.
1. Cấp phát trước cho Slice (Slice Pre-Allocation)
Đây là điểm tối ưu hóa hiệu năng cơ bản nhất nhưng cũng dễ bị lờ đi nhất. Nhiều anh em quen tay viết var data []int rồi lao vào vòng lặp append ngay.
Code thì vẫn chạy, nhưng bên dưới thì khá lộn xộn. Khi Go runtime phát hiện dung lượng (capacity) không đủ, nó phải xin cấp phát một khối bộ nhớ lớn hơn, copy dữ liệu cũ sang, rồi đẩy bộ nhớ cũ cho Garbage Collector (GC) dọn dẹp. Trong các vòng lặp dữ liệu lớn, việc này ngốn CPU và RAM kinh khủng.
Cách viết kém hiệu quả:
// Mỗi lần append đều có nguy cơ kích hoạt mở rộng bộ nhớ và copy dữ liệu
func collectData(count int) []int {
var data []int
for i := 0; i < count; i++ {
data = append(data, i)
}
return data
}
Cách viết hiệu quả:
// Cấp phát bộ nhớ một lần, tránh mở rộng giữa chừng
func collectDataOptimized(count int) []int {
// Dùng make để chỉ định length là 0 và capacity là count
data := make([]int, 0, count)
for i := 0; i < count; i++ {
data = append(data, i)
}
return data
}
Nếu ước lượng được dung lượng, hãy luôn dùng make([]T, 0, cap). Việc này không chỉ giảm tải CPU mà còn giảm áp lực cho GC đáng kể.
2. Cảnh giác với Memory Aliasing của Slice
Slice thực chất là một khung nhìn (view) của một mảng cơ sở (underlying array). Khi bạn thực hiện cắt slice (reslicing), slice mới và slice cũ dùng chung một mảng cơ sở.
Nếu mảng gốc rất lớn và bạn chỉ cần một phần nhỏ của nó, việc cắt slice trực tiếp sẽ khiến toàn bộ mảng lớn đó bị ghim trong bộ nhớ (GC không thu hồi được), gây ra rò rỉ bộ nhớ (memory leak). Hoặc, sửa đổi trên slice mới sẽ vô tình làm thay đổi dữ liệu gốc.
Code có vấn đề:
origin := []int{10, 20, 30, 40, 50}
sub := origin[:2] // sub và origin dùng chung mảng cơ sở
sub[1] = 999 // sửa sub sẽ ảnh hưởng origin
// origin biến thành [10, 999, 30, 40, 50]
Code an toàn:
origin := []int{10, 20, 30, 40, 50}
// Tạo một slice độc lập
sub := make([]int, 2)
copy(sub, origin[:2])
sub[1] = 999
// origin vẫn là [10, 20, 30, 40, 50]
Nếu cần cách ly dữ liệu hoặc tránh memory leak, hãy dùng copy hoặc idiom append([]T(nil), origin[:n]...).
3. Tận dụng Struct Embedding để Combos (Composition)
Go không có kế thừa truyền thống, nhưng thông qua Struct Embedding (Nhúng Struct), bạn có thể đạt được hiệu quả tương tự linh hoạt hơn nhiều. Các phương thức của trường được nhúng sẽ được thăng cấp (promote) trực tiếp lên struct bên ngoài, gọi như thể là phương thức của chính nó.
type BaseEngine struct {
Power int
}
func (e BaseEngine) Start() {
fmt.Printf("Engine started with power: %d\n", e.Power)
}
type Car struct {
BaseEngine // Nhúng ẩn danh (Anonymous embedding)
Model string
}
func main() {
c := Car{
BaseEngine: BaseEngine{Power: 200},
Model: "Sports",
}
// Có thể gọi trực tiếp phương thức Start của BaseEngine
// Cảm giác như phương thức của chính Car
c.Start()
}
Cách này giúp cấu trúc code phẳng hơn và đúng với triết lý thiết kế "Composition over Inheritance" (Ưu tiên tổ hợp hơn kế thừa) của Go.
4. Defer không chỉ để đóng file
Nhiều người chỉ nhớ dùng defer khi làm File.Close(). Thực ra trong lập trình đồng thời, nó là vũ khí chống Deadlock (khóa chết) cực tốt.
Ví dụ khi dùng Mutex, sợ nhất là giữa hàm có cái if err != nil { return } mà quên unlock, thế là treo cả chương trình.
func safeProcess() error {
mu := &sync.Mutex{}
mu.Lock()
// Đăng ký mở khóa ngay lập tức để tránh deadlock do panic hoặc return sớm
defer mu.Unlock()
f, err := os.Open("config.json")
if err != nil {
return err
}
// Đăng ký đóng file ngay khi mở thành công
defer f.Close()
// Logic nghiệp vụ...
return nil
}
Từ Go 1.14, chi phí hiệu năng của defer đã rất nhỏ, gần như không đáng kể trong các tác vụ I/O, nên cứ yên tâm mà dùng.
5. Dùng iota để định nghĩa Enum thanh lịch
Dù Go không có kiểu enum, nhưng bộ đếm hằng số iota giải quyết vấn đề này rất gọn. Kết hợp với kiểu tùy chỉnh và phương thức String(), bạn có thể tạo ra các enum an toàn kiểu và dễ đọc.
type JobState int
const (
StatePending JobState = iota // 0
StateRunning // 1
StateDone // 2
StateFailed // 3
)
func (s JobState) String() string {
return [...]string{"Pending", "Running", "Done", "Failed"}[s]
}
func main() {
current := StateRunning
fmt.Println(current) // Output: Running
}
Việc bảo trì code kiểu này trực quan hơn nhiều.
6. Đếm số lượng truy cập lớn? Atomic nhanh hơn Mutex
Với các bộ đếm đơn giản hoặc cờ trạng thái, dùng sync.Mutex là "giết gà dùng dao mổ trâu". Việc tranh chấp khóa sẽ gây ra chi phí chuyển đổi ngữ cảnh (context switching). Các thao tác nguyên tử (Atomic) trong gói sync/atomic được thực hiện ở cấp độ chỉ lệnh phần cứng nên cực nhanh.
var requestCount int64
func worker(wg *sync.WaitGroup) {
defer wg.Done()
// Tăng nguyên tử, không cần lock
atomic.AddInt64(&requestCount, 1)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go worker(&wg)
}
wg.Wait()
// Đọc nguyên tử
fmt.Println("Total requests:", atomic.LoadInt64(&requestCount))
}
Trong các kịch bản concurrency cực cao, Atomic thường ăn đứt Mutex về hiệu năng.
7. Nhúng Interface để Mock Test dễ dàng
Mock một interface "khổng lồ" khi viết Unit Test rất phiền phức. Bằng cách nhúng các interface nhỏ để tạo thành interface lớn, bạn cho phép các đối tượng Mock chỉ cần implement những phương thức cần thiết.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// Tạo interface mới bằng cách nhúng
type ReadWriter interface {
Reader
Writer
}
// Code nghiệp vụ phụ thuộc vào interface, không phụ thuộc implementation
func CopyData(rw ReadWriter) {
// ...
}
Khi test, bạn chỉ cần implement hàm Read và Write là thỏa mãn ReadWriter, không cần phải kế thừa một base class phức tạp nào cả.
Triết lý của Go là "Less is More" (Ít hơn là nhiều hơn), nhưng nắm vững những chi tiết này sẽ giúp bạn viết code vững chắc hơn trong khuôn khổ cú pháp hạn chế đó. Dù là kiểm soát bố cục bộ nhớ hay chọn các nguyên thủy đồng thời (concurrency primitives), tất cả đều cần thực hành nhiều.
Cuối cùng, nhắc nhẹ anh em: nếu không muốn tốn thời gian cấu hình môi trường local, hoặc cần nhảy qua lại giữa Go 1.11 và Go 1.24 để kiểm chứng mấy tính năng này, thì ServBay là công cụ rất đáng thử. Nó giúp bạn tập trung năng lượng vào logic code thay vì việc setup môi trường.
All rights reserved