前言
學習 Go 我一直想找個機會嘗試併發情境,而一個最基礎的 Worker Pool(Thread Pool)就是很好入門的模式:「讓固定數量的 worker 在背後運行,等待分配給它們工作」。
剛好我最近做了一個小工具 goog,實際使用範例則放在 goog-demo。
這個情境很適合練習併發,因為每張圖片彼此獨立,不需要等前一張完成才能生成下一張,但也不能無限制地全部同時跑,因為背後會啟動 headless Chrome tab 截圖,一次性跑完並不實際。
所以我最後採用的做法是:「用 goroutine 平行處理任務,運用緩衝通道的特性達成信號量的功效,限制平行運算的數量」。
真實案例分析
舉例我的部落格有上千篇文章的 Open Graph 預覽圖片要生成,通常每張圖片用 satori(基於 JavaScript 的圖片生成庫)會需要 800ms 上下,總體會耗費數十分鐘來生成所有圖片。如果能善加利用 Go(編譯式語言、原生併發支援、輕量執行環境)的優勢,我好奇還有多少硬體潛能能被榨取出來?於是我嘗試打造一個 CLI 工具來生成圖片:
- 讀取 HTML template
- 用 Go template 填入文章標題、描述、分類等變數
- 透過 chromedp 開啟 headless Chrome
- 把 HTML 注入頁面
- 截一張
1200x630的圖片 - 寫入圖片檔案
單張圖片可以這樣產生:
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.pngTemplate 則是一個普通的 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 數量:
goog --config images.json --workers 4--workers 4 的意思不是總共只有四張圖片會被處理,而是同一時間最多只允許四個圖片生成任務在跑。
goog 的核心實作
用 goroutine 平行處理任務,運用緩衝通道的特性達成信號量的功效,限制平行運算的數量
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 厲害嗎?不一定,但有一些有趣的特點:
- 可以用任何網頁 Template 渲染而非透過 JSX 渲染 SVG
- 可平行運算
- 現成的 GitHub Action 整合與 Markdown Frontmatter 解析