Bằng một cách nào đó có thể bạn đã từng nghe tới ngôn ngữ lập trình Go. Ngôn ngữ lập trình này đang ngày càng phổ biến vì rất nhiều lý do hoàn toàn xứng đáng. Go xử lý nhanh, đơn giản và nó có một cộng đồng lớn ở đằng sau. Một trong những khía cạnh thú vị nhất của việc học ngôn ngữ này chính là mô hình xử lý đồng thời của nó. Nguyên tắc đồng thời của Go tạo ra các chương trình đồng thời, đa luồng đơn giản và rất thú vị. Tôi sẽ giới thiệu các nguyên tắc đồng thời của Go thông qua một số ví dụ minh họa dưới đây với hy vọng rằng nó sẽ làm cho các khái niệm này dễ dàng cho việc học tập, tìm hiểu nó ở trong tương lai. Bài viết này dành cho những người mới tham gia Go và muốn bắt đầu tìm hiểu về các thành phần chủ chốt trong xử lý đồng thời của ngôn ngữ lập trình Go: goroutine
và channel
.
Bạn có thể đã viết nhiều chương trình đơn luồng trước đây. Một khuôn mẫu chung trong lập trình là có nhiều hàm thực hiện một tác vụ cụ thể, nhưng chúng không được gọi cho đến khi phần trước của chương trình lấy dữ liệu sẵn sàng cho hàm tiếp theo.
Sau đây là ví dụ đầu tiên của chúng ta, một chương trình khai thác quặng. Các hàm trong ví dụ này thực hiện: tìm quặng, khai thác quặng và luyện kim. Trong ví dụ của chúng ta, mỏ và quặng được biểu diễn dưới dạng một mảng các chuỗi, với mỗi hàm nhận và trả về một mảng các chuỗi đã qua xử lý. Đối với một ứng dụng đơn luồng, chương trình sẽ được thiết kế như sau.
Có 3 hàm chính. Một finder
, một miner
và một smelter
. Trong phiên bản này của chương trình, các chức năng của chúng ta chạy trên một thread đơn, hàm này thực hiện ngay sau hàm kia - và thread đơn này (gopher có tên Gary) sẽ cần phải làm tất cả công việc.
func main() {
theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
foundOre := finder(theMine)
minedOre := miner(foundOre)
smelter(minedOre)
}
In ra mảng kết quả "ore" ở cuối mỗi hàm, chúng ta nhận được kết quả sau:
From Finder: [ore ore ore]
From Miner: [minedOre minedOre minedOre]
From Smelter: [smeltedOre smeltedOre smeltedOre]
Kiểu lập trình này có lợi ích đó là thiết kế dễ dàng, nhưng điều gì xảy ra khi bạn muốn tận dụng nhiều luồng và thực hiện các hàm độc lập với nhau? Đây là khi chúng ta cần đến lập trình đồng thời.
Thiết kế khai thác kiểu này sẽ hiệu quả hơn nhiều. Bây giờ, nhiều luồng (các gopher) đang hoạt động độc lập; do đó, Gary không phải đảm nhận một mình toàn bộ công việc nữa. Có một con gopher tìm quặng, một con khai thác quặng, và một con khác sẽ luyện kim - tất cả đều làm việc cùng một lúc.
Để chúng ta có thể đưa loại chức năng này vào mã của chúng ta, chúng ta sẽ cần hai thứ: một cách để tạo ra các gopher làm việc độc lập, và một cách để các gopher có thể giao tiếp (gửi quặng) với nhau. Đây là nơi các nguyên tố đồng thời của Go xuất hiện: goroutine
và các channel
.
Goroutine
có thể được coi như một thread nhỏ. Tạo một goroutine
dễ dàng như việc để bắt đầu gọi một hàm. Một ví dụ về việc nó dễ dàng như thế nào, hãy tạo ra hai hàm tìm kiếm (finder), gọi đến chúng bằng cách sử dụng từ khóa go
, và cho in ra console mỗi khi tìm thấy "quặng" trong mỏ.
func main() {
theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
go finder1(theMine)
go finder2(theMine)
<-time.After(time.Second * 5) // you can ignore this for now
}
Đây là đầu ra từ chương trình của chúng ta:
Finder 1 found ore!
Finder 2 found ore!
Finder 1 found ore!
Finder 1 found ore!
Finder 2 found ore!
Finder 2 found ore!
Như bạn có thể thấy từ đầu ra ở trên, các máy tìm kiếm đang chạy đồng thời. Không có thứ tự thực sự trong những người tìm thấy quặng đầu tiên, và khi chạy nhiều lần, thứ tự không phải lúc nào cũng giống nhau.
Đây là một tiến bộ tuyệt vời! Bây giờ chúng ta có một cách dễ dàng để thiết lập một chương trình đa luồng (multi-gopher), nhưng điều gì sẽ xảy ra khi chúng ta cần các goroutine
độc lập của chúng ta để giao tiếp với nhau? Chào mừng bạn đến với thế giới ma thuật của các channel
.
Các channel
cho phép các goroutine
giao tiếp với nhau. Bạn có thể nghĩ về một channel
như một đường ống, từ đó các goroutine
có thể gửi và nhận thông tin từ các goroutine
khác.
myFirstChannel := make(chan string)
Các goroutine
có thể gửi và nhận trên channel
. Điều này được thực hiện thông qua việc sử dụng một mũi tên (<-
) trỏ theo hướng dữ liệu đang dịch chuyển.
myFirstChannel <- "hello" // Send
myVariable := <- myFirstChannel // Receive
Bây giờ bằng cách sử dụng một channel
, chúng ta sẽ có được quặng ngay khi được tìm thấy bởi các gopher khảo sát gửi cho, mà không cần chờ đợi họ khám phá xong tất cả mọi thứ.
Tôi đã cập nhật lại ví dụ đặt các đoạn code tìm kiếm và các hàm khai thác thiết lập dưới dạng các hàm ẩn danh. Nếu bạn chưa bao giờ thấy các hàm lambda
, bạn không nên tập trung quá nhiều vào phần đó của chương trình, chỉ cần biết rằng mỗi hàm được gọi với từ khóa go
, vì vậy chúng đang được chạy theo cách riêng của chúng. Điều quan trọng là phải lưu ý đến cách thức các goroutine
truyền thông tin với nhau bằng cách sử dụng channel
, cụ thể là oreChan
. Các hàm ẩn danh sẽ được giải thích ở cuối bài viết.
func main() {
theMine := [5]string{"ore1", "ore2", "ore3"}
oreChan := make(chan string)
// Finder
go func(mine [5]string) {
for _, item := range mine {
oreChan <- item // send
}
}(theMine)
// Ore Breaker
go func() {
for i := 0; i < 3; i++ {
foundOre := <-oreChan // receive
fmt.Println("Miner: Received " + foundOre + " from finder")
}
}()
<-time.After(time.Second * 5) // Again, ignore this for now
}
Trong đầu ra dưới đây, bạn có thể thấy rằng Miner của chúng ta nhận được các mẩu "quặng" một lúc từ việc đọc từ channel
quặng (oreChan
) ba lần.
Miner: Received ore1 from finder
Miner: Received ore2 from finder
Miner: Received ore3 from finder
Tuyệt vời, bây giờ chúng ta có thể gửi dữ liệu giữa các goroutine
khác nhau (các gopher) trong chương trình của chúng ta. Trước khi chúng ta bắt đầu viết các chương trình phức tạp với các channel
, trước tiên hãy tìm hiểu qua một số thuộc tính quan trọng của channel
.
Các channel
chặn các goroutine
trong một vài tình huống khác nhau. Điều này cho phép các goroutine
của chúng ta đồng bộ hóa với nhau trong một thời điểm, trước khi tiếp tục thực hiện tiếp các logic của chúng theo cách độc lập.
Một khi một goroutine
(gopher) gửi đến một channel
, việc gửi của goroutine
này sẽ bị chặn cho đến khi một goroutine
khác nhận được những gì được gửi trên channel
đó.
Tương tự như chặn sau khi gửi trên một channel
, một goroutine
có thể chặn chờ đợi để có được một giá trị từ một channel
, mà không có gì được gửi đến nó.
Blocking có thể hơi khó hiểu lúc đầu, nhưng bạn có thể nghĩ nó giống như một giao dịch giữa hai goroutine
(các gopher). Cho dù một gopher đang chờ đợi tiền hoặc gửi tiền, nó sẽ đợi cho đến khi đối tác khác trong giao dịch xuất hiện.
Bây giờ chúng ta có một ý tưởng về những cách khác nhau một goroutine
có thể chặn trong khi giao tiếp thông qua một channel
, cho phép thảo luận về hai loại khác nhau của các channel
: không có bộ đệm, và đệm . Chọn loại channel
bạn sử dụng có thể thay đổi cách hoạt động của chương trình.
Chúng ta đã sử dụng các unbuffered channel
trong tất cả các ví dụ trước. Điều làm cho chúng trở nên độc đáo đó là chỉ có một phần dữ liệu phù hợp được qua channel
tại một thời điểm.
Trong các chương trình đồng thời, thời gian không phải lúc nào cũng hoàn hảo. Trong ví dụ khai thác quặng của chúng ta, chúng ta có thể rơi vào một tình huống mà các gopher khảo sát của chúng ta có thể tìm thấy ba mảnh quặng trong thời gian bằng với thời gian mà các gopher phá quặng chỉ xử lý xong một mảnh quặng. Để không để cho các gopher khảo sát dành phần lớn thời gian của mình chờ đợi để gửi cho các gopher phá quặng một chút quặng cho đến khi nó có thể kết thúc, chúng ta có thể sử dụng một buffered channel
. Hãy bắt đầu bằng cách tạo buffered channel
có dung lượng là 3.
bufferedChan := make(chan string, 3)
Các buffered channel
hoạt động tương tự như các unbuffered channel
, nhưng với một lần - chúng ta có thể gửi nhiều phần dữ liệu đến channel
này trước khi cần một goroutine
khác đọc dữ liệu từ channel
đó.
bufferedChan := make(chan string, 3)
go func() {
bufferedChan <- "first"
fmt.Println("Sent 1st")
bufferedChan <- "second"
fmt.Println("Sent 2nd")
bufferedChan <- "third"
fmt.Println("Sent 3rd")
}()
<-time.After(time.Second * 1)
go func() {
firstRead := <- bufferedChan
fmt.Println("Receiving..")
fmt.Println(firstRead)
secondRead := <- bufferedChan
fmt.Println(secondRead)
thirdRead := <- bufferedChan
fmt.Println(thirdRead)
}()
Thứ tự in giữa hai goroutine
của chúng ta sẽ là:
Sent 1st
Sent 2nd
Sent 3rd
Receiving..
first
second
third
Để đơn giản, chúng ta sẽ không sử dụng các buffered channel
trong chương trình cuối cùng của chúng ta, nhưng điều quan trọng là phải biết loại channel
nào có sẵn trong vành đai công cụ đồng thời của bạn.
Lưu ý: Sử dụng buffered channel
không ngăn chặn việc blocking xảy ra. Ví dụ, nếu gopher khảo sát nhanh hơn 10 lần so với gopher phá quặng, và chúng giao tiếp thông qua một buffered channel
có kích thước là 2, thì gopher khảo sát sẽ vẫn bị chặn nhiều lần trong chương trình.
Bây giờ với sức mạnh của goroutine
và các channel
, chúng ta có thể viết một chương trình tận dụng tối đa nhiều luồng bằng cách sử dụng các nguyên tố đồng thời của Go.
theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
oreChannel := make(chan string)
minedOreChan := make(chan string)
// Finder
go func(mine [5]string) {
for _, item := range mine {
if item == "ore" {
oreChannel <- item // send item on oreChannel
}
}
}(theMine)
// Ore Breaker
go func() {
for i := 0; i < 3; i++ {
foundOre := <-oreChannel // read from oreChannel
fmt.Println("From Finder: ", foundOre)
minedOreChan <- "minedOre" // send to minedOreChan
}
}()
// Smelter
go func() {
for i := 0; i < 3; i++ {
minedOre := <-minedOreChan // read from minedOreChan
fmt.Println("From Miner: ", minedOre)
fmt.Println("From Smelter: Ore is smelted")
}
}()
<-time.After(time.Second * 5) // Again, you can ignore this
Đầu ra của chương trình này là như sau:
From Finder: ore
From Finder: ore
From Miner: minedOre
From Smelter: Ore is smelted
From Miner: minedOre
From Smelter: Ore is smelted
From Finder: ore
From Miner: minedOre
From Smelter: Ore is smelted
Đây là một cải tiến lớn từ ví dụ ban đầu của chúng ta! Bây giờ mỗi chức năng của chúng ta đang chạy độc lập trên các goroutine
riêng của chúng. Ngoài ra, mỗi khi có một phần quặng được xử lý, nó được chuyển sang giai đoạn tiếp theo của dây chuyền khai thác.
Để giữ sự tập trung vào sự hiểu biết cơ bản về channel
và thực hiện các thói quen, có một số thông tin quan trọng mà tôi không đề cập ở trên, nếu bạn không biết, có thể gây ra một số rắc rối khi bạn bắt đầu lập trình. Bây giờ bạn đã hiểu được cách thức hoạt động của các thường trình và channel
, hãy xem qua một số thông tin bạn cần biết trước khi bắt đầu viết mã với các thường trình và channel
đi.
Tương tự như cách chúng ta có thể thiết lập một hàm để chạy theo goroutine
riêng của nó bằng cách sử dụng từ khóa go
, chúng ta có thể tạo một hàm ẩn danh để chạy trên thường trình đi riêng của nó bằng cách sử dụng định dạng sau:
// Anonymous go routine
go func() {
fmt.Println("I'm running in my own go routine")
}()
Bằng cách này, nếu chúng ta chỉ cần gọi một hàm một lần, chúng ta có thể đặt nó theo cách chạy riêng của nó để chạy, mà không cần lo lắng về việc tạo ra một khai báo hàm chính thức.
Các chức năng chính thực sự chạy trong goroutine
riêng của mình! Điều quan trọng cần biết là một khi hàm main trả về, nó đóng tất cả các thường trình đi khác hiện đang chạy. Đây là lý do tại sao chúng ta đã có một bộ đếm thời gian ở dưới cùng của chức năng chính của chúng ta - mà tạo ra một channel
và gửi một giá trị vào nó sau 5 giây.
<-time.After(time.Second * 5) // Receiving from channel after 5 sec
Hãy nhớ làm thế nào một goroutine
sẽ chặn trên một đọc cho đến khi một cái gì đó được gửi? Đó là chính xác những gì đang xảy ra với các thói quen chính bằng cách thêm mã này ở trên. Các thói quen chính sẽ chặn, cho goroutine
khác của chúng ta 5 giây của cuộc sống bổ sung để chạy.
Bây giờ có nhiều cách tốt hơn để xử lý chặn chức năng chính cho đến khi tất cả các goroutine
khác hoàn tất. Một thực tế phổ biến là tạo một channel
đã hoàn thành mà chức năng chính sẽ chặn để chờ đọc. Khi bạn hoàn thành công việc của mình, hãy viết thư cho channel
này và chương trình sẽ kết thúc.
func main() {
doneChan := make(chan string)
go func() {
// Do some work…
doneChan <- "I'm all done!"
}()
<-doneChan // block until go routine signals work is done
}
Trong một ví dụ trước, chúng ta đã đọc miner của chúng ta từ một channel
trong một vòng lặp for đã trải qua 3 lần lặp. Điều gì sẽ xảy ra nếu chúng ta không biết chính xác có bao nhiêu mẩu quặng sẽ đến từ công cụ tìm? Vâng, tương tự như sử dụng range
trên một tập dữ liệu, bạn có thể sử dụng range
trên một channel
.
Cập nhật chức năng khai thác trước đây của chúng ta, chúng ta có thể viết như sau:
// Ore Breaker
go func() {
for foundOre := range oreChan {
fmt.Println("Miner: Received " + foundOre + " from finder")
}
}()
Vì thợ mỏ cần phải đọc mọi thứ mà người khảo sát gửi cho anh ta, sử dụng range
trên một channel
ở đây đảm bảo chúng ta nhận được mọi thứ được gửi đến.
Lưu ý: Việc phân luồng qua channel
sẽ chặn cho đến khi một mục khác được gửi đến channel
này. Cách duy nhất để ngăn chặn goroutine
từ chặn sau khi tất cả gửi đã xảy ra là bằng cách đóng channel
bằng close(channel)
Có một kỹ thuật mà bạn có thể thực hiện đọc mà không bị chặn trên một channel
, bằng cách sử dụng cấu trúc select case
của Go . Bằng cách sử dụng như ví dụ bên dưới, goroutine
của bạn sẽ đọc dữ liệu từ channel
nếu có nội dung nào đó hoặc sẽ nhảy vào trường hợp mặc định.
myChan := make(chan string)
go func(){
myChan <- "Message!"
}()
select {
case msg := <- myChan:
fmt.Println(msg)
default:
fmt.Println("No Msg")
}
<-time.After(time.Second * 1)
select {
case msg := <- myChan:
fmt.Println(msg)
default:
fmt.Println("No Msg")
}
Khi chạy, ví dụ này có đầu ra sau:
No Msg
Message!
Không chặn việc gửi dữ liệu cũng sử dụng chung một cấu trúc select case
, sự khác biệt duy nhất là trường hợp chúng ta sẽ trông giống như gửi chứ không phải là nhận.
select {
case myChan <- "message":
fmt.Println("sent the message")
default:
fmt.Println("no message sent")
}

















