National Yang Ming Chiao Tung University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

陽明交大 AQI 69 24°C PM2.5 26
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 17

多執行緒

std::thread、join、競爭條件與 mutex
Interaction 25 minute(s)

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++ 在機器上其實是三個動作:

  1. 從記憶體讀取 counter 的值,放進 CPU 的暫存器
  2. 暫存器加 1
  3. 把暫存器的值寫回 counter

如果執行緒 A 讀到 5、還沒寫回之前,執行緒 B 也讀到 5,兩個都算出 6 再寫回,counter 只增加了 1,有一次加法遺失了。 這種「結果取決於執行緒剛好怎麼交錯」的錯誤叫做競爭條件(race condition)。C++ 標準規定:多條執行緒同時存取同一個變數、 而且至少有一個在寫入,又沒有同步保護,就是資料競爭(data race),屬於未定義行為。

下面的模擬器把每個 counter++ 拆成三步。你可以自己決定下一步輪到哪條執行緒,親手製造一次更新遺失。

互動練習:兩個執行緒同時 counter++

每條執行緒各把 counter 加 2 次,正確結果是 4。先照順序完成第一個任務;第二個任務要讓兩條執行緒在「讀取」和「寫回」之間交錯,例如 A 讀取之後先讓 B 讀取。勾選 mutex 之後,counter++ 前後多了 m.lock() 與 m.unlock():拿不到鎖的執行緒只能等待。

用 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++ 是不可分割的單一動作,不需要另外上鎖。

想一想:執行緒的基本規則

1.

main 建立了 thread t(work);,但結束前沒有呼叫 t.join(),也沒有 t.detach(),會發生什麼事?

2.

兩條執行緒各執行一次 counter++(counter 原本是 0,沒有上鎖),結束後 counter 可能是多少?

3.

執行緒 a、b 分別印出「A」和「B」,main 依序 a.join(); b.join();。關於輸出順序,下列哪一個正確?

第 1 次作答

想一想:mutex 與 lock_guard

1.

下面哪一段能確保兩條執行緒同時執行 addOne() 時,counter 不會少加?(m 是全域的 mutex)

2.

和直接呼叫 m.lock()、m.unlock() 相比,lock_guard 的主要好處是什麼?

第 1 次作答

重點回顧

  • 一個行程可以有多條執行緒,各有自己的堆疊,共用全域變數與堆積
  • std::thread t(函式, 引數…) 建立並啟動執行緒;用到結果之前 t.join() 等它結束;沒 join 就被銷毀會呼叫 std::terminate()
  • 執行緒的執行順序由作業系統決定,每次可能不同,程式不能依賴某一種順序
  • counter++ 是讀取、加 1、寫回三個動作,多條執行緒同時修改會發生競爭條件,結果遺失更新;沒有保護的資料競爭是未定義行為
  • 用 std::mutex 搭配 lock_guard 保護臨界區;只是計數器時可以用 std::atomic<int>
這一課完成了!
Next lesson:模板
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。