為什麼使用通訊來共享記憶體

  • 2019 年 11 月 4 日
  • 筆記

為什麼這麼設計(Why's THE Design)是一系列關於電腦領域中程式設計決策的文章,我們在這個系列的每一篇文章中都會提出一個具體的問題並從不同的角度討論這種設計的優缺點、對具體實現造成的影響。如果你有想要了解的問題,可以在文章下面留言。

『不要通過共享記憶體來通訊,我們應該使用通訊來共享記憶體』,這是一句使用 Go 語言編程的人經常能聽到的觀點,然而我們可能從來都沒有仔細地思考過 Go 語言為什麼鼓勵我們遵循這一設計哲學,我們在這篇文章中就會介紹為什麼我們應該更傾向於使用通訊的方式交換消息,而不是使用共享記憶體的方式。

概述

使用通訊來共享記憶體其實不只是 Go 語言推崇的哲學,更為古老的 Erlang 語言其實也遵循了同樣的設計,然而這兩者在具體實現上其實有一些不同,其中前者使用通訊順序進程(Communication Sequential Process),而後者使用 Actor 模型進行設計;這兩種不同的並發模型都是『使用通訊來共享記憶體』的具體實現,它們的主要作用都是在不同的執行緒或者協程之間交換資訊。

從本質上來看,電腦上執行緒和協程同步資訊其實都是通過『共享記憶體』來進行的,因為無論是哪種通訊模型,執行緒或者協程最終都會從記憶體中獲取數據,所以更為準確的說法是『為什麼我們使用發送消息的方式來同步資訊,而不是多個執行緒或者協程直接共享記憶體?』

為了理解今天的問題,我們需要了解這兩種不同的資訊同步機制的優點和缺點,對它們之間的優劣進行比較,這樣我們才能充分理解 Go 語言和其他語言以及框架決策時背後的原因。

設計

這篇文章主要會從以下的幾個方面介紹為什麼我們應該選擇使用通訊的方式在多個執行緒或者協程之間保證資訊的同步:

  • 不同的同步機制具有不同的抽象層級;
  • 通過消息同步資訊能夠降低不同組件的耦合;
  • 使用消息來共享記憶體不會導致執行緒競爭的問題;

作者相信雖然這三個角度可能有一些重疊或者不夠完善,但是也能夠為我們提供足夠的資訊作出判斷和選擇,理解 Go 語言如何被這條設計哲學影響並將並發模型設計成現在的這種形式。

抽象層級

發送消息和共享記憶體這兩種方式其實是用來傳遞資訊的不同方式,但是它們兩者有著不同的抽象層級,發送消息是一種相對『高級』的抽象,但是不同語言在實現這一機制時也都會使用作業系統提供的鎖機制來實現,共享記憶體這種最原始和最本質的資訊傳遞方式就是使用鎖這種並發機制實現的。

我們可以這麼理解:更為高級和抽象的資訊傳遞方式其實也只是對低抽象級別介面的組合和封裝,Go 語言中的 Channel 就提供了 Goroutine 之間用於傳遞資訊的方式,它在內部實現時就廣泛用到了共享記憶體和鎖,通過對兩者進行的組合提供了更高級的同步機制。

既然兩種方式都能夠幫助我們在不同的執行緒或者協程之間傳遞資訊,那麼我們應該盡量使用抽象層級更高的方法,因為這些方法往往提供了更良好的封裝和與領域更相關和契合的設計;只有在高級抽象無法滿足我們需求時才應該考慮抽象層級更低的方法,例如:當我們遇到對資源進行更細粒度的控制或者對性能有極高要求的場景。

耦合

使用發送消息的方式替代共享記憶體也能夠幫助我們減少多個模組之間的耦合,假設我們使用共享記憶體的方式在多個 Goroutine 之間傳遞資訊,每個 Goroutine 都可能是資源的生產者和消費者,它們需要在讀取或者寫入數據時先獲取保護該資源的互斥鎖。

然而我們使用發送消息的方式卻可以將多個執行緒或者協程解耦,以前需要依賴同一個片記憶體的多個執行緒,現在可以成為消息的生產者和消費者,多個執行緒也不需要自己手動處理資源的獲取和釋放,其中 Go 語言實現的 CSP 機制通過引入 Channel 來解耦 Goroutine:

另一種使用消息發送的並發控制機制 Actor 模型 就省略了 Channel 這一概念,每一個 Actor 都在本地持有一個待處理資訊的郵箱,多個 Actor 可以直接通過目標 Actor 的標識符發送資訊,所有的資訊都會在本地的信箱中等待當前 Actor 的處理。

這種通過發送資訊的解耦方式,尤其是 Go 語言實現的 CSP 模型其實與消息隊列非常相似,我們引入 Channel 這一中間層讓資源的生產者和消費者更加清晰,當我們需要增加新的生產者或者消費者時也只需要直接增加 Channel 的發送方和接收方。

執行緒競爭

在很多環境中,並發編程帶來的很多問題都是因為沒有正確實現訪問共享編程的邏輯,而 Go 語言卻鼓勵我們將需要共享的變數傳入 Channel 中,所有被共享的變數並不會同時被多個活躍的 Goroutine 訪問,這種方式可以保證在同一時間只有一個 Goroutine 能夠訪問對應的值,所以數據衝突和執行緒競爭的問題在設計上就不可能出現。

Do not communicate by sharing memory; instead, share memory by communicating.

『不要通過共享記憶體來通訊,我們應該通過通訊來共享記憶體』,Go 語言鼓勵我們使用這種方式設計能夠處理高並發請求的程式。

Go 語言在實現上通過 Channel 保證被共享的變數不會同時被多個活躍的 Goroutine 訪問,一旦某個消息被發送到了 Channel 中,我們就失去了當前消息的控制權,作為接受者的 Goroutine 在收到這條消息之後就可以根據該消息進行一些計算任務;從這個過程來看,消息在被發送前只由發送方進行訪問,在發送之後僅可被唯一的接受者訪問,所以從這個設計上來看我們就避免了執行緒競爭。

需要注意的是,如果我們向 Channel 中發送了一個指針而不是值的話,發送方在發送該條消息之後其實也保留了修改指針對應值的權利,如果這時發送方和接收方都嘗試修改指針對應的值,仍然會造成數據衝突的問題。

對於在同一個機器和進程上運行的程式來說,由於記憶體對於當前進程都是可見的,所以我們沒有辦法避免這種問題的發生,只能說這並不是一種被鼓勵的做法和常規的行為,當我們需要處理這種場景時使用更為底層的互斥鎖才是一種正確的方式,然而在大多數時候這都意味著不正確的設計,我們需要重新思考執行緒之間的關係。

總結

Go 語言並發模型的設計深受 CSP 模型的影響,我們簡單總結一下為什麼我們應該使用通訊的方式來共享記憶體。

Do not communicate by sharing memory; instead, share memory by communicating.

  1. 首先,使用發送消息來同步資訊相比於直接使用共享記憶體和互斥鎖是一種更高級的抽象,使用更高級的抽象能夠為我們在程式設計上提供更好的封裝,讓程式的邏輯更加清晰;
  2. 其次,消息發送在解耦方面與共享記憶體相比也有一定優勢,我們可以將執行緒的職責分成生產者和消費者,並通過消息傳遞的方式將它們解耦,不需要再依賴共享記憶體;
  3. 最後,Go 語言選擇消息發送的方式,通過保證同一時間只有一個活躍的執行緒能夠訪問數據,能夠從設計上天然地避免執行緒競爭和數據衝突的問題;

上面的這幾點雖然不能完整地解釋 Go 語言選擇這種設計的方方面面,但是也給出了鼓勵使用通訊同步資訊的充分原因,我們在設計和實現 Go 語言的程式中也應該學會這種思考方式,通過這種並發模型讓我們的程式變得更容易理解。到了現在我們其實可以討論一些更加開放的問題,各位讀者可以想一想下面問題的答案:

  • 除了使用發送消息和共享記憶體的方式,我們還可以選擇哪些方式在不同的執行緒之間傳遞消息呢?
  • 共享記憶體和共享資料庫作為同步資訊的機制是不是有一些相似性,它們之間有什麼異同呢?

如果對文章中的內容有疑問或者想要了解更多軟體工程上一些設計決策背後的原因,可以在部落格下面留言,作者會及時回複本文相關的疑問並選擇其中合適的主題作為後續的內容。

Reference

  • Why build concurrency on the ideas of CSP?
  • Concurrency in Golang
  • Communicating Sequential Processes & Golang.
  • Explain: Don't communicate by sharing memory; share memory by communicating
  • Communicating sequential processes
  • Share Memory By Communicating
  • What is the actual meaning of Go's "Don't communicate by sharing memory, share memory by communicating."?
  • What operations are atomic? What about mutexes?
  • Share by communicating
  • The actor model in 10 minutes