使用go的併發性來解決Hilbert酒店問題

charlieroro發表於2023-03-16

譯自:Designing for Concurrency: the Hilbert’s Hotel Problem in Go,本文使用go的併發性來解決Hilbert酒店問題。本文比較有意思的是它對問題的描述很吸引人,在看完文字描述之後,程式碼實現邏輯也基本順理成章,當然程式碼本身的實現也相當優雅。

文章一開始敘述了併發和並行的區別和聯絡,此處略去該部分。

Hilbert酒店

Hilbert酒店是一個與無限有關的問題。

假設Hilbert是一個酒店的所有者,且該酒店有無限個房間。

一天一個大巴達到了Hilbert的酒店,假設大巴上有無限個旅客想要住在Hilbert的酒店,假設Hilbert的酒店有無限個房間,因此能夠容納無限個旅客。

但有一天有無限輛大巴達到了Hilbert的酒店,每輛大巴上載有無限個旅客,且所有大巴的旅客都要在酒店過夜。

image

Hilbert想了一會說"沒問題",然後他畫了一個數字三角形,然後把房間的鑰匙遞給沿著三角形對角線的所有乘客。透過這種方式,Hilbert能夠給每輛大巴的每個乘客一把唯一的鑰匙。

Hilbert酒店的非併發演算法

我們可以透過重複Hilbert的做法來解決Hilbert酒店問題。

例如,可以建立陣列的陣列來代表三角形的數字,然後遍歷該陣列的陣列來構建對角線並沿著對角線將房間鑰匙交給每輛大巴的乘客。文章末給出了這種方式的實現,當然也存在其他的非併發實現。下面換個角度看下這個問題。

Hilbert酒店的遞迴併發演算法

遞迴併發演算法中,每輛大巴所需的鑰匙位於三角形的最右側對角線。對角線中的鑰匙所在的位置等於三角形高度,這一點就是判定某把鑰匙是不是本大巴的關鍵。

假設Hilbert的酒店中有無限個僱員,每個僱員負責給一輛大巴的所有旅客發放鑰匙。負責第一輛大巴的僱員(Bus 1 Clerk)靠近負責第二輛大巴的僱員(Bus 2 Clerk),負責第二輛大巴的僱員(Bus 2 Clerk)靠近負責第三輛大巴的僱員(Bus 3 Clerk),以此類推。

此外還有一個僱員(Room Key Clerk),他負責將所有房間的鑰匙依次交給第一個僱員(Bus 1 Clerk),即房間一是第一把鑰匙,房間二是第二把鑰匙,房間三是第三把鑰匙,以此類推。

(從Room Key Clerk接收到鑰匙的)Bus 1 Clerk知道接收到的第一把鑰匙是給他負責的大巴的第一個乘客,因此Bus 1 Clerk會為第一輛巴士的第一位乘客準備一號房間的含鑰匙在內的歡迎禮包(welcome kit),此外他還知道接收到的第二把鑰匙不是給他負責的大巴的,而是給下一個僱員的,第三把鑰匙是給Bus 1的,因此由Bus 1 Clerk負責,但第四把和第五把鑰匙不是給Bus 1的,因此Bus 1 Clerk會將它們傳給下一個僱員。當歡迎禮包發放給Bus 1的所有乘客之後(此時需要假設顧客是有限的,防止程式無法結束),Bus 1 Clerk會將它們返還給Hilbert。後面將會看到,將鑰匙還給Hilbert是一個有用的步驟,可以併發實現最終的類似reduce的操作。

下一個僱員Bus 2 Clerk的行為和第一個僱員相同,他知道接收到的第一個鑰匙需要交給他負責的大巴的第一個乘客,而第二把鑰匙則需要交給下一個僱員,以此類推。

image

