Concurrency trong Go Lang

Goroutines (note)

Một chương trình chạy có thể có một hoặc nhiều goroutines. Tuy nhiên hàm main lại đặc biệt hơn. Khi hàm main() exit, tất cả các goroutines lập tức bị terminate.

Từ đó sinh ra một khái niệm mới: WaitGroup.

Một WaitGroup sẽ chờ một tập hợp goroutines kết thúc. Hàm goroutines chính sẽ thêm số goroutines mà nó muốn chờ, mỗi hàm goroutine khi chạy xong sẽ gọi Done(). Cho tới khi mà các goroutines chưa được chạy xong, thì waitgroup sẽ block chương trình tại thời điểm đó.

func main() {
    var message []int
    var wg sync.WaitGroup //tạo instance

    wg.Add(3) // Thêm 3 goroutines vào danh sách muốn đợi
    go func() {
        defer wg.Done() // sau khi chạy 2 lệnh dưới xong sẽ kết thúc,
                        // trả về done cho wg
        time.Sleep(time.Second * 3)
        messages[0] = 1
    }()
    go func() {
        defer wg.Done()
        time.Sleep(time.Second * 2)
        messages[1] = 2
    }()
    go func() {
        defer wg.Done()
        time.Sleep(time.Second * 1)
        messages[2] = 3
    }()
    go func() {
        for i := range messages {
            fmt.Println(i)
        }
    }()

    wg.Wait() // chừng nào chưa chạy xong chưa chạy xong 3 hàm trên,
              // block chương trình.
}

Chúng ta có thể hiểu như sau: Khi wg(WaitGroup) Add n goroutines để đợi, với mỗi goroutine chạy xong, wg sẽ giảm đi 1. Hàm wg.Wait() chỉ được chạy qua khi và chỉ khi wg có số goroutines để đợi bằng 0.

Thử viết lại hàm lúc nãy bằng waitgroup:

var wg sync.WaitGroup
  wg.Add(1)
  go func() {
    defer wg.Done()
    fmt.Println("Hello")
  }()
  wg.Wait()

Xử lý xung đột

Khi hai hay nhiều goroutine xử lý cùng 1 biến hay tài nguyên thì chuyện xảy ra xung đột là không tránh khỏi vì đọc/ghi của các goroutine không theo thứ tự mong muốn. Xử lý xung đột là một trong những vấn đề phức tạp nhất của xử lý đồng thời bởi khả năng gây là lỗi là rất cao. Để đảm bảo không xung đột, biến hay tài nguyên cần phải được đồng bộ khi xử lý và đảm bảo tại một thời điểm chỉ có một goroutine sử dụng biến hay tài nguyên này.

Để xử lý xung đột, Go cung cấp 2 package atomic và sync giúp chúng ta thực hiện việc này dễ dàng.

Atomic

Package atomic tạo cơ chế khóa cấp thấp cho các dữ liệu cần bảo vệ kiểu số và kiểu con trỏ với các hàm:

– AddT giúp cộng thêm giá trị cho dữ liệu.
– StoreT giúp lưu giá trị mới.
– LoadT giúp lấy giá trị của dữ liệu với T là kiểu dữ liệu số và con trỏ.
Ví dụ trên được sửa lại như sau:
– Thêm package sync/atomic vào import.

Mutex

Dùng atomic giải quyết được vấn đề xung đột nhưng chỉ áp dụng được với số nguyên và con trỏ. Go cung cấp mutex để giúp chúng ta xử lý với các biến kiểu dữ liệu khác.
Cách xử dụng mutex cũng vô cùng đơn giản. Đầu tiên chúng ta khai báo biến mutex thuộc kiểu sync.Mutex. Sau đó đoạn nào cần xử lý đồng bộ, chúng ta gọi hàm mutex.Lock(). Sau khi thực hiện xong, chúng ta gọi hàm mutex.Unlock()

– Thêm package “sync” vào import.

 var ( 
     counter int64 
     mutex sync.Mutex 
 ) 
 mutex.Lock() 
 counter++ 
 mutex.Unlock()

