MIT 6.824 分散式系統課程第二課:RPC 和多執行緒

ty4z2008-github發表於2020-03-03

筆記:RPC and Threads

視訊:Lecture 2: RPC and Threads

課程概要

主要討論 Golang 中的多執行緒和 RPC,以及實驗相關的內容。

正文

為什麼是 Golang?

  • 對多執行緒的良好支援,那個執行緒擁有自己的執行棧
  • RPC 特別方便
  • 型別安全
  • 自動垃圾回收(不用擔心記憶體釋放問題)
  • 多執行緒 +GC 非常更吸引人
  • 簡單
  • 教程 Effective Go

多執行緒

I/O 併發(做多件事情)、並行(CPU)。

多執行緒是一個結構化工具,但是有一些坑

Go 叫多執行緒為 goroutines;

Thread = 執行執行緒

多執行緒允許一個程式在執行時去做很多事情。

每個執行緒都是序列執行,就像是非執行緒程式

執行緒可以共享記憶體

每個執行緒都有自己的執行緒狀態:程式計數器、暫存器、棧

執行緒和程式是包含的關係,一個程式可以產生很多執行緒

為什麼選擇多執行緒?

在分散式系統中,需要併發執行。而多執行緒是實現併發已經廉價的方式

I/O 併發:多個客戶端同時傳送請求到多臺 server,並且等待響應。伺服器處理多個客戶端請求,每個請求可能會阻塞。譬如客戶端 X 讀取磁碟資料的同時接到處理客戶端 Y 的請求。

多核效能:並行的在多個 CPU 執行程式碼

方便:通過後臺執行 worker,進行每秒查詢某個值是否有效

是否有執行緒的替代方案?

yes:在單執行緒中寫非序列邏輯。這種方式也被稱為事件驅動。譬如 JavaScript。

做法:使用一個狀態表儲存所有活動的狀態,假設是每個客戶端的請求

事件迴圈:在收到伺服器的響應時,傳入新的輸入值,檢查狀態表中的每個活動的狀態。執行每個活動的下一步。並且更新狀態。依次迴圈完成整個狀態表。

利用事件驅動來達到 I/O 併發的目的:這個相比較多執行緒要更大節約成本。但它不能利用多核,寫起程式碼來也比較痛苦

執行緒的挑戰

共享資料

例如:有兩個執行緒,同時做一個 n=n+1 的邏輯?有一個執行緒正在讀取,而另外有一個執行緒在做自增操作?類似資料庫事務中遇到的資料共享問題。

上面兩個問題都遇到了 “競爭” 問題,這個會成為一個程式的 Bug。在電商領域會有超賣的風險。如何解決?

  • 使用鎖(Go 中使用 sync.Mutex)
  • 避免使用共享可變資料(immutable and mutable)

執行緒間協調

例如:一個執行緒生產資料,另一個執行緒消費資料。生產與消費者

  • 消費者如何等待(不能總是佔著 CPU)
  • 生產者如何喚醒消費者?

如何解決?

  • 使用 Go channel 通訊
  • sync.Cond
  • WaitGroup

死鎖

週期性的鎖檢查或者是通訊(RPC 或 Go channels)

案例學習:Web 爬蟲

什麼是 Web 爬蟲?

  • 獲取一個或多個站點的所有的 web 網頁。例如:用來構建索引
  • 網頁和連結構成是一個圖結構
  • 指向某些頁面的多個連結
  • 圖具有環特徵

爬蟲的挑戰

如何利用 I/O 併發

爬蟲的很多瓶頸都是因為網路頻寬的限制。在同一時刻抓取多個 URL 來提高併發(每秒抓取數量)=> 利用多執行緒來提高併發

避免重複抓取

多次抓取是網路的浪費,這裡需要記錄訪問狀態

要知道何時停止

不能沒有邊界,遇到什麼情況就需要停止繼續爬取

以爬取 Golang.org 為例子

程式碼:https://pdos.csail.mit.edu/6.824/notes/crawler.go

解決方案 1: 序列爬取

通過遞迴呼叫執行按序爬取,定義的 fetched map 會記錄哪些地址已經爬取。函式中所傳遞的引數為引用。

缺點:同一時刻,一次只能爬取一個頁面。不過可以試試執行 go Serial() 來改善效能。

