Go语言协程实现并发编程(附带实例)
Go 语言之所以被称为 Go 语言,源于 goroutine 这个关键词。
goroutine 在 Go 语言中指的是协程,开启一个协程的语法是 go func()。编译器将“go”这个关键字编译为“创建协程结构体g”,每个协程对应一个结构体 g,g 用于存储协程的执行堆栈、状态和任务函数。
协程与并发编程有关,所谓的并发编程指的是同时有多个函数通过特定的手段对协程进行控制。并发编程是 Go 语言的一大特色,在 Go 语言中开启一个协程,仅需一个关键字 go!
Go 语言的并发特性是基于底层的协程实现的。并发编程的关键点在于控制并发和解决共享资源的冲突。
在笔者看来,Go 的并发编程就是先创建协程,然后通过各种手段对其进行控制。虽然在 Go 语言中有许多复杂的并发写法,但其中大部分并不常用,即使在源码中,也很少使用复杂的模式,所以只需掌握常用的手段即可。
从计算机的使用角度来讲,并发是指多个任务在同一个 CPU 上按细分的时间片轮流交替执行。这些任务在逻辑上是同时执行的,但对于这个 CPU 来说,这些任务仍然是按细粒度串行执行的。
区别于并发,并行是将多个任务分配到不同的 CPU 上执行,是真正地同时执行。
因为线程已经是 CPU 调度的最小单元了,且一个 CPU 一次只能处理一个线程,因此从微观角度来看,并发任意时刻都只有一个程序在运行。但是从宏观角度来看,这些程序又是同时在那里执行的。
多线程程序在单核 CPU 上运行,这是并发;多线程程序在多核 CPU 上运行(真正的同时运行),这是并行。

图 1 顺序执行与并发执行的示例代码
图 1 所示程序的最终输出说明如下:
请注意,在并发执行的代码中,Job1() 与 Job2() 的执行顺序是随机的,这与协程的抢占机制有关,谁先抢着谁就执行。
首先,让我们来看一下协程版的“hello world”,示例代码如下:
请注意,这里的执行顺序不是我们想象的那样按照 0~4 的顺序执行。实际上每次的输出都是无序的。这是因为这 5 个协程是并发执行的,每当协程开启时,主程序并不会等待某个协程的代码执行完以后再去执行下一个语句。协程之间会抢占资源,谁先抢到就执行谁,协程调度器则用于调度协程的执行。
这是因为 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 函数不退出,具体如下:
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,所以最终花费的时间是 3(即 1+2)秒。
- 右边在 Job 前添加了关键字 go,表示开启了一个协程。此时,函数 Job1() 和 Job2() 并发地被执行。另外,这里引入了计数器 sync.WaitGroup,它是一种控制协程的手段。这段代码最终执行的时间减少到 2 秒,实现了并发的效果。
请注意,在并发执行的代码中,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
请注意,这里的执行顺序不是我们想象的那样按照 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 语句后直接退出,且不会有任何输出。
前面的示例代码遵循以下执行顺序:
- 协程(入口函数 main 也是协程,称为主协程)按照从上到下的顺序执行当前栈中的函数;
- 当遇到关键字 go 时,Go runtime 会在主协程内创建多个子协程。此时,协程之间会抢占资源,谁抢着就是谁的,所以无法保证谁先被执行;
- 协程启动调用后会立即返回,不会等待执行结果,也不会接收返回值;
- 主协程执行完后会立即退出,不会等待其他尚未执行完的协程。
下图是这段示例代码的执行示意图:

图 2 协程的执行示意图
有多种方法可以确保在协程执行完之前 main 函数不退出,具体如下:
- 使用函数 time.Sleep 让 main 函数等待;
- 使用编排协程的 sync.WaitGroup 控制协程的等待和退出。
- 使用管道或者 select 阻塞协程;
- 使用函数 time.NewTimer 或者 time.NewTicker 控制协程。