揭秘Go语言的潜力:你真的了解它的Goroutine机制吗?

发表时间: 2020-02-08 19:35

来源 | 后端技术指南针(ID:gh_ed1e2b37dcb6)

Go语言的巨大潜力有目共睹,今天我们来学习Go语言的Goroutine机制,这也可能是Go语言最为吸引人的特性了,理解它对于掌握Go语言大有裨益,话不多说开始吧!

通过本文你将了解到以下内容:

  • 什么是协程以及横向对比优势

  • Go语言的Goroutine机制底层原理和特点


聊聊协程

大家对于进程、线程二位明星都很熟悉,但协程就没有火了,是协程不是携程哦!

协程并不是Go语言特有的机制,相反像Lua、Ruby、Python、Kotlin、C/C++等也都有协程的支持,区别在于有的是从语言层面支持、有的通过插件类库支持。Go语言是原生语言层面支持,本文也是从Go角度去理解协程。

1.1 协程基本概念和提出者

协程英文是Coroutine译为协同程序,我们来看下维基百科对Coroutine的介绍:

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed.

Coroutines are well-suited for implementing familiar program components such as cooperative tasks, exceptions, event loops, iterators, infinite lists and pipes.

According to Donald Knuth, Melvin Conway coined the term coroutine in 1958 when he applied it to construction of an assembly program.The first published explanation of the coroutine appeared later, in 1963.

简单翻译一下:

协同程序是一种计算机程序组件,它允许暂停和恢复执行,从而可以作为通用化的非抢占式多任务处理子程序。

协同程序非常适合实现例如协作任务、异常、事件循环、迭代器、管道等熟悉的程序组件。

根据唐纳德·克努特的说法,梅尔文·康威在1958年将Coroutine这个术语应用于装配程序的构建,直到在1963年才首次发表了阐述Coroutine的论文。

协程的提出者梅尔文·爱德华·康威是一位计算机科学家,除了协程之外他还创造了Conway's Law康威定律,他基于社会学观察提出了系统设计的一些观点,本文就不展开了,感兴趣的可以看下作者的论文How Do Committees Invent?:

http://www.melconway.com/Home/Committees_Paper.html

1.2 协程和进线程的对比

我们来复习一下进线程和协程的一些基本特点吧:

进程是系统资源分配的最小单位, 进程包括文本段text region、数据段data region和堆栈段stack region等。进程的创建和销毁都是系统资源级别的,因此是一种比较昂贵的操作,进程是抢占式调度其有三个状态:等待态、就绪态、运行态。进程之间是相互隔离的,它们各自拥有自己的系统资源, 更加安全但是也存在进程间通信不便的问题。

进程是线程的载体容器,多个线程除了共享进程的资源还拥有自己的一少部分独立的资源,因此相比进程而言更加轻量,进程内的多个线程间的通信比进程容易,但是也同样带来了同步和互斥的问题和线程安全问题,尽管如此多线程编程仍然是当前服务端编程的主流,线程也是CPU调度的最小单位,多线程运行时就存在线程切换问题,其状态转移如图:

协程在有的资料中称为微线程或者用户态轻量级线程,协程调度不需要内核参与而是完全由用户态程序来决定,因此协程对于系统而言是无感知的。协程由用户态控制就不存在抢占式调度那样强制的CPU控制权切换到其他进线程,多个协程进行协作式调度,协程自己主动把控制权转让出去之后,其他协程才能被执行到,这样就避免了系统切换开销提高了CPU的使用效率。

抢占式调度和协作式调度的简单对比:

看到这里我们不免去想:看着协作式调度优点更多,那么为什么一直是抢占式调度占上风呢?让我们继续一起学习,可能就能解答这个问题了。

1.3 实际工作中的我们

我们写程序的时经常需要考虑的因素就是提高机器使用率,这个非常好理解。当然机器使用率和开发效率维护成本往往存在权衡,说句大白话就是:要么费人力要么费机器,选一个吧!