在某種程度上,由於我們的程式不能像原來的Hilbert問題那樣永遠繼續下去,因此需要能夠停止移交鑰匙,並通知所有僱員停止工作。此時需要將僱員準備的歡迎禮包還給Hilbert。最終,所有的僱員都會停止工作,並在Hilbert接收到準備好的歡迎禮包之後就可以通知Hilbert也停止工作。

image

Go實現

這裡提供了一個Go實現的併發演算法。使用goroutine來代表每一個角色,包括Hilbert和僱員。

  • Hilbert是主goroutine,用於啟動整個流程並收集大巴僱員建立的歡迎禮包
  • Key Handler Clerk是一個由Hilbert啟動的goroutine,負責依次一系列房間鑰匙,並交給Bus 1 Clerk,直到達到鑰匙上限
  • Bus 1 Clerk是另一個由Hilbert啟動的goroutine,它從Key Handler Clerk接收鑰匙,並實現對應的邏輯:為自己負責的大巴準備歡迎禮包,並將其他鑰匙交給下一個僱員
  • Bus 2 Clerk是由Bus 1 Clerk啟動的goroutine,處理邏輯和Bus 1 Clerk相同
  • 其他Bus Clerks都執行相同的邏輯,每一個都是一個獨立的goroutine,且共享相同的程式碼

下面是Hilbert的實現:

func Hilbert(upTo int) {
   keysCh := make(chan int)
   go RoomKeysClerk(upTo, keysCh)  //1


   make(chan []hilberthotel.WelcomeKit)
   go BusClerk(1, keysCh, welcomeKitCh) //2


   for envelope := range welcomeKitCh { //3
       fmt.Println(envelope)
   }
}

其程式碼比較簡單,入參upTo表示房間鑰匙的上限:

  1. 首先它會為鑰匙建立channel keysch,並使用keysch作為引數來啟動Room Key Clerk的goroutine。
  2. 然後它會建立另一個channel welcomeKitCh(僱員會從該channel中接收鑰匙,並在僱員結束工作後傳送歡迎禮包),用於接收Welcome Kits(歡迎禮包),並使用keyschwelcomeKitCh作為引數來啟動第一個BusClerk(大巴僱員)
  3. 最後,它會透過welcomeKitCh迴圈接收僱員準備的禮包

Room Key Clerk 的實現也很簡單,它透過keysCh將鑰匙分發出去,在鑰匙到達上限upTo之後,關閉keysCh

func RoomKeysClerk(upTo int, keysCh chan<- int) {
   for i := 0; i < upTo; i++ {
       keysCh <- i + 1
   }
   close(keysCh)
}

Bus Clert的實現要複雜一些:

func BusClerk(busNumber int, roomKeysCh <-chan int, welcomeKitsCh chan<- []hilberthotel.WelcomeKit, buffer int, delay time.Duration) { //1
   var count = 0                  //2
   var keyPosition = 0
   var nextClerkCh chan int

   welcomeKits := []hilberthotel.WelcomeKit{}

   for roomKey := range roomKeysCh {
       if nextClerkCh == nil {    //3
           nextClerkCh = make(chan int, buffer)
           go BusClerk(busNumber+1, nextClerkCh, welcomeKitsCh, buffer, delay)
       }
       if count == keyPosition {  //4
           kit := hilberthotel.NewWelcomeKit(busNumber, keyPosition, roomKey, delay)
           welcomeKits = append(welcomeKits, kit)
           keyPosition++
           count = 0
           continue
       }
       nextClerkCh <- roomKey
       count++
   }

   if nextClerkCh != nil { //5
       welcomeKitsCh <- welcomeKits
       close(nextClerkCh)
   } else {
       close(welcomeKitsCh) //6
   }
}
  1. 每個Bus Clert對應一個goroutine。BusClerk的第一個引數是其所屬的大巴號,welcomeKitCh 用於在處理結束之後傳送歡迎禮包(welcomeKits),roomKeysCh用於讀取鑰匙號
  2. 在初始化內部計數器count之後,使用一個變數keyPosition來儲存下一個乘客的鑰匙位置,使用channel nextClerkCh通知下一個BusClerk。透過迴圈讀取roomKeysCh來啟動整個處理邏輯。
  3. 在接收到第一個鑰匙之後,此時nextClerkCh為nil,之後會初始化nextClerkCh並啟動下一個BusClerk
  4. 對比countkeyPosition,用於確定此時的鑰匙是給本大巴的還是給其他大巴的。當鑰匙是給本大巴的,它會建立一個WelcomeKit並將其儲存在它的集合中,反之,將鑰匙傳給下一個BusClerk
  5. 當鑰匙接收完之後(即roomKeysCh被關閉),它會關閉用於通知下一個BusClerk的nextClerkCh channel
  6. 最後一個BusClerk將不會再接收到任何房間鑰匙,因此它會關閉welcomeKitCh並通知Hilbert其將結束任務

