Hãy tưởng tượng phải chọn giữa chiếc xe máy và chiếc xe tải. Xe máy nổ máy là đi, luồn lách gọn trong ngõ nhỏ, đổ xăng ít — nhưng chở được không nhiều. Xe tải chở nặng, thùng đồ nghề đầy đủ, đi đường dài bền bỉ — nhưng nặng nề và nổ máy lâu hơn. Không ai hỏi "xe máy hay xe tải, cái nào tốt hơn" — người ta hỏi "bạn cần chở gì". Go và Java đặt cạnh nhau đúng như vậy, và cả bài cuối này xoay quanh việc đọc cho ra cái thùng hàng của mình. Sáu mươi bài, và mỗi bài đều bắt đầu bằng việc chạy thử rồi mới viết.

Sáu chặng

Bài 1–10: nền tảng. Từ lý do Go tồn tại tới zero value, slice, map — cùng cái bẫy chia sẻ mảng nền.

Bài 11–22: kiểu và tổ chức. struct, method set, interface ngầm định, embedding, generics, và bẫy interface nil.

Bài 23–30: lỗi và tài nguyên. error là giá trị, wrapping, panic, defer, context.

Bài 31–42: đồng thời. Goroutine, channel, select, khoá, race detector, pipeline, rò rỉ goroutine, errgroup.

Bài 43–52: thư viện chuẩn. io, os, JSON, net/http, database/sql, time, testing, pprof.

Bài 53–60: thực chiến. Module, build, Docker, cấu hình, slog, graceful shutdown, bảo mật.

Mười phép đo đáng nhớ

Goroutine: 2,47 µs và 576 byte (bài 31). Đây là con số định hình cả ngôn ngữ — nó cho phép "một goroutine cho mỗi việc" thay vì pool và callback.

append vào slice con ghi đè slice gốc (bài 9): goc=[1 99 3 777 5], không cảnh báo nào.

len("Tiếng Việt") = 14, s[:4] = "Ti\xe1\xba" (bài 22). Mọi hàm cắt chuỗi trong dự án tiếng Việt cần xem lại.

Go 1.21 in 3 3 3, Go 1.22 in 0 1 2 (bài 6) — cùng mã, và hành vi phụ thuộc dòng go trong go.mod chứ không phụ thuộc trình biên dịch.

Đổi thứ tự ba trường: struct 24 byte xuống 16 (bài 11).

p == nil là true nhưng err == nil là false (bài 17) — cái bẫy nổi tiếng nhất của Go.

100 goroutine tăng biến: mong 100.000, nhận 47.948 (bài 34), và ba lần chạy ra ba con số.

Ghi 200.000 lần: 58 ms xuống 1 ms qua bufio (bài 43).

Nối chuỗi: 83.869 ns / 999 allocs xuống 711 ns / 1 alloc (bài 52) — 118 lần.

Ảnh Docker: 1,34 GB xuống 11,7 MB (bài 55).

Đặt cạnh sê-ri Java

Cả hai sê-ri đo trên cùng một máy, nên vài con số so được trực tiếp:

  tạo một đơn vị đồng thời:
    luồng nền tảng Java : 97,3 µs  | 1 MB đặt chỗ
    luồng ảo Java       : 31,6 µs  | ~800 byte
    goroutine Go        :  2,47 µs | 576 byte

  ảnh Docker nhỏ nhất dựng được:
    Java (jlink + distroless) : 126 MB
    Go   (scratch)            : 11,7 MB

  công cụ bắt lỗi đua:
    Java : phải tự dựng khung đo, nới khe hở thủ công (bài 96)
    Go   : go test -race, chỉ thẳng số dòng đọc và ghi

  quét lỗ hổng phụ thuộc:
    Java (OSV-Scanner) : 73 lỗ hổng, không biết cái nào gọi tới được
    Go   (govulncheck) : 36 trong module, 4 trong package import, 0 gọi tới được

