0

Go Zero to Hero - Bài 65: Quản lý vòng đời Goroutine với context.WithCancel

1. Cấu phẫu context.WithCancel

Hàm context.WithCancel nhận vào một Context cha (thường là context.Background()) và sinh ra hai thứ:

  • Một Context con (ctx): Context này sẽ được truyền đi khắp nơi, thâm nhập vào các Goroutines đang chạy.
  • Một hàm hủy (cancelFunc): Đây chính là cái "nút bấm tự hủy". Hàm này được giữ lại ở luồng quản lý (luồng cha). Bất cứ khi nào bạn gọi cancel(), tín hiệu tử thần sẽ được phát đi.
// Tạo một Context có khả năng bị hủy từ một Context gốc
ctx, cancel := context.WithCancel(context.Background())

2. Lắng nghe tín hiệu tử thần bằng <-ctx.Done()

Nếu hàm cha đã bấm nút cancel(), làm sao các Goroutines con đang mải mê làm việc biết được điều đó để dừng lại?

Hãy nhớ lại nguyên lý cơ bản của Channel: Khi một Channel bị close, bất kỳ ai đang chờ rút dữ liệu từ ống đó sẽ lập tức được thông chốt.

Trong đối tượng ctx có một Method cực kỳ lợi hại là Done(). Method này trả về một Channel <-chan struct{}. Khi hàm cancel() được gọi, Go sẽ đóng (close) cái Channel của ctx.Done(). Do đó, bên trong các Goroutines con, chúng ta chỉ cần dùng vòng lặp và lệnh select để liên tục nghe ngóng cái ống này.


3. Thực chiến: Hủy lệnh xuất vé tại trạm AFC

Hãy xem kịch bản: Hệ thống đang tạo báo cáo xuất vé (mất 5 giây). Nhưng ở giây thứ 2, người quản trị viên đột ngột bấm nút "Hủy" trên giao diện Admin.

package main

import (
    "context"
    "fmt"
    "time"
)

// Worker xử lý công việc tốn thời gian
// Chú ý: ctx LUÔN là tham số đầu tiên!
func generateTicketReport(ctx context.Context, workerID int) {
    fmt.Printf("[Worker %d] Bắt đầu gom dữ liệu vé...\n", workerID)
    
    // Giả lập vòng lặp xử lý dữ liệu liên tục
    for {
        select {
        case <-ctx.Done(): // 🚨 Lắng nghe tín hiệu HỦY
            // Hàm cancel() được gọi -> Channel Done() bị đóng -> case này kích hoạt!
            fmt.Printf("🛑 [Worker %d] Nhận được lệnh HỦY! Lập tức dừng công việc, giải phóng RAM.\n", workerID)
            
            // In ra lý do hủy (thường là "context canceled")
            fmt.Println("Lý do:", ctx.Err()) 
            return // KẾT THÚC Goroutine ngay lập tức!
            
        default:
            // Nếu chưa có lệnh hủy, tiếp tục làm việc bình thường
            fmt.Printf("[Worker %d] Đang xử lý dòng dữ liệu...\n", workerID)
            time.Sleep(500 * time.Millisecond) // Tốn 0.5s cho 1 dòng
        }
    }
}

func main() {
    // 1. Tạo Context có khả năng hủy
    ctx, cancel := context.WithCancel(context.Background())

    // 2. Kích hoạt luồng chạy ngầm, truyền ctx vào
    go generateTicketReport(ctx, 101)

    // Hàm main (Người quản lý) làm việc khác...
    time.Sleep(2 * time.Second)

    // 3. ĐIỀU KIỆN ĐỘT BIẾN
    fmt.Println("\n[Admin] Phát hiện lỗi cấu hình trạm! Bấm nút HỦY TOÀN BỘ!")
    
    // Bóp cò phát tín hiệu hủy diệt!
    cancel()

    // Chờ 1 chút để xem log in ra trước khi main kết thúc
    time.Sleep(1 * time.Second)
    fmt.Println("--- HỆ THỐNG ĐÃ TRỞ VỀ TRẠNG THÁI AN TOÀN ---")
}

Kết quả in ra: Worker sẽ miệt mài in dòng [Worker 101] Đang xử lý dòng dữ liệu... khoảng 4 lần. Đến giây thứ 2, hàm main gọi cancel(), tín hiệu lan truyền xuống hàm con, nhánh <-ctx.Done() bị chọc thủng, và Worker lập tức in ra dòng Nhận được lệnh HỦY! rồi tắt đài. Máy chủ được cứu!


4. Bẫy "Rò rỉ Context" (Context Leak) - Kẻ thù vô hình

Đây là lỗi cực kỳ phổ biến của các lập trình viên khi làm việc với WithCancel.

Hãy tưởng tượng một Request chạy rất mượt mà, hoàn thành xuất sắc trong 1 giây mà không hề bị ai bấm nút hủy. Bạn nghĩ mọi chuyện đã xong? Không!

Nếu bạn tạo ra một context.WithCancel mà không bao giờ gọi hàm cancel(), các tài nguyên ngầm (các Goroutine giám sát sự kiện của Go Runtime) dùng để duy trì cái context đó sẽ bị treo lại trong bộ nhớ vĩnh viễn, gây ra Context Leak.

Luật sinh tồn: Dù Request có thành công hay thất bại, dù có cần hủy hay không, HÀM cancel() BẮT BUỘC PHẢI ĐƯỢC GỌI KHI KHỐI LỆNH CHA KẾT THÚC.

Cách viết chuẩn mực nhất là luôn kẹp defer ngay sát bên dưới dòng khởi tạo:

func processHTTPRequest() {
    // 1. Khởi tạo
    ctx, cancel := context.WithCancel(context.Background())
    
    // 2. Dọn dẹp tuyệt đối an toàn
    // Đảm bảo cancel() luôn được gọi dọn rác, kể cả khi hàm này chạy thành công mĩ mãn
    defer cancel() 
    
    // 3. Logic nghiệp vụ...
    go doSomething(ctx)
}

💡 Tổng kết

Bạn đã nắm trong tay quyền sát sinh các Goroutine:

  • Vũ khí: ctx, cancel := context.WithCancel(...).
  • Lắng nghe: Trong các luồng con, dùng select kết hợp với <-ctx.Done() để kiểm tra xem đã có lệnh tử hình chưa.
  • Hành động: Khi <-ctx.Done() phát nổ, lập tức return để kết thúc Goroutine, nhả kết nối DB, nhả RAM.
  • Phòng vệ: Luôn gọi defer cancel() ở luồng cha để chống rò rỉ tài nguyên (Context Leak).

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí