C和C++安全編碼筆記:併發

併發是一種系統屬性,它是指系統中幾個計算同時執行,並可能彼此交互。一個併發程序通常使用順序線程和(或)進程的一些組合來執行計算,其中每個線程和進程執行可以在邏輯上並行執行的計算。這些進程和(或)線程可以在單處理器系統上使用分時搶佔式的方式(用一種時間分片的方法使每個線程和(或)進程中的執行步驟交錯進行)、在多核/多處理器系統中,或者在一個分佈式計算系統中執行。多個控制流併發執行是現代計算環境的重要組成部分。

7.1 多線程:多線程不一定是併發的。一個多線程程序可以以這樣一種方式構建,即它的線程不會併發執行。

一個多線程程序分成可以併發執行的兩個或更多線程。每個線程都作爲一個單獨的程序,但所有線程都在相同的內存中工作,並共享相同的內存。此外,線程之間的切換速度比進程間切換更快。最後,多個線程可以在多個CPU上並行執行,以提高性能收益。

即使沒有多個CPU,現在CPU架構的改進,也可以允許同時多線程,即在相同的內核中使交織在一起的多個獨立線程同時執行。英特爾把這個過程稱爲超線程(hyperthreading)。無論CPU的數量是多少,線程安全都必須加以處理,以避免可能因執行次序而產生的潛在災難性bug。一個單線程的程序是完全不會產生任何額外線程的程序。因此,單線程程序通常並不需要擔心同步,並可以受益於強大的單核處理器。然而,即使是單線程程序也可能有併發問題。

char* err_msg;
#define MAX_MSG_SIZE 24

void handler(int signum)
{
	strcpy(err_msg, "SIGINT encountered.");
}

int test_secure_coding_7_1()
{
	// 即使是單線程程序也可能有併發問題
	// 雖然此程序只使用了一個線程,但它採用了兩個控制流:一個使用test_secure_coding_7_1函數,另一個使用handler函數
	// 如果在調用malloc()的過程中調用信號處理程序,該程序可能奔潰,如在程序執行5秒內,按Ctrl+C鍵
	// 在信號處理函數中只調用異步安全的函數
	signal(SIGINT, handler);
	std::this_thread::sleep_for(std::chrono::seconds(5));
	err_msg = (char*)malloc(MAX_MSG_SIZE);
	if (err_msg == nullptr) { // 處理錯誤條件
		fprintf(stderr, "fail to malloc\n");
		return -1;
	}

	strcpy(err_msg, "No errors yet.");
	// 主代碼循環
	fprintf(stdout, "err_msg: %s\n", err_msg);
	return 0;
}

7.2 並行:所有的並行程序都是併發的,但不是所有的併發程序都是並行的這意味着併發程序既可以用交錯、時間分片的方式執行又可以並行執行

並行計算是”同時使用多臺電腦資源解決計算問題”。把問題分解成幾部分,再細分成一系列指令。然後來自各部分的指令在不同的CPU並行運行,實現並行計算。每個部分都必須獨立於其它部分並可同時解,最終的結果是多個CPU比單個CPU可以在更短的時間內解決問題。

並行包括數據並行(data parallelism)和任務並行(task parallelism)。這些因問題分解的程度而異。數據並行可用於在比順序處理更短的時間內處理計算單元,它是高性能計算的基礎。單指令多數據(Single Instruction, Multiple Data, SIMD)是一類具有多個處理單元,同時對多個數據點執行相同操作的並行計算機。支持SIMD的CPU的例子有包括SIMD流指令擴展(Stream SIMD Extension, SSE)的Intel或AMD處理器和包括NEON指令的ARM處理器。任務並行性指將一個問題分解成可以共享數據的不同任務。各任務在同一時間執行,但執行不同的功能。因爲這種類型的並行性的任務數量是固定的,所以它具有有限的可擴展性。它由主流操作系統和多種編程語言支持,一般用來提高程序的響應能力。