Nhưng cũng có chiều ngược lại. Java có hệ sinh thái sâu hơn nhiều, có generics mạnh hơn, có công cụ chẩn đoán JVM chi tiết hơn (JFR ở bài 89), và có kiểu dữ liệu phong phú hơn cho mô hình hoá miền nghiệp vụ — đúng cái thùng đồ nghề đầy đủ của chiếc xe tải.

Go đổi tất cả những thứ đó lấy: build 48 ms, binary tĩnh 2 MB, đồng thời nằm trong ngôn ngữ, và một cú pháp nhỏ tới mức mã của người khác đọc được ngay — chiếc xe máy nổ là đi.

Đó là đánh đổi, không phải thứ bậc. Chọn theo bài toán.

Ba điều tôi rút ra sau sáu mươi bài

Go tối ưu cho việc đọc mã, không cho việc viết mã. Không có toán tử ba ngôi, không nạp chồng, không tham số mặc định, if err != nil khắp nơi. Viết thì dài; đọc mã của người lạ thì nhanh hơn hẳn.

Zero value là ý tưởng lớn nhất. Bài 18: var b Bo rồi dùng ngay, mutex và buffer bên trong đều chạy. Nó bỏ đi cả một tầng hàm khởi tạo mà các ngôn ngữ khác bắt buộc phải có.

Công cụ chẩn đoán đi trước. -race, pprof, govulncheck, go test -fuzz đều nằm sẵn trong bộ cài và đều là một dòng lệnh. Bài 52 và 59 cho thấy khoảng cách với hệ sinh thái khác.

Đi tiếp từ đây

Web và dịch vụ — net/http đủ cho phần lớn API sau Go 1.22. Cần hơn thì chi (tương thích http.Handler) hoặc echo. Với gRPC thì google.golang.org/grpc.

Dữ liệu — sqlx hoặc sqlc là hai lựa chọn tôi khuyên; ORM đầy đủ ít phổ biến trong Go hơn hẳn Java.

Hạ tầng và DevOps — đây là sân nhà của Go. Kubernetes operator (kubebuilder), CLI (cobra), công cụ nội bộ.

Hiệu năng sâu — pprof nghiêm túc, benchstat, hiểu escape analysis (go build -gcflags='-m').

Đồng thời nâng cao — đọc mã bộ lập lịch runtime, golang.org/x/sync, mô hình bộ nhớ chính thức.

Sách và tài liệu

Effective Go và Go Code Review Comments trên go.dev — hai tài liệu ngắn, đọc trong một buổi, và chúng định nghĩa "mã Go đúng chuẩn".

The Go Programming Language (Donovan, Kernighan) — cuốn duy nhất tôi nói là bắt buộc. Cũ hơn generics nhưng phần nền vẫn đúng nguyên.

100 Go Mistakes and How to Avoid Them (Teiva Harsanyi) — hợp ngay sau sê-ri này.

go.dev/blog và ghi chú phát hành mỗi sáu tháng — Go đổi chậm nhưng đều, và mười lăm phút đọc mỗi bản là đủ theo kịp.

Và một nguồn ít người dùng: mã nguồn thư viện chuẩn. Nó được viết để làm mẫu, comment đầy đủ, và go doc -src <hàm> mở ra ngay trong terminal.

Điều cuối

Sê-ri này theo đúng nguyên tắc của sê-ri Java trước đó: không viết điều mình chưa chạy thử.

Và nó lại trả công theo cùng một cách. Bài 42 định viết "channel chậm hơn mutex" và phép đo nói ngược lại. Bài 57 định viết "LogAttrs nhanh hơn hẳn" và hai con số ra 70 với 69 mili giây. Bài 6 định viết theo trí nhớ về Go 1.22 và việc chạy thử trên ba phiên bản lộ ra chi tiết quan trọng hơn — hành vi phụ thuộc go.mod, không phụ thuộc trình biên dịch.

