并发之痛 Thread,Goroutine,Actor

分享 未结
1 1 1 7
小编 2019-07-17发布
收藏 点赞
来源: 高可用架构

编者按:本文是王渊命在 2 月 27 日 Gopher 北京聚会演讲基础上整理而成,进行了一些补充以及调整,投稿给高可用架构首发。转载请注明来自高可用架构公众号「ArchNotes」。

王渊命,团队协作 IM 服务 Grouk 联合创始人及 CTO,技术极客,曾任新浪微博架构师、微米技术总监。2014 年作为联合创始人创立团队协作 IM 服务 Grouk,长期关注团队协作基础工具和研发环境建设,Docker 深度实践者。

聊这个话题之前,先梳理下两个概念,几乎所有讲并发的文章都要先讲这两个概念:

  • 并发(concurrency) 并发的关注点在于任务切分。举例来说,你是一个创业公司的CEO,开始只有你一个人,你一人分饰多角,一会做产品规划,一会写代码,一会见客户,虽然你不能见客户的同时写代码,但由于你切分了任务,分配了时间片,表现出来好像是多个任务一起在执行。
  • 并行(parallelism) 并行的关注点在于同时执行。还是上面的例子,你发现你自己太忙了,时间分配不过来,于是请了工程师,产品经理,市场总监,各司一职,这时候多个任务可以同时执行了。

所以总结下,并发并不要求必须并行,可以用时间片切分的方式模拟,比如单核 CPU 上的多任务系统,并发的要求是任务能切分成独立执行的片段。而并行关注的是同时执行,必须是多(核)CPU,要能并行的程序必须是支持并发的。本文大多数情况下不会严格区分这两个概念,默认并发就是指并行机制下的并发。

为什么并发程序这么难?

We believe that writing correct  concurrentfault-tolerantand  scalable applications is too hard. Most of the time it’s because we are using the wrong tools and the wrong level of abstraction. —— Akka

Akka 官方文档开篇这句话说的好,之所以写正确的并发,容错,可扩展的程序如此之难,是因为我们用了错误的工具和错误的抽象。(当然该文档本来的意思是 Akka 是正确的工具,但我们可以独立的看待这句话)。

那我们从最开始梳理下程序的抽象。开始我们的程序是面向过程的,数据结构 + func。后来有了面向对象,对象组合了数结构和 func,我们想用模拟现实世界的方式,抽象出对象,有状态和行为。但无论是面向过程的 func 还是面向对象的 func,本质上都是代码块的组织单元,本身并没有包含代码块的并发策略的定义。于是为了解决并发的需求,引入了 Thread(线程)的概念。

线程(Thread)

  1. 系统内核态,更轻量的进程
  2. 由系统内核进行调度
  3. 同一进程的多个线程可共享资源

线程的出现解决了两个问题,一个是 GUI 出现后急切需要并发机制来保证用户界面的响应。第二是互联网发展后带来的多用户问题。最早的 CGI 程序很简单,将通过脚本将原来单机版的程序包装在一个进程里,来一个用户就启动一个进程。但明显这样承载不了多少用户,并且如果进程间需要共享资源还得通过进程间的通信机制,线程的出现缓解了这个问题。

线程的使用比较简单,如果你觉得这块代码需要并发,就把它放在单独的线程里执行,由系统负责调度,具体什么时候使用线程,要用多少个线程,由调用方决定,但定义方并不清楚调用方会如何使用自己的代码,很多并发问题都是因为误用导致的,比如 Go 中的 map 以及 Java 的 HashMap 都不是并发安全的,误用在多线程环境就会导致问题。另外也带来复杂度:

  1. 竞态条件(race conditions) 如果每个任务都是独立的,不需要共享任何资源,那线程也就非常简单。但世界往往是复杂的,总有一些资源需要共享,比如前面的例子,开发人员和市场人员同时需要和 CEO 商量一个方案,这时候 CEO 就成了竞态条件。
  2. 依赖关系以及执行顺序 如果线程之间的任务有依赖关系,需要等待以及通知机制来进行协调。比如前面的例子,如果产品和 CEO 讨论的方案依赖于市场和 CEO 讨论的方案,这时候就需要协调机制保证顺序。

