Go 语言
并发编程
并发设计

并发设计

Go 语言以原生支持并发著称。并发不是并行,并发关乎结构,而并行关乎执行。

并发与并行

传统单线程的应用是难以发挥出多核硬件的威力。传统单线程应用运行在多核硬件上之后,性能反而下降很多,这是因为它仅仅能适应到一颗物理多核处理器中的一个核。 要想利用多核的强大计算能力。一般有两种方案:并发和并行。

并行方案 :在处理器核数充足的情况下,启动多个单线程应用的实例。

并发方案 :并发就是重新做应用结构设计,就是将应用分解成多个在基本执行单元。

基于多线程模型的应用设计就是一种典型的并发程序设计。我们知道操作系统线程的创建、销毁以及线程上下文切换的代价较大,线程的接口还多益标准库形式提供,线程间通信语意也不足或低级,用户层接口晦涩难懂。

Go 语言的设计哲学之一是“原生并发,轻量高效”。Go 并未使用操作系统线程作为承载分解后的代码片段的基本执行单元,而是实现了 goroutine 这一由 Go 运行时负责调度的用户层轻量级线程为并发程序设计提供原生支持。

goroutine 相比传统操作系统线程而言具有如下优势;

  1. 资源占用小,每个 goroutine 的栈空间初始只有 2KB,可以根据需要动态扩展,最大可达 1GB。
  2. 由 Go 运行时负责调度,goroutine 上下文切换代价小,同时也不需要用户关心线程的创建、销毁和调度。
  3. 语言原生支持:由原生的关键字 go 来启动一个 goroutine,函数或方法调用前加上 go 关键字即可,函数或方法返回即表示 goroutine 的退出。
  4. 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供强大的支撑。

并发设计实例

下面是一个模拟机场安检的例子,我们通过该例子,从顺序到并行再到并发设计的演进来对比不同方案的优劣:

  • 排队旅客:代表应用的外部请求;
  • 机场工作人员:代表计算资源;
  • 安检程序:代表应用,必须在获取机场工作人员后才能工作;安检程序内部流程包括有登记身份检查、人身检查和随身物品检查;
  • 安检通道:每个通道对应一个应用程序的实例;

顺序设计

只设置了一个安检通道,所有旅客都排队等待安检,每个旅客都必须等待上一个旅客安检完毕后才能开始安检。

const (
    idCheckDuration = 60
    bodyCheckDuration = 90
    luggageCheckDuration = 120
)
 
func idCheck() int {
    time.Sleep(time.Millisecond * time.Duration(idCheckDuration))
    println("idCheck done.")
    return idCheckDuration
}
 
func bodyCheck() int {
    time.Sleep(time.Millisecond * time.Duration(bodyCheckDuration))
    println("bodyCheck done.")
    return bodyCheckDuration
}
 
func luggageCheck() int {
    time.Sleep(time.Millisecond * time.Duration(luggageCheckDuration))
    println("luggageCheck done.")
    return luggageCheckDuration
}
 
func airportSecurityCheck() int {
    println("start security check.")
    total := 0
    total += idCheck()
    total += bodyCheck()
    total += luggageCheck()
    println("security check done.")
    return total
}
 
func main() {
    total := 0
    passengers := 30
    for i := 0; i < passengers; i++ {
        total += airportSecurityCheck()
    }
    println("total duration:", total)
}
 
// 输出:
// ...
// total duration: 8100

并行设计

设置了多个安检通道,每个旅客都可以选择一个安检通道进行安检,每个旅客的安检时间是独立的。

func airportSecurityCheck(id int) int {
	println("start security check for passenger", id)
	total := 0
	total += idCheck()
	total += bodyCheck()
	total += luggageCheck()
	println("security check for passenger done.", id)
	return total
}
 
func start(id int, f func(int) int, queue <-chan struct{}) <-chan int {
	c := make(chan int)
	go func() {
		total := 0
		for {
			_, ok := <-queue
			if !ok {
				c <- total
				return
			}
			total += f(id)
		}
	}()
	return c
}
 
func max(args ...int) int {
	n := 0
	for _, v := range args {
		if v > n {
			n = v
		}
	}
	return n
}
 
func main() {
	total := 0
	passengers := 30
	c := make(chan struct{})
	c1 := start(1, airportSecurityCheck, c)
	c2 := start(2, airportSecurityCheck, c)
	c3 := start(3, airportSecurityCheck, c)
 
	for i := 0; i < passengers; i++ {
		c <- struct{}{}
	}
	close(c)
 
	total = max(<-c1, <-c2, <-c3)
	println("total duration:", total)
}

