1. Goroutine and Concurrency là gì?

Goroutine và Concurrency là hai khái niệm quan trọng trong ngôn ngữ lập trình Go, đặc biệt khi xây dựng các ứng dụng cần xử lý nhiều công việc cùng lúc như Backend, REST API, hệ thống mạng và các dịch vụ máy chủ.

Goroutine là một đơn vị thực thi nhẹ được Go runtime quản lý, cho phép chương trình chạy một hàm đồng thời với những công việc khác. Thay vì phải tạo một luồng hệ điều hành riêng cho từng tác vụ, Go có thể quản lý rất nhiều goroutine một cách hiệu quả.

Concurrency là cách tổ chức chương trình để nhiều công việc có thể tiến triển xen kẽ trong cùng một khoảng thời gian. Các công việc không nhất thiết phải chạy chính xác cùng một thời điểm, nhưng chương trình có thể chuyển đổi giữa chúng để tận dụng thời gian chờ và cải thiện hiệu quả xử lý.

Ví dụ, một ứng dụng đặt vé máy bay có thể cần kiểm tra thông tin chuyến bay, lấy dữ liệu khách hàng và tải thông tin thanh toán. Thay vì để mọi công việc phải chờ nhau theo một thứ tự cố định, ứng dụng có thể tổ chức các tác vụ để chúng tiến triển đồng thời khi phù hợp.

Trong Go, Goroutine là một trong những cơ chế quan trọng giúp triển khai Concurrency. Tuy nhiên, hai khái niệm này không hoàn toàn giống nhau: Concurrency là cách tổ chức công việc, còn Goroutine là một cơ chế cụ thể để thực hiện cách tổ chức đó trong Go.

2. Goroutine hoạt động như thế nào?

Khi viết chương trình Go thông thường, các câu lệnh trong một hàm sẽ được thực hiện theo thứ tự, từ trên xuống dưới. Nếu một hàm cần nhiều thời gian để xử lý, những câu lệnh phía sau thường phải chờ hàm đó hoàn thành.

Goroutine cho phép chương trình khởi chạy một hàm để thực hiện đồng thời với phần code còn lại. Go runtime sẽ quản lý việc thực thi các goroutine và phân phối chúng lên những luồng hệ điều hành phù hợp.

Để tạo một goroutine, lập trình viên chỉ cần thêm từ khóa go trước lời gọi hàm.

Ví dụ:

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Xin chào từ Goroutine")
}

func main() {
    go sayHello()

    time.Sleep(time.Second)
    fmt.Println("Chương trình chính kết thúc")
}
Go Lang] Lập trình golang căn bản - Goroutine

3. Concurrency khác gì với Sequential Execution?

Sequential Execution là cách thực hiện chương trình tuần tự, nghĩa là một công việc được thực hiện trước rồi mới đến công việc tiếp theo. Cách này dễ hiểu và phù hợp với những tác vụ cần có thứ tự rõ ràng.

Concurrency cho phép nhiều công việc tiến triển trong cùng một khoảng thời gian. Khi một tác vụ đang chờ dữ liệu từ mạng hoặc chờ một thao tác đầu vào hoàn thành, chương trình có thể dành thời gian xử lý những tác vụ khác thay vì để toàn bộ hệ thống chờ.

Ví dụ, một máy chủ nhận yêu cầu từ nhiều người dùng. Nếu mỗi yêu cầu đều phải được xử lý tuần tự hoàn toàn, người dùng sau có thể phải chờ những yêu cầu trước đó hoàn thành. Khi sử dụng Concurrency hợp lý, máy chủ có thể xử lý nhiều yêu cầu đang hoạt động cùng lúc và tận dụng thời gian chờ hiệu quả hơn.

Tuy nhiên, Concurrency không đảm bảo mọi tác vụ đều chạy nhanh hơn. Nếu các công việc phụ thuộc chặt chẽ vào nhau hoặc tranh chấp cùng một tài nguyên, chương trình có thể không cải thiện hiệu năng, thậm chí còn phức tạp hơn.Data, AI, Security, Digital Operations, Managed IT Services

4. Concurrency và Parallelism khác nhau như thế nào?

Concurrency và Parallelism thường được nhắc đến cùng nhau nhưng chúng có ý nghĩa khác nhau.

Concurrency là khả năng tổ chức nhiều công việc để chúng có thể tiến triển trong cùng một khoảng thời gian. Các công việc có thể được thực hiện xen kẽ trên một luồng thực thi hoặc phân phối lên nhiều luồng.

Parallelism là khi nhiều công việc thực sự được thực hiện đồng thời tại cùng một thời điểm, thường trên nhiều lõi CPU hoặc nhiều đơn vị xử lý.

