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 đơn vị thực thi nhẹ được Go runtime quản lý, cho phép chương trình xử lý nhiều công việc theo mô hình concurrency.

Tuy nhiên, khi có nhiều Goroutine cùng thực hiện các công việc khác nhau, một vấn đề xuất hiện: làm thế nào để chúng trao đổi kết quả với nhau?

Ví dụ, Goroutine A thực hiện phép tính 10 + 20, còn Goroutine B cần lấy kết quả để tiếp tục xử lý. Nếu A và B hoạt động độc lập, chúng ta cần một cơ chế để A gửi kết quả và B nhận được kết quả đó.

Channel giải quyết vấn đề này bằng cách cung cấp một phương thức gửi và nhận dữ liệu giữa các Goroutine.

Có thể hình dung Channel như một đường truyền dữ liệu: một Goroutine gửi giá trị vào Channel, còn Goroutine khác nhận giá trị từ Channel.

Cần lưu ý rằng Channel không phải là Goroutine. Goroutine thực hiện công việc, còn Channel hỗ trợ chúng trao đổi dữ liệu và đồng bộ hóa khi cần thiết.

2. Khởi tạo Channel bằng make(chan T)

Trong Go, Channel được tạo bằng hàm dựng sẵn make().

Cú pháp:

ch := make(chan T)

Trong đó:

  • ch là biến tham chiếu đến Channel.
  • chan là từ khóa dùng để khai báo kiểu Channel.
  • T là kiểu dữ liệu mà Channel có thể truyền tải, chẳng hạn int, string hoặc một kiểu dữ liệu do người dùng định nghĩa.

Ví dụ:

ch := make(chan int)

Lệnh này tạo một Channel có thể truyền các giá trị kiểu int.

Nếu muốn truyền chuỗi, chúng ta có thể sử dụng:

ch := make(chan string)

Channel có kiểu dữ liệu xác định, vì vậy một Channel kiểu chan int không thể trực tiếp truyền một giá trị string.

Trong ví dụ trên, Channel được tạo mà không chỉ định dung lượng bộ đệm. Đây là unbuffered channel — loại Channel không có bộ đệm, sẽ được giải thích ở phần sau.

3. Gửi và nhận dữ liệu qua Channel

Go sử dụng toán tử <- để thực hiện thao tác gửi và nhận dữ liệu qua Channel.

Gửi dữ liệu

Cú pháp:

ch <- value

Lệnh này gửi giá trị value vào Channel ch.

Nhận dữ liệu

Cú pháp:

value := <-ch

Lệnh này nhận một giá trị từ Channel ch và gán giá trị nhận được cho biến value.

Hãy xem ví dụ hoàn chỉnh sau:

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(result)
}

Kết quả:

30

Quá trình hoạt động diễn ra như sau:

  1. Hàm main() tạo một Channel kiểu chan int.
  2. Câu lệnh go calculate(ch) khởi chạy hàm calculate() trong một Goroutine.
  3. Goroutine này tính tổng hai số và gửi kết quả 30 vào Channel bằng ch <- result.
  4. Trong khi đó, Goroutine thực hiện main() nhận dữ liệu qua result := <-ch.
  5. Sau khi nhận được kết quả, chương trình in ra 30.

Trong ví dụ này, Channel đóng vai trò trung gian để Goroutine thực hiện phép tính gửi kết quả cho Goroutine đang chạy main().

4. Channel phối hợp các Goroutine như thế nào?

Channel không chỉ truyền dữ liệu mà còn có thể giúp các Goroutine phối hợp thời điểm thực hiện công việc.

Với unbuffered channel, thao tác gửi và nhận cần gặp nhau để hoàn tất. Nếu chưa có bên nhận, Goroutine gửi sẽ chờ. Nếu chưa có bên gửi, Goroutine nhận cũng sẽ chờ.

Ví dụ:

package main

import "fmt"

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

    go func() {
        ch <- "Hello from Goroutine"
    }()

    message := <-ch
    fmt.Println(message)
}

Kết quả:

Hello from Goroutine

Goroutine ẩn danh gửi chuỗi "Hello from Goroutine" vào Channel. Goroutine thực hiện main() nhận chuỗi đó rồi in ra màn hình.

Việc gửi và nhận tạo ra một điểm phối hợp giữa hai Goroutine. Goroutine gửi không thể hoàn tất thao tác gửi trên unbuffered channel cho đến khi có bên nhận tương ứng.

Cơ chế chờ này giúp chương trình phối hợp hoạt động mà không cần tự kiểm tra liên tục xem dữ liệu đã sẵn sàng hay chưa.

5. Unbuffered Channel hoạt động ra sao?

