此文已由作者邱晟授權網易雲社區發布。

歡迎訪問網易雲社區,了解更多網易技術產品運營經驗。

概述

並發往往和並行一起被提及,但是我們應該明確的是「並發」不等同於「並行」

? 並發 :同一時間 對待 多件事情 (邏輯層面)

? 並行 :同一時間 做(執行) 多件事情 (物理層面)

並發可以構造出一種問題解決方法,該方法能夠被用於並行化,從而讓原本只能串列處理的事務並行化,更好地發揮出當前多核CPU,分散式集群的能力。

但是,並發編程和人們正常的思維方式是不一樣的,因此才有了各種編程模型的抽象來幫助我們更方便,更不容易出錯的方式構建並發程序。下面將簡單介紹一些常見的並發編程模型,希望能幫助大家對並發編程有更多的興趣。這些模型都有各自的優勢,需要根據應用場景挑選,而挑選的前提是能夠深入地理解它們。

多線程編程模型

多線程模型是用於處理並發的最通用手段,在 C/C++/JAVA 等語言中廣泛存在。主要特性有:

l 多個相互獨立的執行流.

l 共享內存(狀態).

l 搶佔式的調度.

l 依賴鎖,信號量等同步機制

多線程程序容易編寫(因為寫的是順序程序),但是難分析,難調試,更容易出錯,常見的有競爭條件,死鎖,活鎖,資源耗盡,優先順序反轉… 等等。

為了降低多線程模型編寫難度,很多語言都一直在不斷地引入並發編程方面新的特性,例如Java。從最早1996年的JDK1.0 版本起就已經有了Thread,Runnable類,確立了最基礎的線程模型,這已經比直接調用POSIX介面構建多線程應用的方式有了很大的提高。然後在JDK5時引入了java.util.concurrent包,其中的線程池(Thread Pool,Executors)等類庫,使得Java並發編程的易用性有了更好的提升。

到了JDK7, Fork/Join框架被引入,雖然底層一樣是基於ExecutorService線程池的實現。但在編寫並發邏輯時會比傳統多線程方式更加直觀,開發者可以將一個大的作業抽象為幾個可以並發的子任務的結果整合;而每個子任務又可以繼續按此邏輯繼續劃分,充分發揮現代多核CPU的性能。

同時,Fork/Join框架中還內置了Work-Stealing的任務調度機制,能夠在盡量降低線程競爭的同時嘗試自動均衡各工作線程之間的任務負載。如下圖所示:

? 4個線程每個都有獨立的工作隊列,避免單任務隊列競爭

? 隊列中的任務採用類似LIFO方式進出。由於整體作業都是按照一個大任務fork出多個子任務來抽象,因此可以視為越大粒度的任務會沉在隊列的越底部。

? 當某個線程(示例中為線程D)的工作隊列為空時,該線程就會自動嘗試從另一個線程(示例中為線程A)的隊列底部」偷「一個任務過來執行。由於是從底部竊取的任務,可以假設這個任務將展開更多的子任務,從而減少竊取動作的產生,降低線程爭用頻率。

通過這些手段,Fork/Join框架能幫助開發者無需在考慮手動實現並發任務執行時的高效同步邏輯。

隨後,JDK8中又引入了並行流(Parallel Streams)的概念, 該特性基於Fork/Join框架,但在易用性方面繼續有所提升。並行流採用共享線程池的思路,從而連線程/線程池的配置邏輯都幫開發者簡化了。當然,正是因為這個共享池( ForkJoinPool.commonPool() )是被JVM管理,同時被JVM內的所有線程共享,也導致了一些隱患,如果開發者並沒有了解並行流的底層實現機制,則可能導致應用中利用到並行流的任務產生停滯現象。例如下面的代碼示例:

由於 WS.url(url).get()會觸發HTTP請求,因此執行到這一句代碼時,線程池會被阻塞在IO操作上,結果導致了當前JVM中所有並行流中的任務全部被阻塞。

Callback編程模型

「回調」是一個很容易理解的名詞。簡單來說:某個函數(A)可以接受另一個函數(B)作為參數,在執行流程到某個點時作為參數的函數B就會被函數A調用執行,這個行為就被稱為回調。

現實中,回調常常用於非同步事件。即,函數A一般會在函數B沒有被調用的情況下就先返回,而在某個非同步事件發生時再觸發調用函數B。

但是濫用回調嵌套,就會導致著名的」callback hell」問題,代碼難以閱讀和維護。例如下面的片段:

為了避免此類大坑,我們可以參考以下幾類解決方案:

l Promises/A+規範: 它是一種用於管理非同步回調的代碼結構和流程,一種回調的語法糖。可以把原本嵌套的回調函數展平,使得代碼邏輯更清楚。例如片段:

l Generator: 生成器/半協程方式: 可以將一個函數執行暫停,並保存上下文, 將控制權交還給調用者;當再次被調用時,能夠恢復當時的暫停狀態繼續執行。所以generator函數的行為表現和迭代器很類似,每次觸發它的時候可以獲取到新的結果,而不是像傳統函數全部執行結束後一口氣返回一系列值。 代碼片段:

l Async/Await: 可以視為Generator方式的語法糖,能夠更好地展示非同步調用的語義: async關鍵字用於表示該函數中有非同步操作;await關鍵字表示需要等待(非同步方式)後繼表達式的結果。

Actor編程模型

