跳到正文

Wiki

併發程式設計

約 7 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

C++11 開始,標準庫正式提供執行緒、互斥量、條件變數、原子操作和非同步任務等併發工具。到了 C++17、C++20,又繼續增加了 std::scoped_lockstd::shared_mutexstd::jthreadstd::stop_tokenstd::latchstd::barrierstd::semaphore 等能力。

併發程式設計真正困難的地方並不是“建立一個執行緒”,而是下面幾個問題:

  1. 執行緒什麼時候開始、什麼時候結束,誰負責等待它?
  2. 執行緒函式拿到的引數是副本、引用還是指標?物件會不會提前銷燬?
  3. 多個執行緒同時訪問同一份資料時,怎樣避免資料競爭?
  4. 多把鎖同時使用時,怎樣避免死鎖?
  5. 一個執行緒沒有工作時,怎樣高效等待,而不是一直迴圈佔用 CPU?
  6. 一個任務將在未來產生結果時,怎樣取得它的返回值或異常?
  7. 程式退出時,怎樣讓後臺執行緒安全停止?

本章按照這些問題逐步展開。

1.併發與並行

概念 含義 典型情況
併發(concurrency) 多個任務在同一段時間內推進 單核 CPU 在多個執行緒之間切換;程式同時等待多個 IO
並行(parallelism) 多個任務在同一時刻真正執行 多核 CPU 上多個執行緒同時計算

併發是一種程式組織方式,並行是一種實際執行狀態。併發程式不一定並行,但並行執行通常建立在併發任務之上。

2.執行緒安全最先要理解:資料競爭

如果兩個執行緒同時訪問同一個記憶體位置,並且至少有一個執行緒在寫,而這些訪問之間又沒有正確同步,就可能產生 data race(資料競爭)

在 C++ 記憶體模型中,資料競爭通常意味著 未定義行為(undefined behavior),並不是簡單的“最後結果偶爾少加幾次”。編譯器可以在未定義行為前提下做出很多看起來反直覺的最佳化。

例如:

int counter = 0;

void work()
{
    ++counter;
}

++counter 並不等於“天然不可分割的一條操作”。它通常包含讀取、計算和寫回。多個執行緒交錯執行時就可能丟失更新。

常見解決方案包括:

  • std::mutex 保護一段共享狀態;
  • 對簡單獨立變數使用 std::atomic
  • 儘量減少共享可變狀態,讓每個執行緒處理自己的資料;
  • 需要等待某個條件時使用 std::condition_variable,而不是不斷輪詢。

3.學習路線

本節拆成以下子章節:

順序 章節 重點
1 std::thread 執行緒建立、可呼叫物件、引數傳遞、join()detach()joinable()、移動語義、執行緒生命週期
2 mutex 與 RAII 鎖 mutexlock_guardunique_lockscoped_lock、死鎖、讀寫鎖、一次初始化
3 std::atomic load/store、原子讀改寫、CAS、記憶體序基礎、C++20 wait/notify
4 condition_variable 等待/通知、謂詞、虛假喚醒、超時等待、生產者消費者
5 future / async / promise 非同步任務返回值、異常傳播、packaged_taskshared_future
6 jthread / stop_token C++20 自動 join、協作式停止、執行緒退出設計
7 semaphore / latch / barrier C++20 許可計數、一次性會合、可重複階段同步

建議嚴格按順序學習。後面的章節會預設已經理解前面的生命週期、RAII 和同步概念。

4.常用併發工具總覽

工具 標準 作用 標頭檔案
std::thread C++11 建立和管理執行緒 <thread>
std::mutex C++11 互斥訪問共享狀態 <mutex>
std::lock_guard C++11 簡單 RAII 加鎖 <mutex>
std::unique_lock C++11 可手動解鎖、延遲加鎖、可移動的 RAII 鎖 <mutex>
std::scoped_lock C++17 同時管理一把或多把鎖 <mutex>
std::shared_mutex C++17 多讀單寫 <shared_mutex>
std::condition_variable C++11 等待共享條件變化 <condition_variable>
std::atomic<T> C++11 對單個物件執行原子操作 <atomic>
std::future C++11 接收未來產生的結果 <future>
std::async C++11 以任務形式非同步執行函式 <future>
std::promise C++11 主動向 future 寫入結果或異常 <future>
std::packaged_task C++11 把可呼叫物件包裝成可產生 future 的任務 <future>
std::jthread C++20 析構時自動請求停止並等待執行緒結束 <thread>
std::stop_token C++20 協作式停止請求 <stop_token>
std::counting_semaphore C++20 計數訊號量 <semaphore>
std::latch C++20 一次性計數同步點 <latch>
std::barrier C++20 可重複階段同步點 <barrier>

後三種 C++20 同步工具在普通業務程式碼裡沒有前幾項常用,因此應先把核心併發模型學紮實,再按實際需求使用。

5.編譯

GCC / Clang 在 Linux 上使用標準執行緒庫時通常需要連結 pthread:

g++ demo.cpp -std=c++20 -pthread -O2 -Wall -Wextra

-pthread 不只是簡單新增一個庫,它還可能影響編譯階段與執行緒相關的宏和選項,因此通常應直接使用 -pthread

6.併發程式碼的基本原則

  1. 先保證正確,再討論效能。 一個存在資料競爭的程式即使“跑起來很快”也沒有意義。
  2. 優先減少共享狀態。 不共享就不需要同步。
  3. 鎖要由 RAII 物件管理。 除非確有必要,不要手寫成對的 lock() / unlock()
  4. 鎖保護的是不變數,不只是某一行程式碼。 如果幾個成員必須保持一致,應由同一把鎖共同保護。
  5. 不要持鎖執行耗時操作。 網路、檔案 IO、休眠、大量計算通常應放在臨界區外。
  6. 不要輕易 detach() 脫離執行緒很容易製造生命週期問題。
  7. 執行緒停止應該有明確協議。 C++20 優先了解 std::jthread + std::stop_token
  8. atomic 不等於“永遠無鎖”。 它保證操作的原子語義,但具體實現是否 lock-free 與型別和平臺有關。

7.推薦掌握程度

學習完本章後,至少應該能夠回答:

  • std::thread t(f, a, b) 中第一個引數和後面的引數分別是什麼?
  • 為什麼給 T& 形參傳執行緒引數時常需要 std::ref()
  • joinable() 到底判斷“執行緒是否還在執行”,還是判斷“執行緒物件是否仍關聯執行執行緒”?
  • 為什麼一個已經執行完函式的 std::thread 仍可能 joinable() == true
  • join()detach() 對執行緒物件狀態有什麼影響?
  • 為什麼 joinable 的 std::thread 析構會 std::terminate()
  • lock_guardunique_lockscoped_lock 分別適合什麼場景?
  • 兩把互斥量為什麼會產生死鎖,怎樣避免?
  • atomicmutex 應該怎樣選擇?
  • 為什麼條件變數必須圍繞“共享狀態 + mutex + 謂詞”使用?
  • futurepromise 分別站在結果通道的哪一端?
  • jthread 相比 thread 解決了哪些生命週期問題?
  • semaphorelatchbarrier 分別對應什麼同步模式?

如果這些問題能夠清楚回答,才算真正掌握了標準庫併發基礎。