解決方案 2: 併發互斥爬取(concurrentMutex)

  • 為每個爬取的頁面建立一個執行緒

    併發爬取、提高爬取速度。 “go func” 可以建立一個 goroutine 並且執行該函式(可以是匿名函式)

  • 執行緒之間是共享變數 feteched map

    通過互斥鎖的方式來控制,同時只能一個執行緒訪問該變數

  • 為什麼需要互斥?(Lock()、Unlock())

  • 不同的頁面可能包含相同的 URL

  • 兩個執行緒同時讀取該 URL。譬如 T1 獲取 fetched[url]、執行緒 T2 也獲取 feteched[url],此時因為該 URL 還沒有爬取完,already 還是 false 狀態。那麼會導致兩個執行緒都在爬取。這裡就需要藉助鎖來處理。鎖會保證讀取和更新這兩個操作是具有原子性。

  • 在 Go 語言內部,map 是一個複雜的資料結構(tree or 擴充套件的 hash)。併發更新/更新會導致內部的不可變性。併發的 update/read 可能會導致 read 失敗

思考:假設註釋掉 Lock() / Unlock() 會發生什麼?

  1. go run crawler.go 為什麼可以正常工作?
  2. 即使剛剛列印的結果都是正確的
  3. 即使剛剛列印的結果都是正確的,不過可以執行 go run -race crawler.go 列出變數競爭的日誌

ConcurrentMutex 何時認為執行已經完成?

sync.WaitGroup

Wait() 等待所有的 Add() 操作,但遇見呼叫 Done() 時表示已經執行完成。譬如:可以用來處理等待所有的子程式處理完成。

該爬蟲程式會建立多少個執行緒?等於 URL 數量

ConcurrentChannel 爬蟲

  • go channel:channel 是一個物件,ch := make(chan int)。通道可以讓一個執行緒傳送一個物件給另外一個執行緒。傳送的執行緒使用 ch <- x 把 j 傳送給其他 Goroutine 接收者。接收者通過 y := <- ch 把結果儲存到 y 物件中。for y := range ch 等待通道訊息。
  • 通道的通訊是同步的
  • 通道可以被多個執行緒傳送或者是接收訊息
  • 通道對於作業系統非常廉價
  • 傳送方在接收者接到訊息之前會一直阻塞,直到此條訊息被消費(同步,小心死鎖)

ConcurrentChannel master()

master() 會建立 worker 進行頁面爬取,worker() 把頁面的 URL 格式化為 slice,並且傳送結果到 channel 中。master 作為接收者監聽來自其他 worker 傳送過來的訊息。feteched 不需要鎖,因為不存在資料共享訪問。

問題: master 中的 wait 應該寫在哪兒?等待的時候是否會佔用 CPU 時間?

master 是如何知道已經完成爬取?使用計數的方式 n,每個 worker 只傳送一次訊息到 channel

為什麼多個執行緒使用同一個通道不會造成競爭?(因為通道的傳送和接收是同步的)

當 worker 向 URLs slice 中寫入資料時,是否存在競爭?master 讀取時,為什麼不需要鎖?

  • worker 只會在傳送前寫入
  • master 只會在接收訊息後讀取
  • 並不存在同時使用 URLs 物件的情況

何時應該使用共享變數和鎖?對比通道有什麼好處?

其實所有的問題都有兩種解決方案,依賴你如何去思考。

  • 狀態-- 共享和鎖
  • 通訊-- 通道

對於 6.824 的實驗,推薦如果是狀態就使用共享變數 + 鎖的方式,如果是等待和訊息就使用 sync.Cond 或 channel 或 time.Sleep()

遠端呼叫(RPC)

遠端呼叫是分散式系統的關鍵,所有的實驗都有涉及

RPC 的目的是為了解決客戶端程式和服務端易於程式設計的資料通訊,隱藏網路協議、把資料轉換成統一的格式,譬如 strings, arrays, maps, &c 轉換為 wire 格式

RPC 訊息圖

軟體結構

案例學習:kv.go

程式碼:https://pdos.csail.mit.edu/6.824/notes/kv.go

介紹:這是一個簡單的模擬 KV 儲存伺服器,僅支援 Put(key,value), Get(key)->value

程式碼中使用了 Go 的 RPC 庫 net/rpc

