译自: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表示房间钥匙的上限:

  1. 首先它会为钥匙创建channel keysch,并使用keysch作为参数来启动Room Key Clerk的goroutine。
  2. 然后它会创建另一个channel welcomeKitCh(雇员会从该channel中接收钥匙,并在雇员结束工作后发送欢迎礼包),用于接收Welcome Kits(欢迎礼包),并使用keysch和welcomeKitCh作为参数来启动第一个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. 对比count和keyPosition,用于确定此时的钥匙是给本大巴的还是给其他大巴的。当钥匙是给本大巴的,它会创建一个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的任务越重,并行的提升幅度越大

每个大巴雇员都有一些核心的工作需要完成,此外还有一些并发编排的工作(即配置goroutine和通过channel发送/接收钥匙)。

并行可以提升核心工作的性能。在Hilbert的例子中,核心工作就是为每个客户准备欢迎礼包(即函数NewWelcomeKit执行的内容)。能够并行执行的核心工作越多,就越能在相同的时间内服务更多的顾客。

为了实现并行处理,我们需要执行一些并发编排工作。与并发编排工作相比,核心工作占主导地位越高,从并行性中获得的好处就越多。在Hilbert的例子中,每个大巴雇员的核心工作是准备欢迎礼包。因此,准备欢迎礼包的工作占比越高,多核硬件上运行并发设计的效率就越高。另一方面,处理的顾客越多,并发编排工作也就越重(由于需要在更多的goroutines之间进行切换和发送/接收钥匙),因此并发的成本也会越高。

可以使用样例代码提供的benchmarks,通过变更顾客数目来对性能进行验证。

附录

也可以使用上面所使用的并发方案的基本设计导出非并发递归方案:

  • 将RoomKeysClerk 转变为一个生成钥匙并将其传递给第一个BusClerk的循环
  • 使用闭包(即一个封装了上下文状态的函数(当前计数器,keyPosition和 nextClerk))来实现BusClerk,
  • 使用Hilbert函数用来触发整个执行逻辑,并收集各个BusClerk构造的welcomeKits

使用go的并发性来解决Hilbert酒店问题的更多相关文章

  1. 违反并发性: UpdateCommand影响了预期 1 条记录中的 0 条 解决办法

    本文转载:http://www.cnblogs.com/litianfei/archive/2007/08/16/858866.html UpdateCommand和DeleteCommand出现DB ...

  2. SQL锁表解决并发性

    在数据库开发过程中,不得不考虑并发性的问题,因为很有可能当别人正在更新表中记录时,你又从该表中读数据,那你读出来的数据有可能就不是你希望得到的数据.可以说有些数据同时只能有一个事物去更新,否则最终显示 ...

  3. 深入了解 Scala 并发性

    2003 年,Herb Sutter 在他的文章 “The Free Lunch Is Over” 中揭露了行业中最不可告人的一个小秘密,他明确论证了处理器在速度上的发展已经走到了尽头,并且将由全新的 ...

  4. Flume-NG中Transaction并发性探究

    我们曾经在Flume-NG中的Channel与Transaction关系(原创)这篇文章中说了channel和Transaction的关系,但是在source和sink中都会使用Transaction ...

  5. Java并发编程:进程和线程之由来__进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能

    转载自海子:http://www.cnblogs.com/dolphin0520/p/3910667.html Java多线程基础:进程和线程之由来 在前面,已经介绍了Java的基础知识,现在我们来讨 ...

  6. 读写分离提高 SQL Server 并发性

    转自:http://www.canway.net/Lists/CanwayOriginalArticels/DispForm.aspx?ID=476 在一些大型的网站或者应用中,单台的SQL Serv ...

  7. Java并发性和多线程

    Java并发性和多线程介绍   java并发性和多线程介绍: 单个程序内运行多个线程,多任务并发运行 多线程优点: 高效运行,多组件并行.读->操作->写: 程序设计的简单性,遇到多问题, ...

  8. Java并发性和多线程介绍

    java并发性和多线程介绍: 单个程序内运行多个线程,多任务并发运行 多线程优点: 高效运行,多组件并行.读->操作->写: 程序设计的简单性,遇到多问题,多开线程就好: 快速响应,异步式 ...

  9. SQL Server数据库读写分离提高并发性

    在一些大型的网站或者应用中,单台的SQL Server 服务器可能难以支撑非常大的访问压力.很多人在这时候,第一个想到的就是一个解决性能问题的利器——负载均衡.遗憾的是,SQL Server 的所有版 ...

  10. 提高Django高并发性的部署方案(Python)

    方案: nginx + uWSGI 提高 Django的并发性        1. uWSGI :                 uWSGI是一个web服务器,实现了WSGI协议.uwsgi协议.h ...

随机推荐

  1. STM32F103 的 USART5使用

    STM32F103zet6 的串口5是UART5   不是  USART5

  2. CCIE DC Multicast Part 1.

    Hi Guys! As we all wait anxiously for the training vendors to release Rack Rentals (Come on guys! At ...

  3. Qt实现带有映射关系的进度条

    1.编写继承自widget的新类,这里我们定义为colorWidget; 2.在colorWidget中添加私有变量QVector<QRect> m_rects,用于存放进度条的不同区间( ...

  4. Ubuntu ROOT默认密码设置

    Ubuntu默认密码设置 Ubuntu默认是不设置ROOT密码的,如果需要安装一些包则需要从普通用户调转至ROOT用户,这个时候就需要设置一下密码了. 下面这种情况 chen@chen:~/Deskt ...

  5. 2020年第11届蓝桥杯C/C++B组 第一轮省赛

    # JJU-干干 试题 A: 跑步训练 代码: #include <stdio.h> #include <stdlib.h> /* run this program using ...

  6. Java实验 继承与接口

     一.实验目的 1.理解 Java 语言的继承和多态特性: 2.掌握变量的隐藏.方法的覆盖.重载: 3.掌握抽象类和接口的使用. 二.实验要求 1.根据实验步骤中提出的要求,使用eclipse等开发工 ...

  7. Windows,easygui 安装

    在官网下载了easygui,但是根据网上的方法解压后将 easygui 文件夹(创建文件:easygui,只放easygui.py)放到Python36\Lib\site-packages下不行,有模 ...

  8. Jetpack compose学习笔记之列表(布局)

    一,简介 Jetpack compose中的布局主要分为Column,Row,Box. 二,Column创建的列表 Column创建list时,不管内容是在屏幕内还是屏幕外,都会将list的内容全部创 ...

  9. Django框架搭建web项目(一)

    建议查看官方文档:https://docs.djangoproject.com/zh-hans/4.0/intro/tutorial01/ 1.本地安装python环境(略) 2.本地安装Django ...

  10. howork7

    " 形式化方法   阅读了解形式化方法形式化方法|形式化方法对软件开发的挑战:历史与发展 根据表达能力,形式化方法可以分为五类: 1)基于模型的方法:通过明确定义状态和操作来建立一个系统模型 ...