Go开发关键技术指南:Concurrency & Control

Concurrency

早在十八年前的1999年,千兆网卡还是一个新玩意儿,想当年有吉比特带宽却只能支持10K客户端,还是个值得研究的问题,毕竟Nginx在2009年才出来,在这之前大家还在内核折腾过HTTP服务器,服务器领域还在讨论如何解决C10K问题,C10K中文翻译在这里。读这个文章,感觉进入了繁忙服务器工厂的车间,成千上万错综复杂的电缆交织在一起,甚至还有古老的惊群(thundering herd)问题,惊群像远古狼人一样就算是在21世纪还是偶然能听到它的传说。现在大家讨论的都是如何支持C10M,也就是千万级并发的问题。

并发,无疑是服务器领域永远无法逃避的话题,是服务器软件工程师的基本能力。Go的撒手锏之一无疑就是并发处理,如果要从Go众多优秀的特性中挑一个,那就是并发和工程化,如果只能选一个的话,那就是并发的支持。大规模软件,或者云计算,很大一部分都是服务器编程,服务器要处理的几个基本问题:并发、集群、容灾、兼容、运维,这些问题都可以因为Go的并发特性得到改善,按照《人月神话》的观点,并发无疑是服务器领域的固有复杂度(Essential Complexity)之一。Go之所以能迅速占领云计算的市场,Go的并发机制是至关重要的。

借用《人月神话》中关于固有复杂度(Essential Complexity)的概念,能比较清晰的说明并发问题。就算没有读过这本书,也肯定听过软件开发“没有银弹”,要保持软件的“概念完整性”,Brooks作为硬件和软件的双重专家和出色的教育家始终活跃在计算机舞台上,在计算机技术的诸多领域中都作出了巨大的贡献,在1964年(33岁)领导了IBM System/360和IBM OS/360的研发,于1993年(62岁)获得冯诺依曼奖,并于1999年(68岁)获得图灵奖,在2010年(79岁)获得虚拟现实(VR)的奖项IEEE Virtual Reality Career Award (2010)。

在软件领域,很少能有像《人月神话》一样具有深远影响力和畅销不衰的著作。Brooks博士为人们管理复杂项目提供了具有洞察力的见解,既有很多发人深省的观点,又有大量软件工程的实践。本书内容来自Brooks博士在IBM公司System/360家族和OS/360中的项目管理经验,该项目堪称软件开发项目管理的典范。该书英文原版一经面世,即引起业内人士的强烈反响,后又译为德、法、日、俄、中、韩等多种文字,全球销售数百万册。确立了其在行业内的经典地位。

Brooks是我最崇拜的人,有理论有实践,懂硬件懂软件,致力于大规模软件(当初还没有云计算)系统,足够(长达十年甚至二十年)的预见性,孜孜不倦奋斗不止,强烈推荐软件工程师读《人月神话》。

短暂的广告回来,继续讨论并发(Concurrency)的问题,要理解并发的问题就必须从了解并发问题本身,以及并发处理模型开始。2012年我在当时中国最大的CDN公司蓝汛设计和开发流媒体服务器时,学习了以高并发闻名的NGINX的并发处理机制EDSM(Event-Driven State Machine Architecture),自己也照着这套机制实现了一个流媒体服务器,和HTTP的Request-Response模型不同,流媒体的协议比如RTMP非常复杂中间状态非常多,特别是在做到集群Edge时和上游服务器的交互会导致系统的状态机翻倍,当时请教了公司的北美研发中心的架构师Michael,Michael推荐我用一个叫做ST(StateThreads)的技术解决这个问题,ST实际上使用setjmp和longjmp实现了用户态线程或者叫协程,协程和goroutine是类似的都是在用户空间的轻量级线程,当时我本没有懂为什么要用一个完全不懂的协程的东西,后来我花时间了解了ST后豁然开朗,原来服务器的并发处理有几种典型的并发模型,流媒体服务器中超级复杂的状态机,也广泛存在于各种服务器领域中,属于这个复杂协议服务器领域不可Remove的一种固有复杂度(Essential Complexity)。

我翻译了ST(StateThreads)总结的并发处理模型高性能、高并发、高扩展性和可读性的网络服务器架构:State Threads for Internet Applications,这篇文章也是理解Go并发处理的关键,本质上ST就是C语言的协程库(腾讯微信也开源过一个libco协程库),而goroutine是Go语言级别的实现,本质上他们解决的领域问题是一样的,当然goroutine会更广泛一些,ST只是一个网络库。我们一起看看并发的本质目标,一起看图说话吧,先从并发相关的性能和伸缩性问题说起:

image.png
  • 横轴是客户端的数目,纵轴是吞吐率也就是正常提供服务需要能吐出的数据,比如1000个客户端在观看500Kbps码率的视频时,意味着每个客户端每秒需要500Kb的数据,那么服务器需要每秒吐出500*1000Kb=500Mb的数据才能正常提供服务,如果服务器因为性能问题CPU跑满了都无法达到500Mbps的吞吐率,客户端必定就会开始卡顿。
  • 图中黑色的线是客户端要求的最低吞吐率,假设每个客户端都是一样的,那么黑色的线就是一条斜率固定的直线,也就是客户端越多吞吐率就越多,基本上和客户端数目成正比。比如1个客户端需要500Kbps的吞吐率,1000个就是500Mbps吞吐率。
  • 图中蓝色的实线,是服务器实际能达到的吞吐率。在客户端比较少时,由于CPU空闲,服务器(如果有需要)能够以超过客户端要求的最低吞吐率给数据,比如点播服务器的场景,客户端看500Kbps码率的点播视频,每秒最少需要500Kb的数据,那么服务器可以以800Kbps的吞吐率给客户端数据,这样客户端自然不会卡顿,客户端会将数据保存在自己的缓冲区,只是如果用户放弃播放这个视频时会导致缓存的数据浪费。
  • 图中蓝色实线会有个天花板,也就是服务器在给定的CPU资源下的最高吞吐率,比如某个版本的服务器再4CPU下由于性能问题只能达到1Gbps的吞吐率,那么黑线和蓝线的交叉点,就是这个服务器能正常服务的最多客户端比如2000个。理论上如果超过这个最大值比如10K个,服务器吞吐率还是保持在最大吞吐率比如1Gbps,但是由于客户端的数目持续增加需要继续消耗系统资源,比如10K个FD和线程的切换会抢占用于网络收发的CPU时间,那么就会出现蓝色虚线,也就是超负载运行的服务器,吞吐率会降低,导致服务器无法正常服务已经连接的客户端。
  • 负载伸缩性(Load Scalability)就是指黑线和蓝线的交叉点,系统的负载能力如何,或者说是否并发模型能否尽可能的将CPU用在网络吞吐上,而不是程序切换上,比如多进程的服务器,负载伸缩性就非常差,有些空闲的客户端也会Fork一个进程服务,这无疑是浪费了CPU资源的。同时多进程的系统伸缩性会很好,增加CPU资源时吞吐率基本上都是线性的。
  • 系统伸缩性(System Scalability)是指吞吐率是否随系统资源线性增加,比如新增一倍的CPU,是否吞吐率能翻倍。图中绿线,就是增加了一倍的CPU,那么好的系统伸缩性应该系统的吞吐率也要增加一倍。比如多线程程序中,由于要对竞争资源加锁或者多线程同步,增加的CPU并不能完全用于吞吐率,多线程模型的系统伸缩性就不如多进程模型。

并发的模型包括几种,总结Existing Architectures如下表:

