I. Giới thiệu
Concurrency trong Golang là khả năng thực hiện nhiều tác vụ đồng thời cùng trong một chương trình, điều này cho phép tác vụ không bị chặn có thể run song song. Nó giúp tối ưu hóa hiệu suất của ứng dụng. Trong bài này mình sẽ giới thiệu về các thành phần chính hỗ trợ concurrency trong Golang như goroutine, sync package, channel, select và context.
II. Ưu nhược điểm
1. Ưu điểm
– Hiệu suất cao: Do xử lý được nhiều tác vụ đồng thời mà không bị chặn nên hiệu suất rất cao.
– Goroutine nhẹ: Thay vì sử dụng threads như 1 số ngôn ngữ khác thì Golang sử dụng goroutine, nó rất nhỏ và chỉ tốn khoảng 2KB
– An toàn với dữ liệu và đồng bộ hóa: Cung cấp nhiều tools để an toàn với dữ liệu và đồng bộ hóa tránh tình trạng race conditions như: Channel, sync package…
2. Nhược điểm
– Phức tạp trong thiết kế: Yêu cầu lập trình viên cần nắm rõ cách hoạt động của từng tool để xây dựng kế hoạch hợp lý và lựa chọn phương án thiết kế phù hợp, từ đó tránh được những tình huống không mong muốn như deadlocks hay race conditions
– Khó khăn khi debug: Do nhiều goroutines thực thi đồng thời có thể dẫn đến tình trạng deadlocks hoặc race conditions, gây khó khăn trong quá trình debug và xác định nguyên nhân của các lỗi xảy ra
– Thêm chi phí khởi tạo goroutine: Dù goroutines rất nhẹ, nhưng việc sử dụng không đúng cách có thể làm giảm hiệu suất của ứng dụng
– Quản lý phức tạp: Đôi khi tối ưu hóa hiệu suất thì dẫn đến code phức tạp và khó hiễu => maintenance và update khó khăn hơn
III. Nội dung
1. Goroutines
Goroutines cho phép ta có thể thực thi các function đồng thời, ta có thể khởi tạo một goroutine bằng cách sử dụng từ khóa go. Mỗi goroutine là một đơn vị thực thi rất nhỏ, nhỏ hơn so với một luồng thread và chỉ tốn khoảng 2KB. Ví dụ khởi tạo goroutine:
go func() {
fmt.Println("Example Goroutine!")
}()
2. Sync Package
Sync package trong Golang có một số tính năng chính giúp đồng bộ hóa các tài nguyên chia sẻ giữa các goroutine
2.1 WaitGroup
Là một công cụ dùng để đồng bộ trong Golang, nó có thể quản lý và chờ các goroutine hoàn thành công việc trước khi tiếp tục các tác vụ khác. Nó đặc biệt hữu ích khi bạn có nhiều goroutine run song song nhưng cần đợi tất cả hoàn thành khi tiếp tục bước tiếp theo. WaitGroup sẽ có 3 action chính là:
– Add: Sử dụng để thêm số lượng goroutine mà bạn muốn chờ
– Done: Giảm số lượng goroutine đang chờ trong WaitGroup đi 1, nó thường được sử dụng với cú pháp “defer wg.Done()” ở đầu func của goroutine để đảm bảo rằng Done() sẽ được gọi khi func hoàn tất, các bạn có thể tìm hiểu về “defer” trong Golang để biết rõ hơn.
– Wait: Phương thức này để chờ cho đến khi số lượng goroutine còn lại trong WaitGroup về 0
– Ví dụ, giả sử bạn có 6 goroutine, bạn có thể sử dụng WaitGroup để chia công việc thành hai giai đoạn:
+ Giai đoạn 1: Chạy 3 goroutine đầu tiên (1, 2, 3) và chờ cho đến khi cả 3 hoàn thành.
+ Giai đoạn 2: Sau khi giai đoạn 1 hoàn thành, tiếp tục chạy 3 goroutine tiếp theo (4, 5 và 6).
– Với cách này, WaitGroup giúp bạn đảm bảo giai đoạn 1 hoàn tất trước khi chuyển sang giai đoạn 2, đồng bộ hóa các goroutine một cách dễ dàng và hiệu quả. Dưới đây là ví dụ cho trường hợp này:
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Đảm bảo gọi Done() khi goroutine kết thúc
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
// Giai đoạn 1
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait() //giai đoạn 1 hoàn thành
// Giai đoạn 2
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i + 3, &wg)
}
wg.Wait() //giai đoạn 2 hoàn thành
fmt.Println("done")
}
2.2 Mutex
Dùng để khóa chặn đồng thời vào tài nguyên dùng chung giúp ta tránh các vấn đề xung đột. Mutex Có 2 loại gồm:
– sync.Mutex: Bạn có thể hiểu đơn giản là khi khởi tạo một mutex và có 2 goroutine cùng sử dụng mutex đó, nếu goroutine 1 chạy và gặp mutex.Lock() trước, nó sẽ khóa lại, ngăn không cho goroutine khác vào đoạn mã đã khóa. Trong khi đó, goroutine 2 vẫn có thể thực thi các phần khác của func mà không bị chặn. Nhưng khi goroutine 2 gặp mutex.Lock(), nó phải đợi đến khi goroutine 1 gọi Unlock() để mở khóa. Sau đó goroutine 2 mới có thể tiếp tục thực hiện đoạn mã được bảo vệ bởi mutex.
var (
mu sync.Mutex
counter int
)
func increment(wg *sync.WaitGroup, id int) {
defer wg.Done()
fmt.Printf("Goroutine %d trying lock...\n", id)
mu.Lock()
fmt.Printf("Goroutine %d has locked.\n", id)
for i := 0; i < 5; i++ {
counter++
time.Sleep(100 * time.Millisecond)
}
fmt.Printf("Goroutine %d is unlocking.\n", id)
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
wg.Add(2) //add 2 goroutine vào WaitGroup
go increment(&wg, 1) // Goroutine 1
go increment(&wg, 2) // Goroutine 2
wg.Wait()
fmt.Printf("Counter: %d\n", counter)
}
– sync.RWMutex: Hãy tưởng tượng chúng ta có 3 goroutine trong đó goroutine 1 và 2 sẽ sử dụng RLock (read) và goroutine 3 sẽ sử dụng Lock(write). Từ đó, chúng ta có hai nhóm: nhóm read và nhóm write. Trong nhóm write, cách thức hoạt động của sync.RWMutex tương tự như sync.Mutex, nghĩa là chỉ có một goroutine được phép write tại một thời điểm. Tuy nhiên, nhóm đọc cho phép nhiều goroutine thực hiện read đồng thời, miễn là không có goroutine nào đang write. Điều này giúp tối ưu hiệu suất khi có nhiều goroutine cần truy cập dữ liệu để read cùng lúc.
var (
mu sync.RWMutex
counter int
)
func read(wg *sync.WaitGroup, id int) {
defer wg.Done()
mu.RLock()
fmt.Printf("Goroutine %d reading: %d\n", id, counter)
time.Sleep(1 * time.Second)
mu.RUnlock()
}
func write(wg *sync.WaitGroup, id int) {
defer wg.Done()
mu.Lock()
fmt.Printf("Goroutine %d start write...\n", id)
counter++
time.Sleep(2 * time.Second)
fmt.Printf("Goroutine %d end write: %d\n", id, counter)
mu.Unlock() // Giải phóng ghi lock
}
func main() {
var wg sync.WaitGroup
wg.Add(3) //add 3 goroutine vào WaitGroup
// goroutine read
go read(&wg, 1)
go read(&wg, 2)
// goroutine write
go write(&wg, 3)
wg.Wait()
fmt.Println("Counter:", counter)
}
2.3 Once
Trong Golang nếu bạn có một đoạn mã chỉ nên chạy duy nhất một lần trong suốt vòng đời của ứng dụng, sync.Once là lựa chọn hoàn hảo. Hãy tưởng tượng bạn có một ứng dụng mà mỗi khi người dùng gửi request ứng dụng sẽ cần load config. Nếu có 1k request, thay vì load config 1k lần bạn có thể sử dụng sync.Once chỉ load config một lần duy nhất cho request đầu tiên. Điều này không chỉ giúp giảm thiểu thời gian xử lý mà còn tối ưu hóa hiệu suất cho tất cả các request sau đó.
Ví dụ bạn có file config .env
APP_NAME=TEST
Nội dùng code read file config
#VD này cần install package "github.com/joho/godotenv"
#INSTALL: go get github.com/joho/godotenv
var once sync.Once
var config *Config
type Config struct {
AppName string
}
func LoadFromEnv() *Config {
once.Do(func() {
if err := godotenv.Load(); err != nil {
log.Fatalf("Error loading .env file: %v", err)
}
config = &Config{
AppName: os.Getenv("APP_NAME"),
}
fmt.Println("LOADED CONFIG")
})
return config
}
func main() {
fmt.Println(LoadFromEnv())
fmt.Println(LoadFromEnv())
}
2.4 Cond
Được sử dụng để đánh thức goroutine dựa trên điều kiện. Trường hợp sử dụng phổ biến nhất của Cond là để đồng bộ hóa giữa các luồng hoặc goroutine trong mô hình Producer-Consumer. Ví dụ, khi Producer thêm dữ liệu vào hàng đợi (queue), nó sẽ thông báo cho Consumer để Consumer lấy dữ liệu và xử lý. Ngoài ra, Cond cũng có thể được sử dụng để quản lý số lượng tối đa kết nối đến cơ sở dữ liệu của ứng dụng nếu đạt đến giới hạn tối đa, các yêu cầu sẽ phải chờ cho đến khi một kết nối được giải phóng…
– Khởi tạo Cond và một số actions chính:
+ Khởi tạo Cond: sync.NewCond(nil) khi không dùng mutex thì bạn cần truyền nil vào khi khởi tạo, nếu sử dụng mutex thì bạn cần truyền &mutex(Địa chỉ của biến mutex, bạn có thể xem kỹ hơn tại phần ví dụ)
+ Signal: sẽ đánh thức một goroutine duy nhất đang chờ trên sync.Cond
+ Broadcast: sẽ đánh thức toàn bộ goroutine đang chờ trên sync.Cond
+ Wait: chờ cho đến khi điều kiện được thỏa mãn (Nó sẽ bị chặn cho đến khi một goroutine khác trên sync.Cond gọi đến actions Signal hoặc Broadcast).
– Ví dụ Producer thêm mới item và thông báo cho một hoặc nhiều Consumer xử lý. Mình có sử dụng sync.Mutex để đảm bảo khi Producer và Consumer thao tác với item không bị race condition (tình trạng tranh chấp dữ liệu). Và có truyền địa chỉ của biến mutex khi khởi tạo Cond, để biết nguyên nhân tại sao chúng ta cần truyền &mutex và nó sử dụng cho mục địch gì? Các bạn có xem thêm comment bên dưới hoặc bạn bấm chi tiết vào func Wait để hiểu rõ hơn nhé.
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
item = 0
)
func main() {
// Consumer
for i := 1; i < 5; i++ {
go func(consumerID int) {
for {
consumeItem(consumerID)
time.Sleep(2 * time.Second)
}
}(i)
}
// Producer sử dụng Signal
go AddItem(1, "Signal", 1)
// Producer sử dụng Broadcast
go AddItem(2, "Broadcast", 4)
// Producer sử dụng Broadcast
go AddItem(5, "Broadcast", 2)
// Chờ 6s cho comsumer xử lý xong với VD này
time.Sleep(6 * time.Second)
}
func AddItem(numberItem int, typeCond string, second int) {
time.Sleep(time.Duration(second) * time.Second)
mu.Lock()
defer mu.Unlock() // Mở khóa ngay khi kết thúc
item += numberItem
fmt.Printf("Produced Currenct Item: %d\n", item)
switch typeCond {
case "Broadcast":
cond.Broadcast() // Thông báo cho toàn bộ Consumer
default:
cond.Signal() // Thông báo cho một Consumer
}
}
/**
Hiện tại nếu item > 0 ta không sử dụng func Wait
và func consumeItem của chúng ta sẽ hoạt động bình
thường với mutex có Lock và Unlock, nhưng khi
item = 0 chúng ta sử dụng Wait thì chúng ta mới chỉ
Lock mà chưa thể UnLock được do bị chặn bời func Wait
nên trong func Wait sẽ thực hiện UnLock cho chúng ta
kiểu như gọi đến func Wait sẽ tiến hành các bước như:
- UnLock
- ...Wait chờ thông báo từ Signal hoặc Broadcast
- Lock (Signal hoặc Broadcast báo là đã có dữ liệu)
*/
func consumeItem(consumerID int) {
mu.Lock()
defer mu.Unlock()
for item == 0 {
cond.Wait() // Chờ cho đến khi có dữ liệu
}
item--
fmt.Printf("Consume %d Done, Item: %d \n", consumerID, item)
}
2.5 Pool
Là một cấu trúc dữ liệu được sử dụng để quản lý và tái sử dụng tài nguyên, nó giảm tải cho hệ thống bằng cách thay vì phải khởi tạo rồi hủy object liên tục thì nó sẽ tái sử dụng chúng. Thường chỉ nên sử dụng Pool với các object có thể tái sử dụng nhiều lần và liên tục như buffers, connect database hoặc các object tạm thời khác. Nếu thay đổi object trong quá trình sử dụng thì sau khi sử dụng xong cần reset lại object trước khi trả lại Pool để đảm bảo nhất quán giữa các lân sử dụng, tất nhiên cần phải xem xét việc reset so với khởi tạo mới object chi phí như nào để biết có cần thiết sử dụng pool hay không?
– Khởi tạo Pool và các actions chính là:
+ Khởi tạo Pool: Khi khởi tạo một sync.Pool mà không định nghĩa hàm New, nếu bạn gọi Get khi pool đang rỗng, giá trị trả về sẽ là nil cho đến khi có giá trị được Put vào. Ngược lại, nếu có func New, mỗi khi gọi Get mà pool rỗng, func New sẽ tự động tạo và trả về một new object.
#Khởi tạo không có New
var jsonPool = sync.Pool{}
#Khởi tạo có func New
var jsonPool = sync.Pool{
New: func() interface{} {
fmt.Println("Tạo buffer mới khi pool trống\n")
return new(bytes.Buffer)
},
}
+ Get: Lấy object từ pool nếu có, còn không có mà có func New thì thực thi func New và nhận object từ func.
+ Put: Trả đối tượng vào pool để tái sử dụng chúng
– Ví dụ bạn có 1 API có 1k request trong 2s và response trả về của API là json mỗi khi trả ra kết quả bạn sẽ cần Encode và trả về cho client. Quá trình để encode JSON sẽ luôn cần khởi tạo buffer. OK giờ bạn muốn giảm số lần khởi tạo buffer đi => Bạn sử dụng Pool để quản lý, nếu buffer không có trong Pool khi Encode, bạn sẽ khởi tạo một buffer mới; nếu đã có buffer trong Pool, bạn sẽ lấy buffer đó ra và Reset trước khi sử dụng => Số lần khởi tạo buffer sẽ ít hơn nhưng đồng thời sẽ thêm 1 số chi phí như quản lý pool và reset buffer. Ví dụ sử dụng Pool:
var jsonPool = sync.Pool{
New: func() interface{} {
fmt.Println("Tạo buffer mới khi pool trống\n")
return new(bytes.Buffer)
},
}
type Data struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
for i := 0; i < 5; i++ {
go encoded(Data{ID: i, Name: "QuyenNV"})
if i == 2 {
// Test GC(Garbage Collector) dọn dẹp dữ liệu
runtime.GC()
}
time.Sleep(1 * time.Second)
}
}
func encoded(data Data) {
// Lấy buffer từ pool và đảm bảo nó trống
buf := jsonPool.Get().(*bytes.Buffer)
buf.Reset()
// Đảm bảo buffer được trả về pool sau khi sử dụng
defer jsonPool.Put(buf)
// Mã hóa JSON vào buffer
json.NewEncoder(buf).Encode(data)
fmt.Println("Encoded JSON:", buf.String())
}
2.6 Map
Đây là cấu trúc dữ liệu đồng bộ được thiết kế để an toàn cho việc truy cập đồng thời từ nhiều goroutine mà không cần phải sử dụng mutex trực tiếp. Nếu không sử dụng sync.Map thì thông thường bạn sẽ cần sử dụng map và kèm theo mutex để tránh tình trạng race condition và đảm bảo an toàn khi read và write data.
– Những điều cần lưu ý về sync.Map
+ Được thiết kế có 2 map là Read(Chỉ đọc) và Dirty(Chứa dữ liệu create, edit và delete mới nhất)
+ Tối ưu cho trường hợp Read nhiều và Write ít, còn trường hợp ngược lại thì sử dụng map thông thường kết hợp với sync.RWMutex sẽ tối ưu hơn
+ Không đảm bảo tính tuần tự khi thao tác liên tục và có thể có trường hợp dữ liệu không nhất quán, nguyên nhân chủ yêu là do cơ chế đồng bộ từ Dirty sang Read map
+ Khác với Map thông thường sync.Map chỉ cung cấp 1 số actions chủ yếu như: Store, Load, Delete, Range…
+ Do sử dụng cả Read và Dirty map nên sẽ tốn nhiều bộ nhớ hơn
+ Một số actions như Store, Load, LoadOrStore có thể kích hoạt đồng bộ. Điều kiện sync sẽ là amended(Có sự thay đổi) bằng true và số misses đạt đến giới hạn ví dụ như số lần misses đã lớn hơn hoặc bằng số lượng phần tử trong Dirty map
– Các actions chính trong sync.Map:
+ Store: Thêm mới hoặc cập nhật, nếu key đã tồn tại sẽ cập nhật trong Dirty map, và sau đó có thể được đồng bộ vào Read map
+ Load: Trả về value nếu key có trong sync.Map, quá trình lấy sẽ là truy cập vào Read map, nếu không thấy kiểm tra trong Dirty map
+ LoadOrStore: Kiểm tra xem key đã có trong sync.Map chưa quá trình kiểm tra sẽ tượng tự như Load, nếu không tìm thấy sẽ tiến hành thêm mới vào Dirty map và trả về value vừa thêm
+ Delete: Xóa value trong sync.Map, nếu key tồn tại trong Read map sẽ chuyển nó sang Dirty map và chờ lần đồng bộ tiếp theo thì xử lý.
+ Range: Duyệt qua toàn bộ key và value trong sync.Map
Các bạn có thể tìm hiểu thêm một số action khác trong sync.Map như: Swap, CompareAndSwap, LoadAndDelete, CompareAndDelete.
3. Channel
Channel trong Golang là một cấu trúc giúp các goroutine có thể trao đổi dữ liệu một cách an toàn mà không cần sử dụng mutex hoặc các cơ chế đồng bộ khác. Channel có thể đóng bằng cách close(channel), ngăn không cho gửi thêm giá trị vào channel. Tuy nhiên, việc nhận từ một channel đã close vẫn có thể diễn ra và trả về giá trị của channel, với giá trị nil khi không còn dữ liệu.
– Các câu lệnh thường dùng:
#Có 2 loại channel, khi khởi tạo 2 loại này chỉ khác nhau về tham số. Trong đó 1 loại có dùng lượng buffer và 1 loại thì không
ch := make(chan int, 3) #Loại có dung lượng
ch := make(chan int) #Loại không có dung lượng
#Gửi dữ liệu
ch <- 18
#Nhận dữ liệu
value := <-ch
#Đóng channel
close(ch)
– Tìm hiểu rõ hơn về 2 loại channel qua ví dụ:
+ Unbuffered channels: Không có dung lượng buffer, vì vậy gửi và nhận phải xảy ra đồng thời. Thử ví dụ bên dưới bạn sẽ thấy:
func main() {
ch := make(chan int) //tạo unbuffered channel
//goroutine gửi dữ liệu vào channel
go func() {
defer close(ch)
time.Sleep(2 * time.Second)
ch <- 18//gửi và chờ ở đây cho đến khi có người nhận
fmt.Println("Data sent!")
}()
//nhận dữ liệu từ channel
value := <-ch //nhận và chờ ở đây nếu không có dữ liệu được gửi
fmt.Println("Data received:", value)
}
+ Buffered channels: Có dung lượng buffer, cho phép gửi nhiều giá trị mà không cần ngay lập tức có goroutine nhận giá trị. Khi buffer đầy, goroutine gửi sẽ bị block. Thử ví dụ bên dưới bạn sẽ thấy:
func main() {
//buffered channel với dung lượng buffer là 3
ch := make(chan int, 3)
//goroutine gửi dữ liệu
go func() {
for i := 1; i <= 8; i++ {
ch <- i //gửi và sẽ block khi buffer đầy
fmt.Printf("Sent %d\n", i)
}
}()
//goroutine nhận dữ liệu từ channel
go func() {
for value := range ch {
fmt.Printf("Received %d\n", value)
time.Sleep(1 * time.Second)
}
}()
time.Sleep(12 * time.Second)
}
4. Select
– Select trong Golang sẽ giúp bạn có thể chờ nhận dữ liệu từ nhiều channel khác nhau và bạn còn có thể thêm các cơ chế timeout và default case. Nhưng đối với default case thường sẽ cần bọc bởi vòng lặp for và trong default case sẽ có thêm thời gian sleep, nếu không bọc mà rơi vào case default sẽ dẫn kết kết thúc select.
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
// Goroutine gửi dữ liệu vào ch1 sau 2 giây
go func() {
time.Sleep(5 * time.Second)
ch1 <- "Data from channel 1"
}()
// Goroutine gửi dữ liệu vào ch2 sau 1 giây
go func() {
time.Sleep(1 * time.Second)
ch2 <- "Data from channel 2"
}()
/**
Sử dụng 2 select để chờ dữ liệu từ channel là ch1 và ch2
Thời gian timeout là 3 giây được xử lý với time.After
Khi sử dụng time.After(3 * time.Second), một kênh mới sẽ được tạo.
Sau 3 giây, kênh này gửi một thông báo, và select sẽ nhận được
thông báo đó để biết thời gian chờ đã hết.
*/
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
case <-time.After(3 * time.Second):
fmt.Println("Timeout: No data received")
}
}
time.Sleep(1 * time.Second)
}
5. Context
– Context trong Golang dùng để kiểm soát vong đời của các tác vụ và chia sẻ dữ liệu giữa các luồng với WithValue. Vậy nó kiểm soát vòng đời tác vụ như nào?
+ Giới hạn thời gian: Có 2 loại context giới hạn thời gian, một là thời gian thực thi với WithTimeout ví dụ như “2s”, trường hợp 2 là đặt deadline giới hạn thời gian kết thúc với WithDeadline ví dụ như “2024-11-03 07:52:32.1033345 +0700 +07 m=+6.030920901”
+ Cung cấp phương thức cancel đối với 1 số loại context như: WithCancel, WithTimeout, WithDeadline
+ Cung cấp phương thức Done đối với tất các các loại context dùng để báo hiệu context đã kết thúc
– Ví dụ các loại context:
func doWork(ctx context.Context, taskName string, second int) {
select {
case <-time.After(time.Duration(second) * time.Second):
fmt.Println(taskName, "completed after", second)
case <-ctx.Done(): //nhận thông báo context kết thúc
fmt.Println(taskName, "cancelled:", ctx.Err())
}
}
func main() {
//sử dụng WithCancel
ctxCancel, cancel := context.WithCancel(context.Background())
defer cancel()
fmt.Println("Starting tasks with WithCancel")
go doWork(ctxCancel, "Task 1 (WithCancel)", 3)
go doWork(ctxCancel, "Task 2 (WithCancel)", 5)
//gọi cancel() sau 2 giây
time.Sleep(2 * time.Second)
cancel()
//chờ xem kết quả
time.Sleep(1 * time.Second)
//sử dụng WithTimeout
ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelTimeout()
fmt.Println("\nStarting tasks with WithTimeout")
go doWork(ctxTimeout, "Task 1 (WithTimeout)", 2)
//tác vụ không hoàn thành
go doWork(ctxTimeout, "Task 2 (WithTimeout)", 4)
go doWork(ctxTimeout, "Task 3 (WithTimeout)", 5)
//chờ xem kết quả
time.Sleep(4 * time.Second)
//sử dụng WithDeadline
deadline := time.Now().Add(3 * time.Second)
ctxDeadline, cancelDeadline := context.WithDeadline(context.Background(), deadline)
defer cancelDeadline()
fmt.Println("\nStarting tasks with WithDeadline")
go doWork(ctxDeadline, "Task 1 (WithDeadline)", 2)
//tác vụ không hoàn thành
go doWork(ctxDeadline, "Task 2 (WithDeadline)", 4)
go doWork(ctxDeadline, "Task 3 (WithDeadline)", 5)
//chờ xem kết quả
time.Sleep(4 * time.Second)
//sử dụng WithValue
ctxValue := context.WithValue(context.Background(), "key", "value")
fmt.Println("\nValue from context:", ctxValue.Value("key"))
}
IV. Kết luận
Trên đây là cơ bản những gì mình tìm hiểu được về concurrency trong Golang, và mình thấy để đảm bảo tránh được tình trạng deadlock và race condition chúng ta nên hiểu rõ cách hoạt động các tools trước khi sử dụng. Bên cạnh đó từ những ưu nhược điểm chúng ta sẽ có sự lựa chọn cũng như giải pháp phù hợp với từng đề bài, tối ưu hóa hiệu suất và đảm bảo tính ổn định cho ứng dụng
Cảm ơn các bạn đã đọc!
V. Tài liệu tham khảo
1. https://www.google.com/
2. https://go.dev/
3. https://chat.openai.com/