Skip to content

协程初探

Go 语言以简单、高效地编写高并发程序而闻名,这离不开 Go 语言原语中协程(Goroutine)的设计,也正对应了多核处理器的时代需求。
与传统多线程开发不同,Go 语言通过更轻量级的协程让开发更便捷,同时避免了许多传统多线程开发需要面对的困难。
因此,Go 语言在设计和使用方式上都与传统的语言有所不同,必须理解其基本的设计哲学和使用方式才能正确地使用

线程与协程

在 Go 语言中,协程被认为是轻量级的线程。和线程不同的是,操作系统内核感知不到协程的存在,协程的管理依赖 Go 语言运行时自身提供的调度器。
同时,Go 语言中的协程师从属于某一个线程的。为什么 Go 语言需要在线程的基础上抽象出协程的概念,而不是直接操作线程?
要回答这个问题,就需要深入地理解线程与协程的区别。

调度方式:

协程是用户态的。协程的管理依赖 Go 语言运行时的调度器。
同时,Go 语言中的协程是从属于某一个线程的,协程与线程的对应关系为 M:N,即多对多,如图所示。
Go 语言调度器可以将多个协程调度到一个线程中,一个协程也可能切换到多个线程中执行。

线程与协程的对应关系

上下文切换的速度:

协程的速度要快于线程,其原因在于协程切换不用经过操作系统用户态与内核态的切换,并且 Go 语言中的协程切换只需要保留极少的状态和寄存器变量值(SP/BP/PC),而线程切换会保留额外的寄存器变量值(比如浮点寄存器)。
上下文切换的速度受到诸多因素的影响,这里列出一些值得参考的量化指标: 线程切换的速度大约为 1~2 微秒,Go 语言中协程切换的速度比它快数倍,为 0.2 微秒左右

调度策略:

线程的调度在大部分时间是抢占式的,操作系统调度器为了均衡每个线程的执行周期,会定时发出中断信号强制执行线程上下文切换。
而 Go 语言中的协程在一般情况下是协作式调度的,当一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程。
这意味着协程可以更好地规定时间内完成自己的工作,而不会轻易被抢占。当一个协程运行了过长时间时,Go 语言调度器才会强制抢占其执行

栈的大小:

线程的栈大小一般是在创建时指定的,为了避免出现栈溢出(Stack Overflow),默认的栈会相对较大(例如 2MB),这意味着每创建 1000 个线程就需要消耗 2GB 的虚拟内存,大大限制了线程创建的数量(64 位的虚拟内存地址空间已经让这种限制变得不太严重)。
而 Go 语言中的协程栈默认为 2KB,在实践中,经常会看到成千上万的协程存在

同时,线程的栈在运行时不能更改,但是 Go 语言中的协程展在 Go 运行时的帮助下会动态检测栈的大小,并动态地进行扩容。
因为,在实践中,可以将协程看作轻量的资源

协程的概念

协程并不是 Go 发明的概念,根据维基百科的记录,协程术语 coroutine 最早出现在 1963 年发表的论文中,论文作者为美国计算机科学家 Melvin E. Conway(马尔文 爱德华 康威),也许你听说过著名的康威定律: "设计系统的架构受制于产生这些设计的组织的沟通结构。",即系统设计本质上反映了企业的组织机构,系统各个模块间的接口也反映了企业各部门之间的信息流动和合作方式。

支持协程的编程语言有很多,比如 Python、Perl 等,但没有哪个语言能像 Go 一样把协程支持得如此优雅,Go 在语言层面直接提供对协程的支持称为 goroutine

基本概念

(1) 进程
进程是应用程序的启动实例,每个进程都有独立的内存空间,不同进程通过进程间的通信方式来通信

(2) 线程
线程从属于进程,每个进程至少一个线程,线程是 CPU 调度的基本单位,多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信

