Goroutines & Concurrency

1. Concurrency là gì?

Khi viết chương trình, có những công việc phải được thực hiện lần lượt, nhưng cũng có những công việc có thể tiến triển trong cùng một khoảng thời gian. Để hiểu cách Go xử lý nhiều công việc như vậy, trước tiên cần phân biệt ba khái niệm: Sequential, Concurrency và Parallelism.

Sequential (Tuần tự)

Sequential là cách thực hiện các công việc theo thứ tự. Công việc sau chỉ bắt đầu khi công việc trước hoàn thành.

Ví dụ, một chương trình cần thực hiện hai tác vụ:

  • Tác vụ A mất 2 giây.
  • Tác vụ B mất 2 giây.

Nếu thực hiện tuần tự, chương trình phải hoàn thành A rồi mới bắt đầu B. Tổng thời gian chờ sẽ khoảng 4 giây.

Concurrency (Đồng thời)

Concurrency là cách tổ chức chương trình để nhiều tác vụ có thể tiến triển trong cùng một khoảng thời gian, thay vì bắt buộc hoàn thành hoàn toàn tác vụ này mới bắt đầu tác vụ khác.

Ví dụ, một chương trình cần gửi hai HTTP request đến hai máy chủ khác nhau. Nếu gửi lần lượt và chờ từng máy chủ phản hồi, thời gian chờ có thể cộng dồn. Nếu tổ chức hai request để chúng tiến triển đồng thời, chương trình có thể chờ phản hồi của cả hai trong cùng một khoảng thời gian.

Concurrency đặc biệt hữu ích khi chương trình thường xuyên phải chờ các hoạt động như:

  • Gửi và nhận dữ liệu qua mạng.
  • Đọc hoặc ghi tệp.
  • Giao tiếp với cơ sở dữ liệu.
  • Chờ một dịch vụ bên ngoài phản hồi.

Trong những trường hợp này, chương trình có thể sử dụng thời gian chờ của một tác vụ để cho các tác vụ khác tiếp tục tiến triển.

Parallelism (Song song)

Parallelism là việc nhiều tác vụ thực sự được thực thi cùng một thời điểm, thường nhờ nhiều lõi CPU.

Ví dụ, nếu máy tính có nhiều lõi CPU và chương trình được phép sử dụng chúng, hai tác vụ tính toán có thể chạy trên hai lõi khác nhau cùng lúc.

Cần phân biệt rõ:

  • Sequential: các tác vụ được thực hiện lần lượt.
  • Concurrency: nhiều tác vụ có thể tiến triển xen kẽ hoặc chồng lấp trong cùng một khoảng thời gian.
  • Parallelism: nhiều tác vụ thực sự được thực thi đồng thời tại cùng một thời điểm.

Concurrency không đồng nghĩa với Parallelism. Một chương trình có thể xử lý đồng thời nhiều tác vụ trên một lõi CPU bằng cách chuyển đổi giữa chúng, dù tại mỗi thời điểm lõi CPU chỉ thực thi một luồng lệnh.

Ngược lại, chương trình có thể tận dụng nhiều lõi CPU để thực hiện các tác vụ song song. Trong thực tế, Concurrency và Parallelism có thể kết hợp với nhau.

Vì sao cần xử lý đồng thời?

Mục tiêu không phải lúc nào cũng là làm cho CPU tính toán nhanh hơn. Đôi khi mục tiêu là tận dụng thời gian chờ, xử lý nhiều yêu cầu cùng lúc hoặc giúp chương trình phản hồi tốt hơn.

Ví dụ, một máy chủ Web có thể cần xử lý yêu cầu của nhiều người dùng. Nếu mỗi yêu cầu đều phải đợi yêu cầu trước hoàn thành, hiệu suất phục vụ có thể bị hạn chế. Cách tổ chức xử lý đồng thời cho phép máy chủ tiến triển với nhiều yêu cầu mà không nhất thiết phải xử lý chúng hoàn toàn theo thứ tự.

Tuy nhiên, xử lý đồng thời cũng làm tăng độ phức tạp của chương trình. Nếu nhiều tác vụ cùng truy cập dữ liệu dùng chung mà không được quản lý đúng cách, chương trình có thể phát sinh lỗi khó phát hiện.

