閱讀導航
- 引言
- 一、線程同步
- 1. 競態條件的概念
- 2. 線程同步的概念
- 二、條件變量
- 1. 條件變量函數
- ?使用前提
- (1)初始化條件變量
- (2)等待條件滿足
- (3)喚醒等待
- pthread_cond_broadcast()
- pthread_cond_signal()
- (4)銷毀條件變量
- 2. 條件變量使用規范
- (1)條件變量的使用流程
- (2)條件變量的使用注意事項
- 3. 使用條件變量的示例
- 三、線程安全
- 1. 概念
- 2. 常見的線程不安全的情況
- 3. 常見的線程安全的情況
- 4. 可重入與線程安全的關系(八股文)
- (1)可重入與線程安全的聯系
- (2)可重入與線程安全的區別
- 溫馨提示
引言
在上一篇文章中,我們詳細探討了多線程編程的基礎概念,包括線程互斥、互斥鎖以及死鎖和資源饑餓等問題。我們了解到,在多線程環境下,為了防止數據競爭和保證程序的正確性,需要采用一定的同步機制來協調線程之間的執行順序。本篇文章將繼續深入探討多線程編程中的另一組關鍵概念:線程同步、條件變量和線程安全。
在這篇文章中,我們將具體介紹線程同步的技術和模式,探討條件變量的工作原理以及如何在實際編程中正確使用它們來避免競態條件和提高程序效率。同時,我們還將分析線程安全的概念,并通過示例展示如何編寫線程安全的代碼,以確保多線程程序的可靠性和穩定性。隨著對這些概念的深入理解,我們將能夠更加熟練地掌握多線程編程,打造出更加健壯和高效的軟件系統。
一、線程同步
1. 競態條件的概念
競態條件(Race Condition)是并發編程中的一個重要概念,它指的是程序的輸出或行為依賴于事件或線程的時序。在多線程環境中,如果多個線程共享某些數據,并且它們試圖同時讀寫這些數據而沒有適當的同步機制來協調這些操作,就可能出現競態條件。
簡單來說,當兩個或更多的線程訪問共享數據,并且至少有一個線程在修改這些數據時,如果線程之間的執行順序會影響最終的結果,那么就存在競態條件。由于線程調度通常由操作系統進行,而且具有一定的隨機性,因此競態條件可能導致程序行為不可預測,有時候甚至非常難以復現和調試。
競態條件的一個典型例子是“檢查后行動”(check-then-act)操作,其中線程檢查某個條件(如資源是否可用),然后基于這個條件采取行動。如果在檢查和行動之間的時間窗口內,另一個線程改變了條件(如搶占了資源),那么第一個線程的行動可能基于錯誤的假設。
另一個常見的競態條件是“讀-改-寫”(read-modify-write)操作,這涉及到讀取一個變量的值,對其進行修改,然后寫回新值。如果兩個線程同時執行這樣的操作,而且它們的讀取和寫入操作是交織在一起的,那么最終寫回的值可能只反映了其中一個線程的修改,而另一個線程的修改則丟失了。
為了避免競態條件,我們需要使用線程同步機制,如互斥鎖、信號量、條件變量等,來確保在任何時刻只有一個線程能夠訪問臨界區的代碼。通過這種方式,可以序列化對共享資源的訪問,從而避免不確定的時序和數據沖突,保證程序的正確性和穩定性。
2. 線程同步的概念
線程同步是指在多線程環境中,控制不同線程之間的執行順序,確保它們能夠有序地共享資源和協調工作的一系列機制和方法。當多個線程訪問共享資源時,如果沒有適當的同步,就可能發生競態條件(Race Condition),導致數據不一致、程序錯誤甚至崩潰。
為了防止這些問題,線程同步提供了一種方式,使得在任何時刻只有一個線程可以訪問到臨界區(Critical Section)。臨界區是指那些訪問共享資源的代碼段,這些資源可能是內存、文件或者其他外部狀態。通過線程同步,我們可以確保每次只有一個線程可以操作臨界區內的共享資源,從而避免非預期的交互和數據沖突。
二、條件變量
條件變量是一種同步原語,它用于線程間的通信,使得一個線程能夠在某個特定條件不滿足時掛起(等待),直到另一個線程更新了這個條件并通知等待的線程。條件變量通常與互斥鎖(mutex)一起使用,以避免競態條件,并確保數據的一致性。
1. 條件變量函數
?使用前提
在Linux環境下,使用條件變量相關的函數需要包含<pthread.h>
頭文件:
#include <pthread.h>
<pthread.h>
頭文件中定義了所有與POSIX線程相關的數據類型、函數原型和宏。這包括了條件變量的操作函數、互斥鎖的操作函數以及線程創建和控制的函數。
當編譯使用了 <pthread.h>
的程序時,通常需要鏈接線程庫,這可以通過在編譯命令中添加 -lpthread
選項來實現。例如:
gcc program.c -o program -lpthread
這條命令會編譯 program.c
文件,并將POSIX線程庫鏈接到生成的可執行文件 program
中。
(1)初始化條件變量
在POSIX線程(pthreads)庫中,條件變量可以通過兩種方式進行初始化:
-
靜態初始化:使用預定義的宏
PTHREAD_COND_INITIALIZER
來初始化條件變量。這是在程序開始執行之前,即編譯時期就已經完成的初始化。示例代碼:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
-
動態初始化:使用函數
pthread_cond_init()
在運行時動態地初始化條件變量。這種方式允許你指定條件變量的屬性。示例代碼:
pthread_cond_t cond; int ret = pthread_cond_init(&cond, NULL); // 使用NULL作為屬性參數,表示默認屬性 if (ret != 0) {// 錯誤處理 }
在動態初始化的情況下,如果你想要設置特定的條件變量屬性,可以創建一個 pthread_condattr_t
類型的變量,并使用 pthread_condattr_init()
和相關函數來設置所需的屬性。之后,將這個屬性變量傳遞給 pthread_cond_init()
函數。
不論是靜態還是動態初始化,初始化后的條件變量都處于未信號化的狀態,等待被 pthread_cond_signal()
或 pthread_cond_broadcast()
函數喚醒。
(2)等待條件滿足
pthread_cond_wait
函數是POSIX線程庫中用于等待條件變量的函數。它的作用是阻塞調用線程直到指定的條件變量被信號化。在等待期間,pthread_cond_wait
會自動釋放與條件變量相關聯的互斥鎖,并且在條件變量被信號化后重新獲取互斥鎖。
pthread_cond_wait
函數的原型:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
參數解釋:
cond
:指向需要等待的條件變量的指針。mutex
:指向當前線程已鎖定的互斥鎖的指針,必須在調用pthread_cond_wait
之前由線程鎖定。
返回值:
- 如果函數成功,返回0。
- 如果失敗,將返回一個錯誤碼(非零值)。
使用說明
- 線程在調用
pthread_cond_wait
之前,必須確保已經鎖定了mutex
互斥鎖。 - 調用
pthread_cond_wait
后,線程會阻塞,并且mutex
互斥鎖被自動釋放,以允許其他線程操作條件和互斥鎖。 - 當其他線程對條件變量調用
pthread_cond_signal
或pthread_cond_broadcast
時,等待的線程會被喚醒。 - 被喚醒的線程在返回前將重新獲取
mutex
互斥鎖。這意味著當pthread_cond_wait
返回時,線程已經再次鎖定了mutex
。 - 因為可能有多個線程在等待同一個條件變量,所以即使線程被喚醒,也不能假設條件已經滿足。通常需要在循環中調用
pthread_cond_wait
來重新檢查條件。
示例代碼
// 假設已經聲明并初始化了cond和mutex
pthread_mutex_lock(&mutex);
while (condition_is_not_met)
{pthread_cond_wait(&cond, &mutex);
}
// 此時condition_is_met為真,可以執行依賴于該條件的代碼
pthread_mutex_unlock(&mutex);
在這個示例中,線程首先鎖定互斥鎖mutex
,然后在一個循環中檢查條件是否滿足。如果條件不滿足,線程調用pthread_cond_wait
等待條件變量cond
。當條件變量被其他線程信號化時,線程將被喚醒,并在重新獲得互斥鎖后繼續執行。
(3)喚醒等待
pthread_cond_broadcast
和 pthread_cond_signal
函數都是用來喚醒等待特定條件變量的線程。它們的區別在于喚醒等待線程的數量。
pthread_cond_broadcast()
pthread_cond_broadcast
函數喚醒所有等待特定條件變量的線程。如果沒有線程在等待,調用此函數不會有任何效果。
函數原型如下:
int pthread_cond_broadcast(pthread_cond_t *cond);
參數解釋:
cond
:指向需要廣播信號的條件變量的指針。
返回值:
- 如果函數成功,返回0。
- 如果失敗,將返回一個錯誤碼(非零值)。
pthread_cond_signal()
與pthread_cond_broadcast
不同,pthread_cond_signal
函數只喚醒一個正在等待特定條件變量的線程。如果有多個線程在等待,系統選擇一個線程喚醒。選擇哪個線程通常取決于線程調度策略,程序員無法控制。
函數原型如下:
int pthread_cond_signal(pthread_cond_t *cond);
參數解釋:
cond
:指向需要發送信號的條件變量的指針。
返回值:
- 如果函數成功,返回0。
- 如果失敗,將返回一個錯誤碼(非零值)。
使用說明
- 在調用
pthread_cond_signal
或pthread_cond_broadcast
之前,通常需要鎖定與條件變量相關聯的互斥鎖。 - 調用這些函數后,互斥鎖可以被釋放,以便喚醒的線程可以繼續執行。
- 喚醒的線程將嘗試重新獲取互斥鎖,一旦獲取成功,它們就可以檢查條件是否滿足并繼續執行。
示例代碼
// 假設已經聲明并初始化了cond和mutex
pthread_mutex_lock(&mutex);
// 更新條件并可能修改共享資源
condition_met = 1;
// 喚醒所有等待cond的線程
pthread_cond_broadcast(&cond);
// 或者,只喚醒至少一個等待cond的線程
// pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
在這個示例中,線程首先鎖定互斥鎖mutex
,然后更新條件變量相關的條件。之后,使用pthread_cond_broadcast
或pthread_cond_signal
來喚醒等待該條件變量的線程。最后,線程解鎖互斥鎖。
(4)銷毀條件變量
銷毀條件變量是指在條件變量不再需要時,釋放它所占用的資源。在POSIX線程(pthreads)庫中,可以使用 pthread_cond_destroy
函數來銷毀一個條件變量。
pthread_cond_destroy
函數的原型:
int pthread_cond_destroy(pthread_cond_t *cond);
參數解釋:
cond
:指向需要銷毀的條件變量的指針。
返回值:
- 如果函數成功,返回0。
- 如果失敗,將返回一個錯誤碼(非零值)。
使用說明
- 在調用
pthread_cond_destroy
之前,必須確保沒有線程正在等待或即將等待條件變量。否則,行為是未定義的,并且可能會導致程序崩潰或其他錯誤。 - 通常,在動態初始化的條件變量不再需要時調用
pthread_cond_destroy
。對于靜態初始化的條件變量,如果沒有分配額外的資源,則可以不調用pthread_cond_destroy
。 - 一旦條件變量被銷毀,你應該避免再次使用它,除非它被重新初始化。
示例代碼
// 假設 cond 是一個之前已經初始化的條件變量
int ret = pthread_cond_destroy(&cond);
if (ret != 0)
{// 錯誤處理
}
在這個示例中,cond
是一個先前已經初始化并且現在不再需要的條件變量。通過調用 pthread_cond_destroy
來銷毀它,從而釋放可能分配的資源。如果銷毀過程中出現錯誤,可以根據返回的錯誤碼進行相應的錯誤處理。
2. 條件變量使用規范
條件變量的運行機制基于兩個主要操作:等待(wait)和通知(signal/broadcast)。
(1)條件變量的使用流程
-
等待條件(Waiting for a Condition):
- 線程首先獲取與條件變量關聯的互斥鎖。
- 線程檢查某個條件是否滿足。如果條件不滿足,線程將進入等待狀態,并且原子地釋放互斥鎖,這樣其他線程就可以獲取互斥鎖來更改條件。
- 當條件變量收到通知后,線程被喚醒,重新嘗試獲取互斥鎖。一旦獲取到鎖,線程將再次檢查條件是否滿足,以防在等待期間條件發生了變化。
-
通知等待線程(Notifying Waiting Threads):
- 另一個線程在更改了條件后,會獲取相同的互斥鎖。
- 在保持互斥鎖的情況下,該線程更新條件。
- 更新完畢后,線程通過條件變量發送通知,表示條件已經改變。通知操作有兩種形式:
signal
:喚醒至少一個等待該條件變量的線程。broadcast
:喚醒所有等待該條件變量的線程。
-
重新檢查條件(Rechecking the Condition):
- 被喚醒的線程在從等待狀態返回時需要重新獲得之前釋放的互斥鎖。
- 一旦鎖被重新獲得,線程應該再次檢查條件,因為在多個線程等待相同條件的情況下,條件可能已經再次變為假。
(2)條件變量的使用注意事項
- 使用條件變量時,應當始終與互斥鎖配合使用,以防止競態條件。
- 必須在修改條件之前獲取互斥鎖,并在修改完畢后釋放互斥鎖。
- 在等待條件變量時,程序應該處于循環中檢查條件,即使被
signal
或broadcast
喚醒,也應重新檢查條件是否真正滿足。 - 條件變量的等待和通知操作必須在同一個互斥鎖保護下進行,以確保數據的一致性。
3. 使用條件變量的示例
#include <pthread.h>
#include <stdio.h>// 定義全局的條件變量和互斥鎖
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;void *thread_function(void *arg)
{// 獲取互斥鎖pthread_mutex_lock(&mutex);// 等待條件變量pthread_cond_wait(&cond, &mutex);// 做一些工作...printf("Received signal\n");// 釋放互斥鎖pthread_mutex_unlock(&mutex);return NULL;
}int main()
{pthread_t thread_id;// 創建新線程pthread_create(&thread_id, NULL, thread_function, NULL);// 做一些工作...sleep(1); // 等待一段時間,模擬工作// 發送信號給等待的線程pthread_cond_signal(&cond);// 等待線程結束pthread_join(thread_id, NULL);// 清理資源pthread_cond_destroy(&cond);pthread_mutex_destroy(&mutex);return 0;
}
這個示例中,主線程創建了一個新線程,并通過條件變量發送信號。新線程在接收到信號后開始執行打印操作。
三、線程安全
1. 概念
線程安全指的是當多個線程同時訪問某個功能、對象或變量時,系統能夠確保這個功能、對象或變量仍然能夠如預期般正常工作。具體來說,一個線程安全的程序對于并發訪問沒有任何副作用,不會出現數據競爭、死鎖等問題,可以正確地處理多線程同時訪問的情況。
2. 常見的線程不安全的情況
線程不安全的情況通常發生在多個線程并發訪問共享資源時,由于缺乏適當的同步或互斥機制,導致出現意外的結果。以下是一些常見的線程不安全的情況:
-
競態條件(Race Conditions):當多個線程試圖同時訪問和修改共享的數據,而沒有足夠的同步保護時,會導致競態條件。這可能導致數據損壞或不一致的結果。
-
數據競爭(Data Races):當至少兩個線程同時訪問相同的內存位置,其中至少一個是寫操作時,且沒有適當的同步時,就會發生數據競爭。這可能導致未定義的行為和程序崩潰。
-
死鎖(Deadlock):當兩個或多個線程互相持有對方所需的資源,并且在等待對方釋放資源時都不釋放自己的資源時,就會產生死鎖。這將導致多個線程永遠無法繼續執行。
-
活鎖(Livelock):類似于死鎖,但線程們不斷重試某個操作,卻始終無法取得進展,導致系統無法正常工作。
-
非原子操作:當一個操作需要多個步驟完成,而這些步驟中間被其他線程打斷,可能導致數據狀態處于不一致的狀態。
-
資源泄露:當線程在使用完資源后沒有正確釋放,導致資源泄露,可能最終耗盡系統資源。
-
不一致的狀態:當多個線程并發修改共享狀態時,由于缺乏同步機制,可能導致狀態變得不一致,違反程序的預期行為。
以上情況都代表了典型的線程不安全問題,編寫多線程程序時需要格外注意避免這些問題的發生。為了解決這些問題,可以使用鎖、原子操作、條件變量等同步機制來確保線程安全,以及遵循良好的并發編程實踐。
3. 常見的線程安全的情況
-
不可變對象(Immutable Objects):不可變對象在創建后無法被修改,因此多個線程同時訪問不會引發線程安全問題。例如,Java中的String類就是不可變對象。
-
線程本地存儲(Thread-Local Storage):每個線程都有自己獨立的變量副本,不會被其他線程共享,從而避免了線程安全問題。可以使用ThreadLocal類來實現線程本地存儲。
-
局部變量(Local Variables):局部變量是在每個線程的棧幀中創建的,每個線程擁有自己的副本,不存在線程安全問題。
-
同步容器(Synchronized Containers):某些容器類(如Vector、Hashtable)提供了內部同步機制,可以安全地在多線程環境下使用。這些容器會確保對它們的操作是原子的,并且提供了線程安全的迭代器。
-
并發容器(Concurrent Containers):Java中的ConcurrentHashMap、ConcurrentLinkedQueue等并發容器提供了高效的線程安全操作。它們使用了復雜的算法和數據結構來實現高性能的并發訪問。
-
使用互斥鎖(Mutex)或同步機制:通過在多個線程訪問共享資源時使用互斥鎖、讀寫鎖等同步機制,可以保證線程安全。這樣在任意時刻只有一個線程能夠訪問共享資源。
-
原子操作(Atomic Operations):某些編程語言提供了原子操作,這些操作是不可中斷的,可以保證在多線程環境下的原子性。例如,Java中的AtomicInteger類提供了原子操作的整型變量。
-
使用并發編程庫和框架:一些現代編程語言和框架提供了豐富的并發編程工具和庫,如Java中的java.util.concurrent包,可以更方便地實現線程安全。
4. 可重入與線程安全的關系(八股文)
(1)可重入與線程安全的聯系
- 函數是可重入的,那就是線程安全的。
- 函數是不可重入的,那就不能由多個線程使用,有可能引發線程安全問題。
- 如果一個函數中有全局變量,那么這個函數既不是線程安全也不是可重入的。
(2)可重入與線程安全的區別
- 可重入函數是線程安全函數的一種。
- 線程安全不一定是可重入的,而可重入函數則一定是線程安全的。
- 如果將對臨界資源的訪問加上鎖,則這個函數是線程安全的,但如果這個重入函數若鎖還未釋放則會產生死鎖,因此是不可重入的。
溫馨提示
感謝您對博主文章的關注與支持!如果您喜歡這篇文章,可以點贊、評論和分享給您的同學,這將對我提供巨大的鼓勵和支持。另外,我計劃在未來的更新中持續探討與本文相關的內容。我會為您帶來更多關于Linux以及C++編程技術問題的深入解析、應用案例和趣味玩法等。如果感興趣的話可以關注博主的更新,不要錯過任何精彩內容!
再次感謝您的支持和關注。我們期待與您建立更緊密的互動,共同探索Linux、C++、算法和編程的奧秘。祝您生活愉快,排便順暢!