? ? ? ? 上篇文章中我們簡單介紹了分布式系統的設計思想以及簡單性質,之后用一定篇幅簡要介紹了MapReduce這個經典的分布式計算框架的大致工作原理,相信朋友們已經對此有了最基本的理解。在現實場景中,分布式系統的設計初衷是為了解決并發問題,能夠承受單機系統所不能承受的流量負擔,并充分利用計算機集群的硬件資源。同時,既然會利用到計算機集群,那么集群間通信也是一個不可忽略的討論關鍵吧。由此,本文會著重討論分布式系統中的并發問題以及通信問題。在介紹本文內容之前,需要提示朋友們:如果想要更好的理解本文中提到的知識點,需要有一定的操作系統和網絡相關的知識,否則可能會略顯吃力。
? ? ? ? 說到設計分布式系統,那么相信一定有朋友們會聯想到golang這門編程語言。golang是由Google公司與2007年開始設計,2009年正式發布開源的一門主要適配后端開發的編程語言。golang語言的設計初衷是在保證高性能的同時,提高程序員的開發效率,適合構建高并發、高可用的后端系統。相比于C/C++,golang的語法更加簡潔清晰,刪去了部分冗余特性,同時支持垃圾回收(GC)和手動內存優化,相比C/C++又不失太多性能。最重要的是golang有相對比較完善的內置并發機制。這也是golang語言在現代應用廣泛的主要原因,常應用于云原生與微服務架構的項目開發,構建高并發的web服務,DevOps工具開發,網絡爬蟲,區塊鏈,構建日志系統,數據處理等多個領域。由于篇幅有限,本文不再贅述go語言的基礎語法。
一.golang與并發編程
(一)并發與并行
? ? ? ? 相信學過操作系統相關知識的朋友一定對進程和線程這兩個名詞不會陌生。這里如果讓我去介紹進程線程,我可能真的想不起來那些長篇大論晦澀難懂的八股,背那些東西也挺沒意思的,除了應付考試也沒有什么用。我個人對于進程和線程的理解是:無論是進程還是線程,本質上都是程序運行的載體。進程擁有獨立的內存地址空間,而線程則是進程中最小的執行單元。多個線程可共享同一個進程的內存地址空間,一個進程可以包含很多個線程。
? ? ? ? 假如我們需要并發處理一個任務,那就有多進程和多線程兩種處理策略。可以把多進程策略聯想為讓不同的公司處理這個問題,每家公司都有獨立的辦公區域(內存空間),彼此互不干擾。可以把多線程策略聯想為讓同一家公司內部的不同員工處理這個問題,它們共享辦公區域(內存空間),配合效率更高,但是也需要進行協調避免沖突。
? ? ? ? 處理一個大型任務通常有兩個最基本的優化思路。首先是并發I/O。我們可以通過上面提到的兩種策略實現并發處理,而golang語言則內置了一種更加輕量級的用戶態線程-goroutine(協程)來解決并發問題,允許不同的goroutine各自處理任務,我們后面會進行介紹。另外我們可以利用多核并行。現在的CPU大多數都擁有多個核心,我們完全可以利用多個CPU核心同時處理不同任務來提升任務的處理效率。在實際開發中,服務端開發者通常會結合使用上面這兩種優化思路,在使用多進/線程提高并發量的同時,通過盡可能使用多核并行充分利用CPU中大量核心所產生的性能。
????????這里可能會有一些不了解操作系統基本知識的朋友會疑惑,什么是并發,什么又是并行?這確實是初學者非常容易混淆的兩個概念。并行是指多個任務在多個 CPU 核心上真正同時運行,彼此互不干擾。而并發則是多個任務在單個或多個核心上“交替執行”。操作系統通過時間片輪轉的方式快速在任務之間切換,雖然任意時刻每個核心只執行一個任務,但由于切換足夠快,在用戶看來就像是這些任務同時進行一樣。并發并不意味著真正的同時運行,而是一種在資源有限條件下的高效調度策略。總結一句話,并行關注的是“同時做多件事”,而并發關注的是“如何管理好多件事”。
? ? ? ? 除此之外,還有另外一種處理并發的高效方法,即異步編程,也稱事件驅動編程。它允許任務在等待某些操作完成期間,不阻塞當前線程,而是掛起當前操作、繼續執行其他任務。其核心機制是單線程+事件循環。在 系統層面,異步編程往往依賴于 I/O 多路復用機制 來實現非阻塞的 I/O。而且開銷會比多進/線程小,規避了創建銷毀進/線程,以及上下文切換的成本。不過缺點是無法充分利用CPU的多核并行能力,比較適合I/O密集型任務。所以在選擇策略時,我們應重點關注當前任務屬于計算密集型任務,還是I/O密集型任務。
(二)go如何支持并發
? ? ? ? 在前文的敘述中,我們有提到,golang內置了一種更加輕量級的線程-goroutine來解決并發問題。事實上,goroutine也只是對傳統操作系統線程的一種“用戶態封裝”,由 Go 運行時通過 GMP 模型來進行調度和管理,而并非通過操作系統內核直接調度。關于GMP模型我們會單獨出文章來介紹,這是很多企業面試的常考題,我就有兩三次都被問到過。與傳統線程相比,goroutine 創建和切換的成本非常低,占用資源極少,初始棧空間只有幾 KB(傳統操作系統線程大約是MB級別),可以輕松支持成千上萬個并發任務。開發者只需使用go關鍵字即可啟動一個新的并發任務,無需手動管理線程、鎖或上下文切換。總的來說,goroutine 就是 Go 并發能力的核心。除此之外,go內置了一系列非常使用的并發原語,這些原語我們會在后面穿插介紹。
? ? ? ? 處理并發問題主要有以下幾大挑戰。首先是如何處理共享數據。如果在一塊內存區域中存在一個共享的數據對象,多個線程在同時讀寫共享數據,線程的執行順序不確定,會造成數據的不一致,也就是會導致一系列并發安全相關的問題。同時,多線程也會引入資源競爭的問題。針對上述問題一個非常行之有效的方法便是引入同步機制,其中最常見的便是加互斥鎖。其次,golang的一大設計哲學是“并非通過共享內存實現通信,而是通過通信實現共享內存”。由此,作為開發者,goroutine之間的交互通信需要引起格外重視。go內置了線程安全的channel數據結構來實現gouroutine之間的通信,同時內置Waitgroup原語來協調多個goroutine的執行。最后,Go語言也內置了檢測鎖競爭或者死鎖的工具,在編譯時加上-race標志,可以檢測數據競爭和一些潛在的鎖問題,這里需要注意-race并不是一種靜態檢測機制,即源碼層面的檢查,而是檢查當前程序的運行狀態。如果上面介紹的這些專有名詞沒有理解,沒關系,下面會給出具體的實例來幫助大家理解。
(三)多線程編程實例:簡單網絡爬蟲
? ? ? ? ?相信朋友們應該對“爬蟲”這個貌似挺火的詞匯不陌生。網絡爬蟲是一種自動訪問網頁并提取內容的程序。該程序從網頁URL開始,下載網頁內容并提取網頁中的鏈接,并不斷重復以上過程,在數據的采集,分析,監控等方面有顯著作用。我們往往不想重復抓取一個頁面,這對于網絡帶寬是很大的浪費,所以我們會采用布隆過濾器進行去重操作。在本文中將介紹兩種最常見的爬蟲程序的實現思路。
? ? ? ? 第一種思路是串行爬蟲,我們在網絡路徑圖中通過有效執行深度優先搜索(DFS),逐步訪問頁面,每抓取一個URL就啟動一個goroutine,維護一個map記錄已經爬取過的頁面實現去重,避免重復抓取。思路很簡單,示例代碼如下:
func Serial(url string,fetcher Fetcher,fetched map[string]bool){//去重邏輯if fetched[url]{return}fetched[url]=trueurls,err := fetcher.Fetch(url)if err != nil{return}//深度優先搜索邏輯,遞歸訪問頁面for _, u :=range urls{Serial(u,fetcher,fetched)}return
}
? ? ? ? 第二種思路是并行爬蟲。并行爬蟲有兩種實現方式。第一種是通過共享數據對象以及加鎖實現。在實現中我們使用了 sync.Mutex
來保護共享的 map[string]bool
,用于記錄已經抓取過的 URL。這里可能需要解釋一下為什么要在for循環中使用閉包函數并傳入u,因為 range?循環中的變量 u是被復用的,而 閉包默認捕獲的是變量的引用地址,而不是值本身。這意味著當 goroutine 實際啟動執行時,外層循環可能已經更新了 u
的值,導致捕獲到的并不是我們期望的 URL。為了解決這個問題,我們在閉包函數中將 u
顯式作為參數傳入,確保每個 goroutine 拿到的都是對應循環當時的 u
值,從而保證抓取邏輯的正確性。而這段代碼中也使用到了WaitGroup原語,可以把它看成一個計數器:每啟動一個goroutine,執行Add(1),讓計數器+1。當這個goroutine完成任務時,執行Done(),讓計數器-1。主協程通過調用 Wait()
進入阻塞狀態,直到所有 goroutine 執行完畢、計數器歸零為止。在實際爬蟲程序的設計中,我們往往還需要利用協程池來控制并發goroutine數量,防止資源耗盡。
? ? ? ? 說起閉包函數,我會想到一個非常有意思的問題。如果一個閉包函數引用了其外圍函數中的局部變量,而此時外圍函數已經 return,那么這個變量會發生什么?起初我會擔心:既然外圍函數已經返回,里面定義的局部變量理應隨著棧幀銷毀,那閉包函數所引用的變量是否會“懸空”?是否會導致運行時錯誤?答案是不會,Go 的編譯器在處理閉包時,會自動識別這種捕獲了外部局部變量的情況,并進行“逃逸分析”。當編譯器發現某個局部變量被閉包引用,并且閉包的生命周期可能超過當前函數時,它會將這個變量從棧上分配改為在堆上分配,以確保該變量的生命周期能撐到閉包函數結束。這樣,無論外圍函數何時 return,閉包中捕獲的變量依然有效,直到最后一個引用它的函數也執行完畢,才會被垃圾回收(GC)清理掉。示例代碼如下:
type fetchState struct{mu sync.Mutexfetched map[string]bool
}func ConcurrentMutex(url string,fetcher Fetcher,f *fetchState){f.mu.lock()already :=f.fetched[url]f.fetched[url]=truef.mu.Unlock()if already{return}urls,err := fetcher.Fetcher(url)if err !=nil{return}var wg sync.WaitGroupfor _,u :=range urls{wg.Add(1)//go閉包捕獲引用,每個range重用了ugo func(u string){defer wg.Done()ConcurrentMutex(u,fetcher,f)}(u)}wg.Wait()return
}
? ? ? ? 第二種是通過channel實現協程通信來實現,它遵循 Go 的設計哲學“不要通過共享內存來通信,而應該通過通信來共享內存”的理念。我們無需使用鎖,從而避免了顯式的并發控制復雜性。主線程master 維護抓取狀態,但不共享對象。master中維護了一個map,與基于 mutex
的實現不同,worker 之間并不共享這個 map
,而是由 master 單線程統一維護抓取狀態,這樣天然避免了并發沖突。master 與 worker 通過 channel 通信,所有的 URL 抓取任務都通過一個 chan []string
來傳遞,每一個 worker
負責抓取一個頁面并將獲取到的新 URL 列表通過 channel 發送回 master,由 master 決定是否繼續抓取。在master中使用一個變量 n
來記錄當前正在運行的 worker
數量,每創建一個新的 worker
,n++;
每處理完一輪從 channel 中讀到的 URL 集合后,n--;
當 n=0
時,說明所有任務已完成,master 主動退出循環。在調用 master
之前,ConcurrentChannel
會先將種子 URL寫入 channel,我們把這個過程稱為冷啟動機制。在這個過程中,因為 Go 的 channel 默認是無緩沖的,寫入操作是阻塞的,因此這一步必須放在 goroutine 中,防止阻塞主協程。每個 worker 會在一個goroutine中執行,通過 channel 接收 URL,抓取內容,并將抓取到的新 URL 發回 channel。這些worker之間完全獨立,并不共享任何狀態或對象,主從職責清晰。由于Go 的 channel 內部實現中使用了 mutex,因此它天然就是線程安全的,可以安全地在多個 goroutine 之間傳遞數據,而無需加鎖。示例代碼如下:
func worker(url string,ch chan []string,fetcher Fetcher){//實際的抓取邏輯urls,err:=fetcher.Fetch(url)//向channel中發送信息if err!=nil{ch<-[]string{}}else{ch<-urls}
}func master(ch chan []string,fetcher Fetcher){n:=1fetched:=make(map[string]bool)//從channel中獲取一個URLfor urls:=range ch{//再獲取這URL列表中的URLfor_,u:=range urls{//如果這個URL未被抓取,則啟動一個新的worker線程去抓這個URLif fetched[u]==false{fetched[u]=truen+=1go worker(u,ch,fetcher)}}n-=1//爬蟲完成了所有工作,已抓取完每一個URLif n==0{break}}
}func ConcurrentChannel(url string,fetcher Fetcher){ch:=make(chan []string)//將URL種子寫入channelgo func(){ch<-[]string{url}}()master(ch,fetcher)
}
二.服務通信-RPC
(一)既生HTTP,何生RPC?
? ? ? ? 在分布式系統中,各模塊會部署在不同服務器節點上,此時不同節點之間的通信成為開發者必須考慮的問題。在網絡通信實踐中,相信朋友們一定對HTTP這個最常見的web應用層協議再熟悉不過了吧。作為標題黨,可能會有懂行的朋友立刻指出:HTTP和RPC根本就不能這么對比,前者是協議,后者是設計思想。是這樣沒錯,不過在本文中我還非要取這么個標題,沒關系,咱們接著往下看~
? ? ? ? HTTP全稱超文本傳輸協議。常用于萬維網服務器與本地瀏覽器之間的數據傳輸,是一個基于TCP的應用層協議。雖然HTTP是web世界的通用協議,但是在追求高性能,低延遲,強類型的分布式系統中,傳統的HTTP+JSON的通信方式已經不能滿足需求了。首先我要說清楚,這里說的HTTP指的是傳統的HTTP/1.1+JSON/REST API模式。雖然也可以用這種方式進行服務調用,不過由于JSON是純文本格式,體積太大,解析慢且占用帶寬;且HTTP/1.1是單請求單連接的形式,無多路復用機制,大量并發請求易造成連接瓶頸和資源浪費,所以不適合服務間的高效通信。而且JSON是弱類型協議,前后端接口如果變動容易出問題,且RESTful API 只是一個風格,沒有強制的規范和工具鏈,文檔靠手寫Swagger,代碼全靠人維護,服務變動時容易出現客戶端和服務端使用兩套接口的問題。HTTP只支持客戶端請求,服務端響應的單向模式,但是在分布式系統中,可能會需要客戶端流,服務端流,雙向流等多種通信方式。總結來講,HTTP/1.1+JSON/REST API的設計模式只能說對瀏覽器友好,但是不等同于對服務友好。RPC并不是要替代HTTP,而是專為服務間高效通信而設計的一種更專業的方案。
? ? ? ? 其實RPC設計思想的起源,甚至早于HTTP協議,最早可以追溯到上世紀70年代。RPC,即遠程過程調用,其實是一種通信思想,既可以基于TCP,UDP等傳輸層協議,也可以基于HTTP等應用層協議,有很高的可定制性,比如說我們后面要介紹到的gRPC就是以HTTP/2作為底層傳輸協議實現的。RPC并不是協議,但是像Thrift,gRPC這種具體實現才算得上是協議的范疇。在微服務時代,RPC 提供了更強的性能、更高的類型安全、更好的自動化與服務治理能力,是服務間調用的專業工具。
? ? ? ? 我們拿RPC的一種經典實現方式gRPC,在服務通信的場景下,與傳統的HTTP/1.1進行性能對比。首先gRPC支持雙向流,服務端流等通信方式,支持多路復用,即在同一個TCP連接上互不干擾地并發處理多個請求,gRPC壓縮請求頭的大小,提高傳輸效率。其次,在序列化層面,gRPC使用的Protobuf(二進制序列化協議)相比純文本的JSON要更加輕量,具有更快的序列化和反序列化的速度,由此節省更多的網絡帶寬,不容忽視的的是Protobuf支持強類型結構,并自動生成多語言代碼,極大提升了開發效率與可靠性。而且,gRPC支持在服務端與客戶端添加攔截器來實現鑒權token校驗,限流熔斷,追蹤埋點等服務治理的手段,提升了系統的可用性。總結一句話,對人用,選 HTTP/REST,對服務用,選 RPC/gRPC。而實際上,gRPC 與 REST 并不沖突,二者可共存。
(二)gRPC與最佳實踐
????????
? ? ? ? gRPC是由Google公司研發的一款開源的,高性能的遠程過程調用(RPC)框架。實際上,在我的理解來看gRPC既可以理解為框架,也可以理解為協議,所以大可不必為此感到困擾。它使用Protobuf作為序列化格式,同時基于HTTP/2設計,支持多種開發語言。在 gRPC 中,客戶端應用程序可以直接調用另一臺機器上的服務器應用程序的方法,就像調用本地對象一樣。在服務器端,服務器實現此接口并運行 gRPC 服務器來處理客戶端調用。在客戶端,客戶端有一個stub(存根)它提供與服務器相同的方法,gRPC 客戶端和服務器可以在各種環境中運行并相互通信。這里我把gRPC的官方文檔貼出來,朋友們可以參考學習:gRPC官方文檔
????????與許多 RPC 系統一樣,gRPC 基于定義服務的思想,指定可以遠程調用的方法及其參數和返回類型。默認情況下,gRPC 使用Protobuf作為接口定義語言 (IDL),用于描述服務接口和有效消息的結構。這里我節選出我自己項目中某個微服務的一段接口定義和消息結構定義(.proto文件)作為示范:
// 用戶服務接口定義
service UserService {// 用戶注冊:輸入注冊信息,返回注冊結果rpc RegisterUser (RegisterRequest) returns (RegisterResponse);// 用戶登錄:輸入用戶名密碼,返回登錄 tokenrpc LoginUser (LoginRequest) returns (LoginResponse);
}// 消息結構定義
// 注冊請求
message RegisterRequest {string username = 1;//用戶名string password = 2;//密碼string email = 3;//郵箱string phone = 4;//電話號碼
}// 注冊響應
message RegisterResponse {bool success=1;//是否成功string user_id = 2;//注冊成功后分配的用戶IDstring message = 3;//提示信息
}// 登錄請求
message LoginRequest {string username = 1;//用戶名string password = 2;//密碼
}// 登錄響應(返回JWT)
message LoginResponse {string token = 1;// 返回JWT token,用于鑒權string user_id = 2;//用戶ID
}
? ? ? ? 一旦寫好了.proto文件,gRPC原生提供Protobuf編譯器,我們可以執行相對應的命令,或者編寫腳本,自動生成客戶端和服務端代碼。一般來講客戶端用來調用這些定義好的API,而服務端則實現這些API。這些部分便是一個后端項目真正意義上的業務代碼了,所以我暫時不進行展示。
????????在真正的項目開發過程中,開發者們總結出了一套較為系統的 gRPC 最佳實踐方法。從接口的設計規范、編譯腳本的自動化、到中間件的擴展能力、安全認證、負載均衡再到多語言協同開發,gRPC 已經從最初的高性能通信框架演變為一個現代微服務體系的通信骨干工具。在實際開發過程中,我們往往會引入攔截器提升服務治理能力,使用中間件進行錯誤處理以及重試。我們還經常使用Etcd/Consul等集群管理工具實現服務注冊與自動發現,在請求量大時啟用連接池充分復用節省資源等等。無論是構建小型微服務系統,還是支撐復雜的大規模分布式架構,gRPC 都是一個值得考慮的方案。它不只是“比 HTTP 快”,更是更現代、更自動化、更易維護的服務通信解決方案。
? ? ? ? 以上就是我對并發編程和RPC的粗淺理解,如有不當懇請批評指正,我們一起成長!