Go Handling parallel image generation

Go 緩衝通道實踐工作池處理併發圖片生產

前言

學習 Go 我一直想找個機會嘗試併發情境,而一個最基礎的 Worker Pool(Thread Pool)就是很好入門的模式:「讓固定數量的 worker 在背後運行,等待分配給它們工作」。

剛好我最近做了一個小工具 goog🔗,實際使用範例則放在 goog-demo🔗

這個情境很適合練習併發,因為每張圖片彼此獨立,不需要等前一張完成才能生成下一張,但也不能無限制地全部同時跑,因為背後會啟動 headless Chrome tab 截圖,一次性跑完並不實際。

所以我最後採用的做法是:「用 goroutine 平行處理任務,運用緩衝通道的特性達成信號量的功效,限制平行運算的數量」。

真實案例分析

舉例我的部落格有上千篇文章的 Open Graph 預覽圖片要生成,通常每張圖片用 satori🔗(基於 JavaScript 的圖片生成庫)會需要 800ms 上下,總體會耗費數十分鐘來生成所有圖片。如果能善加利用 Go(編譯式語言、原生併發支援、輕量執行環境)的優勢,我好奇還有多少硬體潛能能被榨取出來?於是我嘗試打造一個 CLI 工具來生成圖片:

  1. 讀取 HTML template
  2. Go template🔗 填入文章標題、描述、分類等變數
  3. 透過 chromedp🔗 開啟 headless Chrome
  4. 把 HTML 注入頁面
  5. 截一張 1200x630 的圖片
  6. 寫入圖片檔案

單張圖片可以這樣產生:

Terminal window
goog \
--var "tag=Tutorial" \
--var "title=How to Generate OG Images in Go" \
--var "description=Learn how to automate social card generation with chromedp" \
--var "site=example.com" \
--out tutorial-og.png

Template 則是一個普通的 HTML 檔案只是可以被注入 Go Template 變數:

<body>
<div class="card">
<div class="tag">{{.tag}}</div>
<div class="title">{{.title}}</div>
<div class="description">{{.description}}</div>
<div class="footer">
<div class="dot"></div>
<span>{{.site}}</span>
</div>
</div>
</body>

但真正有用的是批次模式,例如從 images.json 一次讀取多個任務:

[
{
"vars": {
"tag": "Tutorial",
"title": "Getting Started with Go",
"description": "Learn the basics of Go programming language.",
"site": "example.com"
},
"out": "out/getting-started.png"
},
{
"vars": {
"tag": "Deep Dive",
"title": "Concurrency in Go",
"description": "Goroutines, channels, and patterns for concurrent programming.",
"site": "example.com"
},
"out": "out/concurrency.png"
}
]

執行時再指定 worker 數量:

Terminal window
goog --config images.json --workers 4

--workers 4 的意思不是總共只有四張圖片會被處理,而是同一時間最多只允許四個圖片生成任務在跑

goog 的核心實作

用 goroutine 平行處理任務,運用緩衝通道的特性達成信號量的功效,限制平行運算的數量
generator.go
func (g *Generator) Generate(ctx context.Context, jobs []ImageJob) error {
if len(jobs) == 0 {
return fmt.Errorf("no image jobs to process")
}
sem := make(chan struct{}, g.workers)
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
start := time.Now()
for i, job := range jobs {
wg.Add(1)
sem <- struct{}{} // acquire slot
go func(idx int, j ImageJob) {
defer wg.Done()
defer func() { <-sem }() // release slot
if err := g.processJob(ctx, j); err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("job %d (%s): %w", idx, j.Out, err))
mu.Unlock()
log.Printf("Job %d failed (%s): %v", idx, j.Out, err)
} else {
log.Printf("[%d/%d] saved %s", idx+1, len(jobs), j.Out)
}
}(i, job)
}
wg.Wait()
elapsed := time.Since(start)
fmt.Printf("\nGenerated %d/%d images in %s\n", len(jobs)-len(errs), len(jobs), elapsed.Round(time.Millisecond))
if len(errs) > 0 {
return fmt.Errorf("%d job(s) failed", len(errs))
}
return nil
}

工作池解決什麼問題?

Go 的平行運算可以輕鬆透過開啟一個 goroutine 來達成:

for _, job := range jobs {
go processJob(job)
}

如果一次把上千個圖片生成工作全部丟進 goroutine 會是災難,操作混合了檔案 I/O、瀏覽器資源、記憶體和外部程式協作導致資源耗盡,所以工作池的概念在於「讓平行運算執行有上限」。

信號量是什麼?

