Goroutines & Concurrency
1. Concurrency là gì?

Khi viết chương trình, có những công việc phải được thực hiện lần lượt, nhưng cũng có những công việc có thể tiến triển trong cùng một khoảng thời gian. Để hiểu cách Go xử lý nhiều công việc như vậy, trước tiên cần phân biệt ba khái niệm: Sequential, Concurrency và Parallelism.
Sequential (Tuần tự)
Sequential là cách thực hiện các công việc theo thứ tự. Công việc sau chỉ bắt đầu khi công việc trước hoàn thành.
Ví dụ, một chương trình cần thực hiện hai tác vụ:
- Tác vụ A mất 2 giây.
- Tác vụ B mất 2 giây.
Nếu thực hiện tuần tự, chương trình phải hoàn thành A rồi mới bắt đầu B. Tổng thời gian chờ sẽ khoảng 4 giây.
Concurrency (Đồng thời)
Concurrency là cách tổ chức chương trình để nhiều tác vụ có thể tiến triển trong cùng một khoảng thời gian, thay vì bắt buộc hoàn thành hoàn toàn tác vụ này mới bắt đầu tác vụ khác.
Ví dụ, một chương trình cần gửi hai HTTP request đến hai máy chủ khác nhau. Nếu gửi lần lượt và chờ từng máy chủ phản hồi, thời gian chờ có thể cộng dồn. Nếu tổ chức hai request để chúng tiến triển đồng thời, chương trình có thể chờ phản hồi của cả hai trong cùng một khoảng thời gian.
Concurrency đặc biệt hữu ích khi chương trình thường xuyên phải chờ các hoạt động như:
- Gửi và nhận dữ liệu qua mạng.
- Đọc hoặc ghi tệp.
- Giao tiếp với cơ sở dữ liệu.
- Chờ một dịch vụ bên ngoài phản hồi.
Trong những trường hợp này, chương trình có thể sử dụng thời gian chờ của một tác vụ để cho các tác vụ khác tiếp tục tiến triển.
Parallelism (Song song)
Parallelism là việc nhiều tác vụ thực sự được thực thi cùng một thời điểm, thường nhờ nhiều lõi CPU.
Ví dụ, nếu máy tính có nhiều lõi CPU và chương trình được phép sử dụng chúng, hai tác vụ tính toán có thể chạy trên hai lõi khác nhau cùng lúc.
Cần phân biệt rõ:
- Sequential: các tác vụ được thực hiện lần lượt.
- Concurrency: nhiều tác vụ có thể tiến triển xen kẽ hoặc chồng lấp trong cùng một khoảng thời gian.
- Parallelism: nhiều tác vụ thực sự được thực thi đồng thời tại cùng một thời điểm.
Concurrency không đồng nghĩa với Parallelism. Một chương trình có thể xử lý đồng thời nhiều tác vụ trên một lõi CPU bằng cách chuyển đổi giữa chúng, dù tại mỗi thời điểm lõi CPU chỉ thực thi một luồng lệnh.
Ngược lại, chương trình có thể tận dụng nhiều lõi CPU để thực hiện các tác vụ song song. Trong thực tế, Concurrency và Parallelism có thể kết hợp với nhau.
Vì sao cần xử lý đồng thời?
Mục tiêu không phải lúc nào cũng là làm cho CPU tính toán nhanh hơn. Đôi khi mục tiêu là tận dụng thời gian chờ, xử lý nhiều yêu cầu cùng lúc hoặc giúp chương trình phản hồi tốt hơn.
Ví dụ, một máy chủ Web có thể cần xử lý yêu cầu của nhiều người dùng. Nếu mỗi yêu cầu đều phải đợi yêu cầu trước hoàn thành, hiệu suất phục vụ có thể bị hạn chế. Cách tổ chức xử lý đồng thời cho phép máy chủ tiến triển với nhiều yêu cầu mà không nhất thiết phải xử lý chúng hoàn toàn theo thứ tự.
Tuy nhiên, xử lý đồng thời cũng làm tăng độ phức tạp của chương trình. Nếu nhiều tác vụ cùng truy cập dữ liệu dùng chung mà không được quản lý đúng cách, chương trình có thể phát sinh lỗi khó phát hiện.
2. Goroutine là gì?

