中文译为信道,英文是Channel
,发音为[ˈtʃænl]
),在Go
语言中简写为chan
。
chan
是Go语言的基本数据类型之一,也是Go语言中为难不多三个使用make关键字进行初始化的三个类型(信道、映射和切片)之一。
var c = make(chan int, 5)
和切片的创建一样,当我们使用make关键字创建一个信道时,返回的是一个值类型,并不是引用。这个值内部又包括了指向信道里真正数据的指针和其它一些描述字段。我们在使用切片时,多数情况下也是作为值类型使用,这并不影响效率,因为切片本身结构体字段十分简单,主要数据还在切片指向的内部数组上,并不在切片本身上。在使用方式上,使用make关键字创建的这三个类型:切片、信道和映射,是类似的。
对于初学这门语言的人来讲,Go语言有三大特色:
第一个信道,它不是Go语言的自创,是基于CSP并发模型的一种实现,但也深具特色,因为其它语言像js、java、python等,不是这么玩的。
第二个Go程,也就是goroutine,是Go语言独创的用户微线程。原本一个os线程需要2MB的内存开销,初始的一个goroutine只使用2kb,这是同样的配备为什么Go语言拥有更高并发量的原因。
第三个切片,有人可能觉得奇怪,既然Go语言已经有了数组,为什么还有切片?在Go语言中,数组主要是作为切片的原材料被使用的。开发者直接使用数据也可以,但在大多数情况下显然使用切片更简单、效率也更高。
今天我们就先了解一下三大特色之首,信道。
信道在Go语言并发编程中占据着重要的角色,基于它,我们可以非常优雅又轻松地实现跨线程数据同步。在对信道展开了解之前,我们首先要明确一个概念:
在Go语言编程中(在其它高级语言编程中也是如此),并发并不是意味着两个或多个事情发生的时间点完全一致,而是指它们发生在一段时间内。
——— |—e1——— |——
——— |——— e2—|——
在上面图形中,如果我们用“|”作为分隔一段时间的间隔,那么事件e1与e2就是并发的。虽然实际上它们是在不同时间点发生的。
了解了什么是并发,接下来理解信道就简单多了。我们首先看一下信道操作符:
c <- 0
var a = <- c
一个信道操作符由一个向左的箭头+一个连字符组成,无论是信道的接取,还是发送,是读取,还是写入,使用的都是这个符号。不存在一个连字符+向右的箭头(->)这样的符号。
数据流通的方向永远是从右向左。设有信道变量c,如果信道变量在右边,例如<-c
,是数据流出,这是读取;如果信道变量在左边,例如c <- 0
,是数据流入,这是发送。
好,现在我们理解了信道符号的使用,接下来理解信道操作就会清晰多了。
var c = make(chan int, 5)
从make创建信道的语法来看,chan与int代表通道类型,chan与int共同组成了一个类型,这点与数组类型中数组长度也是类型一部分是一致的。最后的数字5,代表的是信道的缓存容量。缓存容量默认不传为0,0表示不缓存。
现在我们根据信道的两种操作(读写)的顺序,及有无缓存,将信道操作分为以下四种情况:
读 | 写 | |
---|---|---|
有缓存 | 先读后写 x | 先写后读 √ |
无缓存 | 先读后写 √ | 先写后读 √ |
打√代表可以实现信道间的数据同步。有三种情况能实现,一种不能。接下来我们看一下它们分别是怎么实现的。
有缓存的先写后读,在特定信道上的每一次发送操作,都有与其对应的接收操作相匹配。信道上的发送操作,总在对应的接收操作完成前发生。这句话是教科书上写的,理解起来真费劲,我们看代码说:
package main
var c = make(chan int, 1)
var a string
func f() {
a = "hi, ly"
c <- 0
}
func main() {
go f() // 这里启动了一个Go程
<-c
println(a)
}
源码见:go-easy/并发/chan1.go
输出:
hi, ly // 后面所有正常输出都是这个
在这个代码中,第13行<-c
是信道的接收操作,第8行是信道的发送操作。发送操作总在接收操作完成之前产生,在这个代码中,第13行的信道操作没有办法完成,因为信道是空的,主线程代码走到这里的时候,必须等待信道的发送操作在某处完成才可以继续。在哪里完成呢?在第12行go f()
启动的Go程里,完成这个发送操作。
如果没有第13行的信道读取代码,这个程序会一闪而过,不会有任何东西打印的。我们正是借用了信道操作的这个特点,完成了Go程间的事件同步。
我们可以理解为,第12行启动的Go程,与当前程序的主线程整个是并发的。既然是并发的,我们就没有办法确定,当第14行代码println(a)
执行时,到底第7行代码a = "hi, ly"
有没有执行过。
而添加了信道同步以后,两个Go程在某个时间点对齐了,也就是产生了同步事件。在这个代码中,第8行代码c <- 0
与第13行代码<-c
是一个同步的时间点,两个Go程在这里对齐了,如下图所示。
———`a=..`——————`c <- 0`
—`go f()`—————————`<-c`——--—`println(a)
事实上,在这个示例中,我们用 close(c)
代替第8行的 c <- 0
,仍能保证该程序产生相同的行为。因为close操作会使信道返回0,0对于第13行的代码仍然是有用的。在这里我们往信道里写入什么,以及接收到什么并不重要,我们只是借用信道的读写同步机制。
在这里我们标题里说的“先写后读”,指的是读操作发生在写操作之前,不是读的代码在写的代码之前,而是指执行。
理解了第一种情况,第二种情况就好理解了。
从无缓冲信道进行的接收,要发生在对该信道进行的发送完成之前。这句话也是教科书上的,我们先看一下先读后写的代码:
package main
var c = make(chan int)
var a string
func f() {
a = "hi, ly"
<-c
}
func main() {
go f()
c <- 0
println(a)
}
源码见:go-easy/并发/chan2.go
这个示例与上面的第一种情况的示例,输出是一样的。代码只有三行不同,就是原第8行与第13行代码互换了一下,还有第3行代码,我们将信道的容量去掉了。现在这个信道是无缓存的。
先前第一种情况,因为信道是空的,我们无法读取,所以主线程被阻塞了。现在两行代码互换了一下,直接写入为什么也会被阻塞呢?
就是因为信道是有无缓存的。一个没有缓存的信道,我们想在一端往里写入,也必须同时有另一端往外接收才行。这就是“从无缓冲信道进行的接收,要发生在对该信道进行的发送完成之前”。在这个示例中,第13行代码的写入操作,会因为无人接收而被挂起,直到在第12行启动的Go程中,第8行与之对齐,主线程才可以继续执行。而这时候变量a已经被赋值了,所以第14行的打印才会有值。
无缓存,先读后写可以同步,那么先写后读也可以吗?
先看一下代码:
package main
var c = make(chan int)
var a string
func f() {
a = "hi, ly"
c <- 0
}
func main() {
go f()
<-c
println(a)
}
源码见:go-easy/并发/chan2.go
该示例代码与第二种情况,仅是将第8行与第13行互换了一下。测试结果输出是一样的。
为什么无缓存信道,先写后读与先读后写都可以呢?
因为对于无缓存信道,因为没有缓存,读与写的操作必须两头都有接应才行。在这个示例中,第13行代码想读取信道中的值,但是此时无人发送啊,必须也必须等待。
上面这三种情况,都是可以进行Go程同步的。还有一种情况我们没做实验,就是有缓存先读后写的情况。我们看一下代码:
package main
var c = make(chan int, 1)
var a string
func f() {
a = "hi, world"
<-c
}
func main() {
go f()
c <- 0
println(a)
}
源码见:go-easy/并发/chan4.go
在这个示例中,第8行代码尝试读取信道,但因为信道中没有内容,所以会被阻塞。第13行代码对信道进行写入,因为信道有缓存容量,不需要另一端有人实时接收也可以写入,所以这个地方并不会阻塞。
运行效果大概率是没有输出。从原理上讲,两个Go程是并发的,但我们无法保证第8行先于13行代码执行,很大概率是后于执行,所以第14行代码打印的是a变量的空值。
以上就是信道同步的四种情况,多个Go程同步与两个Go程同步道理是一样的。
无论有没有缓冲,信道的接收,总是在发送操作之前。我们将信道比作一个管道,有缓存容量的,充许我们在管道里暂时一些数据;没有缓存的,一端发送时,另一端必须有人接收。教科书上有这样一句话,概括了上面四种情况:
对某信道上进行的的第k次容量为C的发送,必发生在第k+C次从该信道进行的接收操作完成之前,其中k>=1,C>=0。
这就是教科书的简洁。
所有源码见:https://gitee.com/rxyk/go-easy
我讲明白了没有,欢迎讨论。
2021年1月14日