[Golang nâng cao] Goroutines trong Golang

Post on: 2023-04-11 23:09:32 | in: Golang
Goroutine là một hàm độc lập chạy trên một thread riêng biệt và không chặn luồng chính của chương trình. Một chương trình Go có thể chứa hàng nghìn goroutine

Tổng quan

Goroutines có thể được coi là một luồng nhẹ có thực thi độc lập riêng biệt và có thể thực thi song song với các goroutine khác. Đó là một hàm hoặc phương thức đang thực thi song song với các goroutine khác. Nó được quản lý hoàn toàn bởi runtime của GO. Golang là một ngôn ngữ song song. Mỗi goroutine là một thực thi độc lập. Chính là goroutine giúp đạt được tính song song trong golang.

Bắt đầu một goroutine

Golang sử dụng từ khóa đặc biệt 'go' để bắt đầu một goroutine. Để bắt đầu một goroutine, chỉ cần thêm từ khóa 'go' trước một cuộc gọi hàm hoặc phương thức. Hàm hoặc phương thức đó sẽ được thực thi trong goroutine. Lưu ý rằng không phải hàm hoặc phương thức quyết định nó có phải là goroutine hay không. Nếu chúng ta gọi hàm hoặc phương thức đó với từ khóa go thì hàm hoặc phương thức đó được cho là đang thực thi trong một goroutine.

Hãy hiểu sự khác biệt giữa chạy một hàm bình thường và chạy một hàm như một goroutine.

  • Chạy bình thường một hàm
statment1
start()
statement2

Trong quá trình chạy bình thường của một hàm cho tình huống trên:

  • Đầu tiên, statement1 sẽ được thực hiện
  • Sau đó, hàm start() sẽ được gọi
  • Khi hàm start() hoàn thành thì statement2 sẽ được thực hiện

Chạy một hàm như một goroutine

statment1
go start()
statement2

Trong quá trình chạy một hàm như một goroutine cho tình huống trên:

  • Đầu tiên, statement1 sẽ được thực hiện
  • Sau đó, hàm start() sẽ được gọi như một goroutine, được thực thi bất đồng bộ.
  • statement2 sẽ được thực hiện ngay lập tức. Nó sẽ không chờ đợi hàm start() hoàn thành. Hàm start() sẽ được thực thi đồng thời như một goroutine trong khi phần còn lại của chương trình tiếp tục thực thi.

Về cơ bản, khi gọi một hàm như một goroutine, lời gọi sẽ trả về ngay lập tức và chương trình sẽ tiếp tục thực thi từ dòng tiếp theo trong khi goroutine sẽ được thực thi đồng thời trong nền. Lưu ý rằng bất kỳ giá trị trả về nào từ goroutine đều sẽ bị bỏ qua.

Hãy xem một chương trình để hiểu điểm trên:

package main

import (
    "fmt"
    "time"
)

func main() {
    go start()
    fmt.Println("Started")
    time.Sleep(1 * time.Second)
    fmt.Println("Finished")
}

func start() {
    fmt.Println("In Goroutine")
}

Output

Started
In Goroutine
Finished

Trong chương trình trên, chúng ta sử dụng từ khóa 'go' trước một cuộc gọi hàm để bắt đầu một goroutine.

go start()

Dòng trên sẽ bắt đầu một goroutine, chạy hàm start(). Chương trình trước tiên in ra "Started". Chú ý rằng dòng in "Started" nằm sau khi goroutine được bắt đầu. Điều này minh họa cho điểm được đề cập ở trên rằng sau khi một goroutine được bắt đầu, cuộc gọi được tiếp tục từ dòng tiếp theo. Sau đó, chúng ta đặt một thời gian chờ. Thời gian chờ được đặt để goroutine được lên lịch thực thi trước khi goroutine chính đã kết thúc. Vì vậy bây giờ goroutine thực thi và nó in ra... (Không có thông tin về phần tiếp theo của câu, xin vui lòng cung cấp thêm thông tin để mình có thể dịch tiếp).

In Goroutine

Sau đó nó in

Finished

Chúng ta sẽ thử xem điều gì sẽ xảy ra nếu chúng ta loại bỏ timeout. Dưới đây là một chương trình để minh họa.

package main
import (
    "fmt"
)
func main() {
    go start()
    fmt.Println("Started")
    fmt.Println("Finished")
}
func start() {
    fmt.Println("In Goroutine")
}

Output

Started
Finished

Chương trình trên không bao giờ in

In Goroutine

Điều đó có nghĩa là goroutine chưa bao giờ được thực thi. Điều này xảy ra bởi vì goroutine chính hoặc chương trình đã thoát trước khi goroutine có thể được lên lịch thực thi. Điều này đưa ra cuộc thảo luận về goroutine chính.

Main goroutine

Hàm main trong package main là main goroutine. Tất cả các goroutine được bắt đầu từ main goroutine. Các goroutine này có thể bắt đầu nhiều goroutine khác và vân vân.

Main goroutine đại diện cho chương trình chính. Một khi nó thoát thì có nghĩa là chương trình đã thoát.

