你好,我是郝林,今天我们继续分享go语句执行规则的内容。

在上一篇文章中,我们讲到了goroutine在操作系统的并发编程体系,以及在Go语言并发编程模型中的地位和作用等一系列内容,今天我们继续来聊一聊这个话题。

知识扩展

问题1:怎样才能让主goroutine等待其他goroutine?

我刚才说过,一旦主goroutine中的代码执行完毕,当前的Go程序就会结束运行,无论其他的goroutine是否已经在运行了。那么,怎样才能做到等其他的goroutine运行完毕之后,再让主goroutine结束运行呢?

其实有很多办法可以做到这一点。其中,最简单粗暴的办法就是让主goroutine“小睡”一会儿。

for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Millisecond * 500)

for语句的后边,我调用了time包的Sleep函数,并把time.Millisecond * 500的结果作为参数值传给了它。time.Sleep函数的功能就是让当前的goroutine(在这里就是主goroutine)暂停运行一段时间,直到到达指定的恢复运行时间。

我们可以把一个相对的时间传给该函数,就像我在这里传入的“500毫秒”那样。time.Sleep函数会在被调用时用当前的绝对时间,再加上相对时间计算出在未来的恢复运行时间。显然,一旦到达恢复运行时间,当前的goroutine就会从“睡眠”中醒来,并开始继续执行后边的代码。

这个办法是可行的,只要“睡眠”的时间不要太短就好。不过,问题恰恰就在这里,我们让主goroutine“睡眠”多长时间才是合适的呢?如果“睡眠”太短,则很可能不足以让其他的goroutine运行完毕,而若“睡眠”太长则纯属浪费时间,这个时间就太难把握了。

你可能会想到,既然不容易预估时间,那我们就让其他的goroutine在运行完毕的时候告诉我们好了。这个思路很好,但怎么做呢?

你是否想到了通道呢?我们先创建一个通道,它的长度应该与我们手动启用的goroutine的数量一致。在每个手动启用的goroutine即将运行完毕的时候,我们都要向该通道发送一个值。

注意,这些发送表达式应该被放在它们的go函数体的最后面。对应的,我们还需要在main函数的最后从通道接收元素值,接收的次数也应该与手动启用的goroutine的数量保持一致。关于这些你可以到demo39.go文件中,去查看具体的写法。

其中有一个细节你需要注意。我在声明通道sign的时候是以chan struct{}作为其类型的。其中的类型字面量struct{}有些类似于空接口类型interface{},它代表了既不包含任何字段也不拥有任何方法的空结构体类型。

注意,struct{}类型值的表示法只有一个,即:struct{}{}。并且,它占用的内存空间是0字节。确切地说,这个值在整个Go程序中永远都只会存在一份。虽然我们可以无数次地使用这个值字面量,但是用到的却都是同一个值。

当我们仅仅把通道当作传递某种简单信号的介质的时候,用struct{}作为其元素类型是再好不过的了。顺便说一句,我在讲“结构体及其方法的使用法门”的时候留过一道与此相关的思考题,你可以返回去看一看。

再说回当下的问题,有没有比使用通道更好的方法?如果你知道标准库中的代码包sync的话,那么可能会想到sync.WaitGroup类型。没错,这是一个更好的答案。不过具体的使用方式我在后边讲sync包的时候再说。

问题2:怎样让我们启用的多个goroutine按照既定的顺序运行?

在很多时候,当我沿着上面的主问题以及第一个扩展问题一路问下来的时候,应聘者往往会被这第二个扩展问题难住。

所以基于上一篇主问题中的代码,怎样做到让从09这几个整数按照自然数的顺序打印出来?你可能会说,我不用goroutine不就可以了嘛。没错,这样是可以,但是如果我不考虑这样做呢。你应该怎么解决这个问题?

当然了,众多应聘者回答的其他答案也是五花八门的,有的可行,有的不可行,还有的把原来的代码改得面目全非。我下面就来说说我的思路,以及心目中的答案吧。这个答案并不一定是最佳的,也许你在看完之后还可以想到更优的答案。

首先,我们需要稍微改造一下for语句中的那个go函数,要让它接受一个int类型的参数,并在调用它的时候把变量i的值传进去。为了不改动这个go函数中的其他代码,我们可以把它的这个参数也命名为i

for i := 0; i < 10; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}

只有这样,Go语言才能保证每个goroutine都可以拿到一个唯一的整数。其原因与go函数的执行时机有关。

我在前面已经讲过了。在go语句被执行时,我们传给go函数的参数i会先被求值,如此就得到了当次迭代的序号。之后,无论go函数会在什么时候执行,这个参数值都不会变。也就是说,go函数中调用的fmt.Println函数打印的一定会是那个当次迭代的序号。

然后,我们在着手改造for语句中的go函数。

for i := uint32(0); i < 10; i++ {
go func(i uint32) {
fn := func() {
fmt.Println(i)
}
trigger(i, fn)
}(i)
}

