從騰訊面試題入手,帶你吃透C++死鎖
想象一下,你駕車行駛在城市的主干道上,本是一路通暢,突然前方車輛停滯不前,起初你以為只是小狀況,等一等就好,結果等了許久,車輛依舊紋絲未動。你下車查看,發現是兩條道路的車輛在交叉路口互不相讓,都想先行通過,導致誰也無法挪動,道路陷入了僵局。
這便是現實生活中的交通堵塞,而在 C++ 編程的多線程世界里,也存在著類似的困境,那就是死鎖。它就像程序中的 “交通堵塞”,讓線程之間相互等待資源,動彈不得,導致整個程序陷入停滯,無法正常運行。今天,就讓我們一起深入探索死鎖的奧秘,了解它究竟是什么,以及在 C++ 中如何巧妙地避免它。
一、什么是死鎖
在多線程編程中,死鎖就像是一場噩夢,一旦出現,程序就如同陷入了無盡的黑暗,停滯不前。死鎖是指多個進程或線程在執行過程中,因爭奪資源而造成的一種互相等待的現象 ,若無外力作用,它們都將無法推進下去。每個線程都持有對方需要的資源,卻又不愿意釋放自己已持有的資源,從而導致所有線程都被阻塞,無法繼續執行任務。這種僵持不下的局面,就如同現實生活中,幾個人在狹窄的通道中相遇,每個人都希望對方先給自己讓路,結果誰也無法通過。
1.1死鎖存在的條件
所謂死鎖,是指多個進程在運行過程中因爭奪資源而造成的一種僵局,當進程處于這種僵持狀態時,若無外力作用,它們都將無法再向前推進。
我們舉個例子來描述,如果此時有一個線程A,按照先鎖a再獲得鎖b的的順序獲得鎖,而在此同時又有另外一個線程B,按照先鎖b再鎖a的順序獲得鎖。
圖片
產生死鎖的原因
(1)競爭資源——系統中的資源可以分為兩類:
- 可剝奪資源,是指某進程在獲得這類資源后,該資源可以再被其他進程或系統剝奪,CPU和主存均屬于可剝奪性資源;
- 另一類資源是不可剝奪資源,當系統把這類資源分配給某進程后,再不能強行收回,只能在進程用完后自行釋放,如磁帶機、打印機等。
產生死鎖中的競爭資源之一指的是競爭不可剝奪資源(例如:系統中只有一臺打印機,可供進程P1使用,假定P1已占用了打印機,若P2繼續要求打印機打印將阻塞)
產生死鎖中的競爭資源另外一種資源指的是競爭臨時資源(臨時資源包括硬件中斷、信號、消息、緩沖區內的消息等),通常消息通信順序進行不當,則會產生死鎖
(2) 進程間推進順序非法
若P1保持了資源R1,P2保持了資源R2,系統處于不安全狀態,因為這兩個進程再向前推進,便可能發生死鎖
例如,當P1運行到P1:Request(R2)時,將因R2已被P2占用而阻塞;當P2運行到P2:Request(R1)時,也將因R1已被P1占用而阻塞,于是發生進程死鎖
1.2死鎖產生的必要條件
死鎖的產生并非偶然,它需要同時滿足以下四個必要條件:
- 互斥條件:資源在同一時間只能被一個線程或進程占用。就像一把鑰匙只能打開一把鎖,一個線程拿到了這把 “鑰匙”(資源),其他線程就無法同時使用,必須等待該線程釋放資源后才有機會獲取。比如打印機,在某一時刻只能被一個進程使用,其他進程若想使用打印機,就只能排隊等待。
- 請求和保持條件:進程已經持有了一些資源,但又請求其他被占用的資源,并且在等待新資源的過程中,不會釋放已持有的資源。打個比方,你手里已經拿著一個蘋果(已持資源),還想再拿一個橙子(請求其他資源),但橙子被別人拿著,你既不放下手里的蘋果,又一直等著別人把橙子給你,這就滿足了請求和保持條件。在多線程場景中,一個線程已經獲取了鎖 A,又試圖獲取鎖 B,而鎖 B 被另一個線程持有,該線程就會進入等待狀態,同時仍然持有鎖 A。
- 不可搶占條件:資源一旦被某個進程或線程獲得,就只能由持有者主動釋放,不能被其他進程或線程強行剝奪。這就好比你借了朋友的一本書,朋友不能在你沒看完的情況下強行把書拿走,只能等你看完主動歸還。在程序中,一個線程獲取的資源,其他線程不能直接搶走,只能等該線程自行釋放資源。
- 循環等待條件:線程或進程之間形成一種環形的等待鏈,鏈中的每個進程都在等待下一個進程所占用的資源。假設有三個線程 T1、T2、T3,T1 等待 T2 持有的資源,T2 等待 T3 持有的資源,T3 又等待 T1 持有的資源,這樣就形成了一個循環等待的環,導致死鎖的發生。
這四個條件就像四條堅固的繩索,只有當它們同時束縛住程序時,死鎖才會降臨。只要我們能破壞其中任何一個條件,就有望避免死鎖的發生。
1.3死鎖的危害
死鎖一旦發生,就會像一顆隱藏在程序中的定時炸彈,對系統造成嚴重的危害:
- 資源浪費:死鎖會使系統中的資源被無效地占用,無法釋放。因為處于死鎖狀態的進程或線程不釋放已占有的資源,這些資源就一直無法被其他進程利用,導致資源利用率大幅降低。就好比停車場里的兩輛車,分別占據了對方出去的通道,誰也動不了,使得整個停車場的資源無法正常流轉。
- 程序停滯:死鎖會導致相關進程或線程被永久阻塞,無法繼續執行。這意味著程序無法按照預期完成任務,用戶可能會看到程序無響應的情況,極大地影響了用戶體驗。例如,一個圖形界面應用程序發生死鎖,界面會凍結,用戶的任何操作都得不到回應。
- 系統崩潰:在一些極端情況下,死鎖可能導致系統崩潰。如果涉及的進程占有過多的資源,死鎖的影響范圍會逐漸擴大,就像病毒一樣蔓延,最終可能導致整個系統無法正常運行,不得不重啟。比如在一個大型服務器系統中,關鍵進程發生死鎖,可能會導致整個服務器癱瘓,影響大量用戶的使用。
二、C++ 中死鎖的常見場景
2.1多線程多鎖,資源申請順序不一致
在 C++ 多線程編程中,當多個線程需要獲取多個鎖來訪問共享資源時,如果不同線程獲取鎖的順序不一致,就極有可能導致死鎖。假設有兩個線程 A 和 B,以及兩個互斥鎖 mutex1 和 mutex2 。線程 A 先獲取 mutex1,然后嘗試獲取 mutex2;而線程 B 先獲取 mutex2,再嘗試獲取 mutex1。如果線程 A 獲取了 mutex1,線程 B 獲取了 mutex2,此時線程 A 等待線程 B 釋放 mutex2,而線程 B 又等待線程 A 釋放 mutex1,這樣就形成了死鎖,兩個線程都無法繼續執行。
下面通過一段具體的代碼來直觀感受一下這種情況:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1;
std::mutex mutex2;
void threadA() {
mutex1.lock();
std::cout << "線程A獲取了mutex1" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));// 模擬線程A執行一些操作
mutex2.lock();
std::cout << "線程A獲取了mutex2" << std::endl;
// 執行完操作后,釋放鎖
mutex2.unlock();
mutex1.unlock();
}
void threadB() {
mutex2.lock();
std::cout << "線程B獲取了mutex2" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));// 模擬線程B執行一些操作
mutex1.lock();
std::cout << "線程B獲取了mutex1" << std::endl;
// 執行完操作后,釋放鎖
mutex1.unlock();
mutex2.unlock();
}
int main() {
std::thread t1(threadA);
std::thread t2(threadB);
t1.join();
t2.join();
return 0;
}
在這段代碼中,線程 A 和線程 B 分別嘗試以不同的順序獲取 mutex1 和 mutex2 。當程序運行時,很可能會出現線程 A 獲取了 mutex1,線程 B 獲取了 mutex2 的情況,然后兩個線程互相等待對方釋放鎖,從而導致死鎖。
2.2線程對同一個鎖反復加鎖
在 C++11 中,std::mutex 是一種非遞歸鎖。這意味著,如果一個線程已經對 std::mutex 加鎖,在沒有解鎖的情況下再次嘗試加鎖,就會導致死鎖。因為非遞歸鎖不允許同一個線程多次獲取,它認為同一線程重復加鎖是一種錯誤操作,會一直等待鎖的釋放,但這個鎖又被自己持有,所以就陷入了死循環等待,造成死鎖。
來看下面這個示例代碼:
#include <iostream>
#include <mutex>
#include <thread>
std::mutex myMutex;
void recursiveFunction(int count) {
if (count <= 0) return;
myMutex.lock();
std::cout << "遞歸函數,計數: " << count << std::endl;
// 遞歸調用自身,再次嘗試加鎖
recursiveFunction(count - 1);
myMutex.unlock();
}
int main() {
std::thread t(recursiveFunction, 5);
t.join();
return 0;
}
在這個例子中,recursiveFunction函數是一個遞歸函數,它每次調用時都會嘗試對myMutex加鎖。由于myMutex是一個非遞歸鎖,當第一次加鎖后,在遞歸調用中再次嘗試加鎖時,就會發生死鎖,程序會卡在那里無法繼續執行。
2.3優先級翻轉
在非實時操作系統中,當進行了線程優先級劃分時,就可能出現優先級翻轉的情況,進而導致死鎖。具體來說,低優先級的線程可能會持有高優先級線程所需要的鎖,由于非實時操作系統不能保證所有線程在一定時間段內一定會被調用,低優先級線程可能長時間得不到調度,無法執行釋放鎖的代碼,高優先級線程就會因為等待低優先級線程釋放鎖而被阻塞。如果此時高優先級線程還持有了低優先級線程需要的鎖,那么就一定會導致死鎖。
假設有三個線程:高優先級線程 H、中優先級線程 M 和低優先級線程 L,并且線程 H 和線程 L 共享一個資源,通過一個互斥鎖來保護。當線程 L 獲取了鎖并開始執行,此時線程 H 被喚醒,由于其優先級高,搶占了線程 L 的執行權。但線程 H 需要線程 L 持有的鎖才能繼續執行,所以線程 H 進入等待狀態。之后,線程 M 被喚醒并開始執行,由于線程 L 的優先級低于線程 M,線程 L 無法獲得 CPU 執行權,也就無法釋放鎖,線程 H 就會一直等待。如果線程 H 在等待過程中,又獲取了線程 L 需要的另一個鎖,而線程 L 在之后嘗試獲取這個鎖時,就會因為線程 H 持有該鎖而等待,這樣就形成了死鎖,線程 H 和線程 L 都無法繼續執行 。
三、如何在 C++ 中避免死鎖
在了解了 C++ 中死鎖的常見場景后,我們就可以針對性地采取措施來避免死鎖的發生。下面為大家介紹幾種在 C++ 中有效避免死鎖的方法。
3.1使用相同順序獲取鎖
為了避免因資源申請順序不一致導致的死鎖,我們可以讓所有線程按照相同的順序獲取鎖。比如,在前面提到的多線程多鎖的場景中,如果線程 A 和線程 B 都先獲取 mutex1,再獲取 mutex2 ,就不會出現死鎖的情況。這種方法就像是制定了一個交通規則,所有車輛都按照統一的順序行駛,就不會出現堵塞。
3.2使用 std::lock 同時獲取多把鎖
C++ 標準庫提供了 std::lock 函數,它可以同時鎖住多個互斥量,并且內部使用了無死鎖的算法(如 Twins 算法)來鎖定這些互斥量,從而確保不會發生死鎖 。使用 std::lock 時,需要配合 std::lock_guard 或者 std::unique_lock 使用,使其能夠自動解鎖,這樣可以避免手動解鎖時可能出現的錯誤。例如:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1;
std::mutex mutex2;
void threadFunction() {
std::lock(mutex1, mutex2);
std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
// 訪問共享資源的代碼
std::cout << "線程成功獲取了mutex1和mutex2" << std::endl;
}
int main() {
std::thread t(threadFunction);
t.join();
return 0;
}
在這個例子中,std::lock 同時鎖住了 mutex1 和 mutex2 ,然后通過 std::lock_guard 來管理鎖的生命周期,確保在離開作用域時自動解鎖,避免了死鎖的發生。
3.3使用 std::scoped_lock 同時獲取多把鎖
C++17 引入了 std::scoped_lock,它是一個可變函數模板,可以接收多個互斥量作為參數,在構造時自動調用 std::lock 來同時獲取這些互斥量,并在析構時自動解鎖。std::scoped_lock 的出現,讓同時獲取多個互斥量變得更加簡潔和安全,有效避免了死鎖的風險。例如:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1;
std::mutex mutex2;
void threadFunction() {
std::scoped_lock lock(mutex1, mutex2);
// 訪問共享資源的代碼
std::cout << "線程成功獲取了mutex1和mutex2" << std::endl;
}
int main() {
std::thread t(threadFunction);
t.join();
return 0;
}
在這段代碼中,std::scoped_lock 自動管理了 mutex1 和 mutex2 的鎖定和解鎖,使得代碼更加簡潔明了,同時也保證了不會發生死鎖。
3.4避免嵌套鎖,使用遞歸鎖
盡量避免在一個線程中請求多個鎖,減少嵌套鎖的使用。如果確實需要使用嵌套鎖,應該使用遞歸鎖,如 std::recursive_mutex 。std::recursive_mutex 允許同一個線程多次獲取鎖,而不會導致死鎖。例如:
#include <iostream>
#include <thread>
#include <mutex>
std::recursive_mutex recursiveMutex;
void recursiveFunction(int count) {
if (count <= 0) return;
recursiveMutex.lock();
std::cout << "遞歸函數,計數: " << count << std::endl;
// 遞歸調用自身,再次嘗試加鎖
recursiveFunction(count - 1);
recursiveMutex.unlock();
}
int main() {
std::thread t(recursiveFunction, 5);
t.join();
return 0;
}
在這個例子中,使用 std::recursive_mutex 作為遞歸鎖,使得遞歸函數可以多次獲取鎖,避免了因重復加鎖導致的死鎖問題。
3.5引入鎖的等級制度
我們可以對鎖進行等級劃分,規定線程在持有低等級鎖時,不能獲取高等級的鎖。這樣可以避免線程之間形成循環等待的情況,從而預防死鎖的發生。例如,將鎖分為一級鎖、二級鎖和三級鎖,線程在獲取二級鎖之前,必須先釋放所有一級鎖;獲取三級鎖之前,必須先釋放所有一級鎖和二級鎖。通過這種方式,可以有效地打破死鎖產生的循環等待條件。
四、C++死鎖案例分析
4.1代碼示例
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1;
std::mutex mutex2;
void threadFunction1() {
std::lock_guard<std::mutex> lock1(mutex1);
std::cout << "Thread 1 acquired mutex1." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mutex2);
std::cout << "Thread 1 acquired mutex2." << std::endl;
}
void threadFunction2() {
std::lock_guard<std::mutex> lock2(mutex2);
std::cout << "Thread 2 acquired mutex2." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock1(mutex1);
std::cout << "Thread 2 acquired mutex1." << std::endl;
}
int main() {
std::thread t1(threadFunction1);
std::thread t2(threadFunction2);
t1.join();
t2.join();
return 0;
}
在這個案例中,我們有兩個線程t1和t2,以及兩個互斥鎖mutex1和mutex2。線程t1首先獲取mutex1,然后嘗試獲取mutex2;而線程t2則先獲取mutex2,再嘗試獲取mutex1。當兩個線程同時運行時,就可能出現這樣的情況:線程t1持有mutex1并等待mutex2,而線程t2持有mutex2并等待mutex1,從而形成死鎖。
4.2死鎖成因剖析
- 互斥條件:互斥鎖的特性決定了同一時間只能有一個線程訪問共享資源。在上述案例中,mutex1 和 mutex2 都具有互斥性,這是死鎖產生的基礎條件。
- 請求和保持條件:線程 t1 在持有 mutex1 的同時請求 mutex2,線程 t2 在持有 mutex2 的同時請求 mutex1,滿足請求和保持條件。
- 不剝奪條件:一旦線程獲取了互斥鎖,除非它主動釋放,否則其他線程無法強行剝奪該鎖。這使得線程在等待其他鎖時會一直持有已有的鎖,增加了死鎖的可能性。
- 循環等待條件:線程 t1 和 t2 形成了一個循環等待鏈,t1 等待 t2 持有的 mutex2,t2 等待 t1 持有的 mutex1,滿足循環等待條件。
4.3死鎖檢測方法
- 日志記錄:在代碼中添加日志記錄,記錄線程獲取和釋放鎖的時間和順序。通過分析日志,可以發現線程之間的鎖競爭情況,從而判斷是否存在死鎖的可能性。
- 調試工具:使用調試工具(如 Visual Studio 的調試器)可以在程序運行時暫停線程,查看線程的狀態和持有鎖的情況。這有助于定位死鎖發生的具體位置。
- 靜態代碼分析工具:靜態代碼分析工具可以對代碼進行掃描,檢查是否存在潛在的死鎖問題。例如,PVS-Studio 可以檢測出代碼中鎖的獲取和釋放順序不一致等問題。
4.4死鎖解決策略
(1)統一鎖獲取順序
為了避免循環等待條件的出現,可以規定所有線程都按照相同的順序獲取鎖。在上述案例中,我們可以讓兩個線程都先獲取 mutex1
,再獲取 mutex2
。修改后的代碼如下:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1;
std::mutex mutex2;
void threadFunction1() {
std::lock_guard<std::mutex> lock1(mutex1);
std::cout << "Thread 1 acquired mutex1." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mutex2);
std::cout << "Thread 1 acquired mutex2." << std::endl;
}
void threadFunction2() {
std::lock_guard<std::mutex> lock1(mutex1);
std::cout << "Thread 2 acquired mutex1." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mutex2);
std::cout << "Thread 2 acquired mutex2." << std::endl;
}
int main() {
std::thread t1(threadFunction1);
std::thread t2(threadFunction2);
t1.join();
t2.join();
return 0;
}
(2) 使用 std::lock
C++ 標準庫提供了 std::lock 函數,可以同時獲取多個鎖,避免死鎖的發生。修改后的代碼如下:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1;
std::mutex mutex2;
void threadFunction1() {
std::lock(mutex1, mutex2);
std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
std::cout << "Thread 1 acquired both mutexes." << std::endl;
}
void threadFunction2() {
std::lock(mutex1, mutex2);
std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
std::cout << "Thread 2 acquired both mutexes." << std::endl;
}
int main() {
std::thread t1(threadFunction1);
std::thread t2(threadFunction2);
t1.join();
t2.join();
return 0;
}
(3)超時機制
在獲取鎖時設置超時時間,如果在規定時間內無法獲取鎖,則放棄并進行其他處理。這樣可以避免線程無限等待,減少死鎖的可能性。