26 tháng 8, 2018

Xử lý đồng thời trong ngôn ngữ lập trình Go

Xử lý đồng thời trong ngôn ngữ lập trình Go

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: goroutinechannel.

Image-018

Chương trình đơn luồng và đa luồng

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.

Hình 1

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.

Hình 2

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.

Hình 3

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

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ỏ.

Hình 4

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.

Channel

Hình 5

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.

Hình 6

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.

Hình 7

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ứ.

Hình 8

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.

Channel Blocking

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.

Blocking on a Send

Image-009

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 đó.

Blocking on a Receive

Image-010

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.

Unbuffered Channel

Image-011

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.

Buffered Channel

Image-012

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 đó.

Image-013

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.

Kết hợp tất cả chúng cùng nhau

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.

Image-014

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.

Một số điểm chú ý

Các Goroutine ẩn danh

Image-015

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.

Hàm main là một Goroutine

Hình 16

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.

Hình 17

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
}

Bạn có thể lấy toàn bộ dữ liệu từ một Channel

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)

Bạn có thể không bị chặn khi đọc dữ liệu từ 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!

Bạn cũng có thể không chặn việc gửi đến một Channel

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")
}
z_img_001.jpeg
view raw z_img_001.jpeg hosted with ❤ by GitHub
z_img_002.jpeg
view raw z_img_002.jpeg hosted with ❤ by GitHub
z_img_003.jpeg
view raw z_img_003.jpeg hosted with ❤ by GitHub
z_img_004.jpeg
view raw z_img_004.jpeg hosted with ❤ by GitHub
z_img_005.jpeg
view raw z_img_005.jpeg hosted with ❤ by GitHub
z_img_006.jpeg
view raw z_img_006.jpeg hosted with ❤ by GitHub
z_img_007.jpeg
view raw z_img_007.jpeg hosted with ❤ by GitHub
z_img_008.jpeg
view raw z_img_008.jpeg hosted with ❤ by GitHub
z_img_009.jpeg
view raw z_img_009.jpeg hosted with ❤ by GitHub
z_img_010.jpeg
view raw z_img_010.jpeg hosted with ❤ by GitHub
z_img_011.jpeg
view raw z_img_011.jpeg hosted with ❤ by GitHub
z_img_012.jpeg
view raw z_img_012.jpeg hosted with ❤ by GitHub
z_img_013.jpeg
view raw z_img_013.jpeg hosted with ❤ by GitHub
z_img_014.jpeg
view raw z_img_014.jpeg hosted with ❤ by GitHub
z_img_015.jpeg
view raw z_img_015.jpeg hosted with ❤ by GitHub
z_img_016.jpeg
view raw z_img_016.jpeg hosted with ❤ by GitHub
z_img_017.jpeg
view raw z_img_017.jpeg hosted with ❤ by GitHub
z_img_018.jpeg
view raw z_img_018.jpeg hosted with ❤ by GitHub