首页 > 编程笔记 > Go语言笔记 阅读:24

Go语言协程实现并发编程(附带实例)

Go 语言之所以被称为 Go 语言,源于 goroutine 这个关键词。

goroutine 在 Go 语言中指的是协程,开启一个协程的语法是 go func()。编译器将“go”这个关键字编译为“创建协程结构体g”,每个协程对应一个结构体 g,g 用于存储协程的执行堆栈、状态和任务函数。

协程与并发编程有关,所谓的并发编程指的是同时有多个函数通过特定的手段对协程进行控制。并发编程是 Go 语言的一大特色,在 Go 语言中开启一个协程,仅需一个关键字 go!

Go 语言的并发特性是基于底层的协程实现的。并发编程的关键点在于控制并发和解决共享资源的冲突。

在笔者看来,Go 的并发编程就是先创建协程,然后通过各种手段对其进行控制。虽然在 Go 语言中有许多复杂的并发写法,但其中大部分并不常用,即使在源码中,也很少使用复杂的模式,所以只需掌握常用的手段即可。

并发和并行

并发(concurrency)和并行(parallelism)是一组容易混淆的概念。这里用在银行排队办理业务举例,如果银行打开多个办理窗口,多个人同时办业务,就是并行;如果只打开了一个办理窗口,多个人同时办业务,就是并发。

从计算机的使用角度来讲,并发是指多个任务在同一个 CPU 上按细分的时间片轮流交替执行。这些任务在逻辑上是同时执行的,但对于这个 CPU 来说,这些任务仍然是按细粒度串行执行的。

区别于并发,并行是将多个任务分配到不同的 CPU 上执行,是真正地同时执行。

因为线程已经是 CPU 调度的最小单元了,且一个 CPU 一次只能处理一个线程,因此从微观角度来看,并发任意时刻都只有一个程序在运行。但是从宏观角度来看,这些程序又是同时在那里执行的。

多线程程序在单核 CPU 上运行,这是并发;多线程程序在多核 CPU 上运行(真正的同时运行),这是并行。

并发带来的好处

我们常说具有“并发”能力,其实指的是有处理多个任务的能力。下图左边是顺序执行的逻辑,右边是并发执行的逻辑:


图 1 顺序执行与并发执行的示例代码

图 1 所示程序的最终输出说明如下:
请注意,在并发执行的代码中,Job1() 与 Job2() 的执行顺序是随机的,这与协程的抢占机制有关,谁先抢着谁就执行。

Go语言协程的具体实现

用 Go语言编写并发代码的方式很简单,在需要执行并发的语句前添加关键字 go 即可。当然,要想用好协程,还需要掌握很多细节。

首先,让我们来看一下协程版的“hello world”,示例代码如下:
func helloGoroutine(i int)  {
    fmt.Println("hello goroutine", i)
}
 
func main() {
    for i := 0; i < 5; i++ {
        go helloGoroutine(i)
    }
    time.Sleep(time.Millisecond)
}
输出结果为:

hello goroutine 2
hello goroutine 0
hello goroutine 1
hello goroutine 3
hello goroutine 4

在上述示例代码中,for 循环内调用了 5 次 helloGoroutine() 函数。与单线程执行相比,这里最大的变化是在 helloGoroutine() 函数前添加了关键字 go,这意味开启了协程,用并发的方式执行了 helloGoroutine() 函数 5 次。

请注意,这里的执行顺序不是我们想象的那样按照 0~4 的顺序执行。实际上每次的输出都是无序的。这是因为这 5 个协程是并发执行的,每当协程开启时,主程序并不会等待某个协程的代码执行完以后再去执行下一个语句。协程之间会抢占资源,谁先抢到就执行谁,协程调度器则用于调度协程的执行。

Go语言协程的执行顺序

在前面的示例代码中,如果在代码末尾不加 time.Sleep 语句,则程序运行后没有任何输出,原因是什么呢?

这是因为 Go 的主栈不是 main.main,而是 Go runtime。main.main 实际上也是一个协程(main 协程)。Go runtime 开启多个线程后,会将所有的子栈分发到这些线程上。当程序执行时,内核态线程创建第一个协程(称作 G0)。G0 只负责调度,待内核态线程与 main.main 这个协程绑定后程序开始运行。

调用函数 helloGoroutine() 时,在函数前面添加了关键字 go,这是 Go 语言中并发的写法。上述示例代码中开启了 5 个协程分别执行 helloGoroutine() 函数,这 5 个协程与 main 协程是并行的关系,它们也会与真正的内核态线程绑定运行,内核态线程可能是新创建的,也可能是从空闲队列中获取的。

根据协程的特性,main.main 中的 for() 语句执行完以后,主协程并不会等待创建的 G1~G5 执行完,只有在执行 time.Sleep(time.Millisecond) 语句时它才会等待。可见,如果没有最后的 time.Sleep 语句,程序将在执行完 for 语句后直接退出,且不会有任何输出。

前面的示例代码遵循以下执行顺序:
下图是这段示例代码的执行示意图:


图 2 协程的执行示意图

有多种方法可以确保在协程执行完之前 main 函数不退出,具体如下:

相关文章