天天看點

Java程式設計思想第五版(On Java8)(二十四)-并發程式設計術語并發的新定義并發的超能力為速度而生的并發

普通的程式設計:首先發生一件事,然後是下一件事。我們完全控制所有步驟及其發生的順序。

如果我們将值設定為5,那麼稍後會回來并發現它是47,這将是非常令人驚訝的。這就是并的發世界,你往常信賴的一切都不再可靠。你必須知道和了解這些情況發生條件。

建構并發應用程式非常類似于遊戲Jenga,每當你拉出一個塊并将其放置在塔上時,一切都會崩潰。每個大廈和每個應用程式都是獨一無二的,有自己的作用。你從建構系統中學到的東西可能不适用于下一個系統。

術語

在程式設計文獻中并發、并行、多任務、多處理、多線程、分布式系統(concurrent, parallel,

multitasking, multiprocessing, multithreading, distributed systems)使用了許多互相沖突的方式,并且經常被混淆。

Brian Goetz在2016年的演講中指出了這一點From Concurrent to Parallel,他提出了一個合理的解釋:

并發concurrent是關于正确有效地控制對共享資源的通路

并行parallel是使用額外的資源來更快地産生結果

“并發”意味着“一切變得混亂”,Brian Goetz的Java Concurrency in Practice,都在标題中使用這個詞。

并發通常意味着“不止一個任務正在執行中”

并行性幾乎總是意味着“不止一個任務同時執行。”并行也有不止一個任務“正在進行”。

重疊:為并行編寫的程式有時可以在單個處理器上運作,而一些并發程式設計系統可以利用多個處理器。

另一種說法,在低速發生的地方:

并發

同時完成多個任務。在開始處理其他任務之前,目前任務不需要完成。并發解決了阻塞發生的問題。當任務無法進一步執行,直到外部環境發生變化時才會繼續執行。最常見的例子是I/O,其中任務必須等待一些輸入(在這種情況下會被阻止)。這個問題産生在I/O密集型

并行

同時在多個地方完成多個任務。這解決了所謂的計算密集型問題,如果将程式分成多個部分并在不同的處理器上編輯不同的部分,程式可以運作得更快。

術語混淆的原因在上面的定義中顯示:其中核心是“在同一時間完成多個任務。”并行性通過多個處理器增加分布。

更重要的是,兩者解決了不同類型的問題:解決I/O密集型問題,并行化可能對你沒有任何好處,因為問題不是整體速度,而是阻塞。并且考慮到計算力限制問題并試圖在單個處理器上使用并發來解決它可能會浪費時間。兩種方法都試圖在更短的時間内完成更多,但它們提速的方式是不同的,取決于問題所帶來的限制。

這兩個概念混合在一起的一個主要原因是包括Java在内的許多程式設計語言使用相同的機制線程來實作并發和并行。

嘗試添加細粒度的定義(非标準化的術語):

  • 純并發:任務仍然在單個CPU上運作。純并發系統産生的結果比串行系統更快,但如果有更多的處理器,則運作速度不會更快
  • 并發-并行:使用并發技術,結果程式利用更多處理器并更快地生成結果
  • 并行-并發:使用并行程式設計技術編寫,如果隻有一個處理器,結果程式仍然可以運作(Java 8 Streams就是一個很好的例子)
  • 純并行:除非有多個處理器,否則不會運作
  • 在某些情況下,這可能是一個有用的分類法。

抽象的目标是“抽象出”那些對于手頭想法不重要的東西,從不必要的細節中汲取靈感。如果抽象是漏洞,那些碎片和細節會不斷重新聲明自己是重要的,無論你試圖隐藏它們多少

我開始懷疑是否真的有高度抽象。當編寫這些類型的程式時,你永遠不會被底層系統和工具屏蔽,甚至關于CPU緩存如何工作的細節。最後,如果你非常小心,你創作的東西在特定的情況下起作用,但它在其他情況下不起作用。有時,差別在于兩台機器的配置方式,或者程式的估計負載。這不是Java特有的-它是并發和并行程式設計的本質。

