当前位置: 首页 > Go语言, 代码艺术 > 正文

深入理解Go语言的Channels特性

当我第一次使用Go中的通道(Channels)的时候,我误以为把Channels当作一种数据结构。我将Channels看作为队列来在goroutines之间提供同步访问。 这种概念性的误解理解使我编写了许多糟糕而复杂的并发代码。

而随着时间的推移以及对Go语言的深入理解,我逐渐关注到它的行为特性。 所以现在谈到Channels,我就会想到通信(signaling)。 一个通道允许一个goroutine向另一个关于特定事件的goroutine进行通信。把Channels视为通信机制,将允许你使用定义明确且能编写出更加优雅的代码。

要了解通信是如何工作,我们必须了解它的三个属性:

可靠发送

状态

带/不带数据

这三个属性共同构成了围绕通信及其传递的设计理念。 在我讨论这些属性之后,我将提供一些代码示例来演示应用这些属性的通信。

Channels的可靠发送

可靠保证的通信发送接受基于这么个场景“我是否需要保证已收到特定goroutine发送的信号?”,来看看下面的例子:

go func() {
    p := <-ch // Receive
}()

ch <- "paper" // Send

发送goroutine是否需要保证第二行的goroutine接收ch通道的字符串才可以继续?根据这个简单问题,我们可以定义出两种类型的通道:无缓冲和缓冲。在可靠发送上每种类型的Channels都体现了不同的行为特征,可以简单总结如下:

可靠发送是很重要的,尤其是在编写并发软件时,充分了解到你是否需要保证可靠发送到是至关重要的。 随着下面的继续讲解,你将学习到如何选择Channels的类型。

Channels的状态

通道的行为特性直接受到其当前状态的影响。其状态可以分为三种:nilopen或者closed。下面的代码注释中定义并说明了这三种状态的通道声明:

// ** nil channel

// A channel is in a nil state when it is declared to its zero value
var ch chan string

// A channel can be placed in a nil state by explicitly setting it to nil.
ch = nil


// ** open channel

// A channel is in a open state when it’s made using the built-in function make.
ch := make(chan string)    


// ** closed channel

// A channel is in a closed state when it’s closed using the built-in function close.
close(ch)

通道的状态确定了它是发送和接收操作的行为方式。需要注意,信号是通过通道来发送和接收的,但是不能叫作读/写,因为它不是IO操作。其对应的状态类型如下:

当通道处于nil状态时,通道上尝试的任何发送或接收都将被阻止。 当通道处于打开状态时,可以发送和接收信号。 当通道处于关闭状态时,它将不再能够发送信号,但是仍然可以接收信号。

这些状态将在你实际开发中遇到的不同情况提供所需的不同行为特征参考。 将状态与可靠传递结合时,就可以分析你的设计选择而形成的的付出成本与收获。在你了解通道的这些行为特性后,大多数情况下,你就能通过阅读代码快速地发现bug。

 

Channels的是否传递数据

需要考虑的最后一个信号属性是,需不需要在发送信号的时候携带数据。通过在通道上发送数据很简单,只需要如下编码就行:

ch <- "paper"

当你在使用了带数据的信号时,通常是因为:

(1)要求goroutine执行新任务;

(2)goroutine报告了结果。

当然,你也可以通过关闭通道来发出无数据的信号,这种情况下一般是:

(1)goroutine被告知需要停止其正在执行的任务;

(2)goroutine执行完毕报告结果(无需数据);

(3)goroutine报告已完成处理并关闭。

没有数据的信号的一个好处是发送端goroutine可以立即向多个goroutine发出信号。 而带数据的信号一般是两个goroutines之间的1对1通信。

 

带数据的Channels

对于带数据的通道,根据你对数据的可靠性需要不同,这里有三类不同类型的配置参数,如下所示:

三种参数分别为:UnbufferedBuffered >1 和 Buffered =1

可靠保证:

一个不带缓冲的channel将会在信号的从发送出去到接收到的过程中保持可靠的稳定性(因为信号的接收是在信号发送完成之前就已完成了的)。

不可靠保证:

一个带缓冲的channel(Buffered>1时)在信号在发送与接受之间将不提供可靠保证(这是因为在这种情况下,信号的接收是在信号发送完成之后才完成的)。

延时保证:

当Buffered为1时,这个时候将是一个延时保证的信号。这种情况下可以保证收到先前发送出去信号(这是因为第一个信号的接收是在第二个信号发送完成之前发生的)。

所以,基于上面的三种不同情况,缓冲区的大小可不是随便定的,必须针对不同明确定义的条件进行不同的取值。

 

不带数据的Channels

没有数据的信号主要用于执行一些取消的动作。 它允许一个goroutine发出信号通知另一个goroutine取消他们正在做的事情并继续执行之后的业务。 当然,取消可以使用无缓冲或者缓冲通道来实现,但是当不带数据却使用带缓冲的通道时,这种代码就不太优雅了。

