Goroutine và channel: nền tảng concurrency trong Go

1. Tổng quan

Go được thiết kế để viết chương trình concurrent một cách đơn giản, không cần callback hell hay thread pool phức tạp. Hai khối xây dựng cốt lõi là goroutine (đơn vị thực thi nhẹ) và channel (kênh giao tiếp giữa các goroutine). Đọc xong bài này, bạn sẽ biết cách khởi tạo goroutine, đồng bộ chúng bằng sync.WaitGroup, giao tiếp an toàn qua channel, dùng select để xử lý nhiều channel cùng lúc, và tránh những lỗi phổ biến như goroutine leak hay deadlock.

Toàn bộ ví dụ dùng Go 1.22+, không cần thư viện ngoài — bạn chỉ cần go run main.go là chạy được.

2. Goroutine là gì

Goroutine là một hàm chạy độc lập, được quản lý bởi Go runtime thay vì hệ điều hành. Chi phí tạo một goroutine rất rẻ (vài KB stack, tự động co giãn), nên bạn có thể tạo hàng chục nghìn goroutine mà không lo cạn tài nguyên như thread hệ thống.

package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Println("Xin chào,", name)
}

func main() {
	go sayHello("Order Service")
	go sayHello("Payment Service")

	time.Sleep(100 * time.Millisecond) // chờ goroutine chạy xong
	fmt.Println("Main kết thúc")
}

Từ khóa go đứng trước lệnh gọi hàm sẽ chạy hàm đó trong một goroutine mới, không block main. Vì main có thể kết thúc trước khi hai goroutine kia kịp in ra, ta phải time.Sleep tạm để chờ — đây là cách làm không đáng tin cậy, chỉ dùng để minh họa. Output có thể là:

Xin chào, Payment Service
Xin chào, Order Service
Main kết thúc

Thứ tự in ra không cố định vì scheduler quyết định goroutine nào chạy trước. Đây chính là bản chất concurrency: nhiều việc diễn ra “đồng thời” nhưng không đảm bảo thứ tự.

Lưu ý: Dùng time.Sleep để đồng bộ goroutine trong code thật là anti-pattern. Mục 4 sẽ giới thiệu sync.WaitGroup để thay thế.

3. Channel: kênh giao tiếp giữa các goroutine

Channel là ống dẫn type-safe để goroutine gửi và nhận dữ liệu. Triết lý của Go là: “Don’t communicate by sharing memory; share memory by communicating.”

package main

import "fmt"

func calculateTotal(amount float64, result chan<- float64) {
	tax := amount * 0.1
	result <- amount + tax
}

func main() {
	result := make(chan float64)

	go calculateTotal(1_000_000, result)

	total := <-result
	fmt.Printf("Tổng tiền đơn hàng: %.0f\n", total)
}
  • make(chan float64) tạo một unbuffered channel kiểu float64.
  • chan<- float64 trong tham số hàm nghĩa là channel chỉ dùng để gửi (send-only), giúp compiler bắt lỗi nếu bạn lỡ đọc từ nó.
  • result <- amount + tax gửi giá trị vào channel.
  • <-result ở main sẽ block cho đến khi có dữ liệu, đóng vai trò đồng bộ hóa tự nhiên — không cần Sleep.
Tổng tiền đơn hàng: 1100000

3.1. Unbuffered vs buffered channel

Unbuffered channel (make(chan T)) yêu cầu người gửi và người nhận “gặp nhau” cùng lúc — gửi sẽ block cho tới khi có người nhận. Buffered channel (make(chan T, n)) cho phép gửi tối đa n giá trị mà không cần người nhận sẵn sàng.

package main

import "fmt"

func main() {
	orders := make(chan string, 3)

	orders <- "Order-1"
	orders <- "Order-2"
	orders <- "Order-3"
	close(orders)

	for order := range orders {
		fmt.Println("Xử lý:", order)
	}
}

Vì buffer có dung lượng 3, ba lệnh gửi không bị block. close(orders) báo hiệu không còn dữ liệu, giúp vòng lặp range tự kết thúc thay vì block vô hạn chờ giá trị tiếp theo.

Xử lý: Order-1
Xử lý: Order-2
Xử lý: Order-3

Lưu ý: Chỉ bên gửi mới được close channel. Gửi vào channel đã đóng gây panic send on closed channel; đọc từ channel đã đóng vẫn hợp lệ, trả về giá trị zero và ok == false.

4. Đồng bộ nhiều goroutine với WaitGroup

Khi cần chờ nhiều goroutine hoàn thành mà không quan tâm giá trị trả về, sync.WaitGroup là lựa chọn gọn gàng hơn channel.

package main

import (
	"fmt"
	"sync"
)

func processOrder(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Đã xử lý Order-%d\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go processOrder(i, &wg)
	}

	wg.Wait()
	fmt.Println("Tất cả order đã xử lý xong")
}
  • wg.Add(1) tăng bộ đếm trước khi goroutine chạy — phải gọi trước go, không phải bên trong goroutine, để tránh race condition.
  • defer wg.Done() giảm bộ đếm khi goroutine kết thúc.
  • wg.Wait() block main cho đến khi bộ đếm về 0.
Đã xử lý Order-2
Đã xử lý Order-1
Đã xử lý Order-3
Tất cả order đã xử lý xong

Thứ tự ba dòng đầu không cố định, nhưng dòng cuối luôn in sau cùng — đó là điều WaitGroup đảm bảo.

4.1. Kiểm thử hành vi concurrent

Bạn có thể viết test để xác nhận tất cả goroutine đều hoàn thành, dùng kênh đếm kết quả thay vì phụ thuộc thứ tự in:

package orderpkg