机器成本里面最贵的就是CPU了,程序一般分为CPU密集型和IO密集型,对于CPU密集型我们的优化空间可能没那么多,但对于IO密集型却有非常大的优化空间,试想我们的程序总是处于IO等待中让CPU呼呼睡大觉,那该多糟糕。

为了提高IO密集型程序的CPU使用率,我们尝试多进程/多线程编程等让多个任务一起跑分时复用抢占式调度,这样提高了CPU的利用率,但由于多个进线程存在调度切换,这也有一定的资源消耗,因此进线程数量不可能无限增大。

我们现在写的程序大部分都是同步IO的,效率还不够高,因此出现了一些异步IO框架,但是异步框架的编程难度比同步框架要大,但不可否认异步是一个很好的优化方向,先不要晕,来看下同步IO和异步IO就知道了:

同步是指应用程序发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行,异步是指应用程序发起I/O请求后仍继续执行,当内核I/O操作完成后会通知应用程序或者调用应用程序注册的回调函数。

我们以C/C++开发的服务端程序为例,Linux的异步IO出现的比较晚,因此像epoll之类的IO复用技术仍然有相当大的地盘,但是同步IO的效率毕竟不如异步IO,因此当前的优化方向包括:异步IO框架(像boost.asio框架)和协程方案(腾讯libco)。

Go和协程

我们知道协程是Coroutine,Go语言在语言层面对协程进行了原生支持并且称之为Goroutine,这也是Go语言强大并发能力的重要支撑,Go的CSP并发模型是通过Goroutine和channel来实现的,后续会专门写一下CSP并发模型。

2.1 协作式调度和调度器

协作式调度中用户态协程会主动让出CPU控制权来让其他协程使用,确实提高了CPU的使用率,但是不由得去思考用户态协程不够智能怎么办?不知道何时让出控制权也不知道何时恢复执行。

读到这里忽然明白了抢占式调度的优势了,在抢占式调度中都是由系统内核来完成的,用户态不需要参与,并且内核参与使得平台移植好,说到底还是各有千秋啊!

为了解决这个问题我们需要一个中间层来调度这些协程,这样才能让用户态的成千上万个协程稳定有序地跑起来,我们姑且把这个中间层称为用户态协程调度器吧!

2.2 Goroutine和Go的调度器模型

Go语言从2007年底开发直到今天已经发展了12年,Go的调度器也不是一蹴而就的,在最初的几个版本中Go的调度器也非常简陋,无法支撑大并发。

经过多个版本的迭代和优化,目前已经有很优异的性能了,不过我们还是来回顾一下Go调度器的发展历程(详见参考一):

Go的调度器非常复杂,篇幅所限本文只提一些基本的概念和原理,后续会深入去展开Go的调度器。

最近几个版本的Go调度器采用GPM模型,其中有几个概念先看下:

GPM模型使用一种M:N的调度器来调度任意数量的协程运行于任意数量的系统线程中,从而保证了上下文切换的速度并且利用多核,但是增加了调度器的复杂度。

来看两张图来进一步理解一下:

整个GPM调度的简单过程如下:

新创建的Goroutine会先存放在Global全局队列中,等待Go调度器进行调度,随后Goroutine被分配给其中的一个逻辑处理器P,并放到这个逻辑处理器对应的Local本地运行队列中,最终等待被逻辑处理器P执行即可。

在M与P绑定后,M会不断从P的Local队列中无锁地取出G,并切换到G的堆栈执行,当P的Local队列中没有G时,再从Global队列中获取一个G,当Global队列中也没有待运行的G时,则尝试从其它的P窃取部分G来执行相当于P之间的负载均衡。

Goroutine在整个生存期也存在不同的状态切换,主要的有以下几种状态:

画个状态图看下:

巨人的肩膀

  • https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/

  • https://www.flysnow.org/2017/04/11/go-in-action-go-goroutine.html
  • https://segmentfault.com/a/1190000018150987

  • https://tiancaiamao.gitbooks.io/go-internals/content/zh/05.2.html

  • https://wudaijun.com/2018/01/go-scheduler/

  • https://zhuanlan.zhihu.com/p/77620605