, October 02, 2022

0 kết quả được tìm thấy

Những sai lầm thường thấy khi bạn sử dụng goroutines

  • Đăng bởi  Chau Le
  •  Jan 23, 2022

  •   8 min reads
Những sai lầm thường thấy khi bạn sử dụng goroutines

Trong bài viết này, mình sẽ đề cập đến một số trường hợp và sự cố phổ biến mà bạn có thể gặp phải khi sử dụng goroutines và cách để giải quyết chúng.

1. Giới thiệu

Đầu tiên, goroutine là gì? Bản chất Golang là concurrent. Để đạt được tính concurrent, Go sử dụng các goroutines - các hàm hoặc phương thức chạy đồng thời với các hàm hoặc phương thức khác. Ngay cả hàm main của Golang cũng là một goroutine.

Goroutines có thể được xem như các lightweight thread, nhưng không giống như các thread, chúng không được quản lý bởi hệ điều hành mà bởi runtime của Golang.

Việc một ứng dụng Go có hàng trăm, thậm chí hàng nghìn goroutines chạy đồng thời là điều rất bình thường.

(Đọc thêm về goroutines tại đây)

Hãy bắt đầu với một ví dụ nhanh và tạo một tệp hello.go:

package main

import (
 "fmt"
 "time"
)

func hello() {
    fmt.Println("Hello")
}

func main() {
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

và output sẽ là:

$ go run hello.go
Hello
main function

Tuyệt vời, goroutine của chúng ta đã thực thi thành công.

Tuy nhiên, khi bạn bắt đầu thêm nhiều chức năng hơn vào các goroutines, bạn cũng có thể gặp phải một trong những trường hợp phổ biến dưới đây.

2. Vấn đề Waiting…

Hãy bắt đầu với một cái đơn giản:

Như bạn có thể nhận thấy, việc sử dụng time.Sleep rất phổ biến khi thể hiện chức năng cơ bản của goroutines.

Vậy tại sao sleep lại cần thiết ở đây? Hãy kiểm tra nó ngay lập tức mà không cần hàm time.Sleep.

package main

import (
 "fmt"
 "time"
)

func hello() {
    fmt.Println("Hello")
}

func main() {
    go hello()
    // time.Sleep(1 * time.Second)     // now it's commented out
    fmt.Println("main function")
}

$ go run hello.go
main function

Rất tiếc, hiện tại output của goroutine bị thiếu. Tại sao lại như vậy?

Bởi vì quá trình thực thi của chương trình bắt đầu bằng cách khởi tạo main package và sau đó call hàm main. Khi lệnh gọi hàm đó trả về, chương trình sẽ thoát. Nó không đợi các goroutines khác (non-main) hoàn thành.

Có nghĩa là khi hàm main kết thúc, nó sẽ thực thi, nó sẽ không đợi các goroutines khác kết thúc.

Vì vậy, bây giờ chúng ta đã hiểu sự cần thiết của việc đợi các goroutines khác kết thúc, có cách nào hiệu quả và hiệu quả hơn để đợi goroutines kết thúc, thay vì đoán xem sẽ mất bao lâu để kết thúc goroutines?

Có chứ! nó được gọi là WaitGroups.

WaitGroups cho phép chúng ta chặn cho đến khi tất cả các goroutines trong WaitGroups đó hoàn tất quá trình thực thi của chúng.

Một ví dụ về triển khai WaitGroup:

package main

import (
    "fmt"
    "sync"
)

func hello(wgrp *sync.WaitGroup) {
    fmt.Println("Hello")
    wgrp.Done()           /////// notifies the waitgroup that it 			finished
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)         /////// adds an entry to the waitgroup counter
    go hello(&wg)
    wg.Wait()  ////// blocks execution until the goroutine finishes
    fmt.Println("main function")
}

Chạy code

$ time go run hello.go 
Hello
main function
real 0m0.230s
user 0m0.240s
sys 0m0.099s

Tốt hơn và nhanh hơn, vì chúng ta không phải đợi một khoảng thời gian cố định.

3. Deadlocks

Có thể bạn đã từng gặp lỗi đáng sợ này trước đây

fatal error: all goroutines are asleep - deadlock!

Deadlock xảy ra khi một nhóm goroutines đang đợi nhau và không ai trong số đó có thể tiến hành.

Hãy nhớ rằng, main package cũng là goroutine.

package main

import (
    "fmt"
    "sync"
)

func hello(wgrp *sync.WaitGroup) {
    fmt.Println("Hello")
    wgrp.Done()           /////// removing the wgrp.Done will cause a 		deadlock
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)             ///// 2 as the value will cause a deadlock
    go hello(&wg)
    wg.Wait()  ////// blocks execution until the goroutine finishes
    fmt.Println("main function")
}
  1. wgrp.Done() đánh dấu việc thực thi chương trình goroutine đã kết thúc. Nếu bỏ qua điều này cũng sẽ gây ra deadlock.
  2. wg.Add() nhận số lượng goroutines mà chúng ta nên chờ đợi.

