Bài 21- Xử lý đồng thời trong Go (Goroutine)


  • Trùm cuối

    Goroutine

    Thường thì các chương trình thực thi tuần tự các lệnh và các hàm trong main từ đầu đến cuối, lệnh trước xong sẽ đến lệnh sau cho đến khi kết thúc hàm main. Nếu có thể nên xây dựng chương trình theo hướng như vậy, vì đó là cách tạo chương trình đơn giản và dễ quản lý nhất. Tuy vậy, trong nhiều điều kiện, việc xử lý được nhiều tác vụ đồng thời là yêu cầu bắt buộc để đảm bảo chương trình hoạt động hiệu quả.

    Ví dụ dễ thấy nhất là khi xây dựng ứng dụng web. Tại cùng một thời điểm, ứng dụng web có thể nhận và xử lý nhiều yêu cầu từ nhiều người dùng khác nhau. Nếu cứ tuần tự thì những người được phục vụ sau sẽ khó mà chờ nổi. Xử lý đồng thời là yêu cầu không thể thiếu trong những tình huống này và Go đã cung cấp một công cụ tuyệt vời cho việc này.

    Goroutine

    0cf081b2-a67f-476f-8833-a1d3b10752f3-image.png

    Khi chương trình được thực thi, hệ thống sẽ tạo một tiến trình (process) quản lý tất cả các hoạt động và tài nguyên cần cho chương trình hoạt động. Và tiến trình tạo ra tiểu trình (thread) là đối tượng đảm nhận việc thực thi các lệnh của chương trình. Tiểu trình này được tạo và chạy xuyên suốt từ đầu đến cuối cùng với tiến trình gọi là tiểu trình chính (main thread). Khi cần thực hiện thêm các tác vụ đồng thời thì tiểu trình chính sinh ra thêm tiểu trình mới để thực hiện các tác vụ này. Do đó, lập trình xử lý tác vụ đồng thời, các ngôn ngữ khác hay gọi là lập trình thread với các cách tạo thread, khai báo tham số, gọi thực thi rất phức tạp. Với Go, chỉ cần khai báo từ khóa go trước cách gọi hàm thông thường hay trước một phương thức, hàm hay phương thức đó sẽ được xử lý đồng thời. Quá đơn giản phải không nào!

    Mỗi hàm hay phương thức được xử lý đồng thời qua từ khóa go đằng trước, ở Go gọi là goroutine thay vì thread như các ngôn ngữ khác. Go cũng sẽ không giúp gắn các goroutine vào các thread hệ thống để thực thi. Thực tế ở bên dưới, Go có bộ điều khiển quản lý các goroutine rồi phân phối chúng vào các bộ xử lý logic và gắn mỗi bộ xử lý logic này với một thread hệ thống được tạo ra trước đó để thực thi các goroutine này. Nói cách khác, mỗi thread hệ thống sẽ xử lý một nhóm goroutine được điều phối thông qua bộ xử lý logic. Với bộ điều khiển quản lý tác vụ đồng thời và cơ chế bộ xử lý logic, những cái khó khăn, phức tạp khi khai báo thread Go đã xử lý hết giúp chúng ta rồi. Việc chúng ta là tạo ra các goroutine với từ khóa go. Hãy cùng khảo sát ví dụ sau:

       // Chương trình in ra 3 lần bảng chữ cái viết thường và viết hoa
       package main
                                            
       import (
         "fmt"
         //"time"
        )
    
      func main() {
         fmt.Println("Bắt đầu Goroutines")
    
         // Khai báo hàm vô danh và tạo một goroutine với từ khóa go
         go func() {
             // Hiển thị bảng chữ cái viết thường 3 lần
             for count := 0; count < 3; count++ {
                 for char := 'a'; char < 'a'+26; char++ {
                     fmt.Printf("%c ", char)
                     //time.Sleep(time.Millisecond * 10)
                 }
             }
         }()
    
         go func() {
             // Hiển thị bảng chữ cái viết hoa 3 lần
             for count := 0; count < 3; count++ {
                 for char := 'A'; char < 'A'+26; char++ {
                     fmt.Printf("%c ", char)
                     //time.Sleep(time.Millisecond * 10)
                 }
             }
         }()
    
         // Đợi các goroutine kết thúc
         fmt.Println("Đợi kết thúc các gorountine")
         var input string
         fmt.Scanln(&input)
         fmt.Println("\nKết thúc chương trình")
     } 
    
    • Khi thực thi, ứng dụng sẽ tiến hành in đồng thời 3 lần bảng chữ cái viết thường và viết hoa. Tuy nhiên do thời gian hàm vô danh ở dòng 13-21 thực hiện quá nhanh và kết thúc trước khi hàm vô danh ở dòng 23-31 bắt đầu thực thi nên kết quả nhận được là 3 bảng chữ cái thường đến 3 bảng chữ cái hoa được in liên tiếp khiến cho ý nghĩa goroutine không có.
    • Các bạn chỉ cần bỏ đánh dấu chú thích ở các dòng 6, 18, 28 và thực thi lại sẽ thấy kết quả khác ngay. Lúc này, do mỗi tác vụ in nghỉ 10 mili giây nên thời gian thực thi mỗi goroutine được kéo dài và bộ điều khiển goroutine sẽ phân phối thực thi dẫn đến bảng chữ cái thường và hoa được in ra xen kẽ lẫn nhau.
    • Mục đích của dòng 35-36 là để chờ 2 goroutine thực hiện xong. Nếu không có 2 dòng này, sau khi gọi 2 goroutine thì hàm main in ra 2 dòng chữ rồi kết thúc. Hàm main kết thúc thì các goroutine cũng sẽ bị đóng theo và kết quả in ra sẽ không như ý chúng ta muốn.

    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.

    Chúng ta cùng xem qua ví dụ về xung đột tài nguyên như sau: Chúng ta tạo 100 goroutine, mỗi goroutine thực hiện cộng dồn 1 vào biến toàn cục 10000 lần. Kết quả mong muốn là 1000000:

        package main 
      
        import "fmt" 
      
        var counter int64 
      
        func main() { 
            for i := 0; i < 100; i++ { 
                go func() { // Hàm vô danh 
                    for i := 0; i < 10000; i++ { 
                        counter++ 
                    } 
                }() 
            } 
      
            var c string 
            fmt.Scanln(&c) 
            fmt.Println(counter) 
        }  
    
    • Kết quả nhận được là khác nhau ở mỗi lần thực thi nhưng không bao giờ được 1000000 như mong muốn.
    • Nguyên nhân tại cùng thời điểm có thể nhiều goroutine trong tổng số 100 goroutine chạy lấy cùng giá trị counter nên sau bước tăng thêm một ở dòng 11, giá trị mà các goroutine này cập nhật mới tăng thêm một (bởi goroutine cập nhật sau cùng) thay vì tăng đúng số goroutine chạy thời điểm đó.
    • Nếu bạn chạy thử mà vẫn cho kết quả 1000000 thì rất có thể bạn đang xài bản Go phiên bản < 1.5 vì với các phiên bản cũ, mặc định Go chỉ sử dụng một bộ xử lý logic khi xử lý các goroutine nên các goroutine được xử lý luân phiên nhưng do mỗi lượt goroutine đã chạy xong vòng lặp 10000 nên thực chất các goroutine chạy tuần tự liên tiếp nhau. Muốn sử dụng nhiều hơn một bộ xử lý logic bạn cần sử dụng hàm runtime.GOMAXPROCS(<số bộ xử lý logic>). Để lấy số bộ xử lý logic tối đa gắn với tiến trình, chúng ta dùng hàm runtime.NumCPU(). Phiên bản 1.5 trở lên, Go đã lấy mặc định là số bộ xử lý logic tối đa rồi.

    Go có hỗ trợ phát hiện xung đột khi lập trình xử lý đồng thời bằng cách khi biên dịch thêm tham số -race. Khi thực thi Go sẽ báo cho chúng ta biết xung đột chỗ nào.

    481952c8-f33e-4287-9f26-d5b750a1ccc9-image.png

    Để xử lý xung đột, Go cung cấp 2 package atomicsync 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.
    • Dòng 11 ở ví dụ trên sửa thành: atomic.AddInt64(&counter, 1)

    Lúc này kết quả trả về sẽ cho chúng ta giá trị đúng: 1000000. Chúng ta cũng không thấy Go báo có xung đột nữa:

    21b1ac82-1c07-4e0a-a8ae-67425fc3cff7-image.png

    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() như dòng 17 và 19 bên dưới:

    package main 
     
    import ( 
        "fmt" 
        "sync" 
    ) 
    
    var ( 
        counter int64 
        mutex sync.Mutex 
    ) 
     
    func main() { 
        for i := 0; i < 100; i++ { 
            go func() { // Hàm vô danh 
                for i := 0; i < 10000; i++ { 
                    mutex.Lock() 
                    counter++ 
                    mutex.Unlock() 
                } 
            }() 
        } 
        var c string 
        fmt.Scanln(&c) 
        fmt.Println(counter) 
    }
    

    Bài tiếp theo là cơ chế truyền dữ liệu giữa các goroutine.

    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ộ.


Có thể bạn cũng quan tâm

.
DMCA.com Protection Status