7.3 性能目標:除了並行計算的概念,術語並行度(parallelism)用來表示工作(所有指令花費的總時間)跨度(執行最長的並行執行路徑或關鍵路徑所花費的時間)比。所得到的值是沿關鍵路徑的每個步驟完成的平均值,並且是任意數量的處理器可能獲得的最大加速比。因此,可實現的並行度受限於程序結構,依賴於它的關鍵路徑和工作量。能夠並行執行的計算越多,優勢就越大。這種優勢有一個上限,這個上限近似於工作跨度比。

7.4 常見錯誤:

競爭條件:不受控制的併發可能會導致不確定的行爲(即對相同的一組輸入,一個程序可能表現出不同的行爲)。在任何情況下,取決於哪個線程首先完成,只要兩個線程可以產生不同的行爲,都會產生競爭條件。

競爭條件的存在離不開三個屬性

(1).併發屬性:至少有兩個必須同時執行的控制流。

(2).共享對象屬性:兩個併發流都必須訪問一個共享的競爭對象。

(3).改變狀態屬性:至少有一個控制流一定會改變競爭對象的狀態。

競爭條件是一種軟件缺陷,並且經常是漏洞的來源。競爭條件特別陰險,因爲它們有時間依賴性並且是零星出現的。因此,它們難以察覺、重現和消除,並可能導致錯誤,如數據損壞或崩潰。競爭條件是運行時環境導致的,這個運行時環境包括必須對共享資源的訪問進行控制的操作系統,特別是通過進程調度進行控制的。無論運行時環境如何調度執行(在已知的限制條件下),確保代碼正確排序都是程序員的責任。

要消除競爭條件,首先要識別競爭窗口。競爭窗口是訪問競爭對象的一個代碼段,它的執行方式是打開一個機會窗口,在此期間其它併發流可以”競爭進入”,並改變競爭對象。此外,競爭窗口不受鎖或任何其它機制保護。用鎖或無鎖的機制保護的競爭窗口稱爲臨界區(critical section)。

損壞的值:在競爭條件下寫入的值很容易損壞。防止此類數據損壞最常見的緩解措施是使變量成爲原子類型。

易變的對象:具有volatile限定類型的對象,可能以編譯器未知的方式修改,或者有其它未知的副作用。例如,異步信號處理,可能會導致以編譯器未知的方式修改對象。volatile類型限定符對訪問和緩存施加限制。根據C的標準:volatile對象的訪問嚴格按照抽象機的規則進行評估。在省略volatile限定符的情況下,除了可能的別名,可以假定指定位置的內容是不變的。對不能緩存的數據使用volatile當一個變量被聲明爲volatile時,就會禁止編譯器對該內存位置的讀取和寫入順序進行重新排列。但是,編譯器可能對這些讀取、寫入和對其它的內存位置的讀取和寫入的相對順序進行重新排列。具有volatile類型限定符的對象不保證多個線程之間的同步,不防止併發內存訪問,也不保證對對象的原子性訪問。

volatile sig_atomic_t interrupted; // 應聲明爲volatile

void sigint_handler(int signum)
{
	interrupted = 1; // 賦值可能是在test_secure_coding_7_4不可見的
	fprintf(stdout, "interrupted'value is changed\n");
}

int test_secure_coding_7_4()
{
	signal(SIGINT, sigint_handler);

	// 執行後可同時按下ctrl+c鍵停止
	while (!interrupted) { // interrupted若不聲明爲volatile的,循環可能永遠不會終止
		// do something
	}

	return 0;
}

7.5 緩解策略:爲了在C和C++語言中支持併發,許多庫與特定於平臺的擴展已開發出來。一個常見的庫是POSIX線程庫(pthread)。2011年,C和C++的ISO/IEC新版本標準公佈了,兩者都提供了對多線程程序的支持。把線程的支持集成到語言中比起分別通過庫提供線程有幾大優勢。爲了保持最大的兼容性,C的線程支持派生自C++的線程支持,只做了句法的變化以支持C語言更簡單的語法。C++的線程支持使用類和模板。

