Goroutine và Channel trong Go: Worker Pool, errgroup và Context Cancellation

Concurrency (xử lý đồng thời) là điểm mạnh nhất của Go, đồng thời cũng là chủ đề khiến developer mới chuyển từ PHP, Node.js bối rối nhất. Khác với thread của hệ điều hành — nặng nề, mỗi thread chiếm 1–2MB bộ nhớ — goroutine chỉ tốn khoảng 2KB stack ban đầu và được Go runtime quản lý theo mô hình M:N scheduling: hàng trăm nghìn goroutine có thể chạy song song trên chỉ vài thread OS. Nhưng sức mạnh thực sự nằm ở cách các goroutine giao tiếp qua channel và cách bạn kiểm soát chúng bằng context. Bài này đi từ cú pháp cơ bản đến các pattern production-ready: worker pool, fan-out/fan-in, errgroup và context cancellation — thứ bạn dùng hằng ngày khi viết CLI tool, REST API hay công cụ DevOps bằng Go.

Goroutine và sync.WaitGroup — khởi đầu đúng cách

Goroutine là một hàm chạy độc lập, được tạo chỉ bằng từ khóa go. Vấn đề đầu tiên mọi người gặp phải: goroutine chính (main) thoát trước khi các goroutine con hoàn thành, khiến chương trình kết thúc sớm. sync.WaitGroup là công cụ đơn giản nhất để chờ đợi:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			fmt.Printf("Worker %d dang xu ly\n", n)
		}(i)
	}

	wg.Wait()
	fmt.Println("Tat ca goroutine da hoan thanh")
}

Ba điểm cần ghi nhớ. Một, wg.Add(1) phải được gọi trước khi goroutine chạy — đặt ngay trước go func, tuyệt đối không gọi bên trong goroutine vì wg.Wait()main có thể chạy trước và kết thúc chương trình sớm. Hai, luôn defer wg.Done() ngay đầu goroutine để counter được giảm dù hàm có panic. Ba, chú ý cách truyền i vào tham số n: trước Go 1.22, vòng lặp for tái sử dụng biến i, nên goroutine dùng trực tiếp i sẽ in ra giá trị cuối cùng — gotcha kinh điển của Go.

Nhưng WaitGroup chỉ giải quyết việc chờ đợi, không giải quyết việc truyền dữ liệu giữa các goroutine. Đó là lúc channel xuất hiện.

Channel — ống dẫn dữ liệu giữa các goroutine

Channel là kiểu dữ liệu hai hướng: gửi (send) với toán tử <- và nhận (receive) cũng với <-. Channel không buffer sẽ chặn goroutine gửi cho đến khi có goroutine khác nhận — đây chính là cơ chế đồng bộ hoá ngầm không cần lock. Channel có buffer cho phép gửi n phần tử trước khi bị chặn:

package main

import "fmt"

func main() {
	// Channel co buffer 3 phan tu
	ch := make(chan int, 3)

	// Gui 3 gia tri, khong bi chan vi buffer con trong
	ch <- 10
	ch <- 20
	ch <- 30

	// Dong channel sau khi gui xong
	close(ch)

	// Duyet toan bo gia tri con lai trong channel
	for v := range ch {
		fmt.Println(v)
	}
}

Khi dùng range để đọc channel, bắt buộc phải close(ch) sau khi gửi xong — nếu không vòng lặp chờ mãi và rơi vào deadlock. Nguyên tắc vàng: chỉ goroutine gửi mới được đóng channel; đóng channel đã đóng sẽ panic, gửi vào channel đã đóng cũng panic. Channel còn có thể khai báo hướng ngay trong tham số hàm: jobs <-chan int nghĩa là chỉ đọc, results chan<- int nghĩa là chỉ ghi. Go kiểm tra điều này ngay lúc compile — contract giữa các goroutine rõ ràng hơn nhiều so với shared memory trong ngôn ngữ khác.

Worker Pool — pattern quan trọng nhất trong Go

