天天看點

高度并發應用程式的設計原則和模式

作者:JAVA搬運

1. 概要

在本教程中,我們将讨論一些設計原則和模式,它們是随着時間的推移建立起來的,用來建構高度并發的應用程式。

然而,值得注意的是,設計并發應用程式是一個廣泛而複雜的主題,是以沒有任何教程可以聲稱其内容詳盡。我們将在這裡介紹一些常用的技巧!

2. 并發的基礎

在我們繼續下一步之前,讓我們花點時間了解一下基礎知識。首先,我們必須澄清我們對什麼叫做并發程式的了解。如果多個計算同時發生,我們稱之為程式并發。

現在,請注意,我們提到了同時發生的計算——也就是說,它們是同時進行的。但是,它們可能同時執行,也可能不同時執行。了解這種差異很重要,因為同時執行計算被稱為并行。

2.1. 如何建立并發子產品?

了解如何建立并發子產品很重要。有許多選擇,但我們在這裡将集中讨論兩個流行的選擇:

  • 程序:程序是一個正在運作的程式的執行個體,它與同一台機器上的其他程序相隔離。機器上的每個程序都有自己獨立的時間和空間。是以,通常不可能在程序之間共享記憶體,它們必須通過傳遞消息來進行通信。
  • 線程:另一方面,線程隻是程序的一部分。一個程式中可以有多個線程共享同一個記憶體空間。但是,每個線程都有唯一的堆棧和優先級。線程可以是本機的(由作業系統本機排程)或綠色的(由運作時庫排程)。

2.2. 并發子產品是如何互動的?

如果并發子產品不比通信,這是非常理想的,但事實往往并非如此。這就産生了兩種并發程式設計模型:

  • 共享記憶體:在這種模型中,并發子產品通過讀寫記憶體中的共享對象進行互動。這通常會導緻并發計算的交錯,進而導緻競争情況。是以,它會不确定地導緻不正确的狀态。
高度并發應用程式的設計原則和模式
  • 消息傳遞:在這種模型中,并發子產品通過通信信道互相傳遞消息來進行互動。這裡,每個子產品按順序處理傳入的消息。因為沒有共享狀态,是以相對來說更容易程式設計,但是這仍然沒有擺脫競争條件!
高度并發應用程式的設計原則和模式

2.3. 并發子產品如何執行?

摩爾定律在處理器的時脈速度方面碰壁已經有一段時間了。相反,由于我們必須增長,我們已經開始将多個處理器封裝到同一個晶片上,通常稱為多核處理器。但是,仍然很少聽說處理器擁有超過32個核心。

現在,我們知道一個核心一次隻能執行一個線程或一組指令。然而,程序和線程的數量可以分别是數百和數千。那麼,它到底是如何工作的呢?這就是作業系統為我們模拟并發性的地方。作業系統通過時間片來實作這一點,這實際上意味着處理器會頻繁地、不可預測地、不确定地線上程之間切換。

3. 并發程式設計中的問題

當我們讨論設計并發應用程式的原則和模式時,明智的做法是首先了解典型的問題是什麼。

在很大程度上,我們在并發程式設計方面的經驗包括使用本地線程和共享記憶體。是以,我們将集中讨論由此産生的一些常見問題:

  • 互斥(同步原語):交錯線程需要獨占通路共享狀态或記憶體,以保證程式的正确性。共享資源的同步是實作互斥的常用方法。有幾種同步原語可供使用,例如,鎖、螢幕、信号量或互斥體。但是,互斥程式設計容易出錯,并且經常會導緻性能瓶頸。有幾個與此相關的充分讨論的問題,如死鎖和活鎖。
  • 上下文切換(重量級線程):每個作業系統都有對并發子產品(如程序和線程)的本地支援,盡管有所不同。如前所述,作業系統提供的一個基本服務是通過時間片排程線程在有限數量的處理器上執行。現在,這實際上意味着線程在不同狀态之間頻繁切換。在這個過程中,需要儲存和恢複它們的目前狀态。這是一項耗時的活動,會直接影響整體吞吐量。

4. 高并發性的設計模式

現在,我們已經了解了并發程式設計的基礎和其中的常見問題,是時候了解一些避免這些問題的常見模式了。我們必須重申,并發程式設計是一項需要大量經驗的艱巨任務。是以,遵循一些既定的模式可以使任務變得更容易。

4.1. 基于角色的并發

我們将讨論的關于并發程式設計的第一個設計稱為參與者模型。這是一個并發計算的數學模型,基本上把一切都當作一個演員。行動者可以互相傳遞消息,并且作為對消息的響應,可以做出本地決策。這是由卡爾·休伊特首先提出的,并啟發了許多程式設計語言。

Scala并發程式設計的主要構造是actors。Actor是Scala中的普通對象,我們可以通過執行個體化Actor類來建立它。此外,Scala Actors庫提供了許多有用的actor操作:

class myActor extends Actor {
    def act() {
        while(true) {
            receive {
                // Perform some action
            }
        }
    }
}           

在上面的示例中,在無限循環中調用receive方法會挂起actor,直到消息到達。消息到達後,将從參與者的郵箱中删除,并采取必要的措施。

高度并發應用程式的設計原則和模式

參與者模型消除了并發程式設計的一個基本問題——共享記憶體。參與者通過消息進行通信,每個參與者按順序處理來自其專用郵箱的消息。然而,我們通過線程池執行actors。我們已經看到,本機線程可能是重量級的,是以數量有限。

當然,這裡還有其他模式可以幫助我們——我們将在後面讨論這些!

4.2.基于事件的并發

基于事件的設計明确地解決了本地線程産生和運作成本高的問題。基于事件的設計之一是事件循環。事件循環使用事件提供程式和一組事件處理程式。在這個設定中,事件循環阻塞事件提供程式,并在事件到達時将事件分派給事件處理程式。

基本上,事件循環隻不過是一個事件排程器!事件循環本身可以隻在一個本機線程上運作。那麼,事件循環中到底發生了什麼?讓我們看一個非常簡單的事件循環的僞代碼作為例子:

while(true) {
    events = getEvents();
    for(e in events)
        processEvent(e);
}           

基本上,我們的事件循環所做的就是不斷地尋找事件,當找到事件時,處理它們。這種方法非常簡單,但是它獲得了事件驅動設計的好處。

使用這種設計建構并發應用程式給了應用程式更多的控制。此外,它還消除了多線程應用程式的一些典型問題,例如死鎖。

高度并發應用程式的設計原則和模式

JavaScript實作事件循環來提供異步程式設計。它維護一個調用棧來跟蹤所有要執行的函數。它還維護一個事件隊列,用于發送新函數進行處理。事件循環不斷檢查調用堆棧,并從事件隊列中添加新函數。所有異步調用都被分派到web APIs,通常由浏覽器提供。

事件循環本身可以在單線程上運作,但是web APIs提供了單獨的線程。

4.3. 非阻塞算法

在非阻塞算法中,一個線程的挂起不會導緻其他線程的挂起。我們已經看到,我們的應用程式中隻能有有限數量的本機線程。現在,阻塞在一個線程上的算法顯然會顯著降低吞吐量,并阻止我們建構高度并發的應用程式。

非阻塞算法總是利用底層硬體提供的比較和交換原子原語。這意味着硬體會将記憶體位置的内容與一個給定值進行比較,隻有當它們相同時,它才會将該值更新為一個新的給定值。這看起來很簡單,但是它有效地為我們提供了一個原子操作,否則将需要同步。

這意味着我們必須編寫新的資料結構和庫來利用這種原子操作。這給了我們一個巨大的多種語言的無等待和無鎖實作。Java有幾種非阻塞的資料結構,如AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference。

考慮一個應用程式,其中多個線程試圖通路相同的代碼:

boolean open = false;
if(!open) {
    // Do Something
    open=false;
}           

顯然,上面的代碼不是線程安全的,它在多線程環境中的行為是不可預測的。這裡我們的選擇是要麼用鎖同步這段代碼,要麼使用原子操作:

AtomicBoolean open = new AtomicBoolean(false);
if(open.compareAndSet(false, true) {
    // Do Something
}           

正如我們所看到的,使用AtomicBoolean這樣的非阻塞資料結構有助于我們編寫線程安全的代碼,而不會沉迷于鎖的缺點!

5. 程式設計語言支援

我們已經看到有多種方法可以構造一個并發子產品。雖然程式設計語言确實有所不同,但主要是底層作業系統如何支援這個概念。然而,由于本地線程支援的基于線程的并發性在可伸縮性方面遇到了新的障礙,我們總是需要新的選擇。

實作我們在上一節中讨論的一些設計實踐被證明是有效的。然而,我們必須記住,這樣做确實會使程式設計變得複雜。我們真正需要的是能夠提供基于線程的并發能力,同時又不會帶來不良影響的東西。

我們可用的一個解決方案是綠色線程。綠色線程是由運作時庫排程的線程,而不是由底層作業系統本機排程的線程。雖然這并沒有消除基于線程的并發性中的所有問題,但在某些情況下,它确實可以給我們帶來更好的性能。

現在,使用綠色線程不是小事,除非我們選擇使用的程式設計語言支援它。并不是每種程式設計語言都有這種内置的支援。此外,我們籠統地稱之為綠色線程的東西可以由不同的程式設計語言以非常獨特的方式實作。讓我們看看這些選項中的一些。

5.1. Go

Go程式設計語言中的Goroutines是輕量級線程。它們提供可以與其他函數或方法同時運作的函數或方法。Goroutines非常便宜,因為它們首先隻占用幾千位元組的堆棧大小。

最重要的是,goroutines與更少數量的本地線程複用。此外,goroutines使用通道互相通信,進而避免通路共享記憶體。我們幾乎得到了我們需要的一切,猜猜是什麼——什麼都不用做!

5.2. Erlang

在Erlang中,每個執行線程被稱為一個程序。但是,這不太像我們到目前為止讨論的過程!Erlang程序是輕量級的,占用記憶體少,建立和處理速度快,排程開銷低。

在幕後,Erlang程序隻不過是運作時處理排程的函數。此外,Erlang程序不共享任何資料,它們通過消息傳遞互相通信。這就是我們首先稱這些為“過程”的原因!

5.3. Java

Java并發性的故事是一個不斷發展的過程。Java确實支援綠色線程,至少對于Solaris作業系統是這樣。然而,由于超出本教程範圍的障礙,這一部分已經停止。

從那時起,Java中的并發性就完全是關于本機線程以及如何聰明地使用它們!但是由于顯而易見的原因,我們可能很快就會在Java中有一個新的并發抽象,叫做纖程。Project Loom提議引入延續和纖程,這可能會改變我們用Java編寫并發應用程式的方式!

這隻是對不同程式設計語言中可用内容的一個初步了解。其他程式設計語言嘗試用更有趣的方式來處理并發性。

此外,值得注意的是,在設計高度并發的應用程式時,上一節中讨論的設計模式的組合,以及對類似綠色線程的抽象的程式設計語言支援,會非常強大。

6. 高并發應用

現實世界中的應用程式通常有多個元件通過網絡互相互動。我們通常通過網際網路通路它,它由多種服務組成,如代理服務、網關、web服務、資料庫、目錄服務和檔案系統。

在這種情況下,我們如何確定高并發性?讓我們探索其中的一些層,以及我們可以用來建構高度并發應用程式的選項。

正如我們在上一節中看到的,建構高并發性應用程式的關鍵是使用這裡讨論的一些設計概念。我們需要為這項工作選擇正确的軟體——那些已經結合了這些實踐的軟體。

6.1. 網絡層

網絡層通常是使用者請求到達的第一層,在這裡提供高并發性是不可避免的。讓我們看看有哪些選擇:

  • Node(也稱為NodeJS或Node.js)是一個開源的跨平台JavaScript運作時,建立在Chrome的V8 JavaScript引擎上。Node在處理異步I/O操作方面工作得非常好。Node之是以做得這麼好,是因為它在單線程上實作了一個事件循環。事件循環在回調的幫助下異步處理所有阻塞操作,如I/O。
  • nginx是一個開源的web伺服器,我們通常将它用作反向代理。nginx提供高并發性的原因是它使用異步的、事件驅動的方法。nginx在單線程中運作主程序。主程序維護執行實際處理的工作程序。是以,工作程序同時處理每個請求。

6.2. 應用層

在設計應用程式時,有幾個工具可以幫助我們建構高并發性。讓我們來看看這些可用的庫和架構中的一些:

  • Akka是一個用Scala編寫的工具包,用于在JVM上建構高度并發和分布式的應用程式。Akka處理并發的方法是基于我們前面讨論的actor模型。Akka在參與者和底層系統之間建立了一個層。該架構處理建立和排程線程、接收和發送消息的複雜性。
  • Project Reactor是一個反應式庫,用于在JVM上建構非阻塞應用程式。它基于Reactive Streams規範,專注于高效的消息傳遞和需求管理(背壓)。反應器操作者和排程者可以維持消息的高吞吐率。一些流行的架構提供了reactor實作,包括Spring WebFlux和RSocket。
  • Netty是一個異步的、事件驅動的網絡應用架構。我們可以使用Netty來開發高度并發的協定伺服器和用戶端。Netty利用NIO,這是一個Java APIs集合,通過緩沖區和通道提供異步資料傳輸。它為我們提供了幾個優勢,如更好的吞吐量,更低的延遲,更少的資源消耗,以及最大限度地減少不必要的記憶體複制。

6.3. 資料層

最後,沒有資料的應用程式是不完整的,資料來自持久存儲。當我們讨論關于資料庫的高并發性時,大部分焦點仍然在NoSQL家族上。這主要是由于NoSQL資料庫可以提供線性可伸縮性,但在關系型資料庫中很難實作。讓我們來看兩個流行的資料層工具:

  • Cassandra是一個免費的開源NoSQL分布式資料庫,在商用硬體上提供高可用性、高可伸縮性和容錯能力。但是,Cassandra不提供跨多個表的ACID事務。是以,如果我們的應用程式不需要強一緻性和事務,我們可以受益于Cassandra的低延遲操作。
  • Kafka是一個分布式流媒體平台。Kafka按稱為主題的類别存儲記錄流。它可以為記錄的生産者和消費者提供線性水準可伸縮性,同時提供高可靠性和耐用性。分區、副本和代理是它提供大規模分布式并發的一些基本概念。

6.4. 緩存層

在現代世界中,沒有一個以高并發性為目标的web應用程式能夠承受每次都通路資料庫。這讓我們選擇一個緩存—最好是能夠支援我們高度并發應用的記憶體緩存:

  • Hazelcast是一個分布式、雲友好、記憶體中的對象存儲和計算引擎,支援多種資料結構,如Map、Set、List、MultiMap、RingBuffer和HyperLogLog。它具有内置複制功能,并提供高可用性和自動分區。
  • Redis是記憶體中的資料結構存儲,我們主要将其用作緩存。它提供了一個具有可選持久性的記憶體中鍵值資料庫。支援的資料結構包括字元串、散列、清單和集合。Redis具有内置的複制功能,并提供高可用性和自動分區。如果我們不需要持久性,Redis可以為我們提供一個功能豐富、網絡化、性能卓越的記憶體緩存。

當然,在建構高度并發的應用程式的過程中,我們僅僅觸及了可用資源的皮毛。值得注意的是,除了可用的軟體,我們的需求應該引導我們建立一個合适的設計。這些選項中有些可能合适,有些可能不合适。

此外,我們不要忘記,還有更多可能更适合我們需求的選項。

7.總結

在本文中,我們讨論了并發程式設計的基礎。我們了解了并發性的一些基本方面以及它可能導緻的問題。此外,我們研究了一些設計模式,它們可以幫助我們避免并發程式設計中的典型問題。

最後,我們研究了一些可用于建構高度并發的端到端應用程式的架構、庫和軟體。

繼續閱讀