Goroutines & Channels — Sức mạnh concurrency của Go không chỉ là 'chạy song song'

Phong

Hồi mới học Go, mình cứ đau đầu tự hỏi: sao người ta cứ khen concurrency của Go ghê vậy? Mấy ngôn ngữ khác cũng có thread với async/await mà, có gì đặc biệt đâu? Mãi tới khi mình thực sự ngồi viết một cái tool nhỏ phải crawl dữ liệu từ nhiều nguồn cùng lúc, mình mới thấm.

Thì ra cái khác biệt lớn nhất không phải là goroutines hay channels tự thân, mà là Go bắt bạn nghĩ về concurrency một cách có cấu trúc ngay từ đầu. Thread của Java hay Python thì nặng, mỗi thread tốn cả MB bộ nhớ. Async/await của JavaScript thì đỡ hơn nhưng vẫn phải quản lý Promise phức tạp. Còn goroutines của Go thì nhẹ hơn hẳn — hàng chục ngàn goroutines chạy cùng lúc trên một vài OS thread là chuyện thường, mỗi goroutine chỉ tốn khoảng 4KB stack.

Go programming code trên màn hình Ảnh: Stanislav Kondratiev — Pexels

Rồi tới channels — cái này mới thú vị. Trong C# hay Java, muốn giao tiếp giữa các thread thì phải dùng shared memory với lock, semaphore, mutex đủ kiểu. Dùng sai là deadlock, race condition, đau đầu cả tuần. Go chọn cách tiếp cận khác: "Do not communicate by sharing memory; instead, share memory by communicating." Channels là ống dẫn dữ liệu giữa các goroutines — mình chỉ cần gửi dữ liệu vào channel, goroutine khác nhận ra, khỏi lo lock với unlock.

Mình thấy mê nhất là pattern fan-in, fan-out kết hợp goroutines với channels. Có lần mình cần xử lý một file log 2GB — chia nhỏ ra 16 phần, mỗi phần cho một goroutine xử lý (fan-out), rồi gom kết quả qua một channel chung (fan-in). Toàn bộ code xử lý concurrency chỉ gói gọn trong 30 dòng, không lock, không sync primitive phức tạp. Hồi xưa làm tương tự với Python ThreadPoolExecutor, code dài hơn gấp 3 mà còn gặp bug GIL rình rập.

Lập trình viên đang tập trung code Ảnh: cottonbro studio — Pexels

Tất nhiên goroutines không phải không có bẫy. Nếu không cẩn thận, goroutine leak cũng dễ như leak memory. Một goroutine chờ mãi trên một channel không ai ghi — thế là tốn tài nguyên vô ích. Hay deadlock khi dùng unbuffered channel sai cách. Nhưng mà mình nghĩ cái hay của Go là bạn phải chủ động nghĩ về lifecycle của goroutine, chứ không thể vô tư phóng ra mà quên dọn. Công thức của mình: mỗi khi viết go func(), tự hỏi ngay "khi nào goroutine này kết thúc?".

Nếu bạn mới bắt đầu với Go, mình nghĩ concurrency là thứ đáng học nhất — vì nó thay đổi cách bạn suy nghĩ về code chạy song song.

📋 Phụ lục thuật ngữ

  • Goroutine — một lightweight thread do Go runtime quản lý, chạy trên một hoặc vài OS thread thông qua M:N scheduler
  • Channel — ống dẫn để goroutines giao tiếp với nhau qua giá trị, hỗ trợ cả unbuffered (blocking) và buffered (non-blocking tới một giới hạn)
  • Fan-in / Fan-out — pattern chia nhỏ tác vụ để nhiều goroutines cùng xử lý (fan-out) và gom kết quả về một channel chung (fan-in)