2. Goroutine là gì?

Goroutine là một đơn vị thực thi nhẹ trong Go, được Go runtime quản lý. Goroutine cho phép chương trình khởi chạy một hàm để nó có thể tiến triển đồng thời với những công việc khác.

Trong Go, có thể khởi chạy một goroutine bằng từ khóa go:

go doWork()

Lệnh này yêu cầu Go khởi chạy lời gọi hàm doWork() dưới dạng một goroutine. Lời gọi hàm không còn phải chờ hàm hoàn thành theo cách thông thường; chương trình có thể tiếp tục thực hiện các lệnh tiếp theo.

Goroutine được Go runtime quản lý như thế nào?

Goroutine không đồng nghĩa với một OS thread (luồng hệ điều hành).

OS thread là luồng thực thi được hệ điều hành quản lý và lập lịch. Trong khi đó, Go runtime quản lý goroutine và lập lịch để chúng chạy trên các OS thread phù hợp.

Một chương trình Go có thể tạo ra nhiều goroutine mà không cần tạo một OS thread riêng cho mỗi goroutine. Runtime điều phối các goroutine trên những thread mà chương trình sử dụng. Khi điều kiện thực thi cho phép, nhiều goroutine có thể được chạy song song trên nhiều lõi CPU.

Goroutine được thiết kế để có chi phí tạo và quản lý tương đối thấp, nhưng không miễn phí. Mỗi goroutine vẫn sử dụng tài nguyên và cần có điều kiện kết thúc phù hợp.

So sánh Goroutine, OS Thread và C# Task

Khái niệm Vai trò
Goroutine Đơn vị thực thi nhẹ do Go runtime quản lý và lập lịch.
OS Thread Luồng thực thi được hệ điều hành quản lý và lập lịch.
C# Task Đại diện cho một công việc hoặc thao tác bất đồng bộ trong .NET; không nhất thiết tương ứng với một thread riêng.

Goroutine và C# Task có thể được so sánh ở góc độ tổ chức công việc, nhưng chúng không hoàn toàn tương đương. C# cũng có các cơ chế như Thread và async/await, mỗi cơ chế có vai trò khác nhau.

Điểm quan trọng cần nhớ là: goroutine cung cấp một cách tương đối đơn giản để khởi chạy nhiều công việc trong chương trình Go, còn Go runtime chịu trách nhiệm quản lý và lập lịch thực thi chúng.

3. Tạo và chạy Goroutine

Gọi hàm thông thường và gọi hàm bằng go

Xét hàm sau:

func doWork() {
    fmt.Println("Working...")
}

Nếu gọi hàm thông thường:

doWork()
fmt.Println("Main continues")

Chương trình gọi doWork() và chờ hàm hoàn thành trước khi chạy fmt.Println("Main continues").

Nếu sử dụng từ khóa go:

go doWork()
fmt.Println("Main continues")

Go khởi chạy doWork() trong một goroutine, cho phép luồng thực thi hiện tại tiếp tục mà không phải chờ hàm hoàn thành.

Vì vậy, thứ tự xuất hiện của hai dòng thông báo không được đảm bảo. Dòng Main continues có thể xuất hiện trước hoặc sau Working..., tùy vào lịch thực thi.

Ví dụ 1: Chạy hai tác vụ tuần tự

package main

import (
    "fmt"
    "time"
)

func task(name string) {
    fmt.Println(name, "started")
    time.Sleep(2 * time.Second)
    fmt.Println(name, "finished")
}

func main() {
    start := time.Now()

    task("A")
    task("B")

    fmt.Println("Total:", time.Since(start))
}

Ở đây, time.Sleep(2 * time.Second) làm cho goroutine hiện tại tạm dừng trong hai giây.

Chương trình thực hiện A trước, sau đó mới thực hiện B. Tổng thời gian thường vào khoảng 4 giây, chưa tính đáng kể chi phí phụ của chương trình.

Ví dụ 2: Chạy hai tác vụ bằng Goroutine

package main

import (
    "fmt"
    "sync"
    "time"
)

func task(name string, wg *sync.WaitGroup) {
    defer wg.Done()

    fmt.Println(name, "started")
    time.Sleep(2 * time.Second)
    fmt.Println(name, "finished")
}

func main() {
    start := time.Now()

    var wg sync.WaitGroup
    wg.Add(2)

    go task("A", &wg)
    go task("B", &wg)

    wg.Wait()

    fmt.Println("Total:", time.Since(start))
}

Trong ví dụ này, chương trình khởi chạy hai goroutine. Cả hai đều tạm dừng hai giây, nên tổng thời gian thường gần 2 giây thay vì 4 giây.

Đây là một ví dụ đơn giản về việc chồng lấp thời gian chờ. Nó không chứng minh rằng goroutine luôn giúp chương trình chạy nhanh hơn. Kết quả phụ thuộc vào loại công việc, tài nguyên và cách lập lịch.

Một điểm cần lưu ý: thứ tự các thông báo như A started và B started có thể thay đổi giữa những lần chạy khác nhau. Chương trình không nên dựa vào một thứ tự cụ thể nếu không có cơ chế điều phối phù hợp.

Vì sao main() kết thúc có thể khiến Goroutine chưa hoàn thành bị kết thúc theo?

Trong Go, khi hàm main() kết thúc, chương trình kết thúc. Các goroutine khác không tự động giữ cho chương trình tiếp tục chạy đến khi tất cả hoàn thành.

Ví dụ:

package main

import (
    "fmt"
    "time"
)

func task() {
    time.Sleep(2 * time.Second)
    fmt.Println("Task finished")
}

func main() {
    go task()
    fmt.Println("Main finished")
}

main() có thể kết thúc gần như ngay sau khi in Main finished. Khi chương trình kết thúc, thông báo Task finished có thể chưa bao giờ được in.

Điều này không có nghĩa là goroutine đã tự hoàn thành. Nó có nghĩa là vòng đời của chương trình không còn được duy trì để chờ goroutine đó.

Nếu cần đảm bảo công việc hoàn thành trước khi chương trình kết thúc, phải có cơ chế đồng bộ thích hợp.

4. Đồng bộ Goroutine với sync.WaitGroup

Khi một chương trình khởi chạy nhiều goroutine, đôi khi nó cần biết khi nào tất cả các công việc đã hoàn thành.

sync.WaitGroup là một kiểu trong thư viện chuẩn sync, dùng để chờ một nhóm goroutine hoàn thành công việc.

Có ba phương thức thường được sử dụng:

  • Add(n): tăng bộ đếm để thông báo có thêm các công việc cần chờ.
  • Done(): giảm bộ đếm một đơn vị, thường được gọi khi một goroutine hoàn thành.
  • Wait(): chặn goroutine gọi nó cho đến khi bộ đếm trở về 0.

Ví dụ hoàn chỉnh

package main

import (
    "fmt"
    "sync"
    "time"
)

func task(name string, wg *sync.WaitGroup) {
    defer wg.Done()

    fmt.Println(name, "started")
    time.Sleep(2 * time.Second)
    fmt.Println(name, "finished")
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)

    go task("A", &wg)
    go task("B", &wg)

    wg.Wait()

    fmt.Println("All tasks finished")
}

Hãy xem trình tự hoạt động:

Bước 1: var wg sync.WaitGroup tạo một WaitGroup với bộ đếm ban đầu bằng 0.

Bước 2: wg.Add(2) tăng bộ đếm lên 2, tương ứng với hai công việc mà chương trình dự định chờ.

Bước 3: Hai lời gọi go task(...) khởi chạy hai goroutine. Mỗi goroutine thực hiện công việc riêng của mình.

Bước 4: defer wg.Done() đăng ký gọi Done() khi hàm task kết thúc. Vì vậy, mỗi goroutine sẽ giảm bộ đếm một đơn vị khi hoàn thành.

Bước 5: wg.Wait() chờ cho đến khi cả hai goroutine gọi Done() và bộ đếm trở về 0.

Bước 6: Chương trình tiếp tục in All tasks finished.

Lưu ý rằng WaitGroup không tự khởi chạy goroutine và cũng không tự xử lý kết quả trả về. Nó chỉ giúp theo dõi số lượng công việc cần chờ.

