译自:Designing for Concurrency: the Hilbert’s Hotel Problem in Go,本文使用go的并发性来解决Hilbert酒店问题。本文比较有意思的是它对问题的描述很吸引人,在看完文字描述之后,代码实现逻辑也基本顺理成章,当然代码本身的实现也相当优雅。
文章一开始叙述了并发和并行的区别和联系,此处略去该部分。
Hilbert酒店
Hilbert酒店是一个与无限有关的问题。
假设Hilbert是一个酒店的所有者,且该酒店有无限个房间。
一天一个大巴达到了Hilbert的酒店,假设大巴上有无限个旅客想要住在Hilbert的酒店,假设Hilbert的酒店有无限个房间,因此能够容纳无限个旅客。
但有一天有无限辆大巴达到了Hilbert的酒店,每辆大巴上载有无限个旅客,且所有大巴的旅客都要在酒店过夜。
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的行为和第一个雇员相同,他知道接收到的第一个钥匙需要交给他负责的大巴的第一个乘客,而第二把钥匙则需要交给下一个雇员,以此类推。
在某种程度上,由于我们的程序不能像原来的Hilbert问题那样永远继续下去,因此需要能够停止移交钥匙,并通知所有雇员停止工作。此时需要将雇员准备的欢迎礼包还给Hilbert。最终,所有的雇员都会停止工作,并在Hilbert接收到准备好的欢迎礼包之后就可以通知Hilbert也停止工作。
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
表示房间钥匙的上限:
- 首先它会为钥匙创建channel
keysch
,并使用keysch
作为参数来启动Room Key Clerk的goroutine。 - 然后它会创建另一个channel
welcomeKitCh
(雇员会从该channel中接收钥匙,并在雇员结束工作后发送欢迎礼包),用于接收Welcome Kits(欢迎礼包),并使用keysch
和welcomeKitCh
作为参数来启动第一个BusClerk(大巴雇员) - 最后,它会通过
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 } }
- 每个Bus Clert对应一个goroutine。BusClerk的第一个参数是其所属的大巴号,
welcomeKitCh
用于在处理结束之后发送欢迎礼包(welcomeKits
),roomKeysCh
用于读取钥匙号 - 在初始化内部计数器
count
之后,使用一个变量keyPosition
来保存下一个乘客的钥匙位置,使用channelnextClerkCh
通知下一个BusClerk。通过循环读取roomKeysCh
来启动整个处理逻辑。 - 在接收到第一个钥匙之后,此时
nextClerkCh
为nil,之后会初始化nextClerkCh
并启动下一个BusClerk - 对比
count
和keyPosition
,用于确定此时的钥匙是给本大巴的还是给其他大巴的。当钥匙是给本大巴的,它会创建一个WelcomeKit并将其保存在它的集合中,反之,将钥匙传给下一个BusClerk - 当钥匙接收完之后(即
roomKeysCh
被关闭),它会关闭用于通知下一个BusClerk的nextClerkCh
channel - 最后一个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的任务越重,并行的提升幅度越大
每个大巴雇员都有一些核心的工作需要完成,此外还有一些并发编排的工作(即配置goroutine和通过channel发送/接收钥匙)。
并行可以提升核心工作的性能。在Hilbert的例子中,核心工作就是为每个客户准备欢迎礼包(即函数NewWelcomeKit执行的内容)。能够并行执行的核心工作越多,就越能在相同的时间内服务更多的顾客。
为了实现并行处理,我们需要执行一些并发编排工作。与并发编排工作相比,核心工作占主导地位越高,从并行性中获得的好处就越多。在Hilbert的例子中,每个大巴雇员的核心工作是准备欢迎礼包。因此,准备欢迎礼包的工作占比越高,多核硬件上运行并发设计的效率就越高。另一方面,处理的顾客越多,并发编排工作也就越重(由于需要在更多的goroutines之间进行切换和发送/接收钥匙),因此并发的成本也会越高。
可以使用样例代码提供的benchmarks,通过变更顾客数目来对性能进行验证。
附录
也可以使用上面所使用的并发方案的基本设计导出非并发递归方案:
- 将RoomKeysClerk 转变为一个生成钥匙并将其传递给第一个BusClerk的循环
- 使用闭包(即一个封装了上下文状态的函数(当前计数器,keyPosition和 nextClerk))来实现BusClerk,
- 使用Hilbert函数用来触发整个执行逻辑,并收集各个BusClerk构造的welcomeKits