Ví dụ, một đầu bếp chuẩn bị nhiều món ăn có thể chuyển qua lại giữa các công việc trong lúc chờ món khác chín. Đây là cách hình dung về Concurrency. Nếu có nhiều đầu bếp cùng thực hiện các công việc riêng biệt tại cùng một thời điểm, đó là hình dung gần với Parallelism.

Trong Go, chương trình có thể sử dụng nhiều goroutine để tổ chức Concurrency. Nếu điều kiện phần cứng và cấu hình runtime cho phép, nhiều goroutine cũng có thể thực sự chạy song song trên nhiều lõi CPU.

Điều này đặc biệt hữu ích khi xây dựng hệ thống cần xử lý nhiều yêu cầu mạng hoặc những tác vụ tính toán độc lập. Tuy nhiên, không phải cứ tạo nhiều goroutine là chương trình sẽ tận dụng CPU tốt hơn; hiệu quả còn phụ thuộc vào đặc điểm công việc và cách thiết kế.Concurrency vs. Parallelism: What Every Developer Should Know | by Roomey Rahman | FAUN.dev() 🐾

5. WaitGroup trong Go

Khi sử dụng goroutine, một vấn đề thường gặp là hàm main() có thể kết thúc trước khi các goroutine hoàn thành công việc. Khi chương trình kết thúc, những goroutine chưa hoàn thành cũng không tiếp tục chạy trong chương trình đó.

Để giải quyết vấn đề này, Go cung cấp sync.WaitGroup, giúp một goroutine chờ cho đến khi các công việc được theo dõi hoàn thành.

Ví dụ:

package main

import (
    "fmt"
    "sync"
)

func task(id int, wg *sync.WaitGroup) {
    defer wg.Done()

    fmt.Println("Đang xử lý công việc", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go task(i, &wg)
    }

    wg.Wait()
    fmt.Println("Tất cả công việc đã hoàn thành")
}

Giải thích các phần quan trọng:

  • sync.WaitGroup: sử dụng kiểu dữ liệu hỗ trợ chờ nhiều goroutine hoàn thành.
  • wg.Add(1): tăng bộ đếm lên một trước khi khởi chạy mỗi công việc.
  • go task(i, &wg): chạy hàm task() trong một goroutine và truyền vào địa chỉ của WaitGroup.
  • defer wg.Done(): đảm bảo gọi Done() khi hàm task() kết thúc, qua đó giảm bộ đếm đi một.
  • wg.Wait(): chờ cho đến khi bộ đếm trở về 0.

Khi cả ba công việc hoàn thành, chương trình mới in ra thông báo cuối cùng. Thứ tự các dòng Đang xử lý công việc có thể thay đổi giữa những lần chạy vì các goroutine được thực thi đồng thời.

Trong thực tế, WaitGroup hữu ích khi cần chạy nhiều công việc độc lập, chẳng hạn tải dữ liệu từ nhiều nguồn rồi chờ tất cả hoàn thành trước khi tiếp tục xử lý.

6. Channels và giao tiếp giữa các Goroutine

Channel là cơ chế cho phép các goroutine gửi và nhận dữ liệu với nhau. Đây là một phần quan trọng trong mô hình Concurrency của Go, giúp các tác vụ trao đổi kết quả mà không nhất thiết phải dùng chung một biến để ghi dữ liệu.

Ví dụ:

package main

import "fmt"

func calculate(ch chan int) {
    result := 10 + 20
    ch <- result
}

func main() {
    ch := make(chan int)

    go calculate(ch)

    result := <-ch
    fmt.Println("Kết quả:", result)
}

Trong ví dụ trên, make(chan int) tạo một channel có thể truyền dữ liệu kiểu số nguyên. Hàm calculate() thực hiện phép tính rồi gửi kết quả vào channel bằng câu lệnh ch <- result.

Ở hàm main(), câu lệnh result := <-ch nhận dữ liệu từ channel và lưu vào biến result. Với channel không có bộ đệm như ví dụ này, thao tác nhận sẽ chờ cho đến khi có dữ liệu được gửi đến.

Kết quả cuối cùng là Kết quả: 30.

Channels thường được sử dụng khi một goroutine cần gửi kết quả cho goroutine khác, xây dựng hàng đợi công việc hoặc phối hợp nhiều tác vụ. Khi thiết kế chương trình, cần chú ý để tránh tình trạng gửi hoặc nhận dữ liệu nhưng không có goroutine tương ứng xử lý, vì điều đó có thể dẫn đến deadlock.

7. Race Condition là gì?

Race Condition là tình trạng xảy ra khi nhiều goroutine cùng truy cập vào dữ liệu dùng chung, trong đó ít nhất một goroutine thay đổi dữ liệu, khiến kết quả phụ thuộc vào thứ tự thực thi không được kiểm soát.

