Go Zero to Hero - Bài 52: Goroutines - Tạo luồng cực nhẹ với từ khoá go
Trong Golang, việc tạo và vận hành một luồng đồng thời (Goroutine) cực kỳ đơn giản. Tuy nhiên, để kiểm soát vòng đời của chúng một cách an toàn mà không rơi vào các cạm bẫy về thời gian, chúng ta cần nắm vững các cơ chế đồng bộ.
1. Phép thuật 2 chữ: Từ khóa go
Để biến một hàm bình thường (Function) chạy trong một luồng độc lập (Goroutine), bạn chỉ cần đặt từ khóa go ngay trước lời gọi hàm đó.
Hãy xem kịch bản một trạm thu phí AFC cần xử lý dữ liệu vé và đồng thời gửi email biên lai cho khách hàng:
package main
import (
"fmt"
"time"
)
func sendReceiptEmail(ticketID string) {
fmt.Println("[Email] Đang soạn email cho vé:", ticketID)
time.Sleep(2 * time.Second) // Giả lập việc gửi email tốn 2 giây
fmt.Println("[Email] Đã gửi thành công cho vé:", ticketID)
}
func main() {
fmt.Println("--- BẮT ĐẦU GIAO DỊCH ---")
// Gọi hàm BÌNH THƯỜNG (Chạy tuần tự - Synchronous)
// sendReceiptEmail("TXN-1001")
// Gọi hàm bằng GOROUTINE (Chạy bất đồng bộ - Asynchronous)
go sendReceiptEmail("TXN-1001")
fmt.Println("--- GIAO DỊCH HOÀN TẤT, MỞ CỔNG! ---")
}
Cái bẫy xuất hiện:
Nếu bạn chạy đoạn code trên, bạn sẽ thấy dòng chữ "--- GIAO DỊCH HOÀN TẤT, MỞ CỔNG! ---" in ra ngay lập tức, nhưng dòng báo gửi email thành công SẼ KHÔNG BAO GIỜ XUẤT HIỆN. Tại sao code không chạy đến cuối? Hàm gửi email của chúng ta bốc hơi đi đâu rồi?
2. Luật sinh tử: Hàm main là vị Vua tối cao
Để hiểu được cú lừa trên, bạn phải nằm lòng một quy luật bất di bất dịch của Go: Bản thân hàm main() cũng là một Goroutine (gọi là Main Goroutine).
Khi bạn gõ go sendReceiptEmail(...), Go Runtime lập tức rẽ nhánh, tạo ra một luồng mới chạy song song. Nhưng luồng main không hề đứng lại chờ luồng kia làm việc, nó tiếp tục chạy thẳng xuống dòng lệnh tiếp theo và... Kết thúc hàm main.
⚠️ Luật của Go: Khi Main Goroutine chết, TOÀN BỘ các Goroutine con khác đang chạy ngầm sẽ lập tức bị "bóp cổ" chết theo, không cần biết chúng đã làm xong việc hay chưa.
Hàm main chạy tốn chưa tới 1 milli-giây, trong khi hàm gửi email cần 2 giây. Do đó, hàm gửi email chưa kịp in ra kết quả thì đã bị hệ thống "tắt điện".
3. Cách giải quyết "Tồi tệ": Dùng time.Sleep
Nhiều người mới học sẽ "chữa cháy" bằng cách bắt hàm main ngủ một lát để chờ các luồng con chạy xong:
func main() {
go sendReceiptEmail("TXN-1001")
// "Ngủ" 3 giây để luồng con có thời gian chạy
time.Sleep(3 * time.Second)
fmt.Println("Main kết thúc!")
}
Tại sao Backend cấm kỵ cách này?
Trong thực tế, bạn không bao giờ biết một tác vụ I/O (gọi DB, gọi API) tốn chính xác bao nhiêu giây. Nếu mạng nhanh, nó tốn 0.5s, hàm main của bạn lãng phí 2.5s đứng chờ vô ích. Nếu mạng chậm tốn 5s, luồng con vẫn sẽ bị giết giữa chừng.
4. Giải pháp thực chiến: sync.WaitGroup
Đây là "bảo bối" chuẩn mực của dân Backend. WaitGroup hoạt động như một cái máy đếm số lượng công việc (Counter). Nó có 3 thao tác chính:
Add(int): Báo cho máy đếm biết bạn chuẩn bị tung ra bao nhiêu Goroutine (Cộng thêm vào bộ đếm).Done(): Gọi khi một Goroutine làm xong việc (Trừ đi 1 khỏi bộ đếm).Wait(): Bắt luồng hiện tại (thường làmain) phải chặn lại (Block) cho đến khi bộ đếm đếm ngược về đúng số 0.
Hãy viết lại kịch bản xử lý đồng loạt 5 chiếc vé ở cổng AFC bằng WaitGroup:
package main
import (
"fmt"
"sync"
"time"
)
// LUÔN truyền WaitGroup dưới dạng CON TRỎ (*sync.WaitGroup)
// Nếu truyền tham trị (Value), Go sẽ copy WaitGroup và bộ đếm sẽ bị sai lệch hoàn toàn!
func processTicket(id int, wg *sync.WaitGroup) {
// Đảm bảo Done() luôn được gọi khi hàm kết thúc, kể cả khi bị panic
defer wg.Done()
fmt.Printf("Máy quét đang xử lý vé %d...\n", id)
time.Sleep(1 * time.Second) // Giả lập tốc độ xử lý mạng
fmt.Printf("✅ Hoàn tất vé %d!\n", id)
}
func main() {
var wg sync.WaitGroup
// Giả sử có 5 vé cần xử lý đồng thời
totalTickets := 5
// 1. CỘNG VÀO BỘ ĐẾM
wg.Add(totalTickets)
for i := 1; i <= totalTickets; i++ {
// 2. TUNG GOROUTINE RA TRẬN
go processTicket(i, &wg)
}
fmt.Println("Đã tung hết 5 Goroutine. Main đang đứng chờ...")
// 3. ĐẶT CHỐT CHẶN
// Hàm main sẽ đứng khựng lại ở dòng này cho đến khi 5 cái defer wg.Done() được gọi
wg.Wait()
fmt.Println("--- TẤT CẢ GOROUTINE ĐÃ XONG. MAIN KẾT THÚC AN TOÀN! ---")
}
Khi chạy đoạn code trên, bạn sẽ thấy thứ tự hoàn thành của 5 chiếc vé in ra màn hình thay đổi lộn xộn mỗi lần chạy. Đó chính là sức mạnh của đa luồng: Chúng chạy hoàn toàn độc lập, đua nhau giành CPU, không ai phải đợi ai.
Tổng kết
Tạo luồng trong Go dễ như ăn kẹo, nhưng giữ cho nó sống sót mới là nghệ thuật:
- Chữ
go: Đặt trước hàm để chạy bất đồng bộ. - Vòng đời lệ thuộc: Hàm
mainkết thúc đồng nghĩa với toàn bộ Goroutine bị khai tử. - Cứu cánh
sync.WaitGroup: Tuyệt đối không dùngtime.Sleep. Hãy dùngAdd(),Done(), vàWait()để kiểm soát tiến độ công việc. - Luật con trỏ: Khi truyền
WaitGroupvào hàm khác, BẮT BUỘC phải truyền bằng Con trỏ*sync.WaitGroup.
All rights reserved