GO语言基础篇(二十一)- 一文理解透Goroutine

894 阅读10分钟

这是我参与8月更文挑战的第 21 天,活动详情查看: 8月更文挑战

goroutine

说明:在go的1.14及以上版本,已经实现了抢占式调度,之前的版本中是非抢占式调度。下边如有不正确的地方,欢迎指正

前序-辅助理解

并发编程是Go语言比较有特色的一个地方,它对并发编程有一个原生的支持。这在通用型的语言中是比较少见的。下边通过一个例子来看一下它是如何支持并发编程的

package main

import (
    "fmt"
    "time"
)

func main() {
    for i:=0; i < 10; i++ {
        go func(i int) {//这里用的匿名函数。你也可以自己单独封装一个函数,然后通过go xxx(i int)的方式并发调用
            for  {
                fmt.Printf("Hello From goroutine %d\n", i)
            }
            }(i)
    } 
    time.Sleep(time.Millisecond)
}

可以发现其中有一个非常重要的关键字go,这个go关键字加在了函数的前边,加了这个go以后,这个函数的调用就会并发的执行,一共有10个在进行并发的执行,都在打印一句话,并带上自己的编号(为了能看出来有很多的人在打印这句话,所以就加上了一个数字编号)。一共打印了1个毫秒的时间,在这1毫秒的时间中,这10个都在并发的打印(说明:上边用的是匿名函数,你也可以写一个函数,在调用该函数的函数名前加go,是一样的。如果对go匿名函数不了解,点这里

可以看到,变量i是通过传参的方式传入到匿名函数中。虽然我们不传就可以直接用,但是,这种方式并不安全,会在后边说明为什么不安全。所以,现在通过传参的方式将i传进去

如果上边的匿名函数前边,没有加关键字go,那它就相当于循环10次调用里边的匿名函数,结果就是它会不停的打印下边这句话

Hello From goroutine 0 Hello From goroutine 0 Hello From goroutine 0 .....

因为匿名函数里边有个死循环,它一直退不出来,所以第一个循环i=0执行的时候,就一直死循环输出

加了go关键字以后,它就不是调用这个函数了,而是并发的执行这个函数。所以,其实主程序还在往下跑,然后并发的开了一个函数,函数的里边不断的打印一句话。这其实就相当于开了一个线程(暂时这么理解),但实际上并不是线程,而是协程。从现在来看,好像差不多。就相当于开了10个goroutine来不停的执行这个匿名函数

需要注意的是:我在main函数的最下边还加了这么一行代码

time.Sleep(time.Millisecond)

它就是让main函数sleep一毫秒再往下执行。如果不加这行代码,执行main函数,就会发现什么都打印不出来,直接退出了

直接退出的原因就是,main和我们里边开的10goroutine是并发执行的,那里边的goroution还没来得及打印东西,main就循环0~10结束了,然后main就退掉了。在Go里边,如果main退出了,那所有的goroutine就都被杀掉了。所以,这些goroutine还没来得及打印东西,就已经被干掉了

要想看到那些开的goroutine打印东西,就要让mian函数暂时不要退出,所以让它sleep一毫秒,然后再执行上边的程序就会看到如下结果:

Hello From goroutine 9
Hello From goroutine 9
Hello From goroutine 5
Hello From goroutine 2
Hello From goroutine 2
Hello From goroutine 2
Hello From goroutine 2
Hello From goroutine 2
Hello From goroutine 2
Hello From goroutine 2
Hello From goroutine 0
......

可以看到不同的goroutine在不停的打印东西

上边其实只是开了10个goroutine(for循环了10次),这并不稀奇。你还可以把它调成1000,让它开1000个goroutine去执行里边的函数(可以看到它正常的打印了,但是,并不是每一个goroutine都有机会在这一毫秒内被执行了)

Hello From goroutine 644
Hello From goroutine 644
Hello From goroutine 644
Hello From goroutine 89
Hello From goroutine 501
Hello From goroutine 501
Hello From goroutine 501
Hello From goroutine 501
Hello From goroutine 814
Hello From goroutine 688
Hello From goroutine 688
......

这10和1000有什么关系?如果你熟悉操作系统就会知道,开10个线程没有什么问题,开100个线程,其实也没什么大问题,但是已经差不多了。一般的操作系统,每个人开几十个上百个线程,已经了不起了,那要是1000个的话,1000个人要并发的执行一些事情,这个并不能简单的通过线程来做到。在其它的语言中,要通过异步io的方式去做,让1000个人并发的去执行

但是在go语言中,我们不用管,开10个可以,开1000个也可以,反正前边加个关键字go,它就能够并发的执行。下边就从理论上看协程是一个什么东西

goroutine的定义

  • 任何函数只需要加上go就能送给调度器运行
  • 不需要在定义时区分是否是异步函数(这个是针对python来说的,python中的协程是一个异步函数,我在下一篇文章中会说到各种语言中对协程的支持)
  • 调度器会在合适的点尽心切换。它虽然是非抢占式的(1.13及之前的版本),但是还是有一个调度器可以进行切换,这个切换的点,并不能完全的控制。这也是goroutine和线程其中的一个区别
  • 使用-race来检测数据访问冲突(后边会进行演示)

协程Coroutine

goroutine其实是一种协程,或者说它和协程是比较像的。协程叫Coroutine

协程是一个什么东西?它和线程有什么关系呢?

  • 轻量级”线程“

它的作用和线程粗看起来差不多,它都是用来并发的执行一些任务的,但是协程是轻量级,就像前边看到的,我们可以开1000个goroutine都没问题,但是线程就比较重了

  • 非抢占式多任务处理,由协程主动交出控制权

非抢占式就是说由协程主动的交出控制权。线程我们都知道,线程在任何时候都可能会被操作系统进行切换,所以线程就是抢占式多任务处理,它没有控制权,哪怕里边的一个语句执行到一半,都可能会被操作系统从中间掐掉,然后去执行其他的任务,然后操作系统后边还会回来继续执行

协程是非抢占式的,什么时候交出控制权,什么时候不想交出控制权,是协程内部决定的。正是因为非抢占式,协程才能做到轻量级。抢占式的话,就要处理最坏的情况,我去抢的时候,人家正好事情做到一半,就需要存更多的东西,上下文要存更多。非抢占式只需要处理其中几种切换的点就可以了,这样占用的资源就少一点

  • 编译器/解释器/虚拟机层面的多任务

它不是一个操作系统层面的多任务。在Go语言中,一方面可以看做是编译器层面的多任务,编译器会把go function解释成一个协程,在执行的时候,Go里边有一个调度器来调度协程。操作系统里边本身就有一个调度器,Go中它还有自己的一个调度器,来调度它自己的轻量级的协程

  • 多个协程可以在一个或者多个线程上运行

这个是由调度器来决定的。它可以说都在一个线程中运行,也可以说在多个线程中运行

非抢占式多任务

下边具体的理解一下非抢占式多任务。从上边的打印结果来看,它和抢占式没什么区别。因为一个Goroutine并不是一直在打印,会被别的Goroutine抢走。这个是因为Printf是一个IO的操作,在IO的操作里边会进行切换,因为IO操作总会有等待的过程

现在就想办法让它不要切换,所以就把程序改成下边这样

func main() {
    var a [10]int
    for i:=0; i < 10; i++ {
        go func(i int) {
            for  {
                a[i]++
            }
            }(i)
    }
    time.Sleep(time.Millisecond)
    fmt.Println(a)
}

定义了一个长度为10的数组,在函数中,不断的对每个goroutine对应的位置下做累加,sleep完成之后打印这个数组

相信聪明的你应该会知道执行的结果会是什么。上边提到了协程是非抢占式多任务,前边因为Printf涉及IO操作,所以会进行协程之间的切换。但是a[i]++只是一个普通指令,执行它的时候不会有协程之间的切换,这样就会被一个协程所抢掉。这一个协程如果不主动交出控制权的话,它就始终在这个协程里边。所以上边执行的结果就是死机,它会一直卡在那个死循环中(说明:在1.13及之前的版本是这样的,但是如果是1.4及以上的版本,是可以正常打印的,因为1.14及以上版本实现了抢占式调度

会在后边单独介绍Go的抢占式调度。暂时先按1.13及之前的版本来说

main函数自己也是一个goroutine,它执行了sleep,但是没人交出控制权,所以它永远sleep不出来。如果想在执行完a[i]++之后交出控制权,可以在a[i]++下边增加这个语句

runtime.Gosched()//手动交出控制权

但是一般情况下,很少会用到它,这里仅仅是为了演示而使用。一般都会有其它的一些机会进行切换,后边会提到

注意

在上边有提到,那个匿名函数中需要的i,可以用传递,它会自己取外边的i,下边来试一下不直接传递给匿名函数,会出现什么问题

func main() {
    var a [10]int
    for i:=0; i < 10; i++ {
        go func() {
            for  {
                a[i]++
                runtime.Gosched()
            }
        }()
    }
    time.Sleep(time.Millisecond)
    fmt.Println(a)
}

输出:
panic: runtime error: index out of range [10] with length 10

执行这段程序的执行结果是下标越界。这个不太好看出来是为什么,可以通过go run -race xxx.go的方式来检测数据访问的冲突,下边是我的执行结果

image.png

可以看到有一个WARNING: DATA RACE,7这个goroutine(随机的)从一个地址中读到了一个数据,然后main往那个地址中写入了一个数据。相信你也猜到了,那个地址其实就是变量i的地址,你可以打印i的地址证明一下。所以,其实就是main这个goroutine往变量i中写了一个数据,然后其他的goroutine往i里边读取了数据

如果我们不往匿名函数里边传那个i,其实就是前边文章分享的函数式编程的概念(了解go的函数式编程,点这里),它就直接引用了外边那个i,外边的i和里边的i是同一个i。因此,外边的i不断的累加,当外边的i跳出以后,i已经变成了10,当i=10的时候,闭包里边就引用了a[10],所以就会出错

因此我们需要让每一个goroutine把i固定下去,所以就把i传进去

本文可能并不是特别的深入,后边会梳理一篇更深入一点的内容来介绍go语言的调度器

参考

Google资深工程师深度讲解Go语言

《Go程序设计语言》—-艾伦 A. A. 多诺万

《Go语言学习笔记》—-雨痕