Wiki
併發程式設計
本文由簡體中文內容確定性轉換,並受版本化術語表保護。
C++11 開始,標準庫正式提供執行緒、互斥量、條件變數、原子操作和非同步任務等併發工具。到了 C++17、C++20,又繼續增加了 std::scoped_lock、std::shared_mutex、std::jthread、std::stop_token、std::latch、std::barrier、std::semaphore 等能力。
併發程式設計真正困難的地方並不是“建立一個執行緒”,而是下面幾個問題:
- 執行緒什麼時候開始、什麼時候結束,誰負責等待它?
- 執行緒函式拿到的引數是副本、引用還是指標?物件會不會提前銷燬?
- 多個執行緒同時訪問同一份資料時,怎樣避免資料競爭?
- 多把鎖同時使用時,怎樣避免死鎖?
- 一個執行緒沒有工作時,怎樣高效等待,而不是一直迴圈佔用 CPU?
- 一個任務將在未來產生結果時,怎樣取得它的返回值或異常?
- 程式退出時,怎樣讓後臺執行緒安全停止?
本章按照這些問題逐步展開。
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 鎖 |
mutex、lock_guard、unique_lock、scoped_lock、死鎖、讀寫鎖、一次初始化 |
| 3 | std::atomic |
load/store、原子讀改寫、CAS、記憶體序基礎、C++20 wait/notify |
| 4 | condition_variable |
等待/通知、謂詞、虛假喚醒、超時等待、生產者消費者 |
| 5 | future / async / promise |
非同步任務返回值、異常傳播、packaged_task、shared_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.併發程式碼的基本原則
- 先保證正確,再討論效能。 一個存在資料競爭的程式即使“跑起來很快”也沒有意義。
- 優先減少共享狀態。 不共享就不需要同步。
- 鎖要由 RAII 物件管理。 除非確有必要,不要手寫成對的
lock()/unlock()。 - 鎖保護的是不變數,不只是某一行程式碼。 如果幾個成員必須保持一致,應由同一把鎖共同保護。
- 不要持鎖執行耗時操作。 網路、檔案 IO、休眠、大量計算通常應放在臨界區外。
- 不要輕易
detach()。 脫離執行緒很容易製造生命週期問題。 - 執行緒停止應該有明確協議。 C++20 優先了解
std::jthread+std::stop_token。 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_guard、unique_lock、scoped_lock分別適合什麼場景?- 兩把互斥量為什麼會產生死鎖,怎樣避免?
atomic與mutex應該怎樣選擇?- 為什麼條件變數必須圍繞“共享狀態 + mutex + 謂詞”使用?
future和promise分別站在結果通道的哪一端?jthread相比thread解決了哪些生命週期問題?semaphore、latch、barrier分別對應什麼同步模式?
如果這些問題能夠清楚回答,才算真正掌握了標準庫併發基礎。