內存模型:C和C++的多線程使用相同的內存模型,這是從Jave派生的(有一些變化)。一個標準化的線程平臺的內存模型比以前的內存模型要複雜得多。C/C++的內存模型必須提供線程安全性,同時仍然允許細粒度訪問硬件,特別是一個平臺可能會提供的任何低級別的線程原語。

編譯器重新排序:在重組程序方面,編譯器具有非常大的自由度。如果規則授權編譯器改變一個程序指令的順序。不是用來對多線程程序進行編譯的編譯器可能會在程序中採用”彷彿”規則,彷彿程序是單線程的。如果程序使用一個線程庫,如POSIX線程序,那麼事實上,編譯器可能把線程安全的程序改造成非線程安全的程序。

數據競爭(Data Race):如果某個程序在不同的線程中包含兩個相互矛盾的動作,其中至少有一個不是原子的,並且兩者都不在另一個之前發生,那麼執行這個程序包含數據競爭。任何這樣的數據競爭都會導致未定義的行爲。如果兩個表達式中的一個修改某一內存位置,而另一個讀取或修改相同的內存位置,那麼這兩個表達式求值發生衝突。與競爭條件不同,數據競爭專門指內存訪問,並可能不適用於其它共享對象,如文件。

同步原語:爲了防止數據的競爭,對同一對象執行的任何兩個動作,必須有一個”發生在之前”關係。操作的具體順序是無關緊要的。這種關係不但建立動作之間的時間順序,而且也保證了第一個動作改變的內存對第二個動作是可見的。可以使用同步原語(synchronization primitive)建立一個”發生在之前”的關係。C和C++都支持幾種不同類型的同步原語,包括互斥變量(mutex variable)、條件變量(condition variable)和鎖變量(lock variable)。底層操作系統還支持額外的同步原語,如信號量(semaphore)、管道(pipe)、命名管道(named pipe)和臨界區對象(critical section object)。在競爭窗口之前獲取同步對象,然後在窗口結束後釋放它,使競爭窗口中關於使用相同的同步機制的其它代碼是原子的。競爭窗口最終成爲一個代碼臨界區。所有臨界區對執行臨界區的線程以外的所有適當的同步線程都是原子的。

防止臨界區併發執行存在許多策略。這些策略中的大多數涉及鎖機制,鎖機制導致一個或多個線程等待,直到另一個線程退出臨界區

互斥量(mutex):最簡單的一種鎖機制是稱爲互斥量的一個對象。互斥量有兩種可能的狀態:鎖定和解鎖。一個線程鎖定一個互斥量後,任何後續試圖鎖定該互斥量的線程都將被阻止,直到此互斥量被解鎖爲止。當互斥量解鎖後,阻塞線程可以恢復執行,並鎖定互斥量以繼續。此策略可確保一次只有一個線程可以運行花括號內的代碼。因此,互斥量可以包裝在臨界區,以使它們序列化,從而使程序是線程安全的互斥量不與任何其它數據關聯。它們只是作爲鎖對象

C++11中的<mutex>:當對已經鎖定的互斥量執行lock()操作時,該函數會被阻塞直到當前持有該鎖的線程釋放它。try_lock()方法試圖鎖定互斥量,但如果該互斥量已經鎖定,它就立即返回,以允許線程執行其它操作。C++還支持定時的互斥量,它提供try_lock_for()和try_lock_until()方法。這些方法會被阻塞,直到互斥量成功鎖定或經過指定長度的時間。所有其它方法的行爲與普通的互斥量相同。C++還支持遞歸互斥量。這些互斥量的行爲也像普通的互斥量一樣,除了它們允許單個線程不止一次地獲取鎖,而中間不用解鎖。多次鎖定一個互斥量的線程,必須解鎖相同的次數,之後此互斥量纔可以被任何其它線程鎖定。非遞歸互斥量在沒有干預解鎖時不能被同一個線程多次鎖定。最後,C++支持既是定時又是遞歸的互斥量。

