当前位置: 首页 > ops >正文

Go并发编程-goroutine

goroutine

本文是书籍: 《 Go 并发编程实战 》的读书笔记.

基本使用

goroutine, 也就是 GMP 模型里的 G. Go 通过关键字 go 启动一个goroutine.

一条 go 语句意味着一个函数或者方法的并行执行. go 语句由 go 关键字和表达式组成.

简单地讲表达式就是用于描述针对若干操作数的计算方法的式子.

Go 的表达式有多种, 其中一种是调用表达式, 调用表达式表达的是针对函数或者方法的调用, 其中的函数可以是命名也可是匿名. 能成为表达式语句的调用表达式是创建 go 语句时唯一合法的表达式.

创建go 语句时唯一合 法的表达式。针对如下丽数的调用表达式不能称为表达式语句: appendcapcompleximaglenmakenewrealunsafe.Alignofunsafe.Offsetofunsafe. Sizeof. 前8个函数是Go语言的内建函数,而最后3个函数则是标准库代码包 unsafe 中的函数。

下面是一个使用 go 关键字的例子:

go println("Go! Goroutine")

在这使用匿名函数调用则是:

go func() {println("Go! Goroutine")
}()

最后的一对圆括号, 代表了对函数的调用行为, 是调用表达式的必要组成部分.

另外, go 关键字后面的调用表达式不能用圆括号括起来. 这一构建规则和 defer 语句一致.

Go 运行时系统对 go 函数 (go 语句中的函数的简称) 的执行是并发的, 也就是, go 语句执行时, 其中的 go 函数将被单独放入一个 goroutine 中, 此后该 go 函数执行会独立于当前 goroutine 运行.

一般情况下, go 语句之后的语句不会等到前者 go 函数被执行完后才开始执行, 甚至在该 go 函数真正执行前, 运行时系统可能就已经开始执行其后面的语句了, 也就是说: go 函数是并发执行, 但是先后次序是无法确定的.

go 函数可以有返回值, 但是其返回值会在执行完成时被丢弃, 也就是说即使有返回值, 也没什么意义.

如果需要将 go 函数的计算结果传递给其他函数, 应当使用 channel 配合.

示例1

package mainfunc main() {go println("Go! Goroutine!")
}

第一眼看来, 可能以为标准输出上会出现:

Go! Goroutine!

但是事实上什么也不会出现. 想想之前的 GMP 模型, 运行时系统会并发地执行 go 函数, 使用一个 G 来封装 go 函数, 然后放入可运行 G 队列中, 但是这个新的 G 的运行时刻则是看调度器的实时调度情况, 在本例中, go 语句后没有任何语句, main 函数执行结束立刻就返回, 这个 Go 程序也就结束运行了, 这里的打印语句还没来得及执行.

从这个例子中, 我们就要知道, 不能确定 main 函数所在的 G 会是最后一个运行完毕的, 这时候可以加 Sleep() 函数来实现等待.

示例2

package mainimport "time"func main() {go println("Go! Goroutine!")time.Sleep(time.Millisecond * 1)
}

此处调用函数 Sleep(), 让调用该函数的 goroutine 暂停 (此时进入 Gwaiting状态) 一段时间. 此处是暂停了 1ms.

理想情况下, 加入该语句后, 标准输出会打印出我们期望的语句, 但是真实情况不会总是如此, 毕竟谁能知道到底要睡多长时间才能轮到需要的 goroutine 被调度呢? 此时最好把函数替换为 runtime.Gosched().

调用 runtime.Gosched() 会暂停调用该函数的 G, 让其他的 G 有机会运行, 在这里可以用, 但是实际情况一般比这复杂许多, 此时该函数也会失效.

示例3

package mainimport ("fmt""time"
)func main() {name := "dagoujiao"go func() {fmt.Printf("Hello, %s!", name)}()name = "hajimi"time.Sleep(time.Millisecond)
}

运行该程序, 标准输出将是:

Hello, dagoujiao!

Hello, hajimi!

两者取其一, 但是多数情况下为后者, 这更说明了并发性的问题, 在执行了 name = "hajimi" 之后才执行上面的 go 函数,

然后把最后两条语句更换位置看看结果:

package mainimport ("fmt""time"
)func main() {name := "dagoujiao"go func() {fmt.Printf("Hello, %s!", name)}()time.Sleep(time.Millisecond)name = "hajimi"
}

现在将会打印:

Hello, dagoujiao!

这里原因就比较显而易见了.

然后再是更复杂的情况, 如果我要同时问候多个人要怎么办?

示例4

package mainimport ("fmt""time"
)func main() {names := []string{"dagoujiao", "hajimi", "daishuji", "dingdongji", "jiangangma"}for _, name := range names {go func() {fmt.Printf("Hello, %s! \n", name)}()}time.Sleep(time.Millisecond)
}

这里将会乱序输出.

Hello, jiangangma!
Hello, dagoujiao!
Hello, hajimi!
Hello, daishuji!
Hello, dingdongji!

