Trong lập trình Golang, concurrency pattern là những kỹ thuật thiết kế giúp quản lý và tổ chức code cho các tác vụ chạy đồng thời, tận dụng tối đa khả năng xử lý đồng thời của hệ thống. Các kỹ thuật này giúp giải quyết các vấn đề phức tạp liên quan đến đa luồng một cách hiệu quả và an toàn. Bài viết này sẽ giới thiệu một số concurrency pattern phổ biến trong Go.
1. Confinement
Confinement là một kỹ thuật hoặc phương pháp để hạn chế quyền truy cập hoặc tác động của goroutines hoặc biến đến một phần nhất định của dữ liệu hoặc mã, nhằm mục đích tăng cường tính an toàn và tránh xung đột trong lập trình đồng thời.
Có hai loại confinement chính là ad hoc và lexical.
1.1. Ad hoc confinement
Ad hoc confinement dựa vào quy ước về cách sử dụng biến. Giới hạn phạm vi được quy định bởi quy ước chung từ ngôn ngữ, convention trong tổ chức hoặc convention của code base. Nó thường yêu cầu sự đồng thuận trong nhóm lập trình về việc ai có thể viết hoặc đọc một biến nhất định. Việc thực hiện và giám sát việc thực hiện theo quy tắc chung không dễ dàng do cần phải có công cụ phân tích thống kê trên mã nguồn tại thời điểm commit.
Ví dụ về ad hoc confinement
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
data := make([]int, 0) // Một slice rỗng được tạo ra
// Chúng ta đồng thuận rằng chỉ goroutine tạo ra từ appendData mới có quyền thêm dữ liệu vào slice
appendData := func(wg *sync.WaitGroup, data *[]int, value int) {
defer wg.Done()
// Ad hoc confinement: chỉ hàm này được quyền mở rộng 'data'
*data = append(*data, value)
}
wg.Add(1)
go appendData(&wg, &data, 10)
wg.Add(1)
go appendData(&wg, &data, 20)
wg.Wait() // Đợi cho đến khi tất cả goroutines hoàn thành
fmt.Println(data) // Xuất kết quả: [10 20] hoặc [20 10] tùy vào thứ tự hoàn thành của goroutines
}
Trong ví dụ này, data
là một slice mà được chia sẻ giữa các goroutines. Thay vì sử dụng các cơ chế ngôn ngữ để confine truy cập đến data
, chúng ta tạo một quy ước (ad hoc) rằng chỉ hàm appendData
mới có thể thay đổi data
. Điều này giúp đảm bảo rằng dữ liệu chỉ được thay đổi một cách có kiểm soát, nhưng cũng yêu cầu sự tin tưởng và tuân thủ quy ước giữa các lập trình viên.
Điều quan trọng cần lưu ý là Ad hoc confinement phụ thuộc vào sự tự giác của lập trình viên trong việc tuân thủ các quy ước đặt ra và không có cơ chế ngôn ngữ nào để thực thi những quy ước đó. Điều này làm tăng nguy cơ phát sinh lỗi do việc truy cập không mong muốn vào dữ liệu từ các phần của chương trình không được quy ước cho phép.
1.2. Lexical confinement
Lexical confinement dựa vào phạm vi của biến để hạn chế quyền truy cập. Biến chỉ có thể được truy cập trong một phạm vi cố định, thường là bên trong một hàm hoặc block code, giúp ngăn chặn sự truy cập không mong muốn từ các phần khác của chương trình.
Ví dụ về Lexical confinement
func main() {
var data int
go func() {
// chỉ có goroutine này mới có thể truy cập và thay đổi 'data'
data++
}()
// ...
}
Trong ví dụ trên, biến data
được lexical confine bên trong một hàm ẩn danh, nghĩa là chỉ có code bên trong hàm này mới có thể truy cập và thay đổi data
.
2. Vòng lặp for select
Trong Go, for select
kết hợp vòng lặp for
với câu lệnh select
cho phép bạn xử lý nhiều operations trên channel một cách non-blocking hoặc blocking.
Cấu trúc cơ bản của một for select
như sau:
for {
select {
case msg1 := <-channel1:
// Xử lý msg1
case msg2 := <-channel2:
// Xử lý msg2
case <-done:
// Break hoặc return để thoát loop
return
// Thêm các case khác nếu cần
default:
// Case mặc định
}
}
Phân tích cấu trúc của for select
:
for
Loop: Vòng lặp vô hạn cho phépselect
statement được thực thi liên tục, tạo điều kiện để chờ và xử lý các sự kiện từ nhiều channels khác nhau.select
Statement: Cho phép chờ đợi đồng thời trên nhiều channel operations. Khi một trong các operations sẵn sàng,select
sẽ thực thi case tương ứng. Nếu có nhiều case sẵn sàng cùng một lúc,select
sẽ chọn một case một cách ngẫu nhiên.
Ví dụ minh họa sử dụng for select
để xử lý đọc từ hai channels:
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
time.Sleep(2 * time.Second)
c1 <- "one"
}()
go func() {
time.Sleep(1 * time.Second)
c2 <- "two"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println("Received", msg1)
case msg2 := <-c2:
fmt.Println("Received", msg2)
}
}
}
Trong ví dụ trên, có hai goroutines, mỗi goroutine gửi một thông điệp vào một channel sau một khoảng thời gian ngủ nhất định. for select
loop chờ đợi trên cả hai channels, và in ra thông điệp mỗi khi nhận được. Vòng lặp sẽ kết thúc sau khi nhận đủ số lượng thông điệp nhất định, trong trường hợp này là hai.
Vòng lặp for select
rất hữu ích trong các tình huống như:
- Xử lý I/O không đồng bộ hoặc các tác vụ đồng thời khác.
- Thực hiện timeout cho các operations.
- Dừng goroutines một cách an toàn thông qua các signals.
3. Ngăn goroutine leaks
Goroutine là luồng thực thi nhẹ, tương tự như threads nhưng ít tốn kém hơn và được Go runtime quản lý. Chúng chạy trên một số nhỏ threads của hệ điều hành, cho phép hàng ngàn, thậm chí hàng triệu goroutine đồng thời tồn tại mà không gặp phải vấn đề về hiệu suất.
Đọc thêm: Goroutines là gì? Lập trình concurrency chưa bao giờ dễ như Golang
Goroutines rất dễ sử dụng và có thể được khởi tạo chỉ bằng cách thêm từ khóa go
trước khi gọi hàm.
Tuy nhiên, khởi tạo goroutine vẫn tiêu tốn tài nguyên và goroutine không được sử dụng sẽ không được thu gom bởi runtime. Do đó, quản lý lãng phí bộ nhớ khi sử dụng goroutine là điều cần thiết.
Sau đây là ví dụ điển hình về goroutine leak
Khi chạy đoạn code trên, ta thấy goroutine trong hàm doWork
sẽ không dừng cho tới khi main
dừng. Lý do là khi truyền nil
vào doWork
, vòng for
bên trong goroutine sẽ không kết thúc cho tới khi tiến trình dừng, dẫn tới goroutine chiếm bộ nhớ nhưng không thực hiện công việc.
Ở ví dụ trên, vòng đời của process rất ngắn nên bạn không thấy không có vấn đề gì xảy ra. Nhưng trên thực tế, các ứng dụng chạy rất nhiều process phức tạp gọi lẫn nhau, trong khi khởi tạo nhiều goroutine. Dẫn tới, nhiều goroutine thừa còn tồn tại trong một thời gian dài, lãng phí rất nhiều tài nguyên và gây ảnh hưởng tới hiệu suất.
Câu hỏi đặt ra là làm sao để chỉ định goroutine dừng từ bên ngoài nơi gọi chúng?
Khi các goroutine chạy đồng thời, chúng chỉ dừng trong các trường hợp sau:
- Khi chúng hoàn thành công việc
- Khi có lỗi xảy ra và go routine không thể tiếp tục chạy được nữa
- Khi chúng được chỉ định phải dừng
Trường hợp 1 và 2 đều được Go tự xử lý. Trường hợp 3 thường được tự triển khai bằng cách sử dụng read-only channel done
.
package main
import (
"fmt"
"time"
)
func main() {
// channel `done` được truyền vào hàm `doWork`
doWork := func(done <-chan interface{}, strings <-chan string) <-chan interface{} {
terminated := make(chan interface{})
go func() {
defer fmt.Println("doWork exited.")
defer close(terminated)
for {
select {
case s := <-strings:
fmt.Println(s)
case <-done:
// Nếu done có giá trị hoặc bị đóng thì hàm doWork sẽ dừng
return
}
}
}()
return terminated
}
done := make(chan interface{})
terminated := doWork(done, nil)
go func() {
// Dừng doWork sau 1 giây
time.Sleep(1 * time.Second)
fmt.Println("Canceling doWork goroutine...")
close(done)
}()
<-terminated
fmt.Println("Done.")
}
Ở ví dụ trên, goroutine con có thể được chỉ định dừng ở bên ngoài, giúp việc quản lý goroutine được chặt chẽ hơn, giảm lãng phí tài nguyên.
4. Tổng kết
Trong bài Concurrency pattern trong Go - Phần 1, chúng ta đã tìm hiểu 3 pattern quan trọng là confinement, vòng lặp for select
và ngăn chặn goroutine leak.
Confinement là một kỹ thuật hoặc phương pháp để hạn chế quyền truy cập hoặc tác động của goroutines hoặc biến đến một phần nhất định của dữ liệu hoặc mã, nhằm mục đích tăng cường tính an toàn và tránh xung đột trong lập trình đồng thời. Có hai loại confinement chính là ad hoc và lexical.
Trong Go, for select
kết hợp vòng lặp for
với câu lệnh select
cho phép bạn xử lý nhiều operations trên channel một cách non-blocking hoặc blocking.
Goroutine là luồng thực thi nhẹ, tương tự như threads nhưng ít tốn kém hơn và được Go runtime quản lý. Bộ nhớ có thể bị chiếm giữ bởi nhiều goroutine khởi tạo nhưng không dùng. Chúng ta có thể dùng done
channel để đóng goroutine từ bên ngoài nơi gọi chúng khi không cần dùng tới.
Hi vọng bài viết đã cung cấp cho các bạn những kiến thức hữu ích về Go.
5. Tài liệu tham khảo
- Concurrency in Go - Katherine Cox-Buday
Bạn có thể tham khảo thêm những bài viết về chủ đề Golang của 200Lab:
Bài viết liên quan
Design Pattern là gì? 23 Classic Design Pattern với Golang
Aug 21, 2024 • 3 min read
Concurrency pattern trong Go - Phần 2
Apr 11, 2024 • 7 min read
So sánh Golang và NodeJS chi tiết
Oct 14, 2023 • 22 min read
Buffered Channel là gì? Thường xuyên hỏi trong phỏng vấn Golang Dev
Jun 14, 2023 • 6 min read
Golang Channel là gì? Các ứng dụng Channel trong Golang
Jun 14, 2023 • 12 min read
Goroutines là gì? Lập trình concurrency chưa bao giờ dễ như Golang
Jun 13, 2023 • 8 min read