深度解密Go语言之channel

2020-07-09 00:00:00 发送 缓冲 并发 接收 关闭

大家好!“深度解密 Go 语言”系列好久未见,我们今天讲 channel,预祝阅读愉快!在开始正文之前,我们先说些题外话。

上一篇关于 Go 语言的文章讲 Go 程序的整个编码、编译、运行、退出的全过程。文章发出后,反响强烈,在各大平台的阅读量都不错。例如博客园登上了 48 小时阅读排行榜,并且受到了编辑推荐,占据首页头条位置整整一天;在开发者头条首页精选的位置霸榜一周时间……





熟悉码农桃花源的朋友们都知道,这里每篇文章都很长,要花很长时间才能读完。但长并不是目的,把每个问题都讲深、讲透才是重要的。首先我自己得完全理解才行,所以写每篇文章时我都会看很多参考资料,看源码,请教大牛,自己还要去写样例代码跑结果……从创建文稿到真正完成写作需要很长时间。



做这些事情,无非是想力求我写出来的文字,都是我目前所能理解的深层次。如果我暂时理解不了,我会说出来,或者不写进文章里面去,留到以后有能力的时候再来写。

我自己平时有这种体会:看微信公众号的文章都是想快速地看完,快速地拉到后,目的快点开始看下一篇,新鲜感才能不断刺激大脑。有时候碰到长文很花时间,可能就没耐心看下去了,里面说的东西也觉得很难理解,可能直接就放弃了。但是,如果我知道一篇文章价值很高,就会选一个精力比较充沛的时间段,花整块时间看完,这时候反倒很容易看进去。这种情况下,潜意识里就会知道我今天是一定要读完这篇文章的,并且要把里面有价值的东西都吸收进来。

所以,对于码农桃花源的文章,我建议你收藏之后,找个空闲时间再好好看。

上周,我把 GitHub 项目 Go-Question 的内容整合成了开源电子书,阅读体验提升 N 倍,建议关注项目,现在已经 400 star 了,年底目标是 1k star。项目地址列在了参考资料里。



另外,公众号的文章也可以使用微信读书看,体验也非常赞,并且可以放到书架上,每个公众号就是一本书,简直酷炫。



闲话后,一直“吹”了很久的曹大,新书《Go 语言编程》出版了!书的另一位作者是柴树杉老师,这是给 Go 语言提交 pull 的人,他在 Go 语言上面的研究不用我多说了吧。我时间下了单,并且到曹大工位要了签名。


这本书的推荐人有很多大佬,像许世伟,郝林,雨痕等,评价非常高。重点给大家看下雨痕老师对这本书的评价(上图第二排左侧图):

本书阐明了官方文档某些语焉不详的部分,有助于 Gopher 了解更多内在实现,以及日常工作中需要用到的 RPC、Web、分布式应用等内容。我认识本书作者之一曹春晖,对他的学习态度和能力颇为钦佩,因此推荐大家阅读本书。

大家可能不知道,出书一点都不赚钱,但投入的精力却很大。但是像曹大在给读者的书签名时所说的:书籍是时代的生命。多少知识都是通过书本一代代传承!

搬过几次家就知道,纸质书太多,过程会比较痛苦。所以,我现在买纸书都会考虑再三。但是,这次我还是在时间下单了《Go 语言编程》。我也强烈推荐你买一本,支持原创者。

柴老师在武汉,我接触不多。但和曹大却是经常能见面(在同一个公司工作)。他本人经常活跃在各种微信群,社区,也非常乐于解答各种疑难杂症。上周还和曹大一起吃了个饭,请教了很多问题,我总结了一些对家都有用的东西,放在我的朋友圈:



如果你想围观我的朋友圈,想和我交流,可以长按下面的二维码加我好友,备注下来自公众号。



好了,下面开始我们的正文。

并发模型

并发与并行

大家都知道的摩尔定律。1965 年,时任仙童公司的 Gordon Moore 发表文章,预测在未来十年,半导体芯片上的晶体管和电阻数量将每年增加一倍;1975 年,Moore 再次发表论文,将“每年”修改为“每两年”。这个预测在 2012 年左右基本是正确的。

但随着晶体管电路逐渐接近性能极限,摩尔定律终将走到尽头。靠增加晶体管数量来提高计算机的性能不灵了。于是,人们开始转换思路,用其他方法来提升计算机的性能,这就是多核计算机产生的原因。

这一招看起来还不错,但是人们又遇到了一个另一个定律的限制,那就是 Amdahl's Law,它提出了一个模型用来衡量在并行模式下程序运行效率的提升。这个定律是说,一个程序能从并行上获得性能提升的上限取决于有多少代码必须写成串行的。

