如何理解 Golang 中「不要通過共享內存來通信,而應該通過通信來共享內存」?

不要通過共享內存來通信,而應該通過通信來共享內存

這是一句風靡golang社區的經典語,對於剛接觸並發編程的人,該如何理解這句話?


在多核或者分散式環境里實現一個正確又高效的通信原語是非常困難的,所以不要試圖用共享內存去實現你的節點/進程/線程/goroutine之間的複雜通信,重新發明這些通信原語,而是用系統或者語言提供的實現好的。

一種純粹的想法就是那就讓系統里只有一種交互方式:消息傳遞。但是這樣傳遞大數據很不經濟啊(其實現代計算機架構很多時候拷貝比共享訪問更經濟,不過這是另一個主題了),所以有個很經典的思路,控制流和數據流分開走,低帶寬的控制信息走相對高延遲,但一致安全的通信原語,讓系統里的各個player協同保證有序安全訪問共享數據資源。

Go里最簡單的例子就是用channel傳指針,並且約定各個goroutine從channel收到了指針可以隨便玩,但是把它送出去到別的channel之後你就別再碰它了。這就相當於用channel這種標準安全的通信原語傳一個控制令牌(8個位元組的指針),而讓大的數據塊不用拷貝就能安全共享。Erlang是純消息傳遞,但傳一個大binary它是類似的自動變成個引用計數的內存塊然後傳指針,並不是真的拷貝,binary是只讀的這種共享訪問在語言級保證安全而不用約定。「不要通過共享內存來通信,而應該通過通信來共享內存」就是這麼個意思。

這句話完全不是說你不應該用mutex —— mutex也是通信原語好伐。如果你的goroutine之間的協同語義確實就是簡單的「保證只有一個goroutine能夠進入臨界區」,那用mutex沒有什麼不對。只是在通信語義變複雜的時候,你不要用mutex加鎖操作共享對象來傳遞控制信息,重新發明輪子。

這個design principle在Go以外的地方也很有用。我的項目里有一個數據pipeline(Flume, 系統編程 里提到過),後來越搞越複雜,變成了好幾個互相依賴的任務,之前趕deadline都是亂寫的什麼掃一把共享的文件目錄,看見上游文件好了就開工,撞上了幾個race condition就開始搞個done file表示真的搞定了不騙你之類。到這裡就很清楚我們在用共享內存(文件)的方式重新發明通信協議並且一定會撞板了,於是老老實實地改用現成的message queue做通知調度,數據還是放共享目錄互相讀,但通信協同就必須走標準協議了。


從架構上來講,降低共享內存的使用,本來就是解耦和的重要手段之一,舉幾個例子

案例:MMORPG AOI 模塊

MMORPG 伺服器邏輯依賴實時計算 AOI,AOI計算模塊需要實時告訴其他模塊,對於某個玩家:

  • 有哪些人進入了我的視線範圍?
  • 有哪些人離開了我的視線範圍?
  • 區域內的角色發生了些什麼事情?

所有邏輯都依賴上述計算結果,因此角色有動作的時候才能準確的通知到對它感興趣的人。這個計算很費 CPU,特別是 ARPG跑來跑去那種,一般放在另外一個線程來做,但這個模塊又需要頻繁讀取各個角色之間的位置信息和一些用戶基本資料。

最早的做法就是簡單的加鎖:

第一是對主線程維護的用戶位置信息加鎖,保證AOI模塊讀取不會出錯。

第二是對AOI模塊生成的結果數據加鎖,方便主線程訪問。

如此代碼得寫的相當小心,性能問題都不說了,稍有不慎狀態就會弄掛,寫一處代碼要經常回過頭去看另外一處是怎麼寫的,擔心自己這樣寫會不會出錯。新人加入後,經常因為漏看老代碼,考慮少了幾處情況,弄出問題來你還要定位一半天難以查證。

