channel学习

一、channle的基本概念

channels是go中不同goroutines交互数据的一种通道,也就是说如果两个goroutine想要进行数据的传递,那么就必须使用channel。可以把channel理解成某种特定数据类型的管子,goroutine之间需要传递哪种类型的数据,那就是用哪种类型的channel。它的构造方法和map一样,使用的都是ch := make(chan int),这时候变量ch的类型是"chan int"类型。如果我们在函数中复制或者传递一个channel作为参数的时候,实际上传递的是它的引用。
关于channel的操作主要就是三种,分别是发送、接收和关闭。关闭使用的是close("channle"),而发送和接收都使用 "<-"符号进行分割的,比如下面的操作:

ch <- s //表示的是发送,也就是把s传递到channle中去,然后发送到下游的goroutine
s = <- ch //这种表示的是接收,也就是从channle中获取值,并把值赋给s。
还有一种就是只接收,但是不使用,或者说忽略
<- ch //接收channel,但是忽略接收的值,因为如果接收值但是不使用的话,会有语法问题,所以可以忽略掉
另外如果channel已经关闭,而依然向这个channel传递值会出现panic。
关于channle的构造方法ch := make(chan int)其实还有一个可选参数,代表了这个channel的容量,当然这里又引申出另外两个概念,缓冲channel和非缓冲channel。

  ch := make(chan int)//非缓冲channel
  ch := make(chan int,0)//容量为0,是非缓冲channel
  ch := make(chan int,5)//容量5,缓冲channel

先来说下非缓冲的channel,当向一个非缓冲channel发送数据的时候,当前的goroutine会进入阻塞状态,直到另一个goroutine在当前的channel上进行接收操作。反过来也是一样,如果一个接收操作先执行,那么当前的goroutine也会阻塞,直到另外一个goroutine在同一个channel上进行发送操作。从一定程度上可以认为非缓冲的channel是同步channel。
通过channel发送的message有两个重要的方面,每一个message都会有一个值,但是有时候使用channel传递message并不是为了获取它的值,而是获取它发生的时间点,这时候也可以称这个message为事件,即event。而当这个message没有额外值的时候,它的作用就仅仅是同步,或者说就是作为一个信号通知其他的goroutine。这时候channel传递message可以使用这样的形式:

   ch <- struct{}{}
   ch <- 1

因为书写方便,这种情况下可能使用channel传递一个int类型更常见。

二、Pipelines

channel用来不用的goroutine之间进行连接,也就是说这个goroutine的输出,是另一个goroutine的输入,这也可以称为管道(pipeline),看下下面这个例子:

func main()  {
    // 定义两个channle
    nums := make(chan int)
    squar := make(chan int)
    // 发送4个整数
    go func() {
        for x := 1;x < 5 ;x++  {
            nums <- x
        }
    }()
    // 接收4个整数,并进行运算
    go func() {
        for {
            x := <- nums
            squar <- x * x
        }
    }()
    // 打印
    for {
        fmt.Println(<- squar)
    }
}

上面这个列子我们建了两个channel以便在三个goroutine之间进行通讯,也就是主goroutine和两个go statement,第一个是创建4个整数,然后把值放到nums这个channel里面,然后由第二个channel接收,然后处理,再传递给squar。最后主goroutine接收并打印。但是打印完成后项目依然没有停止,因为主goroutine里面使用的无限的循环,就像java的 while(true)一样,而squar里面没有数据,这时候第二个goroutine阻塞了(是不是也可以理解为所有的goroutine都阻塞了呢?主goroutine再等第二个go,而第二个在等第一个go)。但是如果我知道我发送的数据量一定,比如上面的例子,我只发送4个数字,然后打印完后程序已经可以结束了。那么使用channel的close方法是不是就可以了呢?
我在第一个goroutine向nums发送完以后就关闭nums,即在for循环外加上close(nums)

// 接收4个整数
go func() {
    for x := 1;x < 5 ;x++  {
        nums <- x
    }
    close(nums)
}()

然后再跑一遍程序,为了方便看效果修改下打印的循环条件,把无限循环定改成一个0-100的循环

//打印
for x := 1;x < 100 ;x++ {
    fmt.Println(<- squar)
}

打印的结果为:1 4 9 16 0 0 0 0 0 0 .....一直到循环结束。这个结果有点在意料之外,我以为打印到16以后,程序依然不会停止。
实际上,channel关闭以后,再对该channel进行操作会出现panic,但是最后一个数值被该channel下游的goroutine接收以后,下游的goroutine的接收操作不会阻塞,而是会被"0"值填充,如果打印那里依然是无限循环,那么打印完1 4 9 16后将会一直打印0 0 0 0....
对于一个channel有没有关闭并没有直接的方法去检验,但是对于接收操作有其实会有两个结果,一个是接收的数值,另一个是个布尔类型的值"ok",如果接收值成功那么ok的值就是true,否则就是false,可以通过"ok"值来判断nums是否关闭。上面的代码可以再修改一下,在第二个goroutine里面添加:

go func() {
    for {
        x,ok := <- nums
        if !ok {
            break
        }
        squar <- x * x
    }
}()

这时候再去执行代码就和想象中的一样了,即打印完 1 4 9 16后,程序依然没用结束,但是也没打印0值了。
go给提供了一种使用range来遍历channel的操作,这是一种更简便的操作,而且在接收完channel最后一个数值后就相应的goroutine就会终止。将代码再次进行修改:

func main()  {
    // 定义两个channle
    nums := make(chan int)
    squar := make(chan int)
    // 接收4个整数
    go func() {
        for x := 1;x < 5 ;x++  {
            nums <- x
        }
        close(nums)
    }()
    // 接收4个整数,并进行运算
    go func() {
        for x := range nums {
            squar <- x * x
        }
        close(squar)
    }()
    // 打印
    for x := range squar{
        fmt.Println(x)
    }
}

再次启动程序以后再打印完1 4 9 16几个数字后,因为nums和squar都关闭,所以相应goroutine接收完数值后就关闭了,最后程序退出。不需要对每个channel都指向close()这个方法,只要保证channel上游的goroutine发送完数据后关闭即可,使用rang遍历出所有数值,并且接收不到新的数值后,当前的goroutine就会根据go的垃圾回收机制进行回收。
实际中根据项目的情况肯定会将复杂的业务进行抽取,上面的代码是全部写在main函数里面的明显是不合适的,所以这里需要将代码进行重构,根据业务分成三部分,一个是生成整数,另一个是对数字进行计算,最后是打印,代码如下:

func main()  {
    // 定义两个channle
    nums := make(chan int)
    squar := make(chan int)
    //生成整数 
    go generate(nums)
    //进行运算
    go square(nums,squar)
    // 打印
    prints(squar)
}
func prints(ch chan int) {
    for x := range ch {
        fmt.Println(x)
    }
}
func square(nums chan int, squar chan int) {
    for x := range nums {
        squar <- x * x
    }
    close(squar)
}
func generate(nums chan int) {
    for x := 0;x < 5 ;x++  {
        nums <- x
    }
    close(ch)
}

为了防止channel的误用,go提供了一个所谓的“单向”channel类型,就比入上面的代码中nums就是发送的channel,而squar是一个接收的channel,他们都是只能完成一个操作,要不接收要不发送。关于发送和接收有时候容易迷,从代码里面看,nums接收了4个整数,似乎应该是一个接收的channel,为什么说是发送的channel呢?其实我觉得对于一个channel是接收还是发送要从goroutine之间的关系说起,我的理解是最上游的goroutine中的channel肯定是发送的,虽然它接收数值,但是它上游已经没用channel了,而channel是goroutine交互的通道。而最下游的goroutine的channel肯定是接收的,因为到它这里交互已经结束了。

未命名文件.jpg

似乎是channel创建的时候并不确定它是用来发送或者接收的,只是通过函数对它进行了一个相应的隐性转换,比如nums,转换成发送类型。但是对于squar这个似乎又有点疑问,因为它在中间,那么它是发送还是接收呢,或者说即是发送又是接收?答案是发送,channel是单向的,不可能即发送又接收,就好比水管一样,你水流只可能是单向流动的,只有在终结操作的goroutine前的channel才是接收类型,其余都是发送的。prints函数里面的squar就是一个接收类型。对于square函数,它的参数nums是一个接收类型,而squar是发送类型。另外一点,close只能在发送类型的channel上操作。

三、缓冲channel

缓冲channel内部可以有一个包含相应元素的队列,上面说channel的构造方法时说道它有一个可选参数,这个可选参数就是它队列的大小。比如:ch = make(chan string,5),ch的类型是chan string,是一个缓冲的channel,可以容纳5个字符串。对于发送类型,是在队列后面插入数据;而接收类型则是在最前面取出数据。如果发送时队列已满,那么当前的goroutine就会进入阻塞状态,直到其他线程接收了它的数据,然后队列有新的空间。当然如果队列为空,接收操作也会阻塞(这个无论缓冲还是非缓冲都是一样的)。如果channel既不为空,也没满的话,可以进行发送也可以进行接收,并且都不会阻塞,这就是缓冲channel的优点吧。
如果想知道某个channel容量或者队列的大小,可以调用cap("channel")方法,而如果想知道当前channel中元素的个数可以调用len("channel")函数。cap、make、len都是go自带的内建函数。
下面这个方法创建了一个容量为20的字符串channel,并且用这个channel来接收了三个不同的goroutine的响应结果,但是,最终只会返回第一个,也就是说一个channel虽然可以接收来自不同的goroutine的数据,但是一旦它接收到了最先响应的数据,那么之后的其余两个goroutine的数据会被忽略。

func sendMsg() string {
    msg := make(chan string,20)
    go func() {msg <- request("https://www.zhihu.com/")}()
    go func() {msg <- request("https://www.jianshu.com/")}()
    go func() {msg <- request("https://spring.io")}()
    return <- msg
}

但是如果goroutine想发送数据,而没有其他的goroutine来接收的时候那么它就会阻塞。上面的代码中就会出现这种情况,因为msg接收到最先响应的数据后,就会返回给主goroutine,这时候后面两个goroutine发送的数据不会被主goroutine接收,这样就会导致所谓的"goroutine leak",而这个泄漏的goroutine是不会被GC自动回收的,所以对于不再需要的goroutine一定要让它停止。
关于缓冲channel和非缓冲channel选择上,非缓冲channel能够提供更好的同步性能,因为它的发送和它下游goroutine的接收的操作是同步的,也就是说发送一个接收一个。而缓冲channel因为有一定的容量,即使下游goroutine没有接收,只要当前channel还未满依然可以继续发送,也就是说缓冲channel上下游goroutine的发送和接收可以不同步,二者之间关系是分离的。
在单个goroutine的程序中不应该使用channel,因为channel的作用是在不同的goroutine之间进行通讯的,所以如果把channel作为一个队列在单个goroutine中使用是不应该的,因为单个goroutine中永远不会出现其他的goroutine来接收它发送的数据,那么这个goroutine可能就会永久阻塞,channel虽好也不能乱用,一定要了解其使用的场景。多个goroutine可以同时通过一个channel发送或者接收数据。

推荐阅读更多精彩内容