Vì sao truyền *sync.WaitGroup thay vì tạo bản sao?

Hãy xét khai báo:

func task(name string, wg *sync.WaitGroup)

Ký hiệu *sync.WaitGroup nghĩa là tham số wg có kiểu con trỏ đến sync.WaitGroup.

Khi gọi:

go task("A", &wg)

Toán tử &wg lấy địa chỉ của biến wg bên ngoài. Hàm task nhận địa chỉ đó thông qua tham số con trỏ, nên có thể gọi Done() trên chính WaitGroup ban đầu.

Cần phân biệt hai thao tác:

  • wg là biến con trỏ chứa địa chỉ của đối tượng.
  • wg.Done() gọi phương thức Done() trên đối tượng mà con trỏ đang trỏ đến.

Go truyền tham số bằng giá trị, kể cả con trỏ. Vì vậy, giá trị địa chỉ được sao chép vào tham số, nhưng cả hai con trỏ đều tham chiếu đến cùng một WaitGroup.

Nếu mỗi goroutine làm việc trên một bản sao độc lập của WaitGroup, bộ đếm của bản sao sẽ không cập nhật bộ đếm của đối tượng gốc mà main() đang chờ. Vì thế, trong ví dụ này, các goroutine cần dùng chung một WaitGroup.

Không được sao chép một WaitGroup sau khi nó đã bắt đầu được sử dụng. Hãy truyền con trỏ khi cần nhiều hàm cùng thao tác trên một đối tượng WaitGroup.

Đo thời gian thực thi

Có thể dùng time.Now() và time.Since() để đo thời gian:

start := time.Now()

// Khởi chạy công việc và chờ hoàn thành.

fmt.Println("Total:", time.Since(start))

Khi so sánh chương trình tuần tự và chương trình đồng thời, cần bảo đảm chúng thực hiện cùng một khối lượng công việc. Trong ví dụ sử dụng time.Sleep, hai tác vụ có thể chồng lấp thời gian chờ nên tổng thời gian giảm xuống. Với tác vụ tính toán nặng, kết quả có thể khác và phụ thuộc vào khả năng xử lý song song của máy tính.

5. Những lưu ý khi sử dụng Goroutine

Thứ tự thực thi không được đảm bảo

Khi khởi chạy goroutine bằng từ khóa go, chương trình không thể mặc định rằng goroutine đó sẽ chạy ngay lập tức hoặc hoàn thành trước một goroutine khác.

Ví dụ:

go fmt.Println("A")
go fmt.Println("B")

Thứ tự in A và B không được đảm bảo. Nếu chương trình cần một thứ tự cụ thể, cần thiết kế cơ chế điều phối thay vì dựa vào thời điểm chạy tình cờ.

Tạo quá nhiều Goroutine có thể gây tốn tài nguyên

Goroutine có chi phí quản lý tương đối thấp, nhưng mỗi goroutine vẫn cần bộ nhớ và tài nguyên thực thi. Nếu tạo số lượng rất lớn mà không kiểm soát, chương trình có thể tiêu tốn nhiều tài nguyên hoặc gây áp lực lên các dịch vụ bên ngoài.

Vì vậy, nên cân nhắc số lượng công việc được khởi chạy đồng thời, đặc biệt khi chúng truy cập cơ sở dữ liệu, gửi request mạng hoặc sử dụng tài nguyên có giới hạn.

Goroutine cần có điều kiện kết thúc

Một goroutine bị chặn vô thời hạn hoặc tiếp tục chạy mà không còn hữu ích có thể giữ tài nguyên không cần thiết. Tình trạng này thường được gọi là goroutine leak.

Khi thiết kế chương trình, cần xác định công việc bắt đầu khi nào, hoàn thành khi nào và điều gì xảy ra nếu công việc không thể tiếp tục.

Các cơ chế quản lý vòng đời và hủy công việc có thể giúp giải quyết vấn đề này; chúng sẽ được tìm hiểu sâu hơn ở những chủ đề riêng.

Concurrency không phải lúc nào cũng làm chương trình nhanh hơn