演進後的合理做法當然是 AOI和主線程之間不再有共享內存,主線程維護玩家上線下線和移動,那麼它會把這些變化情況抄一份用消息發送給 AOI模塊,AOI模塊根據這些消息在內部構建出另外一份完整的玩家數據,自己訪問不必加鎖;計算好結果後,又用消息投遞給主線程,主線程根據AOI模塊的消息自己在內存中構建出一份AOI結果數據來,自己頻繁訪問也不需要加鎖。

由此AOI模塊得以完全脫離遊戲,單獨開發優化,相互之間的偶合已經降低到只有消息級別的了,由於AOI並不需要十分精密的結果,主線程針對角色位置變化不必要每次都通知AOI,隔一段時間(比如0.2秒)通知下變動情況即可。而兩個線程都需要頻繁的訪問全局玩家坐標信息,這樣各自維護一份以後,將「高頻率的訪問」 這個動作限制在了各自線程自己的私有數據中,完全避免了鎖衝突和邏輯狀態衝突。

用一定程度的數據冗餘,換取了較低的模塊偶合。出問題概率大大降低,可靠性也上升了,每個模塊還可以單獨的開發優化。

案例:IM廣播進程

同頻道/房間/群 人數少於5000,那麼你基本不需要考慮優化廣播;而你如果需要處理同頻道/房間/群的人數超過 1萬,甚至線上跑到10萬的時候,廣播優化就不得不考慮了。

第二代廣播當然是拆線程,拆了線程以後跟AOI一樣的由廣播線程維護用戶狀態。然而針對不同的用戶集合(頻道、房間、群)廣播模塊需要維護的狀態太多了,群的廣播需要寫一套,房間廣播又需要寫一套,用戶離線推送還需要寫一套,都是不同的用戶數據結構。

於是第三代廣播系統徹底獨立成了一個唯一的廣播進程,使用 「用戶標籤」 來決定廣播的範圍,不光是何種類型的邏輯需要廣播了,他只是在同一個用戶身上加入了不同的標籤(唯一字元串),比如群1的所有用戶都有一個群1的標籤,頻道3的用戶都有一個頻道3的標籤。

所有邏輯模塊在用戶登錄的時候都給用戶打一個標籤,這個打標籤的消息匯總到廣播進程自己維護的用戶狀態數據區,以:用戶&<-&>標籤 雙向關係進行維護,發廣播時邏輯模塊只需要告訴廣播進程給什麼標籤的所有用戶發什麼廣播,優先順序多少即可。

廣播進程組會做好命令拆分,用戶分組篩選,消息合併,丟棄,壓縮,節拍控制,等一系列標準化操作,比起第一代來,單次實時廣播支持廣播的人數從幾千上升到幾十萬,模塊間也徹底解耦了。

兩個例子,做的事情都是把原來共享內存幹掉,重新設計了以消息為主的介面方式,各自維護一份數據,以一定程度的數據冗餘換取了更低的代碼偶合,提升了性能和穩定性還有可維護性。

很多教多線程編程的書講完多線程就講數據鎖,給人一個暗示好像以後寫程序也是這樣,建立了一個線程,接下來就該考慮數據共享訪問的事情了。所以Erlang的成功就是給這些老模式很好的舉了個反例。

所以 「減少共享內存」 和多用 「消息」,並不單單是物理分布問題,這本來就是一種良好的編程模型。它不僅針對數據,代碼結構設計也同樣實用,有時候不要總想著抽象點什麼,弄出一大堆 Base Object/Inerface 的後果有時候是災難性的。不同模塊內部做一定程度的冗餘代碼,有時反而能讓整個項目邏輯更加清晰起來。

所以才會說:高內聚低耦合嘛

關於冗餘與偶合的關係,推薦閱讀這篇文章:

Redundancy vs dependencies: which is worse?

------------------------------------------------------------------------

案例3:NUMA 架構

多CPU共享一塊內存的結構很難再有大的發展,各個核之間的數據同步和控制協議的複雜度隨著核的數量上升而成幾何級數上升,並發訪問性能卻不斷下降,傳統的SMP結構如今碰到了很大瓶頸,

