Tìm hiểu lập trình concurrency trong Golang
Giới thiệu
Concurrency là một khái niệm quan trọng trong lập trình, và Golang (Go) đã đặt ra một tiêu chuẩn mới về cách xử lý đồng thời. Bài viết này sẽ cùng bạn khám phá tại sao nó là một phần quan trọng của ngôn ngữ này và làm thế nào nó giúp xây dựng ứng dụng hiệu quả, mạnh mẽ.
Concurrency là gì
1. Lập trình đồng thời (Concurrency) là khả năng phân chia và điều phối nhiều tác vụ khác nhau trong cùng một khoảng thời gian, việc chuyển qua chuyển lại tác vụ rất nhanh nhưng tại một thời điểm chỉ có thể xử lý một tác vụ. Ở trong Go mỗi task vụ này là 1 goroutine.
Goroutines là gì?
Goroutines là các luồng thực thi nhẹ lightweight execution thread do khởi tạo tốn rất ít bộ nhớ stack. Nó được quản lý bởi hệ điều hành, cho phép chúng ta thực hiện các tác vụ đồng thời một cách hiệu quả. Hiểu đơn giản, Goroutines bản chất là các hàm (function) được thực thi một các độc lập và đồng thời với nhau.
- Một chương trình Go có thể chứa hàng ngàn Goroutines xử lý đồng thời
- Trong mỗi chương trình Golang, luôn có một Goroutine chính, gọi là main Goroutine. Nếu thằng chính này dừng lại, thì tất cả những thằng khác (Goroutines) trong chương trình cũng sẽ dừng lại, không được chạy.
Việc khai báo Goroutines trong Golang thì cực kỳ đơn giản, không cần phải import bất kì package nào cả. Bạn chỉ cần thêm cái từ khóa go
trước lời gọi hàm mà bạn muốn chúng chạy đồng thời thôi.
func quan(){
// code
}
// goroutines
// only using go keyword
go quan()
Để hiểu rõ hơn về Goroutines hãy cùng làm một ví dụ nữa
package main
import (
"fmt"
)
func showName(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
}
}
func main() {
// 2 goroutine
go showName("Quân")
go showName("Troy")
}
Ouput:
Như ở ví dụ này, chúng ta mong muốn "Quân" và "Troy" đều phải được in ra 3 lần, nhưng ouput lại không có gì cả. Tại sao lại như vậy? Bởi vì, do cả 2 hàm showName
có dùng từ khóa go
nên nó là goroutine và khi hàm main được chạy nó thấy goroutine sẽ đẩy vào Local Run Queue và Go Runtime sẽ tiếp tục chạy code bên dưới ở hàm main, nhưng không còn code nào để chạy cả nên hàm main
sẽ dừng lại. Như có viết ở trên khi hàm main
dừng thì tất cả goroutine khác đều không được chạy.
Vậy làm thế nào để hàm main
đợi 2 goroutine chạy xong mới dừng lại?
Đơn giản nhất là chúng ta sẽ tạm dừng hàm main 1 khoảng thời gian để 2 goroutine kia được chạy
package main
import (
"fmt"
"time"
)
func showName(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
}
}
func main() {
go showName("Quân")
go showName("Troy")
// Pause the execution for 1 second
time.Sleep(1 * time.Second)
}
Ouput:
Troy
Troy
Troy
Quân
Quân
Quân
Hàm time.Sleep(1 * time.Second)
sẽ tạm dừng hàm main trong 1s. Nên 2 goroutine đã có thời gian để chạy và in ra được kết quả như chúng ta mong muốn. Đương nhiên, trong thực tế chúng ra không để biết goroutine cần bao nhiêu thời gian để để chúng ta Sleep tương ứng, mà chúng ta sẽ cần tìm hiểu thêm về WaitGroup, Channel để hàm main
đợi goroutine thực sự chạy xong mới dừng lại.
WaitGroup
Vẫn dùng ví dụ trên nhưng chúng ta sẽ sửa một chút, không dùng Sleep nữa
package main
import (
"fmt"
"sync"
)
func sayName(s string, wg *sync.WaitGroup) {
// Báo cho hàm main biết là đã chạy xong
defer wg.Done()
for i := 0; i < 3; i++ {
fmt.Println(s)
}
}
func main() {
// Khởi tạo một biến kiểu sync.WaitGroup
var wg sync.WaitGroup
// thông báo thêm 2 goroutine cần phải đợi
wg.Add(2)
go sayName("Quan", &wg)
go sayName("Troy", &wg)
// Đợi cho tất cả các goroutine chạy xong
wg.Wait()
fmt.Println("Application end")
}
Ouput:
Troy
Troy
Troy
Quan
Quan
Quan
Application end
Vẫn ra ouput mong muốn mà không cần dùng Sleep. Mình sẽ giải thích 1 chút về cách hoạt động của WaitGroup trong ví dụ ở trên. Đầu tiên, ta khởi tạo một biến thuộc kiểu sync.WaitGroup
(sync
là một package trong Go). Sau đó, ta thông báo cho WaitGroup biết có bao nhiêu Goroutine cần phải đợi bằng cách sử dụng phương thức Add(n)
với n là số lượng Goroutine. Trong trường hợp này, chúng ta có 2 Goroutine nên sử dụng wg.Add(2)
. Tiếp theo, khi một Goroutine hoàn thành nhiệm vụ của nó, ta thông báo cho hàm main biết bằng cách gọi phương thức wg.Done()
. Từ khóa defer
được sử dụng để đảm bảo rằng một hàm sẽ được gọi vào cuối cùng của hàm chứa nó, trong trường hợp này là wg.Done()
. Cuối cùng, wg.Wait()
sẽ lock hàm main lại đợi goroutine báo đã chạy xong mới unlock và thực hiện in ra dòng "Application end".
Có một lưu ý là phải truyền biến sync.WaitGroup vào goroutine theo dạng tham trị, con trỏ &wg
nếu chỉ truyền bình thường wg
thì trong goroutine sẽ copy lại biến wg
điều này dẫn đến biến wg
ở trong goroutine và hàm main đang không phải là một và logic code sẽ không chạy đúng nữa.
Channel (unbuffered channel)
Trong các ví dụ trên, chúng ta đã thấy các Goroutines chạy độc lập với nhau. Tuy nhiên, liệu có cách nào để chúng giao tiếp với nhau hay không? Câu trả lời là có, và đó là thông qua Channel.
- Mặc định, Channel là một kênh giao tiếp 2 chiều, có nghĩa là nó có thể được sử dụng để gửi và nhận dữ liệu.
- Channel này giúp các Goroutines có thể gửi và nhận dữ liệu cho nhau một cách an toàn thông qua cơ chế lock-free.
Ví dụ đơn giản về Channel
package main
import "fmt"
func sayName(s string, ch chan string) {
result := ""
for i := 0; i < 3; i++ {
result += s + " is my name, "
}
// gửi giá trị vào channel
ch <- result
}
func main() {
// tạo channel có thể chứa string
ch := make(chan string)
go sayName("Quan", ch)
// lấy giá trị từ channel
fmt.Println(<-ch)
fmt.Println("Application end")
}
Ouput:
Quan is my name, Quan is my name, Quan is my name,
Application end
Trong đoạn code trên chúng ta có thể thấy việc giao tiếp cơ bản giữa goroutine sayName()
và main goroutine. sayName()
đã gửi dữ liệu vào vào Channel ch <- result
và main goroutines đã có thể nhận và sử dụng nó. Toán tử <-
đóng vai trò như hướng chỉ dữ liệu sẽ đi từ đâu đến đâu.
Khi một Goroutine gửi dữ liệu qua Channel, nó sẽ bị block cho đến khi có một Goroutine khác nhận dữ liệu từ Channel đó. Tương tự, khi một Goroutine nhận dữ liệu từ Channel, nó cũng sẽ bị chặn cho đến khi có một Goroutine khác gửi dữ liệu vào Channel đó. Dễ hiểu hơn thì tất cả đoạn code của sayName()
mà ở sau dòng gửi dữ liệu ch <- result
sẽ bị block đến khi hàm main
lấy dữ liệu từ channel ra <-ch
. Việc nhận dữ liệu từ Channel cũng tương tự.
Không biết có ai để ý, chúng ta truyền channel vào goroutine bằng tham chiếu ch
chứ không cần dùng tham trị, con trỏ &ch
giống như WaitGroup ở bên trên mà vẫn có thể tương tác với cùng một channel không. Đơn giản là vì bản thân channel sau khi khởi tạo đã là một con trỏ rồi nên không cần thiết truyền kiểu con trỏ làm gì. Bạn có thể thêm dòng fmt.Println(ch)
sau khi khởi tạo channel để kiểm chứng. Điều này tương tự với slices
, maps
, pointers
và functions
.
Deadlock trong channel
Deadlock trong Go là tình huống mà một hoặc nhiều goroutines đang chờ đợi một sự kiện mà không bao giờ xảy ra. Điều này thường xảy ra khi một goroutine đang chờ nhận dữ liệu từ một channel, nhưng không có goroutine nào khác đang gửi dữ liệu vào channel đó, hoặc ngược lại.
Ví dụ về Deadlock:
func main() {
ch := make(chan int)
ch <- 1 // This line will cause a deadlock
}
Trong ví dụ trên, chương trình sẽ bị deadlock tại dòng ch <- 1
vì không có goroutine nào khác đang chờ nhận dữ liệu từ channel ch
. Do đó, dòng này sẽ chờ mãi mãi, dẫn đến deadlock. Lưu ý là nếu cùng gửi và nhận trong cùng 1 goroutine thì deadlock vẫn xảy ra.
Lúc này, Go runtime sẽ phát hiện deadlock và sẽ dừng chương trình, báo lỗi deadlock
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
Tương tự, khi thay dòng ch <- 1
thành <-ch
cũng xảy ra deadlock.
Có 2 cách để giải quyết vấn đề này, cách đầu tiên là cần có một goroutine để gửi dữ liệu vào channel và một goroutine khác lấy dữ liệu từ channel ra. Thứ hai là có thể dùng Buffered Channel.
Buffered Channel
Buffered Channel là một loại channel có khả năng chứa một số lượng nhất định giá trị bên trong nó mà không cần một goroutine khác phải nhận ngay lập tức. Điều này khác với Unbuffered Channel, nơi mà mỗi thao tác gửi (send) phải chờ một thao tác nhận (receive) tương ứng.
Bạn có thể tạo một Buffered Channel bằng cách cung cấp một số nguyên dương làm tham số thứ hai cho hàm make
:
ch := make(chan int, 3)
Trong ví dụ trên, ch
là một Buffered Channel có thể chứa tối đa 3 giá trị mà không cần một goroutine khác phải nhận ngay lập tức. Nếu bạn cố gắng gửi nhiều hơn 3 giá trị vào channel mà không có goroutine nào nhận, thì thao tác gửi sẽ bị chặn cho đến khi có đủ không gian.
Buffered Channel trong Go có hai thuộc tính quan trọng là len và cap.
- len: Trả về số lượng phần tử hiện tại đang có trong channel. Điều này bao gồm cả các giá trị đã được gửi vào channel nhưng chưa được nhận.
- cap: Trả về sức chứa tối đa của bộ đệm channel. Đây là số lượng giá trị mà channel có thể chứa mà không cần một goroutine khác phải nhận ngay lập tức.
func main() {
ch := make(chan int, 3)
start, end := 10, 13
for i := start; i < end; i++ {
ch <- i
}
fmt.Printf("value: %v,lenght: %v, cap:%v \n", <-ch, len(ch), cap(ch))
fmt.Println("Application end")
}
Ouput:
value: 10,lenght: 2, cap:3
Application end
Bạn có thể thấy mặc dù còn 2 giá trị của channel chưa lấy ra nhưng main goroutine không bị block khi chưa vượt quá sức chứa cap
. Nếu ta đổi end
lên 14 thì lúc này ch
sẽ nhận vào 4 giá trị trong khi cap
chỉ có 3 điều này sẽ làm chương trình bị deadlock.
Kết
Trong bài viết, chúng ta đã tìm hiểu cách sử dụng Goroutines. Từ cách khai báo và quản lý Goroutines đến cách đợi chúng hoàn thành, mỗi ví dụ đã giúp chúng ta hiểu rõ hơn về tính năng quan trọng này trong Go. Nhờ vào Goroutines, chúng ta có thể xây dựng các ứng dụng mạnh mẽ và linh hoạt, đồng thời tận dụng tối đa tài nguyên máy tính. Điều này làm tăng hiệu suất và khả năng mở rộng của ứng dụng.
Bài viết là sự tổng hợp của mình trong quá trình học Go, nếu có sai sót hay thắc mắc gì mong các bạn comment xuống bên dưới. Cảm ơn bạn đã đọc.
Tài liệu tham khảo
https://www.youtube.com/playlist?list=PLlahAO-uyDzIVzBvRKwKUDjj2Iaq-5W9l
https://200lab.io/blog/goroutines-la-gi/
https://go.dev/tour/concurrency/1
All rights reserved