天天看點

談消息總線用戶端的多線程實作并發問題的分類RabbitMQ 通信簡介共享執行個體由共享執行個體向線程獨占過渡完全線程獨占的實作總結

其實上面所提到的并發問題,從大的層面上可以劃分為兩類問題:

自身固有的并發問題:這個存在的前提條件是client自身内部使用了多線程技術,并且本身就存線上程安全的缺陷。

被動調用的并發問題:指的是該client處于多線程調用環境下産生的線程安全問題。

如果你想單純得看待怎樣的并發問題算是自身固有的并發問題,那麼你可以假設一個前提:如果你client處于單線程的被調用環境中,那麼你client内部使用了多線程,并且存線上程安全問題,就可以看成是純粹意義上的自身固有的并發問題。說得再直白一點,如果你内部沒有采用多線程,那麼這個client你可以認為它不存在自身固有的并發問題。但有時候外部調用的多線程環境也能觸發你client内部産生并發問題,這種情況下的并發問題我們将其歸類為被動調用的并發問題。

聽起來可能有些抽象,我們通過一個執行個體讓它更直覺一點。我們看redis的用戶端jedis。jedis内部對redis的資料結構指令的調用實作并沒有采用多線程技術,是以我們可以認為它沒有自身固有的并發問題,但一旦你在多線程的環境下共享其Jedis對象(主對象),那麼各種各樣莫名其妙的錯誤就出來了。我們可以認為這是被動調用的并發問題。是以要實作一個絕對線程安全的類是非常不容易的,可以說代價也非常大——因為你必須同時考慮這兩種不同的并發問題。換句話說,當你的client内部外部都可能存在多線程環境,那麼你必須同時考慮調用以及被調用的線程安全。

為了不讓讨論的問題變得大而空,這篇文章我們将關注點放在被動調用的并發問題。

簡單介紹一下RabbitMQ跟通信相關的兩個關鍵對象:Connection、Channel。要通信,肯定要先建立TCP連接配接,這個過程主要由Connection

負責。RabbitMQ支援從一個Connection建立多個Channel,Channel定義并實作了通信的各種API。是以Connection

主要負責鍊路的建立,而Channel主要負責通信邏輯。RabbitMQ這麼設計是為了避免建立太多Connection(每個Connection都是跟RabbitMQ

Server的TCP連接配接)。而對Channel的設計就是網絡中常用的“多路複用”技術。

這是我最初的想法:建構完全線程安全的類,無論是怎樣的被調用環境,都隻使用一個單獨的Messagebus

對象。這是對被調環境最友好的方式,但這種情況下你就必須以被調環境是多線程環境為基準來設計client的實作模型。

是以我之前的想法是在client内部建立一個Channel

的對象池。所有的單工(單向)通信,都直接從對象池中擷取一個Channel對象,通信結束後再歸還Channel到對象池。因為這裡的Pool建構在Apache

Common Pool的基礎上,是以對Channel對象的擷取與歸還是線程安全的,而Channel對象在RabbitMQ 官方Java

client中是被明确标注為線程安全的,是以在整個通信邏輯上沒有線程安全問題。但完全共享執行個體意味着内部關鍵對象也是共享的,這裡涉及到client内部的兩個比較關鍵的對象:

Pubsuber:用于從一個pubsuberCenter擷取實時配置變更資料

ConfigManager:用于解析pubsuberManager push過來的資料并及時調整用戶端的控制邏輯

在共享執行個體的實作方式下,這兩個對象提供的API必須是線程安全的。與此同時,所有client主對象的API都必須是同步的(最簡單也是性能最差的API同步實作方式是将整個方法直接用synchronized标記,如果整個方法不實作為同步的,就必須在方法内部小心得處理同步問題)。

因為多線程問題最主要的根源就是資料共享的問題,是以共享執行個體的實作方式算是正中下懷,而且這是非常考驗實作者并發能力的,本人作為并發新手,如果有辦法敬而遠之,那麼是再好不過的了。是以我的思路逐漸從共享變量向獨占過渡。