Nếu có một thứ đáng mang theo từ sáu mươi ngày này, vẫn là câu cũ: chạy thử đi, rồi hãy tin.

Mẫu số chung

Khép lại hai sê-ri sáu mươi bài, tôi nghĩ điều bền nhất không phải Go, cũng không phải Java, mà là cách nhìn này: không ngôn ngữ nào hơn ngôn ngữ nào một cách tuyệt đối — mỗi cái là một bó đánh đổi có chủ đích. Go đổi sức mạnh kiểu và chiều sâu hệ sinh thái lấy sự đơn giản, khởi động nhanh, và mã dễ đọc. Rust và Scala đổi sự đơn giản lấy hệ thống kiểu giàu hơn. Python đổi tốc độ lấy tầm với và thư viện. C đổi an toàn lấy kiểm soát sát phần cứng. Không cái nào "thắng"; chúng ngồi ở những điểm khác nhau trên cùng một đường cong, và cái thùng hàng của bạn quyết định điểm nào hợp.

Nên kỹ năng thật sự chuyển được qua mọi ngôn ngữ bạn sẽ học tiếp không phải là lòng trung thành với một cái tên, mà là hai thứ: đọc được bó đánh đổi của một ngôn ngữ — nó hào phóng chỗ nào, nghiêm khắc chỗ nào, giấu gì và phơi gì — và chạy thử trước khi tin. Đáng chú ý là hai sê-ri này, viết độc lập về hai ngôn ngữ rất khác nhau, lại hội tụ về cùng một kỷ luật: đo, đừng đoán. Điều đó gợi ý rằng kỷ luật ấy sống lâu hơn bất kỳ ngôn ngữ nào. Người kỹ sư biết đổi giữa xe máy và xe tải theo món hàng — và biết nổ thử máy trước khi tin nó chạy — sẽ đi xa hơn người khăng khăng rằng phương tiện của mình là duy nhất đúng. Thứ duy nhất chắc chắn không chuyển được sang ngôn ngữ sau là tinh thần bộ lạc.

Cảm ơn bạn đã đọc tới đây.

Bài tập làm thử

Bài 1 (đọc hiểu). Bài tổng kết đưa ra ba con số so sánh chi phí tạo một "đơn vị đồng thời": luồng nền tảng Java 97,3 µs, luồng ảo Java 31,6 µs, và goroutine Go 2,47 µs. Theo bài viết, con số 2,47 µs này có ý nghĩa gì lớn hơn bản thân phép đo tốc độ?

Đáp án

Bài viết gọi đây là "con số định hình cả ngôn ngữ" — chi phí cực thấp để tạo một goroutine (cả về thời gian lẫn bộ nhớ, chỉ 576 byte) cho phép lập trình viên Go dùng mô hình "một goroutine cho mỗi việc" một cách tự nhiên, thay vì phải dùng thread pool và callback bất đồng bộ như nhiều ngôn ngữ khác buộc phải làm vì chi phí tạo luồng cao hơn nhiều. Đây không chỉ là một con số hiệu năng mà là lý do kiến trúc đồng thời của Go được thiết kế đơn giản như vậy.

Bài 2 (sửa lỗi). Bài tổng kết nhắc lại ví dụ 100 goroutine tăng biến: mong 100.000, nhận 47.948. Đoạn mã dưới đây tái hiện đúng lỗi đó. Sửa nó để luôn cho kết quả đúng 100.000, dùng công cụ mà chính sê-ri đã giới thiệu.

var n int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for j := 0; j < 1000; j++ {
			n++
		}
	}()
}
wg.Wait()
fmt.Println(n)
Đáp án

Lỗi: nhiều goroutine cùng đọc-sửa-ghi biến n thường mà không đồng bộ — đây là đua dữ liệu (data race), kết quả không xác định (như con số 47.948 thay vì 100.000). Sửa bằng atomic.Int64:

