Hình dung go run như nấu lại một món từ nguyên liệu sống mỗi lần bạn muốn ăn — kể cả chỉ nếm một miếng cũng phải bắc bếp. Còn go build là nấu sẵn, cho vào hộp, lúc ăn chỉ việc mở nắp. Nghe thì cái nấu-sẵn hiển nhiên thắng, nhưng khi đang nêm nếm công thức thì nấu-lại-mỗi-lần lại tiện hơn: sửa một chút, nếm ngay, không phải nghĩ tới chuyện đóng hộp. Cả bài này cài Go và chạy chương trình đầu tiên, nhưng phần đáng đọc nằm ở cuối — khác biệt giữa go run và go build không phải chuyện phong cách, và số đo cho thấy vì sao.
Cài
macOS: brew install go. Linux: tải từ go.dev/dl, giải nén vào /usr/local, thêm /usr/local/go/bin vào PATH. Windows: tải bộ cài .msi.
Kiểm:
$ go version
go version go1.23.12 linux/arm64
Cần Go 1.21 trở lên cho sê-ri này — slog, min/max dựng sẵn và vài thứ khác cần bản đó.
Một điểm khác Java đáng nói ngay: Go không có khái niệm "JDK và JRE riêng". Một bản cài, vừa biên dịch vừa chạy. Và bạn cũng không cần cài Go trên máy chủ — bài 1 đã đo: binary tĩnh, chép lên là chạy.
Đừng đụng vào GOPATH nữa. Nó là cách tổ chức mã từ trước 2018 và mọi hướng dẫn nhắc tới nó đều đã cũ.
Chương trình đầu tiên
package main
import "fmt"
func main() {
fmt.Println("Xin chào từ Go")
}
Bốn thứ cần biết ngay từ bốn dòng này:
package main là bắt buộc cho chương trình chạy được. Mọi tệp Go đều thuộc một package, và package tên main là package đặc biệt: nó sinh ra binary. Package tên khác thì sinh ra thư viện.
func main() là điểm vào, không tham số, không giá trị trả về. Muốn đọc tham số dòng lệnh thì dùng os.Args; muốn trả mã thoát thì os.Exit.
import phải được dùng. Import một package rồi không dùng là lỗi biên dịch, không phải cảnh báo. Điều này làm nhiều người mới bực, và nó là ví dụ đầu tiên của triết lý Go: thà khó chịu lúc viết còn hơn để mã rác tích tụ.
Không có dấu chấm phẩy. Trình biên dịch tự chèn, nên bạn không gõ. Nhưng nó cũng bắt bạn đặt dấu { cùng dòng với func — xuống dòng là lỗi.
gofmt không phải tuỳ chọn
gofmt -w main.go # hoặc: go fmt ./...
Go có một định dạng chuẩn, do công cụ áp đặt, không cấu hình được. Thụt lề bằng tab, khoảng trắng ở đâu, xuống dòng thế nào — tất cả cố định.
Nghe độc đoán, nhưng nó xoá sổ hẳn một loại tranh cãi trong đội, và làm mã của mọi dự án Go trên thế giới trông như nhau. Với tôi đây là một trong những quyết định đúng nhất của ngôn ngữ.
Trình soạn thảo của bạn nên chạy gofmt khi lưu. VS Code với tiện ích Go làm sẵn.
go run khác go build chỗ nào
Đây là phần có số đo.
go run main.go (3 lần) : 1492 ms | 62 ms | 74 ms
./app (3 lần) : 1 ms | 1 ms | 1 ms
Chênh sáu mươi lần, và lý do rất đơn giản: go run biên dịch rồi mới chạy, mỗi lần. Nó dựng một binary tạm trong thư mục cache, chạy nó, rồi xoá đi — đúng là bắc bếp nấu lại mỗi bữa. ./app thì chỉ mở hộp ra ăn.
Lần đầu 1492 ms vì cache còn rỗng. Từ lần thứ hai, Go dùng lại kết quả biên dịch nên xuống còn 62 ms — nhưng vẫn phải liên kết và khởi động lại.
Nên dùng cái nào:
go run khi đang viết và thử — gõ một lệnh, thấy kết quả ngay. Đây là lúc nêm nếm.
go build khi đo hiệu năng, khi chạy nhiều lần, và luôn luôn khi đem lên máy chủ. Đừng bao giờ chạy go run trong sản xuất: bạn cần Go cài trên máy đó, cần mã nguồn nằm đó, và trả 60 ms mỗi lần khởi động. Không ai nấu lại từng đĩa khi dọn cho khách.
Một biến thể hay dùng: go install biên dịch rồi đặt binary vào $GOPATH/bin (mặc định ~/go/bin) — tiện cho công cụ dòng lệnh bạn muốn gọi từ mọi nơi.
Bốn lệnh dùng hằng ngày
go run . # chạy package trong thư mục hiện tại
go build ./... # build mọi package
go test ./... # chạy mọi test
go vet ./... # tìm lỗi mà trình biên dịch không bắt
go vet đáng chạy thường xuyên. Nó bắt những thứ hợp lệ về cú pháp nhưng gần như chắc chắn sai — chuỗi định dạng không khớp đối số, khoá bị sao chép, so sánh vô nghĩa. Rẻ và hay đúng.
Dấu ./... nghĩa là "package này và mọi package con". Bạn sẽ gõ nó rất nhiều.
Và nếu muốn tận mắt thấy Go nghiêm tới đâu, thử đúng ba mươi giây:
mkdir /tmp/thu && cd /tmp/thu
go mod init thu
printf 'package main\nimport "fmt"\nimport "os"\nfunc main(){ fmt.Println("chào", os.Args) }\n' > main.go
go run . mot hai ba
Rồi thử xoá dòng import "os" mà vẫn giữ os.Args, và ngược lại — giữ import mà bỏ chỗ dùng. Cả hai đều là lỗi biên dịch, và ba mươi giây đó cho bạn thấy Go nghiêm khắc tới mức nào với những thứ Java chỉ cảnh báo.
Mẫu số chung
Hai quyết định trong bài này — một lệnh "run" giấu đi bước biên dịch, và một công cụ định dạng duy nhất không cãi được — thoạt nghe độc đoán, nhưng thật ra là hai bài học cả ngành đã học và chép lại.
- Chuyện run-so-với-build có ở mọi ngôn ngữ biên dịch. Rust có
cargo runso vớicargo build— song song một-một với Go,cargo runcũng biên dịch trước rồi mới chạy. Java thì lộ hẳn hai bước (javacrồijava). Còn Python/Node cảm giác nhanh khi lặp chính vì chúng thông dịch — không có bước build để giấu, nên lúc nào cũng nhưgo run. Điều Go (và Rust) làm khéo là cho một lệnh khiến ngôn ngữ biên dịch cảm giác scripty lúc đang viết, mà vẫn cho ra binary thật khi cần. - Chuyện
gofmtcòn có sức ảnh hưởng lớn hơn: Go là kẻ tiên phong của "một định dạng đúng, không cấu hình", và cả ngành nối gót — Rust córustfmt, Python cóblack(khẩu hiệu nửa đùa: "chọn màu nào cũng được, miễn là màu đen"), JS có Prettier. Trước Go, mỗi đội đốt hàng giờ cãi nhau về tab hay space; sau Go, câu hỏi đó biến mất khỏi bàn.
Sợi chỉ chung đáng mang theo: Go đánh đổi một chút khó chịu lúc viết để xoá cả một loại hỗn độn về sau — import thừa là lỗi cứng chứ không phải cảnh báo, định dạng không thương lượng, { phải cùng dòng. Nghe như ngôn ngữ đang càu nhàu, nhưng đó là cùng canh bạc mà mọi ngôn ngữ hiện đại ít nhiều đều đặt: nghiêm ngay bây giờ để sạch về sau. Cảnh báo mà ai cũng phớt lờ thì vô dụng; một lỗi cứng thì buộc phải sửa, và mã của bạn không bao giờ tích rác kiểu "import từng có ích ba tháng trước". Một khi quen, bạn sẽ thấy thiếu nó ở những ngôn ngữ dễ tính hơn.
Ngày mai: package, module và go.mod — cách Go tổ chức mã và quản lý phụ thuộc.
Bài tập làm thử
Bài 1 (đọc hiểu). Bài viết đo go run main.go mất khoảng 1492ms lần đầu và chỉ 62-74ms những lần sau, trong khi ./app (binary đã build) chỉ mất khoảng 1ms mỗi lần. Giải thích vì sao vẫn còn chênh lệch tới ~60 lần giữa go run (đã có cache) và ./app, dù cache biên dịch đã sẵn.
Đáp án
go run biên dịch rồi mới chạy mỗi lần được gọi: dù cache giúp bỏ qua bước biên dịch lại từ đầu, nó vẫn phải dựng một binary tạm trong thư mục cache, liên kết (link), khởi động tiến trình đó, chạy xong rồi xoá đi. ./app thì chỉ cần hệ điều hành nạp một binary có sẵn và chạy ngay — không có bước liên kết hay dựng tệp tạm nào. Phần liên kết và khởi động lại này là chi phí cố định mỗi lần gọi go run, kể cả khi cache biên dịch đã ấm.
Bài 2 (sửa lỗi). Đoạn mã sau không biên dịch được. Tìm đúng dòng gây lỗi và giải thích tại sao Go coi đây là lỗi cứng chứ không phải cảnh báo.
package main
import "fmt"
import "os"
func main() {
fmt.Println("chào")
}
Đáp án
Dòng import "os" gây lỗi biên dịch: imported and not used: "os". Go coi import không dùng là lỗi biên dịch cứng, không phải cảnh báo như nhiều ngôn ngữ khác — đây là ví dụ đầu tiên của triết lý Go: thà khó chịu lúc viết còn hơn để mã rác (import thừa) tích tụ dần trong dự án theo thời gian.
Bài 3 (đọc hiểu). Trong chương trình đầu tiên dưới đây, dòng nào là bắt buộc để chương trình sinh ra được một binary chạy được (chứ không phải một thư viện)?
package main
import "fmt"
func main() {
fmt.Println("Xin chào từ Go")
}
Đáp án
package main. Mọi tệp Go đều thuộc một package, và chỉ package tên main mới sinh ra binary chạy được; package tên khác sẽ sinh ra thư viện. Ngoài ra func main() (không tham số, không giá trị trả về) là điểm vào bắt buộc trong package main.
Bài 4 (vận dụng thực tế). Bạn đang phát triển một công cụ dòng lệnh và muốn gọi nó từ bất kỳ đâu trong terminal mà không cần gõ đường dẫn đầy đủ tới binary mỗi lần. Lệnh nào trong ba lệnh go run, go build, go install phù hợp nhất, và nó đặt kết quả ở đâu?
Đáp án
go install. Nó biên dịch chương trình rồi đặt binary vào $GOPATH/bin (mặc định ~/go/bin) — miễn thư mục đó nằm trong PATH, bạn gọi được công cụ từ mọi nơi bằng tên, không cần biết đường dẫn nguồn hay build lại thủ công.
Bài 5 (bẫy/đánh đổi). Bài viết khuyên "đừng bao giờ chạy go run trong sản xuất". Nêu đủ ba lý do bài viết đưa ra cho lời khuyên này.
Đáp án
Ba lý do: (1) bạn cần cài Go trên máy chủ đó, trong khi binary tĩnh của go build thì không cần; (2) bạn cần mã nguồn nằm sẵn trên máy chủ, làm lộ mã nguồn và tăng bề mặt cần quản lý; (3) mỗi lần khởi động tốn thêm ~60ms để biên dịch lại — với dịch vụ chạy thường trực hoặc khởi động nhiều lần, đây là chi phí không cần thiết. Ẩn dụ của bài: không ai nấu lại từng đĩa khi dọn cho khách — go build (nấu sẵn, đóng hộp) mới là thứ nên giao cho sản xuất.