HOWTO · C

在 C 語言中使用原子型別

本文介紹如何在 C 語言中使用原子型別

本頁內容

C11 原子型別可讓多個執行緒安全地更新同一個共享物件,而不會在該物件上造成資料競爭。本例以原子計數器示範如何宣告、遞增,以及工作執行緒完成後的結果。原子性適用於對原子物件執行的操作;它不會讓程式的每個部分都依序執行,也不會保護無關資料。(atomic_int)

C 原子型別能保證什麼

對原子物件的修改會形成該物件的單一修改順序;原子載入雖然是存取,但不屬於該順序中的修改。像 atomic_fetch_add_explicit 這類操作,相對於對同一計數器執行的其他原子操作,會以不可分割的方式讀取並更新計數器。因此,不會有兩個執行緒從相同的舊值更新計數器,導致其中一次遞增遺失。

這項保證不代表該操作一定會編譯成一條機器指令,也不代表它一定是無鎖的;這些都屬於實作細節。將一個變數宣告為原子型別,也不會讓相鄰的一般變數自動適合共享。若程式需要發布資料、協調多個物件,或維持多步驟的不變條件,就需要採用合適的同步設計。

為什麼一般的共享計數器不安全

counter += 1 在概念上包含讀取、加法和寫入。如果多個執行緒在沒有同步的情況下,同時對一般的共享 int 執行這項操作,彼此衝突的存取就會構成 C 語言的資料競爭。資料競爭會使程式產生未定義行為,而不只是讓計數結果可能偏低。即使某次觀察到的數值看起來合理,也不代表程式正確或行為可預測。

若多個執行緒會各自更新同一個計數器,請使用原子操作。不要用一般 int 版本中某次遺失更新的計數值來驗證程式:發生未定義行為時,結果並非穩定值,無法用來比較不同執行或編譯器的表現。

以原子方式遞增共享計數器

以下 C11 程式會啟動四個工作執行緒。每個執行緒會執行 10,000 次原子遞增,而主執行緒會等候所有工作執行緒結束後,再讀取計數器。在這個例子中,寬鬆記憶體順序已經足夠,因為計數器是唯一的共享狀態:每次遞增都必須是原子的,但它不會發布或保護其他資料。

請執行以下範例:程式中的錯誤訊息與計數輸出標籤仍為英文。

#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>

enum { THREAD_COUNT = 4, INCREMENTS_PER_THREAD = 10000 };

static atomic_int counter = 0;

static int increment_counter(void *unused) {
    (void)unused;
    for (int i = 0; i < INCREMENTS_PER_THREAD; ++i) {
        atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
    }
    return 0;
}

int main(void) {
    thrd_t threads[THREAD_COUNT];
    int created = 0;

    for (; created < THREAD_COUNT; ++created) {
        if (thrd_create(&threads[created], increment_counter, NULL) != thrd_success) {
            fputs("Could not create a worker thread.\n", stderr);
            break;
        }
    }

    int join_failed = 0;
    for (int i = 0; i < created; ++i) {
        if (thrd_join(threads[i], NULL) != thrd_success) {
            join_failed = 1;
        }
    }

    if (created != THREAD_COUNT || join_failed) {
        return EXIT_FAILURE;
    }

    printf("count = %d\n", atomic_load_explicit(&counter, memory_order_relaxed));
    return EXIT_SUCCESS;
}

如果四個執行緒都成功建立並完成等候,算出的結果會是 40,000:四個工作執行緒各自遞增 10,000 次。最後的原子載入會在這些執行緒完成後讀取計數器。memory_order_relaxed 提供原子性與物件內的順序,但不會同步其他資料;若還需要傳遞其他狀態,請使用 release/acquire 協定或互斥鎖。

何時使用原子型別或互斥鎖

若只需簡單、獨立地更新一個值,例如計數器,或使用方式明確的旗標,原子型別就很適合。這類操作可以不使用互斥鎖,但 C 語言實作不保證每種原子型別都是無鎖的。請先判斷程式只需要不可分割的更新,還是也需要協調相關狀態。

如果臨界區需要維持多個值之間的關係、先檢查再更新,或在多項操作期間保護資料結構,使用互斥鎖通常更清楚。加鎖可讓執行緒將這組步驟視為一個受保護的動作。單次原子遞增本身無法把獨立的讀取、條件檢查與寫入合併成不可分割的交易。

編譯並驗證計數器範例

將程式存成 atomic_counter.c。如果 C11 工具鏈提供 <threads.h>,請使用 cc -std=c11 -pthread atomic_counter.c -o atomic_counter 編譯並連結,再執行 ./atomic_counter。有些平台需要不同的執行緒連結選項,或不提供 C11 執行緒;請參考該平台的編譯器與系統文件,或將範例改用平台支援的執行緒 API,同時保留原子計數器並等候所有工作執行緒完成。

成功建立並等候所有執行緒後,預期的計數值可由迴圈次數得知:4 * 10000,也就是 40000。這是根據程式語意推得的預期值,並非表示本封包包含實際執行結果。如果建立或等候執行緒失敗,範例會以失敗狀態結束,不會將部分計數值當成完成結果。