Tóm tắt:

– Sử dụng từ khóa go trước các lệnh gọi hàm hay phương thức để biến chúng thành goroutine xử lý đồng thời. Các goroutine này sẽ được phân phối vào một hay nhiều bộ xử lý logic.
– Mỗi bộ xử lý logic gắn với một thread hệ thống.
– Khi các goroutine cùng xử lý một biến hay tài nguyên, khả năng xung đột sẽ xảy ra. Kiểm tra xung đột bằng cách khai báo thêm tham số -race khi biên dịch chương trình, Go sẽ báo có xung đột khi thực thi.
– Go cung cấp 2 cơ chế xử lý xung đột:
 + Atomic xử lý xung đột cho kiểu số nguyên và con trỏ thông qua các hàm AddT, LoadT và StoreT.
 + Mutex xử lý xung đột bằng các hàm Lock() và Unlock(). Gọi cặp hàm này giữa nhóm hàm cần thực hiện đồng bộ.

 

Defer

Một số lưu ý khi sử dụng defer:

Giá trị tham số của hàm được gọi kèm với defer sẽ nhận giá trị tại thời điểm defer được gọi chứ không phải thời điểm hàm này thực thi.

func a() { 
    i := 0 
    defer fmt.Println(i) 
    i++ 
    return 
}

Khi thực thi thì giá trị i được in ra là 0 do lúc gọi defer thì hàm Println nhận giá trị i = 0 nên dù thực hiện cuối hàm nó vẫn in giá trị 0.
– Khi có nhiều defer trong một hàm thì các lệnh defer thực hiện theo cơ chế, gọi sau thực hiện trước. Trong ví dụ bên dưới thì kết quả in ra là “3210”

func b() { 
    for i := 0; i < 4; i++ { 
        defer fmt.Print(i) 
    } 
}

– Do defer thực hiện sau cùng ở hàm, sau cả lệnh return nên nếu sử dụng defer hàm vô danh thì hàm vô danh có thể nhận giá trị biến trả về mới nhất.

func double(x int) (result int) { 
    defer func() { fmt.Printf("double(%d) = %d\n", x, result) }() 
    return x + x 
}
Ở đây khi thực hiện double (3) ta sẽ có kết quả là 6 do hàm vô danh thực hiện sau cùng nên biến result tại thời điểm thực thi nó đã có giá trị là 6.
– Nếu defer hàm vô danh và hàm này thay đổi giá trị biến trả về thì giá trị mà hàm trả về cũng sẽ thay đổi theo kết quả mà hàm vô danh mang lại.
– Do hàm với defer thực hiện sau cùng ở hàm nên nếu cần mở, đóng hàng loạt tài nguyên trong vòng lặp for thì không nên defer hàm đóng tài nguyên trong vòng lặp for vì nó sẽ không đóng ngay dẫn đến việc mở nhiều tài nguyên và có thể khiến tài nguyên cạn kiệt. Ví dụ:
for _, filename := range filenames { 
     f, err := os.Open(filename) 
     if err != nil { 
         return err 
    } 
    defer f.Close() // Nguy hiểm, có thể gây cạn tài nguyên 
    // ...xử lý f... 
}
Ở ví dụ này các file sẽ không được đóng cho đến khi thực hiện xong hàm chứa vòng lặp này. Cách giải quyết là tách cụm xử lý file từ lúc mở, defer đóng file cho đến xử lý vào chung 1 hàm và vòng for gọi hàm này. Như vậy mỗi lần lặp thì file cũ đã được mở và đóng xong trong hàm riêng xử lý file.
Tóm tắt:
 Lệnh trì hoãn:
+ Là lệnh khai báo với từ khóa defer giúp lệnh không thực thi ngay mà thực thi vào cuối hàm, sau cả lệnh return.
+ Defer thường dùng để báo đóng/hủy tài nguyên đã sử dụng.
+ Không nên dùng defer đóng tài nguyên trong vòng for vì sẽ tạo ra hàng loạt lệnh thực thi cuối hàm dẫn đến ngốn tài nguyên.

 

 

Site Footer