std::mutex shared_lock;
int shared_data = 0;
void thread_function(int id)
{
	// 當對已經鎖定的互斥量執行lock操作時,該函數會被阻塞直到當前持有該鎖的線程釋放它
	shared_lock.lock();
	shared_data = id; // shared_data的競爭窗口開始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data); // shared_data的競爭窗口結束	
	shared_lock.unlock();
}

void test_concurrency_mutex()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_mutex()繼續之前,等待直到線程完成
	fprintf(stdout, "Done\n");
}

C對互斥量的支持與C++對互斥量的支持在語義上是相同的,但具有不同的語法,因爲C缺乏類和模板。C標準庫提供mtx_lock()、mtx_unlock()、mtx_trylock()和mtx_timedlock()函數來鎖定與解鎖互斥量。它還提供mtx_init()和mtx_destroy()函數來創建與銷燬互斥量。

鎖衛士(Lock Guard):是承擔對互斥量(實際上,任何鎖定對象)的看管責任的一個標準對象。當針對一個互斥量構造鎖衛士時,它試圖鎖定互斥量,當鎖衛士本身被銷燬時它解除對該互斥量的鎖定。鎖衛士對互斥量應用資源採集時初始化(Resource Acquisition Is Initialization, RAII)。因此,在用C++編程時,如果發生臨界區拋出異常,或退出時沒有明確地對互斥量解鎖,我們建議使用鎖衛士緩解這些問題

std::mutex shared_lock2;
int shared_data2 = 0;
void thread_function2(int id)
{
	std::lock_guard<std::mutex> lg(shared_lock2);
	shared_data2 = id; // shared_data2的競爭窗口開始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data2);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data2); // shared_data2的競爭窗口結束	
	// lg被銷燬,且互斥量在這裏被隱式地解鎖
}

void test_concurrency_mutex_guard()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function2, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_mutex_guard()繼續之前,等待直到線程完成
	fprintf(stdout, "Done\n");
}

原子操作(Atomic Operation):原子操作是不可分割的。也就是說,一個原子操作不能被任何其它的操作中斷,當正在執行原子操作時,它訪問的內存,也不可以被任何其它機制改變。因此,必須在一個原子操作運行完成後,其它任何事物才能訪問該操作所使用的內存,原子操作不能被劃分成更小的部分。簡單的機器指令,例如,裝載一個寄存器,可能是不可中斷的。被一個原子加載訪問的內存位置不可以由其它任何線程訪問,直到此原子操作完成。原子對象是保證它執行的所有操作都是原子的任何對象。通過對某個對象上的所有操作施加原子性,一個原子對象不會被同時讀取或寫入破壞。原子對象不存在數據競爭,雖然它們仍然可能會受到競爭條件的影響。C和C++對原子對象提供廣泛的支持。每一個基本數據類型都具有類似的原子數據類型。

atomic_flag數據類型提供了經典的測試和設置(test-and-set)功能。它有兩個狀態,設置和清除。通過包括<atomic>頭文件,程序可以訪問原子類型和相關函數。對於每個原子類型,標準還提供了原子類型名稱,如atomic_short或atomic_ulong。

volatile std::atomic_flag shared_lock3;
int shared_data3 = 0;
void thread_function3(int id)
{
	// 只有當標誌在之前未設置時,atomic_flag對象的test_and_set方法纔會設置標誌.當標誌設置成功時,test_and_set
	// 方法返回false,當標誌已經設置時,它返回true.只有當整數鎖以前是0時,這才與設置整數鎖爲1效果相同.但是,
	// 因爲test_and_set方法是原子的,它缺乏這樣的競爭窗口,即該窗口中的其它地方可以篡改標誌.因此共享鎖可以防止
	// 多個線程進入臨界區,所以代碼是線程安全的
	while (shared_lock3.test_and_set()) std::this_thread::sleep_for(std::chrono::seconds(1));
	shared_data3 = id; // shared_data3的競爭窗口開始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data3);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data3); // shared_data3的競爭窗口結束	
	shared_lock3.clear();
}

