這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。在 [RapidLoop](https://www.rapidloop.com/) 中,我們幾乎用 [Go](https://golang.org) 做所有事情,包括我們的伺服器,應用服務和監控系統 [OpsDash](https://www.opsdash.com/)。Go 十分擅長編寫非同步程式 - goroutine 和 channel 使用十分簡單不容易出錯並且和其他語言相比非同步/等待模式,文法和功能都更加強大。請繼續閱讀來瞧瞧圍繞任務隊列的一些有趣的 Go 代碼。## 不使用任務隊列有時候你不需要任務隊列。執行一個非同步任務可以這樣:```gogo process(job)```這種方式對於一些需求確實是很好的方式,例如在處理 HTTP 要求的時候發送 email。需求的規模和複雜度決定我們是否需要更精細化的基礎設施去處理任務,並將任務隊列化以一種可控的方式處理它們(例如控制最大並行的任務數量)。## 簡單的任務隊列這裡有一個簡單的隊列和一個處理隊列任務 worker 函數。goroutine 和 channel 只是將其編碼成優雅緊湊代碼塊的正確抽象。```gofunc worker(jobChan <-chan Job) {for job := range jobChan {process(job)}}// make a channel with a capacity of 100.jobChan := make(chan Job, 100)// start the workergo worker(jobChan)// enqueue a jobjobChan <- job```以上代碼建立了一個容積為 100 的 Job 對象 channel。然後建立了一個 goruntine 執行 worker 函數。worker 從 channel 中取出任務並一次處理 1 個任務。任務可以通過推送進 channel 中進行排隊。雖然只用了幾行代碼,卻已經做很多事情。首先它安全,正確,無競態卻不用混合複雜的鎖和線程代碼。另外的功能就是生產者調節。## 生產者調節建立一個容積為 100 的 channel:```go// make a channel with a capacity of 100.jobChan := make(chan Job, 100)```然後這樣將任務插入隊列:```go// enqueue a jobjobChan <- job```如果已經有 100 個還沒處理的任務在 channel 中它就會阻塞。這通常來說是一件好事情。如果有 SLA/QoS 限制或者其他假設的條件(例如任務需要一定的時間才能完成),你肯定不想積壓太多的任務。如果一個任務需要花費 1 秒鐘,那麼最多隻需 100 秒就能完成你的工作。如果 channel 滿了,你希望你的調用者能在一段時間後返回。例如:一個 REST API 請求,你可以返回一個 503 錯誤碼並且調用者可以稍後重試。通過這種方式可以進行壓力測試來保證服務品質。## 非阻塞入隊如果想嘗試入隊,在需要阻塞的時候返回 fail 怎麼辦?這種方式能夠擷取提交任務的失敗狀態返回 503。關鍵在於使用 select 的 default 語句:```go// TryEnqueue tries to enqueue a job to the given job channel. Returns true if// the operation was successful, and false if enqueuing would not have been// possible without blocking. Job is not enqueued in the latter case.func TryEnqueue(job Job, jobChan <-chan Job) bool {select {case jobChan <- job:return truedefault:return false}}```你能使用這種方式來返回失敗狀態:```goif !TryEnqueue(job, chan) {http.Error(w, "max capacity reached", 503)return}```## 停止 worker我們如何才能優雅的停止 worker 處理函數呢?假定我們不再向隊列中插入任務並且保證所有隊列中的任務可以處理完成,你只需這麼做:```goclose(jobChan)```沒錯,只需這麼做。它會按照預期工作是因為在 `for ... range` 迴圈會彈出任務:```gofor job := range jobChan {...}```並且迴圈會在 channel 關閉的時候退出。在關閉 channel 之前的入隊的所有任務都會正常彈出並被 worker 處理。## 等待 worker 處理這看起來很容易,不過 `close(jobChan)` 不會等待 goroutine 完成就會退出。因此我們還需使用 sync.WaitGroup:```go// use a WaitGroup var wg sync.WaitGroupfunc worker(jobChan <-chan Job) {defer wg.Done()for job := range jobChan {process(job)}}// increment the WaitGroup before starting the workerwg.Add(1)go worker(jobChan)// to stop the worker, first close the job channelclose(jobChan)// then wait using the WaitGroupwg.Wait()```這樣,我們可以通過關閉 channel 給 worker 發送關閉訊號並使用 wg.Wait 會等待 worker 處理完成以後才會退出。注意:我們必須在開始 goroutine 之前遞增 wait group,並且在 goroutine 結束(不管以何種方式)時遞減。## 附帶逾時的等待`wg.Wait()` 會在 goroutine 退出前一直等待。但是如果我們無法無限期的等待怎麼辦?如下協助函數封裝了 `wg.Wait()` 增加了逾時時間:```go// WaitTimeout does a Wait on a sync.WaitGroup object but with a specified// timeout. Returns true if the wait completed without timing out, false// otherwise.func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {ch := make(chan struct{})go func() {wg.Wait()close(ch)}()select {case <-ch:return truecase <-time.After(timeout):return false}}// now use the WaitTimeout instead of wg.Wait()WaitTimeout(&wg, 5 * time.Second)```現在我們在一定時限內等待 worker 退出,如果超過時限就會直接退出。## 取消 worker現在我們能讓 worker 即使是在我們發出停止訊號之後也能處理完它們的工作。可是如果我們想讓 worker 拋棄當前的工作直接退出的話應該怎麼做?我們使用了 `context.Context`:```go// create a context that can be cancelledctx, cancel := context.WithCancel(context.Background())// start the goroutine passing it the contextgo worker(ctx, jobChan)func worker(ctx context.Context, jobChan <-chan Job) {for {select {case <-ctx.Done():returncase job := <-jobChan:process(job)}}}// Invoke cancel when the worker needs to be stopped. This *does not* wait// for the worker to exit.cancel()```總的來說,我們建立了一個"可以取消的 context"。worker 同時等待這個 context 和工作隊列,而 `ctx.Done()` 會在 `cancel()` 函數調用時返回。和關閉任務隊列相似,`cancel()` 函數只會發送取消訊號而不會等待取消操作完成。所以如果你需要等待 worker 退出(即使等待的時間非常短而且沒有其他任務需要執行)你必須添加 wait group 代碼。但是這段代碼有一點比較困難。如果你在 channel 中積壓了一些工作(<-jobChan 不會阻塞),並且已經調用了 cancel() 函數(<-ctx.Done() 也不會阻塞)。因為兩個 case 都沒阻塞,`select` 必須在它們之間作出選擇。事實上不會出現這種情況。儘管在 `<-ctx.Done()` 沒有阻塞時也會選擇 `<-jobChan` 的情況看起來很合理,不過在實際使用的時候很容易讓人苦惱。即使我們調用了取消函數,可 channel 依舊會彈出任務,如果我們插入更多任務,它會一直這樣錯誤地運行。不過我們不需要擔心,但是要注意。context 的取消 case 應該比其他 case 有更高的優先順序。這樣做很不容易,不過使用 Go 提供的內建功能就能解決這個問題。可以使用一個參數可以協助我們完成目的:```govar flag uint64func worker(ctx context.Context, jobChan <-chan Job) {for {select {case <-ctx.Done():returncase job := <-jobChan:process(job)if atomic.LoadUint64(&flag) == 1 {return}}}}// set the flag first, before cancellingatomic.StoreUint64(&flag, 1)cancel()```也可以使用 context 的 `Err()` 函數:```gofunc worker(ctx context.Context, jobChan <-chan Job) {for {select {case <-ctx.Done():returncase job := <-jobChan:process(job)if ctx.Err() != nil {return}}}}cancel()```我們在運行任務之前不檢查 `flag/Err()` 因為我們想在任務彈出以後先把任務處理完再退出。當然如果你想讓退出的優先順序高於處理任務,也可以在處理之前檢查。底線就是要麼在退出 worker 之前做一些額外的工作,要麼仔細設計代碼繞過這種缺陷。## 不使用 context 取消 worker`context.Context` 並不適用所有情況,有時不使用 context 可能會讓代碼更加整潔清晰:```go// create a cancel channelcancelChan := make(chan struct{})// start the goroutine passing it the cancel channel go worker(jobChan, cancelChan)func worker(jobChan <-chan Job, cancelChan <-chan struct{}) {for {select {case <-cancelChan:returncase job := <-jobChan:process(job)}}}// to cancel the worker, close the cancel channelclose(cancelChan)```這其實就是 context(簡單,無層級)的取消操作。不幸的是它也存在上面提到的問題。## worker 池最後,多個 worker 可以讓任務平行處理。最簡單的方式就是建立多個 worker 並在相同的任務 channel 中擷取任務:```gofor i:=0; i<workerCount; i++ {go worker(jobChan)}```其他的代碼不需要修改。這樣多個 worker 會嘗試從相同的 channel 中讀取任務。這樣既有效又安全。只有一個 worker 會擷取到任務,其他的 worker 都會阻塞等待任務到來。這也存在合理分配的問題。試想:總共有 100 個任務,4 個 worker,那麼每個 worker 應該處理 25 個任務。但是事實有可能並不是這樣,所以的代碼不應該建立在這種假設上。想等待 worker 的退出,可以添加 wait group:```gofor i:=0; i<workerCount; i++ {wg.Add(1)go worker(jobChan)}// wait for all workers to exitwg.Wait()```如果想取消操作,你可以建立一個取消 channel,關閉它會取消所有 worker。```go// create cancel channelcancelChan := make(chan struct{})// pass the channel to the workers, let them wait on itfor i:=0; i<workerCount; i++ {go worker(jobChan, cancelChan)}// close the channel to signal the workersclose(cancelChan)```## 一個普通的任務隊列庫表面上看,任務隊列很簡單,可以把它封裝成一個通用的,可重用的組件。而事實上,你往往需要在不同的地方對這個萬用群組件添加更複雜的功能。加上在 Go 中寫一個隊列比其他語言都要簡單,所以你可以在每個需要隊列的地方編寫自己的隊列。## 許可認證以上所有代碼都經由 MIT 認證發布:```Copyright (c) 2017 RapidLoop, Inc.Permission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included inall copies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS INTHE SOFTWARE.```
via: https://www.opsdash.com/blog/job-queues-in-go.html
作者:Mahadevan Ramachandran 譯者:saberuster 校對:rxcai
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
1066 次點擊 ∙ 1 贊