var n atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for j := 0; j < 1000; j++ {
			n.Add(1)
		}
	}()
}
wg.Wait()
fmt.Println(n.Load()) // luôn đúng 100000

Bài 3 (đọc hiểu). Bài viết nói p == nil có thể là true nhưng err == nil lại là false — "cái bẫy nổi tiếng nhất của Go" — dù cả hai trông như đang kiểm tra cùng một thứ. Giải thích ngắn gọn nguồn gốc của nghịch lý này.

Đáp án

Một giá trị interface trong Go (như error) thực chất là một cặp (kiểu, giá trị). Nếu bạn gán một con trỏ cụ thể có giá trị nil (ví dụ var p *MyError = nil) vào một biến kiểu error, biến error đó có phần "kiểu" đã được điền (là *MyError) dù phần "giá trị" là nil — nên so sánh err == nil (so cả cặp) trả về false, trong khi so trực tiếp con trỏ p == nil (chỉ so phần giá trị) trả về true. Đây là lý do hàm không nên trả về một con trỏ lỗi cụ thể đang là nil dưới dạng kiểu error.

Bài 4 (vận dụng thực tế). Dựa trên bảng so sánh "ảnh Docker nhỏ nhất dựng được" (Java 126 MB, Go 11,7 MB), bạn đang chọn ngôn ngữ để viết một công cụ dòng lệnh nội bộ sẽ chạy trong hàng nghìn container ngắn hạn (short-lived jobs) trên Kubernetes. Dựa theo đúng tinh thần "đánh đổi, không phải thứ bậc" của bài viết, hãy nêu một tiêu chí hợp lý để chọn Go trong trường hợp này, và một tiêu chí khác khiến Java vẫn có thể là lựa chọn tốt hơn.

Đáp án

Chọn Go hợp lý khi: kích thước ảnh và tốc độ khởi động là ưu tiên hàng đầu — ảnh nhỏ (11,7 MB so với 126 MB) và không cần khởi động JVM giúp hàng nghìn job ngắn hạn chạy nhanh hơn, tốn ít tài nguyên hạ tầng hơn (đúng tinh thần "hạ tầng và DevOps là sân nhà của Go" mà bài viết nêu).

Java vẫn tốt hơn khi: đội ngũ cần hệ sinh thái sâu hơn, generics mạnh hơn, công cụ chẩn đoán JVM chi tiết hơn (JFR), hoặc kiểu dữ liệu phong phú hơn để mô hình hoá miền nghiệp vụ phức tạp — những thứ bài viết liệt kê là điểm mạnh của "chiếc xe tải" Java mà Go đánh đổi để lấy sự đơn giản.

Bài 5 (bẫy/đánh đổi). Bài viết kết luận "chạy thử đi, rồi hãy tin" và kể ba lần chính tác giả định viết một kết luận theo trí nhớ nhưng số đo thực tế lại khác. Nêu một trong ba ví dụ đó và giải thích bài học rút ra.

Đáp án

Một trong ba ví dụ: bài viết định kết luận "channel chậm hơn Mutex" (niềm tin phổ biến) nhưng phép đo thực tế lại cho kết quả ngược lại trong cấu hình cụ thể đó (channel 12ms nhanh hơn Mutex 21ms). Bài học: trực giác hoặc "kiến thức phổ biến" về hiệu năng thường sai hoặc chỉ đúng trong một số cấu hình nhất định — chỉ có đo thực tế trên đúng bài toán của bạn mới cho câu trả lời đáng tin, và kết luận rút ra từ một phép đo không tự động đúng cho mọi cấu hình khác.

(Hai ví dụ khác: LogAttrs "nhanh hơn hẳn" nhưng đo ra chỉ chênh 70ms so với 69ms; và hành vi closure trong vòng lặp ở Go 1.22 hoá ra phụ thuộc dòng khai trong go.mod chứ không phải phiên bản trình biên dịch.)