(3) 协程
协程可理解为一种轻量级线程,与线程对比,协程不受操作系统调度,协程调度由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。
Go 应用程序的协程调度器由 runtime 包提供,用户使用 go 关键字即可创建协程,这也就是在语言层面直接直接协程的含义

协程的优势

我们知道,在高并发应用中频繁创建线程会造成不必要的开销,所以有了线程池技术。
在线程池中预先保存一定数量的线程,新任务将不再以创建线程的方式去执行,而是将任务发布到任务队列中,线程池中的线程不断地从任务队列中取出任务并执行,这样可以有效地减少线程的创建和销毁所带来的开销。

下面展示了一个典型的线程池:

典型的线程池

为了方便下面的叙述,我们把任务队列中的每一个任务称作 G,而 G 往往代表一个函数。
线程池中的 worker 线程不断地从任务队列中取出任务并执行,而 worker 线程则交给操作系统进行调度

如果 worker 线程执行的 G 任务中发生系统调用,则操作系统会将该线程置为阻塞状态,也就意味着该线程在怠工,由于消费任务队列中的 worker 线程变少了,所以线程池消费任务队列的能力变弱了

如果任务队列中的大部分任务都进行系统调用,则会让这种状态恶化,大部分 worker 线程进入阻塞状态,从而任务队列中的任务产生堆积

解决这个问题的一个思路就是重新审视线程池中线程的数量,增加线程池中的线程数量可以在一定程度上提高消费能力,但随着线程数量增多,过多线程争抢 CPU 资源,消费能力会有上限,甚至出现消费能力下降的现象,如下图所示:

线程数量和消费能力的关系

过多的线程会导致上下文切换的开销变大,而工作在用户态的协程则能大大减少上下文切换的开销。
协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,从而有效地避免了线程的频繁切换,达到了使用少量线程实现高并发的效果。

多个协程分享操作系统分给线程的时间片,从而达到充分利用 CPU 算力的目的,协程调度器则决定了协程运行的顺序。

简单协程入门

我们通过一个程序来检查一些网站是否可以访问,构建一个 links 作为 url 列表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "fmt"
    "net/http"
)

func checkLink(link string) {
    _, err := http.Get(link)
    if err != nil {
        fmt.Println(link, "might be down!")
        return
    }
    fmt.Println(link, "is up!")
}

func main() {
    links := []string{
        "http://www.baidu.com",
        "http://www.jd.com",
        "https://www.taobao.com",
        "https://www.163.com",
        "https://www.sohu.com",
    }
    for _, link := range links {
        checkLink(link)
    }
}

输出结果:

1
2
3
4
5
http://www.baidu.com is up!
http://www.jd.com is up!
https://www.taobao.com is up!
https://www.163.com is up!
https://www.sohu.com is up!

当前程序在正常情况下能够很好地运行,但是其有严重的性能问题。该程序为线性程序,必须等待前一个请求执行完毕,后一个请求才能继续执行。
如果请求的网站出现了问题,则可能需要等待很长时间。这种情况在网络访问、磁盘文件访问时经常会遇到

主协程与子协程

为了能够加快程序的执行,需要将访问修改为并发执行。这样,我们不仅能使用到多核的资源,Go 语言的调度器也能够在当前协程 I/O 堵塞时,切换到其他协程执行。
在 Go 语言中,使用协程非常方便,只需在特定的函数前加上关键字 go 即可。
该关键字会被 Go 语言的编译器识别,并在运行时创建一个新的协程。新创建的协程会独立运行,不需要返回值,也不会堵塞创建它的协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "fmt"
    "net/http"
)

func checkLink(link string) {
    _, err := http.Get(link)
    if err != nil {
        fmt.Println(link, "might be down!")
        return
    }
    fmt.Println(link, "is up!")
}

func main() {
    links := []string{
        "http://www.baidu.com",
        "http://www.jd.com",
        "https://www.taobao.com",
        "https://www.163.com",
        "https://www.sohu.com",
    }
    for _, link := range links {
        go checkLink(link)
    }
}