Ví dụ, nếu hai goroutine cùng tăng giá trị của một biến đếm, chương trình có thể cho ra kết quả không như mong đợi. Nguyên nhân là thao tác tăng một đơn vị có thể bao gồm nhiều bước đọc, tính toán và ghi lại giá trị. Khi các bước này xen kẽ không phù hợp, một lần cập nhật có thể bị mất.

Ví dụ minh họa:

var count int

go func() {
    count++
}()

go func() {
    count++
}()

Trong đoạn code trên, hai goroutine cùng thay đổi biến count. Nếu không có cơ chế đồng bộ phù hợp, chương trình có thể xảy ra Race Condition. Đoạn code này cũng chưa chờ các goroutine hoàn thành, nên không thể dùng để xác định kết quả cuối cùng một cách đáng tin cậy.

Để tránh lỗi này, Go cung cấp một số cơ chế như sync.Mutex, sync/atomic và channel. Lựa chọn phù hợp phụ thuộc vào cách dữ liệu được chia sẻ và mục đích của chương trình.

Ngoài ra, Go có công cụ phát hiện các tình huống tranh chấp dữ liệu trong quá trình chạy kiểm thử. Lập trình viên có thể sử dụng lệnh go test -race để kiểm tra những race mà công cụ phát hiện được trong quá trình thực thi. Tuy nhiên, công cụ không đảm bảo tìm ra mọi lỗi trong tất cả trường hợp.

8. Mutex trong Go

Mutex là viết tắt của Mutual Exclusion, nghĩa là loại trừ lẫn nhau. Đây là cơ chế đồng bộ giúp giới hạn việc truy cập một vùng dữ liệu dùng chung, tránh để nhiều goroutine cùng thay đổi dữ liệu đó một cách không an toàn.

Trong Go, Mutex được cung cấp trong package sync. Hai phương thức thường dùng là Lock() để khóa và Unlock() để mở khóa.

Ví dụ:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    var count int
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)

        go func() {
            defer wg.Done()

            mu.Lock()
            count++
            mu.Unlock()
        }()
    }

    wg.Wait()
    fmt.Println("Count:", count)
}

Trong ví dụ này, chương trình tạo 100 goroutine, mỗi goroutine tăng biến count lên một đơn vị.

  • var mu sync.Mutex: tạo một Mutex để bảo vệ dữ liệu dùng chung.
  • mu.Lock(): khóa vùng dữ liệu trước khi thực hiện thao tác tăng biến.
  • count++: tăng giá trị biến đếm lên một đơn vị.
  • mu.Unlock(): mở khóa để goroutine khác có thể truy cập.
  • wg.Wait(): chờ toàn bộ 100 goroutine hoàn thành.

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

Goroutine giúp chương trình xử lý nhiều công việc hiệu quả, nhưng việc sử dụng quá nhiều hoặc không quản lý đúng cách có thể làm tăng mức tiêu thụ bộ nhớ và khiến chương trình khó kiểm soát.

Trước tiên, cần xác định khi nào nên tạo goroutine. Những công việc độc lập hoặc thường xuyên chờ dữ liệu từ mạng là các trường hợp có thể hưởng lợi từ Concurrency. Ngược lại, những tác vụ rất nhỏ hoặc phụ thuộc hoàn toàn vào nhau chưa chắc cần chạy đồng thời.

Tiếp theo, cần quản lý vòng đời của goroutine. Chương trình phải có cách xác định khi nào công việc kết thúc, khi nào cần chờ kết quả và khi nào cần dừng tác vụ. Trong những hệ thống lớn, có thể sử dụng context.Context để truyền tín hiệu hủy hoặc giới hạn thời gian thực hiện công việc.

Ngoài ra, khi nhiều goroutine truy cập dữ liệu chung, cần xem xét sử dụng Mutex, Atomic hoặc Channels để đồng bộ. Không nên cho rằng goroutine sẽ tự động an toàn chỉ vì Go runtime quản lý việc thực thi của chúng.

Cuối cùng, nên kiểm thử hiệu năng và sử dụng công cụ phát hiện lỗi phù hợp. Concurrency có thể cải thiện khả năng đáp ứng của ứng dụng, nhưng hiệu quả thực tế cần được đánh giá dựa trên khối lượng công việc, tài nguyên phần cứng và cách chương trình được triển khai.

10. Ứng dụng của Goroutine và Concurrency trong thực tế

Goroutine và Concurrency được sử dụng rộng rãi trong các ứng dụng Backend và hệ thống máy chủ. Ví dụ, một API có thể tiếp nhận nhiều request từ người dùng, trong khi mỗi request cần truy vấn dữ liệu, gọi dịch vụ bên ngoài hoặc xử lý thông tin.

Nếu được thiết kế phù hợp, Go có thể sử dụng goroutine để xử lý các công việc này mà không cần tạo một luồng hệ điều hành riêng cho từng yêu cầu. Điều đó giúp tận dụng thời gian chờ và hỗ trợ phục vụ nhiều kết nối.

