摘要:本文由葡萄城技术团队于博客园原创并首发。转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具、解决方案和服务,赋能开发者。
本文主要介绍Go语言、进程、线程、协程的出现背景原因以及Go 语言如何解决协程的问题和并发编程的实现,本文的阅读时长约在15-20分钟左右,请合理的分配您的阅读时间。
话说早在 2007 年 9 月的一天,Google 工程师 Rob Pike 和往常一样启动了一个 C++项目的构建,按照他之前的经验,这个构建应该需要持续 1 个小时左右。这时他就和 Google公司的另外两个同事 Ken Thompson 以及 Robert Griesemer 开始吐槽并且说出了自己想搞一个新语言的想法。当时 Google 内部主要使用 C++构建各种系统,但 C++复杂性巨大并且原生缺少对并发的支持,使得这三位大佬苦恼不已。
第一天的闲聊初有成效,他们迅速构想了一门新语言:能够给程序员带来快乐,能够匹配未来的硬件发展趋势以及满足 Google 内部的大规模网络服务。并且在第二天,他们又碰头开始认真构思这门新语言。第二天会后,Robert Griesemer 发出了如下的一封邮件:
可以从邮件中看到,他们对这个新语言的期望是:在 C 语言的基础上,修改一些错误,删除一些诟病的特性,增加一些缺失的功能。比如修复 Switch 语句,加入 import 语句,增加垃圾回收,支持接口等。而这封邮件,也成了 Go 的第一版设计初稿。
在这之后的几天,Rob Pike 在一次开车回家的路上,为这门新语言想好了名字Go。在他心中,”Go”这个单词短小,容易输入并且可以很轻易地在其后组合其他字母,比如 Go 的工具链:goc 编译器、goa 汇编器、gol 连接器等,并且这个单词也正好符合他们对这门语言的设计初衷:简单。
在统一了 Go 的设计思路之后,Go 语言就正式开启了语言的设计迭代和实现。2008 年,C语言之父,大佬肯·汤普森实现了第一版的 Go 编译器,这个版本的 Go 编译器还是使用C语言开发的,其主要的工作原理是将Go编译成C,之后再把C编译成二进制文件。到2008年中,Go的第一版设计就基本结束了。这时,同样在谷歌工作的伊恩·泰勒(Ian Lance Taylor)为Go语言实现了一个gcc的前端,这也是 Go 语言的第二个编译器。伊恩·泰勒的这一成果不仅仅是一种鼓励,也证明了 Go 这一新语言的可行性 。有了语言的第二个实现,对Go的语言规范和标准库的建立也是很重要的。随后,伊恩·泰勒以团队的第四位成员的身份正式加入 Go 语言开发团队,后面也成为了 Go 语言设计和实现的核心人物之一。罗斯·考克斯(Russ Cox)是Go核心开发团队的第五位成员,也是在2008年加入的。进入团队后,罗斯·考克斯利用函数类型是“一等公民”,而且它也可以拥有自己的方法这个特性巧妙设计出了 http 包的 HandlerFunc 类型。这样,我们通过显式转型就可以让一个普通函数成为满足 http.Handler 接口的类型了。不仅如此,罗斯·考克斯还在当时设计的基础上提出了一些更泛化的想法,比如 io.Reader 和 io.Writer 接口,这就奠定了 Go 语言的 I/O 结构模型。后来,罗斯·考克斯成为 Go 核心技术团队的负责人,推动 Go 语言的持续演化。到这里,Go 语言最初的核心团队形成,Go 语言迈上了稳定演化的道路。
2009年10月30日,罗伯·派克在Google Techtalk上做了一次有关 Go语言的演讲,这也是Go语言第一次公之于众。十天后,也就是 2009 年 11 月 10 日,谷歌官方宣布 Go 语言项目开源,之后这一天也被 Go 官方确定为 Go 语言的诞生日。
(Go语言吉祥物Gopher)
1.Go语言安装包下载
Go 官网:https://golang.google.cn/
选择对应的安装版本即可(建议选择.msi文件)。
2.查看是否安装成功 + 环境是否配置成功
打开命令行:win + R 打开运行框,输入 cmd 命令,打开命令行窗口。
命令行输入 go version 查看安装版本,显示下方内容即为安装成功。
在互联网云时代,几乎所有叫的上名字的组件都是用 Go 开发的,比如 Docker、Kubernetes、Dapr 等等根本都列不完,但是如果就仅凭这些简单的语法是不可能捕获那些大厂的心的。Go 能在互联网时代这么火热,肯定是有自己的立命之本的,没错,就是 Go 的并发编程功能,Go 语言的最大杀器 Goroutine。
Goroutine 是一个合成词,取自原词Coroutine意为协程。
Goroutine 就是协程在 Go 中的一个实现,那协程究竟是什么呢?为啥最近几年才火起来呢?C#没有协程这个概念不是一样可以做 Web 开发么?为啥大厂纷纷从 Java 等大众的语言都投身有协程的 Go 呢?带着上述这些个问题,我开始从头了解协程究竟是个什么东西。
我是很早就听说过协程这个概念的,并且几次三番都想搞懂这个协程到底是个什么意思,但是由于前置知识的缺失,无法形成有用的知识链,不是看不懂,就是刚理解一点就忘。所以我觉得,应该从头来理解协程这个概念的诞生和为什么目前这么火热,才能最终把知识变为自己的经验。
因为要从头说起,那我觉得就应该从最开始为什么会有进程这个概念讲起。
话说在很早以前,计算机还是单道批处理的一个机器,程序员(或者可以称之为打孔员)将自己写好的程序通过纸袋打入计算机中,计算机计算完毕最终会把结果返回给用户。在这个时期其实是没有进程的概念的。
随着技术的不断发展,计算机也从最原始的样子逐渐进化到多道批处理系统。这个时候计算机已经可以并发的执行多个程序了,同时也出现了操作系统的概念。这个时候人们发现,单单用程序这个概念已经不能成功描述一个正在计算机内执行的程序了,因为一份程序可以多次并发地执行,那对于计算机来说,这些代码相同但是并发执行的程序就分别表示的是不同的程序,因此,聪明的脑袋就发明了进程这个概念。
在那个时期,进程是一个程序存在的唯一标识,其不仅作为程序的执行调度单元,同样也是程序信息的保存单元,每个进程都有一个进程控制块PCB,内部存放的是这个进程的一些信息比如:页表、寄存器、程序计数器、堆、栈等等。
除此之外,进程和进程之间是相互隔离的,它们自身有着自己独立的 PCB 和内存等,几个进程之间互不干扰,完全独立地在计算机系统中运行着。
这个时期,一切都在相安无事的完美运行着。
但是随着技术进一步的发展,人们发现单单用进程已经解决不了一些问题了,比如:播放一个 MP3 音频文件。
一个 MP3 音频文件播放的伪代码大致如下:
main() {
while(true) {
/*IO读取音频流*/
Read();
/*解压音频流*/
Decompress();
/*播放*/
Play();
}
}
Read() {...}
Decompress() {...}
Play() {...}
这段代码在进程中执行就存在一个很严重的问题了:无法让三个函数并发执行。因为在调用Read函数时,用户态代码就发出一个系统调用,进行 IO 操作。对于 IO 密集型的操作,操作系统通常情况下都是会将其进程直接阻塞的,当 IO 操作完成触发一个中断,操作系统才会激活之前被阻塞的进程继续执行。
那这就有问题了,在我没有将文件全部读完全部解码完我这个程序是无法播放音频文件的。给用户带来的直接后果就是播放的声音是一段一段的。为什么是一段一段的?因为 IO 操作是有 buffer 的,每次可能只会 IO 一个 buffer 的数据,按照程序的逻辑,会把 buffer 中的数据解码,然后再播放。然后再 IO…那用户听到的就是一段一段的音乐了。
那有没有可能用多个进程实现呢?就是一个进程 IO,一个进程解码,一个进程播放。这看起来好像是一个解决方案,但是依然存在问题,即进程间通信。上一节我们知道了进程间的内存是相互隔离的,三个进程直接是无法直接访问对方的内容的,这就需要 IO 的进程执行完,需要想办法把自己的数据告诉解码的进程。且不说进程间通讯的性能消耗是巨大的,就连三个进程完美地协同工作其实都很难做到。
所以我们知道了,单独的进程无法做到在共享内存的前提下并发执行多个不同的程序。因此,线程这个概念就出现了。
为了解决上述问题,聪明的脑袋将进程的资源管理模块和调度模块进行了更细致的拆分,创建出了线程这个概念。这时,进程依然是一个程序的所有资源控制中心,但是程序的执行已经不是进程来做的了,而是丢个了自己内部的多个线程来完成。这些线程是共享内存的,并且又可以被 CPU 调度并发地执行多个不同的程序,上述这类问题就被线程完美地解决了。
虽然进程内的线程是共享内存的,但是线程的执行时相互独立的,因此每个线程就需要有自己的寄存器和程序计数器,堆栈等资源。因此,和进程控制块 PCB 相同,线程也有自己的线程控制块 TCB,来记录上述的一些自己独享的资源。进程和线程的模型如下图:
线程这个概念一直平稳运行到了互联网时代,这时,新的问题又出现了:
在互联网高速发展的现在,高并发已经是每个互联网企业必须要面对的问题了,因为有了高并发才有流量,有了流量才有自己企业的立命之本。而在高并发时代下,线程已经很难满足需求了。
如果一台服务器 1 秒中的并发量可达 10000 个,那么对应的服务器就需要开启至少 1 万个线程去服务这些并发请求。而线程的创建也是需要资源的,以 Linux 为例,一个 POSIX Thread 的创建成本是1-8MB 不等,那 1 秒 10000 个请求就需要在 1 秒内消耗掉10-80GB的内存资源,这个数量是十分恐怖的。
并且,CPU 从一个线程调度到另一个线程是需要线程上下文切换的,这也是一个性能损耗点。什么是上下文切换?我们上面说了,线程同样也有自己的 TCB 去记录自己的堆栈和程序计数器寄存器等。操作系统在进行线程调度时,需要从一个线程的 TCB 中将上述的所有资源加载到 CPU 执行的寄存器和内存中才能执行。当一个线程的时间片结束切换到另一个线程时,操作系统同样需要将上一个程序的最终资源信息记录回之前的线程 TCB 中,然后再加载新线程的 TCB 中的资源。这个过程被称为线程的上下文切换,这个开销一般在3-5us 左右。
除此之外,目前的互联网请求大多数都是需要读取数据的并且返回给用户展示的。那数据的读取就需要 IO 操作,IO 操作时操作系统又会将对应的线程阻塞,因此有人做过测试,高并发情况下,有80%的线程其实都是阻塞状态的,它们只占资源缺不干活,白白占用了系统资源。并且如果内存不足操作系统可能会挂起进程,从而频繁地触发缺页中断,给了本就不宽裕的IO带宽更大的压力,形成了更严重的恶性循环。
早期的 Web 服务器 Apache 就是通过多线程响应的模型来处理 web 请求的。但是现在几乎没有人用 Apache 这个服务器了,因为那种模型无法解决上述的问题。
这时,聪明的脑袋又想到了新的解决方案:IO 多路复用技术。Nginx就是使用这种技术处理高并发请求的。那什么是 IO 多路复用呢?
IO 多路复用就是,和一个请求开一个线程不同,Nginx 这类服务器是通过一个死循环的线程去监听所有的 Web 请求,当有请求到来时, Linux 的一些 IO 多路复用技术select,poll,epoll,kqueue可以通过一个阻塞的系统调用同时监听多个文件描述符,一旦其中有任何一个文件描述符准备好进行读写操作,就会通知程序进行相应的处理,从而实现高效的事件驱动编程。并且,这些请求的执行线程都是非阻塞的 IO 操作,也就是遇到 IO 操作它们不是等,而是停下来去干别的事,这样就大大降低了服务器的压力。
但是这却又有一个新的问题产生了,即 IO 多路复用技术下,多个请求的响应是事件回调机制的,而处理这些程序的程序员很难去找到回调的时机,这让程序开发人员增加了无限高的心智压力,代码非常难写。
为了解决以上的种种问题,我们的主角协程就出现了。
协程的本质其实就是用户态的线程。这里又来了一个新的概念,啥是用户态?目前市面上的所有 CPU 指令集其实都是分级别的,一般分为 ring0~ring3 一共 4 个级别,离 ring0 越近,获得的 CPU 指令的权限就越大,相应能干的事就越多,但是对应的不安全的风险也就越高。由此我们可以知道,用户态和内核态其实是完全隔离的。
因此现在的操作系统都是分用户态和内核态的。以 Linux 为例,ring0 是内核态,ring3 即是用户态。我们日常开发的所有程序都是用户态程序,在用户态程序我们仅能操作计算机很小一部分的功能,大部分功能,比如 IO 读写,内存分配和各种硬件交互等等都是由内核程序完成的。
这时肯定有同学问了,哎不对啊,我的代码同样可以读文件并且把各种信息写在屏幕上啊?那是怎么实现的?
这些功能其实都是用户态程序向内核态程序发送各种不同的系统调用实现的。而发送一次系统调用,就会触发一次用户态到内核态的上下文切换,这同样也会带来一次性能损耗。
去年网上有一个阿里巴巴的二面面试题,问为什么 RocketMQ 和 Kafka 的速度那么快?其实是因为 RocketMQ 和 Kafka 在进行 IO 操作的时候都用到了 linux 中的一个零拷贝技术 mmap,让数据读写过程中少一次系统调用切换带来的内存拷贝,而是映射到相同的一块内存区域,从而达到的加速。对这个问题感兴趣的同学可以去看 什么是 mmap。
因此,在线程这个概念出现之前,就已经出现了用户态线程的概念,即用户态的程序内部自己模拟多线程的调度,操作系统仅仅是调度了对应的进程,却感知不到对应进程内的线程,协程带来的直接好处是不需要创建 TCB 了,可以节省下线程创建时对应的内存开销。
不过如果这么看来,协程就又是一个和 Docker一样旧瓶装新酒的技术?还真不是。
早期的用户态线程虽然有一定的性能优势,但是还是解决不了一个问题:无法感知系统中断。我们知道,现在的操作系统都是抢占式的,操作系统会默认优先执行高优先级的程序,如果目前正在调度一个低优先级的程序,那操作系统会触发一个中断让高优先级的程序抢占它。抢占是操作系统通过发送系统中断实现的,然而操作系统感知不到协程的存在,所以协程自身是无法处理抢占的中断事件的。此外,如果一个用户态线程进行了 IO 操作,那操作系统会把整个线程阻塞,对应的没有调用 IO 的协程也会被阻塞。最后,由于操作系统的调度单位是进程,所以每次时间片分配到各个协程就更少了,所以 CPU 算力也是需要解决的一个问题。也是因为这些问题吧,各个操作系统才进一步推出自己的系统级线程。
Goroutine其实是Go为了解决上述普通协程的问题而做出的更高层的封装。
Go 的作者 Rob Pike 是这样描述 Goroutine 的:Goroutine 是一个与其他 Goroutine 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或多个 Goroutine 组成。它于线程、协程、进程等不同。它就是一个 Goroutine。
对于上一节提到的早期协程,基本和线程的匹配模型都是N:1的,即一个线程需要同时维护多个协程。这样的构建模型就无法解决上述出现的问题。为此 Go 在语言提供了一种 GM 模型(后期逐渐演化为 GMP 模型),总体来说就是让用户态线程即 Goroutine(G) 和真正的线程 Machine(M) 成为一个N:M的模型,如下图所示:
可以看到 Goroutine 是依附在系统线程运行的。它们统一由 Go Runtime 管理,所以 Go 的核心其实就是它的 runtime,go runtime 内统一管理了 Goroutine 的创建销毁,和会统一给它们分配内存,响应系统调用等等,其内部包括了内存管理,进程管理,设备管理的主要功能,而真正的操作系统其实也就这么点功能,可以说 go runtime 就已经是一个小型的操作系统了。
Github 上有一个大佬,是百度的 Go 工程师,他自己用 Go 写了一个小操作系统eggos,并且成功把它装到了一个没有操作系统的裸机上,并且还能在里面玩超级马里奥。感兴趣的同学可以去学习了解一下,这种事在.net 平台几乎是无法实现的。
上图是早期(Go1.2 版本)Go 对 Goroutine 的调度模型。我们程序中新创建的 Goroutine 其实最开始是被加入了global queue这个队列中,然后程序真正的执行器 M 就会从这个队列中捞出待调度的 Goroutine 来运行。当运行中的 Goroutine 触发了 IO 等系统调度时,runtime 会重新把它移回到 global queue 中。同样的,如果运行中的 Goroutine 内创建了新的 Goroutine,那同样也会把 Goroutine 放入 global queue 中等待调度。此外,runtime 还会启动一个监控线程监控这些运行中的 Goroutine,如果超过规定的时间片,那这些 Goroutine 就会被重新移回 global queue 中。
可以看到,GM 模型其实也是存在很多问题的,比如统一使用了一个全局锁,Goroutine 的调度依赖全局队列,程序执行器和 Goroutine 没有强依赖导致很多情况下不满足局部性原理,M 的内存分配和扩展等等。因此 Go 团队后期又进一步进化到了 GMP 模型,加入了一个 Processor,感兴趣的同学去学习了解GMP 模型。
综上,Goroutine 和线程相比,具有以下优势:
同时,结合 IO 多路复用技术和 runtime 调度,解决了早期协程一些严重性的问题,从而顺利从互联网时代突围出来,成为了各个大厂以及底层组件的主力语言。
好了,知道了 Goroutine 的各种优点,最后我们来看看一个 Go 的并发编程模型是如何实现的。
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 \* time.Millisecond)
fmt.Println(s)
}
}
func sayHello() {
go say("hello")
say("world")
}
这是 Go 官方文档中的 Goroutine 部分的一段示例。可以看到运行一段 Goroutine 的语法非常简单,就只需要一个 go 关键字即可。上述的例子最终会输出”hello world”。和 C#的传染性不同,Go 代码从外部是完全看不到代码是不是异步实现的,这就给开发者降低了一些心理压力。
我们知道,C#设计这套复杂的async/await模型,其实就是为了解决异步方法 callback 难以获取的问题。所以加入了 await 关键字,对异步状态机的结果监听,最终返回异步线程上下文中的结果。然而 Go 没有 await,那是如何进行上下文同步的呢?
func calN(n int, ch chan<- int) {
// 模拟复杂的N计算
time.Sleep(time.Second)
// 返回结果
ch <- n \ n
}
func main() {
ch := make(chan int, 1)
go calN(12345, ch)
select {
case ans := <-ch: {
fmt.Printf("answer is: %d", ans)
}
default {
time.Sleep(time.Millisecond \ 100)
}
}
}
这里我们终于学到了最后一个关键字 select 以及最后一个引用类型 chan 了。
chan
先来说 chan,chan 是 channel 的意思,是多个 goroutine 进行数据传递的通道,其作用类似于 C#中的 Pipe,相当于是再多个并发执行的 goroutine 中掏了个洞洞用来传递数据。
chan 和指针一样是由类型的,是一个引用类型,通过make()函数初始化,第二个参数是通道的 size。由此可知,通道其实就是一个双端队列,多个 goroutine 都可以往通道中读写数据,当通道 buffer 被写满后,写通道的 goroutine 就会被阻塞。
写通道的语法特别简单就是一个箭头符号<-(注意只有左箭头唯一一种,没有右箭头),ch<-代表想通道写数据,<-ch代表从通道读出数据。
select
再来看 select 关键字,这里的 select 其实就是 linux 操作系统的 IO 多路复用技术的一个指令,其目的就是当接收到异步事件时轮询每一个事件结果。
Go 实现了语言级别的 select 功能,它的作用和 linux 的 select 类似,就是阻塞当前 goroutine等待 chan 的返回。
通常 select 会配合 case 和 default 使用,使用方式类似于 switch-case 语句,满足哪一个就触发哪一个。上述代码中,当我使用select关键字读取通道内的数据时,由于刚开始caN函数还没有返回,所以 main 的 goroutine 进入了 default 会睡 100 毫秒。之后再次循环之前的操作直至某个 case 中有 return 等退出的语句。
如果同一时间同时满足了多个 case,那 Go Runtime 会随机选择一个 case 去执行。通常情况下,Goroutine 的超时都是自己写一个超时函数实现的,比如下列代码:
func makeNum(ch chan<- int) {
time.Sleep(5 * time.Second)
ch <- 10
}
func timeout(ch chan<- int) {
time.Sleep(3 time.Second)
ch <- 0
}
func chanBlock() {
ch := make(chan int, 1)
timeoutCh := make(chan int, 1)
go makeNum(ch)
go timeout(timeoutCh)
select {
case <-ch:
fmt.Println(ch)
case <-timeoutCh:
fmt.Println("timeout")
}
}
生产者-消费者模型
好了,为了更熟练地理解 Goroutine 的编程风格,最后让我们用 Goroutine 实现一个操作系统同步互斥问题中比较经典的生产者-消费者模型:
// 需求:创建生产者消费者模型,其中生产者和消费者分别是N和M个
// 生产者每隔一段时间生产X产品,消费者同样也每隔一段时间消费Y产品
// 生产者如果将产品容器填满应该被阻塞,多次阻塞之后将会退出
// 每个消费者需要消费满Z个产品才能退出,否则就要一直消费产品
const (
ProducerCount = 3 // 生产者数量
ConsumerCount = 5 // 消费者数量
FullCount = 15 // 消费者需求数量,消费者吃够了应该回家
TimeFactor = 5 // 时间间隔因子,每生产/消费一个产品,需要休息一段时间
QuitTimes = 3 // 生产者退出次数,如果生产者阻塞了多次,则会下班
SleepFactor = 3 // 睡眠时间因子,如果生产者被阻塞应该睡眠一段时间
)
var (
waitGroup = sync.WaitGroup{}
)
func producer(n int, ch chan<- int) {
defer waitGroup.Done()
times := createFactor()
asleepTimes := 0
for true {
p := createFactor()
select {
case ch <- p:
{
t := time.Duration(times) \ time.Second
fmt.Printf("Producer: %d produced a %d, then will sleep %d s\\n", n, p, times)
time.Sleep(t)
}
default:
{
time.Sleep(time.Second \ SleepFactor)
asleepTimes++
fmt.Println("I need consumers!")
if asleepTimes == QuitTimes {
fmt.Printf("Producer %d will go home\\n", n)
return
}
}
}
}
}
func consumer(n int, ch chan int) {
waitGroup.Done()
s := make([]int, 0, FullCount)
times := createFactor()
for len(s) < FullCount {
select {
case c := <-ch:
{
s = append(s, c)
fmt.Printf("Consumer: %d consume a %d, remains %d, then will sleep %d s\\n", n, c, FullCount-len(s), times)
time.Sleep(time.Duration(times) \ time.Second)
}
default:
{
fmt.Println("Producers need to hurry up, I'm hungry!")
time.Sleep(time.Second \ SleepFactor)
}
}
}
fmt.Printf("Consumer: %d already full\\n", n)
}
func createFactor() int {
times := 0
for times == 0 {
times = rand.Intn(TimeFactor)
}
return times
}
func main() {
rand.Seed(time.Now().UnixNano())
ch := make(chan int, FullCount)
waitGroup.Add(ProducerCount)
for i := 0; i \< ProducerCount; i++ {
go producer(i, ch)
}
waitGroup.Add(ConsumerCount)
for i := 0; i \< ConsumerCount; i++ {
go consumer(i, ch)
}
waitGroup.Wait()
}
这里用到了 Goroutine 中另一个比较常见的包sync,上例中用到了一个 WaitGroup,其目的类似于 C#中的Task.WaitAll,用来等待所有的 goroutine 执行结束。可以看到它基本是基于信号量实现的,所以每次创建 goroutine 时,都需要执行 Add 函数。
最后,提供一个我自己纯手工每一行代码都是自己实现的Toy-Web。这个练手的小项目也花了我很大的精力才完成,真正想要自己实现其实也需要一点算法功底,比如路由应该使用 Trie 树,匹配通配符需要 BFS 和 DFS 的知识,如果需要路由节点扩展,则需要会回溯的算法。总之,万丈高楼平地起,加油吧,各位新生的 Gopher 们,祝我们都有一个美好的未来。
扩展链接: