Go语言常见的高效并发模式

如何用 goroutine、channel、sync包 这些基础元素组成并发模式,更好地编写并发程序。

for select 循环模式

1
2
3
4
5
for { //for无限循环,或者for range循环
  select {
    //通过一个channel控制
  }
}

for select 循环模式一 无限循环直到停止指令

1
2
3
4
5
6
7
8
for  {
   select {
   case <-done:
      return
   default:
      //执行具体的任务
   }
 }

for select 循环模式二 迭代循环 一般用于把可以迭代的内容发送到 channel 上

1
2
3
4
5
6
7
for _,s:=range []int{}{
   select {
   case <-done:
      return
   case resultCh <- s:
   }
}

select timeout 模式

假如需要访问服务器获取数据,因为网络的不同响应时间不一样,为保证程序的质量,不可能一直等待网络返回,所以需要设置一个超时时间,这时候就可以使用 select timeout 模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
   result := make(chan string)
   go func() {
      //模拟网络访问
      time.Sleep(8 * time.Second)
      result <- "服务端结果"
   }()
   select {
   case v := <-result:
      fmt.Println(v)
   case <-time.After(5 * time.Second):
      fmt.Println("网络访问超时了")
   }
}

select timeout 模式的核心在于通过 time.After 函数设置一个超时时间,防止因为异常造成 select 语句的无限等待。

Pipeline 模式

Pipeline 模式也称为流水线模式,模拟的就是现实世界中的流水线生产。以手机组装为例,整条生产流水线可能有成百上千道工序,每道工序只负责自己的事情,最终经过一道道工序组装,就完成了一部手机的生产。

从技术上看,每一道工序的输出,就是下一道工序的输入,在工序之间传递的东西就是数据,这种模式称为流水线模式,而传递的数据称为数据流。

通过以上流水线模式示意图,可以看到从最开始的生产,经过工序 1、2、3、4 到最终成品,这就是一条比较形象的流水线,也就是 Pipeline。

现在以组装手机为例,讲解流水线模式的使用。假设一条组装手机的流水线有 3 道工序,分别是配件采购、配件组装、打包成品,如图所示: 从以上示意图中可以看到,采购的配件通过 channel 传递给工序 2 进行组装,然后再通过 channel 传递给工序 3 打包成品。相对工序 2 来说,工序 1 是生产者,工序 3 是消费者。相对工序 1 来说,工序 2 是消费者。相对工序 3 来说,工序 2 是生产者。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//工序1采购
func buy(n int) <-chan string {
   out := make(chan string)
   go func() {
      defer close(out)
      for i := 1; i <= n; i++ {
         out <- fmt.Sprint("配件", i)
      }
   }()
   return out
}
//工序2组装
func build(in <-chan string) <-chan string {
   out := make(chan string)
   go func() {
      defer close(out)
      for c := range in {
         out <- "组装(" + c + ")"
      }
   }()
   return out
}
//工序3打包
func pack(in <-chan string) <-chan string {
   out := make(chan string)
   go func() {
      defer close(out)
      for c := range in {
         out <- "打包(" + c + ")"
      }
   }()
   return out
}

流水线上的三道工序都完成后,就可以通过一个组织者把三道工序组织在一起,形成一条完整的手机组装流水线,这个组织者可以是我们常用的 main 函数,如下面的代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {
   coms := buy(10)    //采购10套配件
   phones := build(coms) //组装10部手机
   packs := pack(phones) //打包它们以便售卖
   //输出测试,看看效果
   for p := range packs {
      fmt.Println(p)
   }
}
运行结果
打包(组装(配件1))
打包(组装(配件2))
打包(组装(配件3))
打包(组装(配件4))
打包(组装(配件5))
打包(组装(配件6))
打包(组装(配件7))
打包(组装(配件8))
打包(组装(配件9))
打包(组装(配件10))

从上述例子中,我们可以总结出一个流水线模式的构成:

  • 流水线由一道道工序构成,每道工序通过 channel 把数据传递到下一个工序;

  • 每道工序一般会对应一个函数,函数里有协程和 channel,协程一般用于处理数据并把它放入 channel 中,整个函数会返回这个 channel 以供下一道工序使用;

  • 最终要有一个组织者(示例中的 main 函数)把这些工序串起来,这样就形成了一个完整的流水线,对于数据来说就是数据流。

扇出和扇入模式

手机流水线经过一段时间的运转,组织者发现产能提不上去,经过调研分析,发现瓶颈在工序 2 配件组装。工序 2 过慢,导致上游工序 1 配件采购速度不得不降下来,下游工序 3 没太多事情做,不得不闲下来,这就是整条流水线产能低下的原因。

为了提升手机产能,组织者决定对工序 2 增加两班人手。人手增加后,整条流水线的示意图如下所示:

从改造后的流水线示意图可以看到,工序 2 共有工序 2-1、工序 2-2、工序 2-3 三班人手,工序 1 采购的配件会被工序 2 的三班人手同时组装,这三班人手组装好的手机会同时传给merge 组件汇聚,然后再传给工序 3 打包成品。在这个流程中,会产生两种模式:扇出和扇入。

  • 示意图中红色的部分是扇出,对于工序 1 来说,它同时为工序 2 的三班人手传递数据(采购配件)。以工序 1 为中点,三条传递数据的线发散出去,就像一把打开的扇子一样,所以叫扇出。

  • 示意图中蓝色的部分是扇入,对于 merge 组件来说,它同时接收工序 2 三班人手传递的数据(组装的手机)进行汇聚,然后传给工序 3。以 merge 组件为中点,三条传递数据的线汇聚到 merge 组件,也像一把打开的扇子一样,所以叫扇入。

