声明
本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。
我们知道,Go实现了两种并发形式,第一种是多线程共享内存,其实就是Java,C++等语言的多线程并发,通过锁来进行访问。另一种则是Go特有的CSP(communicating sequential processes)并发模型。
什么是CSP?
CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,由 Tony Hoare 于 1977 年提出。它是在串行时代提出的一个概念,慢慢的演化成了现在的一种并发模型。简单来说,CSP 模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫 channel。那么,CSP 模型的关键是关注 channel,而不关注发送消息的实体。而Go 语言实现了 CSP 部分理论,具体的模式如下图所示。
Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;由于它是线程安全的,所以用起来非常方便;channel 还提供 “先进先出” 的特性;它还能影响 goroutine 的阻塞和唤醒。
说到这,可能就有的同学有些疑问,为什么要用channel,Goroutine不就可以看作一个线程,然后线程间通信用共享内存来通信不行么?请往下看。
为什么要用channel
相信大家都听过这么一句话,Do not communicate by sharing memory; instead, share memory by communicating(不要通过共享内存来通信,而要通过通信来实现内存共享),这两句话难道不是一个意思么?从本质上来看,计算机上线程和协程同步信息其实都是通过共享内存来进行的,因为无论是哪种通信模型,线程或者协程最终都会从内存中获取数据,所以更为准确的说法是为什么我们使用发送消息的方式来同步信息,而不是多个线程或者协程直接共享内存?
我们从使用场景分析一下,首先,前半句应该是指我们多应用于多线程通信的方式,一般线程同步在线程间交换的信息仅仅是控制信息,比如某个A线程释放了锁,B线程能获取到锁并开始运行,这个不涉及数据的交换。数据的交换主要还是通过共享内存(共享变量或者队列)来实现,为了保证数据的安全和正确性,共享内存就必需要加锁等线程同步机制。而线程同步使用起来特别麻烦,容易造成死锁,且过多的锁会造成线程的阻塞以及这个过程中上下文切换带来的额外开销。我们通常会因为在代码中加锁而感到烦恼。
下半句呢?我理解后半句是说的channel来共享内存,在Go的这种方式中,要传递某个数据给另一个goroutine(协程),可以把这个数据封装成一个对象,然后把这个对象的指针传入某个channel中,另外一个goroutine从这个channel中读出这个指针,并处理其指向的内存对象。channel本身保证来同一时间只有一个goroutine能访问channel的数据,就不用开发者去处理锁了。
我们根据他们的差异来总结一下:
- 首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰;
- 其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存;
- 最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题;
另外,是不是我们都得使用channel来代替共享内存mutex,当然是不可能的,我们在这来说明一个原因:如果我们向 Channel 中发送了一个指针而不是值的话,发送方在发送该条消息之后其实也保留了修改指针对应值的权利,如果这时发送方和接收方都尝试修改指针对应的值,仍然会造成数据冲突的问题。当然这种大多数情况下是一种设计上的问题,然而针对这种情况使用更为底层的互斥锁才是一种正确的方式。
当然,我们会问channel怎么保证同一时间只有一个活跃的线程能够访问数据的呢?其实channel本身也是通过锁来实现,这就对照我们上边所说的抽象的思想的结论了。具体是怎么实现的呢?我们将会在下一篇文章讲述。
channel的不同种类以及常见的错误
channel分为两种,有缓冲channel和无缓冲channel。我们通过下边的代码例子来区分不同的channel种类。
func main() {
pipline := make(chan string) //构造无缓冲通道
pipline <- "hello world" //发送数据
fmt.Println(<-pipline) //读数据
}
运行会抛出错误,如下:
fatal error: all goroutines are asleep - deadlock!
思考一下,我们创建的是一个无缓冲通道,而对于无缓冲通道,在接收者未准备好之前,发送操作是阻塞的。那么,我们该怎么去解决这种问题呢?看下边代码。
func hello(pipline chan string) {
<-pipline
}
func main() {
pipline := make(chan string)
go hello(pipline) //如果我们换成直接在同一个协程里读数据会永远阻塞
pipline <- "hello world"
}
那么我们如果把这个例子改成有缓冲通道还会阻塞吗?我们看下边的例子:
func main() {
pipline := make(chan string, 1)
pipline <- "hello world"
fmt.Println(<-pipline)
}
运行正常,此时是不是就能看出缓冲和没有缓冲的区别呢?是的,区别在于在发送操作是否发生在有接受者时。那么,对于有缓冲通道会发生什么特殊情况呢?
func main() {
ch1 := make(chan string, 1)
ch1 <- "hello world"
ch1 <- "hello China"
fmt.Println(<-ch1)
}
看这个例子,没错,他也会阻塞,每个缓冲通道,都有容量,当通道里的数据量等于通道的容量后,此时再往通道里发送数据,就失造成阻塞,必须等到有人从通道中消费数据后,程序才会往下进行。
比如这段代码,通道容量为 1,但是往通道中写入两条数据,对于一个协程来说就会造成死锁。
那么问题来了,当程序一直在等待从通道里读取数据,而此时并没有人会往通道中写入数据。此时程序就会陷入死循环,造成死锁,我们如何去解决呢?看下边的例子:
func main() {
pipline := make(chan string)
go func() {
pipline <- "hello world"
pipline <- "hello China"
}()
for data := range pipline{
fmt.Println(data)
}
}
运行结果当然是all goroutines are asleep - deadlock!,通道没有被关闭,程序就一直在等待读取值,怎么解决呢?
func main() {
pipline := make(chan string)
go func() {
pipline <- "hello world"
pipline <- "hello China"
close(pipline) // 重点
}()
for data := range pipline{
fmt.Println(data)
}
}
注意看我标为重点的地方,关闭通道,很明确的方法,既然问题是因为通道没有被关闭造成的阻塞,那么我在发送完数据后关掉就ok了啊~
下期预告
【Go语言踩坑系列(十)】Channel(下)
关注我们
欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~
有疑问加站长微信联系(非本文作者)