相對獨立的處理流程

上述解決方案基於"相對獨立"的處理流程:

  • Room Key Clerk的任務相對獨立於Bus Clerk 1,這裡說的"相對獨立"並不是完全獨立,因為Room Key Clerk還需要等待Bus Clerk 1從keyCh channel接收鑰匙(即使用了帶快取的 keysCh channel,緩衝區仍然會被填滿,從而迫使 Room Key Clerk 等待Bus Clerk 1從通道讀取資料)
  • 類似地,Bus Clerk 1也會依賴Bus Clerk 2來從它們共享的keysCh中讀取資料,以此類推
  • 最終,所有的Bus Clerk 都會依賴Hilbert從welcomeKitCh channel中接收welcomeKits

因此,我們可以說所有的Bus Clerk和Hilber執行的都是"相對獨立"的邏輯,需要透過調整channel的傳送/接收來達到期望的結果。

併發是有代價的,但啟用並行可以帶來好處

雖然我們的併發設計實現的方案很優雅,但它也帶來了如下開銷:

  • 生成的goroutine數目等於大巴的數目 + Hilbert + Room Key Clerk
  • 需要不斷在可用的核上排程goroutines
  • 需要初始化和goroutines一樣多的channels
  • 需要透過這些channels執行傳送和接收操作

另一方面,這種設計的好處在於,如果在多核硬體上執行該併發演算法,演算法中固有的並行邏輯會帶來效能上的提升。

併發goroutine的任務越重,並行的提升幅度越大

image

每個大巴僱員都有一些核心的工作需要完成,此外還有一些併發編排的工作(即配置goroutine和透過channel傳送/接收鑰匙)。

並行可以提升核心工作的效能。在Hilbert的例子中,核心工作就是為每個客戶準備歡迎禮包(即函式NewWelcomeKit執行的內容)。能夠並行執行的核心工作越多,就越能在相同的時間內服務更多的顧客。

為了實現並行處理,我們需要執行一些併發編排工作。與併發編排工作相比,核心工作占主導地位越高,從並行性中獲得的好處就越多。在Hilbert的例子中,每個大巴僱員的核心工作是準備歡迎禮包。因此,準備歡迎禮包的工作佔比越高,多核硬體上執行併發設計的效率就越高。另一方面,處理的顧客越多,併發編排工作也就越重(由於需要在更多的goroutines之間進行切換和傳送/接收鑰匙),因此併發的成本也會越高。

可以使用樣例程式碼提供的benchmarks,透過變更顧客數目來對效能進行驗證。

附錄

也可以使用上面所使用的併發方案的基本設計匯出非併發遞迴方案:

  • 將RoomKeysClerk 轉變為一個生成鑰匙並將其傳遞給第一個BusClerk的迴圈
  • 使用閉包(即一個封裝了上下文狀態的函式(當前計數器,keyPosition和 nextClerk))來實現BusClerk,
  • 使用Hilbert函式用來觸發整個執行邏輯,並收集各個BusClerk構造的welcomeKits

相關文章