Golang
單元:通道與同步
主題:原子操作(sync/atomic)
簡介
在多執行緒(goroutine)環境下,共享記憶體的安全存取是最常見的挑戰之一。若直接使用一般變數進行讀寫,極易產生競爭條件(race condition),導致程式行為不可預期。Go 標準函式庫提供了 sync/atomic 套件,讓開發者可以在不加鎖的情況下,以原子方式操作基本資料型別(int32、int64、uintptr、Pointer 等),從而達到高效且正確的同步。
本篇文章將從概念、常用 API、實作範例、常見陷阱與最佳實踐,逐步帶領讀者掌握 sync/atomic 的使用技巧,並說明它在真實系統中的應用場景,讓你在開發高併發服務時能夠更得心應手。
核心概念
1. 什麼是「原子」操作?
原子(Atomic)意指一個操作在執行過程中 不可被中斷,對其他 goroutine 來說,它要麼全部完成,要麼完全未執行。這種「不可分割」的特性保證了資料的一致性,尤其在多核 CPU 上尤為重要。
在 Go 中,sync/atomic 透過底層硬體指令(如 x86 的 LOCK CMPXCHG)實作原子讀寫、加減、比較交換(Compare-And-Swap, CAS)等功能。這些操作的執行時間遠低於使用 sync.Mutex 加鎖與解鎖的開銷,適合 高頻率、簡單的計數或旗標。
2. 支援的資料型別
| 型別 | 對應的原子函式前綴 |
|---|---|
int32、uint32 |
AddInt32、LoadInt32、StoreInt32、CompareAndSwapInt32 |
int64、uint64 |
AddInt64、LoadInt64、StoreInt64、CompareAndSwapInt64 |
uintptr |
AddUintptr、LoadUintptr、StoreUintptr、CompareAndSwapUintptr |
unsafe.Pointer |
LoadPointer、StorePointer、CompareAndSwapPointer |
注意:在 32 位元平台上,對
int64/uint64的原子操作會自動使用 雙字對齊(double‑word)指令,但仍建議僅在必要時使用,避免因對齊問題產生額外成本。
3. 基本 API
| 功能 | 範例 | 說明 |
|---|---|---|
| Load | val := atomic.LoadInt64(&counter) |
以原子方式讀取值 |
| Store | atomic.StoreInt64(&counter, 0) |
以原子方式寫入值 |
| Add | new := atomic.AddInt64(&counter, 1) |
原子加法,返回新值 |
| Swap | old := atomic.SwapInt64(&counter, 10) |
交換舊值與新值 |
| CompareAndSwap | ok := atomic.CompareAndSwapInt64(&counter, old, new) |
CAS,只有當舊值相符時才寫入新值,返回成功與否 |
程式碼範例
以下範例皆使用 go.mod 初始化的模組,並以 go run 可直接執行。
1️⃣ 基本的原子計數器
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64 // 必須是 int64、int32 等支援的型別
var wg sync.WaitGroup
// 啟動 100 個 goroutine,每個都遞增 1000 次
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
atomic.AddInt64(&counter, 1) // 原子遞增
}
}()
}
wg.Wait()
fmt.Printf("最終計數: %d (期望 100000)\n", counter)
}
說明
counter必須是 對齊 的變數,直接傳入其位址 (&counter)。AddInt64會在 CPU 層面確保「讀‑改‑寫」的完整性,避免 race。
2️⃣ 使用 Compare-And-Swap(CAS)實作自旋鎖
package main
import (
"fmt"
"runtime"
"sync/atomic"
"time"
)
type SpinLock struct {
flag int32 // 0: unlocked, 1: locked
}
// Lock 嘗試以 CAS 把 flag 從 0 設為 1,失敗則自旋
func (sl *SpinLock) Lock() {
for !atomic.CompareAndSwapInt32(&sl.flag, 0, 1) {
// 讓出 CPU,減少忙等 (busy-wait) 的能耗
runtime.Gosched()
}
}
// Unlock 把 flag 設回 0
func (sl *SpinLock) Unlock() {
atomic.StoreInt32(&sl.flag, 0)
}
func main() {
var lock SpinLock
done := make(chan struct{})
// 兩個 goroutine 交替寫入同一變數
go func() {
for i := 0; i < 5; i++ {
lock.Lock()
fmt.Println("Goroutine A 獲得鎖")
time.Sleep(100 * time.Millisecond)
lock.Unlock()
}
done <- struct{}{}
}()
go func() {
for i := 0; i < 5; i++ {
lock.Lock()
fmt.Println("Goroutine B 獲得鎖")
time.Sleep(150 * time.Millisecond)
lock.Unlock()
}
done <- struct{}{}
}()
<-done
<-done
}
說明
CompareAndSwapInt32是 CAS 的核心:只有當flag為0時才會改為1,保證不會有兩個 goroutine 同時取得鎖。runtime.Gosched()讓出執行緒,降低自旋帶來的 CPU 消耗。- 此實作適合 臨界區極短、對延遲要求極高的情境;若臨界區較長,仍建議使用
sync.Mutex。
3️⃣ 原子指標(Atomic Pointer)— 安全的單例模式
package main
import (
"fmt"
"sync"
"sync/atomic"
"unsafe"
)
// 假設有一個昂貴的資源需要全域唯一實例
type Config struct {
Name string
Port int
}
// 全域變數,使用 unsafe.Pointer 包裝
var configPtr unsafe.Pointer
var once sync.Once
// GetConfig 只會建立一次 Config,之後直接返回同一個指標
func GetConfig() *Config {
once.Do(func() {
cfg := &Config{Name: "MyApp", Port: 8080}
atomic.StorePointer(&configPtr, unsafe.Pointer(cfg))
})
// 讀取時使用 atomic.LoadPointer,保證可見性
return (*Config)(atomic.LoadPointer(&configPtr))
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := GetConfig()
fmt.Printf("Goroutine %d 取得 Config: %+v\n", id, cfg)
}(i)
}
wg.Wait()
}
說明
unsafe.Pointer允許我們在 原子層級 讀寫任意指標。once.Do確保初始化只執行一次,而atomic.StorePointer與LoadPointer保證其他 goroutine 讀到的指標是 已完成寫入 的最新值。- 這種寫法比
sync.Once+ 全域變數直接賦值更具記憶體屏障(memory barrier)保證。
4️⃣ 原子旗標(Flag)— 多路復用的停止信號
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func worker(id int, stop *int32, wg *sync.WaitGroup) {
defer wg.Done()
for {
// 先檢查停止旗標
if atomic.LoadInt32(stop) == 1 {
fmt.Printf("Worker %d 收到停止訊號,退出\n", id)
return
}
// 模擬工作
fmt.Printf("Worker %d 正在執行\n", id)
time.Sleep(200 * time.Millisecond)
}
}
func main() {
var stopFlag int32 // 0: 繼續, 1: 停止
var wg sync.WaitGroup
// 啟動 3 個 worker
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &stopFlag, &wg)
}
// 主程式跑 1 秒後發送停止訊號
time.Sleep(1 * time.Second)
atomic.StoreInt32(&stopFlag, 1) // 原子寫入停止旗標
wg.Wait()
fmt.Println("所有 worker 已安全退出")
}
說明
stopFlag為 共享旗標,所有 goroutine 透過atomic.LoadInt32讀取。- 主程式使用
atomic.StoreInt32設為1,立即對所有執行中的 goroutine 可見,避免因緩衝或快取不一致導致延遲關閉。
5️⃣ 原子加法與統計(Rate Limiter)
package main
import (
"fmt"
"sync/atomic"
"time"
)
type RateLimiter struct {
limit int64 // 每秒允許的請求數
counter int64 // 當前秒的計數
reset int64 // 上一次重置的 Unix 秒
}
// Allow 嘗試取得一個配額,若超過上限則返回 false
func (rl *RateLimiter) Allow() bool {
now := time.Now().Unix()
// 若時間已跨秒,重置計數
if atomic.LoadInt64(&rl.reset) != now {
atomic.StoreInt64(&rl.reset, now)
atomic.StoreInt64(&rl.counter, 0)
}
// 原子遞增,並檢查是否仍在限制內
if atomic.AddInt64(&rl.counter, 1) <= rl.limit {
return true
}
return false
}
func main() {
rl := RateLimiter{limit: 5}
for i := 0; i < 10; i++ {
if rl.Allow() {
fmt.Printf("第 %d 次請求: 允許\n", i+1)
} else {
fmt.Printf("第 %d 次請求: 被拒絕\n", i+1)
}
}
}
說明
Allow內部使用atomic.AddInt64來 安全遞增 請求計數。- 透過
atomic.LoadInt64與StoreInt64來重置計數,確保跨秒切換時不會產生競爭。 - 這是一個 輕量級 的速率限制器,適合在 API 網關或內部服務中快速檢查。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的解決方式 |
|---|---|---|
直接對變數使用 +=、-- |
這些操作不是原子性的,會在編譯後被拆解成讀‑改‑寫三步。 | 必須改用 atomic.AddInt64、atomic.AddInt32 等 API。 |
混用 sync.Mutex 與 sync/atomic |
若同一變數同時被鎖保護與原子操作,可能導致記憶體可見性不一致。 | 統一使用一種同步機制;若需要混用,務必在文件中說明並避免同時存取。 |
在 32 位元平台上使用 int64 原子操作 |
某些 CPU 需要雙字對齊,若變數未對齊可能導致 panic 或不正確結果。 | 使用 int64 前確保變數是 64 位元對齊(如在 struct 中使用 int64 作為第一個欄位),或改用 int32。 |
忘記使用 unsafe.Pointer 轉型 |
atomic.LoadPointer、StorePointer 需要 unsafe.Pointer,直接傳 *T 會編譯錯誤。 |
使用 unsafe.Pointer(&value) 轉型,並在取回時 (*T)(ptr)。 |
自旋鎖未加 runtime.Gosched() 或 time.Sleep |
會導致 CPU 飽和,影響整體效能。 | 在自旋迴圈中加入 runtime.Gosched() 或適當的 time.Sleep,或改用 sync.Mutex。 |
忽視 go vet -race 的檢測 |
雖然 atomic 可以避免 race,但錯誤使用仍可能產生隱藏競爭。 |
在開發階段執行 go test -race,確保所有共享變數都已正確原子化。 |
最佳實踐
- 只對簡單資料型別使用原子操作:計數器、旗標、指標等。複雜結構(如 slice、map)仍應使用
sync.Mutex或sync.RWMutex包裝。 - 將原子變數封裝成類別(struct),提供明確的 API,避免外部直接操作底層變數。
- 使用
atomic.Value來存放任意類型的不可變資料,避免自行實作指標的 CAS。 - 在跨平台部署前測試 32/64 位元差異,尤其是
int64、uint64的對齊問題。 - 結合
sync/atomic與context,在長時間運算或 I/O 時傳遞取消訊號,提升可控性。
實際應用場景
| 場景 | 為何適合使用 sync/atomic |
|---|---|
| 高頻率計數器(如 API 請求次數、訊息佇列長度) | 原子加法的成本僅是一次 CPU 指令,遠低於鎖的開銷。 |
| 旗標控制(服務啟停、功能開關) | 只需要讀寫單一位元,使用 LoadInt32/StoreInt32 即可保證即時可見。 |
| 單例初始化(全域設定、資料庫連線池) | atomic.CompareAndSwapPointer 可在無鎖的情況下保證「一次」初始化。 |
| 自旋鎖或輕量級互斥(臨界區極短、硬體支援) | CAS 能在 nanosecond 級別完成,適合極端效能需求。 |
| 速率限制(Rate Limiter) | 每秒的請求計數可用 atomic.AddInt64 快速累計,避免全域鎖。 |
| 多執行緒的統計收集(Histogram、Metrics) | 每個 metric 使用獨立的原子變數,避免競爭且易於匯出。 |
總結
sync/atomic 為 Go 提供了 低延遲、無鎖 的同步原語,讓開發者在高併發環境下仍能保持資料一致性。透過 原子讀寫、加法、比較交換 等 API,我們可以安全地實作計數器、旗標、單例、速率限制器等常見需求。
然而,原子操作僅適用於 簡單資料型別,且在使用時必須注意平台對齊、記憶體可見性以及與其他同步機制的相容性。遵循最佳實踐、封裝 API、結合 go vet -race,即可在保證正確性的同時,獲得極佳的效能表現。
希望本篇文章能讓你在日常開發與系統設計中,熟練運用 sync/atomic,寫出更快、更可靠的 Go 程式碼。祝開發順利!