内置的close函数用于在没有数据的情况下发出信号。 在上面的“状态”章节有说过,在已关闭的通道上依然是可以接收信号的。 实际上,已关闭的通道上的任何接收都是不会阻塞的,并且接收操作总会返回。

在大多数情况下,我们一般使用标准库的context包来实现没有数据的信号。实际上context包的底层也是使用不带缓冲的通道用进行信号通信的,内置的close函数传递信号也是不带数据的。

当你使用自己定义的channel进行取消操作而不是标准库中的context包时,channel应该定义为chan struct {}类型的。 这是表示仅用于信号通信的零占用空间的channel的惯用方式。

 

实际应用举例

学习并了解了Channel上面的这些属性之后,咋们就可以进一步了解它们在实践中是如何工作应用的,下面配合一些代码示范加以讲解说明。

 

带数据 – 可靠传递 – 不带缓冲的Channel

当您需要知道已收到正在发送的信号时,会出现两种情况:等待任务(Wait For Task)和等待结果(Wait For Result)。

场景1:等待任务

这种情况就如同你作为一名经理雇用了一名新员工。你希望新员工马上开始执行任务,而他却需要等待到你准备好。

func waitForTask() {
    ch := make(chan string)

    go func() {
        p := <-ch

        // Employee performs work here.
        // Employee is done and free to go.
    }()

    time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)

    ch <- "paper"
}

上面的代码中第2行,创建了一个Unbuffered的通道,它是一个字符串类型的数据。然后在第4行,这里等待ch通道的结果。一旦ch发送出去了数据,在go关键字创建的goroutine就会接收到,并继续执行它之后的业务逻辑。

场景2:等待结果

这种情况正好相反。 这次你希望新的goroutine在创建时立即执行,并在主goroutine中等待结果,下面的代码实例可以简单说明:

func waitForResult() {
    ch := make(chan string)

    go func() {
        time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
   
        ch <- "paper"

        // Employee is done and free to go.
    }()

    p := <-ch
}

优缺点对比:

无缓冲通道可确保接收到正在发送的信号。 但是这种保证的成本是损失是带来了未知的延迟。 在等待任务方案中,新goroutine不知道发送该ch消息需要等待多长时间,而在等待结果方案中,主goroutine不知道新创建的那个goroutine将多久后将结果发送回来。

 

带数据 – 不可靠传递 – 带缓冲(Buffer>1)的Channel

当您不需要知道已收到正在发送的信号时,这两种情况就会发挥作用:Fan Out模式和Drop模式。

带缓冲的通道明确地定义的空间大小用于存储正在发送的数据。 那么我们应该如何确定到底需要多少空间呢?可以从下面几个方面考虑:(1)工作量制定是否完善?(3)如果员工无法跟上,是否会影响你当下的工作?(3)如果程序意外终止,有多大可接受的风险?

Fan Out模式

fan out模式允许你针对同一问题创建多个的worker进行处理。 由于每个任务都是由一个goroutine进行处理,所以你可以确切的知道将收到多少结果。但是如果他们同时发送结果时,就会存在竞争现象,造成他们阻塞循环等待结果。

还是上面的场景,但这次你创建了多个goroutine来单独处理任务。 这个场景的工作情况可如下描述:

func fanOut() {
    emps := 20
    ch := make(chan string, emps)

    for e := 0; e < emps; e++ {
        go func() {
           time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
           ch <- "paper"
       }()
   }

   for emps > 0 {
       p := <-ch
       fmt.Println(p)
       emps--
   }
}

在上述代码的第3行中,创建了一个可指定大小的字符串类型的缓冲通道,第5至10行线创建了20个goroutine并立即开始工作。但是主goroutine并不知道每个新的goroutine需要处理多长时间。而在最后的for循环中,主goroutine循环等待所有新建的goroutine都处理完成,才确定全部任务执行完毕。

Drop模式

在Drop模式下,当所有的工作goroutine都在满负荷时,将放弃新的任务。 这样做的好处是可以继续接收要处理的任务,而不会造成工作goroutine压力过后或者延迟。 所以,这里的关键在于如何知道工作goroutine可以接受新的任务,什么时候知道工作goroutine压力过大。

func selectDrop() {
   const cap = 5
   ch := make(chan string, cap)

   go func() {
       for p := range ch {
           fmt.Println("employee : received :", p)
       }
   }()

   const work = 20
   for w := 0; w < work; w++ {
       select {
           case ch <- "paper":
               fmt.Println("manager : send ack")
           default:
               fmt.Println("manager : drop")
       }
   }

   close(ch)
}

优缺点对比:

Buffer>1的缓冲通道不保证始终接收到正在发送的信号。 而如果要保证两个goroutines之间的通信减少或没有延迟,则不要使用这个带缓存的通道。 在fan out模式中,每个新的goroutine需发送的消息都自带了一个缓冲。 而在Drop模式中,缓冲是指定容量的,如果达到了容量,则会丢弃新的任务,知道子goroutine处理完手头的任务进去到了空闲状态。