import (
	"sync"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestProcessOrders_AllCompleted(t *testing.T) {
	var wg sync.WaitGroup
	var processed int32

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			atomic.AddInt32(&processed, 1)
		}(i)
	}

	wg.Wait()

	assert.Equal(t, int32(5), processed)
}

atomic.AddInt32 tránh race condition khi nhiều goroutine cùng tăng biến đếm — nếu dùng processed++ thông thường, go test -race sẽ báo lỗi data race.

Dependency cho test (thêm vào go.mod):

go get github.com/stretchr/testify

5. Select: xử lý nhiều channel cùng lúc

select giống switch nhưng dành cho channel — chọn case nào sẵn sàng trước để thực thi. Rất hữu ích khi bạn cần chờ nhiều nguồn dữ liệu hoặc đặt timeout.

package main

import (
	"fmt"
	"time"
)

func fetchFromCache(ch chan<- string) {
	time.Sleep(50 * time.Millisecond)
	ch <- "dữ liệu từ cache"
}

func fetchFromDB(ch chan<- string) {
	time.Sleep(200 * time.Millisecond)
	ch <- "dữ liệu từ database"
}

func main() {
	cacheCh := make(chan string)
	dbCh := make(chan string)

	go fetchFromCache(cacheCh)
	go fetchFromDB(dbCh)

	select {
	case res := <-cacheCh:
		fmt.Println("Nhận được:", res)
	case res := <-dbCh:
		fmt.Println("Nhận được:", res)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("Timeout, dùng dữ liệu mặc định")
	}
}

select block cho đến khi một trong các case có dữ liệu sẵn sàng. Vì cache trả về sau 50ms còn DB sau 200ms, case cacheCh thắng:

Nhận được: dữ liệu từ cache

time.After tạo một channel nhận giá trị sau khoảng thời gian chỉ định, dùng để implement timeout cho toàn bộ select.

6. Hủy goroutine đúng cách với context

Một lỗi phổ biến là khởi tạo goroutine nhưng không có cách nào dừng nó, dẫn đến goroutine leak. Package context giải quyết vấn đề này bằng cách truyền tín hiệu hủy xuyên suốt call chain.

package main

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

func monitorPayment(ctx context.Context, orderID string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Dừng theo dõi order:", orderID, "-", ctx.Err())
			return
		case <-time.After(500 * time.Millisecond):
			fmt.Println("Kiểm tra trạng thái order:", orderID)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
	defer cancel()

	go monitorPayment(ctx, "Order-99")

	<-ctx.Done()
	time.Sleep(50 * time.Millisecond) // chờ goroutine in nốt dòng cuối
}
  • context.WithTimeout tạo context tự động Done() sau 1200ms.
  • Trong monitorPayment, select lắng nghe cả ctx.Done() lẫn tick định kỳ. Khi context hết hạn, goroutine thoát sạch sẽ thay vì chạy mãi.
  • defer cancel() đảm bảo giải phóng tài nguyên context ngay cả khi hàm kết thúc sớm.
Kiểm tra trạng thái order: Order-99
Kiểm tra trạng thái order: Order-99
Dừng theo dõi order: Order-99 - context deadline exceeded

Lưu ý: Luôn gọi cancel() (qua defer) dù dùng WithTimeout hay WithCancel. Bỏ quên bước này không gây lỗi ngay nhưng làm rò rỉ context trong các vòng lặp dài hạn.

7. Lỗi thường gặp

Deadlock vì channel không có người nhận. Gửi vào unbuffered channel mà không có goroutine nào đọc sẽ khiến chương trình đứng im và Go runtime tự phát hiện:

func main() {
	ch := make(chan int)
	ch <- 1 // không ai đọc → deadlock
}
fatal error: all goroutines are asleep - deadlock!

Goroutine leak. Goroutine chờ đọc channel mãi mãi vì không ai gửi và không có cơ chế hủy — bộ nhớ bị giữ lại cho đến khi chương trình kết thúc. Luôn kèm context hoặc channel “done” khi khởi tạo goroutine chạy dài hạn.

Closure bắt biến vòng lặp sai (trước Go 1.22). Nếu bạn dùng Go < 1.22, biến lặp i trong for được chia sẻ giữa các lần lặp, khiến tất cả goroutine có thể thấy cùng một giá trị cuối cùng. Từ Go 1.22 trở đi, mỗi vòng lặp tạo biến i mới nên vấn đề này đã biến mất — nhưng nếu dự án bạn dùng phiên bản cũ hơn, hãy truyền biến qua tham số như ví dụ ở mục 4.1.

Race condition khi chia sẻ biến. Hai goroutine cùng đọc/ghi một biến không qua channel hay sync/atomic là race condition kinh điển. Luôn chạy go test -race hoặc go run -race trong giai đoạn phát triển để phát hiện sớm.

Khi nào KHÔNG nên dùng goroutine: với tác vụ ngắn, chi phí tạo goroutine và đồng bộ hóa có thể lớn hơn lợi ích. Nếu công việc chỉ mất vài microsecond, chạy tuần tự thường nhanh hơn và dễ đọc hơn.

8. Kết luận

Goroutine cho bạn cách tạo luồng thực thi nhẹ với chi phí cực thấp, còn channel cung cấp cơ chế giao tiếp an toàn thay vì chia sẻ bộ nhớ trực tiếp. Kết hợp với sync.WaitGroup để đồng bộ, select để xử lý đa nguồn, và context để hủy đúng lúc, bạn đã có đủ công cụ để xây dựng các pattern concurrency phổ biến trong Go — từ worker pool, pipeline, đến fan-in/fan-out. Bước tiếp theo nên là thực hành với go test -race để rèn thói quen phát hiện race condition ngay từ sớm.

golanggoroutinechannelconcurrency