我在go函数中先声明了一个匿名的函数,并把它赋给了变量fn。这个匿名函数做的事情很简单,只是调用fmt.Println函数以打印go函数的参数i的值。

在这之后,我调用了一个名叫trigger的函数,并把go函数的参数i和刚刚声明的变量fn作为参数传给了它。注意,for语句声明的局部变量igo函数的参数i的类型都变了,都由int变为了uint32。至于为什么,我一会儿再说。

再来说trigger函数。该函数接受两个参数,一个是uint32类型的参数i, 另一个是func()类型的参数fn。你应该记得,func()代表的是既无参数声明也无结果声明的函数类型。

trigger := func(i uint32, fn func()) {
for {
if n := atomic.LoadUint32(&count); n == i {
fn()
atomic.AddUint32(&count, 1)
break
}
time.Sleep(time.Nanosecond)
}
}

trigger函数会不断地获取一个名叫count的变量的值,并判断该值是否与参数i的值相同。如果相同,那么就立即调用fn代表的函数,然后把count变量的值加1,最后显式地退出当前的循环。否则,我们就先让当前的goroutine“睡眠”一个纳秒再进入下一个迭代。

注意,我操作变量count的时候使用的都是原子操作。这是由于trigger函数会被多个goroutine并发地调用,所以它用到的非本地变量count,就被多个用户级线程共用了。因此,对它的操作就产生了竞态条件(race condition),破坏了程序的并发安全性。

所以,我们总是应该对这样的操作加以保护,在sync/atomic包中声明了很多用于原子操作的函数。

另外,由于我选用的原子操作函数对被操作的数值的类型有约束,所以我才对count以及相关的变量和参数的类型进行了统一的变更(由int变为了uint32)。

纵观count变量、trigger函数以及改造后的for语句和go函数,我要做的是,让count变量成为一个信号,它的值总是下一个可以调用打印函数的go函数的序号。

这个序号其实就是启用goroutine时,那个当次迭代的序号。也正因为如此,go函数实际的执行顺序才会与go语句的执行顺序完全一致。此外,这里的trigger函数实现了一种自旋(spinning)。除非发现条件已满足,否则它会不断地进行检查。

最后要说的是,因为我依然想让主goroutine最后一个运行完毕,所以还需要加一行代码。不过既然有了trigger函数,我就没有再使用通道。

trigger(10, func(){})

调用trigger函数完全可以达到相同的效果。由于当所有我手动启用的goroutine都运行完毕之后,count的值一定会是10,所以我就把10作为了第一个参数值。又由于我并不想打印这个10,所以我把一个什么都不做的函数作为了第二个参数值。

总之,通过上述的改造,我使得异步发起的go函数得到了同步地(或者说按照既定顺序地)执行,你也可以动手自己试一试,感受一下。

总结

在本篇文章中,我们接着上一篇文章的主问题,讨论了当我们想让运行结果更加可控的时候,应该怎样去做。

主goroutine的运行若过早结束,那么我们的并发程序的功能就很可能无法全部完成。所以我们往往需要通过一些手段去进行干涉,比如调用time.Sleep函数或者使用通道。我们在后面的文章中还会讨论更高级的手段。

另外,go函数的实际执行顺序往往与其所属的go语句的执行顺序(或者说goroutine的启用顺序)不同,而且默认情况下的执行顺序是不可预知的。那怎样才能让这两个顺序一致呢?其实复杂的实现方式有不少,但是可能会把原来的代码改得面目全非。我在这里提供了一种比较简单、清晰的改造方案,供你参考。

总之,我希望通过上述基础知识以及三个连贯的问题帮你串起一条主线。这应该会让你更快地深入理解goroutine及其背后的并发编程模型,从而更加游刃有余地使用go语句。

思考题

1.runtime包中提供了哪些与模型三要素G、P和M相关的函数?(模型三要素内容在上一篇)

戳此查看Go语言专栏文章配套详细代码。