小提示:扇出和扇入都像一把打开的扇子,因为数据传递的方向不同,所以叫法也不一样,扇出的数据流向是发散传递出去,是输出流;扇入的数据流向是汇聚进来,是输入流。

已经理解了扇出扇入的原理,就可以开始改造流水线了。这次改造中,三道工序的实现函数 buy、build、pack 都保持不变,只需要增加一个 merge 函数即可,如下面的代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//扇入函数(组件),把多个chanel中的数据发送到一个channel中
func merge(ins ...<-chan string) <-chan string {
   var wg sync.WaitGroup
   out := make(chan string)
   //把一个channel中的数据发送到out中
   p:=func(in <-chan string) {
      defer wg.Done()
      for c := range in {
         out <- c
      }
   }
   wg.Add(len(ins))
   //扇入,需要启动多个goroutine用于处于多个channel中的数据
   for _,cs:=range ins{
      go p(cs)
   }
   //等待所有输入的数据ins处理完,再关闭输出out
   go func() {
      wg.Wait()
      close(out)
   }()
   return out
}

新增的 merge 函数的核心逻辑就是对输入的每个 channel 使用单独的协程处理,并将每个协程处理的结果都发送到变量 out 中,达到扇入的目的。总结起来就是通过多个协程并发,把多个 channel 合成一个。

在整条手机组装流水线中,merge 函数非常小,而且和业务无关,不能当作一道工序,而是叫作组件。该 merge 组件是可以复用的,流水线中的任何工序需要扇入的时候,都可以使用 merge 组件。

小提示:这次的改造新增了 merge 函数,其他函数保持不变,符合开闭原则。开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
   coms := buy(100)    //采购100套配件
   //三班人同时组装100部手机
   phones1 := build(coms)
   phones2 := build(coms)
   phones3 := build(coms)
   //汇聚三个channel成一个
   phones := merge(phones1,phones2,phones3)
   packs := pack(phones) //打包它们以便售卖
   //输出测试,看看效果
   for p := range packs {
      fmt.Println(p)
   }
}

这个示例采购了 100 套配件,也就是开始增加产能了。于是同时调用三次 build 函数,也就是为工序 2 增加人手,这里是三班人手同时组装配件,然后通过 merge 函数这个可复用的组件将三个 channel 汇聚为一个,然后传给 pack 函数打包。

这样通过扇出和扇入模式,整条流水线就被扩充好了,大大提升了生产效率。因为已经有了通用的扇入组件 merge,所以整条流水中任何需要扇出、扇入提高性能的工序,都可以复用 merge 组件做扇入,并且不用做任何修改。

Futures 模式

Pipeline 流水线模式中的工序是相互依赖的,上一道工序做完,下一道工序才能开始。但是在我们的实际需求中,也有大量的任务之间相互独立、没有依赖,所以为了提高性能,这些独立的任务就可以并发执行。

举个例子,比如我打算自己做顿火锅吃,那么就需要洗菜、烧水。洗菜、烧水这两个步骤相互之间没有依赖关系,是独立的,那么就可以同时做,但是最后做火锅这个步骤就需要洗好菜、烧好水之后才能进行。这个做火锅的场景就适用 Futures 模式。

Futures 模式可以理解为未来模式,主协程不用等待子协程返回的结果,可以先去做其他事情,等未来需要子协程结果的时候再来取,如果子协程还没有返回结果,就一直等待。我用下面的代码进行演示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
//洗菜
func washVegetables() <-chan string {
   vegetables := make(chan string)
   go func() {
      time.Sleep(5 * time.Second)
      vegetables <- "洗好的菜"
   }()
   return vegetables
}
//烧水
func boilWater() <-chan string {
   water := make(chan string)
   go func() {
      time.Sleep(5 * time.Second)
      water <- "烧开的水"
   }()
   return water
}

洗菜和烧水这两个相互独立的任务可以一起做,所以示例中通过开启协程的方式,实现同时做的功能。当任务完成后,结果会通过 channel 返回。

小提示:示例中的等待 5 秒用来描述洗菜和烧火的耗时。 在启动两个子协程同时去洗菜和烧水的时候,主协程就可以去干点其他事情(示例中是眯一会),等睡醒了,要做火锅的时候,就需要洗好的菜和烧好的水这两个结果了。我用下面的代码进行演示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	vegetablesCh := washVegetables() //洗菜
	waterCh := boilWater()           //烧水
	fmt.Println("已经安排洗菜和烧水了,我先眯一会")
	time.Sleep(2 * time.Second)
	fmt.Println("要做火锅了,看看菜和水好了吗")
	vegetables := <-vegetablesCh
	water := <-waterCh
	fmt.Println("准备好了,可以做火锅了:", vegetables, water)
}

洗菜和烧水这两个相互独立的任务可以一起做,所以示例中通过开启协程的方式,实现同时做的功能。当任务完成后,结果会通过 channel 返回。 Futures 模式下的协程和普通协程最大的区别是可以返回结果,而这个结果会在未来的某个时间点使用。所以在未来获取这个结果的操作必须是一个阻塞的操作,要一直等到获取结果为止。

如果你的大任务可以拆解为一个个独立并发执行的小任务,并且可以通过这些小任务的结果得出最终大任务的结果,就可以使用 Futures 模式。

总结

并发模式和设计模式很相似,都是对现实场景的抽象封装,以便提供一个统一的解决方案。但和设计模式不同的是,并发模式更专注于异步和并发。