并发之痛

并发 & 并行

  • 并发(concurrency)关注任务切分

    创业公司开始只有一个人,一人分饰多角,一会做产品规划,一会写代码,一会见客户

    虽然不能见客户的同时写代码,但通过分配时间片,切分了任务,,表现出来好像是多个任务一起在执行

  • 并行(parallelism)关注同时执行

    发现自己太忙了,时间分配不过来,于是请了工程师,产品经理,市场总监,各司其职,这时候多个任务可以同时执行了

为什么并发程序(容错,可扩展)这么难?

用了错误的工具和错误的抽象

程序的抽象

  • 面向过程:数据结构 + func
  • 面向对象:对象组合了数结构和 func,用模拟现实世界的方式,抽象出对象,有状态和行为

func本质上都是代码,本身不包含并发策略定义,通过引入 Thread(线程)概念解决并发需求

线程(Thread)

由kernel调度,更轻量的进程

同一进程的多个线程可共享资源

线程解决的问题

  • GUI 出现后需要并发机制来保证用户界面的响应

  • 互联网发展后带来的用户量问题

早期CGI

单机版程序(脚本)装在进程里,来一个用户就启动一个进程

耗资源且进程间共享资源得用进程间通信机制,线程的出现缓解了这个问题

使用线程

简单,如果觉得代码块需要并发,放单独的线程(池)执行,由系统负责调度

具体什么时候使用线程,要用多少个线程,由调用方决定,但定义方并不清楚调用方会如何使用自己的代码

很多并发问题都是因为误用导致的 (Go 的 map 及 Java 的 HashMap)

复杂度

  • 竞态条件(race conditions)

现实世界可能 任务间非独立,需要共享资源

开发,市场人员同时需要和 CEO 商量一个方案,这时候 CEO 就成了竞态条件

  • 依赖关系以及执行顺序

线程之间的任务有依赖关系,需要等待以及通知机制来进行协调

产品和 CEO 讨论的方案依赖于市场和 CEO 讨论的方案,要协调机制保证顺序

机制的实现

  • Mutex(Lock) (Go sync 包, Java concurrent 包)

通过互斥量保护数据,但有了锁,明显就降低了并发度

  • semaphore

通过信号量来控制并发度或者作为线程间信号(signal)通知

  • volatile

Java 降低只读情况下的锁的使用

  • compare-and-swap

硬件 CAS 机制保证原子性(atomic),降低锁成本的机制


如果说上面两个问题只是增加了复杂度,通过

  • 深入学习
  • 严谨的 CodeReview
  • 全面的并发测试(Go 单元测试加上 - race 参数)

一定程度上能解决(有争议,有论文认为当前的大多数并发程序没出问题只是并发度不够,CPU 核数继续增加,程序运行的时间更长,很难保证不出问题)


但最让人头痛的还是下面这个问题

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

从硬件资源考虑线程的成本

  • 内存(线程栈空间)

每个线程都需要一个栈(Stack)空间来保存挂起(suspending)时的状态

Java (64 位 VM)默认1024k,不算别的内存,只是栈空间,启动 1024 个线程就要 1G 内存

虽然可以用 -Xss 参数控制,但由于线程是本质上也是进程,系统假定是要长期运行的

栈太小会导致稍复杂的递归调用(如复杂正则表达式匹配)导致栈溢出。所以调整参数治标不治本

  • 调度成本(context-switch)

非严格测试,模拟两个线程互相唤醒轮流挂起,线程切换成本大约 6000 纳秒 / 次

还没考虑栈空间大小的影响。论文得出的结论是切换成本和栈空间使用大小直接相关

  • CPU 使用率

提高 CPU 利用率,最大限度的压榨硬件资源,应该用多少线程

1

100(时间片)/(15(运算)+5(解析))*4核=20线程最合适

但网络时间不固定,其他瓶颈资源(锁,数据库连接池)会更复杂


作为一个 1 岁多孩子的父亲,这个问题的难度好比写个给孩子喂饭的程序,需要考虑‘给孩子喂多少饭合适?’

回答以及策略:

  • 不吃了就好了(但孩子贪玩,不吃了可能是想去玩了)

  • 吃饱了就好了(1岁孩子又不会说话)

逐渐增量,长期观察,然后计算一个平均值(调整线程常用的策略,但增量增加到多少合适呢?)

  • 吃吐了就别喂了(如果用逐渐增量的模式,通过外部观察,可能会到达这个边界条件。系统性能如果因为线程的增加倒退了,就别增加线程了)

  • 没控制好边界,把孩子给给撑坏了 (调整线程的时候往往不小心可能就把系统搞挂了)

从外部系统来观察,或者以经验的方式进行计算,都是非常困难的

结论

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

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

从以上讨论得出结论:

  • 线程成本较高(内存,调度)不可能大规模创建

  • 应该由语言或者框架动态解决这个问题


线程池方案

Java1.5 Executor 系列是典型的线程池方案

线程池一定程度上控制了线程的数量,实现线程复用,降低线程使用成本

还是没有解决数量的问题,线程池初始化还要设置最小和最大线程数,以及任务队列长度,自管理只是在设定范围内的动态调整