Worker pool là pattern xuất hiện ở hầu hết hệ thống Go production: giới hạn số tác vụ chạy đồng thời và xử lý hàng loạt công việc có kiểm soát. Ý tưởng: một channel jobs chứa danh sách công việc, N goroutine worker liên tục nhận job từ channel, kết quả ghi vào channel results:

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("Worker %d nhan job %d\n", id, j)
		time.Sleep(200 * time.Millisecond)
		results <- j * 2
	}
}

func main() {
	const numJobs = 10
	const numWorkers = 3

	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)

	var wg sync.WaitGroup
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			worker(id, jobs, results)
		}(w)
	}

	// Gui job vao hang doi roi dong lai
	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}
	close(jobs)

	wg.Wait()
	close(results)

	// Doc ket qua
	for r := range results {
		fmt.Println("Ket qua:", r)
	}
}

Vì sao pattern này quan trọng? Thứ nhất, giới hạn số worker (3) giúp kiểm soát tài nguyên — nếu tạo 10.000 goroutine, mỗi goroutine mở một kết nối SSH hay database, server sẽ cạn file descriptor rất nhanh. Thứ hai, channel jobs đóng vai trò hàng đợi tự nhiên: job gửi vào khi worker còn bận sẽ nằm trong buffer, không mất dữ liệu. Thứ ba, khi close(jobs), mọi worker tự động thoát vòng lặp range — đây là cách báo hiệu “hết việc” phổ biến nhất trong Go.

Fan-out và Fan-in — chia việc và gộp kết quả

Fan-out là kỹ thuật chia một luồng công việc cho nhiều goroutine xử lý song song — worker pool ở trên chính là một dạng fan-out điển hình. Fan-in ngược lại: gộp kết quả từ nhiều nguồn vào một channel duy nhất để một consumer xử lý tập trung. Hai pattern này xuất hiện liên tục trong pipeline xử lý dữ liệu: đọc log từ nhiều file, crawl nhiều website, resize hàng loạt ảnh, kiểm tra uptime của nhiều server. Cách kết hợp chuẩn: fan-out bằng worker pool, fan-in bằng cách cho mọi worker ghi vào chung một channel results (như code ở phần trên), rồi một goroutine duy nhất đọc results và tổng hợp.

errgroup — xử lý lỗi đồng thời đúng cách

WaitGroup chờ tất cả goroutine hoàn thành nhưng không quan tâm lỗi. Trong thực tế bạn thường cần dừng sớm khi một tác vụ thất bại — ví dụ kiểm tra hàng loạt URL, một URL chết thì không cần chờ phần còn lại. golang.org/x/sync/errgroup giải quyết chính xác bài toán này:

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"golang.org/x/sync/errgroup"
)

func main() {
	g, ctx := errgroup.WithContext(context.Background())

	urls := []string{
		"https://chuyendev.com",
		"https://codetot.vn",
		"https://khoipro.com",
	}

	for _, url := range urls {
		url := url
		g.Go(func() error {
			req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
			if err != nil {
				return err
			}
			client := &http.Client{Timeout: 5 * time.Second}
			resp, err := client.Do(req)
			if err != nil {
				return fmt.Errorf("loi khi goi %s: %w", url, err)
			}
			defer resp.Body.Close()
			if resp.StatusCode >= 400 {
				return fmt.Errorf("%s tra ve HTTP %d", url, resp.StatusCode)
			}
			fmt.Printf("OK: %s (%d)\n", url, resp.StatusCode)
			return nil
		})
	}

	// Tra ve loi dau tien xay ra
	if err := g.Wait(); err != nil {
		fmt.Println("Co loi xay ra:", err)
		return
	}
	fmt.Println("Tat ca URL deu hoat dong")
}

Điểm mạnh của errgroup.WithContext: context được tạo ra sẽ tự động bị cancel ngay khi goroutine đầu tiên trả về lỗi. Các goroutine còn lại nhận tín hiệu qua ctx.Done() và dừng sớm thay vì tiếp tục lãng phí tài nguyên. Lưu ý g.Wait() chỉ trả về lỗi đầu tiên; nếu cần thu thập nhiều lỗi, hãy dùng errors.Join hoặc một channel riêng.

Context Cancellation — nghệ thuật dừng đúng lúc

