什么是“線程安全”?
“線程安全”指的是一個函數、方法或代碼塊能夠在多個線程同時執行時,不會出現意外的交互或破壞共享數據,能夠安全地運行。
POSIX 對線程安全的定義很清楚:
“一個線程安全的函數可以在多個線程中被安全地并發調用,無論是與同一個函數的其他調用,還是與其他線程安全函數的調用。”
這意味著,當多個線程同時調用一個函數時,這個函數應該能夠正常工作,而不會導致數據破壞或未定義的行為。
線程安全的例子:
- 非線程安全的例子:
int in[100], out[100]; void thread1() {memcpy(&out, &in, sizeof(in)); } void thread2() {memcpy(&out, &in, sizeof(in)); }
- 在這里,
thread1
和thread2
都在并發訪問同一個out
數組。 - 如果這兩個線程同時調用
memcpy
,就會發生 數據競爭。即一個線程在將數據復制到out
時,另一個線程可能正在修改它,導致不可預測的結果。 - 這個例子 不是線程安全的,因為
out
數組是共享的,并且沒有同步機制來防止多個線程同時訪問它。
- 在這里,
- 線程安全的例子:
void thread1() {int out[100];memcpy(&out, &in, sizeof(in)); } void thread2() {int out[100];memcpy(&out, &in, sizeof(in)); }
- 在這種情況下,每個線程都使用自己獨立的
out
數組。 - 由于每個線程都有自己的
out
數組,因此不會發生線程間的沖突。 - 這個代碼 是線程安全的,因為每個線程都有自己的內存空間,不會互相干擾。
- 在這種情況下,每個線程都使用自己獨立的
我們要“安全”避免什么?
在多線程的上下文中,提到“安全”主要是為了避免 競態條件(race condition)。
什么是 競態條件?
競態條件是指當系統的行為依賴于事件的順序或時機,而這個順序或時機是無法控制的情況下發生的情況。在多線程程序中,當兩個或更多的線程同時訪問共享資源(如內存或變量),并且至少有一個線程修改了這個資源,就可能發生競態條件。
競態條件的例子:
- 線程 A 和 線程 B 都訪問一個共享的計數器。
- 線程 A 讀取計數器的值并將其加 1。
- 線程 B 也讀取了相同的計數器值(在線程 A 寫入更新的值之前),然后將其加 1。
- 最終的計數器值可能沒有反映出兩個線程的增量,因為兩個線程在更新之前都讀取了相同的初始值,導致 更新丟失 或 錯誤的結果。
為什么線程安全很重要?
線程安全確保了在多線程環境中,數據的一致性不會被破壞,并且避免了未定義的行為。如果沒有適當的線程同步,多個線程可能會相互干擾,導致 數據損壞、不預期的行為,甚至 崩潰。
總結:
- 線程安全的代碼確保多個線程可以并發執行而不會發生不安全的交互。
- 它通過在共享數據訪問上進行有效的同步管理,避免了競態條件。
- 通常,使用 互斥鎖、鎖 或 原子操作 等同步機制來保證代碼線程安全。
如果你想要更深入的了解如何讓代碼線程安全,或者有任何問題,歡迎隨時提問!
我們要“安全”避免什么?—— 數據競爭(Data Races)
在多線程編程中,數據競爭(data race)是一個常見且嚴重的問題,它通常會導致不可預測的行為、錯誤的結果,甚至是程序崩潰。
什么是數據競爭?
根據 C++ 標準,一個程序中如果存在數據競爭,那么它包含了兩個潛在的并發沖突操作,其中至少有一個操作是 非原子操作。
更具體地說:
數據競爭是指兩個表達式的評估存在沖突:
- 其中一個表達式修改了某個內存位置
- 另一個表達式在同一時間讀取或修改了同一個內存位置。
數據競爭的例子:
- 修改共享變量的例子:
int i = 0; void thread1() {++i; // 增加 i 的值 } void thread2() {std::cout << i; // 輸出 i 的值 }
- 問題:
thread1
在增量操作i
時,i
的值可能會受到thread2
讀取i
時的影響。- 如果
thread1
和thread2
同時執行,i
的值可能會因為這兩者之間的沖突而變得不確定(例如,thread1
修改了i
,但thread2
可能讀取了它的舊值,或者i
的增量操作沒有被正確執行)。
- 數據競爭的原因:
i
是共享資源,并且沒有同步機制(例如,鎖、原子操作等)來保護對i
的并發訪問。
- 解決方案:
- 使用 原子操作(如
std::atomic
)來確保對i
的訪問是原子性的。 - 或者使用互斥鎖(
std::mutex
)來同步對i
的訪問。
- 使用 原子操作(如
- 問題:
- 修改字符串的例子:
std::string s = ""; void thread1() {s.append("foo"); // 向字符串中添加 "foo" } void thread2() {std::cout << s; // 輸出字符串 s }
- 問題:
thread1
修改了字符串s
,而thread2
同時嘗試讀取s
。- 如果這兩個線程同時執行,
s
可能在thread1
修改時發生沖突,導致 字符串內容不一致 或者 程序崩潰。
- 數據競爭的原因:
s
是共享資源,且沒有任何同步機制來防止線程間的并發訪問。
- 解決方案:
- 使用
std::mutex
來同步對s
的訪問,確保只有一個線程可以修改或讀取s
。 - 或者考慮使用線程安全的數據結構(例如,
std::atomic<std::string>
,雖然這在 C++ 標準庫中并沒有直接支持)。
- 使用
- 問題:
總結:
數據競爭是指兩個或多個線程在并發執行時,至少一個線程對共享數據的訪問是非原子的,且沒有適當的同步機制來確保數據一致性。數據競爭會導致不確定的行為和錯誤的結果,因此我們需要使用鎖、原子操作等同步機制來避免數據競爭。
- 避免數據競爭的關鍵點:
- 確保對共享資源的訪問是原子的。
- 使用合適的同步機制(如
std::mutex
、std::atomic
)。 - 盡量避免多個線程同時修改同一共享資源。
通過這些措施,可以確保代碼在并發執行時的正確性和可靠性。
我們要“安全”避免什么?—— API 競爭(API Races)
除了 數據競爭,程序中的另一個常見并發問題是 API 競爭。API 競爭是指在同一個對象上執行并發操作時,該對象的 API 合同(或約定)并不允許這些操作并發執行。
什么是 API 競爭(API Race)?
API 競爭發生在一個程序執行兩個并發操作,這兩個操作在同一個對象上進行,但該對象的 API 并沒有保證這些操作是安全的,或者沒有提供適當的同步機制。
簡而言之,API 競爭是指多個線程或操作同時在同一個對象上執行時,違反了該對象的使用規則或約定,可能會導致不一致或錯誤的結果。
API 競爭的例子:
std::string s = "";
void thread1() {s.append("foo"); // 向字符串 s 中添加 "foo"
}
void thread2() {std::cout << s; // 輸出字符串 s
}
問題分析:
- 在這個例子中,
s.append("foo")
和std::cout << s
是兩個操作,分別發生在不同的線程中。 - 雖然
std::string
類本身是可以用于多個線程,但在并發訪問時,std::string
的 API 并沒有保證這些操作是線程安全的。thread1
正在修改字符串s
。thread2
正在讀取字符串s
。
- 這些操作如果同時執行,可能會導致API 競爭,因為
std::string
沒有內建機制來同步并發訪問。這樣會導致以下問題:- 字符串的修改和讀取之間可能會發生沖突,導致讀取到不一致的值。
- 在某些情況下,可能會發生內存損壞或者崩潰。
API 競爭的原因:
std::string
的 API 并沒有保證多線程環境下并發修改或讀取的安全性。- 沒有同步機制來保證
append
和<<
操作不會互相干擾。
解決方案:
- 使用互斥鎖(mutex):使用
std::mutex
來保證在某個時刻,只有一個線程能夠訪問和修改s
。std::mutex mtx; std::string s = ""; void thread1() {std::lock_guard<std::mutex> lock(mtx);s.append("foo"); } void thread2() {std::lock_guard<std::mutex> lock(mtx);std::cout << s; }
- 使用線程安全的類或 API:如果需要多線程操作共享數據,選擇專門設計為線程安全的類,或者自己實現相應的同步機制。
總結:
API 競爭是指在并發執行時,程序操作的對象的 API 合同并沒有明確保障多個操作能夠安全地并發執行。為了避免 API 競爭,我們需要確保:
- API 的約定:明確使用對象時,它的 API 是否支持并發操作,或者需要采取額外的同步措施。
- 同步機制:通過互斥鎖、條件變量等同步工具,確保在多線程環境下共享資源的安全訪問。
通過合理的同步機制和正確理解 API 合同,可以有效避免 API 競爭,提高程序的并發安全性。
識別 API 競爭(API Races)
API 競爭發生在多個線程同時操作同一個對象時,而該對象的 API 合同并不支持這種并發操作。識別 API 競爭是確保多線程程序安全的關鍵部分。下面我們來通過幾個例子詳細探討如何識別 API 競爭。
例 1: API 競爭示例 — 在同一對象上調用不同方法
Widget shared_widget;
void thread1() {// 線程1調用 shared_widget 的 foo() 方法shared_widget.foo();
}
void thread2() {// 線程2調用 shared_widget 的 bar() 方法shared_widget.bar();
}
分析:
- 問題:
shared_widget
對象被多個線程同時操作,foo()
和bar()
方法可能對同一個數據進行修改。由于Widget
類的 API 沒有保證這兩個方法的線程安全,這可能導致數據競爭(Data Race),比如兩個線程同時修改shared_widget
內部的成員變量,從而導致數據不一致。 - 解決方案:
- 確保
foo()
和bar()
方法是線程安全的。 - 使用互斥鎖(
std::mutex
)保護對shared_widget
對象的訪問,確保一次只有一個線程能夠調用這兩個方法。
- 確保
例 2: API 競爭示例 — 通過不同的函數調用同一對象
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget); // 線程1調用 t.foo(shared_widget)
}
void thread2() {Whatever w;w.bar(shared_widget); // 線程2調用 w.bar(shared_widget)
}
分析:
- 問題:
shared_widget
被兩個線程分別傳遞給Thingy
和Whatever
類型的對象,并通過它們調用方法。這種情形依然可能導致 API 競爭,尤其是在foo()
和bar()
方法內部執行對shared_widget
的修改操作時。 - 解決方案:
- 讓
Thingy::foo()
和Whatever::bar()
方法同步訪問shared_widget
,通過互斥鎖等同步機制確保同一時刻只有一個線程能訪問該對象。 - 如果方法內部沒有修改
shared_widget
,那么可以認為是線程兼容的,但仍需要確保沒有其他競爭條件。
- 讓
例 3: 線程安全的設計 — 使用互斥鎖避免競爭條件
// 線程安全的 JobRunner 類
class JobRunner {JobSet running_;JobSet done_;std::mutex m_; // 用于同步的互斥鎖void OnJobDone(Job* job) {m_.lock();running_.erase(job); // 在互斥鎖保護下操作 running_ 集合done_.insert(job); // 在互斥鎖保護下操作 done_ 集合m_.unlock();}
};
// 線程安全的 JobSet 類
class JobSet {std::set<Job*> jobs_;std::mutex m_; // 用于同步的互斥鎖void erase(Job* job) {m_.lock();jobs_.erase(job); // 線程安全地操作 jobs_ 集合m_.unlock();}
};
分析:
- 問題解決:在這個設計中,
JobRunner
和JobSet
類通過使用std::mutex
來同步對共享數據的訪問,確保了并發操作的安全性。這樣,多個線程可以安全地同時調用OnJobDone()
和erase()
方法,而不會引起競爭條件。
例 4: 數據類型的線程安全
int shared_int;
void thread1() {Thingy t;t.foo(shared_int); // 線程1調用 foo() 方法
}
void thread2() {Whatever w;w.bar(shared_int); // 線程2調用 bar() 方法
}
void Thingy::foo(int i) {// 對 shared_int 進行修改
}
void Whatever::bar(const int& i) {// 讀取 shared_int
}
分析:
- 問題:如果
Thingy::foo
和Whatever::bar
方法都對shared_int
進行修改,或者至少是并發讀取,它們之間就可能會發生 API 競爭,尤其在shared_int
沒有進行同步保護的情況下。 - 解決方案:
- 對
shared_int
進行同步保護,確保同一時刻只有一個線程能夠修改或讀取它。 - 可以使用互斥鎖來保護對
shared_int
的訪問,或者使用std::atomic
來確保并發操作時的原子性。
- 對
例 5: 線程兼容的對象
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget);
}
void Thingy::foo(const Widget& widget) {// 對 widget 進行只讀操作,不修改 shared_widget
}
void thread2() {Whatever w;w.bar(shared_widget);
}
void Whatever::bar(const Widget& widget) {// 對 widget 進行只讀操作,不修改 shared_widget
}
分析:
- 問題:如果
shared_widget
是一個線程兼容的類型,并且在foo()
和bar()
方法中僅進行讀取操作,而不進行修改,則不會發生 API 競爭。因為此時沒有線程同時修改shared_widget
,因此不會產生并發修改的問題。 - 解決方案:確保在多線程環境下,只讀訪問共享對象,而不進行修改。這樣,即使多個線程同時訪問該對象,也不會引發競爭條件。
總結:
- API 競爭的核心問題:當多個線程在同一時刻訪問同一對象,而該對象的 API 合同不支持并發操作時,就會產生 API 競爭。
- 如何避免 API 競爭:
- 使用 互斥鎖(
std::mutex
)來同步對共享對象的訪問。 - 如果操作是只讀的,并且對象類型支持線程兼容(如
std::atomic
),則可以避免競爭。 - 設計線程安全的 API 或類型,確保在多線程環境下可以安全并發執行。
- 使用 互斥鎖(
- 識別 API 競爭:
- 如果對象類型不是線程安全的,或者操作沒有同步機制,便會發生 API 競爭。
- 如果對象的操作不符合 API 合同(例如不保證線程安全),則需要加鎖或其他同步措施。
通過正確的同步和線程安全設計,可以有效避免 API 競爭,保證多線程程序的安全性和穩定性。
識別 API 競爭(API Races)
API 競爭指的是多個線程在同一時刻并發訪問同一對象的情況,導致不可預期的行為,尤其當該對象的 API 合同不支持并發時。正確識別并避免 API 競爭是確保多線程程序正確性和穩定性的關鍵。以下是通過幾個例子進一步理解 API 競爭及其避免方法。
例 1: 線程安全的 LazyStringView
class LazyStringView {const char* data_;mutable std::optional<size_t> size_;mutable std::mutex mu_;
public:size_t size() const {std::scoped_lock lock(mu_); // 通過 scoped_lock 確保線程安全if (!size_) {size_ = strlen(data_);}return *size_;}
};
分析:
- 問題:
LazyStringView
類的size()
方法需要訪問size_
和data_
成員變量,這在多線程環境下是潛在的并發問題源。為了確保線程安全,在size()
方法中使用了std::mutex
來加鎖,確保每次只有一個線程能修改size_
。 - 解決方案:通過
std::scoped_lock
確保在訪問size_
和data_
時是互斥的。這樣可以確保即使多個線程同時調用size()
,也不會發生競爭條件。
例 2: 競爭條件示例 — 不同線程調用不同方法
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget); // 線程1調用 foo(shared_widget)
}
void thread2() {Whatever w;w.bar(shared_widget); // 線程2調用 bar(shared_widget)
}
分析:
- 問題:在這個例子中,
shared_widget
是一個共享對象,多個線程同時調用不同的方法可能導致競爭條件。假如foo()
和bar()
方法都操作了shared_widget
,且這些方法沒有進行適當的同步,那么就會發生數據競爭。 - 解決方案:可以使用互斥鎖(
std::mutex
)對對shared_widget
的訪問進行保護,確保同一時刻只有一個線程能夠操作它。
例 3: 不同線程調用相同對象的不同方法
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget);
}
void Thingy::foo(const Widget& widget) {// 對 widget 的操作
}
void thread2() {Whatever w;w.bar(shared_widget);
}
void Whatever::bar(const Widget& widget) {// 對 widget 的操作
}
分析:
- 問題:
shared_widget
可能在兩個不同線程中同時被傳遞給foo()
和bar()
方法。如果這兩個方法都試圖訪問并修改shared_widget
,且沒有適當的同步機制,就會發生競爭條件。 - 解決方案:確保
foo()
和bar()
方法在訪問shared_widget
時是線程安全的。可以使用互斥鎖來同步訪問,或者將Widget
設計為線程安全類型。
例 4: 線程不安全的函數調用
void Thingy::foo(const Widget&) {static int counter = 0;counter++; // 修改靜態變量,可能導致數據競爭
}
void Whatever::bar(const Widget&) {static int counter = 0;counter++; // 修改靜態變量,可能導致數據競爭
}
分析:
- 問題:
foo()
和bar()
方法都在修改靜態變量counter
,如果兩個線程同時調用這兩個方法,且它們都試圖修改counter
,就會發生數據競爭。 - 解決方案:將靜態變量
counter
設計為線程安全的,或者使用互斥鎖保護對counter
的訪問,避免數據競爭。
線程安全與線程兼容類型
1. 線程安全的類型:
- 如果對象的類型本身是線程安全的(例如使用
std::mutex
等機制進行同步),則該對象在多線程環境下是安全的,不能成為 API 競爭的源頭。
2. 線程兼容的類型:
- 如果對象類型是線程兼容的(例如,只進行只讀操作,不會改變內部狀態),并且沒有被并發修改,那么該對象在并發訪問下是安全的。
3. 非線程安全類型:
- 如果一個對象的類型在多線程環境下沒有設計為線程安全,且多個線程同時對它進行修改操作,那么就會發生 API 競爭。這種對象必須加以同步或避免并發訪問。
總結:
- 線程安全的類型:如果一個對象的類型是線程安全的(如通過互斥鎖保護),它不能成為 API 競爭的源頭。
- 線程兼容的類型:如果對象的狀態不會在并發訪問中被修改,那么即使多個線程同時訪問該對象,也不會發生 API 競爭。
- 靜態變量與線程安全:靜態變量在多線程環境中需要特別注意,如果多個線程并發訪問并修改它們,可能會導致數據競爭。可以通過互斥鎖或其他同步機制來避免這個問題。
通過合理的設計和同步機制,我們可以有效避免 API 競爭,確保多線程程序的安全性和正確性。
識別 API 競爭條件(API Races)
API 競爭(API Race)指的是在多線程環境中,多個線程并發訪問同一對象并試圖對其進行操作,導致不一致的結果或不可預測的行為。為避免這些問題,我們需要確保訪問共享數據時采取正確的同步機制。下面是一些典型的案例以及如何判斷和避免 API 競爭。
如何確保沒有 API 競爭條件
一行代碼如果要保證沒有 API 競爭條件,必須滿足以下條件:
- 不調用線程敵對函數(thread-hostile functions)。
- 所有輸入參數必須是活動的(live),即沒有被銷毀或處于非法狀態。
- 每個輸入對象必須滿足下列其中一個條件:
- 不被其他線程訪問。
- 線程安全(thread-safe):即可以在多線程環境下安全使用。
- 線程兼容(thread-compatible)且 不會被任何線程修改。
示例 1: 數據競爭(Data Race)
代碼示例:
vector<int> shared_vec = {0, 0};
void thread1() {// 修改第一個元素++shared_vec[0];
}
void thread2() {// 修改第二個元素++shared_vec[1];
}
分析:
- 問題:多個線程對同一
shared_vec
進行操作,雖然它們操作的是不同的元素([0]
和[1]
),但如果沒有合適的同步,可能會發生數據競爭。 - 解決方案:對于不同的線程修改同一共享數據的情況,確保每個操作的同步性,或者確保每個線程訪問的數據是獨立的且不發生沖突。
示例 2: 線程不安全的 vector<bool>
代碼示例:
vector<bool> shared_vec = {false, false};
void thread1() {// 修改第一個元素shared_vec[0] = true;
}
void thread2() {// 修改第二個元素shared_vec[1] = true;
}
分析:
- 問題:
vector<bool>
是一個特別的案例,它并沒有真正存儲bool
類型,而是以壓縮的方式存儲,導致其并不是線程安全的。在多個線程并發寫入時會發生數據競爭。 - 解決方案:避免使用
vector<bool>
,或者確保線程間訪問時的同步。
示例 3: 對 vector<int>
的并發修改
代碼示例:
vector<int> shared_vec = {0, 0};
void thread1() {// 修改第一個元素++shared_vec[0];
}
void thread2() {// 修改第二個元素++shared_vec[1];
}
分析:
- 問題:類似前面的例子,盡管
shared_vec[0]
和shared_vec[1]
是不同的元素,但它們仍然是shared_vec
的成員,可能會發生數據競爭。 - 解決方案:通過鎖(如
std::mutex
)來同步對shared_vec
的訪問,或者在訪問時確保不會發生沖突。
示例 4: 并發訪問 std::vector<int>
的部分范圍
代碼示例:
// 對迭代器區間內的每個元素進行加 1
template <typename Iterator>
void f(Iterator begin, Iterator end) {for (Iterator it = begin; it != end; ++it)++*it;
}
vector<int> v = {1, 2, 3};
void thread1() {f(v.begin(), v.begin() + 2); // 修改前兩個元素
}
void thread2() {f(v.begin() + 1, v.end()); // 修改后兩個元素
}
分析:
- 問題:這段代碼在兩個線程中并發執行,
thread1
和thread2
都試圖修改v
中的不同部分([0, 1]
和[1, 2]
)。這意味著兩者在操作相同的元素時會發生數據競爭。 - 解決方案:確保在訪問共享資源時加鎖,或者重構代碼避免同時操作共享數據。
示例 5: 線程不安全的成員函數
代碼示例:
class Widget {int* counter_;
public:Widget(int* counter) : counter_(counter) {}// 線程不安全!void Twiddle() {++*counter_;}
};
Widget MakeWidget();
void thread1() {Widget w = MakeWidget();w.Twiddle();
}
void thread2() {Widget w = MakeWidget();w.Twiddle();
}
分析:
- 問題:多個線程可能同時調用
Twiddle()
,而counter_
是一個指向共享數據的指針。如果沒有同步機制,可能會導致數據競爭。 - 解決方案:使用鎖來保護
counter_
的訪問,確保每次只有一個線程可以修改共享的counter_
。
推薦的最佳實踐
對于庫代碼:
- 使類型線程兼容(thread-compatible):盡量設計為線程兼容,如果有必要,再設計為線程安全(thread-safe)。
- 明確文檔化:清晰地文檔化哪些類型是線程安全的,哪些是線程不兼容的。推薦將其他類型顯式標注為線程兼容(thread-compatible)。
- 避免暴露內部狀態:在設計中小心地暴露子對象,尤其是不可變的數據。
- 避免使用線程不安全的函數:如避免隱式共享的可變狀態,避免使用指向共享數據的私有指針。
對于應用程序代碼:
- 使共享對象線程安全:或者確保它們是線程兼容且不可變的。不要讓它們成為并發操作的源頭。
總結
- 線程安全:在多線程環境中,不會因并發操作導致數據損壞或未定義行為。使用鎖、原子操作等技術來確保線程安全。
- 線程兼容:即對象可以在多個線程中并發訪問,但不會被修改。如果沒有修改操作,可以認為是線程兼容的。
- API 競爭:多個線程對同一對象的不同操作可能會導致不一致的結果。通過合理設計數據訪問和同步機制,可以避免這種情況。
總之,確保多線程程序的正確性需要考慮對象的訪問權限、線程安全性以及同步機制,尤其是避免并發訪問導致的 API 競爭條件。