程式碼分為三部分:

  • common

宣告一些引數以及 server 響應的資料結構

  • client

    connect() 建立一個 tcp 連線,並連線到 server

    get() 和 put() 是客戶端的操作,函式內部的 Call() 呼叫 RPC 庫。主要包含動作、操作、值以及錯誤處理

  • server

    宣告供 RPC 呼叫的方法、完成物件註冊到 RPC 庫。庫包含:連線讀取、為每個連線建立 goroutine、解碼請求、查詢儲存結果、呼叫物件方法、編碼響應結果、回寫結果給客戶端;Get() 和 Put() 都必須使用鎖,因為多執行緒會存在同時運算元據。

一些其他的細節:

  • Binding:客戶端如何連線需要通訊的服務端?對於 Go rpc。使用主機名加埠號的形式。在大型系統中,會存在各種命名和配置伺服器
  • Marshalling:編碼,傳送時的資料格式。Go 中支援傳入字串、陣列、物件、map、指標等,不支援 channel 和函式。可以通過指標來複制需要訪問的資料

RPC 的問題:呼叫失敗怎麼辦?

例如:丟包、網路中斷、伺服器慢、伺服器崩潰

對於連線的客戶端來說,故障會表現出什麼樣子?

  • 客戶端得不到服務端響應
  • 客戶端不知道服務端是否已經收到了請求,這裡有幾種情況。請求未收到、請求已經收到正在處理,但在返回結果時崩潰了、服務端傳送了結果,但是網路出現故障

簡單的故障處理方式:best effort

  • Call() 時等待一段時間的響應
  • 重發請求
  • 當等待一段時間後,發現還是沒有響應就放棄並返回錯誤。

Q:開發的應用程式如何做到處理邏輯更簡單,又有效?

假設一個場景,客戶端執行 Put(“k”,10);Put(“k”,20);兩者都成功執行成功。再執行 Get(“k”) 時會得到什麼結果?中間會有一些問題:超時、重發、先傳送的後到。

Q:這種 Best effort 的形式是否就夠了?

只讀操作、保持冪等性。譬如,資料庫在進行 inserted 前可以進行檢查記錄是否存在(唯一鍵)

優秀的 RPC 服務:只做一次

解決方式:RPC 伺服器能識別什麼是重複的請求,如果是重複的請求,則返回前面生成的結果。

問題:如何識別出重複的請求?

客戶端在請求時,針對每個請求,可以生成一個唯一請求 ID(request id)。當進行重發時,該 ID 不變。邏輯虛擬碼如下:

該方案的複雜點:(lab3)

假設有兩個客戶端生成了相同的 xid 怎麼辦?更多的隨機數?使用客戶端 ID(譬如 IP)?

服務端最終需要丟棄掉舊的 RPC 請求,那何時丟棄才是安全的?

思路:

  • 每個客戶端有一個唯一 ID、每個客戶端 會生成一個 RPC 序列號、每個客戶端都包含"seen all replies <= X"(像是 TCP 序列號和 ack)。
  • 第二種方案,在同一時刻,只允許一個 RPC 連線。seq+1 的訊息被允許。小於 seq 的訊息就丟棄

當遇到衝突的請求時,前一個請求還在處理中。如何解決衝突?為每個執行的 RPC 都定一個 flag 標示。最終來決定是等待還是丟棄。

at-most-once 的策略,當遇到伺服器崩潰或者重啟如何處理?如果重複記錄的資訊是儲存在記憶體中,當伺服器重啟時,之前的記錄就會丟失。這裡可以:

  • 從副本從獲取
  • 每次寫結果前,先落盤

GO RPC 就是 at-more-once 的策略模式,只執行一次。

  • 開啟 TCP 連線
  • 傳送請求到 TCP
  • 永不進行重發(伺服器就看不到重複呼叫了)
  • 如果沒有得到響應,就返回錯誤.(TCP 超時、服務端未收到請求、服務端處理了但是網路故障)

擴充套件:恰好一次的策略(exactly once)

無限制的重試,重複檢測以及容錯服務(Raft)

參考文獻

lecture 1: introduce

部落格地址

更多原創文章乾貨分享,請關注公眾號
  • MIT 6.824 分散式系統課程第二課:RPC 和多執行緒
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章