为了解决上述问题,我们引入了许多复杂机制来保证:

  • Mutex(Lock)(Go 里的 sync 包, Java 的 concurrent 包)通过互斥量来保护数据,但有了锁,明显就降低了并发度。
  • Semaphore 通过信号量来控制并发度或者作为线程间信号(signal)通知。
  • Volatile Java 专门引入了 volatile 关键词来,来降低只读情况下的锁的使用。
  • Compare-and-swap 通过硬件提供的 CAS 机制保证原子性(atomic),也是降低锁的成本的机制。

如果说上面两个问题只是增加了复杂度,我们通过深入学习,严谨的 Code Review,全面的并发测试(比如 Go 语言中单元测试的时候加上 -race 参数),一定程度上能解决(当然这个也是有争议的,有论文认为当前的大多数并发程序没出问题只是并发度不够,如果 CPU 核数继续增加,程序运行的时间更长,很难保证不出问题)。但最让人头痛的还是下面这个问题:

系统里到底需要多少线程?

这个问题我们先从硬件资源入手,考虑下线程的成本:

  • 内存(线程的栈空间)每个线程都需要一个栈(Stack)空间来保存挂起(suspending)时的状态。Java 的栈空间(64位VM)默认是 1024k,不算别的内存,只是栈空间,启动 1024 个线程就要 1G 内存。虽然可以用 -Xss 参数控制,但由于线程是本质上也是进程,系统假定是要长期运行的,栈空间太小会导致稍复杂的递归调用(比如复杂点的正则表达式匹配)导致栈溢出。所以调整参数治标不治本。
  • 调度成本(context-switch)我在个人电脑上做的一个非严格测试,模拟两个线程互相唤醒轮流挂起,线程切换成本大约 6000 纳秒/次。这个还没考虑栈空间大小的影响。国外一篇论文专门分析线程切换的成本,基本上得出的结论是切换成本和栈空间使用大小直接相关。
  • CPU使用率我们搞并发最主要的一个目标就是我们有了多核,想提高CPU利用率,最大限度的压榨硬件资源,从这个角度考虑,我们应该用多少线程呢?

这个我们可以通过一个公式计算出来,100 / (15 + 5) * 4 = 20,用 20 个线程最合适。但一方面网络的时间不是固定的,另外一方面,如果考虑到其他瓶颈资源呢?比如锁,比如数据库连接池,就会更复杂。

作为一个 1 岁多孩子的父亲,认为这个问题的难度好比你要写个给孩子喂饭的程序,需要考虑『给孩子喂多少饭合适?』,这个问题有以下回答以及策略:

  • 孩子不吃了就好了(但孩子贪玩,不吃了可能是想去玩了)
  • 孩子吃饱了就好了(废话,你怎么知道孩子吃饱了?孩子又不会说话)
  • 逐渐增量,长期观察,然后计算一个平均值(这可能是我们调整线程常用的策略,但增量增加到多少合适呢?)
  • 孩子吃吐了就别喂了(如果用逐渐增量的模式,通过外部观察,可能会到达这个边界条件。系统性能如果因为线程的增加倒退了,就别增加线程了)
  • 没控制好边界,把孩子给给撑坏了 (这熊爸爸也太恐怖了。但调整线程的时候往往不小心可能就把系统搞挂了)

通过这个例子我们可以看出,从外部系统来观察,或者以经验的方式进行计算,都是非常困难的。于是结论是:

让孩子会说话,吃饱了自己说,自己学会吃饭,自管理是最佳方案。

然并卵,计算机不会自己说话,如何自管理?

但我们从以上的讨论可以得出一个结论:

  • 线程的成本较高(内存,调度)不可能大规模创建
  • 应该由语言或者框架动态解决这个问题

线程池方案

Java 1.5 后,Doug Lea 的 Executor 系列被包含在默认的 JDK 内,是典型的线程池方案。

线程池一定程度上控制了线程的数量,实现了线程复用,降低了线程的使用成本。但还是没有解决数量的问题,线程池初始化的时候还是要设置一个最小和最大线程数,以及任务队列的长度,自管理只是在设定范围内的动态调整。另外不同的任务可能有不同的并发需求,为了避免互相影响可能需要多个线程池,最后导致的结果就是 Java 的系统里充斥了大量的线程池。

新的思路

从前面的分析我们可以看出,如果线程是一直处于运行状态,我们只需设置和 CPU 核数相等的线程数即可,这样就可以最大化的利用 CPU,并且降低切换成本以及内存使用。但如何做到这一点呢?

陈力就列,不能者止