Goroutine là một đơn vị thực thi nhẹ trong Go, được Go runtime quản lý. Goroutine cho phép chương trình khởi chạy một hàm để nó có thể tiến triển đồng thời với những công việc khác.
Trong Go, có thể khởi chạy một goroutine bằng từ khóa go:
go doWork()
Lệnh này yêu cầu Go khởi chạy lời gọi hàm doWork() dưới dạng một goroutine. Lời gọi hàm không còn phải chờ hàm hoàn thành theo cách thông thường; chương trình có thể tiếp tục thực hiện các lệnh tiếp theo.
Goroutine được Go runtime quản lý như thế nào?
Goroutine không đồng nghĩa với một OS thread (luồng hệ điều hành).
OS thread là luồng thực thi được hệ điều hành quản lý và lập lịch. Trong khi đó, Go runtime quản lý goroutine và lập lịch để chúng chạy trên các OS thread phù hợp.
Một chương trình Go có thể tạo ra nhiều goroutine mà không cần tạo một OS thread riêng cho mỗi goroutine. Runtime điều phối các goroutine trên những thread mà chương trình sử dụng. Khi điều kiện thực thi cho phép, nhiều goroutine có thể được chạy song song trên nhiều lõi CPU.
Goroutine được thiết kế để có chi phí tạo và quản lý tương đối thấp, nhưng không miễn phí. Mỗi goroutine vẫn sử dụng tài nguyên và cần có điều kiện kết thúc phù hợp.
So sánh Goroutine, OS Thread và C# Task
| Khái niệm | Vai trò |
|---|---|
| Goroutine | Đơn vị thực thi nhẹ do Go runtime quản lý và lập lịch. |
| OS Thread | Luồng thực thi được hệ điều hành quản lý và lập lịch. |
| C# Task | Đại diện cho một công việc hoặc thao tác bất đồng bộ trong .NET; không nhất thiết tương ứng với một thread riêng. |
Goroutine và C# Task có thể được so sánh ở góc độ tổ chức công việc, nhưng chúng không hoàn toàn tương đương. C# cũng có các cơ chế như Thread và async/await, mỗi cơ chế có vai trò khác nhau.
Điểm quan trọng cần nhớ là: goroutine cung cấp một cách tương đối đơn giản để khởi chạy nhiều công việc trong chương trình Go, còn Go runtime chịu trách nhiệm quản lý và lập lịch thực thi chúng.
3. Tạo và chạy Goroutine
Gọi hàm thông thường và gọi hàm bằng go
Xét hàm sau:
func doWork() {
fmt.Println("Working...")
}
Nếu gọi hàm thông thường:
doWork()
fmt.Println("Main continues")
Chương trình gọi doWork() và chờ hàm hoàn thành trước khi chạy fmt.Println("Main continues").
Nếu sử dụng từ khóa go:
go doWork()
fmt.Println("Main continues")
Go khởi chạy doWork() trong một goroutine, cho phép luồng thực thi hiện tại tiếp tục mà không phải chờ hàm hoàn thành.
Vì vậy, thứ tự xuất hiện của hai dòng thông báo không được đảm bảo. Dòng Main continues có thể xuất hiện trước hoặc sau Working..., tùy vào lịch thực thi.
Ví dụ 1: Chạy hai tác vụ tuần tự
package main
import (
"fmt"
"time"
)
func task(name string) {
fmt.Println(name, "started")
time.Sleep(2 * time.Second)
fmt.Println(name, "finished")
}
func main() {
start := time.Now()
task("A")
task("B")
fmt.Println("Total:", time.Since(start))
}
Ở đây, time.Sleep(2 * time.Second) làm cho goroutine hiện tại tạm dừng trong hai giây.
Chương trình thực hiện A trước, sau đó mới thực hiện B. Tổng thời gian thường vào khoảng 4 giây, chưa tính đáng kể chi phí phụ của chương trình.
Ví dụ 2: Chạy hai tác vụ bằng Goroutine
package main
import (
"fmt"
"sync"
"time"
)
func task(name string, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println(name, "started")
time.Sleep(2 * time.Second)
fmt.Println(name, "finished")
}
func main() {
start := time.Now()
var wg sync.WaitGroup
wg.Add(2)
go task("A", &wg)
go task("B", &wg)
wg.Wait()
fmt.Println("Total:", time.Since(start))
}
Trong ví dụ này, chương trình khởi chạy hai goroutine. Cả hai đều tạm dừng hai giây, nên tổng thời gian thường gần 2 giây thay vì 4 giây.
Đây là một ví dụ đơn giản về việc chồng lấp thời gian chờ. Nó không chứng minh rằng goroutine luôn giúp chương trình chạy nhanh hơn. Kết quả phụ thuộc vào loại công việc, tài nguyên và cách lập lịch.
Một điểm cần lưu ý: thứ tự các thông báo như A started và B started có thể thay đổi giữa những lần chạy khác nhau. Chương trình không nên dựa vào một thứ tự cụ thể nếu không có cơ chế điều phối phù hợp.
Vì sao main() kết thúc có thể khiến Goroutine chưa hoàn thành bị kết thúc theo?
Trong Go, khi hàm main() kết thúc, chương trình kết thúc. Các goroutine khác không tự động giữ cho chương trình tiếp tục chạy đến khi tất cả hoàn thành.
Ví dụ:
package main
import (
"fmt"
"time"
)
func task() {
time.Sleep(2 * time.Second)
fmt.Println("Task finished")
}
func main() {
go task()
fmt.Println("Main finished")
}
main() có thể kết thúc gần như ngay sau khi in Main finished. Khi chương trình kết thúc, thông báo Task finished có thể chưa bao giờ được in.
Điều này không có nghĩa là goroutine đã tự hoàn thành. Nó có nghĩa là vòng đời của chương trình không còn được duy trì để chờ goroutine đó.
Nếu cần đảm bảo công việc hoàn thành trước khi chương trình kết thúc, phải có cơ chế đồng bộ thích hợp.
4. Đồng bộ Goroutine với sync.WaitGroup
Khi một chương trình khởi chạy nhiều goroutine, đôi khi nó cần biết khi nào tất cả các công việc đã hoàn thành.
sync.WaitGroup là một kiểu trong thư viện chuẩn sync, dùng để chờ một nhóm goroutine hoàn thành công việc.
Có ba phương thức thường được sử dụng:
Add(n): tăng bộ đếm để thông báo có thêm các công việc cần chờ.Done(): giảm bộ đếm một đơn vị, thường được gọi khi một goroutine hoàn thành.Wait(): chặn goroutine gọi nó cho đến khi bộ đếm trở về 0.
Ví dụ hoàn chỉnh
package main
import (
"fmt"
"sync"
"time"
)
func task(name string, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println(name, "started")
time.Sleep(2 * time.Second)
fmt.Println(name, "finished")
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go task("A", &wg)
go task("B", &wg)
wg.Wait()
fmt.Println("All tasks finished")
}
Hãy xem trình tự hoạt động:
Bước 1: var wg sync.WaitGroup tạo một WaitGroup với bộ đếm ban đầu bằng 0.
Bước 2: wg.Add(2) tăng bộ đếm lên 2, tương ứng với hai công việc mà chương trình dự định chờ.
Bước 3: Hai lời gọi go task(...) khởi chạy hai goroutine. Mỗi goroutine thực hiện công việc riêng của mình.
Bước 4: defer wg.Done() đăng ký gọi Done() khi hàm task kết thúc. Vì vậy, mỗi goroutine sẽ giảm bộ đếm một đơn vị khi hoàn thành.
Bước 5: wg.Wait() chờ cho đến khi cả hai goroutine gọi Done() và bộ đếm trở về 0.
Bước 6: Chương trình tiếp tục in All tasks finished.
Lưu ý rằng WaitGroup không tự khởi chạy goroutine và cũng không tự xử lý kết quả trả về. Nó chỉ giúp theo dõi số lượng công việc cần chờ.
Vì sao truyền *sync.WaitGroup thay vì tạo bản sao?
Hãy xét khai báo:
func task(name string, wg *sync.WaitGroup)
Ký hiệu *sync.WaitGroup nghĩa là tham số wg có kiểu con trỏ đến sync.WaitGroup.
Khi gọi:
go task("A", &wg)
Toán tử &wg lấy địa chỉ của biến wg bên ngoài. Hàm task nhận địa chỉ đó thông qua tham số con trỏ, nên có thể gọi Done() trên chính WaitGroup ban đầu.
Cần phân biệt hai thao tác:
wglà biến con trỏ chứa địa chỉ của đối tượng.wg.Done()gọi phương thứcDone()trên đối tượng mà con trỏ đang trỏ đến.
Go truyền tham số bằng giá trị, kể cả con trỏ. Vì vậy, giá trị địa chỉ được sao chép vào tham số, nhưng cả hai con trỏ đều tham chiếu đến cùng một WaitGroup.
Nếu mỗi goroutine làm việc trên một bản sao độc lập của WaitGroup, bộ đếm của bản sao sẽ không cập nhật bộ đếm của đối tượng gốc mà main() đang chờ. Vì thế, trong ví dụ này, các goroutine cần dùng chung một WaitGroup.
Không được sao chép một WaitGroup sau khi nó đã bắt đầu được sử dụng. Hãy truyền con trỏ khi cần nhiều hàm cùng thao tác trên một đối tượng WaitGroup.
Đo thời gian thực thi
Có thể dùng time.Now() và time.Since() để đo thời gian:
start := time.Now()
// Khởi chạy công việc và chờ hoàn thành.
fmt.Println("Total:", time.Since(start))
Khi so sánh chương trình tuần tự và chương trình đồng thời, cần bảo đảm chúng thực hiện cùng một khối lượng công việc. Trong ví dụ sử dụng time.Sleep, hai tác vụ có thể chồng lấp thời gian chờ nên tổng thời gian giảm xuống. Với tác vụ tính toán nặng, kết quả có thể khác và phụ thuộc vào khả năng xử lý song song của máy tính.
5. Những lưu ý khi sử dụng Goroutine
Thứ tự thực thi không được đảm bảo
Khi khởi chạy goroutine bằng từ khóa go, chương trình không thể mặc định rằng goroutine đó sẽ chạy ngay lập tức hoặc hoàn thành trước một goroutine khác.
Ví dụ:
go fmt.Println("A")
go fmt.Println("B")
Thứ tự in A và B không được đảm bảo. Nếu chương trình cần một thứ tự cụ thể, cần thiết kế cơ chế điều phối thay vì dựa vào thời điểm chạy tình cờ.
Tạo quá nhiều Goroutine có thể gây tốn tài nguyên
Goroutine có chi phí quản lý tương đối thấp, nhưng mỗi goroutine vẫn cần bộ nhớ và tài nguyên thực thi. Nếu tạo số lượng rất lớn mà không kiểm soát, chương trình có thể tiêu tốn nhiều tài nguyên hoặc gây áp lực lên các dịch vụ bên ngoài.
Vì vậy, nên cân nhắc số lượng công việc được khởi chạy đồng thời, đặc biệt khi chúng truy cập cơ sở dữ liệu, gửi request mạng hoặc sử dụng tài nguyên có giới hạn.
Goroutine cần có điều kiện kết thúc
Một goroutine bị chặn vô thời hạn hoặc tiếp tục chạy mà không còn hữu ích có thể giữ tài nguyên không cần thiết. Tình trạng này thường được gọi là goroutine leak.
Khi thiết kế chương trình, cần xác định công việc bắt đầu khi nào, hoàn thành khi nào và điều gì xảy ra nếu công việc không thể tiếp tục.
Các cơ chế quản lý vòng đời và hủy công việc có thể giúp giải quyết vấn đề này; chúng sẽ được tìm hiểu sâu hơn ở những chủ đề riêng.
Concurrency không phải lúc nào cũng làm chương trình nhanh hơn
Concurrency có thể giúp tận dụng thời gian chờ và tăng khả năng xử lý nhiều công việc. Tuy nhiên, nó cũng có chi phí như tạo goroutine, điều phối thực thi và đồng bộ dữ liệu.
Với một tác vụ nhỏ hoặc một phép tính đơn giản, việc chạy tuần tự có thể dễ hiểu và hiệu quả hơn. Với tác vụ cần chờ nhiều nguồn dữ liệu, tổ chức xử lý đồng thời có thể đem lại lợi ích rõ rệt.
Do đó, quyết định sử dụng goroutine nên dựa trên đặc điểm của bài toán và kết quả đo đạc, không phải giả định rằng càng nhiều goroutine thì chương trình càng nhanh.
6. Kết luận
Goroutine là một trong những cơ chế quan trọng giúp Go tổ chức các công việc có thể tiến triển đồng thời. Thông qua từ khóa go, chương trình có thể khởi chạy nhiều lời gọi hàm mà không cần thực hiện chúng hoàn toàn theo thứ tự.
Tuy nhiên, khởi chạy goroutine chỉ là bước đầu. Chương trình còn cần quản lý thời điểm các công việc hoàn thành, bảo đảm vòng đời của chúng phù hợp và tránh tạo ra những tác vụ không còn cần thiết. sync.WaitGroup cung cấp một cách đơn giản để chờ một nhóm goroutine hoàn thành.
Những kiến thức cốt lõi cần nắm gồm:
- Phân biệt Sequential, Concurrency và Parallelism.
- Hiểu goroutine là đơn vị thực thi do Go runtime quản lý.
- Biết cách khởi chạy goroutine bằng từ khóa
go. - Hiểu vì sao chương trình có thể kết thúc trước khi các goroutine hoàn thành.
- Sử dụng
sync.WaitGroupđể chờ các công việc cần thiết. - Nhận thức được giới hạn về tài nguyên và việc concurrency không luôn giúp chương trình nhanh hơn.
Nguồn tham khảo








Khoá học lập trình game con rắn cho trẻ em