因此同物理主機內部也出現了 NUMA結構,讓不同核心訪問各自獨立的內存區域,由此核心數量可以大大提升,Linux內核已早已支持這樣的結構。而很多程序至今仍然用SMP的方式進行編碼。

倘若哪天NUMA逐步取代SMP時,要寫高性能服務端代碼,共享內存這玩意兒,估計你想用都用不了了。

-----------------------------------------------------------------------

反例:XXGAME服務端引擎

國內某兩個字母的最大型的休閒遊戲平台,XXGAME,遊戲為了避免邏輯崩潰影響網路鏈接,十多年前就把網路進程獨立出來了,邏輯一個進程,網路一個進程,其實就是大多數架構的 LinkServer / Gate 和業務的關係,網路進程和業務之間使用socket通信即可(Linux2.6以後本地 socket通行有 short cut,性能和本地管道一樣,基本等同 兩次memcpy)。可XXGame服務端引擎,發明了一個 「牛逼的」 共享內存模塊,用共享內存+RingBuffer 來給網路進程和邏輯進程做數據交換用,然後寫了一大堆覺得很高明的代碼來維護這個東西。

聽說這套引擎後來還用到了該公司其他牛逼的大型遊戲中去了。

這裡問一句,網卡每秒鐘能傳輸多少數據?內存的帶寬是網卡的多少倍?寫那麼多的代碼避免了一到兩次memcpy換來把時間從 100降低到 99,卻讓代碼之間充滿了各種偶合,飛線,好玩么?十多年前我聽說這套架構的時候就笑了,如今十多年過去了,面對那麼多新產生的架構方法和設計理念,你們這套模塊自己都不敢怎麼改了吧?新人都不敢給他們怎麼維護了吧?要不怎麼我最近聽著還有好幾個遊戲在用這麼老的模式呢。

----

今天也並非向大家提倡純粹無狀態的actor,上面aoi的例子內部實現仍然是個狀態機。但進程和線程間的狀態隔離內存隔離,以冗餘換低耦合本來就是一種經住實踐考驗的好思路。


這是個好問題。這句俏皮話具體說來就是,不同的線程不共享內存不用鎖,線程之間通訊用channel同步也用channel。

這種並發的範式實際上已經是主流了,不提erlang,即使是C++也有很多高性能的架構是只依賴於高性能的MPSC隊列,而從來不在事務邏輯里用鎖。在Rust裡面,配合所有權概念和Send trait,編譯器能夠靜態的保證沒有數據競爭。

用這種範式的主要優點是邏輯簡單清楚,系統有高正確性。你的程序能保證每個線程里事件都是sequential consistent的,不會有競爭出現。你不需要在寫完程序之後花大筆時間去debug各種詭異的線程競爭問題。在交易系統中,這個對保證交易邏輯的正確性至關重要。

另一方面很多人沒有看到的是,這種範式反而會比線程之間單純的共享內存更快。在我看到的頂尖低延遲領域,這種範式越來越主流。線程之間不用共享內存,共享內存和memory-order的細緻優化被完全使用在實現高性能的無鎖隊列上。由於隊列可以無鎖,系統延遲完全沒有鎖的contention的影響,單線程的邏輯同時保證最低延遲。宏觀上來講,由於邏輯能夠被更容易的reason about(理清?)。沒有數據競爭,人會更容易而且更傾向於寫出清楚的責任劃分,可以隨意並行的系統。大型系統的性能從來都更在乎是否有一個好的架構而通常不是去優化個別函數。

另外,這跟一份內存還是兩份內存是沒有關係的。很多情況下,數據從channel的一端到另一端其實並沒有拷貝,而只是一個move,也就是一個指針的替換。上面所說的對延遲的影響也很容易看到這並不是通過降低速度而換取低複雜性的作法。通常正確實現這類範式的結果是速度變快而不是變慢,無論是延遲還是吞吐。