Những giá trị khả thi bao gồm:

0 và goroutine sẽ không thực thi

1 sẽ hoạt động như mong đợi

2 trở lên sẽ dẫn đến deadlock

Trong cả hai trường hợp, chúng ta sẽ gặp phải deadlock vì hàm main đợi quy trình khác hoàn thành quá trình thực thi của nó:

Trường hợp 1: Goroutine sẽ không bao giờ đánh dấu việc thực thi nó trên WorkGroup là xong.

Trường hợp 2: wg.Add sẽ tiếp tục chờ đợi nhiều goroutines hơn dự kiến ​​để chạy.

Một trường hợp khác mà bạn sẽ gặp deadlock là khi không có goroutines nào khác để nhận những gì người gửi gửi, vì điều này không thể xảy ra trong cùng một goroutine:

package main

import "fmt"

func main() {
    c := make(chan string)
    c <- "hello"
    fmt.Println(<-c)
}

thay vào đó, hãy làm điều này:

package main

import (
    "fmt"
)

func main() {
    c := make(chan string)
    go func() {
        get := <-c                     
        fmt.Println("get value:", get)
    }()
    fmt.Println("push to channel c")
    c <- "hello" // send the value and wait until it's received.
}

4. Kết quả bất ngờ

Thêm một vòng lặp for vào hỗn hợp:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    players := []string{"James Harden", "Kyrie Irving", "Kevin Durant"}
    wg.Add(len(players))
	for _, player := range players {
        go func() {
            fmt.Printf("printing player %s\n", player)
            wg.Done()
        }()
    }
    wg.Wait()
}

$ go run hello.go
printing player Kevin Durant
printing player Kevin Durant
printing player Kevin Durant

Huh? Có phải nó sẽ in các tên khác nhau mỗi lần lặp lại không?

Uhm.. đúng là như vậy, nhưng các goroutines được tạo bên trong vòng lặp for sẽ không nhất thiết phải thực thi tuần tự.

Mỗi quy trình bắt đầu ngẫu nhiên.

Cách giải quyết khá đơn giản, chúng ta sẽ chỉ chuyển mục hiện tại của lần lặp:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    players := []string{"James Harden", "Kyrie Irving", "Kevin Durant"}
    wg.Add(len(players))
for _, player := range players {
        go func(baller string) { // add the current iterated player
            fmt.Printf("printing player %s\n", baller)
            wg.Done()
        }(player) // add the current iterated player
    }
    wg.Wait()
}

$ go run hello.go
printing player James Harden
printing player Kevin Durant
printing player Kyrie Irving

5. Race conditions và chia sẻ data giữa các goroutine

Giờ đây, nó trở nên phức tạp và thú vị hơn một chút:

Hãy tưởng tượng rằng bạn có một ứng dụng ngân hàng, nơi khách hàng có thể gửi và rút tiền.

Miễn là ứng dụng là single thread và đồng bộ, sẽ không có bất kỳ vấn đề gì, nhưng điều gì sẽ xảy ra nếu ứng dụng của bạn tạo ra hàng trăm hoặc hàng nghìn goroutines?

Hãy xem xét tình huống này:

Một khách hàng có số dư là 100 đô la và gửi 50 đô la vào tài khoản của mình.

Một goroutines xem các giao dịch, đọc số dư hiện tại là 100 đô la và thêm 50 đô la vào số dư.

Nhưng khoan đã, đồng thời cũng có một khoản phí 80 đô la được áp dụng cho tài khoản của khách hàng để thanh toán hóa đơn của anh ta tại quán bar địa phương.

Goroutine thứ hai sẽ đọc số dư hiện tại khi đó là 100 đô la, trừ đi 80 đô la trong tài khoản và cập nhật số dư tài khoản.

Sau đó, khách hàng sẽ kiểm tra số dư tài khoản của mình và thấy rằng đó chỉ là 20 đô la thay vì 70 đô la, vì goroutine thứ hai đã ghi đè giá trị số dư khi xử lý giao dịch đó.

Để giải quyết vấn đề này, chúng ta có thể sử dụng Mutex.

Mutex? Mutex (loại trừ lẫn nhau) là một phương pháp được sử dụng như một locking mechanism để đảm bảo rằng chỉ có một Goroutine đang truy cập vào phần code quan trọng tại bất kỳ thời điểm nào.

Thêm Mutexes tại đây. Nó sẽ trông như thế này:

package main

import (
    "fmt"
    "sync"
)