Goroutine leak là lỗi nghiêm trọng nhất trong ứng dụng Go: goroutine “quên” thoát, tích tụ dần đến khi server cạn RAM. Nguyên tắc bất biến: bất kỳ goroutine nào chạy lâu đều phải lắng nghe context và tự thoát khi bị huỷ. Pattern chuẩn là select với ctx.Done():

package main

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

func process(ctx context.Context, id int) error {
	select {
	case <-time.After(3 * time.Second):
		fmt.Printf("Job %d hoan thanh\n", id)
		return nil
	case <-ctx.Done():
		fmt.Printf("Job %d bi huy: %v\n", id, ctx.Err())
		return ctx.Err()
	}
}

func main() {
	// Toan bo chuoi goroutine se bi huy sau 2 giay
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	for i := 1; i <= 3; i++ {
		if err := process(ctx, i); err != nil {
			fmt.Println("Dung pipeline:", err)
			break
		}
	}
}

Trong ví dụ trên, context timeout 2 giây khiến job mất 3 giây bị huỷ giữa chừng. Hàm process luôn trả về — không có goroutine nào bị bỏ rơi. Khi viết API server bằng Fiber, hãy truyền c.Request().Context() xuống các hàm xử lý: client ngắt kết nối thì toàn bộ chuỗi goroutine phía sau tự động dừng. Đây là chi tiết phân biệt code “chạy được” và code “production-ready”.

Semaphore — giới hạn concurrency bằng buffered channel

Có những lúc bạn không cần worker pool phức tạp, chỉ cần giới hạn số goroutine chạy đồng thời. Một buffered channel với struct{} rỗng chính là semaphore: mỗi goroutine gửi một giá trị vào sem khi bắt đầu (chiếm một slot) và nhận ra khi kết thúc (trả slot):

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	// Toi da 3 goroutine chay dong thoi
	sem := make(chan struct{}, 3)
	var wg sync.WaitGroup

	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			sem <- struct{}{}        // chiem 1 slot
			defer func() { <-sem }() // tra slot

			fmt.Printf("Dang xu ly job %d\n", n)
			time.Sleep(time.Second)
		}(i)
	}

	wg.Wait()
	fmt.Println("Hoan thanh tat ca")
}

Vì sao dùng struct{}? Vì đây là zero-size type — không chiếm bất kỳ bộ nhớ nào, hoàn hảo cho vai trò “tín hiệu”. Pattern này rất hữu ích khi giới hạn số kết nối đồng thời tới database hoặc API bên thứ ba để tránh bị rate limit.

Case study — công cụ bảo trì hàng loạt tại Code Tốt

Trong quá trình triển khai CI/CD và bảo trì hàng loạt dự án WordPress cho khách hàng tại Code Tốt, chúng tôi từng gặp bài toán kinh điển: mỗi tháng phải SSH vào từng server để backup database, cập nhật plugin và xoá cache. Chạy tuần tự mất khoảng 45 phút cho toàn bộ fleet. Chúng tôi viết một CLI tool bằng Go — kết hợp worker pool 10 goroutine, errgroup để dừng ngay khi một server lỗi và context timeout 5 phút cho mỗi tác vụ SSH — thời gian maintenance giảm xuống còn khoảng 7 phút, và quan trọng hơn: không còn tác vụ nào bị treo khi server không phản hồi. Đây cũng là pattern mà Khôi Pro đúc kết thành checklist tái sử dụng cho mọi dự án DevOps tại agency.

Kết luận

Goroutine rẻ đến mức bạn có thể tạo hàng nghìn cái mà không lo lắng — nhưng sức mạnh thực sự nằm ở kỷ luật: luôn dùng WaitGroup hoặc errgroup để chờ đợi, luôn lắng nghe context để dừng đúng lúc, luôn giới hạn concurrency bằng worker pool hoặc semaphore. Nắm vững bốn pattern trong bài này, bạn đã xử lý được phần lớn bài toán đồng thời trong thực tế.

Bài viết thuộc series Go lang trên chuyendev.com. Đọc thêm:

Khoi Pro (Nguyễn Minh Khôi)

Founder of Code Tốt (codetot.vn). WordPress developer and performance optimization specialist. Author at Chuyên DEV (chuyendev.com). Personal site: khoipro.com.