GO 之 Goroutine 学习

Goroutine

Goroutine 算是 Go 语言最大的亮点了。在 Go 里面,每一个并发执行的活动的都称为 Goroutine。当开始接触 Goroutine 的时候,可以将它类比为传统的线程,但你绝不能把它当作线程,两者是有本质的区别的。

当你开始写 Go 程序的时候,必须有一个主函数 main,这个是由主 Goroutine 来调用。新的 goroutine 由关键字 go 来建立。并且 go 关键字支持匿名函数。像这样:

go func() {
    //  执行  
    fmt.Println("this is a goroutine")
}()

就是这么简单,很轻松的创建一个 Goroutine,这样就可以开启你的并发之旅了。

Go 的并发模型

还记得上面讲了的吗?Goroutine 和线程是有本质区别的。那么 Goroutine 到底是什么呢?Goroutine 既不是 OS 线程,也不是绿色线程(用户空间的线程,有运行的语言管理)。它们是更加抽象且轻量级的,称之为协程。协程是非抢占式的且不能被中断的 Goroutine (函数,闭包或者方法),但是协程有多个点,可以暂停或者重新进入。

Goroutine 是由一个名为 M:N 的调度器实现的,他是将 M 个绿色线程映射到 N 个 OS 线程,然后再将 Goroutine 安排到绿色线程。当 Gouroutine 的数量超过了可用的绿色线程的时候,调度程序将处理分布在线程上的 Gotoutine,并且确保这些 Goroutine 被阻塞的时候,其他的 Goroutine 可以运行。这就是 Goroutine 的并发模型。

Fork-Join 模型

Go 语言还遵循这个一个称为 Fork-Join 的模型。fork 指的是在程序中的任意一点,他可以将执行的子分支与自己的父节点同时运行。join 指的是在将来的某个时候,这些分支会合并在一起。下面的示意图:fork-join 模型 图片来自网络

下面开看一个简单的例子:

func main() {
    go func() {
        fmt.Println("hello")
    }()

    // do something
    fmt.Println("world")
}

本例中,使用了一个 Goroutine 开启了一个协程运行,主函数则运行其余部分。在这个例子中,其实是没有 Join 点的,因为你不知道这个协程将在哪个时间点退出,这是不确定的。单单就这个例子而言,我们是不确定是否会打印出 “hello world” 的。Goroutine 虽然被创建了,并且在运行时执行,但是实际上很可能在 main goroutine 退出之后执行,你将看不到任何输出。

ps:如果你看到了也不必有任何疑问,因为这是不确定的。

你可以增加一个竞争条件,例如在 main Goroutine 加入 time.Sleep,但这并不是创建 Join 点,因为你还是并不能保证 goroutine 一定执行,你还是不确定退出时间。Join 点是保证程序的正确性和消除竞争条件的关键。

所以想要创建一个 Join 点,你必须同步 main Goroutine 和 子 goroutine。这可以通过多种方式来实现,比如 Sync 包。下一篇就主要介绍一下这个包。

下面来看一个有意思的现象,关于 Join 点的问题。注意这里将会使用 Sync 包。来看一下下面的两段代码:

无 Join 点

    str := "hello"
    go func() {
        defer wg.Done()
        str = "world"
    }()
    fmt.Println(str)

有 Join 点

var wg sync.WaitGroup
    str := "hello"
    wg.Add(1)
    go func() {
        defer wg.Done()
        str = "world"
    }()
    wg.Wait() // 这里就是 join 点
    fmt.Println(str)

当你运行第一段的代码的时候,结果在大多数可能下会是 hello。当然并不能说是一定,因为这是不确定的。除非你加入竞争条件(其实也是不确定的,只不过加大了概率)或者像第二段代码一样加入了 Join 点。使用 Sync 包之后,引入 Join 点的情况下,可以很确定结果是 world。因为他保证了程序的正确性,以第二段代码为列的话,可以认为我们确定了 str 变量确确实实被重新赋予了新的值。这是一个确定的事件,所以结果也是确定的。

从上面的例子我们还可以看出来一点,就是 goroutine 在他们所创建的相同的地址空间运行(main goroutine 和 子 goroutine 都同时使用了 str),再来看一段代码:

var wg sync.WaitGroup
    str := "hello"
    for _, str = range []string{"H", "J", "K"} {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(str)
        }()
    }
    wg.Wait()

请思考一下结果是什么?H, J, K 或者是 H, K, J ?? 很多人可能会认为结果会是无序打印三个值。可是结果并不是你想像的那样,结果大多数情况下会是打印三个 K。虽然上面已经说了 goroutine 运行在相同的地址空间,但是为什么并不会无序的打印出三个值呢?来看一下这整个过程,goroutine 内打印的是 str 的字符串引用,也就是说整个迭代过程中 str 一直是在变的,当我们创建 goroutine 的时候,因为不确定 goroutine 的在什么时间运行,很可能当 goroutine 运行的时候迭代已经结束了。所以这个例子的代码大概率将会看到三个 k 的输出。可以加大数组的长度加以验证 goroutine 的运行的时间是不确定性的证明。那么这里还有一个问题 。

既然迭代结束了, str 的会不会被回收呢?当然从结果来看,GO 语言还是保留了 str 的引用。

那么该如何解决这个问题呢?既然他每次只能引用到迭代处的值。那么就只需要把迭代的值传入就可以了。

var wg sync.WaitGroup
    str := "hello"
    for _, str = range []string{"H", "J", "K"} {
        wg.Add(1)
        go func(s  string) {
            defer wg.Done()
            fmt.Println(s)
        }(str)
    }
    wg.Wait()

阅读中有任何建议和疑问欢迎反馈,有不恰当的地方欢迎修正。共同进步

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!