这里同样说明了, 对于 go 函数的执行实际不能做任何期望, 这里用了一个循环, 循环将会执行 5 次 go 函数, 由 5 个 G 分别封装运行, 此时就看出问题了, 没人能知道具体执行到了哪一个迭代器.

第一种方案是, 将 time.Sleep(time.Millisecond) 语句提前到 for 循环内就可以解决了:

package mainimport ("fmt""time"
)func main() {names := []string{"dagoujiao", "hajimi", "daishuji", "dingdongji", "jiangangma"}for _, name := range names {go func() {fmt.Printf("Hello, %s! \n", name)}()time.Sleep(time.Millisecond)}
}

但是这种方法, 虽然简单但是也有问题, 一旦 for 循环复杂了, 或者go 函数复杂了, 打印顺序仍然无法保证.

然后再考虑第二种思路. 如果在 go 函数中使用的 name 的值不会受到外部变量变化影响, 这样既能保证 go 函数的独立执行, 也不用担心其正确性受破坏. 我们将实现了改设想的函数称为 “可重入函数”.

go 函数和普通函数一样有参数生命, 可以将迭代变量 name 的值作为参数传递给 go 函数, 这样可以实现上述的设想.

示例5

package mainimport ("fmt""time"
)func main() {names := []string{"dagoujiao", "hajimi", "daishuji", "dingdongji", "jiangangma"}for _, name := range names {go func(who string) {fmt.Printf("Hello, %s! \n", who)}(name)}time.Sleep(time.Millisecond)
}

按照上面的说法, 这里这样编写即可, 在每次迭代开始时, name 变量的值都是 names 中的某一个元素值, 之后这个值将会被传入 go 函数, 传入过程中, 该值将会被复制, 然后在 go 函数中有一个参数 who 指代. 此后 name 值的改变和 go 函数就没关系了.

主 goroutine 的运作

封装 main 函数的 goroutine 就是主 goroutine. 主 goroutine 由 runtime.m0 负责运行

主 goroutine 不只是执行 main 函数这一个功能.

首先要做的: 设定每一个 goroutine 所能申请的栈空间的最大尺寸. 32位计算机中该最大尺寸为250MB, 64位计算机中该最大尺寸为1GB. 如果某个 goroutine 栈空间大于此限制, 那么运行时系统立马就发出 stack overflow (栈溢出) panic. 随后终止程序运行.

在设定完 goroutine 最大栈尺寸之后, 主 goroutine 在当前 M 的 g0 上执行系统检测任务. 系统检测任务作用就是为调度器查漏补缺. 这也是让系统检测任务执行先于 main 函数的原因.

之后, 主 goroutine 将进行一系列初始化工作, 涉及内容大体如下:

  • 检查当前 M 是否是 runtime.m0. 如果不是, 说明之前的程序出现了某问题, 此时主 goroutine 立即抛出异常, 意味着 Go 程序启动失败.

  • 创建一个特殊的 defer 语句, 用于在主 goroutine 退出时做必要的善后处理. 因为主 goroutine 可能是非正常结束, 所以这点很重要.

  • 启用专用于在后台清扫内存垃圾的 goroutine, 设置 GC 可用的标识.

  • 执行 main 包中的 init 函数.

在上述初始化工作成功完成后, 主 goroutine 执行 main 函数. 执行完 main 函数后, 还会检查主 goroutine 是否引发了运行时恐慌, 然后进行必要的处理. 最后, 主 goroutine 会结束自己以及当前进程运行.

main 函数执行期间, 运行时系统会根据 Go 程序中的 go 语句, 复用或者新建 goroutine 来封装 go 函数.

这使得等待时间非常短暂, 但是也足够长, 以至于会使得某些更小时间片的 goroutine 错过甚至永远失去运行时机.

runtime 包与 goroutine

runtime 包提供了许多可以使用户程序与 Go 运行时系统交互的功能, 然后是一些可以获得 goroutine 信息的 API, 为了汇总讲函数罗列于此:

runtime.GOMAXPROCS

调用该函数, 用户程序可以在运行期间设定常规运行时系统中的 P 的最大数量, 不过这会引起 stw (stop the world), 所以应该尽量早运行这个函数, 当然最好的做法就是设置环境变量 GOMAXPROCS.

Go 运行时系统中 P 最大数量范围总是在 1 ~ 256

runtime.Goexit

调用该函数后, 会立即使当前 goroutine 的运行终止,而其他 goroutine 并不会受此影响.

runtime . Goexit 函数在终止当前 goroutine 之前,会先执行该 goroutine 中所有还未执行的 defer 语句.

该函数会把被终止的 goroutine 置于 Gdead 状态,并将其放入本地 P 的自由 G 列表,然后触发调度器的一轮调度流程.

注意,千万不要在主 goroutine 中调用 runtime .Goexit 函数,否则会触发 panic。

runtime .Gosched

该函数的作用是暂停当前 goroutine 的运行。当前 goroutine 会被置为 Grunnable 状态,并放入调度器的可运行G队列;

这也是使用“暂停”这个词的原因. 经过调度器的调度,该 goroutine 马上就会再次运行.

