05 | 协程:如何快速地实现高并发服务?

你好,我是陶辉。

上一讲谈到,零拷贝通过减少上下文切换次数,提升了文件传输的性能。事实上高并发服务也是通过降低切换成本实现的,这一讲我们来看看它是如何做到的。

如果你需要访问多个服务来完成一个请求的处理,比如实现文件上传功能时,首先访问Redis缓存,验证用户是否登陆,再接收HTTP消息中的body并保存在磁盘上,最后把文件路径等信息写入MySQL数据库中,你会怎么做?

用阻塞API写同步代码最简单,但一个线程同一时间只能处理一个请求,有限的线程数导致无法实现万级别的并发连接,过多的线程切换也抢走了CPU的时间,从而降低了每秒能够处理的请求数量。

为了达到高并发,你可能会选择一个异步框架,用非阻塞API把业务逻辑打乱到多个回调函数,通过多路复用实现高并发,然而,由于业务代码过度关注并发细节,需要维护很多中间状态,不但Bug率会很高,项目的开发速度也上不去,产品及时上线存在风险。

如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码,这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。

你会发现,解决高并发问题的技术一直在变化,从多进程、多线程,到异步化、协程,面对不同的场景,它们都在用各自不同的方式解决问题。我们就来看看,高并发的解决方案是怎么演进的,协程到底解决了什么问题,它又该如何应用。

如何通过切换请求实现高并发?

我们知道,主机上资源有限,一颗CPU、一块磁盘、一张网卡,如何同时服务上百个请求呢?多进程模式是最初的解决方案。内核把CPU的执行时间切分成许多时间片(timeslice),比如1秒钟可以切分为100个10毫秒的时间片,每个时间片再分发给不同的进程,通常,每个进程需要多个时间片才能完成一个请求。

这样,虽然微观上,比如说就这10毫秒时间CPU只能执行一个进程,但宏观上1秒钟执行了100个时间片,于是每个时间片所属进程中的请求也得到了执行,这就实现了请求的并发执行。

不过,每个进程的内存空间都是独立的,这样用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便。于是,多线程模式就出现了,多线程模式通过共享内存地址空间,解决了这两个问题。

然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。这也是如Nginx等强调稳定性的服务坚持使用多进程模式的原因。

事实上,无论基于多进程还是多线程,都难以实现高并发,这由两个原因所致。

首先,单个线程消耗的内存过多,比如,64位的Linux为每个线程的栈分配了8MB的内存,还预分配了64MB的内存作为堆内存池(你可以从[第2讲] 中找到Linux系统为什么这么做)。所以,我们没有足够的内存去开启几万个线程实现并发。

其次,切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,当调用阻塞方法时,内核为了让CPU充分工作,也会切换到其他线程执行。一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的CPU运算能力。

下图以上一讲介绍过的磁盘IO为例,描述了多线程中使用阻塞方法读磁盘,2个线程间的切换方式。

那么,怎么才能实现高并发呢?把上图中本来由内核实现的请求切换工作,交由用户态的代码来完成就可以了,异步化编程通过应用层代码实现了请求切换,降低了切换成本和内存占用空间。异步化依赖于IO多路复用机制,比如Linux的epoll或者Windows上的iocp,同时,必须把阻塞方法更改为非阻塞方法,才能避免内核切换带来的巨大消耗。Nginx、Redis等高性能服务都依赖异步化实现了百万量级的并发。

下图描述了异步IO的非阻塞读和异步框架结合后,是如何切换请求的。

然而,写异步化代码很容易出错。因为所有阻塞函数,都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能,但调用方式却不同。第一个函数由你显式调用,第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则,函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时,异步化的改造工作会非常困难。

有没有办法既享受到异步化带来的高并发,又可以使用阻塞函数写同步化代码呢?

协程可以做到,它在异步化之上包了一层外衣,兼顾了开发效率与运行效率。

协程是如何实现高并发的?

协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程继续执行。如下图所示:

看起来非常棒,然而,异步化是通过回调函数来完成请求切换的,业务逻辑与并发实现关联在一起,很容易出错。协程不需要什么“回调函数”,它允许用户调用“阻塞的”协程方法,用同步编程方式写业务逻辑。

那协程的切换是如何完成的呢?

实际上,用户态的代码切换协程,与内核切换线程的原理是一样的。内核通过管理CPU的寄存器来切换线程,我们以最重要的栈寄存器和指令寄存器为例,看看协程切换时如何切换程序指令与内存。

每个线程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU中的栈寄存器SP指向了当前线程的栈,而指令寄存器IP保存着下一条要执行的指令地址。因此,从线程1切换到线程2时,首先要把SP、IP寄存器的值为线程1保存下来,再从内存中找出线程2上一次切换前保存好的寄存器值,写入CPU的寄存器,这样就完成了线程切换。(其他寄存器也需要管理、替换,原理与此相同,不再赘述。)

协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态:

从协程1切换到协程2后的状态如下图所示:

创建协程时,会从进程的堆中(参见[第2讲])分配一段内存作为协程的栈。线程的栈有8MB,而协程栈的大小通常只有几十KB。而且,C库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着10万个协程。当然,栈缩小后,就尽量不要使用递归函数,也不能在栈中申请过多的内存,这是实现高并发必须付出的代价。

由此可见,协程就是用户态的线程。然而,为了保证所有切换都在用户态进行,协程必须重新封装所有的阻塞系统调用,否则,一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。比如,普通的sleep函数会让当前线程休眠,由内核来唤醒线程,而协程化改造后,sleep只会让当前协程休眠,由协程框架在指定时间后唤醒协程。再比如,线程间的互斥锁是使用信号量实现的,而信号量也会导致线程休眠,协程化改造互斥锁后,同样由框架来协调、同步各协程的执行。

所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。比如MySQL官方提供的客户端SDK,它使用了阻塞socket做网络访问,会导致线程休眠,必须用非阻塞socket把SDK改造为协程函数后,才能在协程中使用。

当然,并不是所有的函数都能用协程改造。比如[第4讲] 提到的异步IO,它虽然是非阻塞的,但无法使用PageCache,降低了系统吞吐量。如果使用缓存IO读文件,在没有命中PageCache时是可能发生阻塞的。

这种时候,如果对性能有更高的要求,就需要把线程与协程结合起来用,把可能阻塞的操作放在线程中执行,通过生产者/消费者模型与协程配合工作。

实际上,面对多核系统,也需要协程与线程配合工作。因为协程的载体是线程,而一个线程同一时间只能使用一颗CPU,所以通过开启更多的线程,将所有协程分布在这些线程中,就能充分使用CPU资源。

除此之外,为了让协程获得更多的CPU时间,还可以设置所在线程的优先级,比如Linux下把线程的优先级设置到-20,就可以每次获得更长的时间片。另外,[第1讲] 曾谈到CPU缓存对程序性能的影响,为了减少CPU缓存失效的比例,还可以把线程绑定到某个CPU上,增加协程执行时命中CPU缓存的机率。

虽然这一讲中谈到协程框架在调度协程,然而,你会发现,很多协程库只提供了创建、挂起、恢复执行等基本方法,并没有协程框架的存在,需要业务代码自行调度协程。这是因为,这些通用的协程库并不是专为服务器设计的。服务器中可以由客户端网络连接的建立,驱动着创建出协程,同时伴随着请求的结束而终止。在协程的运行条件不满足时,多路复用框架会将它挂起,并根据优先级策略选择另一个协程执行。

因此,使用协程实现服务器端的高并发服务时,并不只是选择协程库,还要从其生态中找到结合IO多路复用的协程框架,这样可以加快开发速度。

小结

这一讲,我们从高并发的应用场景入手,分析了协程出现的背景和实现原理,以及它的应用范围。你会发现,协程融合了多线程与异步化编程的优点,既保证了开发效率,也提升了运行效率。

有限的硬件资源下,多线程通过微观上时间片的切换,实现了同时服务上百个用户的能力。多线程的开发成本虽然低,但内存消耗大,切换次数过多,无法实现高并发。

异步编程方式通过非阻塞系统调用和多路复用,把原本属于内核的请求切换能力,放在用户态的代码中执行。这样,不仅减少了每个请求的内存消耗,也降低了切换请求的成本,最终实现了高并发。然而,异步编程违反了代码的内聚性,还需要业务代码关注并发细节,开发成本很高。

协程参考内核通过CPU寄存器切换线程的方法,在用户态代码中实现了协程的切换,既降低了切换请求的成本,也使得协程中的业务代码不用关注自己何时被挂起,何时被执行。相比异步编程中要维护一堆数据结构表示中间状态,协程直接用代码表示状态,大大提升了开发效率。

在协程中调用的所有API,都需要做非阻塞的协程化改造。优秀的协程生态下,常用服务都有对应的协程SDK,方便业务代码使用。开发高并发服务时,与IO多路复用结合的协程框架可以与这些SDK配合,自动挂起、切换协程,进一步提升开发效率。

协程并不是完全与线程无关,首先线程可以帮助协程充分使用多核CPU的计算力,其次,遇到无法协程化、会导致内核切换的阻塞函数,或者计算太密集从而长时间占用CPU的任务,还是要放在独立的线程中执行,以防止它影响所有协程的执行。

思考题

最后,留给你一个思考题,你用过协程吗?觉得它还有什么优点?如果没有在生产环境中使用协程,原因是什么?欢迎你在留言区与我一起探讨。

感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。

精选留言

  • 忆水寒

    2020-05-08 10:57:24

    优点:
    1、首先协程是比线程更轻量级的对象,在Linux内核来说,线程和进程最终对应的都是task结构。
    2、从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。
    3、协程可以认为是一种并发编程技术,性能比较高,可用性也比较高。Java中的Loom 项目的目标就是支持协程,像go语言更是天然支持协程。
    在我们项目中没有用到协程,主要还是使用的还是异步回调方式。
    主要原因是:大家不知道协程(接收度比较低,觉得没用过可能会遇到很多坑,万一影响产品稳定性怎么办),而且产品里面已经充斥着大量的回调,没法大规模切换了。
    作者回复

    是的,协程还需要生态上各类SDK的完善

    2020-09-11 17:49:18

  • Bitstream

    2020-05-09 09:46:55

    说实话,这篇看下来,感觉挺抽象的,要是有例子就好了,但是想想,简单的例子可能还真没法体现线程和协程的差别,暂且当做作业自己下去实现个例子吧。另外,这里把协程和用户态线程(基于栈的协程)等价起来了,协程也有无栈的实现方式,我觉得应该提一下。
  • 郭郭

    2020-05-08 23:12:14

    老师,有没有比较好的C++协程库推荐一下?
    作者回复

    阿里开源的libeasy你可以考虑下哈,非常高效,在阿里的合伙人多隆写的协程库,代码质量很高

    2020-05-09 09:05:51

  • Geek_89bbab

    2020-05-09 18:23:53

    再补充一些,
    1. 一些协程库会使用共享栈,如腾讯的libco。
    2. 协程调度和内核调度相比另一个高效的原因,内核是抢占式的调度,协程是非抢占式的、按需调度,所以协程的调度次数远远小于内核的调度次数。
    作者回复

    谢谢Geek_89bbab的分享!!

    2020-09-11 17:40:48

  • 苦行僧

    2020-05-14 15:17:05

    就记住一句话 协程就是用户态的线程
    作者回复

    是的

    2020-05-15 07:27:13

  • 每天晒白牙

    2020-05-08 07:32:28

    今日得到
    协程:如何快速地实现高并发服务?
    要想实现高并发,一个简单的做法就是多线程,为每个请求分配一个线程来执行。但多线程的方式也是有弊端的,如下:
    1.单个线程消耗内存过多,没有足够的内存去创建几万线程实现并发
    2.切换请求是内核通过切换线程来实现的,线程的切换就会带来上下文的切换,也是会耗费 CPU 资源的

    如何破?
    把本来由内核实现的请求切换工作交给用户态的代码来完成,这样可以降低切换成本和内存占用

    异步编程可以实现用户态的请求切换。异步化依赖 IO 多路复用机制的同时,还需要把阻塞方法改为非阻塞方法

    比如一个线程处理两个请求,请求 1 过来通过异步框架发起异步 IO 读,同时向异步框架注册回调函数。然后切换到请求 2,由异步框架发起异步 IO 读,同样也会注册回调函数。
    最后由异步框架依赖 IO 多路复用机制来检查数据是否就绪,如果数据就绪就通过之前请求注册的回调函数去处理

    异步代码不好写,容易出错,我们项目中用的 vertx 异步框架,我到现在也写不好异步代码。

    协程可以弥补异步框架的不足,其实协程是建立在异步的基础上的,他俩都是使用非阻塞的系统调用与内核交互,把请求切换放到用户态。他俩不同的地方在于,协程把异步化中的两段函数封装成一个阻塞的协程函数。在该函数执行时,由协程框架完成协程之间的切换,协程是无感知的。

    协程是如何完成切换的?
    在用户态完成协程的切换和在内核态完成线程的切换原理类似。
    每个协程有独立的栈,一般占用空间选小于线程的栈,(协程一般是几十 KB,线程是 8MB
    )所以相同的内存空间可以创建更多的协程来处理请求。栈中保存了函数的调用关系、参数和返回值。CPU 中的栈寄存器 SP 指向当前协程的栈,指令寄存器 IP 保存下一条执行的指令的地址。

    在协程 1 切换到协程 2 时要把协程 1 的 SP 和 IP 寄存器的值保存下来,再从内存中找到协程 2 上一次切换前保存的寄存器值,写入到 CPU 的寄存器,这样就完成了协程的切换

    协程是用户态的线程,一个线程可以包含多个协程,要保证协程的切换由用户态代码完成,如果协程触发了线程的切换就会导致该线程上的所有协程都阻塞,因为线程的切换是由内核态完成的

    所以要想使用协程,需要协程的生态是完整的,go 好像是天然支持协程,Java 的协程生态现在应该还不成熟,用的比较少
  • 杉松壁

    2020-06-28 16:06:50

    协程既然在用户态,是怎么有权限切换CPU寄存器的?
    作者回复

    通过汇编语言直接修改寄存器的值就可以做到,具体你可以看下汇编指令

    2020-09-04 17:23:12

  • 那时刻

    2020-05-08 10:31:25

    使用过go语言里的协程,通过GMP来完成goroutine的调度,简单来说,通过P来绑定内核线程M于协程G。通过老师的讲解,加深了理解。
    作者回复

    ^_^

    2020-09-11 17:49:27

  • Kvicii.Y

    2020-06-20 13:25:20

    陶老师,协程和线程的区别点在于:线程是CPU通过寄存器进行切换,协程是在用户态中进行切换,除此之外二者还有什么更细微的差别吗?
    作者回复

    线程是由内核实现的,其切换是在内核态进行,因此有内核态与用户态的切换成本,而且内核很难高度定制化,因此它通常考虑的是通用场景,不会为了高并发服务器做过多优化;
    协程是由应用代码实现,且主要是用于高并发服务器,耗费内存很小,多个请求间的切换成本也很低。

    2020-09-09 17:00:26

  • 范闲

    2020-05-18 19:41:43

    没有用过协程。cpp没有协程标准库。另外协程本身也依赖于线程吧。只不过是一个线程可以对应多个些协程。
    作者回复

    可以参考阿里开源的libeasy,是一个很高效的C/C++协程库

    2020-05-20 08:46:42

  • ryanxw

    2023-03-04 14:53:31

    讲的真的好,很稳
  • 第一装甲集群司令克莱斯特

    2022-11-08 09:56:28

    资深工程师,必须回系统性能调优,这是基础价值。
  • 托尼斯威特

    2021-07-01 19:05:05

    老师您好,跟您确认几个问题,不知道我理解的对不对。

    大家谈高并发,有的时候谈的是高并发连接,有的时候谈的是高并发请求,高并发连接用epoll eventloop就可以了吧?

    这篇文章主要是讲高并发请求。本来业务逻辑里需要阻塞线程,而用协程池取代线程池处理请求,可以节约大量的线程。

    我疑惑的是,实际应用服务器一般要么是Cpu瓶颈,要么是内存瓶颈,我们的tomcat服务跑30个线程可以处理200QPS,CPU或者内存就接近100%了。这种情况我怎么觉得异步优化和协程优化都没什么用?

    作者回复

    对,CPU、网卡通常是瓶颈,这时应该看看CPU究竟消耗在哪个函数上了,火焰图是个很好的工具。对于IO型应用,内存一般不是瓶颈。

    2021-07-05 17:37:32

  • 北极的大企鹅

    2021-03-30 20:40:56

    之前浅浅了解过Kotlin中的协程
  • 木头发芽

    2020-08-15 11:47:49

    公司所有的微服务都用go写,所以协程一直都在用,通过这节更深入的理解了协程的出现解决的问题及其原理.对GMP模型的理解有了更底层的知识支撑.
    作者回复

    后面我会再写一篇与GO协程相关的加餐

    2020-08-21 14:23:59

  • Robust

    2020-05-17 10:36:23

    “然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。”

    这里的出错应该表示一些特殊的错误吧,或者是说和共享内存有关的错误,比如申请不到内存等。老师,我理解的没错吧?
    作者回复

    指无法恢复的错误,不仅是内存申请错误,比如访问已经释放的资源,且没有捕获异常或者无法捕获异常,进而操作系统只能杀死线程时,进程里的其他线程也会被杀死。

    2020-05-17 15:07:48

  • 阿鼎

    2020-05-13 00:42:30

    请问老师,协程只能用在io线程,当io耗时时,靠多路复用实现调度吗!非io线程,应用代码如何进行调度协程切换,难道像模仿操作系统切换时间片?
  • 2020-05-10 21:50:10

    nodejs 中的 generator , await , async 就是使用协程进行实现的
    作者回复

    谢谢一步的分享!

    2020-09-11 17:29:55

  • borefo

    2020-05-08 21:23:05

    我们的服务都是协程框架,异步拉取外部多个接口数据,然后本地做运算,最后返回。但是发现,同样协程框架的不同服务性能相差很大,这个影响性能的因素有哪些?如何评估一个程序的性能呢?比如,已知这个程序做了哪些操作,能不能大概知道服务的qps是多少呢?谢谢!
  • 亦知码蚁

    2020-05-08 09:43:10

    老师 C10K 用epoll模型实现的 不就是非阻塞线程+多路复用嘛 线程为啥说不能支持几万的并发 应该是可以的吧
    作者回复

    你好问题大师,我是指同步开发下,要支持几万并发,就得几万个线程或者协程。
    你说的方式,应该是指异步非阻塞模式吧?这没有限制。如果同步方式下,几万个线程是无法实现的,因为每个线程占用的内存太大,是以MB计算的,而协程占用的内存是以KB计算的。

    2020-05-09 09:04:55