言
私以為個人的技術水平應該是一個螺旋式上升的過程:先從書本去了解一個大概,然后在實踐中加深對相關知識的理解,遇到問題后再次回到書本,然后繼續實踐……接觸C++並發編程已經一年多,從慢慢啃《C++並發編程實戰》這本書開始,不停在期貨高頻交易軟件的開發實踐中去理解、運用、優化多線程相關技術。多線程知識的學習也是先從最基本的線程建立、互斥鎖、條件變量到更高級的線程安全數據結構、線程池等等技術,當然在項目中也用到了簡單的無鎖編程相關知識,今天把一些體會心得跟大家分享一下,如有錯誤,還望大家批評指正。
多線程並發讀寫
在編寫多線程程序時,最重要的問題就是多線程間共享數據的保護。多個線程之間共享地址空間,所以多個線程共享進程中的全局變量和堆,都可以對全局變量和堆上的數據進行讀寫,但是如果兩個線程同時修改同一個數據,可能造成某線程的修改丟失;如果一個線程寫的同時,另一個線程去讀該數據時可能會讀到寫了一半的數據。這些行為都是線程不安全的行為,會造成程序運行邏輯出現錯誤。舉個最簡單的例子:
#include <iostream> #include <thread> using namespace std; int i = 0; mutex mut; void iplusplus() { int c = 10000000; //循環次數 while (c--) { i++; } } int main() { thread thread1(iplusplus); //建立並運行線程1 thread thread2(iplusplus); //建立並運行線程2 thread1.join(); // 等待線程1運行完畢 thread2.join(); // 等待線程2運行完畢 cout << "i = " << i << endl; return 0; }
上面代碼main函數中建立了兩個線程thread1和thread2,兩個線程都是運行iplusplus函數,該函數功能就是運行i++語句10000000次,按照常識,兩個線程各對i自增10000000次,最后i的結果應該是20000000,但是運行后結果卻是
i並不等於20000000,這是在多線程讀寫情況下沒有對線程間共享的變量i進行保護所導致的問題。
有鎖編程
對於保護多線程共享數據,最常用也是最基本的方法就是使用C++11線程標准庫提供的互斥鎖mutex保護臨界區,保證同一時間只能有一個線程可以獲取鎖,持有鎖的線程可以對共享變量進行修改,修改完畢后釋放鎖,而不持有鎖的線程阻塞等待直到獲取到鎖,然后才能對共享變量進行修改,這種方法幾乎是並發編程中的標准做法。大體流程如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <chrono>
using namespace std;
int i = 0;
mutex mut; //互斥鎖
void iplusplus() {
int c = 10000000; //循環次數
while (c--) {
mut.lock(); //互斥鎖加鎖
i++;
mut.unlock(); //互斥鎖解鎖
}
}
int main()
{
chrono::steady_clock::time_point start_time = chrono::steady_clock::now();//開始時間
thread thread1(iplusplus);
thread thread2(iplusplus);
thread1.join(); // 等待線程1運行完畢
thread2.join(); // 等待線程2運行完畢
cout << "i = " << i << endl;
chrono::steady_clock::time_point stop_time = chrono::steady_clock::now();//結束時間
chrono::duration<double> time_span = chrono::duration_cast<chrono::microseconds>(stop_time - start_time);
std::cout << "共耗時:" << time_span.count() << " ms" << endl; // 耗時
system("pause");
return 0;
}
代碼14行和16行分別為互斥鎖加鎖和解鎖代碼,29行我們打印程序運行耗時,代碼運行結果
可以看到,通過加互斥鎖,i的運行結果是正確的,由此解決了多線程同時寫一個數據產生的線程安全問題,代碼總耗時3.37328ms。
無鎖編程
原子操作是無鎖編程的基石,原子操作是不可分隔的操作,一般通過CAS(Compare and Swap)操作實現,CAS操作需要輸入兩個數值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較下舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。C++11的線程庫為我們提供了一系列原子類型,同時提供了相對應的原子操作,我們通過使用這些原子類型即可擺脫每次對共享變量進行操作都進行的加鎖解鎖動作,節省了系統開銷,同時避免了線程因阻塞而頻繁的切換。原子類型的基本使用方法如下:
#include <iostream> #include <thread> #include <mutex> #include <atomic> #include <chrono> using namespace std; atomic<int> i = 0; void iplusplus() { int c = 10000000; //循環次數 while (c--) { i++; } } int main() { chrono::steady_clock::time_point start_time = chrono::steady_clock::now();//開始時間 thread thread1(iplusplus); thread thread2(iplusplus); thread1.join(); // 等待線程1運行完畢 thread2.join(); // 等待線程2運行完畢 cout << "i = " << i << endl; chrono::steady_clock::time_point stop_time = chrono::steady_clock::now();//結束時間 chrono::duration<double> time_span = chrono::duration_cast<chrono::microseconds>(stop_time - start_time); std::cout << "共耗時:" << time_span.count() << " ms" << endl; // 耗時 system("pause"); return 0; }
代碼的第8行定義了一個原子類型(int)變量i,在第13行多線程修改i的時候即可免去加鎖和解鎖的步驟,同時又能保證變量i的線程安全性。代碼運行結果如下:
可以看到i的值是符合預期的,代碼運行總耗時1.12731ms,僅為有鎖編程的耗時3.37328ms的1/3,由此可以看出無鎖編程由於避免了加鎖而相對於有鎖編程提高了一定的性能。
總結
無鎖編程最大的優勢是什么?是性能提高嗎?其實並不是,我們的測試代碼中臨界區非常短,只有一個語句,所以顯得加鎖解鎖操作對程序性能影響很大,但在實際應用中,我們的臨界區一般不會這么短,臨界區越長,加鎖和解鎖操作的性能損耗越微小,無鎖編程和有鎖編程之間的性能差距也就越微小。
我認為無鎖編程最大的優勢在於兩點:
- 避免了死鎖的產生。由於無鎖編程避免了使用鎖,所以也就不會出現並發編程中最讓人頭疼的死鎖問題,對於提高程序健壯性有很大積極意義
- 代碼更加清晰與簡潔。對於一個多線程共享的變量,保證其安全性我們只需在聲明時將其聲明為原子類型即可,在代碼中使用的時候和使用一個普通變量一樣,而不用每次使用都要在前面寫個加鎖操作,在后面寫一個解鎖操作。我寫的C++期貨高頻交易軟件中,有一個全局變量fund,存儲的是當前資金量,程序采用線程池運行交易策略,交易策略中頻繁使用到fund變量,如果采用加鎖的方式,使用起來極其繁瑣,為了保護一個fund變量需要非常頻繁的加鎖解鎖,后來將fund變量改為原子類型,后面使用就不用再考慮加鎖問題,整個程序閱讀起來清晰很多。
如果是為了提高性能將程序大幅改寫成無鎖編程,一般來說結果可能會讓我們失望,而且無鎖編程里面需要注意的地方也非常多,比如ABA問題,內存順序問題,正確實現無鎖編程比實現有鎖編程要困難很多,除非有必要(確定了性能瓶頸)才去考慮使用無鎖編程,否則還是使用互斥鎖更好,畢竟程序的高性能是建立在程序正確性的基礎上,如果程序不正確,一切性能提升都是徒勞無功。
原文 http://irootlee.com/juicer_lock_free/