99.99%面试中被问的Go语言并发模式,你会如何回答 | 文末送书
共 4237字,需浏览 9分钟
·
2021-04-24 21:13
什么是并发? 有哪些我们需要知道的并发模式? Go 语言中的协程并发模型是怎样的? 什么是主 goroutine? 它与我们自己启用的其他 goroutine 有什么不同?
本文就来为你一一解答!
以下内容节选自《Go语言极简一本通:零基础入门到项目实战》一书!
串行程序,即程序的执行顺序和程序的编写顺序一致,整个程序只有一个上下文,就是一个栈,一个堆。
并发程序,则需要运行多个上下文,对应多个调用栈。每个进程在运行时,都有自己的调用栈和堆,有一套完整的上下文。操作系统在调用时,会保证被调度进程的上下文环境,待该进程获得时间后,再将该进程的上下文恢复到系统中。
串行的代码是逐行执行的,是确定的,而并发引入了不确定性。线程通信只能采用共享内存的方式,为了保证共享内存的有效性,可以加锁,但是这样又引入了死锁的风险。
并发的优势如下:
可以充分利用 CPU 核心的优势,提高程序的执行效率。 并发能充分利用 CPU 与其他硬件设备的异步性,如文件操作等。
下面介绍 3 种并发模式。
1.多进程是操作系统层面的并发模式
所有的进程都由内核管理。进程描述的是程序的执行过程,是运行着的程序。
一个进程其实就是一个程序运行时的产物。
电脑为什么可以同时运行那么多应用程序?手机为什么可以有那么多 App 同时在后台刷新?
这是因为在它们的操作系统之上有多个代表着不同应用程序的进程在同时运行。
操作系统会为每个独立的程序创建一个进程,进程可以装下整个程序需要的资源。例如,程序执行的进度、执行的结果等,都可以放在里面。在程序运行结束后,再把进程销毁,然后运行下一个程序,周而复始。
进程在程序运行中是非常占用资源的,无论是否会用到全部的资源,只要程序启动了,就会被加载到进程中。
优势是进程互不影响,劣势是开销非常大。
2.多线程属于系统层面的并发模式,也是使用最多、最有效的一种模式
线程是在进程之内的,可以把它理解为轻量级的进程。它可以被视为进程中代码的执行流程。这样在处理程序的运行和记录中间结果时,就可以使用更少的资源。待资源用完,线程就会被销毁。
线程要比进程轻量级很多。一个进程至少包含一个线程。如果一个进程只包含一个线程,那么它里面的所有代码都只会被串行地执行。
每个进程的第一个线程都会随着该进程的启动而被创建,它们被称为其所属进程的主线程。同理,如果一个进程中包含多个线程,那么其中的代码就可以被并发地执行。
除进程的第一个线程外,其他的线程都是由进程中已存在的线程创建出来的。也就是说,主线程之外的其他线程都只能由代码显式地创建和销毁。这需要我们在编写程序时进行手动控制。
优势是比进程开销小一些,劣势是开销仍然较大。
3.Goroutine
从本质上说,goroutine 是一种用户态线程,不需要操作系统进行抢占式调度。
在 Go 程序中,Go 语言的运行时系统会自动地创建和销毁系统级的线程。
系统级线程指的是操作系统提供的线程,而对应的用户级线程(goroutine)指的是架设在系统级线程之上的,由用户(或者说我们编写的程序)完全控制的代码执行流程。
用户级线程的创建、销毁、调度、状态变更,以及其中的代码和数据都完全需要我们的程序自己去实现和处理,其优势如下:
(1)因为它们的创建和销毁不需要通过操作系统去做,所以速度很快,可以提高任务并发性。编程简单、结构清晰。
(2)由于不用操作系统去调度它们的运行,所以很容易控制,并且很灵活。
协程并发模型
在 Go 语言中,不要通过共享数据来通信,恰恰相反,要通过通信的方式来共享数据。
Go 语言不仅有 goroutine,还有强大的用来调度 goroutine、对接系统级线程的调度器。
调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模型中的三个主要元素,即G(goroutine 的缩写)、P(processor 的缩写)和 M(machine 的缩写),如下图所示。
其中,M 指的就是系统级线程。而 P 指的是一种可以引用若干个 G,且能够使这些 G 在恰当的时机与 M 进行对接,并得到运行的中介。
从宏观上说,由于 P 的存在,G 和 M 可以呈现出多对多的关系。当一个正在与某个 M 对接并运行着的 G,需要因某个事件(比如等待 I/O 或锁的解除)而暂停运行时,调度器总会及时地发现,并把这个 G 与那个 M 分离开,以释放计算资源供那些等待运行的 G 使用。
而当一个 G 需要恢复运行时,调度器又会尽快地为它寻找空闲的计算资源(包括 M)并安排运行。另外,当 M 不够用时,调度器会向操作系统申请新的系统级线程,而当某个 M 已无用时,调度器又会负责把它及时地销毁。
程序中的所有 goroutine 也都会被充分地调度,其中的代码也都会被并发地运行,即使 goroutine 数以十万计,仍然可以如此。
什么是主 goroutine?它与我们自己启用的其他 goroutine 有什么不同?
先来看下面的代码:
package main
import "fmt"
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}
这段代码只在 main 函数中写了一条 for 语句。这条 for 语句中的代码会迭代运行 10 次,并有一个局部变量 i 表示当次迭代的序号,该序号是从 0 开始的。在这条 for 语句中仅有一条 Go 语句,在这条 Go 语句中也仅有一条语句,该语句调用了 fmt.Println 函数,想要打印出变量 i 的值。
这个程序很简单,只有三条语句。这个程序被执行后,会打印出什么内容呢?
答案是:大部分计算机执行后,屏幕上不会有任何内容被打印出来。
这是为什么呢?
一个进程总会有一个主线程,类似地,每一个独立的 Go 程序在运行时也总会有一个主 goroutine。这个主 goroutine 会在 Go 程序的运行准备工作完成后被自动地启用。
一般来说,每条 Go 语句都带有一个函数调用,这个被调用的函数就是 Go 函数。而主 goroutine 的 Go 函数就是那个作为程序入口的 main 函数。Go 函数执行的时间与其所属的 Go 语句执行的时间不同。
如下图所示,当程序执行到一条 Go 语句时,Go 语言的运行时系统会先试图从某个空闲的 G 队列中获取一个 G(也就是 goroutine),只有在找不到空闲 G 的情况下它才会去创建一个新的 G。
如果已经存在一个 goroutine,那么已存在的 goroutine 总是会被优先复用。如果不存在,就去启动另一个 goroutine。
在 Go 语言中,创建 G 的成本非常低。创建一个 G 并不需要像新建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,而是在 Go 语言的运行时系统内部就可以完全做到,一个 G 仅相当于为需要并发执行代码片段服务的上下文环境。
在拿到一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个 Go 函数(或者一个匿名的函数),然后再把这个 G 追加到某个可运行的 G 队列中。队列中的 G 总是按照先入先出的顺序,由运行时系统安排运行。
由于上面所说的那些准备工作是不可避免的,所以会消耗一定时间。因此,Go 函数的执行时间总是慢于它所属的 Go 语句的执行时间。
明白了这些之后,再来看上面的例子。请记住,只要 Go 语句本身执行完毕,Go 程序不会等待 Go 函数的执行,它就会立刻执行后边的语句,这就是异步并发执行。
这里“后边的语句”一般指的是上面例子中 for 语句中的下一个迭代。当最后一个迭代运行时,这个“后边的语句”是不存在的。
上面的那条 for 语句会以很快的速度执行完毕。当它执行完毕时,那 10 个包装了 Go 函数的 goroutine 往往还没有获得运行的机会。Go 函数中的那个对 fmt.Println 函数的调用是以 for 语句中的变量 i 作为参数的。
当 for 语句执行完毕时,这些 Go 函数都还没有执行,那么它们引用的变量 i 是多少呢?
一旦主 goroutine 中的代码(也就是 main 函数中的那些代码)执行完毕,当前的 Go 程序就会结束运行。当 Go 程序结束运行时,无论其他的 goroutine 是否运行,都不会被执行了。当 for 语句的最后一个迭代运行时,其中的那条 Go 语句即最后一条语句。所以,在执行完这条 Go 语句之后,主 goroutine 中的代码就执行完了,Go 程序会立即结束运行。因此前面的代码不会有任何内容被打印输出。
严谨地讲,Go 语言并不管这些 goroutine 以怎样的顺序运行。由于主 goroutine 会与我们自己启用的其他 goroutine 一起被调度,而调度器很可能会在 goroutine 中的代码只执行了一部分的时候暂停,以便所有的 goroutine 都有运行的机会。所以哪个 goroutine 先执行完,哪个 goroutine 后执行完往往是不可预知的。
对于上面简单的代码而言,绝大多数情况都是“不会有任何内容被打印出来”。但是为了严谨起见,无论回答“打印出 10 个 10”,还是“不会有任何内容被打印出来”,或是“打印出乱序的 0 到 9”都是对的。
这个原理非常重要,希望读者能理解。
有任何问题,可以通过以下 3 种方式联系作者
微信:write_code_666 公众号:面向加薪学习 B 站:面向加薪学习
赠 书 活 动
直接点击下方小程序参与抽奖,将抽取3本《Go 语言极简一本通》图书~