void test_concurrency_atomic()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function3, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_atomic()繼續之前,等待直到線程完成
	fprintf(stdout, "Done\n");
}

每種原子整數類型都支持裝載和存儲操作,以及更高級的操作。atomic_exchange()泛型函數把一個新值存儲到一個原子變量,並返回變量的舊值。當且僅當目前的變量包含特定值時,atomic_compare_exchange()泛型函數纔會把一個新值存儲到一個原子變量中,只有當成功地改變原子變量時,函數才返回true。最後,原子整數支持讀--修改--寫操作,例如atomic_fetch_add()函數。這個函數類似於”+=”運算符,但有兩個不同的行爲。首先,它返回變量的舊值,而”+=”返回相加的總和。其次,”+=”缺乏線程安全保證。而原子提取函數承諾,當相加發生時,該變量不能被任何其它線程訪問。對於減法、按位與、按位或、按位異或,存在類似的提取函數。C標準也定義了atomic_flag的類型,但它只支持兩個函數:atomic_flag_clear()函數清除標誌,當且僅當標誌以前是清除狀態時,atomic_flag_test_and_set()函數才設置標誌。atomic_flag類型保證是無鎖的。其它原子類型的變量可能會或可能不會以無鎖的方式操縱。

std::atomic<int> shared_lock4;
int shared_data4 = 0;
void thread_function4(int id)
{
	// 鎖定對象是一個可賦值爲數值的原子整數.atomic_compare_exchange_weak函數安全地將鎖設置爲1,此函數允許意外失敗.
	// 也就是說,即使當原子整數的預期值爲0,它也可能沒有設置爲1. 出於這個原因,必須始終在一個循環內調用此函數,
	// 以便它在遇到意外失敗時可以重試
	int zero = 0;
	while (!std::atomic_compare_exchange_weak(&shared_lock4, &zero, 1))
	      std::this_thread::sleep_for(std::chrono::seconds(1));
	shared_data4 = id; // shared_data4的競爭窗口開始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data4);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data4); // shared_data4的競爭窗口結束	
	shared_lock4 = 0;
}

void test_concurrency_atomic2()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function4, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_atomic2()繼續之前,等待直到線程完成
	fprintf(stdout, "Done\n");
}

C++標準提供了一個與C類似的API。它提供了<atomic>頭文件。C++提供了一個atomic<>模板用於創建整數類型的原子版本,如atomic<short>和atomic<unsigned long>。atomic_bool的行爲類似於C,並具有相似的API。C++標準支持與C相同的原子操作,但是,它們既可以用函數表示,又可以用原子模板對象的方法表示。例如,atomic_exchange()函數與C裏一樣工作,但被atomic<>::exchange()模板方法所取代。此外,C++提供了附加的運算符(+、-、++、--、+=、-=)的重載版本,它們使用atomic_fetch_add()和類似的函數。C++缺乏提供相應位運算功能的運算符。

圍欄:內存障礙(memory barrier),也稱爲內存圍欄(memory fence),它是一組指令,用於防止CPU和可能的編譯器對隔着圍欄的讀取和寫入操作重新排序。內存障礙是一種減緩數據競爭的低級別方法。

信號量(semaphore):類似於互斥量,但信號量也維護了一個其值在初始化時聲明的計數器。因此,信號量是遞減和遞增的,而不是鎖定和解鎖的。通常情況下,在一個臨界區的開頭遞減信號量並在臨界區結束處遞增信號量。當一個信號量的計數器達到0時,遞減信號量的後續嘗試將被阻止,直到計數器被遞增。信號量的好處是,它控制當前訪問由信號量把守的臨界區的線程數量。對於管理資源池或協調多個線程使用單個資源,這是非常有用的。信號量計數器的初始值是將授權併發訪問由該信號量把守的臨界區的線程總數。需要注意的是,一個初始計數器爲1的信號量的行爲,就好像是一個互斥量。

無鎖的方法:無鎖算法提供了一種對共享數據執行操作的方法,而不用在線程之間調用系統開銷大的同步函數。標準atomic_compare_exchange_weak()函數和atomic_flag::test_and_set()方法是無鎖的方法。它們使用內置的互斥技術,而不是使用明確的鎖對象,如互斥量,來使它們原子化。