var (
    mutex   sync.Mutex
    balance int
)

func init() {
    balance = 100
}

func deposit(val int, wg *sync.WaitGroup) {
    mutex.Lock()             // lock
    balance += val
    mutex.Unlock()           // unlock
    wg.Done()
}

func withdraw(val int, wg *sync.WaitGroup) {
    mutex.Lock()             // lock
    balance -= val
    mutex.Unlock()           // unlock
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    wg.Add(3)
    go deposit(20, &wg)
    go withdraw(80, &wg)
    go deposit(40, &wg)
    wg.Wait()
    fmt.Printf("Balance is: %d\n", balance)
}

$ go run mutex.go
Balance is: 80

Hãy chú ý đến các lệnh mutex.Lock() command và mutex.Unlock() command để làm cho nó xảy ra.

Chúng ta vẫn sử dụng workgroup theo cách tương tự như đã giải thích trước đó.

Có một cách khác để giải quyết nó, lần này là sử dụng các channel.

Channel là các đường ống kết nối các tuyến concurrent goroutines. Bạn có thể gửi các giá trị vào các channel từ một goroutine và nhận các giá trị đó vào một goroutine khác.

Hãy nhớ rằng, hàm main cũng là một goroutine.

(Xem thêm các channel tại đây)

Trong ví dụ này, chúng ta sử dụng buffered channel.

Buffered channel này được sử dụng để đảm bảo rằng chỉ một goroutine có thể truy cập vào phần code quan trọng, là phần điều chỉnh số dư.

package main

import (
    "fmt"
    "sync"
)

var (
    balance int
)

func init() {
    balance = 100
}

func deposit(val int, wg *sync.WaitGroup, ch chan bool) {
    ch <- true
    balance += val
    <-ch
    wg.Done()
}

func withdraw(val int, wg *sync.WaitGroup, ch chan bool) {
    ch <- true
    balance -= val
    <-ch
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    ch := make(chan bool, 1)      // create the channel
    wg.Add(3)
    go deposit(20, &wg, ch)
    go withdraw(80, &wg, ch)
    go deposit(40, &wg, ch)
    wg.Wait()
    fmt.Printf("Balance is: %d\n", balance)
}

$ go run buf.go 
Balance is: 80

Chúng ta đã tạo một buffered channel với dung lượng là1, vì chúng ta muốn sửa đổi số dư chỉ một lần cho mỗi operation và nó được chuyển cho các goroutines gửi/rút tiền.

Vậy chúng ta nên chọn cái nào?

Nói chung, sử dụng các channel khi các Goroutines cần giao tiếp với nhau và tắt tiếng khi chỉ một Goroutine truy cập vào phần code quan trọng.
Trong trường hợp này, cách tốt nhất là sử dụng Mutex.

Mình hy vọng bạn thấy bài viết này hữu ích.

Bài viết được lược dịch từ Reshef Sharvit.

Bài viết liên quan

Buffered Channel là gì? Thường xuyên bị hỏi trong phỏng vấn Golang Dev

Buffered Channel là một channel trong Golang có khả năng lưu trữ được dữ liệu bên trong nó. Khả năng này được gọi là sức chứa (capacity)....

Buffered Channel là gì? Thường xuyên bị hỏi trong phỏng vấn Golang Dev
Golang Channel là gì? Các ứng dụng Channel trong Golang

Channel là kênh giao tiếp trung gian giữa 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....

Golang Channel là gì? Các ứng dụng Channel trong Golang
Goroutines là gì? Lập trình concurrency chưa bao giờ dễ như Golang

Goroutines bản chất là các hàm được thực thi một các độc lập và đồng thời trong Golang cực kỳ đơn giản và gọn nhẹ so với các Thread truyền thống....

Goroutines là gì? Lập trình concurrency chưa bao giờ dễ như Golang
Rust vs Go - Ngôn ngữ nào tốt nhất năm 2022

Rust và Go hiện là 2 ứng cử viên nặng ký và tin dùng cho các hệ thống lớn. Rust và Golang trông có vẻ tương tự nhau nhưng chúng cũng rất khác biệt...

Rust vs Go - Ngôn ngữ nào tốt nhất năm 2022
Ứng dụng Clean Architecture cho service Golang REST API

Trong bài viết này mình sẽ cung cấp một ví dụ cụ thể cho việc vận dụng nguyên tắc và tư duy Clean Architecture cho một service REST API Golang...

Ứng dụng Clean Architecture cho service Golang REST API
You've successfully subscribed to 200Lab Blog
Great! Next, complete checkout for full access to 200Lab Blog
Xin chào mừng bạn đã quay trở lại
OK! Tài khoản của bạn đã kích hoạt thành công.
Success! Your billing info is updated.
Billing info update failed.
Your link has expired.