Go语言核心36讲19的更多相关文章

  1. Go语言核心36讲(Go语言实战与应用三)--学习笔记

    25 | 更多的测试手法 在本篇文章,我会继续为你讲解更多更高级的测试方法.这会涉及testing包中更多的 API.go test命令支持的,更多标记更加复杂的测试结果,以及测试覆盖度分析等等. 前 ...

  2. Go语言核心36讲(导读)--学习笔记

    目录 开篇词 | 跟着学,你也能成为Go语言高手 导读 | 写给0基础入门的Go语言学习者 导读 | 学习专栏的正确姿势 开篇词 | 跟着学,你也能成为Go语言高手 Go 语言是由 Google 出品 ...

  3. Go语言核心36讲(Go语言进阶技术八)--学习笔记

    14 | 接口类型的合理运用 前导内容:正确使用接口的基础知识 在 Go 语言的语境中,当我们在谈论"接口"的时候,一定指的是接口类型.因为接口类型与其他数据类型不同,它是没法被实 ...

  4. Go语言核心36讲(Go语言进阶技术十三)--学习笔记

    19 | 错误处理(上) 提到 Go 语言中的错误处理,我们其实已经在前面接触过几次了. 比如,我们声明过error类型的变量err,也调用过errors包中的New函数. 我们说过error类型其实 ...

  5. Go语言核心36讲(Go语言进阶技术十六)--学习笔记

    22 | panic函数.recover函数以及defer语句(下) 我在前一篇文章提到过这样一个说法,panic 之中可以包含一个值,用于简要解释引发此 panic 的原因. 如果一个 panic ...

  6. Go语言核心36讲(Go语言实战与应用一)--学习笔记

    23 | 测试的基本规则和流程 (上) 在接下来的日子里,我将带你去学习在 Go 语言编程进阶的道路上,必须掌握的附加知识,比如:Go 程序测试.程序监测,以及 Go 语言标准库中各种常用代码包的正确 ...

  7. Go语言核心36讲(Go语言实战与应用四)--学习笔记

    26 | sync.Mutex与sync.RWMutex 从本篇文章开始,我们将一起探讨 Go 语言自带标准库中一些比较核心的代码包.这会涉及这些代码包的标准用法.使用禁忌.背后原理以及周边的知识. ...

  8. Go语言核心36讲(Go语言实战与应用十四)--学习笔记

    36 | unicode与字符编码 在开始今天的内容之前,我先来做一个简单的总结. Go 语言经典知识总结 在数据类型方面有: 基于底层数组的切片: 用来传递数据的通道: 作为一等类型的函数: 可实现 ...

  9. Go语言核心36讲(Go语言实战与应用十八)--学习笔记

    40 | io包中的接口和工具 (上) 我们在前几篇文章中,主要讨论了strings.Builder.strings.Reader和bytes.Buffer这三个数据类型. 知识回顾 还记得吗?当时我 ...

  10. Go语言核心36讲(Go语言实战与应用二十二)--学习笔记

    44 | 使用os包中的API (上) 我们今天要讲的是os代码包中的 API.这个代码包可以让我们拥有操控计算机操作系统的能力. 前导内容:os 包中的 API 这个代码包提供的都是平台不相关的 A ...

随机推荐

  1. 字节微服务HTTP框架Hertz使用与源码分析|拥抱开源

    一.前言 Hertz[həːts] 是一个 Golang 微服务 HTTP 框架,在设计之初参考了其他开源框架 fasthttp.gin.echo 的优势, 并结合字节跳动内部的需求,使其具有高易用性 ...

  2. KingbaseES V8R6备份恢复案例之--删除test数据库后sys_backup.sh备份

    案例说明: KingbaseES V8R6通过sys_backup.sh执行物理备份,默认sys_backup.sh执行备份初始化时,需要连接test数据库进行身份的认证:在一些生产环境为了安全需求, ...

  3. mysql8.0及以上修改Root密码

    ALTER user 'root'@'localhost' IDENTIFIED BY 'Cliu123#' //1.不需要flush privileges来刷新权限. //2.密码要包含大写字母,小 ...

  4. eclipse 统一设置编码_项目工程统一设置成utf8编码_eclipse代码规范

    在做项目的时候文件有的时候编码不同一 经常出现乱码,eclipse统一设置编码 可以解决项目编码混乱的问题, 设置eclipse java,jsp,css,js文件编码的方法如下: 1.在工具栏中点击 ...

  5. 选择排序C语言版本

    算法思路,从头至尾扫描序列. 首先从第二个到最后,找出最小的一个元素,和第一个元素交换: 接着从第三个到最后,后面找出最小的一个元素,和第二个元素交换: 依次类推最终得到一个有序序列. void Se ...

  6. 使用ESP8266nodeMCU 向微信推送模板数据

    使用HTTPS协议向微信公众号推送消息,(使用ESP8266的低成本实现) 前几天被朋友问到这个东西的实现方式,花了一下午时间研究一下,特此记录.没有排版比较乱. 一丶前往微信公众平台注册微信微信公众 ...

  7. Django 使用cmd 创建工程

    一.Django 安装 1 通过pip安装 Django 如果你是通过升级的方式安装Django,那么你需要先卸载旧的版本. Django 提供3种发行版本,推荐使用官方的稳定版本: 你的操作系统提供 ...

  8. 9. Ceph 基础篇 - Crush Maps

    文章转载自:https://mp.weixin.qq.com/s?__biz=MzI1MDgwNzQ1MQ==&mid=2247485302&idx=1&sn=00a3a204 ...

  9. filebeat直接给es传输日志,自定义索引名

    ElasticStack从2019年1月29日的6.6.0版本的开始,引入了索引生命周期管理的功能,新版本的Filebeat则默认的配置开启了ILM,导致索引的命名规则被ILM策略控制. 加上这个配置 ...

  10. 解决RHEL7.3系统找不到yum命令,无法使用YUM源的问题

    前言 RHEL的YUM源需要注册用户才能更新使用,由于CentOS和RHEL基本没有区别,并且CentOS已经被REHL收购.所以将RHEL的YUM源替换为CentOS即可. [root@NISEC- ...