Trong hệ thống xử lý dữ liệu, nhiều goroutine cũng có thể đảm nhận các phần việc độc lập như đọc tệp, chuyển đổi dữ liệu hoặc xử lý từng nhóm bản ghi. Channels có thể được sử dụng để chuyển dữ liệu giữa các bước, còn WaitGroup giúp chờ các công việc hoàn thành.

Tuy nhiên, khi khối lượng công việc lớn, lập trình viên cần giới hạn số tác vụ chạy đồng thời, quản lý lỗi và bảo đảm các goroutine không tiếp tục chạy sau khi công việc không còn cần thiết. Việc tổ chức Concurrency tốt không chỉ nằm ở chỗ chạy được nhiều tác vụ mà còn ở khả năng kiểm soát tài nguyên, dữ liệu và vòng đời của từng tác vụ.

11. Kết luận

Goroutine và Concurrency là những kiến thức nền tảng khi lập trình bằng Go, đặc biệt đối với các ứng dụng Backend, REST API và hệ thống cần xử lý nhiều yêu cầu cùng lúc.

Goroutine giúp thực thi các hàm một cách nhẹ nhàng dưới sự quản lý của Go runtime, còn Concurrency là cách tổ chức nhiều công việc để chúng có thể tiến triển đồng thời. Channels hỗ trợ trao đổi dữ liệu giữa các goroutine, WaitGroup giúp chờ các tác vụ hoàn thành và Mutex bảo vệ dữ liệu dùng chung khỏi những thao tác truy cập không an toàn.

Để sử dụng hiệu quả các cơ chế này, lập trình viên cần hiểu sự khác biệt giữa Concurrency và Parallelism, biết cách quản lý vòng đời goroutine, tránh Race Condition và kiểm soát tài nguyên. Khi nắm vững những kiến thức đó, việc xây dựng các ứng dụng Go có khả năng xử lý nhiều công việc sẽ an toàn, rõ ràng và dễ bảo trì hơn.

Bài viết khác

REST API & HTTP trong Flutter

REST API & HTTP trong Flutter 1. REST API và HTTP 1.1. Khái niệm REST API REST API là một phương thức thiết kế giao tiếp giữa các hệ thống phần mềm thông qua giao thức HTTP. REST là viết tắt của Representational State Transfer, còn API là viết tắt của Application Programming Interface. REST […]

Dio & HTTP Client trong Flutter

Dio & HTTP Client trong Flutter 1. Tổng quan về Dio và HTTP Client Trong quá trình phát triển ứng dụng Flutter, việc giao tiếp với máy chủ (Server) là một phần quan trọng để ứng dụng có thể lấy dữ liệu, gửi thông tin, đăng nhập, đăng ký tài khoản, tải hình ảnh, cập […]

Flutter Widget Fundamentals – Tối ưu hiệu năng và kiểm thử Widget

Tối ưu hiệu năng và kiểm thử Widget trong Flutter 1. Rebuild Optimization – Tối ưu quá trình xây dựng lại giao diện 1.1. Khái niệm Rebuild Optimization Rebuild Optimization là quá trình tối ưu việc xây dựng lại giao diện trong Flutter nhằm giảm những lần thực thi build() không cần thiết, hạn chế […]

Equivalence Partitioning(Phân vùng tương đương)

1.Equivalence Partitioning (Phân vùng tương đương) là gì? Equivalence Partitioning (Phân vùng tương đương) là một trong những kỹ thuật thiết kế kiểm thử hộp đen (Black-box Testing Techniques) cơ bản và quan trọng nhất trong kỹ nghệ phần mềm. Kỹ thuật này được áp dụng nhằm giảm thiểu số lượng ca kiểm thử (Test […]

Flutter Widget Fundamentals – Các cơ chế UI nâng cao trong Flutter

Các cơ chế UI nâng cao trong Flutter 1. Gesture System và Hit Testing 1.1. Tổng quan về Gesture System Gesture System là hệ thống xử lý cử chỉ tương tác trong Flutter, cho phép ứng dụng nhận biết và phản hồi những hành động của người dùng như chạm, nhấn giữ, kéo thả, vuốt, […]

Flutter Widget Fundamentals – Vòng đời và quản lý trạng thái của Widget

Vòng đời và quản lý trạng thái của Widget trong Flutter 1. StatefulWidget Lifecycle chuyên sâu 1.1. Khái niệm vòng đời của StatefulWidget Trong Flutter, vòng đời của StatefulWidget là chuỗi các giai đoạn mà một Widget trải qua từ khi được tạo, khởi tạo trạng thái, hiển thị giao diện, nhận các thay đổi […]

Leave a Reply

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