Channel trong Golang

Bài đăng này chỉ ra cách các channel hoạt động trong Go và cách bạn có thể sử dụng chúng trong code của mình.
Trong Go, channel là một cấu trúc lập trình cho phép chúng ta di chuyển dữ liệu giữa các phần khác nhau trong code, thường là từ các goroutine khác nhau.
Tạo channel
Chúng ta có thể khai báo một loại channel mới bằng cách sử dụng keyword chan cùng với type của dữ liệu truyền trong channel:
var c chan intỞ đây, c thuộc type chan int - có nghĩa là một channel mà qua đó data có type int được gửi đi. Giá trị mặc định của channel là nil, vì vậy chúng ta cần gán giá trị trước khi sử dụng channel đó.
Ngoài ra, chúng ta có thể sử dụng hàm make để khai báo và khởi tạo channel:
c := make(chan int)Gửi và nhận dữ liệu
Bây giờ chúng ta hãy xem một số đoạn code sử dụng channel để có được kết quả của phép nhân:
package main
import (
"fmt"
)
func main() {
n := 3
// This is where we "make" the channel, which can be used
// to move the `int` datatype
out := make(chan int)
// We still run this function as a goroutine, but this time,
// the channel that we made is also provided
go multiplyByTwo(n, out)
// Once any output is received on this channel, print it to the console and proceed
fmt.Println(<-out)
}
// This function now accepts a channel as its second argument...
func multiplyByTwo(num int, out chan<- int) {
result := num * 2
//... and pipes the result into it
out <- result
}
Channel cung cấp cho chúng ta một cách để “kết nối” các concurrent parts khác nhau trong chương trình của chúng ta. Trong trường hợp này, chúng ta có thể biểu diễn kết nối này giữa hai concurrent blocks một cách trực quan:
Mũi tên xanh ở đây biểu thị dữ liệu đi qua một channel
Các channel có thể được coi là “đường ống” hoặc “động mạch” kết nối các concurrent parts khác nhau trong code của chúng ta.
Directional Channels
Các channel mang tính định hướng - có nghĩa là bạn có thể chỉ định một channel chỉ gửi hoặc nhận dữ liệu. Điều này được chỉ định bởi mũi tên ( <-) kèm theo khai báo channel
Ví dụ: hãy xem định nghĩa type của đối số out trong hàm multiplyByTwo:
out chan<- int- Khai báo
chan<-cho chúng ta biết rằng bạn chỉ có thể gửi dữ liệu vào channel chứ không thể nhận dữ liệu từ channel đó. - Khai báo kiểu int cho chúng ta biết rằng channel sẽ chỉ chấp nhận data có kiểu dữ liệu
int.
Mặc dù chúng trông giống như các phần riêng biệt, nhưng chan<- int có thể được coi là một kiểu dữ liệu, mô tả channel chỉ có thể gửi data có kiểu int .
Tương tự, một ví dụ về khai báo channel “chỉ nhận” sẽ như sau:
out <-chan intBạn cũng có thể khai báo một channel mà không chỉ ra hướng, nghĩa là channel đó có thể gửi hoặc nhận dữ liệu:
out chan intĐiều này được thấy khi chúng ta tạo ra channel out trong hàm main:
out := make(chan int)Sau đó, channel này có thể được chuyển thành directional channels dựa trên cách ép kiểu mà bạn muốn áp đặt ở nơi khác trong code của mình.
Blocking Conditions
Các câu lệnh gửi hoặc nhận giá trị từ các channel sẽ bị block bên trong goroutine của chính chúng. Điều này có nghĩa là:
- Câu lệnh nhận dữ liệu từ một channel sẽ bị block cho đến khi nhận được data
- Câu lệnh gửi dữ liệu đến một channel sẽ đợi cho đến khi data gửi đi được nhận.
Ví dụ: khi chúng tôi cố gắng in giá trị nhận được (trong hàm main):
fmt.Println(<-out)Câu lệnh <-out sẽ block code cho đến khi nhận được dữ liệu trên out channel. Để giúp hình dung điều này, bằng cách chia main thành hai phần: phần chạy cho đến thời điểm chờ channel nhận dữ liệu và phần sau đó.
Mũi tên chấm được thêm vào đây để chỉ ra rằng hàm main đã bắt đầu goroutine multiplyByTwo.
Phần thứ hai main chỉ có thể chạy khi dữ liệu được nhận qua channel (được mô tả bằng mũi tên màu xanh lục)
Lưu ý: Việc gửi hoặc nhận dữ liệu từ nil channel cũng sẽ làm bạn bị block vĩnh viễn.
Tạo Workers
Ví dụ số 1 có thể được triển khai theo cách khác, bằng cách sử dụng 2 channel: một channel để gửi dữ liệu đến goroutine và một channel khác để nhận kết quả.
Một mô hình phổ biến để phân phối công việc cho goroutines là tạo ra các workers hoạt động liên tục để thực hiện gửi và nhận thông tin qua các channel.
func main() {
out := make(chan int)
in := make(chan int)
// Create 3 `multiplyByTwo` goroutines.
go multiplyByTwo(in, out)
go multiplyByTwo(in, out)
go multiplyByTwo(in, out)
// Up till this point, none of the created goroutines actually do
// anything, since they are all waiting for the `in` channel to
// receive some data, we can send this in another goroutine
go func() {
in <- 1
in <- 2
in <- 3
in <- 4
}()
// Now we wait for each result to come in
fmt.Println(<-out)
fmt.Println(<-out)
fmt.Println(<-out)
fmt.Println(<-out)
}
func multiplyByTwo(in <-chan int, out chan<- int) {
fmt.Println("Initializing goroutine...")
for {
num := <-in
result := num * 2
out <- result
}
}Bây giờ, ngoài main, multiplyByTwo cũng được chia thành 2 phần: phần trước và sau điểm chúng ta chờ đợi trên channel in( num := <- in)
Số lượng worker được tạo ra tương ứng với số lượng concurrent processes mà bạn muốn.
Trong ví dụ trên, chúng ta tạo ra ba worker và có bốn task. Ba task đầu tiên sẽ ngay lập tức được giao cho một worker, nhưng task thứ tư sẽ phải đợi cho đến khi một trong các worker hoàn thành.
Mặc dù task4 đã sẵn sàng nhưng nó cần phải đợi cho đến khi có ít nhất một worker rảnh rỗi.
Ví dụ 2: Tạo Worker SendEmail khi channel in được gửi data vào
type MailData struct {
Content string
To string
}
func main() {
inChan := make(chan MailData)
outChan := make(chan string)
// Create 3 worker sending mail
for i := 0; i < 3; i++ {
go createSendMailWorker(i, inChan, outChan)
}
go func() {
inChan <- MailData{
Content: "A",
To: "Cuong",
}
inChan <- MailData{
Content: "B",
To: "Nghia",
}
inChan <- MailData{
Content: "C",
To: "Hung",
}
inChan <- MailData{
Content: "D",
To: "My",
}
}()
fmt.Println(<-outChan)
fmt.Println(<-outChan)
fmt.Println(<-outChan)
fmt.Println(<-outChan)
}
func createSendMailWorker(index int, in <-chan MailData, out chan<- string) {
log.Printf("Worker %d runing", index)
for {
data := <-in
time.Sleep(5 * time.Second)
result := fmt.Sprintf("Sending to %s with content %s by worker %d", data.To, data.Content, index)
out <- result
}
}“Select” Statement
Chúng ta có thể sử dụng câu lệnh select khi có nhiều channel đang chờ nhận thông tin và muốn thực hiện một hành động khi bất kỳ channel nào trong số chúng hoàn thành trước .
select {
case res := <-someChannel:
// do something
case anotherChannel <- someData:
// do something else
case <- yetAnotherChannel:
// do another thing
}Ở đây, hành động được thực hiện tùy thuộc vào case nào hoàn thành trước - những trường hợp khác sẽ bị bỏ qua.
Hãy xem một ví dụ trong đó chúng ta có một worker làm việc rất nhanh nhanh và một worker làm việc rất chậm để thực hiện tính toán:
// The `fast` and `slow` functions do the same thing
// but `slow` takes more time to complete
func fast(num int, out chan<- int) {
result := num * 2
time.Sleep(5 * time.Millisecond)
out <- result
}
func slow(num int, out chan<- int) {
result := num * 2
time.Sleep(15 * time.Millisecond)
out <- result
}
func main() {
out1 := make(chan int)
out2 := make(chan int)
// we start both fast and slow in different
// goroutines with different channels
go fast(2, out1)
go slow(3, out2)
// perform some action depending on which channel
// receives information first
select {
case res := <-out1:
fmt.Println("fast finished first, result:", res)
case res := <-out2:
fmt.Println("slow finished first, result:", res)
}
}Nếu chúng ta chạy đoạn code này , chúng ta sẽ nhận được kết quả:
fast finished first, result: 4Câu lệnh select được trigger bời out1 và bỏ qua hành động được chỉ định trong trường hợp out2 :
Trường hợp sử dụng phổ biến cho các câu lệnh select là phát hiện khi nào cần cancel một hành động - nếu chúng ta đang thực hiện một thao tác "nhạy cảm" với thời gian, lý tưởng nhất là chúng ta muốn giữ đúng thời hạn và về lỗi nhanh nếu thao tác đó bị đình trệ hoặc mất quá nhiều thời gian thực thi.
Buffered Channels
Trong một số ví dụ trước, chúng ta đã thấy rằng các câu lệnh channel bị block cho đến khi dữ liệu được gửi vào hoặc nhận từ một channel.
Điều này xảy ra vì một channel không có nơi nào để “lưu trữ” dữ liệu đi vào và do đó cần phải chờ câu lệnh nhận dữ liệu.
Buffered channel là một loại channel có dung lượng lưu trữ bên trong. Để tạo buffered channel, chúng tôi thêm đối số thứ hai vào câu lệnh makeđể chỉ định dung lượng:
out := make(chan int, 3)Bây giờ out là một buffered channel có dung lượng = 3. Điều này có nghĩa là nó có thể nhận tối đa ba giá trị trước khi block:
package main
import "fmt"
func main() {
out := make(chan int, 3)
out <- 1
out <- 2
out <- 3
// this statement will block
out <- 4
}Bạn có thể coi buffered channel như một channel bình thường có dung lượng (hoặc buffer):
Buffered channels được sử dụng trong trường hợp chúng ta không muốn câu lệnh channel bị block nếu không có bộ thu nào khả dụng. Việc thêm buffer cho phép chúng ta đợi một số receivers được giải phóng mà không block code gửi.
Tại sao chúng ta cần channel?
Các channel cho phép chúng tôi giao tiếp một cách tự nhiên giữa các phần khác nhau của các đoạn code đang chạy đồng thời.
Vì các câu lệnh channel bị block khi gửi hoặc nhận dữ liệu nên code của chúng tôi ít bị lỗi hơn vì nhiều lỗi phát sinh từ data race khi chúng ta đọc một giá trị trước khi nó được ghi.
Hãy cẩn thận và các lỗi
Các channel giúp việc lập trình đồng thời dễ dàng hơn nhiều trong Go và giúp code của bạn dễ đọc hơn trong một số trường hợp nhất định.
Tuy nhiên, thật dễ dàng để sử dụng các channel ở những nơi bạn không thực sự cần đến. Đôi khi, việc sử dụng con trỏ và waitgroup để truyền thông tin sẽ dễ dàng hơn.
Giống như tất cả các chương trình lập trình đồng thời, điều quan trọng là phải tránh các race condition vì điều này có thể tạo ra các lỗi khó dự đoán.
Khi nghi ngờ, tốt hơn hết bạn nên hình dung cách dữ liệu di chuyển giữa các goroutine (như tôi đã trình bày trong một số sơ đồ ở đây) trước khi viết code.