iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
自我挑戰組

韌體工程師的不只 0x10 個問題系列 第 25

Day 25-解決資料競爭,不能解決競賽條件

  • 分享至 

  • xImage
  •  

以往初學「競賽條件(race condition)」時,用的其實都是 Day 23 提到的「資料競爭(data race)」當例子,但在本次參加鐵人賽的過程中,我才發現這兩者其實不同,解決資料競爭不能解決競賽條件。

「資料競爭」與「競賽條件」

Day 23 提到的「資料競爭」指的是有複數個執行緒共用同一記憶體資源、且至少有一個執行緒的行為是「寫入」,使執行結果不如預期。

「競賽條件」則是程式邏輯的錯誤,即便沒有多執行緒共享資料,還是可能發生。

以下將說明為何用 Day 24 講的方法解決「資料競爭」後,「競賽條件」依然可能發生。

互斥鎖 Mutex

以下程式碼用互斥鎖避免資料競爭,但競賽條件仍在:

#include <iostream>
#include <queue> 
#include <mutex>
#include <thread>
#include <vector>

class RaceConditionQueue {
private:
    std::queue<int> data; 
    std::mutex mtx;

public:
    RaceConditionQueue() { data.push(42); } 

    bool empty() {
        std::lock_guard<std::mutex> lock(mtx);
        return data.empty();
    }

    int front() {
        std::lock_guard<std::mutex> lock(mtx);
        return data.front(); 
    }

    void pop() {
        std::lock_guard<std::mutex> lock(mtx);
        data.pop(); 
    }
};

RaceConditionQueue q;

void worker(int thread_id) {
    if (!q.empty()) {       
        int value = q.front(); 
        q.pop();               
        
        std::cout << "Thread ID " << thread_id << " got " << value << '\n';
    } else {
        std::cout << "Thread " << thread_id << " found queue empty。\n";
    }
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);

    t1.join();
    t2.join();

    std::cout << "End\n";
    return 0;
}

這裡的 RaceConditionQueue 在每種操作 empty()front()pop() 中,都有用互斥鎖把 data 保護起來,不會有複數個執行緒同時操作 data 的問題,因此不會產生資料競爭。

然而,互斥鎖保護的時機是在執行 empty()front()pop() 時,在 worker() 中是各自分開的:

void worker(int thread_id) {
    if (!q.empty()) {            // 做 empty() 時上鎖保護 data,做完就解鎖
        int value = q.front();   // 做 front() 時上鎖保護 data,做完就解鎖
        q.pop();                 // 做 pop() 時上鎖保護 data,做完就解鎖          
        // ...
}

這代表執行順序可能為:

  1. 執行緒 t1 執行完它的 q.empty(),還沒來得及處理到 q.front()
  2. 另一個執行緒 t2 執行完 q.empty()q.front()q.pop()
  3. 所以等 t1 要做自己的 q.front() 時,是空的,直接噴錯。

而不能跑出如期待中的:

  1. t1 依序做完自己的 q.empty()q.front()q.pop()
  2. t2 再依序做完自己的 q.empty()q.front()q.pop()

原子操作

在下列程式碼中,tickets 變數被綁定原子特性,但依然有競賽條件發生的可能:

#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>
#include <vector>

std::atomic<int> tickets{1};

void buyTicket(int thread_id) {
    if (tickets > 0) {    // 原子特性只在 if 條件中保護,判斷完 if 條件就放開
        
        // 原子特性沒有把 if(tickers > 0) 跟 --tickets 兩個行為視為一體
        
        --tickets;  // 原子特性只保護 --tickets 過程本身
        
        std::cout << "Thread " << thread_id << " got the ticket!\n";
    } else {
        std::cout << "Thread " << thread_id << " found ticket sold out.\n";
    }
}

int main() {
    std::cout << "Initially there are " << tickets << " ticket(s)\n";

    std::thread t1(buyTicket, 1);
    std::thread t2(buyTicket, 2);

    t1.join();
    t2.join();

    std::cout << "Eventually there are " << tickets << " tickets.\n";
    
    if (tickets< 0) {
        std::cout << "Not having enough tickets.\n";
    }

    return 0;
}

這裡最大的問題是,tickets 的原子特性在 if (tickets > 0) 跟後面的 --tickets; 是各自處理的,沒有把兩個行為視為一體,所以執行順序可能如下:

  1. 執行緒 t1 判斷 tickets > 0 成立,還來不及進行 --tickets
  2. 執行緒 t2 插隊進來做完 tickets > 0--tickets
  3. 執行緒 t1 要去做 --tickets 時,票數已經被前一步 t2 多扣了一張。

雖然不會在 --tickets 中,還沒完整做完「讀取、扣 1、寫入」三步驟就被另一個執行緒插隊、形成「資料競爭」,但還是會出現競賽條件。

條件變數

在以下範例中,q 跟旗標 done 都被 mtx 保護住,不會發生 t1 執行緒修改兩變數之一尚未完成時,同樣的變數就被另一執行緒 t2 搶走的狀況,但依然會發生競賽條件:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;
bool done = false;

void consumer() {
    // 消費者的鎖保護 q 跟旗標 done
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return !q.empty() || done; }); // q 不為空或 done 為 true 都會被喚醒
    
    // 但這裡的條件判斷只有讀 done
    if (done) {
        return;
    }
    
    // 當 done 為 true 時,根本不會執行這裡
    int value = q.front();
    q.pop();
    std::cout << "consume: " << value << '\n';
}

void producer() {
    {
        // 生產者的鎖保護住 q 跟旗標 done
        std::lock_guard<std::mutex> lock(mtx);
        q.push(42);
        done = true;
    }
    cv.notify_all();
}

int main() {
    std::thread t1(consumer);
    std::thread t2(producer);

    t1.join();
    t2.join();

    return 0;
}

這段程式碼基本上不會顯示任何結果,其原因是程式的邏輯瑕疵,在 consumer 中,只要 done 為 true 都會直接 return,最下面包含 std::cout << "consume: " << value << '\n'; 的程式碼永遠不會執行到。

互斥鎖、原子操作與條件變數雖然能避免「資料競爭」,但若程式邏輯有瑕疵,依然不能避免「競賽條件」,這兩者不能混為一談。

參考資料

  1. What Is Time of Check Time of Use (TOCTOU)? Explained (DeepStrike)
  2. Swift 並行安全完全指南:一次搞懂 Data Races 與 Race Conditions 的關鍵差異 (方格子)

上一篇
Day 24-資料競爭的解決方法
下一篇
Day 26-死結與解決方式
系列文
韌體工程師的不只 0x10 個問題31
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言