Các goroutine không có parents hoặc children. Khi bạn bắt đầu một goroutine, nó chỉ thực thi song song với tất cả các goroutine khác đang chạy. Mỗi goroutine chỉ thoát khi hàm của nó trả về. Ngoại lệ duy nhất là tất cả các goroutine sẽ thoát khi main goroutine (goroutine chạy hàm main) thoát.

Hãy xem một chương trình để chứng minh rằng goroutine không có parents hoặc children.

package main

import (
    "fmt"
    "time"
)

func main() {
    go start()
    fmt.Println("Started")
    time.Sleep(1 * time.Second)
    fmt.Println("Finished")
}

func start() {
    go start2()
    fmt.Println("In Goroutine")
}
func start2() {
    fmt.Println("In Goroutine2")
}

Output

Started
In Goroutine
In Goroutine2
Finished

Trong chương trình trên, goroutine đầu tiên bắt đầu goroutine thứ hai. Sau đó, goroutine đầu tiên in ra "In Goroutine" và sau đó nó thoát. Goroutine thứ hai sau đó bắt đầu và in ra "In Goroutine2". Điều này cho thấy rằng goroutine không có cha mẹ hoặc con cái và chúng tồn tại như một luồng thực thi độc lập.

Ngoài ra, xin lưu ý rằng Timeout chỉ là để minh họa và không nên được sử dụng trong môi trường production. 

Tạo nhiều Goroutines

Hãy xem chương trình dưới đây để bắt đầu nhiều goroutine. Ví dụ này cũng sẽ minh họa rằng các goroutine được thực thi song song.

package main

import (
    "fmt"
    "time"
)

func execute(id int) {
    fmt.Printf("id: %d\n", id)
}

func main() {
    fmt.Println("Started")
    for i := 0; i < 10; i++ {
        go execute(i)
    }
    time.Sleep(time.Second * 2)
    fmt.Println("Finished")
}

Output

Started
id: 4
id: 9
id: 1
id: 0
id: 8
id: 2
id: 6
id: 3
id: 7
id: 5
Finished

Chương trình trên sẽ tạo ra 10 goroutine trong một vòng lặp. Mỗi lần bạn chạy chương trình, nó sẽ cho ra các kết quả khác nhau vì các goroutine sẽ được thực thi song song và không đảm bảo rằng goroutine nào sẽ chạy trước.

Hãy hiểu cách hoạt động của go scheduler. Sau đó, việc hiểu về goroutine sẽ dễ dàng hơn.

Lập lịch các goroutine

Sau khi chương trình Go bắt đầu, runtime của Go sẽ khởi chạy các luồng OS tương đương với số lượng CPU logic có thể sử dụng bởi quá trình hiện tại. Có một CPU logic cho mỗi lõi ảo trong đó lõi ảo có nghĩa là

virtual_cores = x*number_of_physical_cores

trong đó x = số luồng phần cứng trên mỗi lõi.

Hàm runtime.Numcpus có thể được sử dụng để lấy số lượng bộ xử lý logic có sẵn cho chương trình GO. Xem chương trình dưới đây:

package main
import (
    "fmt"
    "runtime"
)
func main() {
    fmt.Println(runtime.NumCPU())
}

Trên máy tính của tôi, nó in ra 16. Máy tính của tôi có 8 lõi vật lý với 2 luồng phần cứng cho mỗi lõi. Do đó, 2 * 8 = 16.

Chương trình Go sẽ khởi chạy các luồng OS bằng số CPU logic có sẵn cho nó hoặc đầu ra của runtime.NumCPU (). Các luồng này sẽ được quản lý bởi hệ điều hành và lên lịch các luồng này trên các lõi CPU là trách nhiệm của hệ điều hành.

Chương trình Go có trình lập lịch riêng của nó sẽ chia sẻ đa luồng các groutines trên cấp độ luồng hệ thống trong chương trình Go. Vì vậy, về cơ bản, mỗi goroutine đang chạy trên một luồng hệ thống được gán cho một CPU logic.

Có hai hàng đợi liên quan đến việc quản lý goroutines và phân công chúng cho các luồng hệ thống.

Local run queue

Trong chương trình Go, mỗi luồng hệ thống sẽ có một hàng đợi được liên kết với nó. Đó là Local Run Queue. Nó chứa tất cả các goroutines sẽ được thực thi trong ngữ cảnh của luồng đó. Chương trình Go sẽ thực hiện lập lịch và chuyển ngữ cảnh của các goroutines thuộc một LRQ cụ thể sang luồng hệ thống tương ứng sở hữu LRQ này.

Global Run Queue

Global Run Queue là một danh sách các goroutine chưa được di chuyển đến bất kỳ LRQ (Local Run Queue) của bất kỳ luồng hệ thống nào. Scheduler của Go sẽ gán một goroutine từ hàng đợi này vào LRQ của bất kỳ luồng hệ thống nào.

Hình vẽ bên dưới mô tả cách hoạt động của bộ lập lịch.

Bộ lập lịch của Golang là một bộ lập lịch Hợp tác (Cooperative Scheduler).