runtime . NumGoroutine

调用该函数后,会返回当前 Go 运行时系统中处于非 Cdead 状态的用户 G 的数量.

这些 goroutine 被视为“活跃的”或者“可被调度运行的”.

该函数的返回值总会大于等于1.

runtime . LockOSThread 和 runtime .UnlockOSThread

对前者的调用会使当前 goroutine 与当前 M 锁定在一起,而对后者的调用则会解除这样的锁.

多次调用前者不会造成任何问题,但是只有最后一次调用会生效,可以想象成对同一个变量的多次赋值. 另一.方面,即使在之前没有调用过前者,对后者的调用也不会产生任何副作用.

runtime/debug. SetMaxStack函数

该函数的功能是约束单个 goroutine 所能申请栈空间的最大尺寸.

已知,在 main 函数及 init 函数真正执行之前,主 goroutine 会对此数值进行默认设置.

250MB 和 1GB 分别是在 32 位和 64 位的计算机系统下的默认值.

该函数接收一个 int 类型的参数, 该参数是需要设定的栈空间最大字节数.

该函数在执行完毕的时候,会把之前的设定值作为结果返回.

如果运行时系统在为某个 goroutine 增加栈空间的时候,发现它的实际尺寸已经超过了设定值,就会发起一个panic 并终止程序的运行.

需要注意的是,该函数并不会像 runtime.COMAXPROCS 函数那样对传入的参数值进行检查和纠正. 所以,我们应该在调用它的时候保持足够的警惕.

尤其是,即使我们设定了一个过小的值,相关的问题也一般不会在程序的运行初期就显现出来,因为运行时系统仅在增长 goroutine 的栈空间时,才会对它的实际尺寸进行检查.

这样,错误设置就像给程序埋下了一颗定时炸弹,造成的后果也会很严重.

runtime/debug. SetMaxThreads函数

该函数的作用是对 Go 运行时系统所使用的内核线程的数量(也可以认为是 M 的数量)进行设置. 在引导程序中该数量被设置成了 10000.

这对于操作系统和 Go 程序来说,都已经是一个足够大的值了. 该函数接受一个 int 类型的值,也会返回一一个 int 类型的值. 前者代表欲设定的新值,而后者则代表之前设定的旧值.

前面说过,如果调用此函数时给定的新值比运行时系统当前正在使用的 M 的数量还要小的话,就会引发一个panic.

另一方面, 在对此函数的调用完成之后,我们设定的新值就会立即发挥作用.

每当运行时系统新建一个 M 时,就会检查它当前所持 M 的数量。如果该数量大于 M 最大数量的设定, 运行时系统就会发起一个同样的 panic.

GC 相关的函数

runtime/debug.SetGCPercent, runtime.GC, runtime/debug.FreeOSMemory.

第一个函数用于设定触发自动 GC 的条件, 后两者用于发起手动 GC.

自动 GC 在默认情况下为并发运行, 但是手动 GC 总是串行运行.

这也意味着, 后两个函数执行期间, 调度是停止的.

另外, 函数 runtime/debug.FreeOSMemory 比函数 runtime.GC 多了一个 GC 之后清扫一次堆内存的行为.

http://www.xdnf.cn/news/18100.html

相关文章:

  • Docker小游戏 | 使用Docker部署文字风格冒险网页小游戏
  • 【计算机视觉与深度学习实战】05计算机视觉与深度学习在蚊子检测中的应用综述与假设
  • wait / notify、单例模式
  • TDengine `count_window` 指定列计数功能用户手册
  • 密码管理中随机数安全修复方案
  • 【金融数据分析】用Python对金融产品价格进行时间序列分解
  • JVM 面试精选 20 题
  • MyCAT完整实验报告
  • 音频分类模型笔记
  • 集成电路学习:什么是Face Detection人脸检测
  • CentOS 7.9 部署 filebrowser 文件管理系统
  • 动态规划:入门思考篇
  • 【完整源码+数据集+部署教程】海洋垃圾与生物识别系统源码和数据集:改进yolo11-RVB
  • 第一阶段C#基础-15:面向对象梳理
  • nsfp-
  • 《Unity Shader入门精要》学习笔记二
  • 多数据源 Demo
  • python 数据拟合(线性拟合、多项式回归)
  • WPF 打印报告图片大小的自适应(含完整示例与详解)
  • quic协议与应用开发
  • 实战架构思考及实战问题:Docker+‌Jenkins 自动化部署
  • [Oracle数据库] Oracle 进阶应用
  • 基于 ONNX Runtime 的 YOLOv8 高性能 C++ 推理实现
  • 网络间的通用语言TCP/IP-网络中的通用规则2
  • CMakeLists.txt 学习笔记
  • Java中的128陷阱:深入解析Integer缓存机制及应对策略
  • 深度解析阿里巴巴国际站商品详情 API:从接口调用到数据结构化处理
  • 8.18决策树
  • Unity引擎播放HLS自适应码率流媒体视频
  • 代码随想录算法训练营四十五天|图论part03