并发之痛 Thead、Goroutine、Actor
博文笔记
来自于王渊命演讲
并发(concurrency)与并行(parallelism)
并发的关注点在于任务切分。多个任务看上去并行执行。并发不要求必须并行,可以用时间片切分的方式模拟。并发的要求是任务能切分成独立执行的片段。并行的关注点在于同时执行,必须是多(核)CPU。并发在乎结构,并行在乎执行。
并发程序为什么难
写正确的并发,容错,可扩展的程序如此之难,是因为我们使用了错误的工具和错误的抽象。
程序是面向过程的,数据结构+func,后来有了对象,组合了数据结构和func,我们用模拟世界的方式,抽象出对象,有状态和行为。无论是面向过程的func还是面向对象的func,本质上都是代码块的组织单元,本身并没有包含代码块的并发策略的定义。因此引入线程(Thread):
- 系统内核态,更轻量的进程
- 由系统内核进行调度
- 同一进程的多个线程可以共享资源
线程的出现,解决了GUI的响应以及互联网发展带来的多用户问题。
线程的使用比较简单,使用多少线程,什么时候使用线程由使用者决定,但是代码如何调用则由系统决定,因此容易产生不少问题,带来不少复杂度
- 竞态条件,任务之间需要共享资源
- 依赖关系以及执行顺序,需要等待以及通知机制用于协调
引入不少复杂机制来保证正确:
- Mutex,降低了并发度
- Semaphore,通信
- Volatile,JAVA降低只读情况下锁的使用
- Compare-and-swap,硬件提供CAS机制保证原子性
系统到底需要多少线程?
- 内存,每个线程都需要一个栈空间来保持挂起时的状态,太小可能溢出
- 调度成本,切换成本和栈空间使用大小直接相关
- CPU使用率
线程的多少不好控制,使用不够,使用过多导致崩溃等从外部系统来观察或以经验的方式进行计算,都是比较困难的。
结论:线程的成本较高,不可能大规模创建;应该由语言或者框架动态解决这个问题
Java引入线程池,但是并没有完全解决问题
新思路
如果线程一直处于运行状态,只需设置和核数一样的线程数就可。
陈力就列,不能者止
两种方案:
- 异步回调方案,遇到阻塞的时候注册回调方法(其实还有上下文处理对象)给IO调度器,当前线程释放。等数据准备好了之后,将结果传递给回调方法执行,其实执行在其他的线程中,用户不感知。问题是Callback hell
- GreenThread/Coroutine/Fiber方案,该方案与回调类似,关键在于回调上下文的保存以及执行机制。写代码时按照顺序写,遇到阻塞时,暂停,保存上下文,让出当前线程。恢复后再找个线程让当前代码片段恢复上下文继续执行。
GreenThread
用户空间,避免用户态和内核态的切换成本
由语言或者框架层调度
更小的栈空间允许创建大量实例(百万级别)
几个概念
- continuation,程序可以暂停,然后下次调用从上次暂停地方开始,相当于程序调用多了一种入口
- corontine,是continuation的一种实现,一般表现为语言层面的组件或者类库,主要提供yield,resume
- Fiber和Coroutine是一体两面,从系统层面描述,可以理解成coroutine运行之后就是Fiber
Goroutine
Goroutine是GreenThread系列解决方案的一种演进和实现
- 内置了Coroutine机制
- 内置了调度器,实现了Coroutine的多线程并行调度,同时通过对网络等库的封装,多用户屏蔽了调度细节
- 提供channel机制,用于Goroutine之间通信,实现CSP并发模型(Communicating Sequential Processes)
调度说明
- Go实现了M:N的调度,线程和Goroutine之间是多对多的关系,这样可以发挥多核优势
- 某个系统线程如果被阻塞,排列在该线程上的Goroutine会被迁移
- 系统启动时,会启动独立的后台线程(不在Goroutine的调度池内),启动netpoll的轮询,当有goroutine发起网络请求时,会关联其fd,并挂起其当前的goroutine,获得epoll的event后再重启。
Goroutine解决了CPU利用率的问题,其他的系统瓶颈(带锁的共享资源、数据库链接等)。如果每个请求都扔给一个goroutine,当瓶颈出现时会导致大量goroutine堵塞,导致超时,这时候又要对Goroutine池进行流控,又回到之前的问题:池子里设置多少个Goroutine合适?
Actor模型
Actor有如下特征:
- Processing,actor可以做计算的,不需要占用调用方的CPU时间片,并发策略也是由自己决定
- Storage,actor可以保存状态
- Communication,actor之间可以通过发送消息通信
Actor遵循如下规则:
- 发送消息给其他的actor
- 创建其他的actor
- 接受并处理消息,修改自己的状态
Actor的目标:
- actor可独立更新,实现热升级。之间无直接耦合
- 无缝弥合本地与远程调用,使用基于消息的通信机制
- 容错,actor之间的通信是异步的,发送方只管发送,不关心超时以及错误,这些由框架层和独立的错误处理机制接管
- 易扩展,天然分布式,本地处理不过来时,可以远程节点上启动actor,然后转发消息过去
Actor实现:
- Erlang/OTP Actor模型的标杆,实现了热升级以及分布式
- Akka(Scala、Java) 基于线程(Java中无Fiber)和异步回调模式(避免线程被阻塞)实现。实现了分布式,不支持热升级
- Quasar(Java) 通过字节码增强的方式,在Java中实现了Coroutine/Fiber。通过ClassLoad的机制实现了热升级,缺点是系统启动的时候需要通过java agent机制进行字节码增强
Golang CSP vs Actor
两者都是通过消息通信的机制来避免竞态条件,但具体的抽象和实现上有些差异
- CSP模型里面,消息和Channel是主体,处理器是匿名的
发送方关心消息类型,以及该写到哪个Channel,不关心谁消费以及多少个消费者,一个Channel只写一种类型的消息,所以CSP需要支持alt/selcet机制,同时监听多个Channel。Channel是同步的模式(Golang的Channel支持buffer,支持一定数量的异步),背后的逻辑是发送方非常关心消息是否被处理,CSP保证每个消息都被正常处理,没被处理就阻塞着
- Actor模型里,Actor是主体,Mailbox是透明的
假定发送方关心消息发给谁,不关心类型以及通道。所以是异步模式,不能假定消息一定会被收到和处理。必须支持强大的模式匹配机制,对消息进行分发。背后的逻辑是现实世界本来就应该是异步的,不确定的
CSP的机制比较适合BOSS-Worker模式的任务分发机制,侵入性没有那么强。不试图解决通信的超时容错问题,还是需要发起方进行处理。由于Channel是显式的,当前难透明使用远程Channel。
Actor是一种全新的抽象,面临应用架构机制和思维方式的变更,试图解决容错、分布式。效率无法达到直接调用的效率。折中的方式是将系统的某个层面的组件抽象成Actor。
Rust
Rust解决并发的思路是首先承认世界的资源是有限的,想彻底避免是不可能的。不试图完全避免资源共享,认为并发的问题不在于资源共享,在于错误的使用资源共享。
- 定义类型的时候要明确指定该类型是否是并发安全的
- 引入变量的所有权概念,非并发安全的数据结构在多个线程间转移,不一定会导致问题,导致问题的是多个线程同时操作,也就是变量的所有权不明确导致。引入后,变量传递会导致所有权变更,从语言层面限制竞态条件出现。
结论
构想:在Goroutine上实现actor
- 分布式解决了单机效率问题,是不是可以尝试解决下分布式效率问题
- 和容器集群融合,当前的自动伸缩方案基本上都是通过监控服务器或者loadbalance设置阀值实现,是基于经验的方式
- 自管理,实现一个可以自管理的系统