golang的特色就是它的channel是所謂的first-class citizen(一等公民),使用方便,配套設施完備。加上go-routine,它可以在避免操作系統線程切換的overhead的同時享受channel通信的簡單方便。在我看來,這應該是golang的殺手特性。


如果程序設計成通過通信來共享數據的話,那麼通信的兩端是不是在同一個物理設備上就無所謂了,只有這樣才能實現真正的分散式計算。

golang就是在向這個方向走,但是路還很長。

比如 golang 里常見的 chan

ch := make(chan int)

go func() {
n := &<-ch println(n) }() ch &<- 123

chan 目前在golang里就是個隊列,是個內存的數據結構,一個goroutine往chan里放數據,另一個goroutine從裡面取數據,go程序開發者並不關心chan裡面是怎麼保存和傳遞數據的,對開發者來說chan就是個goroutine之間的通信管道。

如果golang能把chan用TCP/IP來實現,就可以達到跨設備通信的目的,甚至可以用更高速的匯流排來達到接近內存的性能,這樣兩個goroutine是可以運行在不同的物理設備上的,他們之間只通過chan通信來達到交換數據的目的,並不關心對方是不是和自己在同一個進程同一個機器上。


這句話你聽聽就好,不必當作真理,即使它是 go 開發團隊里的人說的。

go 從來不排斥共享內存,也不鼓勵濫用 channel。go 的 wiki 就有專門一頁說這個事情:golang/go 。全文引用如下,免得有人懶得看:

Use a sync.Mutex or a channel?

One of Go"s mottos is "Share memory by communicating, don"t communicate by sharing memory."

That said, Go does provide traditional locking mechanisms in the sync package. Most locking issues can be solved using either channels or traditional locks.

So which should you use?

Use whichever is most expressive and/or most simple.

A common Go newbie mistake is to over-use channels and goroutines just because it"s possible, and/or because it"s fun. Don"t be afraid to use a sync.Mutex if that fits your problem best. Go is pragmatic in letting you use the tools that solve your problem best and not forcing you into one style of code.

As a general guide, though:

Channel passing ownership of data,
distributing units of work,
communicating async results

Mutex caches,
state

If you ever find your sync.Mutex locking rules are getting too complex, ask yourself whether using channel(s) might be simpler.

Wait Group

Another important synchronisation primitive is sync.WaitGroup. These allow co-operating goroutines to collectively wait for a threshold event before proceeding independently again. This is useful typically in two cases.

Firstly, when "cleaning up", a sync.WaitGroup can be used to ensure that all goroutines - including the main one - wait before all terminating cleanly.

The second more general case is of a cyclic algorithm that involves a set of goroutines that all work independently for a while, then all wait on a barrier, before proceeding independently again. This pattern might be repeated many times. Data might be exchanged at the barrier event. This strategy is the basis of Bulk Synchronous Parallelism (BSP).

Channel communication, mutexes and wait-groups are complementary and can be combined.

More Info

  • Channels in Effective Go: http://golang.org/doc/effective_go.html#channels
  • The sync package: http://golang.org/pkg/sync/


即 goroutine A 不訪問 goroutine B 的內存,goroutine B 也不訪問 goroutine A 的內存,而是共同操作一個數據隊列 channel C,並且這個 channel 的存取能在語言底層層面配合 goroutine 的調度,不需要你手動額外加鎖,避免了額外的鎖開銷,但是可能會有一點點拷貝開銷,不過因為 Go 有 GC,所以分配內存的開銷不會很大,所以 Go 比較倡導拷貝傳遞數據而較少的使用指針。


浪費更多的系統資源當然可以讓編程變得更簡單,就像人們喜歡同步IO函數不喜歡非同步IO函數,必要時寧願開一個線程被卡住也不喜歡OVERLAPPED一樣。反正現在大家的計算資源都過剩了,浪費就浪費唄,少加班才是正道,用戶覺得卡就買新電腦。