信號量(Semaphore)是實踐工作池的手段之一,可以想成一個有固定數量通行證的櫃台,如果 workers = 4,就代表同時只有四張通行證。每個任務開始前要先拿一張,任務結束後歸還。當四張都被拿走,第五個任務就會在原地等待,直到有人完成並釋放通行證。在 Go 裡可以用緩衝通道自然達成:

sem := make(chan struct{}, workers)
sem <- struct{}{} // acquire:拿一張通行證
<-sem // release:歸還通行證

這裡用 struct{}{} 是因為我們不在乎 channel 裡面的值,只在乎 buffer 裡已經放了幾個元素。空 struct 不佔額外資料意義,很適合拿來表示「一個名額」。當 channel buffer 滿了,sem <- struct{}{} 就會阻塞,直到其他 goroutine 從 sem 讀出一個值。

WaitGroup:等待所有工作完成

sync.WaitGroup 負責讓主流程知道所有 goroutine 都完成了。

wg.Add(1)
go func() {
defer wg.Done()
// do work
}()
wg.Wait()

如果沒有 wg.Wait(),主程式可能在 goroutine 還沒完成之前就往下執行,甚至直接結束。我把 wg.Done() 放在 defer 裡,是為了確保不管任務成功、失敗或中途 return,都一定會通知 WaitGroup:這個工作已經結束。

緩衝通道:限制同時執行數量

真正控制併發數的是這行:

sem := make(chan struct{}, g.workers)

以及每次啟動 goroutine 前的 acquire:

sem <- struct{}{}

還有 goroutine 結束時的 release:

defer func() { <-sem }()

這個做法有個細節:sem <- struct{}{} 放在 go func(...) 之前,所以主 goroutine 在建立新 goroutine 之前就會先嘗試取得名額。

也就是說,如果 workers = 4,前四個 job 會順利啟動。到了第五個 job,主 goroutine 會卡在 sem <- struct{}{},直到前面某個 job 結束並釋放名額。

這種寫法不需要真的預先建立四個長期存活的 worker goroutine,而是透過信號量讓「短生命週期 goroutine」最多同時存在指定數量。

Mutex:保護錯誤列表

多個 goroutine 可能同時失敗,並且同時把錯誤 append 到 errs

errs = append(errs, err)

append 會修改 slice header 和底層陣列,不是 thread-safe 的操作。因此這裡需要 sync.Mutex

mu.Lock()
errs = append(errs, fmt.Errorf("job %d (%s): %w", idx, j.Out, err))
mu.Unlock()

這段程式碼的目的不是讓圖片生成變慢,而是保護共享資料。圖片生成本身仍然併發執行,只有寫入錯誤列表時短暫排隊。

共享無頭瀏覽器

一開始最容易寫出的版本,是每張圖片都啟動一個新的 Chrome。這樣隔離性最好,但成本很高,goog 最後採用的是共享 browser context:

allocCtx, allocCancel := chromedp.NewExecAllocator(
context.Background(),
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
)...,
)
browserCtx, browserCancel := chromedp.NewContext(allocCtx)
if err := chromedp.Run(browserCtx); err != nil {
allocCancel()
return nil, fmt.Errorf("failed to start browser: %w", err)
}

然後每個 job 開自己的 tab context:

tabCtx, tabCancel := chromedp.NewContext(g.browserCtx)
defer tabCancel()
tabCtx, timeoutCancel := context.WithTimeout(tabCtx, 30*time.Second)
defer timeoutCancel()

這樣的好處是 Chrome 只需要啟動一次,但每個截圖任務仍然有自己的頁面上下文。配合 worker pool,就可以避免同時開太多 tab。

總結

這次寫 goog 最大的收穫是:併發不是把所有事情同時丟出去,而是要知道哪裡該平行、哪裡該限流。Semaphore-based worker pool 不一定是所有 worker pool 問題的答案,但很適合這種「任務列表已知、每個任務獨立、外部資源昂貴、需要限制同時執行數」的 CLI 批次處理情境。

無頭瀏覽器截圖反而單張圖片渲染更慢

goog 生成一張圖片需要 1 秒左右的時間,反而相比於先前提到的 satori 800ms 左右生成更慢,原因是相較於用 JSX 渲染 SVG 的方案,背後驅動了整個瀏覽器渲染截圖,但有更大的渲染彈性。

雖然兩者的技術選型與方向不同,但我還是粗略的替我的部落格 Satori 生成🔗替換上 goog 生成🔗 後看到整體圖片渲染有至少 3 倍的速度差距(18 分鐘 > 6 分鐘)。所以 goog 比 satori 厲害嗎?不一定,但有一些有趣的特點:

  1. 可以用任何網頁 Template 渲染而非透過 JSX 渲染 SVG
  2. 可平行運算
  3. 現成的 GitHub Action 整合與 Markdown Frontmatter 解析

延伸閱讀