Actor模型首先是由Carl Hewitt在1973年提出定義, 隨後由Erlang OTP (Open Telecom Platform) 推廣開來。Actor屬於並發組件模型, 通過組件方式定義並發編程範式的高級階段,避免使用者直接接觸多線程並發或線程池等基礎概念,其消息傳遞更加符合面向對象的原始意圖。

傳統多數流行的語言並發是基於多線程之間的共享內存,使用同步機制來防止寫爭奪。而Actors使用消息模型,每個Actors在同一時間處理最多一個消息,可以發送消息給其他Actors,保證了單獨寫原則,從而巧妙避免了多線程的寫爭奪。

Actor模型不僅僅對於單機的並發應用開發有意義,對於分散式應用的開發也是一個可以大展手腳的場景: 節點之間互相獨立,只能靠消息通訊,非同步消息避免節點瓶頸等特性都非常貼合Actor模型的使用。

Actor模型的特點是:

l 萬物皆是Actor

l Actor之間完全獨立,只允許消息傳遞,不允許其他」任何」共享

l 每個Actor最多同時只能進行一樣工作

l 每個Actor都有一個專屬的命名Mailbox(非匿名)

l 消息的傳遞是完全非同步的;

l 消息是不可變的

在Java中,可以利用Akka進行Actor編程模型的應用開發。Akka 將自身定義為一套用於構建JVM上高並發,容錯式,分散式,消息驅動特性應用開發的工具包和運行環境。詳細介紹可參見官網: akka.io/

下面用代碼片段來展示下基於AKKA開發示例:

我們定義了兩個Actor: HelloWorld 和 Greeter.

l HelloWorld會處理幾個消息

n 啟動消息(可以將preStart方法的調用視為收到一個專屬啟動事件的處理): 主動向Greeter(ActorRef可以視為對應Actor的專屬Mailbox)發送一個Msg.GREET消息

n Msg.Done消息: 接收完該消息後,停止當前Actor

n 其他消息: 調用unhandled() 處理

l Greeter會處理這些消息:

n Msg.GREET消息: 向System.out輸出字元串, 並向消息的發送者回復一個Msg.Done消息

n 其他消息: 調用unhandled() 處理

HelloWorld,Greeter可以根據需要實例化在多個線程中執行,編碼過程中不需要考慮傳統多線程中的Lock/Wait/Notify等同步手段就能讓這兩個Actor之間分別指示對方完成相應動作。

CSP編程模型

CSP(Communicating Sequential Processes)是由Tony Hoare在1978的論文上首次提出的。 它是處理並發編程的一種設計模式或者模型,指導並發程序的設計,提供了一種並發程序可實踐的組織方法或者設計範式。通過此方法,可以減少並發程序引入的其它缺點,減少和規避並發程序的常見缺點和bug,並且可以被數學理論所論證。

CSP將程序分成兩種模塊,Processor 與 Channel:Processor 代表了執行任務的順序單元,它們內部沒有並發,而Channel代表了並發流之間的信息交互,如共享數據的交換、修改、消息傳遞等等。

除了Channel,Processor之間再無聯繫,這樣就將並發同步作用縮小在Channel之處,使得問題得到了約束、集中。同步操作與爭用並沒有消失,只是聚焦在Channel之上。Processor之間的協作,Channel提供原語來支持,如Barrier等。

CSP 的好處是使得系統較為清晰,Processor 之間是解耦合的,職責也非常清楚,容易理解和維護。

l 工作者之間不直接進行通信

l 工作者向不同的通道中發布自己的消息(事件)。其他工作者們可以在這些通道上監聽消息,發送者不知道具體誰在執行(匿名)

l 消息交互是同步方式

在Java中對於CSP模型的實現庫有JCSP。 同時在JDK中的SynchronousQueue,和CSP中的Channel有異曲同工之妙。Executors.newCachedThreadPool()中就利用到了SynchronousQueue,任務提交者是並不清楚底層哪個線程會處理提交的任務,並且當提交任務操作完成時必然已經有某個線程接受了該任務(並不代表線程開始執行),因此提交操作這次消息交互是同步的方式。這和Executors.newFixedThreadPool()之類創建的線程池是截然不同的,其他線程池在提交操作完成時,任務分配給線程這個動作是非同步的。

此外,Go語言內置的goroutines & channels並發模型就是參考了CSP的思想,因此Go的並發編程強調不要利用共享內存來進行線程通訊,而應該依靠通訊來共享數據(Do not communicate by sharing memory; instead, share memory by communicating),盡量避免鎖和線程爭用。

參考資料

l http://web.stanford.edu/~ouster/cgi-bin/papers/threads.pdf

l en.wikipedia.org/wiki/A

l https://en.wikipedia.org/wiki/Communicating_sequential_processes

l https://talks.golang.org/2012/waza.slide#1

l https://www.quora.com/What-are-the-differences-between-parallel-concurrent-and-asynchronous-programming

l http://wiki.commonjs.org/wiki/Promises/A

l http://www.ibm.com/developerworks/cn/java/j-csp1.html

l http://blog.takipi.com/forkjoin-framework-vs-parallel-streams-vs-executorservice-the-ultimate-benchmark/

l https://www.cs.kent.ac.uk/projects/ofa/jcsp/cpa2007-jcsp.pdf

l http://tutorials.jenkov.com/java-concurrency/index.html

l http://www.raychase.net/698

免費領取驗證碼、內容安全、簡訊發送、直播點播體驗包及雲伺服器等套餐

更多網易技術、產品、運營經驗分享請點擊。


推薦閱讀:
相关文章