举个例子,对于一个和用户打交道的界面程序,它必须和用户打交道。用户点一个按钮,然后才能继续运行下一步,这必须是串行执行的。这种程序的运行效率就取决于和用户交互的速度,你有多少核都白瞎。用户就是不按下一步,你怎么办?

2000 年左右云计算兴起,人们可以方便地获取计算云上的资源,方便地水平扩展自己的服务,可以轻而易举地就调动多台机器资源甚至将计算任务分发到分布在全球范围的机器。但是也因此带来了很多问题和挑战。例如怎样在机器间进行通信、聚合结果等。难的一个挑战是如何找到一个模型能用来描述 concurrent。

我们都知道,要想一段并发的代码没有任何 bug,是非常困难的。有些并发 bug 是在系统上线数年后才发现的,原因常常是很诡异的,比如用户数增加到了某个界限。

并发问题一般有下面这几种:

数据竞争。简单来说就是两个或多个线程同时读写某个变量,造成了预料之外的结果。

原子性。在一个定义好的上下文里,原子性操作不可分割。上下文的定义非常重要。有些代码,你在程序里看起来是原子的,如简单的 i++,但在机器层面看来,这条语句通常需要几条指令来完成(Load,Incr,Store),不是不可分割的,也就不是原子性的。原子性可以让我们放心地构造并发安全的程序。

内存访问同步。代码中需要控制同时只有一个线程访问的区域称为临界区。Go 语言中一般使用 sync 包里的 Mutex 来完成同步访问控制。锁一般会带来比较大的性能开销,因此一般要考虑加锁的区域是否会频繁进入、锁的粒度如何控制等问题。

死锁。在一个死锁的程序里,每个线程都在等待其他线程,形成了一个首尾相连的尴尬局面,程序无法继续运行下去。

活锁。想象一下,你走在一条小路上,一个人迎面走来。你往左边走,想避开他;他做了相反的事情,他往右边走,结果两个都过不了。之后,两个人又都想从原来自己相反的方向走,还是同样的结果。这就是活锁,看起来都像在工作,但工作进度就是无法前进。

饥饿。并发的线程不能获取它所需要的资源以进行下一步的工作。通常是有一个非常贪婪的线程,长时间占据资源不释放,导致其他线程无法获得资源。

关于并发和并行的区别,引用一个经典的描述:

并发是同一时间应对(dealing with)多件事情的能力。 并行是同一时间动手(doing)做多件事情的能力。

雨痕老师《Go 语言学习笔记》上的解释:

并发是指逻辑上具备同时处理多个任务的能力;并行则是物理上同时执行多个任务。

而根据《Concurrency in Go》这本书,计算机的概念都是抽象的结果,并发和并行也不例外。它这样描述并发和并行的区别:

Concurrency is a property of the code; parallelism is a property of the running program.

并发是代码的特性,并行是正在运行的程序的特性。先忽略我拙劣的翻译。很新奇,不是吗?我也是次见到这样的说法,细想一下,还是很有道理的。

我们一直说写的代码是并发的或者是并行的,但是我们能提供什么保证吗?如果在只有一个核的机器上跑并行的代码,它还能并行吗?你就是再天才,也无法写出并行的程序。充其量也就是代码上看起来“并发”的,如此而已。

当然,表面上看起来还是并行的,但那不过 CPU 的障眼法,多个线程在分时共享 CPU 的资源,在一个粗糙的时间隔里看起来就是“并行”。

所以,我们实际上只能编写“并发”的代码,而不能编写“并行”的代码,而且只是希望并发的代码能够并行地执行。并发的代码能否并行,取决于抽象的层级:代码里的并发原语、runtime,操作系统(虚拟机、容器)。层级越来越底层,要求也越来越高。因此,我们谈并发或并行实际上要指定上下文,也就是抽象的层级。

《Concurrency in Go》书里举了一个例子:假如两个人同时打开电脑上的计算器程序,这两个程序肯定不会影响彼此,这就是并行。在这个例子中,上下文就是两个人的机器,而两个计算器进程就是并行的元素。

随着抽象层次的降低,并发模型实际上变得更难也更重要,而越低层次的并发模型对我们也越重要。要想并发程序正确地执行,就要深入研究并发模型。

在 Go 语言发布前,我们写并发代码时,考虑到的底层抽象是:系统线程。Go 发布之后,在这条抽象链上,又加一个 goroutine。而且 Go 从的计算机科学家 Tony Hoare 那借来一个概念:channel。Tony Hoare 就是那篇文章《Communicating Sequential Processes》的作者。