消息隊列(message queue):是一個用於線程和進程間通信的異步通信機制。消息傳遞併發往往比共享內存併發的推理容易得多,後者通常需要具有某種形式的鎖定的應用程序(例如,互斥量、信號量或監視器)在線程之間協調。

線程角色分析(研究):許多多線程軟件系統包含規範線程、可執行代碼,以及可能的共享狀態間的聯繫的策略。例如,系統可能會限制允許哪些線程執行特定的代碼段,通常作爲一種手段來限制那些線程對特定元素的狀態讀取或寫入。這些線程使用策略(thread usage policy),確保如狀態禁閉或讀/寫的限制等屬性,通常沒有要鎖定的資源或事務原則。線程使用策略的概念不是特定於語言的。

不可變的數據結構:只有當兩個或多個線程共享數據而且至少一個線程視圖對數據進行修改時,纔可能有競爭條件。提供線程安全的一種常用的方法是簡單地防止線程修改共享數據,在本質上,即是使數據只讀。保護不可改變的共享數據不需要鎖。有幾個技巧使共享數據只讀。一旦初始化,一些類根本無法提供任何方法來修改它們的數據。可以安全地在線程之間共享這些類的對象。在C和C++中一種常見的戰術是聲明一個共享對象爲const。另一種方法是複製一個線程可能要修改的任何對象。再次,在這種情況下,所有共享對象都是隻讀的,任何需要修改一個對象的線程都會創建一個共享對象的私有副本,其後只能用它的副本工作。因爲副本是私有的,所以共享的對象仍然是不變的。

併發代碼屬性:線程安全和可重入。

線程安全:線程安全函數的使用可以幫助消除競爭條件。根據定義,一個線程安全函數通過鎖或其它互斥機制來防止共享資源被併發訪問。因此,一個線程安全的函數可以同時被多個線程調用,而不用擔心。如果一個線程不使用靜態數據或共享資源,它明顯是線程安全的。然而,使用全局函數引發了線程安全的紅旗,且任何對全局數據的使用必須同步,以避免競爭條件。爲了使一個函數成爲線程安全的,它必須同步訪問共享資源。特定數據的訪問或整個庫可以鎖定。然而,在庫上使用全局鎖會導致爭用(contention)。

可重入:可重入(reentrant)函數也可以減輕併發編程錯誤。函數是可重入的,是指相同函數的多個實例可以同時運行在相同的地址空間中,而不會創建潛在的不一致的狀態。IBM定義的可重入函數,是指它在連續調用時不持有靜態數據,也不會返回一個指向靜態數據的指針。因此,可重入函數使用的所有數據都由調用者提供,並且可重入函數不能調用不可重入函數。可重入函數可以中斷,並重新進入(reentered)而不會丟失數據的完整性,因此,可重入函數是線程安全的。可重入函數一定也是線程安全的,但線程安全的函數卻可能無法重入。

7.6 緩解陷阱:當併發實現得不正確時,就會產生漏洞。多線程程序中常見錯誤:

(1).沒有用鎖保護共享數據(即數據競爭)。

(2).當鎖確實存在時,不使用鎖訪問共享數據。

(3).過早釋放鎖。

(4).對操作的一部分獲取正確的鎖,釋放它,後來再次取得它,然後又釋放它,而正確的做法是一直持有該鎖。

(5).在想要用局部變量時,意外地通過使用全局變量共享數據。

(6).在不同的時間對共享數據使用兩個不同的鎖。

(7).由下列情況引起死鎖:不恰當的鎖定序列(加鎖和解鎖序列必須保持一致);鎖定機制使用不當或錯誤選擇;不釋放鎖或試圖再次獲取已經持有的鎖。

一些常見的併發陷阱包括以下內容:

(1).缺乏公平:所有線程沒有得到平等的機會來獲得處理。