当执行此程序时,我们会惊讶地发现程序直接退出了,原因在于协程(Goroutine)分为了主协程(main Goroutine)与子协程(child Goroutine)

main 函数是一个特殊的协程,当主协程退出时,程序直接退出,这是主协程宇其他协程的显著区别。
如果其他协程还未执行完成,主协程就直接退出了,那么此时不会有任何输出

因此,这不是程序的 bug,而是 Go 语言中协程的特点。明白这一点后,可以设法对程序进行调整,例如在 main 程序后休眠 2s

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
    links := []string{
        "http://www.baidu.com",
        "http://www.jd.com",
        "https://www.taobao.com",
        "https://www.163.com",
        "https://www.sohu.com",
    }
    for _, link := range links {
        go checkLink(link)
    }
    time.Sleep(2 * time.Second)
}

再次执行程序,即可看到理想的运行结果,现在的结果和 url 列表的顺序是不一致的,甚至每一次执行程序,输出的结果都可能不同

这一现象是并发的特性导致的,我们无法保证哪一个协程先执行,哪一个先结束。

GMP 调度模型

Go 语言中经典的 GMP 的概念模型生动地概括了线程与协程的关系: Go 进程中的众多协程其实依托于线程,借助操作系统将线程调度到 CPU 执行,从而最终执行协程。
在 GMP 模型中,
G 代表的是 Go 语言中的协程(Goroutine),
M 代表的是实际的线程,
而 P 代表的是 Go 逻辑处理器(Process),
Go 语言为了方便协程调度与缓存,抽象出了逻辑处理器。G、M、P 之间的对应关系如下图所示:

GMP模型

在任一时刻,一个 P 可能在其本地包含多个 G,同时,一个 P 在任一时刻只能绑定一个 M。
途中没有涵盖的信息是: 一个 G 并不是固定绑定同一个 P 的,有很多情况(例如 P 在运行时被销毁)会导致一个 P 中的 G 转移到其他的 P 中。
同样的,一个 P 只能对应一个 M,但是具体对应的是哪一个 M 也是不固定的,一个 M 可能在某些时候转移到其他的 P 中执行

调度策略

Go 协程调度器也是不断演进的,使得 Go 支持越来越多的调度策略,以便在不同的应用场景下都能产生优异的并发效果

队列轮转

每个处理器 P 维护着一个协程 G 的队列,处理 P 依次将协程 G 调度到 M 中执行

协程 G 执行结束后,处理器 P 会再次调度一个协程 G 到 M 中执行(协程进入系统调用和协程长时间运行的场景略为复杂)

同时,每个 P 会周期性地查看全局队列中是否有 G 待运行并将其调度到 M 中执行,全局队列中的 G 主要来自从系统调用中恢复的 G。
之所以 P 会周期性地查看全局队列,也是为了防止全局队列中的 G 长时间得不到调度机会而被 "饿死"

抢占式调度

所谓强调式调度,是指避免某个协程长时间执行,而阻碍其他协程被调度的机制

调度器会监控每个协程的执行时间,一旦执行时间过长且有其他协程在等待时,会把协程暂停,转而调度等待的协程,以达到类似于时间片轮转的效果。

在 Go 1.14 之前,Go 协程调度器抢占式调度机制是有一定局限的,在该设计中,在函数调用间隙检查协程是否可被抢占,如果协程没有函数调用,则会无限期地占用执行权,如以下代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    runtime.GOMAXPROCS(1)
    go func() {
        for {
            // 无函数调用的无限循环
        }
    }()
    time.Sleep(1 * time.Second) // 系统调用,出让执行权给上面的协程
    println("Done")
}

上面的代码在 Go 1.14 之前会陷入协程的无限循环中,协程永远无法被抢占,导致主协程无法继续执行。
直到在 Go 1.14 中,调度器引入了基于信号的抢占机制,这个问题才得到了解决