Arch Load Scalability System Scalability Robust Complexity Example
Multi-Process Poor Good Great Simple Apache1.x
Multi-Threaded Good Poor Poor Complex Tomcat, FMS/AMS
Event-Driven
State Machine
Great Great Good Very
Complex
Nginx, CRTMPD
StateThreads Great Great Good Simple SRS, Go
  • MP(Multi-Process)多进程模型:每个连接Fork一个进程服务。系统的鲁棒性非常好,连接彼此隔离互不影响,就算有进程挂掉也不会影响其他连接。负载伸缩性(Load Scalability)非常差(Poor),系统在大量进程之间切换的开销太大,无法将尽可能多的CPU时间使用在网络吞吐上,比如4CPU的服务器启动1000个繁忙的进程基本上无法正常服务。系统伸缩性(System Scalability)非常好,增加CPU时一般系统吞吐率是线性增长的。目前比较少见纯粹的多进程服务器了,特别是一个连接一个进程这种。虽然性能很低,但是系统复杂度低(Simple),进程很独立,不需要处理锁或者状态。
  • MT(Multi-Threaded)多线程模型:有的是每个连接一个线程,改进型的是按照职责分连接,比如读写分离的线程,几个线程读,几个线程写。系统的鲁棒性不好(Poor),一个连接或线程出现问题,影响其他的线程,彼此互相影响。负载伸缩性(Load Scalability)比较好(Good),线程比进程轻量一些,多个用户线程对应一个内核线程,但出现被阻塞时性能会显著降低,变成和多进程一样的情况。系统伸缩性(System Scalability)比较差(Poor),主要是因为线程同步,就算用户空间避免锁,在内核层一样也避免不了;增加CPU时,一般在多线程上会有损耗,并不能获得多进程那种几乎线性的吞吐率增加。多线程的复杂度(Complex)也比较高,主要是并发和锁引入的问题。
  • EDSM(Event-Driven State Machine)事件驱动的状态机。比如select/poll/epoll,一般是单进程单线程,这样可以避免多进程的锁问题,为了避免单程的系统伸缩问题可以使用多进程单线程,比如NGINX就是这种方式。系统鲁棒性比较好(Good),一个进程服务一部分的客户端,有一定的隔离。负载伸缩性(Load Scalability)非常好(Great),没有进程或线程的切换,用户空间的开销也非常少,CPU几乎都可以用在网络吞吐上。系统伸缩性(System Scalability)很好,多进程扩展时几乎是线性增加吞吐率。虽然效率很高,但是复杂度也非常高(Very Complex),需要维护复杂的状态机,特别是两个耦合的状态机,比如客户端服务的状态机和回源的状态机。
  • ST(StateThreads)协程模型。在EDSM的基础上,解决了复杂状态机的问题,从堆开辟协程的栈,将状态保存在栈中,在异步IO等待(EAGAIN)时,主动切换(setjmp/longjmp)到其他的协程完成IO。也就是ST是综合了EDSM和MT的优势,不过ST的线程是用户空间线程而不是系统线程,用户空间线程也会有调度的开销,不过比系统的开销要小很多。协程的调度开销,和EDSM的大循环的开销差不多,需要循环每个激活的客户端,逐个处理。而ST的主要问题,在于平台的适配,由于glibc的setjmp/longjmp是加密的无法修改SP栈指针,所以ST自己实现了这个逻辑,对于不同的平台就需要自己适配,目前Linux支持比较好,Windows不支持,另外这个库也不在维护有些坑只能绕过去,比较偏僻使用和维护者都很少,比如ST Patch修复了一些问题。

我将Go也放在了ST这种模型中,虽然它是多线程+协程,和SRS不同是多进程+协程(SRS本身是单进程+协程可以扩展为多进程+协程)。

从并发模型看Go的goroutine,Go有ST的优势,没有ST的劣势,这就是Go的并发模型厉害的地方了。当然Go的多线程是有一定开销的,并没有纯粹多进程单线程那么高的负载伸缩性,在活跃的连接过多时,可能会激活多个物理线程,导致性能降低。也就是Go的性能会比ST或EDSM要差,而这些性能用来交换了系统的维护性,个人认为很值得。除了goroutine,另外非常关键的就是chan。Go的并发实际上并非只有goroutine,而是goroutine+chan,chan用来在多个goroutine之间同步。实际上在这两个机制上,还有标准库中的context,这三板斧是Go的并发的撒手锏。

  • goroutine: Go对于协程的语言级别原生支持,一个go就可以启动一个协程。ST是通过函数来实现。
  • chan和select: goroutine之间通信的机制,ST如果要实现两个协程的消息传递和等待,只能自己实现queue和cond。如果要同步多个呢?比如一个协程要处理多种消息,包括用户取消,超时,其他线程的事件,Go提供了select关键字。参考Share Memory By Communicating
  • context: 管理goroutine的组件,参考GOLANG使用Context管理关联goroutine以及GOLANG使用Context实现传值、超时和取消。参考Go Concurrency Patterns: Timing out, moving onGo Concurrency Patterns: Context

由于Go是多线程的,关于多线程或协程同步,除了chan也提供了Mutex,其实这两个都是可以用的,而且有时候比较适合用chan而不是用Mutex,有时候适合用Mutex不适合用chan,参考Mutex or Channel

Channel Mutex
passing ownership of data,
distributing units of work,
communicating async results
caches,
state

特别提醒:不要惧怕使用Mutex,不要什么都用chan,千里马可以一日千里却不能抓老鼠,HelloKitty跑不了多快抓老鼠却比千里马强。

Context

