本文 AI 產出,尚未審核

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. 支援的資料型別

型別 對應的原子函式前綴
int32uint32 AddInt32LoadInt32StoreInt32CompareAndSwapInt32
int64uint64 AddInt64LoadInt64StoreInt64CompareAndSwapInt64
uintptr AddUintptrLoadUintptrStoreUintptrCompareAndSwapUintptr
unsafe.Pointer LoadPointerStorePointerCompareAndSwapPointer

注意:在 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
}

說明

  • CompareAndSwapInt32CAS 的核心:只有當 flag0 時才會改為 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.StorePointerLoadPointer 保證其他 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.LoadInt64StoreInt64 來重置計數,確保跨秒切換時不會產生競爭。
  • 這是一個 輕量級 的速率限制器,適合在 API 網關或內部服務中快速檢查。

常見陷阱與最佳實踐

陷阱 說明 建議的解決方式
直接對變數使用 +=-- 這些操作不是原子性的,會在編譯後被拆解成讀‑改‑寫三步。 必須改用 atomic.AddInt64atomic.AddInt32 等 API。
混用 sync.Mutexsync/atomic 若同一變數同時被鎖保護與原子操作,可能導致記憶體可見性不一致。 統一使用一種同步機制;若需要混用,務必在文件中說明並避免同時存取。
在 32 位元平台上使用 int64 原子操作 某些 CPU 需要雙字對齊,若變數未對齊可能導致 panic 或不正確結果。 使用 int64 前確保變數是 64 位元對齊(如在 struct 中使用 int64 作為第一個欄位),或改用 int32
忘記使用 unsafe.Pointer 轉型 atomic.LoadPointerStorePointer 需要 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,確保所有共享變數都已正確原子化。

最佳實踐

  1. 只對簡單資料型別使用原子操作:計數器、旗標、指標等。複雜結構(如 slice、map)仍應使用 sync.Mutexsync.RWMutex 包裝。
  2. 將原子變數封裝成類別(struct),提供明確的 API,避免外部直接操作底層變數。
  3. 使用 atomic.Value 來存放任意類型的不可變資料,避免自行實作指標的 CAS。
  4. 在跨平台部署前測試 32/64 位元差異,尤其是 int64uint64 的對齊問題。
  5. 結合 sync/atomiccontext,在長時間運算或 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 程式碼。祝開發順利!