为了模拟并行设计,我们对程序做了改动:创建了三个 goroutine,每个 goroutine 代表一个安检通道,每个旅客都可以选择一个安检通道进行安检,每个旅客的安检时间是独立的。

运行结果:

// ...
security check for passenger done. 1
luggageCheck done.
security check for passenger done. 3
luggageCheck done.
security check for passenger done. 2
total duration: 2700

结果符合我们的预期:开启了三个安检通道,运行着相同的安检程序。安检效率是原先的3倍。

并发设计

假设机场现有的建设规模最大只能开通3条安检通道,旅客依旧在增多,即便有了并行方案,旅客的安检时长也无法再缩短。即便机场有充足的人手(计算资源)可用,可是安检通道只能用到一名工作人员。 所以并行设计无法很好适应工作人员的增加。

原先的安检程序弊端明显,当工作人员处于某个检查环节,其他两个环节就会处于等待状态。一条很明显的改进思路就有了:将三个检查环节同时运行起来,就像流水线一样,这就是并发。

const (
	idCheckDuration      = 60
	bodyCheckDuration    = 90
	luggageCheckDuration = 120
)
 
func idCheck() int {
	time.Sleep(time.Millisecond * time.Duration(idCheckDuration))
	println("idCheck done.")
	return idCheckDuration
}
 
func bodyCheck() int {
	time.Sleep(time.Millisecond * time.Duration(bodyCheckDuration))
	println("bodyCheck done.")
	return bodyCheckDuration
}
 
func luggageCheck() int {
	time.Sleep(time.Millisecond * time.Duration(luggageCheckDuration))
	println("luggageCheck done.")
	return luggageCheckDuration
}
 
func start(id string, f func() int, next chan<- struct{}) (chan<- struct{}, chan<- struct{}, <-chan int) {
	queue := make(chan struct{}, 10)
	quit := make(chan struct{})
	result := make(chan int)
 
	go func() {
		total := 0
		for {
			select {
			case <-quit:
				result <- total
				return
			case v := <-queue:
				total += f()
				if next != nil {
					next <- v
				}
			}
		}
	}()
 
	return queue, quit, result
}
 
func newAirportSecurityCheckChannel(id string, queue <-chan struct{}) {
	go func(id string) {
		println("start security check for passenger", id)
 
		queue3, quit3, result3 := start(id, luggageCheck, nil)
		queue2, quit2, result2 := start(id, bodyCheck, queue3)
		queue1, quit1, result1 := start(id, idCheck, queue2)
 
		for {
			select {
			case v, ok := <-queue:
				if !ok {
					close(quit1)
					close(quit2)
					close(quit3)
					total := max(<-result1, <-result2, <-result3)
					println("total duration for passenger", id, ":", total)
					return
				}
				queue1 <- v
			}
		}
	}(id)
}
 
func max(args ...int) int {
	n := 0
	for _, v := range args {
		if v > n {
			n = v
		}
	}
	return n
}
 
func main() {
	passengers := 30
	queue := make(chan struct{}, 30)
	newAirportSecurityCheckChannel("1", queue)
	newAirportSecurityCheckChannel("2", queue)
	newAirportSecurityCheckChannel("3", queue)
 
	time.Sleep(3 * time.Second)
	for i := 0; i < passengers; i++ {
		queue <- struct{}{}
	}
	time.Sleep(3 * time.Second)
	close(queue)
 
	time.Sleep(3 * time.Second)
}

在这一版中,我们模拟开启了三个通道,每个通道创建三个 goroutine,分别负责处理 idCheckbodyCheckluggageCheck。 三个通道之间通过 channel 进行通信,每个通道的 goroutine 之间是并发的,每个通道的 goroutine 之间是并行的。

我们来看一下执行结果:

total duration for passenger 2 : 720
total duration for passenger 3 : 1440
total duration for passenger 1 : 1440

我们看到,30名旅客从 2700 下降到了 1440,并发方案使得安检通道的效率有了进一步的提升。如果计算资源不足,并发方案的每条安检通道的效率最差也就是退回到与顺序设计大致的等同水平。

小结

对并发的原生支持让 Go 语言更契合云计算时代的硬件,适应现代计算环境。 Go 语言鼓励在程序设计时优先按并发设计思路组织程序结构,进行独立计算的分解。只有并发设计才能让应用自然适应计算资源的规模化,并显现出更大的威力。