实际上goroutine的管理,在真正高可用的程序中是非常必要的,我们一般会需要支持几种gorotine的控制方式:

  1. 错误处理:比如底层函数发生错误后,我们是忽略并告警(比如只是某个连接受到影响),还是选择中断整个服务(比如LICENSE到期)。
  2. 用户取消:比如升级时,我们需要主动的迁移新的请求到新的服务,或者取消一些长时间运行的goroutine,这就叫热升级。
  3. 超时关闭:比如请求的最大请求时长是30秒,那么超过这个时间,我们就应该取消请求。一般客户端的服务响应是有时间限制的。
  4. 关联取消:比如客户端请求服务器,服务器还要请求后端很多服务,如果中间客户端关闭了连接,服务器应该中止,而不是继续请求完所有的后端服务。

而goroutine的管理,最开始只有chan和sync,需要自己手动实现goroutine的生命周期管理,参考Go Concurrency Patterns: Timing out, moving onGo Concurrency Patterns: Context,这些都是goroutine的并发范式。

直接使用原始的组件管理goroutine太繁琐了,后来在一些大型项目中出现了context这些库,并且Go1.7之后变成了标准库的一部分。具体参考GOLANG使用Context管理关联goroutine以及GOLANG使用Context实现传值、超时和取消。

Context也有问题:

  1. 支持Cancel、Timeout和Value,这些都是扩张Context树的节点。Cancel和Timeout在子树取消时会删除子树,不会一直膨胀;Value没有提供删除的函数,如果他们有公共的根节点,会导致这个Context树越来越庞大;所以Value类型的Context应该挂在Cancel的Context树下面,这样在取消时GC会回收。
  2. 会导致接口不一致或者奇怪,比如io.Reader其实第一个参数应该是context,比如Read(Context, []byte)函数。或者提供两套接口,一种带Contex,一种不带Context。这个问题还蛮困扰人的,一般在应用程序中,推荐第一个参数是Context。
  3. 注意Context树,如果因为Closure导致树越来越深,会有调用栈的性能问题。比如十万个长链,会导致CPU占用500%左右。

备注:关于对Context的批评,可以参考Context should go away for Go 2,作者觉得在标准库中加context作为第一个参数不能理解,比如Read(ctx context.Context等。

Links

由于限制了文章字数,只好分成不同章节:

  • Overview 为何Go有时候也叫Golang?为何要选择Go作为服务器开发的语言?是冲动?还是骚动?Go的重要里程碑和事件,当年吹的那些牛逼,都实现了哪些?
  • Could Not Recover 君可知,有什么panic是无法recover的?包括超过系统线程限制,以及map的竞争写。当然一般都能recover,比如Slice越界、nil指针、除零、写关闭的chan等。
  • Errors 为什么Go2的草稿3个有2个是关于错误处理的?好的错误处理应该怎么做?错误和异常机制的差别是什么?错误处理和日志如何配合?
  • Logger 为什么标准库的Logger是完全不够用的?怎么做日志切割和轮转?怎么在混成一坨的服务器日志中找到某个连接的日志?甚至连接中的流的日志?怎么做到简洁又够用?
  • Interfaces 什么是面向对象的SOLID原则?为何Go更符合SOLID?为何接口组合比继承多态更具有正交性?Go类型系统如何做到looser, organic, decoupled, independent, and therefore scalable?一般软件中如果出现数学,要么真的牛逼要么装逼。正交性这个数学概念在Go中频繁出现,是神仙还是妖怪?为何接口设计要考虑正交性?
  • Modules 如何避免依赖地狱(Dependency Hell)?小小的版本号为何会带来大灾难?Go为什么推出了GOPATH、Vendor还要搞module和vgo?新建了16个仓库做测试,碰到了9个坑,搞清楚了gopath和vendor如何迁移,以及vgo with vendor如何使用(毕竟生产环境不能每次都去外网下载)。
  • Concurrency & Control 服务器中的并发处理难在哪里?为什么说Go并发处理优势占领了云计算开发语言市场?什么是C10K、C10M问题?如何管理goroutine的取消、超时和关联取消?为何Go1.7专门将context放到了标准库?context如何使用,以及问题在哪里?
  • Engineering Go在工程化上的优势是什么?为什么说Go是一门面向工程的语言?覆盖率要到多少比较合适?什么叫代码可测性?为什么良好的库必须先写Example?
  • Go2 Transition Go2会像Python3不兼容Python2那样作吗?C和C++的语言演进可以有什么不同的收获?Go2怎么思考语言升级的问题?
  • SRS & Others Go在流媒体服务器中的使用。Go的GC靠谱吗?Twitter说相当的靠谱,有图有真相。为何Go的声明语法是那样?C的又是怎样?是拍的大腿,还是拍的脑袋?

你可能感兴趣的:(Go开发关键技术指南:Concurrency & Control)