對Golang不太了解,但是我自己是這麼理解,借用代碼大全的一句話(我覺得是最重要的一句):軟體的首要技術使命是管理複雜度。

從複雜度上講,通過共享內存來通信比通過通信來共享內存複雜太多了。


這種編程哲學遠早於golang就出現了,為一般的專業編程人員,特別是並行編程人員所熟悉。其背後的思想則跟源於對系統抽象的理解,OO領域耳熟能詳的針對介面而不是實現編程的說法也能看到其中的影子。

無論是共享內存還是消息,本質都是不同實體之間的如何協調信息,以達成某種一致。直接共享內存基於的通訊協議由硬體和OS保證,這種保證是寬泛的,事實上可以完成任何事情,同樣也帶來管理的複雜和安全上的妥協。而消息是高級的介面,可以通過不同的消息定義和實現把大量的控制,安全,分流等相關的複雜細節封裝在消息層,免除上層代碼的負擔。所以,這裡其實是增加了一層來解決共享內存存在的問題,實際上印證了另一句行業黑話:計算機科學領域所有的問題都可以通過增加一個額外的間接層來解決。

然而其實還有另一句話,計算機可以領域大多數的性能問題都可以通過刪除不必要的間接層來解決。不要誤解這句話,這句話不過是說,針對領域問題的性能優化可以使用不同於通用問題的辦法,因為通用的辦法照顧的是大多數情況下的可用性而不是極端情況下的性能表現。詬病消息系統比共享內存性能差其實是一個偽問題。當二者都不存在的時候,自然共享內存實現直接而簡單,成熟的消息系統則需要打磨並且設計不同情況下的策略。人們自然選擇快而髒的共享內存。

然而,技術進步的意義就在於提供高層次的選擇的靈活性。當二者都存在的時候,選擇消息系統一般是好的,而且絕大多數的性能問題可以通過恰當的策略配置得以解決。針對遺留系統,則可以選擇使用消息系統模擬共享內存。這種靈活性,是共享內存本身不具備的。

對這種編程哲學,golang提供語言層面的支持無疑是好的,可以推動良好設計的宣傳和廣泛使用。

實際使用上,毫無疑問選擇消息系統。如果所用的平台或者庫沒有,嘗試實現一個。


對於c++程序員來說,簡單說就是不在多線程或多進程中共享任何東西(當然常量表還是可以共享的),用複製取代共享。目的是儘可能的避免鎖的使用。以前的模型是內存共享,需要通訊的時候只是給對方線程發一個事件通知傳過去一個指針。現在則把需要傳遞的數據複製一份給目標線程從而避免共享內存,因此也就不需要加鎖。為了儘可能減小拷貝開銷,設計時就必須謹慎選擇數據結構,盡量減小拷貝的範圍。同時,以前的模型中再爛的設計通常都可以通過一個範圍巨大的鎖解決。新模型中你得接受線程是並行的而且沒有鎖。


把一份內存的開銷變成2份內存開銷而已。。只能減免讀寫鎖,不能免除寫鎖的問題。

A的任何寫操作都丟給B,B的任何寫操作都丟給A,兩邊各自維護一份類似資料庫或者redis的binlog和內存鏡像。

問題是可能2邊對於某個操作順序的認識可能不一致:A把x變成3,B把x變成4。兩邊都收到了對面對x的修改,但是可能都認為對面比自己的操作晚(或者早),所以A認為x是3,B認為x是4。數據不一致就產生了唄。硬要去用高精度時間戳之類的排序,那本質又是「使用了共享內存來通信」(時鐘就成了你的共享內存),還不要說讀寫條件之類的問題如何解決了。

所以,資料庫只可能有一個主。同理狹義的消息傳遞只能解耦單寫多讀(多份存儲開銷),但並不允許多寫。

記著: 問題本身的複雜性並不能靠語言去解決。


一份還是兩份內存的區別,其他答案已經說得比較多了

另一個角度的理解:比如你要有一塊共享數據,a和b兩個協程都需要讀寫,那麼兩種實現方式:

1 通過加鎖來大家搶佔,這存在兩個問題,一是不可控性,不知道一個時刻中輪到誰了,而是這塊內存在「設計」上是一個全局的,也就是除了a和b,其他協程也能看到,這有一定的安全性問題(不該訪問的也能進來搞事情),如果通過這塊共享的區域進行信息交換,又需要搞一個控制

2 a或b中的「發起方」生成這塊內存,然後通過chan傳來傳去實現共享,可以控制給指定的協程,也可以控制不給不想讓它看到的協程,這樣相對會好一些,反正chan中也只是傳遞個指針,需要拷貝的時候再拷貝


就是說golang中做並發應該用channel。特別是在並發控制的時候。

這種模型有些類似actor,像Akka(Java,Scala)。

說實話,這種並發模型比內存控制的模型好太多。容易理解,規則簡單,不容易出錯。可惜很多人居然還不願意使用。

現在用Java做並發的人,估計還有很多糾結wait,join這樣的原語。


已經為goroutine造好輪子(channel),就不要再造輪子了

golang編程過程中,用好channel,盡量不要自己寫共享內存代碼來通信,用channel實現統一通信豈不更好


看到這一問題,以及@韋易笑的回答,很是觸動。如今這時代,所謂「武林秘籍」,無非也就一句話,甚至一個關鍵詞。如果我一年前知道「不要用共享內存來通信,要用通信來共享內存」以及「AOI」的話,我的遊戲伺服器至少能省去三個月的彎路工期。

然而話說回來,實地掉坑得來的總結,能與前人言論相應,也算不是太虧吧。


說明go 就是為上層軟體開發而設計的語言,如果你要處理每秒100mb 的視頻流,做圖像識別處理,你不用共享內存你用什麼..


寫一般的業務邏輯服務用非同步單線程模型就夠了,搞什麼多線程什麼共享內存還有什麼線程通訊,完全是沒事找事,掉坑裡還感嘆並發編程好難啊,純粹是自找的好么。


對golang了解不深,就erlang來講,進程之間只能通過消息相互交換信息,這樣是為了保證當進程在不同機器上時,依然可以和相同模式,在程序不做修改的情況下執行。

通過內存共享來在不同的作用域傳遞消息,在需要讀取變數的進程都在一台機器上可以,但如果在不同機器上,程序將無法正常運行。

golang也是為了並發分散式設計的語言,在一開始就考慮了分散式情況下程序執行情況吧。

不知道golang是否也是變數不可變的,如果變數可變,那不同進程對變數進行操作時,還涉及到加鎖,會增加並發程序設計難度,而就算正確加鎖,有副作用情況下,在調試時因為變數狀態可變,多個進程可以讀取一個變數的話,依然可能比較難準確預測每個進程的行為,也會造成一些麻煩。


初始看到這個問題時唐生的答案還比較靠前,他是go專家,評價還比較客觀。現在很多答案根據自身經驗在比較優劣,有點誇大其辭,故多言幾句。

所謂的好處說來說去就是一個消息隊列而已,各種各樣的並發模型大多都以消息隊列打底,為什麼?因為消息隊列是大範圍,大力度,高層次,一目了然的同步設施啊。共享時最難或最怕的問題是什麼?同步啊。

所以這是一個根據需求平衡的問題,就算是消息隊列,也不一定都是channel或傳統意義上的實現方式,也可以完全對使用者透明。至少理解了問題本質在同步,可以選擇的方法和手段還有很多。


這只是go團隊宣傳的標語根據使用場景而定。


推薦閱讀:

為什麼 Go 語言如此不受待見?
Golang 里的fatal error怎麼處理?
golang里gc相關的write barrier(寫屏障)是個什麼樣的過程或者概念?
為什麼go語言gc的時候要暫停整個程序?
如何評價 Go for android 或 Swift for web 這種現象?

TAG:Java | CC | 多線程 | Go語言 | 並發 |