Go语言36讲笔记--16 go语句及其执行规则(上)

163 阅读7分钟

本篇文章了解一下Go语言的特色流程与语法。而go语句是 Go 语言的最大特色,代表 Go 语言最重要的编程哲学和并发编程模式。(GMP模型)

通道(也就是 channel)类型的值,被用来以通讯的方式共享数据。这里的通讯,就是被用来在不同的 goroutine 之间传递数据。

关于goroutine

简单来说,goroutine 代表着并发编程模型中的用户级线程。操作系统本身就提供了进程和线程,这两种并发执行程序的工具 聊聊进程与线程(这个问题我看了不下十篇技术blog,不同角度分析,结论不同。这里不展开了。:) 宏观理解:进程是程序运行时的产物。OS上同时有多个进程在执行;线程总是在进程内的,可以被视为进程中运行着的控制流(即代码执行的流程)。

从进程中包含线程个数的角度分析一下:

  1. 若一个进程中只包含了一个线程,那进程中的所有代码都只能被串行执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。
  2. 若一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。 Go语言在系统级线程(M)方面的特点 一般情况下,主线程之外的其他线程都只能由代码显式地创建和销毁。需要在编写程序的时候进行手动控制,操作系统以及进程本身并不会帮我们下达这样的指令。

在 Go 程序当中,运行时系统会帮助我们自动地创建和销毁系统级的线程,即操作系统提供的线程


关于用户级线程(G)

用户级线程指的是架设在系统级线程之上的,由用户(即我们编写的程序)完全控制的代码执行流程。

用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。

优势:因为它们的创建和销毁并不用通过操作系统去做,所以速度会很快;由于不用等着操作系统去调度它们的运行,所以往往会很容易控制并且可以很灵活

劣势:最重要的一个劣势就是复杂

如果我们只使用了系统级线程,那么我们只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好了,其他的一切具体实现都会由操作系统 代劳。

但是,如果使用用户级线程,我们就不得不既是指令下达者,又是指令执行者。我们必须全权负责与用户级线程有关的所有具体实现。

操作系统不但不会帮忙,还会要求我们的具体实现必须与它正确地对接,否则用户级线程就无法被并发地,甚至正确地运行。毕竟我们编写的所有代码最终都需要通过操作系统才能在计算机上执行。


下面聊一下用于调度 goroutine、对接系统级线程的调度器

这个调度器的主要作用就是用来解决上面谈到的劣势问题,帮助解决用户级线程与系统级线程的对接。

image.png M、P、G 之间的关系(简化版)

这个调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模型中的三个主要元素,即:G(goroutine 的缩写)、P(processor 的缩写)和 M(machine 的缩写)。

其中的 M 指代的就是系统级线程。而 P 指的是一种可以承载若干个 G,且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。

从宏观上说,G 和 M 由于 P 的存在可以呈现出多对多的关系。当一个正在与某个 M 对接并运行着的 G,需要因某个事件(比如等待 I/O 或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个 G 与那个 M 分离开,以释放计算资源供那些等待运行的 G 使用。

而当一个 G 需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括 M)并安排运行。另外,当 M 不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个 M 已无用时,调度器又会负责把它及时地销毁掉。


什么是主 goroutine,它与启用的其他 goroutine 有什么不同?

关于主goroutine

这个概念可以对标每个进程中都有一个主线程。主 goroutine 会在 Go 程序的运行准备工作完成后被自动地启用,并不需要我们做任何手动的操作。

关于go函数

每条go语句一般都会携带一个函数调用,这个被调用的函数常常被称为go函数。而主 goroutine 的go函数就是那个作为程序入口的main函数。

go函数执行过程

结论go函数真正被执行的时间,总会与其所属的go语句被执行的时间不同)

原因:go函数--找到G--放进G--队列中被调度到--执行,这个过程需要时间

详细分析: 当程序执行到一条go语句的时候,Go 语言的运行时系统,会先试图从某个存放空闲的 G 的队列中获取一个 G(也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个新的 G。这也是为什么我总会说“启用”一个 goroutine,而不说“创建”一个 goroutine 的原因。已存在的 goroutine 总是会被优先复用。然而,创建 G 的成本也是非常低的。

创建一个 G 并不会像新建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在 Go 语言的运行时系统内部就可以完全做到了,更何况一个 G 仅相当于为需要并发执行代码片段服务的上下文环境而已。

在拿到了一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个go函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。

这类队列中的 G 总是会按照先入先出的顺序,由运行时系统内部的调度器安排运行。

表象:只要go语句本身执行完毕,Go 程序完全不会等待go函数的执行,它会立刻去执行后边的语句。这就是所谓的异步并发地执行

这里“后边的语句”指的一般是for语句中的下一个迭代。然而,当最后一个迭代运行的时候,这个“后边的语句”是不存在的。 demo(结果:未知的,大概率什么也不输出)

package main
 
import "fmt"
 
func main() {
	for i := 0; i < 10; i++ {
		go func() {
			fmt.Println(i)
		}()
	}
}

for语句会很快执行完毕。当它执行完毕时,那 10 个包装了go函数的 goroutine 往往还没有获得运行的机会。

主 goroutine 的一个重要特性:一旦主 goroutine 中的代码(也就是main函数中的那些代码)执行完毕,当前的 Go 程序就会结束运行。

在这段demo中,Go 语言不会去保证这些 goroutine 会以怎样的顺序运行。

由于主 goroutine 会与我们手动启用的其他 goroutine 一起接受调度,又因为调度器很可能会在 goroutine 中的代码只执行了一部分的时候暂停,以期所有的 goroutine 有更公平的运行机会。

所以,哪个 goroutine 先执行完、哪个 goroutine 后执行完往往是不可预知的。