你可能會認為純函數式語言沒有這些限制。實際上,純函數式語言解決了大量并發問題,是以如果你正在解決一個困難的并發問題,你可以考慮用純函數語言編寫這個部分。

但最終,如果你編寫一個使用隊列的系統,例如,如果它沒有正确調整并且輸入速率要麼沒有被正确估計或被限制(并且限制意味着,在不同情況下不同的東西具有不同的影響),該隊列将填滿并阻塞或溢出。最後,你必須了解所有細節,任何問題都可能會破壞你的系統。這是一種非常不同的程式設計方式

并發的新定義

并發性是性能技術的集合,專注于減少等待

這實際上是一個相當多的聲明,是以我将其分解:

集合:有許多不同的方法來解決這個問題。這是使定義并發性如此具有挑戰性的問題之一,因為技術之間的差别很大

性能技術:并發的關鍵點在于讓你的程式運作得更快。在Java中,并發是非常棘手和困難的,是以絕對不要使用它,除非你有重大的性能問題 - 即使這樣,使用最簡單的方法産生你需要的性能,因為并發很快變得無法管理。

“減少等待”:無論你運作多少個處理器,你隻能在等待某個地方時産生結果。如果你發起I/O請求并立即獲得結果,沒有延遲,是以無需改進。如果你在多個處理器上運作多個任務,并且每個處理器都以滿容量運作,并且任何其他任務都沒有等待,那麼嘗試提高吞吐量是沒有意義的。并發的唯一形式是如果程式的某些部分被迫等待。等待可以以多種形式出現 - 這解釋了為什麼存在如此不同的并發方法。

值得強調的是,這個定義的有效性取決于等待這個詞。如果沒有什麼可以等待,那就沒有機會了。如果有什麼東西在等待,那麼就會有很多方法可以加速,這取決于多種因素,包括系統運作的配置,你要解決的問題類型以及其他許多問題。

并發的超能力

想象一下,你置身于一部科幻電影。你必須在高層建築中搜尋一個精心巧妙地隐藏在建築物的一千萬個房間之一中的單個物品。你進入建築物并沿着走廊向下移動。走廊分開了。

你自己完成這項任務需要一百個生命周期。

現在假設你有一個奇怪的超能力。你可以将自己一分為二,然後在繼續前進的同時将另一半送到另一個走廊。每當你在走廊或樓梯上遇到分隔到下一層時,你都會重複這個分裂的技巧。最終,整個建築中的每個走廊的終點都有一個你。

每個走廊都有一千個房間。你的超能力變得有點弱,是以你隻能分裂出50個自己來搜尋這間房間。

一旦克隆體進入房間,它必須搜尋房間的每個角落。這時它切換到了第二種超能力。它分裂成了一百萬個納米機器人,每個機器人都會飛到或爬到房間裡一些看不見的地方。你不需要了解這種功能 - 一旦你開啟它就會自動工作。在他們自己的控制下,納米機器人開始行動,搜尋房間然後回來重新組裝成你,突然間,你獲得了尋找的物品是否在房間内的消息。

“并發就是剛才描述的置身于科幻電影中的超能力“就像你自己可以一分為二然後解決更多的問題一樣簡單。

但是問題在于,我們來描述這種現象的任何模型最終都是洩漏抽象的(leaky abstraction)。

以下是其中一個漏洞:在理想的世界中,每次克隆自己時,你還會複制硬體處理器來運作該克隆。但當然不會發生這種情況 - 你的機器上可能有四個或八個處理器(通常在寫入時)。你可能還有更多,并且仍有許多情況隻有一個處理器。在抽象的讨論中,實體處理器的配置設定方式不僅可以洩漏,甚至可以支配你的決策

讓我們在科幻電影中改變一些東西。現在當每個克隆搜尋者最終到達一扇門時,他們必須敲門并等到有人回答。如果我們每個搜尋者有一個處理器,這沒有問題 - 處理器隻是空閑,直到門被回答。但是如果我們隻有8個處理器和數千個搜尋者,那麼隻是因為搜尋者恰好是因為處理器閑置了被鎖,等待一扇門被接聽。相反,我們希望将處理器應用于搜尋,在那裡它可以做一些真正的工作,是以需要将處理器從一個任務切換到另一個任務的機制。

許多型号能夠有效地隐藏處理器的數量,并允許你假裝你的數量非常大。但是有些情況會發生故障的時候,你必須知道處理器的數量,以便你可以解決這個問題。

其中一個最大的影響取決于你是單個處理器還是多個處理器。如果你隻有一個處理器,那麼任務切換的成本也由該處理器承擔,将并發技術應用于你的系統會使它運作得更慢。

這可能會讓你決定,在單個處理器的情況下,編寫并發代碼沒有意義。然而,有些情況下,并發模型會産生更簡單的代碼,實際上值得讓它運作得更慢以實作。

在克隆體敲門等待的情況下,即使單處理器系統也能從并發中受益,因為它可以從等待(阻塞)的任務切換到準備好的任務。但是如果所有任務都可以一直運作那麼切換的成本會降低一切,在這種情況下,如果你有多個程序,并發通常隻會有意義。

在接聽電話的客戶服務部門,你隻有一定數量的人,但是你可以撥打很多電話。那些人(處理器)必須一次撥打一個電話,直到完成電話和額外的電話必須排隊。

在“鞋匠和精靈”的童話故事中,鞋匠做了很多工作,當他睡着時,一群精靈來為他制作鞋子。這裡的工作是分布式的,但即使使用大量的實體處理器,在制造鞋子的某些部件時會産生限制 - 例如,如果鞋底需要制作鞋子,這會限制制鞋的速度并改變你設計解決方案的方式。

是以,你嘗試解決的問題驅動解決方案的設計。打破一個“獨立運作”問題的進階抽象,然後就是實際發生的現實。實體現實不斷侵入和震撼這種抽象。

這隻是問題的一部分。考慮一個制作蛋糕的工廠。我們不知何故在勞工中分發了蛋糕制作任務,但是現在是時候讓勞工把蛋糕放在盒子裡了。那裡有一個盒子,準備收到蛋糕。但是,在勞工将蛋糕放入盒子之前,另一名勞工投入并将蛋糕放入盒子中!我們的勞工已經把蛋糕放進去了,然後就開始了!這兩個蛋糕被砸碎并毀了。這是常見的“共享記憶體”問題,産生我們稱之為競争條件的問題,其結果取決于哪個從業人員可以首先在框中擷取蛋糕(通常使用鎖機制來解決問題,是以一個從業人員可以先抓住框并防止蛋糕砸)。

當“同時”執行的任務互相幹擾時,會出現問題。并發性“可以說是确定性的,但實際上是非确定性的。”也就是說,你可以假設編寫通過維護和代碼檢查正常工作的并發程式。然而,在實踐中,編寫僅看起來可行的并發程式更為常見,但是在适當的條件下,将會失敗。這些情況可能會發生,或者很少發生,你在測試期間從未看到它們。實際上,編寫測試代碼通常無法為并發程式生成故障條件。由此産生的失敗隻會偶爾發生,是以它們常以客戶bug形式出現。

盡管Java 8在并發性方面做出了很大改進,但仍然沒有像編譯時驗證(compile-time verification)或受檢查的異常(checked exceptions)那樣的安全網來告訴你何時出現錯誤。通過并發,你隻能依靠自己,隻有知識淵博,保持懷疑和積極進取的人,才能用Java編寫可靠的并發代碼。