Bộ lập lịch của Go là một bộ lập lịch Hợp tác, có nghĩa là nó không được phân chia thời gian để chuyển đổi giữa các goroutine, như trong trường hợp của bộ lập lịch phân chia thời gian (preemptive scheduler). Trong bộ lập lịch hợp tác, các luồng phải tường minh nhường thực thi. Có một số điểm kiểm tra cụ thể mà goroutine có thể nhường thực thi của nó cho goroutine khác.

Khi một goroutine thực hiện bất kỳ cuộc gọi hàm nào, bộ lập lịch sẽ được gọi và có thể xảy ra việc chuyển đổi ngữ cảnh, có nghĩa là một goroutine mới có thể được lên lịch. Cũng có thể là goroutine hiện có tiếp tục thực thi. Bộ lập lịch cũng có cơ hội chuyển đổi ngữ cảnh trên các sự kiện sau đây:

  • Các cuộc gọi hàm
  • Thu gom rác
  • Các cuộc gọi mạng
  • Các hoạt động kênh
  • Sử dụng từ khóa go
  • Chặn trên các nguyên tố cơ bản như mutex v.v.

Nói rõ rằng bộ lập lịch chạy trong các sự kiện trên nhưng điều đó không có nghĩa rằng chuyển đổi ngữ cảnh sẽ xảy ra. Đó chỉ là bộ lập lịch có cơ hội. Tùy thuộc vào bộ lập lịch xem liệu có thực hiện chuyển đổi ngữ cảnh hay không..

Các ưu điểm của goroutines so với các luồng(threads)

  • Goroutines bắt đầu với kích thước 8kb và kích thước của chúng có thể tăng hoặc giảm dựa trên yêu cầu thời gian chạy. Trong khi đó, các luồng OS có kích thước hơn 1 MB. Vì vậy, goroutines rất rẻ để phân bổ. Do đó, một số lượng lớn goroutines có thể được khởi chạy cùng một lúc. Việc co giãn và mở rộng của một goroutine được quản lý bởi go runtime bên trong. Vì goroutines rất rẻ tiền, bạn có thể khởi chạy hàng trăm ngàn goroutines trong khi chỉ có thể khởi chạy vài ngàn luồng.

  • Lịch trình goroutine được quản lý bởi Go runtime. Như đã đề cập ở trên, Go runtime khởi chạy các OS threads tương đương với số lượng CPU logic. Sau đó, nó sẽ tái lập lịch trình cho các goroutine vào mỗi OS thread. Do đó, việc lập lịch cho goroutine được thực hiện bởi Go runtime và do đó nó rất nhanh chóng. Trong trường hợp của các thread, lịch trình của chúng được quản lý bởi OS runtime. Do đó, thời gian chuyển đổi ngữ cảnh của goroutine nhanh hơn nhiều so với thời gian chuyển đổi ngữ cảnh của các thread. Vì vậy, hàng ngàn goroutine có thể được đa kênh trên một hoặc hai OS thread. Nếu bạn khởi chạy 1000 thread trong JAVA, điều đó sẽ tiêu tốn nhiều tài nguyên và 1000 thread này cần được quản lý bởi hệ điều hành. Hơn nữa, mỗi thread này sẽ có kích thước lớn hơn 1 MB.

  • Goroutines sử dụng kênh (channel) tích hợp sẵn để giao tiếp và được xây dựng để xử lý các tình huống đua nhau (race condition). Do đó, việc truyền thông giữa các goroutine là an toàn và không cần phải sử dụng khóa (lock) tường minh. Ngược lại, khi lập trình đa luồng sử dụng các thread, người lập trình cần sử dụng khóa để truy cập vào các biến được chia sẻ. Điều này có thể dẫn đến tình trạng bế tắc (deadlock) và tình huống đua nhau (race condition) khó phát hiện. So với đó, Goroutines sử dụng kênh để truyền thông và toàn bộ đồng bộ hóa được quản lý bởi go runtime. Nhờ vậy, việc tránh deadlock và race condition được thực hiện một cách dễ dàng. Thực tế, Go tin tưởng vào câu ngạn ngữ

"Don't share memory for communication, instead share memory by communicating"

Goroutine vô danh (Anonymous Goroutines)

Các hàm vô danh (anonymous functions) trong golang cũng có thể được gọi bằng cách sử dụng goroutine. Hãy tham khảo bài viết này để hiểu thêm về anonymous functions - https://golangbyexample.com/go-anonymous-function/

Dưới đây là định dạng để gọi một anonymous function trong một goroutine:

go func(){
   //body
}(args..)

Không có sự khác biệt trong hành vi khi gọi một hàm ẩn danh sử dụng goroutine hoặc gọi một hàm bình thường sử dụng goroutine.

Hãy xem một ví dụ:

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        fmt.Println("In Goroutine")
    }()

    fmt.Println("Started")
    time.Sleep(1 * time.Second)
    fmt.Println("Finished")
}

Output

Started
In Goroutine
Finished

Tổng kết

Đó là tất cả về goroutines trong golang. Hy vọng bạn đã thích bài hướng dẫn này. Vui lòng chia sẻ phản hồi / cải tiến / lỗi trong phần bình luận.

Tag: go Golang nâng cao