高併發應用的設計原則和模式
一、概述
在本教程中,我們將討論一些隨着時間的推移而建立的用於構建高度併發應用程序的設計原則和模式。
然而,值得注意的是,設計併發應用程序是一個廣泛而複雜的主題,因此沒有任何教程可以聲稱其處理是詳盡無遺的。我們將在這裏介紹的是一些經常使用的流行技巧!
- 併發基礎 =======
在我們繼續之前,讓我們花一些時間瞭解基礎知識。首先,我們必須澄清我們對併發程序的理解。如果多個計算同時發生,我們稱程序爲併發。
現在,請注意我們提到了同時發生的計算——也就是說,它們同時進行。但是,它們可能會或可能不會同時執行。理解其中的區別很重要,因爲同時執行的計算稱爲並行。
2.1. 如何創建併發模塊?
瞭解我們如何創建併發模塊很重要。有很多選擇,但我們將在這裏重點介紹兩個流行的選擇:
-
進程:進程是正在運行的程序的一個實例,它與同一臺機器上的其他進程隔離開來。機器上的每個進程都有自己獨立的時間和空間。因此,通常不可能在進程之間共享內存,它們必須通過傳遞消息進行通信。
-
線程:另一方面,線程只是進程的一部分。一個程序中可以有多個線程共享同一個內存空間。但是,每個線程都有唯一的堆棧和優先級。線程可以是本地的(由操作系統本地調度)或綠色的(由運行時庫調度)。
2.2. 併發模塊如何交互?
如果併發模塊不必通信是非常理想的,但通常情況並非如此。這產生了兩種併發編程模型:
- 共享內存:在該模型中,併發模塊通過讀寫內存中的共享對象進行交互。這通常會導致併發計算的交錯,從而導致競爭條件。因此,它可能不確定地導致不正確的狀態。
- 消息傳遞:在此模型中,併發模塊通過通信通道相互傳遞消息進行交互。在這裏,每個模塊按順序處理傳入的消息。由於沒有共享狀態,因此編程相對容易,但這仍然無法避免競爭條件!
2.3. 併發模塊如何執行?
摩爾定律在處理器時鐘速度方面遇到瓶頸已經有一段時間了。相反,由於我們必須成長,我們已經開始將多個處理器封裝到同一個芯片上,通常稱爲多核處理器。但是,仍然很少聽說有超過 32 個內核的處理器。
現在,我們知道單個內核一次只能執行一個線程或一組指令。但是,進程和線程的數量可能分別爲數百和數千。那麼,它到底是如何工作的呢?這是操作系統爲我們模擬併發的地方。操作系統通過時間片來實現這一點——這實際上意味着處理器在線程之間頻繁、不可預測和不確定地切換。
- 併發編程中的問題 ===========
當我們着手討論設計併發應用程序的原則和模式時,首先了解典型問題是什麼是明智的。
在很大程度上,我們的併發編程經驗涉及使用具有共享內存的本機線程。因此,我們將重點關注由此產生的一些常見問題:
-
Mutual Exclusion (Synchronization Primitives):交錯線程需要對共享狀態或內存有獨佔訪問權,以保證程序的正確性。共享資源的同步是一種流行的實現互斥的方法。有幾種同步原語可供使用——例如,鎖、監視器、信號量或互斥體。但是,互斥編程容易出錯,並且經常會導致性能瓶頸。有幾個與此相關的經過充分討論的問題,如 deadlock 和 livelock。
-
上下文切換(重量級線程):每個操作系統都有對併發模塊(如進程和線程)的原生支持,儘管有所不同。如前所述,操作系統提供的一項基本服務是通過時間分片調度線程在有限數量的處理器上執行。現在,這實際上意味着線程在不同狀態之間頻繁切換。在此過程中,需要保存和恢復它們當前的狀態。這是一項直接影響整體吞吐量的耗時活動。
- 高併發的設計模式 ===========
現在,我們瞭解了併發編程的基礎知識和其中的常見問題,是時候瞭解一些避免這些問題的常見模式了。我們必須重申,併發編程是一項需要大量經驗的艱鉅任務。因此,遵循一些既定模式可以使任務更容易。
4.1. 基於 Actor 的併發
我們將討論的關於併發編程的第一個設計稱爲 Actor 模型。這是一個併發計算的數學模型,基本上將所有事物都視爲一個 Actor。Actor 可以相互傳遞消息,並且可以響應消息做出本地決策。這是由 Carl Hewitt 首次提出的,並啓發了許多編程語言。
Scala 的併發編程的主要構造是參與者。Actor 是 Scala 中的普通對象,我們可以通過實例化 Actor 類來創建它們。此外,Scala Actors 庫提供了許多有用的 actor 操作:
class myActor extends Actor {
def act() {
while(true) {
receive {
// Perform some action
}
}
}
}
在上面的示例中,在無限循環中調用 receive 方法會暫停 actor,直到消息到達。到達後,消息從演員的郵箱中刪除,並採取必要的行動。
參與者模型消除了併發編程的一個基本問題——共享內存。Actor 通過消息進行通信,每個 Actor 依次處理來自其專屬郵箱的消息。但是,我們通過線程池執行 actor。我們已經看到本機線程可以是重量級的,因此數量有限。
當然,還有其他模式可以幫助我們——我們稍後會介紹這些模式!
4.2. 基於事件的併發
基於事件的設計明確解決了本機線程的生成和操作成本高昂的問題。基於事件的設計之一是事件循環。事件循環與事件提供者和一組事件處理程序一起工作。在此設置中,事件循環在事件提供者上阻塞,並在到達時將事件分派給事件處理程序。
基本上,事件循環不過是一個事件調度器!事件循環本身可以只在一個本地線程上運行。那麼,事件循環中到底發生了什麼?讓我們以一個非常簡單的事件循環的僞代碼爲例:
while(true) {
events = getEvents();
for(e in events)
processEvent(e);
}
基本上,我們所有的事件循環所做的就是不斷地尋找事件,並在找到事件時處理它們。該方法非常簡單,但它獲得了事件驅動設計的好處。
使用此設計構建併發應用程序可爲應用程序提供更多控制。此外,它還消除了多線程應用程序的一些典型問題,例如死鎖。
JavaScript 實現事件循環以提供異步編程。它維護一個調用堆棧來跟蹤所有要執行的函數。它還維護一個事件隊列,用於發送新函數進行處理。事件循環不斷檢查調用堆棧並從事件隊列中添加新函數。所有異步調用都被分派到 Web API,通常由瀏覽器提供。
事件循環本身可以在單個線程上運行,但 Web API 提供單獨的線程。
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 這樣的非阻塞數據結構可以幫助我們編寫線程安全的代碼,而不會沉迷於鎖的弊端!
- 編程語言支持 =========
我們已經看到有多種方法可以構建併發模塊。雖然編程語言確實有所不同,但主要是底層操作系統如何支持這個概念。然而,由於本機線程支持的基於線程的併發性在可伸縮性方面遇到了新障礙,我們總是需要新的選擇。
實施我們在上一節中討論的一些設計實踐確實被證明是有效的。但是,我們必須記住,它確實使編程本身變得複雜。我們真正需要的是能夠提供基於線程的併發能力而又不會帶來不良影響的東西。
我們可用的一種解決方案是綠色線程 (green threads)。綠色線程是由運行時庫調度的線程,而不是由底層操作系統本地調度的線程。雖然這並不能消除基於線程的併發中的所有問題,但在某些情況下它肯定可以爲我們提供更好的性能。
現在,使用綠色線程並非易事,除非我們選擇使用的編程語言支持它。並非每種編程語言都有這種內置支持。此外,我們粗略地稱爲綠色線程的東西可以由不同的編程語言以非常獨特的方式實現。讓我們看看其中一些可供我們使用的選項。
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 提議引入 continuations 和 fibers,這可能會改變我們用 Java 編寫併發應用程序的方式!
這只是對不同編程語言可用內容的初步瞭解。其他編程語言嘗試使用更有趣的方式來處理併發。
此外,值得注意的是,在設計高度併發的應用程序時,上一節中討論的設計模式組合以及對類似綠色線程的抽象的編程語言支持可能非常強大。
- 高併發應用 ========
真實世界的應用程序通常有多個組件通過網絡相互交互。我們通常通過 Internet 訪問它,它由代理服務、網關、Web 服務、數據庫、目錄服務和文件系統等多種服務組成。
在這種情況下我們如何保證高併發呢?讓我們探索其中的一些層以及我們用於構建高度併發應用程序的選項。
正如我們在上一節中看到的,構建高併發應用程序的關鍵是使用那裏討論的一些設計概念。我們需要爲工作選擇合適的軟件——那些已經包含其中一些實踐的軟件。
6.1. 網絡層
Web 通常是用戶請求到達的第一層,這裏不可避免地要提供高併發性。讓我們看看有哪些選項:
-
Node(也稱爲 NodeJS 或 Node.js)是基於 Chrome 的 V8 JavaScript 引擎構建的開源跨平臺 JavaScript 運行時。Node 在處理異步 I/O 操作方面做得很好。Node 之所以做得這麼好,是因爲它在單個線程上實現了事件循環。在回調的幫助下,事件循環異步處理所有阻塞操作,如 I/O。
-
nginx 是一種開源 Web 服務器,我們通常將其用作其他用途中的反向代理。nginx 提供高併發的原因是它使用了異步的、事件驅動的方法。nginx 在單個線程中與主進程一起運行。主進程維護執行實際處理的工作進程。因此,工作進程同時處理每個請求。
6.2. 應用層
在設計應用程序時,有幾種工具可以幫助我們構建高併發。讓我們檢查一些可供我們使用的庫和框架:
-
Akka 是一個用 Scala 編寫的工具包,用於在 JVM 上構建高度併發和分佈式的應用程序。Akka 處理併發的方法基於我們之前討論的角色模型。Akka 在參與者和底層系統之間創建了一個層。該框架處理創建和調度線程、接收和分發消息的複雜性。
-
Project Reactor 是在 JVM 上構建非阻塞應用程序。它基於 Reactive Streams 規範,專注於高效的消息傳遞和需求管理(背壓)。反應堆操作員和調度程序可以維持消息的高吞吐率。幾個流行的框架提供反應器實現,包括 Spring WebFlux 和 RSocket。
-
Netty 是一個異步的、事件驅動的網絡應用程序框架。我們可以使用 Netty 來開發高併發的協議服務器和客戶端。Netty 利用 NIO,它是 Java API 的集合,可通過緩衝區和通道提供異步數據傳輸。它爲我們提供了幾個優勢,例如更高的吞吐量、更低的延遲、更少的資源消耗以及最小化不必要的內存複製。
6.3. 數據層
最後,沒有數據的應用程序是不完整的,數據來自持久存儲。當我們討論與數據庫有關的高併發時,大部分的焦點都集中在 NoSQL 系列上。這主要是由於 NoSQL 數據庫可以提供線性可擴展性,但在關係變體中很難實現。讓我們看一下兩個流行的數據層工具:
-
Cassandra 是一種免費的開源 NoSQL 分佈式數據庫,可在商用硬件上提供高可用性、高可擴展性和容錯能力。但是,Cassandra 不提供跨多個表的 ACID 事務。所以如果我們的應用程序不需要強一致性和事務,我們可以受益於 Cassandra 的低延遲操作。
-
Kafka 是一個分佈式流媒體平臺。Kafka 將記錄流存儲在稱爲主題的類別中。它可以爲記錄的生產者和消費者提供線性水平可擴展性,同時提供高可靠性和持久性。分區、副本和代理是它提供大規模分佈式併發的一些基本概念。
6.4. 緩存層
好吧,現代世界中沒有任何以高併發爲目標的 Web 應用程序能夠承受每次都訪問數據庫。這讓我們選擇一個緩存 - 最好是可以支持我們的高併發應用程序的內存緩存:
-
Hazelcast 是一種分佈式、雲友好的內存中對象存儲和計算引擎,支持多種數據結構,例如 Map、 Set、 List、 MultiMap、 RingBuffer 和 HyperLogLog。它具有內置複製並提供高可用性和自動分區。
-
Redis 是一種內存數據結構存儲,我們主要用作緩存。它提供了一個內存中的鍵值數據庫,具有可選的持久性。支持的數據結構包括字符串、散列、列表和集合。Redis 具有內置複製並提供高可用性和自動分區。如果我們不需要持久化,Redis 可以爲我們提供功能豐富、網絡化、性能卓越的內存緩存。
當然,在我們追求構建高度併發的應用程序的過程中,我們僅僅觸及了可用資源的皮毛。重要的是要注意,除了可用的軟件之外,我們的需求應該指導我們創建合適的設計。其中一些選項可能合適,而另一些可能不合適。
而且,我們不要忘記還有更多可用選項可能更適合我們的要求。
七、結論
在本文中,我們討論了併發編程的基礎知識。我們瞭解併發的一些基本方面及其可能導致的問題。此外,我們還介紹了一些可以幫助我們避免併發編程中的典型問題的設計模式。
最後,我們瞭解了一些可用於構建高度併發的端到端應用程序的框架、庫和軟件。
原文:
https://www.baeldung.com/concurrency-principles-patterns
來源:
https://www.toutiao.com/article/7180705623687070219/?log_from=91338ae9da3d9_1672195519303
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/34GIpZUvHapCMpB8OGHRjw