Tổng quan về Goroutine trong Go
Các khái niệm cơ bản
Trước khi tìm hiểu sâu về Goroutine, chúng ta cần làm rõ một số thuật ngữ:
- Quá trình (Process): Là lần thực thi của một chương trình trên hệ điều hành. Mỗi lần chạy chương trình, hệ điều hành sẽ gán cho nó một ID và tài nguyên độc lập.
- Làn (Thread): Là một thực thể thuộc quá trình, là đơn vị nhỏ nhất mà CPU có thể phân bổ và thực hiện.
Về bình song (Parallel) và cùng lúc (Concurrency):
- Bình song: Nhiều công việc được thực hiện đồng thời trên nhiều CPU khác nhau.
- Cùng lúc: Các tác vụ luân phiên sử dụng tài nguyên CPU để tạo ảo giác rằng chúng đang hoạt động đồng thời.
Goroutine trong Go
Go cung cấp tính năng hỗ trợ cùng lúc thông qua Goroutine, một dạng thread nhẹ hơn rất nhiều so với thread tiêu chuẩn. Goroutine do runtime của Go quản lý và tự động tối ưu hóa việc sử dụng các nguồn lực hệ thống.
Tại sao chọn Goroutine?
- Goroutine chiếm ít tài nguyên hơn so với thread tiêu chuẩn (chỉ khoảng 2KB ban đầu).
- Goroutine được chuyển đổi nhanh chóng bởi scheduler bên trong runtime của Go thay vì phụ thuộc vào hệ điều hành.
Mối liên hệ giữa Goroutine và Thread
Goroutine không phải là thread thuần túy mà là một lớp trừu tượng cao hơn. Runtime của Go sẽ tự động phân phối các Goroutine lên các thread hợp lý dựa trên số lượng CPU và tải hệ thống.
Thực hành với Goroutine
Tạo Goroutine
Để khởi tạo một Goroutine, bạn chỉ cần thêm từ khóa go trước tên hàm:
go funcName()
Ví dụ minh họa
package main
import (
"fmt"
"time"
)
func main() {
go performTask()
fmt.Println("Chương trình chính tiếp tục...")
}
func performTask() {
for i := 0; i < 5; i++ {
fmt.Printf("Goroutine đang chạy... %d\n", i)
time.Sleep(1 * time.Second)
}
}
Khi chạy đoạn mã trên, bạn sẽ thấy chương trình chính không chờ Goroutine hoàn thành mà tiếp tục thực hiện ngay lập tức.
Cải thiện hiệu suất bằng cách sử dụng Goroutine
Hãy tưởng tượng bạn cần lấy dữ liệu từ nhiều nguồn khác nhau. Thay vì thực hiện tuần tự, hãy dùng Goroutine để tăng tốc độ xử lý.
Không dùng Goroutine
package main
import (
"fmt"
"time"
)
func main() {
for i := 1; i <= 5; i++ {
fmt.Println(processData(i))
}
}
func processData(num int) int {
time.Sleep(2 * time.Second)
return num * num
}
Trong ví dụ này, mỗi lần gọi hàm processData đều mất 2 giây, dẫn đến tổng thời gian xử lý là 10 giây.
Sử dụng Goroutine
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
result := processData(index)
fmt.Printf("Kết quả từ task %d: %d\n", index, result)
}(i)
}
wg.Wait()
}
func processData(num int) int {
time.Sleep(2 * time.Second)
return num * num
}
Đoạn mã này sẽ hoàn thành sau khoảng 2 giây nhờ việc chạy đồng thời các tác vụ.
Xử lý vấn đề Goroutine chưa hoàn thành
Nếu chương trình chính kết thúc trước khi tất cả Goroutine hoàn thành, các Goroutine còn lại sẽ bị hủy bỏ. Để giải quyết vấn đề này, chúng ta có thể sử dụng sync.WaitGroup.
Ví dụ với WaitGroup
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func main() {
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
result := calculate(index)
fmt.Printf("Tính toán xong phần tử %d: %d\n", index, result)
}(i)
}
wg.Wait()
}
func calculate(num int) int {
return num * num
}
WaitGroup giúp chương trình chính đợi cho đến khi tất cả các Goroutine đã hoàn thành.
Các vấn đề về cùng lúc trong Go
Một vấn đề phổ biến khi sử dụng Goroutine là tình trạng tranh chấp tài nguyên. Ví dụ:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func main() {
var shared []int
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
shared = append(shared, process(index))
}(i)
}
wg.Wait()
fmt.Println("Danh sách kết quả:", shared)
}
func process(num int) int {
return num * num
}
Kết quả có thể không đúng như mong muốn do nhiều Goroutine truy cập và thay đổi cùng một tài nguyên (shared) mà không kiểm soát.
Giải pháp
Thay vì chỉnh sửa trực tiếp vào biến chia sẻ, chúng ta có thể sử dụng channel để truyền dữ liệu giữa các Goroutine:
package main
import (
"fmt"
)
func main() {
results := make(chan int)
for i := 1; i <= 5; i++ {
go func(index int) {
results <- process(index)
}(i)
}
close(results)
finalResults := []int{}
for res := range results {
finalResults = append(finalResults, res)
}
fmt.Println("Danh sách kết quả:", finalResults)
}
func process(num int) int {
return num * num
}
Với cách tiếp cận này, dữ liệu được gửi qua channel và xử lý sau khi tất cả Goroutine hoàn thành.