之前談到一個Connection可以建立多個Channel。RabbitMQ

官方client在其doc上已經直接說明了:Channel是線程安全的,它直接支援在多線程環境下通信。

是以,我的思路很快就變成了像下面這樣:

談消息總線用戶端的多線程實作并發問題的分類RabbitMQ 通信簡介共享執行個體由共享執行個體向線程獨占過渡完全線程獨占的實作總結

這種模式下,Connection被實作為單例模式,在一個JVM程序中(不管被調環境是否處于多線程狀态)隻會存在一個Connection對象。一個Client主對象(就是上圖中的Messagebus對象)隻關聯一個獨立的Channel并且約定一個Client主對象隻能适用于一個獨立的線程,不得跨線程使用。這樣,整個client的通信邏輯中就完全不必關注并發問題。但這種模式下,Pubsuber以及ConfigManager仍然是單例的(也就是說是共享執行個體的模式),是以他們的API還必須實作為線程安全的。

這看起來是一種不錯的方法,但當我已經開始動手實作之後,才發現一個問題:如果Messagebus對象在各個線程中是獨占的,誰行駛Connection的關閉動作?如果它被某個線程上的Messagebus對象關閉了,對其他線程上正在工作的Messagebus将是一個災難——它們将以一種極其不優雅的方式抛出異常。共享執行個體模式下,client主對象倒不存在這個問題,因為其client主對象的控制權完全歸主線程所有。

client主對象以及所有的關聯對象由線程獨占(Connection對象在不同的線程中也是不同的執行個體)。這也是jedis的實作方式,其實在思考上面兩種方式之前,我就了解了這種實作方式。但我一直在嘗試是否還有其他的方式,因為這麼做畢竟比較浪費資源——用戶端建立多少個通信線程,就至少有多少套對象簇(包括這麼多Connection對象)。但這也是最簡單、通信性能最好的方式——因為它完全不需要處理被調線程安全問題,并且也不像上面一種思路中需要為誰掌管Connection的控制權而糾結。單個線程的記憶體模型如下:

談消息總線用戶端的多線程實作并發問題的分類RabbitMQ 通信簡介共享執行個體由共享執行個體向線程獨占過渡完全線程獨占的實作總結

也就是說,當你建立一個Messagebus

client對象,就會有上面虛線框内的關聯對象簇被建立(它們跟client主對象有相同的生命周期)。

這種實作方式是如何解決被調多線程并發問題的?兩步:

基于約定:規定不得在多線程之間共享client主對象,否則後果自負

Client主對象以及依賴對象完全獨占,并建立Client主對象的對象池

現在,在多線程環境下就不存在任何對象共享了:

談消息總線用戶端的多線程實作并發問題的分類RabbitMQ 通信簡介共享執行個體由共享執行個體向線程獨占過渡完全線程獨占的實作總結

這種模式,client的所有實作代碼都無需再為線程安全問題而做任何傷害性能的加鎖操作而且對象的歸屬上也非常清晰。但毫無疑問,它也存在一些缺點:

它浪費了不少資源:不隻是用戶端的JVM的記憶體空間,還有RabbitMQ Server的連接配接資源

安全隐患:這種模式,如果服務端不做任何處理,用戶端甚至可以通過不斷建立Messagebus 對象,而發起DDos攻擊

其實,歸根到底一切都是權衡——看你關注什麼又願意舍棄什麼。

其實很多API都無法完全做到十全十美,本文的并發問題隻是一個普遍現象,其他的問題還有亂用、錯用的問題。比如上面說到的第二種模型,如果我們不談消息總線,隻采用RabbitMQ原生的java

client的話,多線程通信時你可以這樣:在主線程上建立Connection對象,然後為每個線程配置設定獨立的Channel對象,最終在主線程上關閉Connection對象。但Channel對象開放了擷取Connection對象的API,是以也就給了每個線程對Connection的控制權。你隻能說技術元件隻能顧好自身,它不揣測任何使用場景以及使用意圖。

原文釋出時間為:2015-03-08

本文作者:vinoYang

<a></a>