go併發-工作池模式

Remember發表於2021-08-02

開篇

之前寫過一篇文章,它有個響亮的名字: Handling 1 Million Requests per Minute with Go。 這是國外的一個作者寫的,我做了一篇說明。起的也是這個標題, 閱讀量是我最好的一篇,果然文章都是靠標題出彩的…..

今天偶然看到另一篇文章(原文在文末)。兩篇文章原理相似:有一批工作任務(job),通過工作池(worker-pool)的方式,達到多 worker 併發處理 job 的效果。

他們還是有很多不同的點,實現上差別也是蠻大的。

首先上一篇文章我放了一張圖片,大概就是上篇整體的工作流。 image

  • 每個 worker 處理完任務就好,不關心結果,不對結果做進一步處理。
  • 只要請求不停止,程式就不會停止,沒有控制機制,除非當機。

這篇文章不同點在於:

首先資料會從 generate (生產資料)->併發處理資料->處理結果聚合。 圖大概是這樣的, image

然後它可以通過 context.context 達到控制工作池停止工作的效果。

最後通過程式碼,你會發現它不是傳統意義上的 worker-pool,後面會說明。

下圖能清晰表達整體流程了。 image

順便說一句,這篇文章實現的程式碼比 Handling 1 Million Requests per Minute with Go 的程式碼簡單多了。

首先看 job

package wpool

import (
    "context"
)

type JobID string
type jobType string
type jobMetadata map[string]interface{}

type ExecutionFn func(ctx context.Context, args interface{}) (interface{}, error)

type JobDescriptor struct {
    ID       JobID 
    JType    jobType
    Metadata map[string]interface{}
}

type Result struct {
    Value      interface{}
    Err        error
    Descriptor JobDescriptor
}

type Job struct {
    Descriptor JobDescriptor
    ExecFn     ExecutionFn
    Args       interface{}
}

// 處理 job 邏輯,處理結果包裝成 Result 結果
func (j Job) execute(ctx context.Context) Result {
    value, err := j.ExecFn(ctx, j.Args)
    if err != nil {
        return Result{
            Err:        err,
            Descriptor: j.Descriptor,
        }
    }

    return Result{
        Value:      value,
        Descriptor: j.Descriptor,
    }
}

這個可以簡單過一下。最終每個 job 處理完都會包裝成 Result 返回。

下面這段就是核心程式碼了。

package wpool

import (
    "context"
    "fmt"
    "sync"
)

// 執行中的每個worker
func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan Job, results chan<- Result) {
    defer wg.Done()
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return
            }
            results <- job.execute(ctx)
        case <-ctx.Done():
            fmt.Printf("cancelled worker. Error detail: %v\n", ctx.Err())
            results <- Result{
                Err: ctx.Err(),
            }
            return
        }
    }
}

type WorkerPool struct {
    workersCount int //worker 數量
    jobs         chan Job // 儲存 job 的 channel 
    results      chan Result // 處理完每個 job 對應的 結果集
    Done         chan struct{} //是否結束
}

func New(wcount int) WorkerPool {
    return WorkerPool{
        workersCount: wcount,
        jobs:         make(chan Job, wcount),
        results:      make(chan Result, wcount),
        Done:         make(chan struct{}),
    }
}

func (wp WorkerPool) Run(ctx context.Context) {
    var wg sync.WaitGroup
    for i := 0; i < wp.workersCount; i++ {
        wg.Add(1)
        go worker(ctx, &wg, wp.jobs, wp.results)
    }

    wg.Wait()
    close(wp.Done)
    close(wp.results)
}

func (wp WorkerPool) Results() <-chan Result {
    return wp.results
}

func (wp WorkerPool) GenerateFrom(jobsBulk []Job) {
    for i, _ := range jobsBulk {
        wp.jobs <- jobsBulk[i]
    }
    close(wp.jobs)
}

整個 WorkerPool 結構很簡單。 jobs 是一個緩衝 channel。每一個任務都會放入 jobs 中等待處理 woker 處理。

results 也是一個通道型別,它的作用是儲存每個 job 處理後產生的結果 Result

首先通過 New 初始化一個 worker-pool 工作池,然後執行 Run 開始執行。

func New(wcount int) WorkerPool {
    return WorkerPool{
        workersCount: wcount,
        jobs:         make(chan Job, wcount),
        results:      make(chan Result, wcount),
        Done:         make(chan struct{}),
    }
}
func (wp WorkerPool) Run(ctx context.Context) {
    var wg sync.WaitGroup

    for i := 0; i < wp.workersCount; i++ {
        wg.Add(1)
        go worker(ctx, &wg, wp.jobs, wp.results)
    }

    wg.Wait()
    close(wp.Done)
    close(wp.results)
}

初始化的時候傳入 worker 數,對應每個 g 執行 work(ctx,&wg,wp.jobs,wp.results),組成了 worker-pool。 同時通過 sync.WaitGroup,我們可以等待所有 worker 工作結束,也就意味著 work-pool 結束工作,當然可能是因為任務處理結束,也可能是被停止了。

每個 job 資料來源是如何來的?

// job資料來源,把每個 job 放入到 jobs channel 中
func (wp WorkerPool) GenerateFrom(jobsBulk []Job) {
    for i, _ := range jobsBulk {
        wp.jobs <- jobsBulk[i]
    }
    close(wp.jobs)
}

對應每個 worker 的工作,

func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan Job, results chan<- Result) {
    defer wg.Done()
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return
            }
            results <- job.execute(ctx)
        case <-ctx.Done():
            fmt.Printf("cancelled worker. Error detail: %v\n", ctx.Err())
            results <- Result{
                Err: ctx.Err(),
            }
            return
        }
    }
}

每個 worker 都嘗試從同一個 jobs 獲取資料,這是一個典型的 fan-out 模式。 當對應的 g 獲取到 job 進行處理後,會把處理結果傳送到同一個 results channel 中,這又是一個 fan-in 模式。 當然我們通過 context.Context 可以對每個 worker 做停止執行控制。

最後是處理結果集合,

// 處理結果集
func (wp WorkerPool) Results() <-chan Result {
    return wp.results
}

那麼整體的測試程式碼就是:

func TestWorkerPool(t *testing.T) {
    wp := New(workerCount)

    ctx, cancel := context.WithCancel(context.TODO())
    defer cancel()

    go wp.GenerateFrom(testJobs())

    go wp.Run(ctx)

    for {
        select {
        case r, ok := <-wp.Results():
            if !ok {
                continue
            }

            i, err := strconv.ParseInt(string(r.Descriptor.ID), 10, 64)
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }

            val := r.Value.(int)
            if val != int(i)*2 {
                t.Fatalf("wrong value %v; expected %v", val, int(i)*2)
            }
        case <-wp.Done:
            return
        default:
        }
    }
}

看了程式碼之後,我們知道,這並不是一個傳統意義的 worker-pool。它並不像 Handling 1 Million Requests per Minute with Go 這篇文章一樣, 初始化一個真正的 worker-pool,一旦接收到 job,就嘗試從池中獲取一個 worker, 把對應的 job 交給這個 work 進行處理,等 work 處理完畢,重新進行到工作池中,等待下一次被利用。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
吳親庫裡

相關文章