带数据 – 延迟传递 – 带缓冲(Buffer=1)的Channel

在这种情况下,您有一名新员工,但他们要做的不仅仅是一项任务。你将一个接一个地为他们提供许多任务。但是,他们必须先完成每项任务才能开始新任务。由于它们一次只能处理一项任务,因此在切换工作之间可能存在延迟问题。如果可以减少延迟而不失去员工正在处理下一个任务的保证,那么它可能有所帮助。

正如在前面讲的延时保证的情况,在发送新信号之前已收到之前发送出去的信号,这种情况下就是Wait For Tasks场景

场景1:Wait For Tasks

在这种情况下,如下代码,创建了一个新的goroutine进行任务处理,它可以串行的接收多个任务并处理。由于每次只能处理一个任务,因此在切换任务之间可能存在延迟问题。如果可以减少延迟而又不会丢失任务,那么这是比较理想的状态,这也是带缓冲通道优势所在。但是当处理worker的处理速度跟不上时,缓冲区就会被满而早上通道阻塞。

func waitForTasks() {
   ch := make(chan string, 1)

   go func() {
       for p := range ch {
           fmt.Println("employee : working :", p)
       }
   }()

   const work = 10
   for w := 0; w < work; w++ {
       ch <- "paper"
   }

   close(ch)
}

不带数据的信号- Context

在最后这个场景中,将讲到如何使用Go的标准库中的context包中的Context来取消正在运行的goroutine。当然这也可以使用关闭的无缓冲的通道来执行没有数据的信号来实现。实例代码如下:

func withTimeout() {
   duration := 50 * time.Millisecond

   ctx, cancel := context.WithTimeout(context.Background(), duration)
   defer cancel()

   ch := make(chan string, 1)

   go func() {
       time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
       ch <- "paper"
   }()

   select {
   case p := <-ch:
       fmt.Println("work complete", p)

   case <-ctx.Done():
       fmt.Println("moving on")
   }
}

实际上,context包会创建一个goroutine,一旦超时时间到了,它将关闭与Context值关联的无缓存的通道。 当然你也可以通过调用WithTimeout函数返回的cancel来主动取消,以清除掉Context创建的内容。 另外cancel函数也可以被多次调用。

在实际项目中,使用带缓冲(Buffer=1)的通道是比较合适的,如果使用无缓冲的通道的时候,当工作goroutine长期处于阻塞状态也无法被唤醒时就会造成goroutine leak。goroutine leak同内存泄漏一样可怕,这样的 goroutine 会不断地吞噬资源,导致系统运行变慢,甚至是崩溃。当然这个并不在本文的讨论范围之中。

小 结

可靠保证、通道状态和是否带缓冲对于理解Channel或者Go并发是非常重要的。尤其是在你编写高并发或者算法性程序的时候。这篇通过示例程序,展示了通道属性在不同的场景中如何工作。可以简单总结如下:

 

语言特点:

(1)使用通道来协调和协调goroutines

专注于通信属性而不是数据共享;

带数据的通信和不带数据的通信;

明确同步对于共享状态的影响;

(2)无缓冲的通道:

接收发生在发送之前;

好处:100%保证已收到信号;

不足:接收信号存在未知延迟;

(3)有缓冲的通道:

发送发生在接收之前;

好处:减少信号之间的阻塞延迟;

不足:无法保证何时收到信号(buffer越大,可以延迟越大);

(4)关闭通道:

关闭发生在接收之前(同缓冲通道一样);

不带数据通信;

适用于信号取消和超时时间;

(5)nil类型的通道:

发送和接收都是阻塞式;

关闭通信;

适用于限速或短期停止;

 

设计哲学:

(1)如果通道上发送的信号会导致发送goroutine阻止:

不允许使用(Buffer>1)的缓冲通道;

必须知道当goroutine发送信号阻塞时会发生什么;

(2)如果通道上发送的信号不会导致发送goroutine阻止:

每次发送都有明确的缓冲区大小(Fan Out模式);

有缓冲区最大容量限制(Drop模式);

(3)缓冲区越小越好:

考虑缓冲时,不要考虑性能;

缓冲可以减少信号之间的阻塞延迟;

如果带缓冲的通道能提供更大的并发量那更好(更大并发量更小缓冲区原则)。

原文参考:https://www.ardanlabs.com/blog/2017/10/the-behavior-of-channels.html



这篇博文由 s0nnet 于2018年08月10日发表在 Go语言, 代码艺术 分类下, 通告目前不可用,你可以至底部留下评论。
如无特别说明,计算机技术分享发表的文章均为原创,欢迎大家转载,转载请注明: 深入理解Go语言的Channels特性 | 计算机技术分享
关键字:

深入理解Go语言的Channels特性:等您坐沙发呢!

发表评论

快捷键:Ctrl+Enter