大家好,今天将梳理出的 Go语言并发知识内容,分享给大家。 请多多指教,谢谢。
前面 Golang 基础之并发知识 (二) 章节已经和大家简单介绍了 goroutine 的实现机制,本篇文章我在针对 goroutine 知识点进行一个补充。
本章节内容
- 介绍 & 使用
- 优点
- goroutine性能消耗
- 主 goroutine 的运作
介绍 & 使用
goroutine 是Go中最基本的组织单位之一。事实上,每个Go程序至少拥有一个:main goroutine,当程序开始时会自动创建并启动。在几乎所有Go程序中,你都可能会发现自己迟早加入到一个goroutine中。那么它到底是什么?
简单来说,goroutine 是一个并发的函数(记住:不一定是并行)和其他代码一起运行。
go语句与goroutine
go语句是由 go 关键字和表达式组成的,针对如下函数的调用表达式不能称为表达式语句:append、cap、complex、imag、len、make、new、real、unsafe.Alignof、unsafe.Offsetof 、unsafe.Sizeof。前8个Go语言内建函数,后3个函数是标准库代码包unsafe中的函数。
例如,使用内建函数 println
的调用表达式组成一条go语句
go println("hello 帽儿山的枪手")
也可以简单的通过将go关键字放在函数前面来启动它:
func main() {
go sayHello() // goroutine并发
}
func sayHello() {
fmt.Println("hello 帽儿山的枪手")
}
goroutine 实际上是如何工作的? 是OS线程吗? 绿色线程? 我们可以创建多少个?
goroutine 对Go来说是独一无二的(尽管其他一些语言有类似的并发原语)。它们不是操作系统线程,它们不完全是绿色的线程(由语言运行时管理的线程),它们是更高级别的抽象,被称为协程(coroutines)。协程是非抢占的并发子程序,也就是说,它们不能被中断。
Go的独特之处在于goroutine与Go的运行时深度整合。Goroutine没有定义自己的暂停或再入点; Go的运行时观察着goroutine的行为,并在阻塞时自动挂起它们,然后在它们变畅通时恢复它们。在某种程度上,这使得它们可以抢占,但只是在goroutine被阻止的地方。它是运行时和goroutine逻辑之间的一种优雅合作关系。 因此,goroutine可以被认为是一种特殊的协程。
协程,因此可以被认为是goroutine的隐式并发构造,但并发并非协程自带的属性:某些东西必须能够同时托管几个协程,并给每个协程执行的机会,否则它们无法实现并发。当然,有可能有几个协程按顺序执行,但看起来就像并行一样,在Go中这样的情况比较常见。
Go的宿主机制实现了所谓的M:N调度器,这意味着它将M个绿色线程映射到N个系统线程。 goroutine随后被安排在绿色线程上。 当我们拥有比绿色线程更多的 goroutine 时,调度程序处理可用线程间 goroutine的分布,并确保当这些 goroutine 被阻塞时,可以运行其他 goroutine。
Go遵循称为 fork-join 模型的并发模型.fork这个词指的是在程序中的任何一点,它都可以将一个子执行的分支分离出来,以便与其父代同时运行。join这个词指的是这样一个事实,即在将来的某个时候,这些并发的执行分支将重新组合在一起。子分支重新加入的地方称为连接点。这里有一个图形表示来帮助你理解它:
go关键字为Go程序实现了fork,fork的执行者是 goroutine。sayHello()
函数会在属于它的 goroutine 上运行,与此同时程序的其他部分继续执行。在这个例子中,没有连接点。执行 sayHello()
的goroutine将在未来某个不确定的时间退出,并且该程序的其余部分将继续执行。
然而,这个例子存在一个问题:我们不确定 sayHello()
函数是否可以运行。goroutine将被创建并交由Go的运行时安排执行,但在 main()
goroutine退出前它实际上可能没有机会运行。
这里
main()
函数会在sayHello()
之前执行完成,所有不会看到Println输出信息。
这种情况,可以加入连接点确保程序正确性并消除竞争条件。创建连接点可以通过多种方式完成,这里使用sync包中提供的一个解决方案:sync.WaitGroup (后面并发原语文章会详细介绍,这里简单了解即可)。
var wg sync.WaitGroup
func main() {
wg.Add(1)
go sayHello()
wg.Wait()
}
func sayHello() {
defer wg.Done()
fmt.Println("hello")
}
输出
hello
优点
新建立一个 goroutine 有几千字节,这样的大小几乎总是够用的。 如果出现不够用的情况,运行时会自动增加(并缩小)用于存储堆栈的内存,从而允许许多 goroutine 存在适量的内存中。CPU开销平均每个函数调用大约三个廉价指令。 在相同的地址空间中创建数十万个 goroutine 是可以的。如果 goroutine 只是执行等同于线程的任务,那么系统资源的占用会更小。
以上来自官方FAQ的摘录
goroutine性能消耗
这里引用了代码案例,可以简单看出 goroutine 的资源开销。
// 计算 goroutine 消耗
package main
import (
"fmt"
"sync"
"runtime"
)
var wg sync.WaitGroup
func main() {
memConsumed := func() uint64 {
runtime.GC()
var s runtime.MemStats
runtime.ReadMemStats(&s)
return s.Sys
}
var c <-chan interface{}
noop := func() { wg.Done(); <-c }
const numGoroutines = 1e4 // 10000 goroutine
wg.Add(numGoroutines)
before := memConsumed()
for i := numGoroutines; i > 0; i-- {
go noop()
}
wg.Wait()
after := memConsumed()
fmt.Printf("%.3fkb\n", float64(after-before)/numGoroutines/1000)
}
输出
2.576kb
当前我的机器配置CPU Intel i5,内存8G;ubuntu操作系统,内核5.15;go版本1.17.8
按照这个数据,在我当前环境中支持数百万个gouroutine是没问题的。
性能可能影响的因素,例如上下文切换,即当某个并发进程承载的某些内容必须保存其状态以切换到其他进程时。如果我们有太多的并发进程,上下文切换可能花费所有的CPU时间,并且无法完成任何实际工作。在操作系统级别,使用线程,这样做代价可能会非常高昂。操作系统线程必须保存寄存器值,查找表和内存映射等内容,才能在操作成功后切换回当前线程。 然后它必须为传入线程加载相同的信息。
主 goroutine 的运作
封装 main()
函数的 goroutine 称为主 goroutine。 主 goroutine 会由 runtime.m0
负责运行。
主 goroutine 所做的事情不是执行 main()
函数那么简单。它首先要做:设定每一个 goroutine 所能申请的栈空间的最大尺寸。 在 32 位的计算机系统中此最大尺寸为 250 MB , 而在 64 位的计算机系统中此尺寸为 1 GB。如果有某个 goroutine 的栈空间尺寸大于这个限制,那么运行时系统就会发起一个栈溢出( stack overflow )的运行时恐慌。 随即,这个Go程序运行也会终止。
在设定好 goroutine 的最大栈尺寸之后, 主 goroutine 会在当前 M 的 g0
上执行系统检测任务。其作用就是为调度器查漏补缺,也是让系统检测任务的执行先于 main()
函数。
此后,主 goroutine
会进行一系列的初始化工作
- 检查当前 M 是否是
runtime.m0
。 如果不是,就说明之前的程序出现了某种问题;这时主 goroutine 会立即抛出异常,意味着Go 程序启动失败。 - 创建一个特殊的 defer语句,用于在主 goroutine 退出时做必要的善后处理。 因为主 goroutine 也可能非正常的结束。
- 启用专用于在后台清扫内存垃圾的 goroutine,并设置 GC 可用的标识。
- 执行
main
包中的init()
函数。
如果上述初始化工作成功完成,那么主 goroutine 就会去执行 main()
函数。 在执行完 main()
函数之后,它还会检查主 goroutine 是否引发了运行时恐慌,并进行必要的处理。 最后,主 goroutine 会结束自己以及当前进程的运行。
关于 goroutine 生命周期及调度机制,请查看之前文章 Golang 基础之并发知识 (二)
技术文章持续更新,请大家多多关注呀~~
搜索微信公众号,关注我【 帽儿山的枪手 】
参考材料
- 《Concurrency in Go》、《Go 并发编程实战》