Unbuffered channel là Channel không có bộ đệm lưu trữ các giá trị đang chờ được nhận.

Khi sử dụng loại Channel này, thao tác gửi cần có một thao tác nhận tương ứng để hoàn tất.

Ví dụ:

package main

import "fmt"

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

    ch <- 100

    fmt.Println("Finished")
}

Đoạn code trên sẽ bị chặn tại dòng:

ch <- 100

Nguyên nhân là main() đang gửi giá trị 100 vào một unbuffered channel nhưng không có Goroutine nào nhận dữ liệu. Vì thao tác gửi không thể hoàn tất, chương trình không thể đi tiếp đến câu lệnh fmt.Println("Finished").

Nếu không còn Goroutine nào có thể tiếp tục thực thi do đang chờ các sự kiện không thể xảy ra, Go runtime sẽ phát hiện tình trạng deadlock và kết thúc chương trình bằng thông báo lỗi.

Có thể sửa ví dụ bằng cách tạo một Goroutine nhận dữ liệu:

package main

import "fmt"

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

    go func() {
        value := <-ch
        fmt.Println(value)
    }()

    ch <- 100
}

Kết quả:

100

Lúc này, Goroutine trong hàm ẩn danh chờ nhận dữ liệu. Khi main() gửi 100, thao tác gửi và nhận phối hợp với nhau, cho phép cả hai tiếp tục thực thi.

Điểm quan trọng cần ghi nhớ là unbuffered channel không phải nơi lưu dữ liệu để nhận sau; nó tạo ra sự phối hợp trực tiếp giữa bên gửi và bên nhận.

6. Những lưu ý khi sử dụng Channel

Channel có thể khiến Goroutine phải chờ

Thao tác gửi hoặc nhận có thể bị chặn nếu chưa có thao tác tương ứng đáp ứng điều kiện của Channel.

Điều này là hành vi bình thường, nhưng nếu chương trình tổ chức gửi và nhận không đúng cách, một hoặc nhiều Goroutine có thể chờ mãi và dẫn đến deadlock.

Channel có kiểu dữ liệu xác định

Một Channel chỉ truyền được các giá trị phù hợp với kiểu dữ liệu mà nó khai báo. Chẳng hạn, chan int truyền số nguyên, còn chan string truyền chuỗi.

Channel có thể được tạo với bộ đệm

Ngoài unbuffered channel, Go còn hỗ trợ buffered channel, được tạo bằng cú pháp:

ch := make(chan int, 3)

Số 3 là dung lượng bộ đệm. Channel này có thể lưu tối đa ba giá trị chưa được nhận trước khi thao tác gửi phải chờ, với điều kiện Channel chưa bị đóng.

Buffered channel có quy tắc chờ khác unbuffered channel. Vì vậy, cần phân biệt hai loại này khi thiết kế luồng xử lý dữ liệu.

Channel có thể được đóng

Go cung cấp hàm close(ch) để thông báo rằng sẽ không có thêm dữ liệu nào được gửi vào Channel.

Đóng Channel không có nghĩa là xóa ngay các giá trị đã gửi vào đó. Với buffered channel, bên nhận vẫn có thể đọc các giá trị còn lại trước khi nhận trạng thái Channel đã đóng.

Thông thường, bên gửi chịu trách nhiệm đóng Channel khi đã hoàn tất việc gửi dữ liệu. Không nên đóng Channel một cách tùy tiện, đặc biệt khi vẫn còn Goroutine khác có thể gửi dữ liệu vào đó.

Không phải mọi trường hợp đều cần Channel

Channel phù hợp khi các Goroutine cần trao đổi dữ liệu hoặc phối hợp hoạt động. Tuy nhiên, không phải mọi bài toán concurrency đều bắt buộc phải sử dụng Channel.

Việc lựa chọn Channel hay một cơ chế đồng bộ khác phụ thuộc vào mục tiêu của chương trình và cách các công việc tương tác với nhau.

Kết luận

Channel là một thành phần quan trọng trong mô hình concurrency của Go, cho phép các Goroutine gửi và nhận dữ liệu, đồng thời phối hợp hoạt động thông qua các thao tác truyền dữ liệu.

Để sử dụng Channel hiệu quả, cần nắm vững cách khởi tạo, gửi và nhận dữ liệu, cũng như hiểu rõ hành vi chờ của unbuffered channel. Việc tổ chức luồng gửi và nhận hợp lý giúp chương trình tránh các tình trạng chờ không cần thiết và deadlock.

Nắm vững những kiến thức này là nền tảng để tiếp tục tìm hiểu các cách sử dụng Channel trong những chương trình Go có nhiều Goroutine và luồng xử lý phức tạp 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ử […]

Goroutine & Concurrency

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 […]

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 *