这句话是说,能干活的代码片段就放在线程里,如果干不了活(需要等待,被阻塞等),就摘下来。通俗的说就是不要占着茅坑不拉屎,如果拉不出来,需要酝酿下,先把茅坑让出来,因为茅坑是稀缺资源。

要做到这点一般有两种方案:

  1. 异步回调方案 典型如 NodeJS,遇到阻塞的情况,比如网络调用,则注册一个回调方法(其实还包括了一些上下文数据对象)给 IO 调度器(Linux 下是 Libev,调度器在另外的线程里),当前线程就被释放了,去干别的事情了。等数据准备好,调度器会将结果传递给回调方法然后执行,执行其实不在原来发起请求的线程里了,但对用户来说无感知。但这种方式的问题就是很容易遇到 callback hell,因为所有的阻塞操作都必须异步,否则系统就卡死了。还有就是异步的方式有点违反人类思维习惯,人类还是习惯同步的方式。
  2. GreenThread/Coroutine/Fiber 方案 这种方案其实和上面的方案本质上区别不大,关键在于回调上下文的保存以及执行机制。为了解决回调方法带来的难题,这种方案的思路是写代码的时候还是按顺序写,但遇到 IO 等阻塞调用时,将当前的代码片段暂停,保存上下文,让出当前线程。等 IO 事件回来,然后再找个线程让当前代码片段恢复上下文继续执行,写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但对程序无感。

GreenThread

  • 用户空间 首先是在用户空间,避免内核态和用户态的切换导致的成本。
  • 由语言或者框架层调度
  • 更小的栈空间允许创建大量实例(百万级别)

几个概念

  • Continuation 这个概念不熟悉 FP 编程的人可能不太熟悉,不过这里可以简单的顾名思义,可以理解为让我们的程序可以暂停,然后下次调用继续(contine)从上次暂停的地方开始的一种机制。相当于程序调用多了一种入口。
  • Coroutine 是 Continuation 的一种实现,一般表现为语言层面的组件或者类库。主要提供 yield,resume 机制。
  • Fiber 和 Coroutine 其实是一体两面的,主要是从系统层面描述,可以理解成 Coroutine 运行之后的东西就是 Fiber。

Goroutine

Goroutine 其实就是前面 GreenThread 系列解决方案的一种演进和实现。

  • 首先,它内置了 Coroutine 机制。因为要用户态的调度,必须有可以让代码片段可以暂停/继续的机制。
  • 其次,它内置了一个调度器,实现了 Coroutine 的多线程并行调度,同时通过对网络等库的封装,对用户屏蔽了调度细节。
  • 最后,提供了 Channel 机制,用于 Goroutine 之间通信,实现 CSP 并发模型(Communicating Sequential Processes)。因为 Go 的 Channel 是通过语法关键词提供的,对用户屏蔽了许多细节。其实 Go 的 Channel 和 Java 中的 SynchronousQueue 是一样的机制,如果有 buffer 其实就是 ArrayBlockQueue。

Goroutine 调度器

这个图一般讲 Goroutine 调度器的地方都会引用,想要仔细了解的可以看看原博客(小编:点击阅读原文获取)。这里只说明几点:

  1. M 代表系统线程,P 代表处理器(核),G 代表 Goroutine。Go 实现了 M : N 的调度,也就是说线程和 Goroutine 之间是多对多的关系。这点在许多GreenThread / Coroutine 的调度器并没有实现。比如 Java 1.1 版本之前的线程其实是 GreenThread(这个词就来源于 Java),但由于没实现多对多的调度,也就是没有真正实现并行,发挥不了多核的优势,所以后来改成基于系统内核的 Thread 实现了。
  2. 某个系统线程如果被阻塞,排列在该线程上的 Goroutine 会被迁移。当然还有其他机制,比如 M 空闲了,如果全局队列没有任务,可能会从其他 M 偷任务执行,相当于一种 rebalance 机制。这里不再细说,有需要看专门的分析文章。
  3. 具体的实现策略和我们前面分析的机制类似。系统启动时,会启动一个独立的后台线程(不在 Goroutine 的调度线程池里),启动 netpoll 的轮询。当有 Goroutine 发起网络请求时,网络库会将 fd(文件描述符)和 pollDesc(用于描述 netpoll 的结构体,包含因为读 / 写这个 fd 而阻塞的 Goroutine)关联起来,然后调用 runtime.gopark 方法,挂起当前的 Goroutine。当后台的 netpoll 轮询获取到 epoll(Linux 环境下)的 event,会将 event 中的 pollDesc 取出来,找到关联的阻塞 Goroutine,并进行恢复。

