《七週七併發模型》——痛不欲生卻欲罷不能

關於本書

七週七併發模型》是我在書店尋找Actor相關資料時偶遇的一本200多頁的小冊子。目錄看似簡單,實際的內容卻涵蓋了多種編程語言、併發模型與框架,對我實在是一大考驗。斷斷續續地看了一個多月才勉強讀完一遍,很粗淺地對書中提到的模型有了大體的瞭解。因爲其中每一個模型都須要大量的基礎知識支撐,很容易讓本身陷入碎片化學習的泥沼,所以暫時我就把它當個入門的索引了。java

七個併發模型

  • 線程與鎖:這是最傳統的、圍繞共享數據進行併發編程的基礎。
  • 函數式編程:FP主要依賴於無可變狀態、無反作用的純函數這兩大特性,擺脫傳統的命令式編程因共享數據致使的併發衝突。
  • Clojure之道:結合FP與CP的優點進行的混搭,借鑑了FP的持久數據結構以免競爭。這一章須要的Clojure知識太多,我只看了個大概。
  • Actor:藉助Actor這種自帶事件郵箱的事件驅動的併發最小實體,實現無數據共享的併發與協做。
  • 通訊順序進程:藉助共用的消息隊列和相對獨立的Go併發狀態機,實現與Actor互補的另外一種併發模型。好比.NET裏的async與await內部實現就借鑑了這種機制。
  • 數據級並行:藉助GPU在圖像處理等方面進行矩陣或向量相關的等大量數字計算。
  • Lambda架構:時下最潮的基於Map-Reduce和批處理的分佈式協做模型。

主要實例

  • 哲學家用餐:有一張餐桌,若干個哲學家。哲學家或在進餐、或在思考。進餐時哲學家須要拿起左右兩邊的筷子,進餐結束後則放下筷子開始思考。
  • 詞頻統計:根據一個較大尺寸的文本檔(多是XML格式),統計其中的每一個單詞及其出現頻率。實質是個生產者-消費者問題。

併發與並行

  • 併發:同一時間應對(Dealing With)多件事情的能力。另外一種解釋是同時有多個操做出現。
  • 並行:同一時間執行(Doing)多件事件的能力。另外一種解釋是以同時多個操做完成單個目標。

阿姆達爾定律(Amdahl's law)

公式:並行加速率=(Ws+Wp)/(Ws+Wp/p)

受計算中並行份量Wp、串行份量Ws、處理器數量p影響,加速化曲線呈S狀梯形上升。算法

路線圖

如前所述,因爲涉及的語言和框架的跨度較大,每一個章節又相對獨立、知識點相對零散,因此我沒法從中窺探出一幅真正意義上的路線圖,只能記下其中的聊聊數筆。編程

首先是關於餐桌加鎖仍是筷子加鎖的抉擇

以哲學家進餐爲例,誰同時拿上左右兩支筷子才能吃上飯,其餘人則只能等待他放下筷子才能機會拿齊一雙筷子進餐。這就是線程與鎖的故事——簡單而粗暴。這一把把筷子就比如是一把把的鎖,這鍋飯就比如你們競爭的共享數據。沒拿到筷子的人,即被阻塞在不停搶筷子的循環裏——天知道我何時能搶到?安全

因爲存在多把鎖的同時競爭,很容易形成死鎖。因而引入了一個簡單而有效的規則:『始終按照一個全局的、固定的順序獲取多把鎖』。好比說約定老是先取左手邊的筷子,再取右手邊的。因而獲得以下的一個示意圖。圖中的每一個元素、每一個環節都須要進行同步化。數據結構

線程與鎖示意圖

儘管問題獲得初步解決,但內置鎖在線程被阻塞後沒法中斷,形成線程假死,並且鎖越多意味着死鎖的風險也越大。因而引入可中斷的鎖、超時鎖、條件變量等一些彌補機制。其中的條件變量condition機制,在Java 5.0和.NET 4.0以後的併發框架中獲得了普遍應用。在引入Condition後,獲得哲學家進餐問題的第二個版本:再也不是每支筷子對應一把鎖,而改成視整張餐桌爲一把鎖,再把左右哲學家進餐結束視做競爭條件。這樣,當左右兩邊的哲學家進餐結束時,就意味着本身能夠進餐了。架構

private void eat() {
  // 先獲取鎖
  table.lock();
  try {
    // 若是條件知足就解鎖並await一直等待
    while (left.eating || right.eating)
      condition.await();
    // 接收到其餘線程經過signal()或signalAll()發出的信號
    // 因而條件得以知足,加鎖後訪問共享資源
    eating = true;  
  }
  finally { table.unlock(); }
}

private void think() {
  table.lock();
  try {
    eating = false;
    left.condition.signal();
    right.condition.signal();
  }
  finally { table.unlock(); }
}

而後是詞頻統計的第一個版本

拆解問題,能夠得出統計須要三個步驟:用下載的XML文本構造出若干張Page,而後逐頁分析每張Page裏出現的單詞Word,最後合計每一個Word出現的次數。併發

在一般的串行化方案(逐行解析並統計)以後,依次給出了以下三個併發解決方案:框架

  • 1個生產者-1個消費者:共享一個queue和map。注意使用blocking queue,在隊列空或者滿時發生阻塞,以免生產與消費速度失配形成的影響。

1個生產者-1個消費者

  • 1個生產者-N個消費者:共享一個queue和map,但使用的併發專用的ConcurrentHashMap以免多個線程過分競爭map,提升消費速度。

1個生產者-N個消費者

  • 1個生產者-N個消費者:共享一個queue,每一個消費者本身維護一個map,在消費者完成統計時再將統計結果彙總進共用的ConcurrentHashMap。這個就是Map-Reduce的基本原理了。

1個生產者-N個自帶Map的消費者

接着一腳跨入函數式編程

我對函數式編程FP的認識,就是y=f(x),對函數f給定x就必定獲得y,不會由於f或者x持有其餘的狀態而產生不一樣於y的其餘結果。而在FP的世界,除了常量與遞歸成爲常態外,最多見的就是map、reduce、fold等一些映射與聚合函數了。我只想說,FP的實現代碼真是簡潔得可怕!異步

  • 這是串行版本。
(defn get-words [text] (re-seq #"\w+" text))

(defn count-words-sequential [pages]
  (frequencies (mapcat get-words pages)))
  • 接着是第一個並行版本。它每次會取一個Page交給get-words,而後利用merge-with產生的局部函數,交給pmap逐頁進行結果合併。
(defn count-words-parallel [pages]
  (reduce (partial merge-with +)
    (pmap #(frequencies (get-words %)) pages)))
  • 而後是對Page進行分組,按100頁做爲一個批次進行批處理的版本。這個出發點相似鎖版本的第3個方案。每一個批次先小計,最後再合計。
(defn count-words [pages]
  (reduce (partial merge-with +)
    (pmap count-words-sequential (partition-all 100 pages))))
  • 最後用二分法的摺疊fold進行的實現。這種狀況下,整個彙總過程相似一棵Tree,上層的結點reduce產生統計結果後,才合併到下一層的結點,最後獲得的根結點即爲統計結果。用下面這個parallel-frequencies替換掉frequencies。
(defn parallel-frequencies [coll]
  (r/fold
    (partial merge-with +)
    (fn [counts x] (assoc counts x (inc (get counts x 0))))
    coll))

緊接着是Clojure的標識與狀態分離

這部分多數涉及Clojure的框架,因此我只關注了與FP密切相關的『持久數據結構』。async

理解持久數據結構,有點相似於理解『引用』與引用指向的『內存塊』。而在FP裏,純粹的函數是不會修改既有結構的,由於它老是產生一個新的結果。

(def list_1 (1, 2, 3))
(def list_2 (cons 4 list_1))
(def list_3 (cons 5 (rest list_1)))

這段代碼將產生下面這樣的一個鏈表結構:

標識與狀態分離

由此展現了CP與FP的一大重要區別:在CP中,一個變量既是標識Identity也是狀態State,你在此時拿到某個列表是是(1,2,3),下一刻可能被別人改成(1,3,4,5),而在FP中則會始終是(1,2,3),這即是持久數據結構的本質。即一個標識,會對應多個版本、隨時間變化的值。

到了我最感興趣的Actor部分

「使用Actor就像租車——若是咱們須要,能夠快速便捷地租到一輛;若是車輛發生故障,也不須要本身修理,直接打電話給租車公司更換一輛便可。」

每一個Actor,都是一個封閉的、有狀態的、自帶郵箱、經過消息與外界進行協做的併發實體。在Actors之間的消息發送、接收是併發的,可是在Actor內部,消息被郵箱存儲後都是串行處理的。即Actor在同一時刻只會對一條 異步消息作出迴應,從而回避鎖策略。

使用Actor編程有個很不一樣尋常的編程思想——「任其崩潰」!這是由於每一個Actor都被其監督者管理,這些不一樣層次的Actor及其監督者搭建成一棵完整的Actor模型樹。這棵樹的葉結點是各類Actor,非葉的結點則是監督者。當某個Actor出現錯誤而崩潰時,由其監督者採起重啓、忽略錯誤、記錄緣由等措施。

消化掉以上兩點,我就開始讀《Reactive Messaging Patterns with the Actor Model》做爲進階了。最後,一樣援引Smalltalk設計者、面向對象之父Alan Kay的一段話結束本節。好吧,我認可誤入歧途了。

好久之前,我在描述「面向對象編程」時使用了「對象」這個概念。很抱歉,這個概念讓許多人誤入歧途,他們將學習重心放在了「對象」這個次要的方面。真正主要的方面是「消息」……建立一個規模宏大且能夠生長的系統,關鍵在於其模塊之間應如何交流,而不在於其內部的屬性,以及行爲應該如何表現。

再者是與Actor緊密聯繫的CSP

CSP(Communicating Sequential Process,通訊順序進程),和Actor比較相似。CSP不關心消息是誰發送的,只關心用於消息傳遞的那個通道Channel,我把這個Channel視做一個線程安全的消息隊列,消息兩端與消息隊列自己是脫耦的。而在這方面,Actor模型中的消息兩端是明確已知的,消息隊列也是由Actor郵箱自帶的。

CSP的執行體主要是各類Go塊。這個Go塊就當是一個狀態機,這與C#中async和await的實現是一致的,具體參考《CLR via C#》第4版第649頁『28.3 編譯器如何將異步函數轉換爲狀態機』。

GPU很閒,須要幫助嗎

這部分主要圍繞矩陣、向量等線性代數方面所需的大量數值計算,引入OpenCL驅動GPU進行並行計算。這方面我沒什麼研究,直接跳過了。

終於到了Lambda這個最終Boss

這章我只知道Map-Reduce是Lambda的主要基石,將問題分解爲Map和Reduce兩個部分是一切的關鍵。其中,Map負責把輸入映射爲若干對key-value,而後由Reduce負責聚合這些key-value,輸出最終數據。

除了Map-Reduce,爲了解決報表與分析等一些須要及時反饋的信息,又引入了流處理技術。個人理解,就是對原始數據進行一個合理的分片,再利用批處理生成一個與報表需求一致的中間結果批處理視圖,最後再借由服務層按需拼湊成最終結果。這個部分,合理的分片和拼湊算法是關鍵。

若是及時響應的要求還要更高,那麼還有個加速層的東西,根據最後一次生成批處理視圖的原始數據,直接生成相應的派生信息。這個部分,決定哪些數據過時、如何讓其過時是關鍵。

結語

分佈式的世界,分佈式的軟件。

相關文章
相關標籤/搜索