Concurrency có thể giúp tận dụng thời gian chờ và tăng khả năng xử lý nhiều công việc. Tuy nhiên, nó cũng có chi phí như tạo goroutine, điều phối thực thi và đồng bộ dữ liệu.

Với một tác vụ nhỏ hoặc một phép tính đơn giản, việc chạy tuần tự có thể dễ hiểu và hiệu quả hơn. Với tác vụ cần chờ nhiều nguồn dữ liệu, tổ chức xử lý đồng thời có thể đem lại lợi ích rõ rệt.

Do đó, quyết định sử dụng goroutine nên dựa trên đặc điểm của bài toán và kết quả đo đạc, không phải giả định rằng càng nhiều goroutine thì chương trình càng nhanh.

6. Kết luận

Goroutine là một trong những cơ chế quan trọng giúp Go tổ chức các công việc có thể tiến triển đồng thời. Thông qua từ khóa go, chương trình có thể khởi chạy nhiều lời gọi hàm mà không cần thực hiện chúng hoàn toàn theo thứ tự.

Tuy nhiên, khởi chạy goroutine chỉ là bước đầu. Chương trình còn cần quản lý thời điểm các công việc hoàn thành, bảo đảm vòng đời của chúng phù hợp và tránh tạo ra những tác vụ không còn cần thiết. sync.WaitGroup cung cấp một cách đơn giản để chờ một nhóm goroutine hoàn thành.

Những kiến thức cốt lõi cần nắm gồm:

  • Phân biệt Sequential, Concurrency và Parallelism.
  • Hiểu goroutine là đơn vị thực thi do Go runtime quản lý.
  • Biết cách khởi chạy goroutine bằng từ khóa go.
  • Hiểu vì sao chương trình có thể kết thúc trước khi các goroutine hoàn thành.
  • Sử dụng sync.WaitGroup để chờ các công việc cần thiết.
  • Nhận thức được giới hạn về tài nguyên và việc concurrency không luôn giúp chương trình nhanh hơn.

Nguồn tham khảo

 

Bài viết khác

Gin-Gonic

Gin-Gonic là gì? Hướng dẫn xây dựng REST API với Go 1. Gin-Gonic là gì? Gin-Gonic, thường được gọi là Gin, là một framework Web mã nguồn mở dành cho ngôn ngữ lập trình Go. Gin cung cấp các công cụ giúp lập trình viên xây dựng HTTP server và REST API, chẳng hạn như […]

Go Context

Go Context là gì? Hướng dẫn sử dụng Context trong Go 1. Context là gì? Trong Go, context là một package thuộc thư viện chuẩn, cung cấp cơ chế để quản lý thời hạn thực hiện công việc, gửi tín hiệu hủy tác vụ và truyền một số dữ liệu gắn với quá trình xử […]

Go Channels

Go Channels là gì? Vì sao cần Channel khi sử dụng Goroutine? 1. Channel là gì? Vì sao cần Channel? Trong Go, Channel là cơ chế cho phép các Goroutine trao đổi dữ liệu và phối hợp hoạt động với nhau. Ở bài viết trước(Goroutine & Concurrency), chúng ta đã tìm hiểu Goroutine — một […]

Go Fundamentals

Go là gì? Go, còn được biết đến với tên gọi Golang, là một ngôn ngữ lập trình mã nguồn mở, được thiết kế tại Google bởi Robert Griesemer, Rob Pike và Ken Thompson. Go là ngôn ngữ biên dịch (compiled language) và có hệ thống kiểu dữ liệu tĩnh (statically typed). Khác với những […]

Use docker to run go project

Docker is a powerful tool that enables developers to create, deploy and run applications in a containerized environment. Using Docker to run Go projects has many advantages, including the ability to isolate your application from the underlying operating system, simplifying the deployment process, and allowing for greater scalability and flexibility. In this guide, we will […]

Golang the basic concept of Go Programing

Go is a statically-typed, concurrent, and garbage-collected programming language developed by Google. Here are some of the basic concepts and guidelines for writing code in Go: Syntax: Go has a simple and straightforward syntax that is easy to learn. The language has a strict coding style, with specific rules for naming variables, functions, and types, […]

Leave a Reply

Your email address will not be published. Required fields are marked *