Goroutine 是银弹么?

Goroutine 很大程度上降低了并发的开发成本,是不是我们所有需要并发的地方直接 go func 就搞定了呢?

Go 通过 Goroutine 的调度解决了 CPU 利用率的问题。但遇到其他的瓶颈资源如何处理?比如带锁的共享资源,比如数据库连接等。互联网在线应用场景下,如果每个请求都扔到一个 Goroutine 里,当资源出现瓶颈的时候,会导致大量的 Goroutine 阻塞,最后用户请求超时。这时候就需要用 Goroutine 池来进行控流,同时问题又来了:池子里设置多少个 Goroutine 合适?

所以这个问题还是没有从更本上解决。

Actor 模型

Actor 对没接触过这个概念的人可能不太好理解,Actor 的概念其实和 OO 里的对象类似,是一种抽象。面对对象编程对现实的抽象是对象 = 属性 + 行为(method),但当使用方调用对象行为(method)的时候,其实占用的是调用方的 CPU 时间片,是否并发也是由调用方决定的。这个抽象其实和现实世界是有差异的。现实世界更像 Actor 的抽象,互相都是通过异步消息通信的。比如你对一个美女 say hi,美女是否回应,如何回应是由美女自己决定的,运行在美女自己的大脑里,并不会占用发送者的大脑。

所以 Actor 有以下特征:

  • Processing – actor 可以做计算的,不需要占用调用方的 CPU 时间片,并发策略也是由自己决定。
  • Storage – actor 可以保存状态
  • Communication – actor 之间可以通过发送消息通讯

Actor 遵循以下规则:

  • 发送消息给其他的 Actor
  • 创建其他的 Actor
  • 接受并处理消息,修改自己的状态

Actor 的目标:

  • Actor 可独立更新,实现热升级。因为 Actor 互相之间没有直接的耦合,是相对独立的实体,可能实现热升级。
  • 无缝弥合本地和远程调用 因为 Actor 使用基于消息的通讯机制,无论是和本地的 Actor,还是远程 Actor 交互,都是通过消息,这样就弥合了本地和远程的差异。
  • 容错 Actor 之间的通信是异步的,发送方只管发送,不关心超时以及错误,这些都由框架层和独立的错误处理机制接管。
  • 易扩展,天然分布式 因为 Actor 的通信机制弥合了本地和远程调用,本地Actor 处理不过来的时候,可以在远程节点上启动 Actor 然后转发消息过去。

Actor 的实现:

  • Erlang/OTP Actor 模型的标杆,其他的实现基本上都一定程度参照了 Erlang 的模式。实现了热升级以及分布式。
  • Akka(Scala,Java)基于线程和异步回调模式实现。由于 Java 中没有 Fiber,所以是基于线程的。为了避免线程被阻塞,Akka 中所有的阻塞操作都需要异步化。要么是 Akka 提供的异步框架,要么通过 Future-callback 机制,转换成回调模式。实现了分布式,但还不支持热升级。
  • Quasar (Java) 为了解决 Akka 的阻塞回调问题,Quasar 通过字节码增强的方式,在 Java 中实现了 Coroutine/Fiber。同时通过 ClassLoader 的机制实现了热升级。缺点是系统启动的时候要通过 javaagent 机制进行字节码增强。

Golang CSP VS Actor

二者的格言都是:

Don’t communicate by sharing memory, share memory by communicating

通过消息通信的机制来避免竞态条件,但具体的抽象和实现上有些差异。

  • CSP 模型里消息和 Channel 是主体,处理器是匿名的。也就是说发送方需要关心自己的消息类型以及应该写到哪个 Channel,但不需要关心谁消费了它,以及有多少个消费者。Channel 一般都是类型绑定的,一个 Channel 只写同一种类型的消息,所以 CSP 需要支持 alt/select 机制,同时监听多个 Channel。Channel 是同步的模式(Golang 的 Channel 支持 buffer,支持一定数量的异步),背后的逻辑是发送方非常关心消息是否被处理,CSP 要保证每个消息都被正常处理了,没被处理就阻塞着。
  • Actor 模型里 Actor 是主体,Mailbox(类似于 CSP 的 Channel)是透明的。也就是说它假定发送方会关心消息发给谁消费了,但不关心消息类型以及通道。所以 Mailbox 是异步模式,发送者不能假定发送的消息一定被收到和处理。Actor 模型必须支持强大的模式匹配机制,因为无论什么类型的消息都会通过同一个通道发送过来,需要通过模式匹配机制做分发。它背后的逻辑是现实世界本来就是异步的,不确定(non-deterministic)的,所以程序也要适应面对不确定的机制编程。自从有了并行之后,原来的确定编程思维模式已经受到了挑战,而 Actor 直接在模式中蕴含了这点。

