Learning Objectives
- 說明行程(process)與執行緒(thread)的差別,以及多執行緒的用途
- 用 std::thread 建立執行緒,並用 join 等待它結束
- 說明多個執行緒同時修改同一個變數時,為什麼會發生競爭條件
- 用 std::mutex 與 lock_guard 保護共用資料,並說明 std::atomic 的用途
為什麼需要多執行緒
到目前為止,我們寫的程式都是一條執行緒:從 main 的第一行開始,一次執行一個指令,照順序往下走。
現在的電腦大多有好幾個 CPU 核心,可以真的「同時」做好幾件事。一個執行中的程式叫做行程(process), 一個行程裡可以有好幾條執行緒(thread),每條執行緒有自己的執行位置和自己的堆疊(區域變數、函式呼叫), 但共用同一份全域變數和堆積上的資料。
多執行緒常見的用途:
- 把大量計算分給多個核心,例如影像處理時每條執行緒處理一部分畫面
- 一邊等待網路或檔案(很慢),一邊繼續做別的事,介面不會卡住
- 伺服器同時服務很多個連線
代價是:執行緒什麼時候輪到誰執行,是作業系統決定的,每次執行的順序都可能不同。 多條執行緒同時讀寫同一份資料時,如果沒有協調好,結果就會出錯,而且錯誤不一定每次都出現。
CodeLab 的 C++ 直譯器一次只執行一條執行緒,不能執行
std::thread。本課的程式碼請在自己的電腦用 g++ 編譯執行, 「執行緒怎麼交錯」則用下方的模擬器一步一步觀察。
建立執行緒:std::thread 與 join
C++11 起,標準函式庫在 <thread> 提供 std::thread。建立 thread 物件時把要執行的函式(和它的引數)交給它,
新的執行緒就開始執行那個函式:
#include <iostream>
#include <thread>
using namespace std;
void hello(int id) {
cout << "執行緒 " << id << " 開始工作\n";
}
int main() {
thread a(hello, 1); // 建立執行緒 a,執行 hello(1)
thread b(hello, 2); // 建立執行緒 b,執行 hello(2)
a.join(); // 等 a 結束
b.join(); // 等 b 結束
cout << "全部完成\n";
return 0;
}
在 Linux 上用 g++ 編譯時,建議加上 -pthread 選項:g++ -std=c++17 -pthread hello.cpp -o hello。
幾個重點:
- 執行緒一建立就開始跑,和
main同時進行。「執行緒 1」和「執行緒 2」哪一行先印出來不一定,甚至可能兩行的字混在一起 join()讓呼叫它的執行緒停下來,等那條執行緒結束。main要用到執行緒的結果前,一定要先 join- 每個
thread物件被銷毀前,都必須join()(或detach()讓它獨立執行)。如果main結束時還有沒 join 的執行緒,thread的解構子會呼叫std::terminate(),整個程式直接中止 - 執行緒裡的區域變數在它自己的堆疊上,互不干擾;全域變數、static 變數、堆積上的資料是共用的
C++20 另外提供
std::jthread,物件銷毀時會自動 join。
競爭條件:counter++ 不是一個動作
兩條執行緒各把同一個全域變數加 100000 次,結果應該是 200000:
int counter = 0;
void work() {
for (int i = 0; i < 100000; i++) {
counter++;
}
}
int main() {
thread a(work), b(work);
a.join();
b.join();
cout << counter << endl; // 常常小於 200000,而且每次都不一樣
}
問題在於 counter++ 在機器上其實是三個動作:
- 從記憶體讀取
counter的值,放進 CPU 的暫存器 - 暫存器加 1
- 把暫存器的值寫回
counter
如果執行緒 A 讀到 5、還沒寫回之前,執行緒 B 也讀到 5,兩個都算出 6 再寫回,counter 只增加了 1,有一次加法遺失了。
這種「結果取決於執行緒剛好怎麼交錯」的錯誤叫做競爭條件(race condition)。C++ 標準規定:多條執行緒同時存取同一個變數、
而且至少有一個在寫入,又沒有同步保護,就是資料競爭(data race),屬於未定義行為。
下面的模擬器把每個 counter++ 拆成三步。你可以自己決定下一步輪到哪條執行緒,親手製造一次更新遺失。
用 mutex 保護共用資料
mutex(mutual exclusion,互斥鎖)保證同一時間只有一條執行緒能進入被它保護的程式碼(臨界區,critical section):
#include <mutex>
int counter = 0;
mutex m;
void work() {
for (int i = 0; i < 100000; i++) {
lock_guard<mutex> guard(m); // 建立時上鎖,離開這個區塊時自動解鎖
counter++;
}
}
m.lock()取得鎖;鎖已經被別人拿走時,這條執行緒會停下來等,直到對方m.unlock()- 直接呼叫
lock()/unlock()容易漏掉解鎖(例如中途 return 或丟出例外),其他執行緒就會永遠等下去。lock_guard在建構時上鎖、解構時解鎖,離開作用域就一定會解鎖,這是 C++ 常見的 RAII 寫法 - 鎖要保護「讀取、計算、寫回」整個過程,而且所有存取這份資料的地方都要用同一把鎖
- 上鎖會讓執行緒排隊,臨界區越小越好;兩條執行緒互相等對方的鎖,會發生死結(deadlock)
只是要讓一個整數安全地加減時,也可以用 <atomic> 的 std::atomic<int>:atomic<int> counter{0}; 之後的 counter++ 是不可分割的單一動作,不需要另外上鎖。
想一想:執行緒的基本規則
main 建立了 thread t(work);,但結束前沒有呼叫 t.join(),也沒有 t.detach(),會發生什麼事?
兩條執行緒各執行一次 counter++(counter 原本是 0,沒有上鎖),結束後 counter 可能是多少?
執行緒 a、b 分別印出「A」和「B」,main 依序 a.join(); b.join();。關於輸出順序,下列哪一個正確?
想一想:mutex 與 lock_guard
下面哪一段能確保兩條執行緒同時執行 addOne() 時,counter 不會少加?(m 是全域的 mutex)
和直接呼叫 m.lock()、m.unlock() 相比,lock_guard 的主要好處是什麼?
重點回顧
- 一個行程可以有多條執行緒,各有自己的堆疊,共用全域變數與堆積
std::thread t(函式, 引數…)建立並啟動執行緒;用到結果之前t.join()等它結束;沒 join 就被銷毀會呼叫std::terminate()- 執行緒的執行順序由作業系統決定,每次可能不同,程式不能依賴某一種順序
counter++是讀取、加 1、寫回三個動作,多條執行緒同時修改會發生競爭條件,結果遺失更新;沒有保護的資料競爭是未定義行為- 用
std::mutex搭配lock_guard保護臨界區;只是計數器時可以用std::atomic<int>