繼續上週的編程範式話題,今天想聊一下併發範式。java
真正的併發式編程,毫不只是調用線程API或使用synchronized、lock之類的關鍵字那麼簡單。從宏觀的架構設計,到微觀的數據結構、流程控制乃至算法,相比一般的串行式編程均可能發生變化。絕不誇張的說,是又一場思想和技術上革命。程序員
在平常開發中,併發編程難度是比較高的,屬於高級程序員才能掌握的內容。其難點在哪裏,咱們平常習慣的是線性思惟,這與併發編程的多維世界觀是不一樣的,提高思考的維度無疑是艱難。但還好,在大神們的努力下,已逐漸化繁爲簡,這也是併發範式帶來的力量。在併發領域有許多的模型,讓咱們來巡禮一下。算法
併發編程以資源共享和競爭爲主線。這意味着程序設計將圍繞進程的劃分與調度、進程之間的通訊與同步等來展開。合理的併發式設計須要諸多方面的權衡考量。數據庫
線程是對底層硬件過程的形式化,是併發編程的核心。不一樣的線程各自獨立運行,有如一個個的平行宇宙。可是,併發編程並不只僅串行化編程的疊加,主要的差別在於,線程之間存在共享和競爭。共享資源會帶來哪些問題呢?編程
當線程1對共享數據進行修改時,線程2有可能會讀處處於中間狀態的數據,這個問題稱之爲髒讀。安全
解決的思路比較簡單,就是讓線程1僅提交最終修改結果,在修改過程當中產生並使用快照數據。這種相似影分身的技術稱之爲MVCC(Multi-Version Concurrency Control)。數據結構
當線程1對數據進行修改時,若是線程2同時修改,因爲採用了MVCC,雙方各自沒法看到,那最終提交時,極可能會形成其中一個線程結果與預期不一致,這個問題稱爲丟失更新。
其解決方法是加鎖,在修改前進行加鎖,一旦佔用,則第二個線程沒法獲取。多線程
髒讀和丟失更新是須要同時考慮的,因此標準的多線程處理是同時使用到了MVCC和鎖這兩個技術。架構
在已解決了丟失更新和髒讀的狀況下,下面要考慮屢次讀取的狀況。以下圖所示,線程1對數據集進行了屢次讀取,可是部分數據在線程2中進行了更新,這時候出現了線程1在沒有任何做爲的狀況下,兩次讀取不一致的狀況!!!這個問題稱爲幻讀。併發
解決幻讀的方法是擴大數據的鎖範圍,不只僅是更改過的記錄,全部讀取的記錄都要加鎖。
這就是目前咱們最主流的併發與鎖的實現思路方法,有很是普遍的使用。不知道你們讀完這段的感受怎麼,我看的時候,第一個感受是複雜,真的很是的燒腦,因爲大量概念的堆積對於初學者來講很是不友好;第二個感受是矛盾,按照最終幻讀的解決方案,實際上就是放棄了程序間的並行,繞了一圈,又回到了原點。正由於如此,目前主流的數據庫,實際上默認都是放棄對於幻讀問題解決的,這也是開發上的一大坑。
綜合的來看,這種解決方式學習成本很高,並且還沒能解決所有的問題,並不能讓人滿意。有沒有更好點的方法,讓咱們繼續。
傳統併發模型中,最使人糾結的無疑就是共享數據訪問這塊了。若不爽,就另闢蹊徑。咱們能不能不對共享數據進行寫入呢?有什麼樣的程序是隻讀不寫的呢,大神們已經找到了答案,就是上週介紹的函數式編程。
首先想說明的事,純函數式的編程功能上並不完備,有很是多的缺陷,但其有一個自然的適用場景,就是數學運算分析,也就是咱們如今時常掛在嘴邊的大數據計算。
因爲拋棄掉了共享狀態,其代碼的健壯性和擴展性獲得了大大的加強,只要有足夠的計算資源就能夠處理無限大的數據。
函數式編程思惟比較數學化,難度是比較高的,在此基礎上,誕生了Lambda框架,是對應用模式的固化,有助於下降學習成本和大範圍推廣。Lambda框架既使用了能夠進行大規模批處理的MapReduce技術,也使用了能夠快速處理數據並及時反饋的流處理技術,這樣的混搭可以爲大數據問題提供擴展性、響應性和容錯性都能優秀的解決方案。
Lambda架構也能夠這樣來描述:在該架構中,被讀取的數據是不可變的,在並行處理過程當中數據會依次進入批處理系統(batch system)與流處理系統。從邏輯上看,傳輸過程發生了兩次,一次是在批處理中,一次是在流處理中。在查詢時,當這二者都返回結果後,纔算是完成一次完整的查詢。
函數式編程模型的應用使得併發編程的應用踏入了工業級,帶動了大數據的熱潮。可是其解決思路是拋棄了可變狀態,服務是有損的。對於必須提供無損服務的場景該如何進行改進呢。
從最一開始線程與鎖的模型中,咱們能夠看到串行化是最重的解決方案,可是爲了串行化,咱們須要MVCC、鎖等一系列的工具,比較複雜,Actor模型就是用來簡化此類操做的。
Actor模型中抽象出了兩個概念Actor和Mailbox,Actor就是指代共享數據,Mailbox管理數據的操做。對於每一個Actor的操做,要經過mailbox來進行,在mailbox端實現了隊列的控制,從而實現了序列化的效果。
Actor模型會帶來一些額外的好處:
class Pong extends Actor { def act() { var pongCount = 0 while (true) { receive { case Ping => if (pongCount % 1000 == 0) Console.println("Pong: ping " + pongCount) sender ! Pong pongCount = pongCount + 1 case Stop => Console.println("Pong: stop") exit() } } } }
不少狀況下咱們須要一個高效的、線程安全的併發解決方案。高效意味着耗用資源要少,程序處理速度要快;線程安全也很是重要,這個在多線程下能保證數據的正確性。有一個解決方案是原子變量。
一般狀況下,在Java裏面,++i或者--i不是線程安全的,這裏面有三個獨立的操做:得到變量當前值,爲該值+1/-1,而後寫回新的值。在沒有額外資源能夠利用的狀況下,只能使用加鎖才能保證讀-改-寫這三個操做是「原子性」的。
下面是示例代碼:
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return next; } }
在這裏採用了CAS操做,每次從內存中讀取數據而後將此數據和+1後的結果進行CAS操做,若是成功就返回結果,不然重試直到成功爲止。而compareAndSet利用JNI來完成CPU指令的操做。
原子變量在一些對性能有極端要求的系統中(好比Jetty、Tomcat)有很是普遍的應用,是一種精益求精的體現,其在可靠性和性能方面表現很突出,但在易用性方面比較偏計算機思惟,理解難度較大,並不夠簡潔,須要反覆練習才能掌握。
在今天的篇文章中,列舉了併發範式的四個主流模型:線程與鎖、函數式編程、Actor、原子變量。能夠看到,每一個模型都是在功能、性能和易用之間尋求了一種平衡,並無一種模型在功能、性能和易用三方面同時達到最優,也就是說沒有銀彈。 這是咱們面對併發問題時的困境,也是挑戰,也正說明了併發並非一個簡單的線性問題,咱們須要針對具體場景、具體問題進行分析,尋找最適合的解決方法,這也是開發人員須要養成的一種重要素養。