設計并發數據結構

簡介

現在,您的計算機有四個 CPU 核;并行計算 是最時髦的主題,您急于掌握這種技術。但是,并行編程不只是在隨便什么函數和方法中使用互斥鎖和條件變量。C++
開發人員必須掌握的關鍵技能之一是設計并發數據結構。本文是兩篇系列文章的第一篇,討論如何在多線程環境中設計并發數據結構。對于本文,我們使用 POSIX Threads 庫(也稱為 Pthreads;見 參考資料 中的鏈接),但是也可以使用 Boost Threads 等實現(見 參考資料 中的鏈接)。
本文假設您基本了解基本數據結構,比較熟悉 POSIX Threads 庫。您還應該基本了解線程的創建、互斥鎖和條件變量。在本文的示例中,會相當頻繁地使用pthread_mutex_lockpthread_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 操作

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;
}

設計并發阻塞隊列

目前,如果讀線程試圖從沒有數據的隊列讀取數據,僅僅會拋出異常并繼續執行。但是,這種做法不總是我們想要的,讀線程很可能希望等待(即阻塞自身),直到有數據可用時為止。這種隊列稱為阻塞的隊列。如何讓讀線程在發現隊列是空的之后等待?一種做法是定期輪詢隊列。但是,因為這種做法不保證隊列中有數據可用,它可能會導致浪費大量 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 線程和并發隊列算法。
轉自(https://www.ibm.com/developerworks/cn/aix/library/au-multithreaded_structures1/index.html?ca=drs-

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容