看起来事情变得更加复杂,因为 Go 又引入了一个更底层的抽象,但事实并不是这样。因为 goroutine 并不是看起来的那样又抽象了一层,它其实是替代了系统线程。Gopher 在写代码的时候,并不会去关心系统线程,大部分时候只需要考虑到 goroutine 和 channel。当然有时候会用到一些共享内存的概念,一般就是指 sync 包里的东西,比如 sync.Mutex。

什么是 CSP

CSP 经常被认为是 Go 在并发编程上成功的关键因素。CSP 全称是 “Communicating Sequential Processes”,这也是 Tony Hoare 在 1978 年发表在 ACM 的一篇论文。论文里指出一门编程语言应该重视 input 和 output 的原语,尤其是并发编程的代码。

在那篇文章发表的时代,人们正在研究模块化编程的思想,该不该用 goto 语句在当时是激烈的议题。彼时,面向对象编程的思想正在崛起,几乎没什么人关心并发编程。

在文章中,CSP 也是一门自定义的编程语言,作者定义了输入输出语句,用于 processes 间的通信(communicatiton)。processes 被认为是需要输入驱动,并且产生输出,供其他 processes 消费,processes 可以是进程、线程、甚至是代码块。输入命令是:!,用来向 processes 写入;输出是:?,用来从 processes 读出。这篇文章要讲的 channel 正是借鉴了这一设计。

Hoare 还提出了一个 -> 命令,如果 -> 左边的语句返回 false,那它右边的语句就不会执行。

通过这些输入输出命令,Hoare 证明了如果一门编程语言中把 processes 间的通信看得等重要,那么并发编程的问题就会变得简单。

Go 是个将 CSP 的这些思想引入,并且发扬光大的语言。仅管内存同步访问控制(原文是 memory access synchronization)在某些情况下大有用处,Go 里也有相应的 sync 包支持,但是这在大型程序很容易出错。

Go 一开始就把 CSP 的思想融入到语言的核心里,所以并发编程成为 Go 的一个独特的优势,而且很容易理解。

大多数的编程语言的并发编程模型是基于线程和内存同步访问控制,Go 的并发编程的模型则用 goroutine 和 channel 来替代。Goroutine 和线程类似,channel 和 mutex (用于内存同步访问控制)类似。

Goroutine 解放了程序员,让我们更能贴近业务去思考问题。而不用考虑各种像线程库、线程开销、线程调度等等这些繁琐的底层问题,goroutine 天生替你解决好了。

Channel 则天生就可以和其他 channel 组合。我们可以把收集各种子系统结果的 channel 输入到同一个 channel。Channel 还可以和 select, cancel, timeout 结合起来。而 mutex 就没有这些功能。

Go 的并发原则非常,目标就是简单:尽量使用 channel;把 goroutine 当作免费的资源,随便用。

说明一下,前面这两部分的内容来自英文开源书《Concurrency In Go》,强烈推荐阅读。

引入结束,我们正式开始今天的主角:channel。

什么是 channel

Goroutine 和 channel 是 Go 语言并发编程的 两大基石。Goroutine 用于执行并发任务,channel 用于 goroutine 之间的同步、通信。

Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;由于它是线程安全的,所以用起来非常方便;channel 还提供“先进先出”的特性;它还能影响 goroutine 的阻塞和唤醒。

相信大家一定见过一句话:

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而要通过通信来实现内存共享。

这就是 Go 的并发哲学,它依赖 CSP 模型,基于 channel 实现。

简直是一头雾水,这两句话难道不是同一个意思?

通过前面两节的内容,我个人这样理解这句话:前面半句说的是通过 sync 包里的一些组件进行并发编程;而后面半句则是说 Go 推荐使用 channel 进行并发编程。两者其实都是必要且有效的。实际上看完本文后面对 channel 的源码分析,你会发现,channel 的底层就是通过 mutex 来控制并发的。只是 channel 是更高一层次的并发编程原语,封装了更多的功能。

关于是选择 sync 包里的底层并发编程原语还是 channel,《Concurrency In Go》这本书的第 2 章 “Go's Philosophy on Concurrency” 里有一张决策树和详细的论述,再次推荐你去阅读。我把图贴出来:



channel 实现 CSP

Channel 是 Go 语言中一个非常重要的类型,是 Go 里的对象。通过 channel,Go 实现了通过通信来实现内存共享。Channel 是在多个 goroutine 之间传递数据和同步的重要手段。

使用原子函数、读写锁可以保证资源的共享访问安全,但使用 channel 更优雅。

channel 字面意义是“通道”,类似于 Linux 中的管道。声明 channel 的语法如下:

chan T // 声明一个双向通道
chan<- T // 声明一个只能用于发送的通道
<-chan T // 声明一个只能用于接收的通道

相关文章