(2).飢餓:當一個線程霸佔共享資源、阻止其它線程使用時發生。

(3).活鎖:線程繼續執行,但未能獲得處理。

(4).假設線程將:以一個特定的順序運行;不能同時運行;同時運行;在一個線程結束前獲得處理。

(5).假設一個變量不需要鎖定,因爲開發人員認爲只有一個線程寫入它且所有其它線程都讀取它。這還假定該變量上的操作是原子的。

(6).使用非線程安全庫。如果一個庫能保證由多個線程同時訪問時不會產生數據競爭,那麼認爲它是線程安全的。

(7).依託測試,以找到數據競爭和死鎖。

(8).內存分配和釋放問題。當內存在一個線程中分配而在另一個線程中釋放時,這些問題可能出現,不正確的同步可能會導致內存仍然被訪問時被釋放。

死鎖:傳統上,通過使衝突的競爭窗口互斥,使得一旦一個臨界區開始執行時,沒有額外的線程可以執行,直到前一個線程退出臨界區爲止,從而消除競爭條件。但是,同步原語的不正確使用可能會導致死鎖(deadlock)。當兩個或多個控制流以彼此都不可以繼續執行的方式阻止對方時,就會發生死鎖。特別是,對於一個併發執行流的循環,如果其中在循環中的每個流都已經獲得了導致在循環中隨後的流懸停的同步對象,則會發生死鎖。死鎖的一個明顯的安全漏洞是拒絕訪問。

int shared_data5 = 0;
std::mutex* locks5 = nullptr;
int thread_size5;
void thread_function5(int id)
{
	if (0) { // 產生死鎖
		// 此代碼將產生一個固定數量的線程,每個線程都修改一個值,然後讀取它.雖然通常一個鎖就足夠了,但是每個
		// 線程(thread_size5)都用一個鎖守衛共享數據值.每個線程都必須獲得兩個鎖,然後才能再訪問該值.如果
		// 一個線程首先獲得鎖0,第二個線程獲得鎖1,那麼程序將會出現死鎖
		if (id % 2)
			for (int i = 0; i < thread_size5; ++i)
				locks5[i].lock();
		else
			for (int i = thread_size5; i >= 0; --i)
				locks5[i].lock();

		shared_data5 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data5);

		if (id % 2)
			for (int i = thread_size5; i >= 0; --i)
				locks5[i].unlock();
		else
			for (int i = 0; i < thread_size5; ++i)
				locks5[i].unlock();
	}
	else { // 不會產生死鎖
		// 每個線程都以同一順序獲取鎖,可以消除潛在的死鎖.下面的程序無論創建多少線程都不會出現死鎖
		for (int i = 0; i < thread_size5; ++i)
			locks5[i].lock();

		shared_data5 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data5);

		for (int i = 0; i < thread_size5; ++i)
			locks5[i].unlock();
	}
}

void test_concurrency_deadlock()
{
	thread_size5 = 5;
	std::thread* threads = new std::thread[thread_size5];
	locks5 = new std::mutex[thread_size5];

	for (size_t i = 0; i < thread_size5; ++i)
		threads[i] = std::thread(thread_function5, i);

	for (size_t i = 0; i < thread_size5; ++i)
		threads[i].join();

	// test_concurrency_deadlock()繼續之前,等待直到線程完成
	delete[] locks5;
	delete[] threads;
	fprintf(stdout, "Done\n");
}

像所有的數據競爭一樣,死鎖行爲對環境的狀態而不只是程序的輸入敏感。特別是,死鎖(和其它的數據競爭)可能對以下條件敏感:

(1).處理器速度。

(2).進程或線程調度算法的變動。

(3).在執行的時候,強加的不同內存限制。

(4).任何異步事件中斷程序執行的能力。

(5).其它併發執行進程的狀態。

過早釋放鎖:

