————————————————
版權聲明:本文為CSDN博主「Dablelv」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/K346K346/article/details/85345477
1.認識原子操作
原子操作是在多線程程序中“最小的且不可並行化的”操作,意味着多個線程訪問同一個資源時,有且僅有一個線程能對資源進行操作。通常情況下原子操作可以通過互斥的訪問方式來保證,例如Linux下的互斥鎖(mutex),Windows 下的臨界區(Critical Section)等。下面看一個Linux環境使用 POSIX 標准的 pthread 庫實現多線程下的原子操作:
1 #include <pthread.h> 2 #include <iostream> 3 using namespace std; 4 5 int64_t total=0; 6 pthread_mutex_t m=PTHREAD_MUTEX_INITIALIZER; 7 8 //線程函數,用於累加 9 void* threadFunc(void* args) 10 { 11 int64_t endNum=*(int64_t*)args; 12 for(int64_t i=1;i<=endNum;++i) 13 { 14 pthread_mutex_lock(&m); 15 total+=i; 16 pthread_mutex_unlock(&m); 17 } 18 } 19 20 int main() 21 { 22 int64_t endNum=100; 23 pthread_t thread1ID=0,thread2ID=0; 24 25 //創建線程1 26 pthread_create(&thread1ID,NULL,threadFunc,&endNum); 27 //創建線程2 28 pthread_create(&thread2ID,NULL,threadFunc,&endNum); 29 30 //阻塞等待線程1結束並回收資源 31 pthread_join(thread1ID,NULL); 32 //阻塞等待線程2結束並回收資源 33 pthread_join(thread2ID,NULL); 34 35 cout<<"total="<<total<<endl; //10100 36 }
上面的代碼,兩個線程同時對 total 進行操作,為了保證total+=i 的原子性,采用互斥鎖來保證同一時刻只有同一線程執行total+=i操作,所以得出正確結果total=10100。如果沒有做互斥處理,那么 total 同一時刻可能會被兩個線程同時操作,即會出現兩個線程同時讀取了寄存器中的 total 值,分別操作之后又寫入寄存器,這樣就會有一個線程的增加操作無效,會得出一個小於 10100 隨機的錯誤值。
2.C++11 實現原子操作
在 C++11 之前,使用第三方 API 可以實現並行編程,比如 pthread 多線程庫,但是在使用時需要創建互斥鎖,以及進行加鎖、解鎖等操作來保證多線程對臨界資源的原子操作,這無疑增加了開發的工作量。不過從 C++11 開始,C++ 從語言層面開始支持並行編程,內容包括了管理線程、保護共享數據、線程間的同步操作、低級原子操作等各種類。新標准極大地提高了程序的可移植性,以前的多線程依賴於具體的平台,而現在有了統一的接口。
C++11 通過引入原子類型幫助開發者輕松實現原子操作。
1 #include <atomic> 2 #include <thread> 3 #include <iostream> 4 using namespace std; 5 6 atomic_int64_t total = 0; //atomic_int64_t相當於int64_t,但是本身就擁有原子性 7 8 //線程函數,用於累加 9 void threadFunc(int64_t endNum) 10 { 11 for (int64_t i = 1; i <= endNum; ++i) 12 { 13 total += i; 14 } 15 } 16 17 int main() 18 { 19 int64_t endNum = 100; 20 thread t1(threadFunc, endNum); 21 thread t2(threadFunc, endNum); 22 23 t1.join(); 24 t2.join(); 25 26 cout << "total=" << total << endl; //10100 27 }
程序正常編譯並運行輸出正確結果total=10100。使用C++11提供的原子類型與多線程標准接口,簡潔地實現了多線程對臨界資源的原子操作。原子類型C++11中通過atomic<T>類模板來定義,比如atomic_int64_t是通過typedef atomic<int64_t> atomic_int64_t實現的,使用時需包含頭文件<atomic>。除了提供atomic_int64_t,還提供了其它的原子類型。常見的原子類型有:
原子操作是平台相關的,原子類型能夠實現原子操作是因為 C++11 對原子類型的操作進行了抽象,定義了統一的接口,並要求編譯器產生平台相關的原子操作的具體實現。C++11 標准將原子操作定義為 atomic 模板類的成員函數,包括讀(load)、寫(store)、交換(exchange)等。對於內置類型而言,主要是通過重載一些全局操作符來完成的。比如對上文total+=i的原子加操作,是通過對operator+=重載來實現的。使用g++ 編譯的話,在 x86_64 的機器上,operator+=() 函數會產生一條特殊的以 lock 為前綴的 x86_64 指令,用於控制總線及實現 x86_64平台上的原子性加法。
有一個比較特殊的原子類型是 atomic_flag,因為 atomic_flag 與其他原子類型不同,它是無鎖(lock_free)的,即線程對其訪問不需要加鎖,而其他的原子類型不一定是無鎖的。因為atomic<T>並不能保證類型T是無鎖的,另外不同平台的處理器處理方式不同,也不能保證必定無鎖,所以其他的類型都會有 is_lock_free() 成員函數來判斷是否是無鎖的。atomic_flag 只支持 test_and_set() 以及 clear() 兩個成員函數,test_and_set()函數檢查 std::atomic_flag 標志,如果 std::atomic_flag 之前沒有被設置過,則設置 std::atomic_flag 的標志;如果之前 std::atomic_flag 已被設置,則返回 true,否則返回 false。clear()函數清除 std::atomic_flag 標志使得下一次調用 std::atomic_flag::test_and_set()返回 false。可以用 atomic_flag 的成員函數test_and_set() 和 clear() 來實現一個自旋鎖(spin lock):
1 #include <unistd.h> 2 #include <atomic> 3 #include <thread> 4 #include <iostream> 5 6 std::atomic_flag lock = ATOMIC_FLAG_INIT; 7 8 void func1() 9 { 10 while (lock.test_and_set(std::memory_order_acquire)) // 在主線程中設置為true,需要等待t2線程clear 11 { 12 std::cout << "func1 wait" << std::endl; 13 } 14 std::cout << "func1 do something" << std::endl; 15 } 16 17 void func2() 18 { 19 std::cout << "func2 start" << std::endl; 20 lock.clear(); 21 } 22 23 int main() 24 { 25 lock.test_and_set(); // 設置狀態 26 std::thread t1(func1); 27 usleep(1); //睡眠1us 28 std::thread t2(func2); 29 30 t1.join(); 31 t2.join(); 32 33 return 0; 34 }
以上代碼中,定義了一個 atomic_flag 對象 lock,使用初始值 ATOMIC_FLAG_INIT 進行初始化,即處於 false 的狀態。線程 t1 調用 test_and_set() 一直返回 true(因為在主線程中被設置過),所以一直在等待,而等待一段時間后當線程 t2 運行並調用了 clear(),test_and_set() 返回了 false 退出循環等待並進行相應操作。這樣一來,就實現了一個線程等待另一個線程的效果。當然,可以封裝成鎖操作的方式,比如:
1 void Lock(atomic_flag& lock){ while ( lock.test_and_set()); } 2 void UnLock(atomic_flag& lock){ lock.clear(); }
3.內存模型:強順序與弱順序
內存模型通常是硬件上的概念,表示的是機器指令是以什么樣的順序被處理器執行的,現代的處理器並不是逐條處理機器指令的:
1 1: Load reg3, 1; // 將立即數1放入寄存器reg3 2 2: Move reg4,reg3; // 將reg3的數據放入reg4 3 3: Store reg4, a; // 將reg4的數據存入內存地址a 4 4: Load reg5, 2; // 將立即數2放入寄存器reg5 5 5: Store reg5, b; // 將reg5的數據存入內存地址b
以上的偽匯編代碼代表了temp = 1; a = temp; b = 2,通常情況下指令都是按照1~5的順序執行,這種內存模型稱為強順序(strong ordered)。不過可以看到,指令1、2、3和指令4、5的運行順序不影響結果,有一些處理器可能會將指令的順序打亂,例如按照1-4-2-5-3的順序執行,這種內存模型稱為弱順序(weak ordered)。弱順序內存模型下,指令5(b的賦值)很有可能在指令3(a的賦值)之前完成。
現實中,x86_64以及SPARC(TSO模式)都是采用強順序內存模型的平台。在多線程程序中,強順序類型意味着對於各個線程看到的指令執行順序是一致的。對於處理器而言,內存中的數據被改變的順序與機器指令中的一致。相反的,弱順序就是各個線程看到的內存數據被改變的順序與機器指令中聲明的不一致。弱順序內存模型可能會導致程序問題,為什么有些平台,諸如Alpha、PowerPC、Itanlium、ArmV7等平台會使用這種模型?簡單地說,這種模型能讓處理器有更好的並行性,提高指令執行的效率。並且,為了保證指令執行的順序,通常需要在匯編指令中加入一條內存柵欄(memory barrier)指令,但是會影響處理器性能。比如在PowerPC上,就有一條名為sync的內存柵欄指令。該指令迫使已經進入流水線中的指令都完成后處理器才會執行sync以后的指令。
事實上,C++11中的原子操作還可以包含一個參數:內存順序(memory_order),是C++11為原子類型定義的內存模型,讓程序員根據實際情況靈活地控制原子類型的執行順序。通常情況下,使用該參數將有利於編譯器進一步提高並行性能。
1 atomic<int> a{0}; 2 atomic<int> b{0}; 3 4 //線程函數 5 int valueSet(int) 6 { 7 int t=1; 8 a.store(t); 9 b.store(2); 10 }
如果原子類型變量a和b並沒有要求執行的順序性,那么可以采用一種松散的內存模型來放松對原子操作的執行順序的要求。改造如下:
1 void func1() 2 { 3 int t=t; 4 a.store(t, std::memory_order_relaxed); 5 b.store(2, std::memory_order_relaxed); 6 }
上面的代碼使用了store函數進行賦值,store函數接受兩個參數,第一個是要寫入的值,第二個是名為memory_order的枚舉值。這里使用了std::memory_order_relaxed,表示松散內存順序,該枚舉值代表編譯器可以任由編譯器重新排序或則由處理器亂序處理。這樣a和b的賦值執行順序性就被解除了。在C++11中一共有7種memory_order枚舉值,默認按照memory_order_seq_cst執行:
需要注意的是,不是所有的memory_order都能被atomic成員使用:
(1)store函數可以使用memory_order_seq_cst、memory_order_release、memory_order_relaxed。
(2)load函數可以使用memory_order_seq_cst、memory_order_acquire、memory_order_consume、memory_order_relaxed。
(3)需要同時讀寫的操作,例如test_and_flag、exchange等操作。可以使用memory_order_seq_cst、memory_order_release、memory_order_acquire、memory_order_consume、memory_order_relaxed。
原子類型提供的一些操作符,比如operator=、operator+=等函數,都是以memory_order_seq_cst為memory_order的實參的原子操作封裝,所以他們都是順序一致性的。如果要指定內存順序的話,則應該采用store、load、atomic_fetch_add這樣的版本。
最后說明一下,std::atomic和std::memory_order只有在多線程無鎖編程時才會用到。在x86_64平台,由於是強順序內存模型的,為了保險起見,不要使用std::memory_order,使用std::atmoic默認形式即可,因為std::atmoic默認是強順序內存模型。
參考文獻
[1]《深入理解C++11》筆記-原子類型和原子操作
[2] 深入理解C++11[M].C6.3原子類型與原子操作.P196-214