从这样看来,CSP 的模式比较适合 Boss-Worker 模式的任务分发机制,它的侵入性没那么强,可以在现有的系统中通过 CSP 解决某个具体的问题。它并不试图解决通信的超时容错问题,这个还是需要发起方进行处理。同时由于 Channel 是显式的,虽然可以通过 netchan(原来 Go 提供的 netchan 机制由于过于复杂,被废弃,在讨论新的 netchan)实现远程 Channel,但很难做到对使用方透明。而 Actor 则是一种全新的抽象,使用 Actor 要面临整个应用架构机制和思维方式的变更。它试图要解决的问题要更广一些,比如容错,比如分布式。但Actor的问题在于以当前的调度效率,哪怕是用 Goroutine 这样的机制,也很难达到直接方法调用的效率。当前要像 OO 的『一切皆对象』一样实现一个『一切皆 Actor』的语言,效率上肯定有问题。所以折中的方式是在 OO 的基础上,将系统的某个层面的组件抽象为 Actor。

再扯一下 Rust

Rust 解决并发问题的思路是首先承认现实世界的资源总是有限的,想彻底避免资源共享是很难的,不试图完全避免资源共享,它认为并发的问题不在于资源共享,而在于错误的使用资源共享。比如我们前面提到的,大多数语言定义类型的时候,并不能限制调用方如何使用,只能通过文档或者标记的方式(比如 Java 中的 @ThreadSafe, @NotThreadSafe annotation)说明是否并发安全,但也只能仅仅做到提示的作用,不能阻止调用方误用。虽然 Go 提供了 -race 机制,可以通过运行单元测试的时候带上这个参数来检测竞态条件,但如果你的单元测试并发度不够,覆盖面不到也检测不出来。所以 Rust 的解决方案就是:

  • 定义类型的时候要明确指定该类型是否是并发安全的
  • 引入了变量的所有权(Ownership)概念 非并发安全的数据结构在多个线程间转移,也不一定就会导致问题,导致问题的是多个线程同时操作,也就是说是因为这个变量的所有权不明确导致的。有了所有权的概念后,变量只能由拥有所有权的作用域代码操作,而变量传递会导致所有权变更,从语言层面限制了竞态条件出现的情况。

有了这机制,Rust 可以在编译期而不是运行期对竞态条件做检查和限制。虽然开发的时候增加了心智成本,但降低了调用方以及排查并发问题的心智成本,也是一种有特色的解决方案。

结论

革命尚未成功 同志任需努力

本文带大家一起回顾了并发的问题,和各种解决方案。虽然各家有各家的优势以及使用场景,但并发带来的问题还远远没到解决的程度。所以还需努力,大家也有机会。

最后抛个砖,构想:在 Goroutine 上实现 Actor?

  • 分布式 解决了单机效率问题,是不是可以尝试解决下分布式效率问题?
  • 和容器集群融合 当前的自动伸缩方案基本上都是通过监控服务器或者 LoadBalancer,设置一个阀值来实现的。类似于我前面提到的喂饭的例子,是基于经验的方案,但如果系统内和外部集群结合,这个事情就可以做的更细致和智能。
  • 自管理 前面的两点最终的目标都是实现一个可以自管理的系统。做过系统运维的同学都知道,我们照顾系统就像照顾孩子一样,时刻要监控系统的各种状态,接受系统的各种报警,然后排查问题,进行紧急处理。孩子有长大的一天,那能不能让系统也自己成长,做到自管理呢?虽然这个目标现在看来还比较远,但我觉得是可以期待的。

阅读老王在高可用架构发表的其他文章

点击 阅读原文 可观看本次演讲视频、下载 PPT 及进一步阅读本文相关参考资料链接。

- EOF -


回帖