用於並行計算的多線程數據結構,第 1 部分: 設計併發數據結構
簡介
現在,您的計算機有四個 CPU 核;並行計算 是最時髦的主題,您急於掌握這種技術。但是,並行編程不只是在隨便什麼函數和方法中使用互斥鎖和條件變量。C++
開發人員必須掌握的關鍵技能之一是設計併發數據結構。本文是兩篇系列文章的第一篇,討論如何在多線程環境中設計併發數據結構。對於本文,我們使用
POSIX Threads 庫(也稱爲 Pthreads;見 參考資料 中的鏈接),但是也可以使用
Boost Threads 等實現(見 參考資料 中的鏈接)。
本文假設您基本瞭解基本數據結構,比較熟悉 POSIX Threads 庫。您還應該基本瞭解線程的創建、互斥鎖和條件變量。在本文的示例中,會相當頻繁地使用 pthread_mutex_lock
、pthread_mutex_unlock
、pthread_cond_wait
、pthread_cond_signal
和pthread_cond_broadcast
。
設計併發隊列
我們首先擴展最基本的數據結構之一:隊列。我們的隊列基於鏈表。底層列表的接口基於 Standard Template Library(STL;見 參考資料)。多個控制線程可以同時在隊列中添加數據或刪除數據,所以需要用互斥鎖對象管理同步。隊列類的構造函數和析構函數負責創建和銷燬互斥鎖,見 清單 1。
清單 1. 基於鏈表和互斥鎖的併發隊列
#include <pthread.h> #include <list.h> // you could use std::list or your implementation namespace concurrent { template <typename T> class Queue { public: Queue( ) { pthread_mutex_init(&_lock, NULL); } ~Queue( ) { pthread_mutex_destroy(&_lock); } void push(const T& data); T pop( ); private: list<T> _list; pthread_mutex_t _lock; } };
在併發隊列中插入和刪除數據
顯然,把數據放到隊列中就像是把數據添加到列表中,必須使用互斥鎖保護這個操作。但是,如果多個線程都試圖把數據添加到隊列中,會發生什麼?第一個線程鎖住互斥並把數據添加到隊列中,而其他線程等待輪到它們操作。第一個線程解鎖/釋放互斥鎖之後,操作系統決定接下來讓哪個線程在隊列中添加數據。通常,在沒有實時優先級線程的 Linux® 系統中,接下來喚醒等待時間最長的線程,它獲得鎖並把數據添加到隊列中。清單 2 給出代碼的第一個有效版本。
清單 2. 在隊列中添加數據
void Queue<T>::push(const T& value ) { pthread_mutex_lock(&_lock); _list.push_back(value); pthread_mutex_unlock(&_lock); }
取出數據的代碼與此類似,見 清單 3。
清單 3. 從隊列中取出數據
T Queue<T>::pop( ) { if (_list.empty( )) { throw ”element not found”; } pthread_mutex_lock(&_lock); T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; }
清單 2 和 清單
3 中的代碼是有效的。但是,請考慮這樣的情況:您有一個很長的隊列(可能包含超過 100,000 個元素),而且在代碼執行期間的某個時候,從隊列中讀取數據的線程遠遠多於添加數據的線程。因爲添加和取出數據操作使用相同的互斥鎖,所以讀取數據的速度會影響寫數據的線程訪問鎖。那麼,使用兩個鎖怎麼樣?一個鎖用於讀取操作,另一個用於寫操作。清單
4 給出修改後的 Queue
類。
清單 4. 對於讀和寫操作使用單獨的互斥鎖的併發隊列
template <typename T> class Queue { public: Queue( ) { pthread_mutex_init(&_rlock, NULL); pthread_mutex_init(&_wlock, NULL); } ~Queue( ) { pthread_mutex_destroy(&_rlock); pthread_mutex_destroy(&_wlock); } void push(const T& data); T pop( ); private: list<T> _list; pthread_mutex_t _rlock, _wlock; }
清單 5 給出 push/pop 方法的定義。
清單 5. 使用單獨互斥鎖的併發隊列 Push/Pop 操作
void Queue<T>::push(const T& value ) { pthread_mutex_lock(&_wlock); _list.push_back(value); pthread_mutex_unlock(&_wlock); } T Queue<T>::pop( ) { if (_list.empty( )) { throw ”element not found”; } pthread_mutex_lock(&_rlock); T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_rlock); return _temp; }
設計併發阻塞隊列
目前,如果讀線程試圖從沒有數據的隊列讀取數據,僅僅會拋出異常並繼續執行。但是,這種做法不總是我們想要的,讀線程很可能希望等待(即阻塞自身),直到有數據可用時爲止。這種隊列稱爲阻塞的隊列。如何讓讀線程在發現隊列是空的之後等待?一種做法是定期輪詢隊列。但是,因爲這種做法不保證隊列中有數據可用,它可能會導致浪費大量
CPU 週期。推薦的方法是使用條件變量 — 即 pthread_cond_t
類型的變量。在深入討論語義之前,先來看一下修改後的隊列定義,見 清單
6。
清單 6. 使用條件變量的併發阻塞隊列
template <typename T> class BlockingQueue { public: BlockingQueue ( ) { pthread_mutex_init(&_lock, NULL); pthread_cond_init(&_cond, NULL); } ~BlockingQueue ( ) { pthread_mutex_destroy(&_lock); pthread_cond_destroy(&_cond); } void push(const T& data); T pop( ); private: list<T> _list; pthread_mutex_t _lock; pthread_cond_t _cond; }
清單 7 給出阻塞隊列的 pop 操作定義。
清單 7. 從隊列中取出數據
T BlockingQueue<T>::pop( ) { pthread_mutex_lock(&_lock); if (_list.empty( )) { pthread_cond_wait(&_cond, &_lock) ; } T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; }
當隊列是空的時候,讀線程現在並不拋出異常,而是在條件變量上阻塞自身。pthread_cond_wait
還隱式地釋放 mutex_lock
。現在,考慮這個場景:有兩個讀線程和一個空的隊列。第一個讀線程鎖住互斥鎖,發現隊列是空的,然後在 _cond
上阻塞自身,這會隱式地釋放互斥鎖。第二個讀線程經歷同樣的過程。因此,最後兩個讀線程都等待條件變量,互斥鎖沒有被鎖住。
現在,看一下 push()
方法的定義,見 清單
8。
清單 8. 在阻塞隊列中添加數據
void BlockingQueue <T>::push(const T& value ) { pthread_mutex_lock(&_lock); const bool was_empty = _list.empty( ); _list.push_back(value); pthread_mutex_unlock(&_lock); if (was_empty) pthread_cond_broadcast(&_cond); }
如果列表原來是空的,就調用 pthread_cond_broadcast
以宣告列表中已經添加了數據。這麼做會喚醒所有等待條件變量 _cond
的讀線程;讀線程現在隱式地爭奪互斥鎖。操作系統調度程序決定哪個線程獲得對互斥鎖的控制權
— 通常,等待時間最長的讀線程先讀取數據。
併發阻塞隊列設計有兩個要注意的方面:
-
可以不使用
pthread_cond_broadcast
,而是使用pthread_cond_signal
。但是,pthread_cond_signal
會釋放至少一個等待條件變量的線程,這個線程不一定是等待時間最長的讀線程。儘管使用pthread_cond_signal
不會損害阻塞隊列的功能,但是這可能會導致某些讀線程的等待時間過長。 -
可能會出現虛假的線程喚醒。因此,在喚醒讀線程之後,要確認列表非空,然後再繼續處理。清單
9 給出稍加修改的
pop()
方法,強烈建議使用基於while
循環的pop()
版本。
清單 9. 能夠應付虛假喚醒的 pop() 方法
T BlockingQueue<T>::pop( ) { pthread_cond_wait(&_cond, &_lock) ; while(_list.empty( )) { pthread_cond_wait(&_cond) ; } T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; }
設計有超時限制的併發阻塞隊列
在許多系統中,如果無法在特定的時間段內處理新數據,就根本不處理數據了。例如,新聞頻道的自動收報機顯示來自金融交易所的實時股票行情,它每 n 秒收到一次新數據。如果在 n 秒內無法處理以前的一些數據,就應該丟棄這些數據並顯示最新的信息。根據這個概念,我們來看看如何給併發隊列的添加和取出操作增加超時限制。這意味着,如果系統無法在指定的時間限制內執行添加和取出操作,就應該根本不執行操作。清單 10 給出接口。
清單 10. 添加和取出操作有時間限制的併發隊列
template <typename T> class TimedBlockingQueue { public: TimedBlockingQueue ( ); ~TimedBlockingQueue ( ); bool push(const T& data, const int seconds); T pop(const int seconds); private: list<T> _list; pthread_mutex_t _lock; pthread_cond_t _cond; }
首先看看有時間限制的 push()
方法。push()
方法不依賴於任何條件變量,所以沒有額外的等待。造成延遲的惟一原因是寫線程太多,要等待很長時間才能獲得鎖。那麼,爲什麼不提高寫線程的優先級?原因是,如果所有寫線程的優先級都提高了,這並不能解決問題。相反,應該考慮創建少數幾個調度優先級高的寫線程,把應該確保添加到隊列中的數據交給這些線程。清單
11 給出代碼。
清單 11. 把數據添加到阻塞隊列中,具有超時限制
bool TimedBlockingQueue <T>::push(const T& data, const int seconds) { struct timespec ts1, ts2; const bool was_empty = _list.empty( ); clock_gettime(CLOCK_REALTIME, &ts1); pthread_mutex_lock(&_lock); clock_gettime(CLOCK_REALTIME, &ts2); if ((ts2.tv_sec – ts1.tv_sec) <seconds) { was_empty = _list.empty( ); _list.push_back(value); { pthread_mutex_unlock(&_lock); if (was_empty) pthread_cond_broadcast(&_cond); }
clock_gettime
例程返回一個 timespec
結構,它是系統紀元以來經過的時間(更多信息見 參考資料)。在獲取互斥鎖之前和之後各調用這個例程一次,從而根據經過的時間決定是否需要進一步處理。
具有超時限制的取出數據操作比添加數據複雜;注意,讀線程會等待條件變量。第一個檢查與 push()
相似。如果在讀線程能夠獲得互斥鎖之前發生了超時,那麼不需要進行處理。接下來,讀線程需要確保(這是第二個檢查)它等待條件變量的時間不超過指定的超時時間。如果到超時時間段結束時還沒有被喚醒,讀線程需要喚醒自身並釋放互斥鎖。
有了這些背景知識,我們來看看 pthread_cond_timedwait
函數,這個函數用於進行第二個檢查。這個函數與 pthread_cond_wait
相似,但是第三個參數是絕對時間值,到達這個時間時讀線程自願放棄等待。如果在超時之前讀線程被喚醒,pthread_cond_timedwait
的返回值是0
。清單
12 給出代碼。
清單 12. 從阻塞隊列中取出數據,具有超時限制
T TimedBlockingQueue <T>::pop(const int seconds) { struct timespec ts1, ts2; clock_gettime(CLOCK_REALTIME, &ts1); pthread_mutex_lock(&_lock); clock_gettime(CLOCK_REALTIME, &ts2); // First Check if ((ts1.tv_sec – ts2.tv_sec) < seconds) { ts2.tv_sec += seconds; // specify wake up time while(_list.empty( ) && (result == 0)) { result = pthread_cond_timedwait(&_cond, &_lock, &ts2) ; } if (result == 0) { // Second Check T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; } } pthread_mutex_unlock(&lock); throw “timeout happened”; }
清單 12 中的 while
循環確保正確地處理虛假的喚醒。最後,在某些
Linux 系統上,clock_gettime
可能是
librt.so 的組成部分,可能需要在編譯器命令行中添加 –lrt
開關。
使用 pthread_mutex_timedlock API
清單 11 和 清單
12 的缺點之一是,當線程最終獲得鎖時,可能已經超時了。因此,它只能釋放鎖。如果系統支持的話,可以使用pthread_mutex_timedlock
API
進一步優化這個場景(見 參考資料)。這個例程有兩個參數,第二個參數是絕對時間值。如果在到達這個時間時還無法獲得鎖,例程會返回且狀態碼非零。因此,使用這個例程可以減少系統中等待的線程數量。下面是這個例程的聲明:
int pthread_mutex_timedlock(pthread_mutex_t *mutex, const struct timespec *abs_timeout);
設計有大小限制的併發阻塞隊列
最後,討論有大小限制的併發阻塞隊列。這種隊列與併發阻塞隊列相似,但是對隊列的大小有限制。在許多內存有限的嵌入式系統中,確實需要有大小限制的隊列。
對於阻塞隊列,只有讀線程需要在隊列中沒有數據時等待。對於有大小限制的阻塞隊列,如果隊列滿了,寫線程也需要等待。這種隊列的外部接口與阻塞隊列相似,見 清單
13。(注意,這裏使用向量而不是列表。如果願意,可以使用基本的 C/C++
數組並把它初始化爲指定的大小。)
清單 13. 有大小限制的併發阻塞隊列
template <typename T> class BoundedBlockingQueue { public: BoundedBlockingQueue (int size) : maxSize(size) { pthread_mutex_init(&_lock, NULL); pthread_cond_init(&_rcond, NULL); pthread_cond_init(&_wcond, NULL); _array.reserve(maxSize); } ~BoundedBlockingQueue ( ) { pthread_mutex_destroy(&_lock); pthread_cond_destroy(&_rcond); pthread_cond_destroy(&_wcond); } void push(const T& data); T pop( ); private: vector<T> _array; // or T* _array if you so prefer int maxSize; pthread_mutex_t _lock; pthread_cond_t _rcond, _wcond; }
在解釋添加數據操作之前,看一下 清單 14 中的代碼。
清單 14. 在有大小限制的阻塞隊列中添加數據
void BoundedBlockingQueue <T>::push(const T& value ) { pthread_mutex_lock(&_lock); const bool was_empty = _array.empty( ); while (_array.size( ) == maxSize) { pthread_cond_wait(&_wcond, &_lock); } _ array.push_back(value); pthread_mutex_unlock(&_lock); if (was_empty) pthread_cond_broadcast(&_rcond); }
鎖是否可以擴展到其他數據結構?
當然可以。但這是最好的做法嗎?不是。考慮一個應該允許多個線程使用的鏈表。與隊列不同,列表沒有單一的插入或刪除點,使用單一互斥鎖控制對列表的訪問會導致系統功能正常但相當慢。另一種實現是對每個節點使用鎖,但是這肯定會增加系統的內存佔用量。本系列的第二部分會討論這些問題。
對於 清單 13 和 清單
14,要注意的第一點是,這個阻塞隊列有兩個條件變量而不是一個。如果隊列滿了,寫線程等待 _wcond
條件變量;讀線程在從隊列中取出數據之後需要通知所有線程。同樣,如果隊列是空的,讀線程等待 _rcond
變量,寫線程在把數據插入隊列中之後向所有線程發送廣播消息。如果在發送廣播通知時沒有線程在等待 _wcond
或 _rcond
,會發生什麼?什麼也不會發生;系統會忽略這些消息。還要注意,兩個條件變量使用相同的互斥鎖。清單
15 給出有大小限制的阻塞隊列的 pop()
方法。
清單 15. 從有大小限制的阻塞隊列中取出數據
T BoundedBlockingQueue<T>::pop( ) { pthread_mutex_lock(&_lock); const bool was_full = (_array.size( ) == maxSize); while(_array.empty( )) { pthread_cond_wait(&_rcond, &_lock) ; } T _temp = _array.front( ); _array.erase( _array.begin( )); pthread_mutex_unlock(&_lock); if (was_full) pthread_cond_broadcast(&_wcond); return _temp; }
注意,在釋放互斥鎖之後調用 pthread_cond_broadcast
。這是一種好做法,因爲這會減少喚醒之後讀線程的等待時間。
結束語
本文討論了幾種併發隊列及其實現。實際上,還可能實現其他變體。例如這樣一個隊列,它只允許讀線程在數據插入隊列經過指定的延時之後才能讀取數據。請通過 參考資料 進一步瞭解 POSIX 線程和併發隊列算法。
用於並行計算的多線程數據結構,第 2 部分: 設計不使用互斥鎖的併發數據結構
簡介
本文是本系列的最後一篇,討論兩個主題:關於實現基於互斥鎖的併發鏈表的設計方法和設計不使用互斥鎖的併發數據結構。對於後一個主題,我選擇實現一個併發堆棧並解釋設計這種數據結構涉及的一些問題。用 C++
設計獨立於平臺的不使用互斥鎖的數據結構目前還不可行,所以我選用
GCC version 4.3.4 作爲編譯器並在代碼中使用 GCC 特有的 __sync_*
函數。如果您是
WIndows® C++
開發人員,可以考慮使用
Interlocked* 系列函數實現相似的功能。
併發單向鏈表的設計方法
清單 1 給出最基本的併發單向鏈表接口。是不是缺少什麼東西?
清單 1. 併發單向鏈表接口
template <typename T> class SList { private: typedef struct Node { T data; Node *next; Node(T& data) : value(data), next(NULL) { } } Node; pthread_mutex_t _lock; Node *head, *tail; public: void push_back(T& value); void insert_after(T& previous, T& value); // insert data after previous void remove(const T& value); bool find(const T& value); // return true on success SList( ); ~SList( ); };
清單 2 給出 push_back
方法定義。
清單 2. 把數據添加到併發鏈表中
void SList<T>::push_back(T& data) { pthread_mutex_lock(&_lock); if (head == NULL) { head = new Node(data); tail = head; } else { tail->next = new Node(data); tail = tail->next; } pthread_mutex_unlock(&_lock); }
現在,假設一個線程試圖通過調用 push_back
把 n 個整數連續地添加到這個鏈表中。這個接口本身要求獲取並釋放互斥鎖 n 次,即使在第一次獲取鎖之前已經知道要插入的所有數據。更好的做法是定義另一個方法,它接收一系列整數,只獲取並釋放互斥鎖一次。清單
3 給出方法定義。
清單 3. 在鏈表中插入數據的更好方法
void SList<T>::push_back(T* data, int count) // or use C++ iterators { Node *begin = new Node(data[0]); Node *temp = begin; for (int i=1; i<count; ++i) { temp->next = new Node(data[i]); temp = temp->next; } pthread_mutex_lock(&_lock); if (head == NULL) { head = begin; tail = head; } else { tail->next = begin; tail = temp; } pthread_mutex_unlock(&_lock); }
優化搜索元素
現在,我們來優化鏈表中的搜索元素 — 即 find
方法。下面是幾種可能出現的情況:
- 當一些線程正在迭代鏈表時,出現插入或刪除請求。
- 當一些線程正在迭代鏈表時,出現迭代請求。
- 當一些線程正在插入或刪除數據時,出現迭代請求。
顯然,應該能夠同時處理多個迭代請求。如果系統中的插入/刪除操作很少,主要活動是搜索,那麼基於單一鎖的方法性能會很差。在這種情況下,應該考慮使用讀寫鎖,即 pthread_rwlock_t
。在本文的示例中,將在 SList
中使用 pthread_rwlock_t
而不是 pthread_mutex_t
。這麼做就允許多個線程同時搜索鏈表。插入和刪除操作仍然會鎖住整個鏈表,這是合適的。清單
4 給出使用 pthread_rwlock_t
的鏈表實現。
清單 4. 使用讀寫鎖的併發單向鏈表
template <typename T> class SList { private: typedef struct Node { // … same as before } Node; pthread_rwlock_t _rwlock; // Not pthread_mutex_t any more! Node *head, *tail; public: // … other API remain as-is SList( ) : head(NULL), tail(NULL) { pthread_rwlock_init(&_rwlock, NULL); } ~SList( ) { pthread_rwlock_destroy(&_rwlock); // … now cleanup nodes } };
清單 5 給出鏈表搜索代碼。
清單 5. 使用讀寫鎖搜索鏈表
bool SList<T>::find(const T& value) { pthread_rwlock_rdlock (&_rwlock); Node* temp = head; while (temp) { if (temp->value == data) { status = true; break; } temp = temp->next; } pthread_rwlock_unlock(&_rwlock); return status; }
清單 6 給出使用讀寫鎖的 push_back
方法。
清單 6. 使用讀寫鎖在併發單向鏈表中添加數據
void SList<T>::push_back(T& data) { pthread_setschedprio(pthread_self( ), SCHED_FIFO); pthread_rwlock_wrlock(&_rwlock); // … All the code here is same as Listing 2 pthread_rwlock_unlock(&_rwlock); }
我們來分析一下。這裏使用兩個鎖定函數調用(pthread_rwlock_rdlock
和 pthread_rwlock_wrlock
)管理同步,使用pthread_setschedprio
調用設置寫線程的優先級。如果沒有寫線程在這個鎖上阻塞(換句話說,沒有插入/刪除請求),那麼多個請求鏈表搜索的讀線程可以同時操作,因爲在這種情況下一個讀線程不會阻塞另一個讀線程。如果有寫線程等待這個鎖,當然不允許新的讀線程獲得鎖,寫線程等待,到現有的讀線程完成操作時寫線程開始操作。如果不按這種方式使用 pthread_setschedprio
設置寫線程的優先級,根據讀寫鎖的性質,很容易看出寫線程可能會餓死。
下面是對於這種方式要記住的幾點:
-
如果超過了最大讀鎖數量(由實現定義),
pthread_rwlock_rdlock
可能會失敗。 -
如果有 n 個併發的讀鎖,一定要調用
pthread_rwlock_unlock
n 次。
允許併發插入
應該瞭解的最後一個方法是 insert_after
。同樣,預期的使用模式要求調整數據結構的設計。如果一個應用程序使用前面提供的鏈表,它執行的插入和搜索操作數量差不多相同,但是刪除操作很少,那麼在插入期間鎖住整個鏈表是不合適的。在這種情況下,最好允許在鏈表中的分離點(disjoint
point)上執行併發插入,同樣使用基於讀寫鎖的方式。下面是構造鏈表的方法:
- 在兩個級別上執行鎖定(見 清單 7):鏈表有一個讀寫鎖,各個節點包含一個互斥鎖。如果想節省空間,可以考慮共享互斥鎖 — 可以維護節點與互斥鎖的映射。
- 在插入期間,寫線程在鏈表上建立讀鎖,然後繼續處理。在插入數據之前,鎖住要在其後添加新數據的節點,插入之後釋放此節點,然後釋放讀寫鎖。
- 刪除操作在鏈表上建立寫鎖。不需要獲得與節點相關的鎖。
- 與前面一樣,可以併發地執行搜索。
清單 7. 使用兩級鎖定的併發單向鏈表
template <typename T> class SList { private: typedef struct Node { pthread_mutex_lock lock; T data; Node *next; Node(T& data) : value(data), next(NULL) { pthread_mutex_init(&lock, NULL); } ~Node( ) { pthread_mutex_destroy(&lock); } } Node; pthread_rwlock_t _rwlock; // 2 level locking Node *head, *tail; public: // … all external API remain as-is } };
清單 8 給出在鏈表中插入數據的代碼。
清單 8. 使用雙重鎖定在鏈表中插入數據
void SList<T>:: insert_after(T& previous, T& value) { pthread_rwlock_rdlock (&_rwlock); Node* temp = head; while (temp) { if (temp->value == previous) { break; } temp = temp->next; } Node* newNode = new Node(value); pthread_mutex_lock(&temp->lock); newNode->next = temp->next; temp->next = newNode; pthread_mutex_unlock(&temp->lock); pthread_rwlock_unlock(&_rwlock); return status; }
基於互斥鎖的方法的問題
到目前爲止,都是在數據結構中使用一個或多個互斥鎖管理同步。但是,這種方法並非沒有問題。請考慮以下情況:
- 等待互斥鎖會消耗寶貴的時間 — 有時候是很多時間。這種延遲會損害系統的可伸縮性。
- 低優先級的線程可以獲得互斥鎖,因此阻礙需要同一互斥鎖的高優先級線程。這個問題稱爲優先級倒置(priority inversion ) (更多信息見 參考資料 中的鏈接)。
- 可能因爲分配的時間片結束,持有互斥鎖的線程被取消調度。這對於等待同一互斥鎖的其他線程有不利影響,因爲等待時間現在會更長。這個問題稱爲鎖護送(lock convoying)(更多信息見 參考資料 中的鏈接)。
互斥鎖的問題還不只這些。最近,出現了一些不使用互斥鎖的解決方案。話雖如此,儘管使用互斥鎖需要謹慎,但是如果希望提高性能,肯定應該研究互斥鎖。
比較並交換指令
在研究不使用互斥鎖的解決方案之前,先討論一下從 80486 開始在所有 Intel® 處理器上都支持的 CMPXCHG 彙編指令。清單 9 從概念角度說明這個指令的作用。
清單 9. 比較並交換指令的行爲
int compare_and_swap ( int *memory_location, int expected_value, int new_value) { int old_value = *memory_location; if (old_value == expected_value) *memory_location = new_value; return old_value; }
這裏發生的操作是:指令檢查一個內存位置是否包含預期的值;如果是這樣,就把新的值複製到這個位置。清單 10 提供彙編語言僞代碼。
清單 10. 比較並交換指令的彙編語言僞代碼
CMPXCHG OP1, OP2 if ({AL or AX or EAX} = OP1) zero = 1 ;Set the zero flag in the flag register OP1 = OP2 else zero := 0 ;Clear the zero flag in the flag register {AL or AX or EAX}= OP1
CPU 根據操作數的寬度(8、16 或 32)選擇 AL、AX 或 EAX 寄存器。如果 AL/AX/EAX 寄存器的內容與操作數 1 的內容匹配,就把操作數 2 的內容複製到操作數 1;否則,用操作數 2 的值更新 AL/AX/EAX 寄存器。Intel Pentium® 64 位處理器有相似的指令 CMPXCHG8B,它支持 64 位的比較並交換。注意,CMPXCHG 指令是原子性的,這意味着在這個指令結束之前沒有可見的中間狀態。它要麼完整地執行,要麼根本沒有開始。在其他平臺上有等效的指令,例如 Motorola MC68030 處理器的 compare and swap (CAS) 指令有相似的語義。
我們爲什麼對 CMPXCHG 感興趣?這意味着要使用彙編語言編寫代碼嗎?
需要了解 CMPXCHG 和 CMPXCHG8B 等相關指令是因爲它們構成了無鎖解決方案的核心。但是,不必使用彙編語言編寫代碼。GCC (GNU Compiler Collection,4.1 和更高版本)提供幾個原子性的內置函數(見 參考資料),可以使用它們爲 x86 和 x86-64 平臺實現 CAS 操作。實現這一支持不需要包含頭文件。在本文中,我們要在無鎖數據結構的實現中使用 GCC 內置函數。看一下這些內置函數:
bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...) type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...)
__sync_bool_compare_and_swap
內置函數比較 oldval
和 *ptr
。如果它們匹配,就把 newval
複製到 *ptr
。如果 oldval
和 *ptr
匹配,返回值是
True,否則是 False。__sync_val_compare_and_swap
內置函數的行爲是相似的,只是它總是返回舊值。清單
11 提供一個使用示例。
清單 11. GCC CAS 內置函數的使用示例
#include <iostream> using namespace std; int main() { bool lock(false); bool old_value = __sync_val_compare_and_swap( &lock, false, true); cout >> lock >> endl; // prints 0x1 cout >> old_value >> endl; // prints 0x0 }
設計無鎖併發堆棧
既然瞭解了 CAS,現在就來設計一個併發堆棧。這個堆棧沒有鎖;這種無鎖的併發數據結構也稱爲非阻塞數據結構。清單 12 給出代碼接口。
清單 12. 基於鏈表的非阻塞堆棧實現
template <typename T> class Stack { typedef struct Node { T data; Node* next; Node(const T& d) : data(d), next(0) { } } Node; Node *top; public: Stack( ) : top(0) { } void push(const T& data); T pop( ) throw (…); };
清單 13 給出壓入操作的定義。
清單 13. 在非阻塞堆棧中壓入數據
void Stack<T>::push(const T& data) { Node *n = new Node(data); while (1) { n->next = top; if (__sync_bool_compare_and_swap(&top, n->next, n)) { // CAS break; } } }
壓入(Push)操作做了什麼?從單一線程的角度來看,創建了一個新節點,它的 next 指針指向堆棧的頂部。接下來,調用 CAS 內置函數,把新的節點複製到 top 位置。
從多個線程的角度來看,完全可能有兩個或更多線程同時試圖把數據壓入堆棧。假設線程 A 試圖把 20 壓入堆棧,線程 B 試圖壓入 30,而線程 A 先獲得了時間片。但是,在 n->next
= top
指令結束之後,調度程序暫停了線程 A。現在,線程 B 獲得了時間片(它很幸運),它能夠完成 CAS,把 30 壓入堆棧後結束。接下來,線程 A 恢復執行,顯然對於這個線程 *top
和 n->next
不匹配,因爲線程
B 修改了 top 位置的內容。因此,代碼回到循環的開頭,指向正確的 top 指針(線程 B 修改後的),調用 CAS,把 20 壓入堆棧後結束。整個過程沒有使用任何鎖。
彈出操作
清單 14 給出從堆棧彈出數據的代碼。
清單 14. 從非阻塞堆棧彈出數據
T Stack<T>::pop( ) { if (top == NULL) throw std::string(“Cannot pop from empty stack”); while (1) { Node* next = top->next; if (__sync_bool_compare_and_swap(&top, top, next)) { // CAS return top->data; } } }
用與 push
相似的代碼定義彈出操作語義。堆棧的頂存儲在 result
中,使用
CAS 把 top 位置更新爲 top->next
並返回適當的數據。如果恰在執行
CAS 之前線程失去執行權,那麼在線程恢復執行之後,CAS 會失敗,繼續循環,直到有有效的數據可用爲止。
結果好就一切都好
不幸的是,這種堆棧彈出實現有問題 — 包括明顯的問題和不太明顯的問題。明顯的問題是 NULL 檢查必須放在 while
循環中。如果線程
P 和線程 Q 都試圖從只剩一個元素的堆棧彈出數據,而線程 P 恰在執行 CAS 之前失去執行權,那麼當它重新獲得執行權時,堆棧中已經沒有可彈出的數據了。因爲 top
是
NULL,訪問 &top
肯定會導致崩潰
— 這顯然是可以避免的 bug。這個問題也突顯了併發數據結構的基本設計原則之一:決不要假設任何代碼會連續執行。
清單 15 給出解決了此問題的代碼。
清單 15. 從非阻塞堆棧彈出數據
T Stack<T>::pop( ) { while (1) { if (top == NULL) throw std::string(“Cannot pop from empty stack”); Node* next = top->next; if (top && __sync_bool_compare_and_swap(&top, top, next)) { // CAS return top->data; } } }
下一個問題比較複雜,但是如果您瞭解內存管理程序的工作方式(更多信息見 參考資料 中的鏈接),應該不太難理解。清單 16 展示這個問題。
清單 16. 內存的回收利用會導致 CAS 出現嚴重的問題
T* ptr1 = new T(8, 18); T* old = ptr1; // .. do stuff with ptr1 delete ptr1; T* ptr2 = new T(0, 1); // We can't guarantee that the operating system will not recycle memory // Custom memory managers recycle memory often if (old1 == ptr2) { … }
在此代碼中,無法保證 old
和 ptr2
有不同的值。根據操作系統和定製的應用程序內存管理系統的具體情況,完全可能回收利用已刪除的內存
— 也就是說,刪除的內存放在應用程序專用的池中,可在需要時重用,而不返回給系統。這顯然會改進性能,因爲不需要通過系統調用請求更多內存。儘管在一般情況下這是有利的,但是對於非阻塞堆棧不好。現在我們來看看這是爲什麼。
假設有兩個線程 — A 和 B。A 調用 pop
並恰在執行
CAS 之前失去執行權。然後 B 調用 pop
,然後壓入數據,數據的一部分來自從前面的彈出操作回收的內存。清單
17 給出僞代碼。
清單 17. 序列圖
Thread A tries to pop Stack Contents: 5 10 14 9 100 2 result = pointer to node containing 5 Thread A now de-scheduled Thread B gains control Stack Contents: 5 10 14 9 100 2 Thread B pops 5 Thread B pushes 8 16 24 of which 8 was from the same memory that earlier stored 5 Stack Contents: 8 16 24 10 14 9 100 2 Thread A gains control At this time, result is still a valid pointer and *result = 8 But next points to 10, skipping 16 and 24!!!
糾正方法相當簡單:不存儲下一個節點。清單 18 給出代碼。
清單 18. 從非阻塞堆棧彈出數據
T Stack<T>::pop( ) { while (1) { Node* result = top; if (result == NULL) throw std::string(“Cannot pop from empty stack”); if (top && __sync_bool_compare_and_swap(&top, result, result->next)) { // CAS return top->data; } } }
這樣,即使線程 B 在線程 A 試圖彈出數據的同時修改了堆棧頂,也可以確保不會跳過堆棧中的元素。
結束語
本系列討論瞭如何設計支持併發訪問的數據結構。您已經看到,設計可以基於互斥鎖,也可以是無鎖的。無論採用哪種方式,要考慮的問題不僅僅是這些數據結構的基本功能 — 具體來說,必須一直記住線程會爭奪執行權,要考慮線程重新執行時如何恢復操作。目前,解決方案(尤其是無鎖解決方案)與平臺/編譯器緊密相關。請研究用於實現線程和鎖的 Boost 庫,閱讀 John Valois 關於無鎖鏈表的文章(見 參考資料 中的鏈接)。C++0x
標準提供了 std::thread
類,但是目前大多數編譯器對它的支持很有限,甚至不支持它