另外不同任务有不同并发需求为了避免互相影响需要多个线程池,导致系统里充斥了大量的线程池


新的思路

如果线程一直处于运行状态,只需设置和 CPU 核数相等的线程数即可,可最大化利用 CPU,且降低切换成本以及内存使用

陈力就列,不能者止 能干活的代码片段就放在线程里,干不了活(需要等待,被阻塞等)就摘下来

不要占着茅坑不拉屎,如果拉不出来,需要酝酿下,先把茅坑让出来,因为茅坑是稀缺资源


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

  • 异步回调
  • GreenThread/Coroutine/Fiber 方案

异步回调

NodeJS遇阻塞(如网络调用),注册一个回调方法(其实还包括一些上下文数据对象)给 IO 调度器(linux 下是 libev,调度器在另外线程里),当前线程就被释放去干别的事情了

数据准备好,调度器将结果传递给回调方法执行,执行其实不在原来发起请求的线程里了,但对用户来说无感知

但会 callback hell,所有阻塞操作都必须异步,否则进程就卡死了

1

异步方式违反人类同步思维习惯!

GreenThread/Coroutine/Fiber

回调上下文保存及执行机制 的区别

为了解决回调方法带来的难题,写代码还是按顺序写,但遇到 IO 等阻塞调用时,将当前的代码片段暂停,保存上下文,让出当前线程

等 IO 事件回来,然后再找个线程让当前代码片段恢复上下文继续执行,写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但对程序无感知

GreenThread

  • 在用户空间,避免内核态和用户态切换成本

  • 由语言或者框架层调度

  • 更小的栈空间允许创建大量实例(百万级别)

Continuation

理解为让程序可以暂停,然后下次调用继续(contine)从上次暂停的地方开始的一种机制。相当于程序调用多了一种入口

Coroutine 是 Continuation 的一种实现,一般表现为语言层面的组件或者类库。主要提供 yield,resume 机制

Fiber 和 Coroutine 其实是一体两面的,主要是从系统层面描述,可以理解成 Coroutine 运行之后的东西就是 Fiber

Goroutine

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

  • 内置了 Coroutine 机制(用户态调度,有让代码片段可以暂停 / 继续的机制)

  • 内置了一个调度器,实现了 Coroutine 的多线程并行调度,同时通过对网络等库的封装,对用户屏蔽了调度细节

  • 提供了 Channel 机制,用于 Goroutine 之间通信,实现 CSP 并发模型

Channel 通过语法关键词提供,对用户屏蔽细节

Go 的 Channel 和 Java 中的 SynchronousQueue 是一样的机制,如果有 buffer 其实就是 ArrayBlockQueue


Goroutine 是银弹么?

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

Goroutine 调度解决了 CPU 利用率的问题。但其他的瓶颈资源(带锁的共享资源,数据库连接)如何处理?

互联网在线应用场景下,如果每个请求都扔到一个 Goroutine 里,当资源出现瓶颈的时候,会导致大量的 Goroutine 阻塞,最后用户请求超时

这时就需要用 Goroutine 池来进行控流,同时问题又来了:池子里设置多少个 Goroutine 合适?

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


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 的阻塞回调问题,通过字节码增强的方式,在 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(太复杂被废弃)实现远程 Channel,但很难做到对使用方透明

而 Actor 则是一种全新的抽象,使用 Actor 要面临整个应用架构机制和思维方式的变更

它试图要解决的问题要更广一些,比如容错,比如分布式

但 Actor 的问题在于以当前的调度效率,哪怕是用 Goroutine 这样的机制,也很难达到直接方法调用的效率

当前要像 OO 的‘一切皆对象’一样实现一个‘一切皆 Actor’的语言,效率上肯定有问题。所以折中的方式是在 OO 的基础上,将系统的某个层面的组件抽象为 Actor

Rust

解决并发问题的思路是首先承认现实世界的资源总是有限的,想彻底避免资源共享是很难的,不试图完全避免资源共享,并发的问题不在于资源共享,而在于错误的使用资源共享

大多数语言定义类型的时候,并不能限制调用方如何使用,只能通过文档或者标记的方式(Java @ThreadSafe ,@NotThreadSafe annotation)说明是否并发安全,但也只能仅仅做到提示的作用,不能阻止调用方误用

虽然 Go 提供了 - race 机制,可以通过运行单元测试的时候带上这个参数来检测竞态条件,但如果你的单元测试并发度不够,覆盖面不到也检测不出来

所以 Rust 的解决方案就是:

定义类型的时候要明确指定该类型是否是并发安全的

引变量的所有权(Ownership)概念

非并发安全的数据结构在多个线程间转移,也不一定就会导致问题,导致问题的是多个线程同时操作,也就是说是因为这个变量的所有权不明确导致的

有了所有权的概念后,变量只能由拥有所有权的作用域代码操作,而变量传递会导致所有权变更,从语言层面限制了竞态条件出现的情况

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

结论

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

回顾了并发的问题,和各种解决方案

虽然各家有各家的优势以及使用场景,但并发带来的问题还远远没到解决的程度

所以还需努力,大家也有机会啊