以往初學「競賽條件(race condition)」時,用的其實都是 Day 23 提到的「資料競爭(data race)」當例子,但在本次參加鐵人賽的過程中,我才發現這兩者其實不同,解決資料競爭不能解決競賽條件。
Day 23 提到的「資料競爭」指的是有複數個執行緒共用同一記憶體資源、且至少有一個執行緒的行為是「寫入」,使執行結果不如預期。
「競賽條件」則是程式邏輯的錯誤,即便沒有多執行緒共享資料,還是可能發生。
以下將說明為何用 Day 24 講的方法解決「資料競爭」後,「競賽條件」依然可能發生。
以下程式碼用互斥鎖避免資料競爭,但競賽條件仍在:
#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,做完就解鎖
// ...
}
這代表執行順序可能為:
t1 執行完它的 q.empty(),還沒來得及處理到 q.front()。t2 執行完 q.empty() 、 q.front() 、 q.pop()。t1 要做自己的 q.front() 時,是空的,直接噴錯。而不能跑出如期待中的:
t1 依序做完自己的 q.empty() 、 q.front() 、 q.pop()。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; 是各自處理的,沒有把兩個行為視為一體,所以執行順序可能如下:
t1 判斷 tickets > 0 成立,還來不及進行 --tickets。t2 插隊進來做完 tickets > 0 與 --tickets。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'; 的程式碼永遠不會執行到。
互斥鎖、原子操作與條件變數雖然能避免「資料競爭」,但若程式邏輯有瑕疵,依然不能避免「競賽條件」,這兩者不能混為一談。