為速度而生的并發

  • 并發程式設計有這麼多問題,是否值得這麼麻煩?
  • 不,除非你的程式運作速度不夠快。如果有一種方法可以在更快的機器上運作你的程式,或者如果你可以對其進行分析并發現瓶頸并在該位置交換更快的算法,那麼請執行此操作。隻有在顯然沒有其他選擇時才開始使用并發。

速度問題一開始聽起來很簡單:如果你想要一個程式運作得更快,将其分解成碎片并在一個單獨的處理器上運作每個部分。由于我們能夠提高時脈速度流(至少對于傳統晶片),速度的提高是出現在多核處理器的形式而不是更快的晶片。為了使你的程式運作得更快,你必須學習利用那些超級處理器,這是并發性給你的一個建議。

使用多處理器機器,可以在這些處理器之間配置設定多個任務,這可以顯着提高吞吐量。強大的多處理器Web伺服器通常就是這種情況,它可以在程式中為CPU配置設定大量使用者請求,每個請求配置設定一個線程。

但是,并發性通常可以提高在單個處理器上運作的程式的性能。由于上下文切換的成本增加(從一個任務更改為另一個任務),在單個處理器上運作的并發程式實際上應該比程式的所有部分順序運作具有更多的開銷。在表面上,将程式的所有部分作為單個任務運作并節省上下文切換的成本似乎更便宜。

可以産生影響的問題是阻塞。

如果你的程式中的一個任務由于程式控制之外的某些條件(I/O)而無法繼續,任務或線程阻塞(在我們的科幻故事中,克隆體已敲門等待它打開)。如果沒有并發性,整個程式就會停止,直到外部條件發生變化。但是,如果使用并發編寫程式,則當一個任務被阻止時,程式中的其他任務可以繼續執行,是以程式繼續向前。實際上,從性能的角度來看,在單處理器機器上使用并發沒有意義,除非其中一個任務可能阻塞。

單處理器系統中性能改進的一個常見例子是事件驅動程式設計,特别是使用者界面程式設計。考慮一個程式執行一些長時間運作操作,進而最終忽略使用者輸入和無響應。如果你有一個“退出”按鈕,你不想在你編寫的每段代碼中輪詢它。這會産生笨拙的代碼,無法保證程式員不會忘記執行檢查。

沒有并發性,生成響應式使用者界面的唯一方法是讓所有任務定期檢查使用者輸入。通過建立單獨的執行線程來響應使用者輸入,該程式保證了一定程度的響應。

實作并發的直接方法是在作業系統級别,使用與線程不同的程序。

程序是一個在自己的位址空間内運作的自包含程式。作業系統通常将一個程序與另一個隔離,它們不會互相幹擾。

線程共享記憶體和I/O等資源,是以編寫多線程程式時遇到的困難是在不同的線程驅動的任務之間協調這些資源,一次不能通過多個任務通路它們。

有些人甚至提倡将程序作為并發的唯一合理方法,但不幸的是,通常存在數量和開銷限制,以防止它們在并發頻譜中的适用性

一些程式設計語言旨在将并發任務彼此隔離。這些通常被稱為_函數式語言_,其中每個函數調用不産生其他影響(不能與其他函數幹涉),是以可以作為獨立的任務來驅動。Erlang就是這樣一種語言,它包括一個任務與另一個任務進行通信的安全機制。如果你發現程式的一部分必須大量使用并發性并且你在嘗試建構該部分時遇到了過多的問題,那麼你可能會考慮使用專用并發語言建立程式的那一部分。

Java采用了更傳統的方法[^2],即在順序語言之上添加對線程的支援而不是在多任務作業系統中配置設定外部程序,線程在執行程式所代表的單個程序中建立任務交換。

并發性會帶來成本,包括複雜性成本,但可以通過程式設計,資源平衡和使用者便利性的改進來抵消。通常,并發性使你能夠建立更加松散耦合的設計;否則,你的代碼部分将被迫明确标注通常由并發處理的操作。