Parallel Programming: Atomic, Mutex, Semaphore

平行程式開發:原子操作、鎖、信號量、具狀態 Goroutine

前言

大多程式都可以採取「並行 Concurrency」的方式來達成簡單安全且高效的運行,而隨著多核 CPU 出現以及特定場景對於效率的追求「平行 Parallelism」運行程式是另一個提高運行效率方向。本文練習透過 Go 複習平行程式開發下資料共享相關技巧:

  • 原子操作 Atomic
  • 鎖 Mutex
  • 信號量 Semaphore

競爭危害

程式輸出依賴於不受控制的事件順序或時機,通常發生在平行同時存取或修改共享資源時

舉例以下程式不太可能每次 count 結果都相同,原因是因為 count++ 其實是三個步驟:

  1. 讀取 count
  2. 加一
  3. 寫回 count
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++
}()
}
wg.Wait()

原子操作 Atomic

如果把產生競爭危害的程式片段改為無法分割的「原子操作」就不存在操作的先後順序,自然就不存在競爭危害:

import "sync/atomic"
var count int64
atomic.AddInt64(&count, 1)
  • 特點
    • 開銷最輕量,效能最好
    • 僅適用於「單一變數操作」
  • 使用時機
    • 計數器(requests count)
    • 狀態標記(flag)
    • 指標替換(unsafe.Pointer)

鎖 Mutex

import "sync"
var mu sync.Mutex
var count int
mu.Lock()
count++ // 臨界區
mu.Unlock()
  • 特點
    • 可保護任意複雜邏輯
    • 易理解、通用性高
    • 有鎖競爭成本
  • 使用時機
    • 複雜狀態更新
    • 複雜資料操作(struct / map)
    • 需要「臨界區」保護

信號量 Semaphore

sem := make(chan struct{}, 3) // 最多 3 個同時執行
for i := 0; i < 10; i++ {
go func() {
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
// do work
}()
}
  • 特點
    • 控制「同時執行數量」
    • 不直接保護資料
    • 常用於資源限制
  • 使用時機
    • API rate limit
    • DB connection pool
    • worker pool 控制

具狀態的 Goroutine

package main
import (
"fmt"
"sync"
"sync/atomic"
)
type op struct {
action string // "deposit" | "withdraw" | "balance"
amount int
resp chan result
}
type result struct {
balance int
ok bool
}
func bankManager(ops chan op) {
balance := 0
for o := range ops {
switch o.action {
case "deposit":
balance += o.amount
o.resp <- result{balance: balance, ok: true}
case "withdraw":
if balance >= o.amount {
balance -= o.amount
o.resp <- result{balance: balance, ok: true}
} else {
o.resp <- result{balance: balance, ok: false}
}
case "balance":
o.resp <- result{balance: balance, ok: true}
}
}
}
func main() {
ops := make(chan op)
go bankManager(ops) // 唯一擁有 balance 的 goroutine
var wg sync.WaitGroup
var successCount uint64
// 模擬 5 個用戶同時操作
actions := []struct {
action string
amount int
user string
}{
{"deposit", 1000, "Alice"},
{"deposit", 500, "Bob"},
{"withdraw", 200, "Alice"},
{"withdraw", 9999, "Bob"}, // 餘額不足
{"balance", 0, "Charlie"},
}
for _, a := range actions {
wg.Add(1)
a := a
go func() {
defer wg.Done()
resp := make(chan result, 1)
ops <- op{action: a.action, amount: a.amount, resp: resp}
r := <-resp
if r.ok {
atomic.AddUint64(&successCount, 1)
fmt.Printf("[%s] %-8s %4d → 餘額: %d\n",
a.user, a.action, a.amount, r.balance)
} else {
fmt.Printf("[%s] %-8s %4d → 失敗,餘額不足(現有: %d\n",
a.user, a.action, a.amount, r.balance)
}
}()
}
wg.Wait()
close(ops)
fmt.Printf("\n成功操作次數:%d\n", successCount)
}
  • 特點
    • 狀態由單一 goroutine 獨佔,其他 goroutine 透過 channel 傳遞請求
    • 存取狀態的瞬間是排序執行的,但其餘業務邏輯仍並行執行
    • 無需顯式管理鎖
    • 邏輯較複雜,但正確性更容易推理
  • 使用時機
    • 狀態需要集中管理,且已有多個 channel 在協作
    • 管理多把 Mutex 容易出錯且難以維護時

通訊共享記憶體還是共享記憶體通訊?

Go 雖然有個座右銘叫做:「透過通訊共享記憶體,而不是透過共享記憶體通訊」,不過並不強迫使用特定程式風格來實踐
  • Mutex 方案:任何 goroutine 都可直接碰資料,靠「鎖」來排隊
  • Stateful Goroutine:資料只有一個主人,其他人只能「通訊」

延伸閱讀