std::mutex shared_lock6;
int shared_data6 = 0;
void thread_function6(int id)
{
	// 每個線程都把一個共享變量設置爲它的線程編號,然後打印出共享變量的值.爲了防止數據競爭,每個線程都
	// 鎖定一個互斥量,以使變量被正確地設置
	if (0) { // 過早地釋放鎖
		// 當共享變量的每一個寫操作都由互斥量所保護時,隨後的讀取是不受保護的
		shared_lock6.lock();
		shared_data6 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data6);
		shared_lock6.unlock();
		std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
		fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data6);
	}
	else {
		// 讀取和寫入共享數據都必須受到保護,以確保每一個線程讀取到它寫入的相同的值.將臨界區擴展爲包括讀取值,此代碼就呈現爲線程安全的
		// 需要注意的是,線程的順序仍然可以有所不同,但每個線程都正確地打印出線程編號
		shared_lock6.lock();
		shared_data6 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data6);
		std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
		fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data6);
		shared_lock6.unlock();
	}
}

void test_concurrency_prematurely_release_lock()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
		threads[i] = std::thread(thread_function6, i);

	for (size_t i = 0; i < thread_size; ++i)
		threads[i].join();

	// test_concurrency_prematurely_release_lock()繼續之前,等待直到線程完成
	fprintf(stdout, "Done\n");
}

爭用:當一個線程試圖獲取另一個線程持有的鎖時,就會發生鎖爭用。有些鎖爭用是正常的,這表明,鎖正在”工作”,以防止競爭條件。過多的鎖爭用會導致性能不佳。減少持有鎖的時間量或通過降低每個鎖保護的粒度或資源量,可以解決鎖爭用導致的性能差的問題。持有鎖的時間越長,另一個線程嘗試獲取鎖,並被迫等待的概率將越大。反之,減少持有鎖的持續時間就減少了爭用。例如,不會作用於共享資源的代碼,不需要在臨界區之內得到保護,並可以與其它線程並行運行。在一個臨界區之內執行一個阻塞操作延伸了臨界區的持續時間,從而增加了潛在的爭用。在臨界區之內的阻塞操作也可能導致死鎖。在臨界區之內執行阻塞操作幾乎始終是一個嚴重的錯誤。

鎖的粒度也可以影響爭用。增加由一個單一鎖保護的共享資源的數量,或擴大共享資源的範圍(例如,鎖定整個表以訪問一個單元格),將使在同一時間多個線程嘗試訪問該資源的概率增大。在選擇鎖的數量時,增加鎖的開銷和減少鎖爭用之間有一個權衡。更細的粒度(每個保護少量的數據)需要更多的鎖,使得鎖本身的開銷增加。額外的鎖也會增加死鎖的風險。鎖一般是相當快的,但是,當然單個執行線程運行速度會比沒有鎖更慢。

ABA問題:在同步過程中,當一個位置被讀取兩次,並有相同的值供讀取時,就發生ABA的問題。然而,第二個線程已在兩次讀取之間執行並修改了這個值,執行其它工作,然後把值再修改回來,從而愚弄第一個線程,讓它以爲第二個線程尚未執行。實現無鎖數據結構時,經常會遇到ABA問題。如果將一個條目從列表中移除,並刪除,然後分配一個新的條目,並把它添加到列表中,因爲優化,新的對象通常會放置在被刪除的對象的相同位置。因此,指向新條目的指針可能等於舊項目的指針,這可能會導致ABA問題。

自旋鎖(spinlock):是一種類型的鎖實現,其中線程在一個循環中反覆嘗試獲得鎖,直到它終於成功。一般而言,只有當等待獲得鎖的時間很短時,自旋鎖纔是有效的。在這種情況下,自旋鎖避免了昂貴的上下文切換時間和在傳統的鎖中等待資源時,調度運行需要花費的時間。當獲得鎖的等待時間是明顯長時,自旋鎖在試圖獲取一個鎖時就會浪費大量的CPU時間。一個常見的防止自旋鎖浪費CPU週期的緩解措施是,在while循環中讓該線程休眠或把控制讓給其它線程。

以上代碼段的完整code見:GitHub/Messy_Test

GitHubhttps://github.com/fengbingchun/Messy_Test

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章