并发(concurrency) 并发的关注点在于任务切分。举例来说,你是一个创业公司的CEO,开始只有你一个人,你一人分饰多角,一会做产品规划,一会写代码,一会见客户,虽然你不能见客户的同时写代码,但由于你切分了任务,分配了时间片,表现出来好像是多个任务一起在执行,任务交替执行。
并行(parallelism) 并行的关注点在于同时执行。还是上面的例子,你发现你自己太忙了,时间分配不过来,于是请了工程师,产品经理,市场总监,各司一职,这时候多个任务可以同时执行了。
所以总结下,并行只是并发的一种特殊情况,并发并不要求必须并行,可以用时间片切分的方式模拟,比如单核 CPU 上的多任务系统,并发的要求是任务能切分成独立执行的片段。而并行关注的是同时执行,必须是多(核)CPU,要能并行的程序必须是支持并发的。大多数情况下不会严格区分这两个概念,因为并行只是并发的一种特殊情况,并行需要考虑的所有共享冲突,竞争等问题,并发一样需要考虑。
为什么并发程序这么难?
We believe that writing correct concurrent, fault-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)
系统内核态,更轻量的进程
由系统内核进行调度
同一进程的多个线程可共享资源
线程的出现解决了两个问题,一个是 GUI (web编程)出现后急切需要并发机制来保证用户界面的响应。第二是互联网发展后带来的多用户问题。最早的 CGI 程序很简单,将通过脚本将原来单机版的程序包装在一个进程里,来一个用户就启动一个进程。但明显这样承载不了多少用户,并且如果进程间需要共享资源还得通过进程间的通信机制,线程的出现缓解了这个问题。
线程的使用比较简单,如果你觉得这块代码需要并发,就把它放在单独的线程里执行,由系统负责调度,具体什么时候使用线程,要用多少个线程,由调用方决定,但定义方并不清楚调用方会如何使用自己的代码,很多并发问题都是因为误用导致的,比如 Go 中的 map 以及 Java 的 HashMap 都不是并发安全的,误用在多线程环境就会导致问题。另外也带来复杂度:
竞态条件(race conditions) 如果每个任务都是独立的,不需要共享任何资源,那线程也就非常简单。但世界往往是复杂的,总有一些资源需要共享,比如前面的例子,开发人员和市场人员同时需要和 CEO 商量一个方案,这时候 CEO 就成了竞态条件。
依赖关系以及执行顺序 如果线程之间的任务有依赖关系,需要等待以及通知机制来进行协调。比如前面的例子,如果产品和 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 个线程最合适。但一方面网络的时间不是固定的,另外一方面,如果考虑到其他瓶颈资源呢?比如锁,比如数据库连接池,就会更复杂。
但我们从以上的讨论可以得出一个结论:
线程的成本较高(内存,调度)不可能大规模创建
应该由语言或者框架动态解决这个问题
线程池方案
Java 1.5 后,Doug Lea 的 Executor 系列被包含在默认的 JDK 内,是典型的线程池方案。
线程池一定程度上控制了线程的数量,实现了线程复用,降低了线程的使用成本。但还是没有解决数量的问题,线程池初始化的时候还是要设置一个最小和最大线程数,以及任务队列的长度,自管理只是在设定范围内的动态调整。另外不同的任务可能有不同的并发需求,为了避免互相影响可能需要多个线程池,最后导致的结果就是 Java 的系统里充斥了大量的线程池。
新的思路
从前面的分析我们可以看出,如果线程是一直处于运行状态,我们只需设置和 CPU 核数相等的线程数即可,这样就可以最大化的利用 CPU,并且降低切换成本以及内存使用。但如何做到这一点呢?
要做到这点一般有两种方案:
异步回调方案 典型如 NodeJS,遇到阻塞的情况,比如网络调用,则注册一个回调方法(其实还包括了一些上下文数据对象)给 IO 调度器(Linux 下是 Libev,调度器在另外的线程里),当前线程就被释放了,去干别的事情了。等数据准备好,调度器会将结果传递给回调方法然后执行,执行其实不在原来发起请求的线程里了,但对用户来说无感知。但这种方式的问题就是很容易遇到 callback hell,因为所有的阻塞操作都必须异步,否则系统就卡死了。还有就是异步的方式有点违反人类思维习惯,人类还是习惯同步的方式。
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 调度器的地方都会引用,想要仔细了解的可以看看原博客(小编:点击阅读原文获取)。这里只说明几点:
M 代表系统线程,P 代表处理器(核),G 代表 Goroutine。Go 实现了 M : N 的调度,也就是说线程和 Goroutine 之间是多对多的关系。这点在许多GreenThread / Coroutine 的调度器并没有实现。比如 Java 1.1 版本之前的线程其实是 GreenThread(这个词就来源于 Java),但由于没实现多对多的调度,也就是没有真正实现并行,发挥不了多核的优势,所以后来改成基于系统内核的 Thread 实现了。
某个系统线程如果被阻塞,排列在该线程上的 Goroutine 会被迁移。当然还有其他机制,比如 M 空闲了,如果全局队列没有任务,可能会从其他 M 偷任务执行,相当于一种 rebalance 机制。这里不再细说,有需要看专门的分析文章。
具体的实现策略和我们前面分析的机制类似。系统启动时,会启动一个独立的后台线程(不在 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,设置一个阀值来实现的。类似于我前面提到的喂饭的例子,是基于经验的方案,但如果系统内和外部集群结合,这个事情就可以做的更细致和智能。
自管理 前面的两点最终的目标都是实现一个可以自管理的系统。做过系统运维的同学都知道,我们照顾系统就像照顾孩子一样,时刻要监控系统的各种状态,接受系统的各种报警,然后排查问题,进行紧急处理。孩子有长大的一天,那能不能让系统也自己成长,做到自管理呢?虽然这个目标现在看来还比较远,但我觉得是可以期待的。
作者:jolestar
链接:https://www.zhihu.com/question/28080077/answer/89076444
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
其实比较Akka和goroutine并不太合适,二者不是同一个层面的东西。应该比较Akka的Actor模式和Go的CSP模式。二者都是通过消息通信的机制来避免竞态条件,但具体的抽象和实现上有些差异。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。如果纯粹从调度的层面分析,正如 @邱鹏滔 的回答,Akka是在线程池基础上实现调度的,但线程是有限的,所以Akka的Actor中要避免任何阻塞操作,要么用Akka提供的异步框架,要么通过Future-callback机制,转换成回调模式。而Goroutine是用户态的线程,创建和切换成本都比较小,可以把异步的callback机制转换为同步模式,对开发比较友好些。
Akka/Erlang的actor模型与Go语言的协程Goroutine与通道Channel代表的CSP(Communicating Sequential Processes)模型有什么区别呢?
首先这两者都是并发模型的解决方案,我们看看Actor和Channel这两个方案的不同:
Actor模型
在Actor模型中,主角是Actor,类似一种worker,Actor彼此之间直接发送消息,不需要经过什么中介,消息是异步发送和处理的:
actor模型
Actor模型描述了一组为了避免并发编程的常见问题的公理:
1.所有Actor状态是Actor本地的,外部无法访问。
2.Actor必须只有通过消息传递进行通信。
3.一个Actor可以响应消息:推出新Actor,改变其内部状态,或将消息发送到一个或多个其他参与者。
4.Actor可能会堵塞自己,但Actor不应该堵塞它运行的线程。
更多可见Actor模型专题
Channel模型
Channel模型中,worker之间不直接彼此联系,而是通过不同channel进行消息发布和侦听。消息的发送者和接收者之间通过Channel松耦合,发送者不知道自己消息被哪个接收者消费了,接收者也不知道是哪个发送者发送的消息。
channel模型
Go语言的CSP模型是由协程Goroutine与通道Channel实现:
Go协程goroutine: 是一种轻量线程,它不是操作系统的线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。是一种绿色线程,微线程,它与Coroutine协程也有区别,能够在发现堵塞后启动新的微线程。
通道channel: 类似Unix的Pipe,用于协程之间通讯和同步。协程之间虽然解耦,但是它们和Channel有着耦合。
Actor模型和CSP区别
Actor模型和CSP区别图如下:
Actor之间直接通讯,而CSP是通过Channel通讯,在耦合度上两者是有区别的,后者更加松耦合。
同时,它们都是描述独立的进程通过消息传递进行通信。主要的区别在于:在CSP消息交换是同步的(即两个进程的执行”接触点”的,在此他们交换消息),而Actor模型是完全解耦的,可以在任意的时间将消息发送给任何未经证实的接受者。由于Actor享有更大的相互独立,因为他可以根据自己的状态选择处理哪个传入消息。自主性更大些。
在Go语言中为了不堵塞进程,程序员必须检查不同的传入消息,以便预见确保正确的顺序。CSP好处是Channel不需要缓冲消息,而Actor理论上需要一个无限大小的邮箱作为消息缓冲。
Use locks and shared memory to shoot yourself in the foot in parallel. (From https://wiki.python.org/moin/Concurrency/Patterns )
再丢一句
Do not communicate by sharing memory; instead, share memory by communicating. (From Effective Go)
缘由以及很大一段题外话
蠢作者第一次接触到并发模型这种概念是在 七周七并发模型 这本书中。不过先扯点题外话吧,来一个再常见不过的问题,并发和并行有什么区别?
蠢作者所在学校的操作系统教材 计算机操作系统(第四版) 中第 13 页对这两个概念进行了区分:
并行性和并发性是既相似又有区别的两个概念。并行性是指两个或多个事件在同一时刻发生。而并发性是指两个或多个事件在同一时间间隔内发生。
可能这也是大部分人的答案,或者有人还会总结出并行一定并发,并发不一定并行,单核计算机只能并发执行这种经过一番推敲的答案。这个答案无疑是正确的,但却也是一个不够完美的答案。因为它只是从任务层级(task-level)上去描述这两个概念,如果你了解计算机偏向硬件方面的知识的话, 你可能会听过并行加法器这种组件。其实现代计算机在不同层次上都使用了并行技术。
下面引用自 七周七并发模型
位级(bit-level)并行
为什么32位计算机的运行速度比8位计算机更快?因为并行。对于两个32位数的加法,8位计算机必须进行多次8位计算,而32位计算机可以一步完成,即并行地处理32位数的4字节。 计算机的发展经历了8位、16位、32位,现在正处于64位时代。然而由位升级带来的性能改善是存在瓶颈的,这也正是短期内我们无法步入128位时代的原因。
指令级(instruction-level)并行
现代CPU的并行度很高,其中使用的技术包括流水线、乱序执行和猜测执行等。 程序员通常可以不关心处理器内部并行的细节,因为尽管处理器内部的并行度很高,但是经过精心设计,从外部看上去所有处理都像是串行的。 而这种“看上去像串行”的设计逐渐变得不适用。处理器的设计者们为单核提升速度变得越来越困难。进入多核时代,我们必须面对的情况是:无论是表面上还是实质上,指令都不再串行执行了。我们将在2.2节的“内存可见性”部分展开讨论。
数据级(data)并行
数据级并行(也称为“单指令多数据”,SIMD)架构,可以并行地在大量数据上施加同一操作。这并不适合解决所有问题,但在适合的场景却可以大展身手。 图像处理就是一种适合进行数据级并行的场景。比如,为了增加图片亮度就需要增加每一个像素的亮度。现代GPU(图形处理器)也因图像处理的特点而演化成了极其强大的数据并行处理器。
任务级(task-level)并行
这里略掉吧
所以即使是单任务的情况下也是会存在并行的,广义上的并行和并发不存在子集关系。并发偏向于逻辑,并行更偏向于物理。
回到正题,传统并发有什么不好?
我想如果你写过多线程,你可能会知道线程安全问题,然后去用锁,然后去查看是否有死锁/活锁;如果你选择了多进程,那么需要考虑进程通信问题。总之当你想要用并发去解决问题,反而会引入另一些问题……
并发模型并没有改变依赖多线程/多进程的实质,只是提供了一种避免麻烦的手段。
有了以上准备,开始正题
Actor 模型
Actor 模型是一种适用性非常好的通用并发编程模型。它可以应用于共享内存架构和分布式内存架构,适合解决地理分布型的问题。同时它还能提供很好的容错性。
我们都知道在(线程)并发中,共享的可变状态会引入诸多麻烦。对此有许多解决方法,比如常见的锁,事务,函数式编程中则直接使用了不可变状态。而 Actor 模型则允许可变状态,只是只通过消息传递的方式来进行状态的改变。Actor 模型由一个个称为 Actor 的执行体和 mailbox 组成。用户将消息发送给 Actor,实际上就是将消息放入一个队列中, 然后将其转交给处理被接受消息的一个内部线程。消息让 Actor 之间解耦。一个 Actor 收到其他 Actor 的消息后,会做出不同的行为,还可能会给其他 Actor 发送更进一步的消息。
下面代码来自 Python3 CookBook
actor.py
from queue import Queue
from threading import Thread, Event
class ActorExit(Exception):
pass
class Actor(object):
def __init__(self):
self._mailbox = Queue()
def send(self, msg):
self._mailbox.put(msg)
def recv(self):
msg = self._mailbox.get()
if msg is ActorExit:
raise ActorExit()
return msg
def close(self):
self.send(ActorExit)
def start(self):
self._terminated = Event()
t = Thread(target=self._bootstrap)
t.daemon = True
t.start()
def _bootstrap(self):
try:
self.run()
except ActorExit:
pass
finally:
self._terminated.set()
def join(self):
self._terminated.wait()
def run(self):
'''
Run method to be implemented by the user
'''
while True:
msg = self.recv() demo.py
from actor import Actor
from threading import Event
class Result(object):
def __init__(self):
self._evt = Event()
self._result = None
def set_result(self, value):
self._result = value
self._evt.set()
@property
def result(self):
self._evt.wait()
return self._result
class Worker(Actor):
def submit(self, func, *args, **kwargs):
r = Result()
self.send((func, args, kwargs, r))
return r
def run(self):
while True:
func, args, kwargs, r = self.recv()
r.set_result(func(*args, **kwargs))
if name == ‘main’:
worker = Worker()
worker.start()
r = worker.submit(pow, 2, 4)
print(‘it will not block’)
print(r.result)
上面只是一个简单的示例 Actor 的 send 方法可以更改为在套接字上传输数据或者通过消息队列作为中间层 比如 RabbitMQ 来发送。
CSP 模型
CSP 为 [communicating sequential processes] 的缩写,有趣的是它还是代数演算,见 wiki 。与 Actor 模型类似,CSP 模型也是由独立的、并发执行的实体所组成,实体之间也是通过发送消息进行通信。但两种模型的差别是:CSP 模型不关注发送消息的实体,而是关注发送消息时使用的 channel。这个区别之后会再次提到。
写出来大概是下面这样,摘自 Taipei Pycon 2016
from threading import Thread
from queue import Queue
from time import sleep
def run_thread(func, *args, **kwargs):
t = Thread(target=func, args=args, kwargs=kwargs)
t.start()
return t
def work_for(channel):
while True:
cmd = channel.get()
if cmd == ‘return’:
return
print('Start working on {} ...'.format(cmd))
sleep(1)
print('Done')
if name == ‘main’:
channel = Queue()
for i in range(3):
run_thread(work_for, channel)
for i in range(6):
channel.put(i)
for i in range(3):
channel.put('return') WTF,这个好像和平时利用 Queue 的多线程没有什么区别啊?的确,我们无意之间已经在使用 CSP 了。
再来看看 Golang 的,Golang 自身支持了CSP,通过一个叫 goroutine 的作为执行实体。实际上就是协程。
Go 版本代码
package main
import “fmt”
var ch = make(chan string)
func message(){
msg := <- ch
fmt.Println(msg)
}
func main(){
go message()
ch <- “Hello World.”
}
所以,你看懂 Actor 和 CSP 两者的区别了么?
个人认为,这两者是十分相近的概念,它们都着眼于消息传递,而且都使用了 channel/mailbox 这样的信道(队列)和 Actor/goroutine 这样的执行实体。只不过 Actor 模型将执行实体和 mailbox 进行了耦合,一个 Actor 用一个 mailbox;而 CSP 则可以绑定多个 channel(类似发布者/订阅者模型)。另外有人说,向 Actor 发送消息时是非阻塞操作,而 CSP 如果执行体正忙则会产生阻塞。这点我更觉得奇怪了,感觉有点牵强,不太清楚这种设计的理由。只是有一点是可以肯定的:CSP 中的 channel 通常是匿名的, 即任务放进 channel 之后你并不需要知道是哪个 channel 在执行任务,而 Actor 是有”身份”的,你可以明确的知道哪个 Actor 在执行任务。
Reference
Golang CSP并发模型 七周七并发模型 并发编程:Actors模型和CSP模型
Actor模型和CSP模型的区别
Akka/Erlang的actor模型与Go语言的协程Goroutine与通道Channel代表的CSP(Communicating Sequential Processes)模型有什么区别呢?
首先这两者都是并发模型的解决方案,我们看看Actor和Channel这两个方案的不同:
Actor模型
在Actor模型中,主角是Actor,类似一种worker,Actor彼此之间直接发送消息,不需要经过什么中介,消息是异步发送和处理的:
actor模型
Actor模型描述了一组为了避免并发编程的常见问题的公理:
1.所有Actor状态是Actor本地的,外部无法访问。
2.Actor必须只有通过消息传递进行通信。
3.一个Actor可以响应消息:推出新Actor,改变其内部状态,或将消息发送到一个或多个其他参与者。
4.Actor可能会堵塞自己,但Actor不应该堵塞它运行的线程。
更多可见Actor模型专题
Channel模型
Channel模型中,worker之间不直接彼此联系,而是通过不同channel进行消息发布和侦听。消息的发送者和接收者之间通过Channel松耦合,发送者不知道自己消息被哪个接收者消费了,接收者也不知道是哪个发送者发送的消息。
channel模型
Go语言的CSP模型是由协程Goroutine与通道Channel实现:
Go协程goroutine: 是一种轻量线程,它不是操作系统的线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。是一种绿色线程,微线程,它与Coroutine协程也有区别,能够在发现堵塞后启动新的微线程。
通道channel: 类似Unix的Pipe,用于协程之间通讯和同步。协程之间虽然解耦,但是它们和Channel有着耦合。
Actor模型和CSP区别
Actor模型和CSP区别图如下:
Actor之间直接通讯,而CSP是通过Channel通讯,在耦合度上两者是有区别的,后者更加松耦合。
同时,它们都是描述独立的流程通过消息传递进行通信。主要的区别在于:在CSP消息交换是同步的(即两个流程的执行”接触点”的,在此他们交换消息),而Actor模型是完全解耦的,可以在任意的时间将消息发送给任何未经证实的接受者。由于Actor享有更大的相互独立,因为他可以根据自己的状态选择处理哪个传入消息。自主性更大些。
在Go语言中为了不堵塞流程,程序员必须检查不同的传入消息,以便预见确保正确的顺序。CSP好处是Channel不需要缓冲消息,而Actor理论上需要一个无限大小的邮箱作为消息缓冲。
Akka是基于线程池实现的actor,如果你的actor里存在长时间的io阻塞导致线程耗尽,会使所有的actor都卡住,所以Akka是很害怕那种长时间io阻塞的操作。
scala中actor是简单版本的actor实现
akka是另一个独立的actor, 比scala自带的强大的多, 当然也是scala写的
很多有过 JVM 相关语言工作经验的程序员或许都遇到过如下问题:
[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread:
[error] java.lang.OutOfMemoryError: unable to create native thread:
[error] at java.base/java.lang.Thread.start0(Native Method)
[error] at java.base/java.lang.Thread.start(Thread.java:813)
…
[error] at java.base/java.lang.Thread.run(Thread.java:844)
额,超出 thread 限制导致内存溢出。在作者的笔记本的 linux 上运行,这种情况一般发生在创建了 11500 个左右的 thread 时候。
但如果你用 Go 语言来做类似的尝试,每创建一个 Goroutine ,并让它永久的 Sleep ,你会得到一个完全不同的结果。在作者的笔记本上,在作者等待的不耐烦之前,GO语言创建了大约7千万个 Goroutine 。为什么我们可以创建的 Goroutines 比 thread 多这么多呢?回答这个问题需要回到操作系统层面来进行一次愉快的探索。这不仅仅是一个学术问题—在现实世界中它也揭示了如何进行软件设计。事实上,作者碰到过很多次软件出现 JVM 的 Thread 达到上限的情况,要么是因为垃圾代码导致 Thread 泄露,要么就是因为一些开发工程师压根不知道 JVM 有 Thread 限制这回事。
那么到底什么是 Thread ?
“Thread” 本身其实可以代表很多不同的意义。在这篇文章中,作者把它描述为一种逻辑上的 Thread。Thread 由如下内容组成:一系列按照线性顺序可以执行的指令(operations);和一个逻辑上可以执行的路径。CPUs 中的每一个 Core 在同一时刻只能真正并发执行一个 logic thread[1]。这就产生了一个结论:如果你的 threads 个数大于 CPU 的 Core 个数的话,有一部分的 Threads 就必须要暂停来让其他 Threads 工作,直到这些 Threads 到达一定的时机时才会被恢复继续执行。而暂停和恢复一个线程,至少需要记录两件事情:
当前执行的指令位置。亦称为:说当前线程被暂停时,线程正在执行的代码行;
还需要一个栈空间。 亦可认为:这个栈空间保存了当前线程的状态。一个栈包含了 local 变量也就是一些指针指向堆内存的变量(这个是对于 Java 来说的,对于 C/C++ 可以存储非指针)。一个进程里面所有的 threads 是共享一个堆内存的[2]。
有了上面两样东西后,cpu 在调度 thread 的时候,就有了足够的信息,可以暂停一个 thread,调度其他 thread 运行,然后再将暂停的 thread 恢复,从而继续执行。这些操作对于 thread 来说通常是完全透明的。从 thread 的角度来看,它一直都在连续的运行着。thread 被取消调度这样的行为可以被观察的唯一办法就是测量后续操作的时间[3]。
让我们回到最初的问题,为什么我们可以创建那么多的 Goroutinues 呢?
JVM使用的是操作系统的Thread
尽管规范没有要求所有现代的通用 JVM,在我所知道的范围内,当前市面上所有的现代通用目的的 JVM 中的 thread 都是被设计成为了操作系统的thread。下面,我将使用“用户空间 threads” 的概念来指代被语言来调度而不是被操作系统内核调度的 threads。操作系统级别实现的 threads 主要有如下两点限制:首先限制了 threads 的总数量,其次对于语言层面的 thread 和操作系统层面的 thread 进行 1:1 映射的场景,没有支持海量并发的解决方案。
JVM 中固定的栈大小
使用操作系统层面的 thread,每一个 thread 都需要耗费静态的大量的内存
第二个使用操作系统层面的 thread 所带来的问题是,每一个 thread 都需要一个固定的栈内存。虽然这个内存大小是可以配置的,但在 64 位的 JVM 环境中,一个 thread 默认使用1MB的栈内存。虽然你可以将默认的栈内存大小改小一点,但是您会权衡内存使用情况, 从而增加堆栈溢出的风险。在你的代码中递归次数越大,越有可能触发栈溢出。如果使用1MB的栈默认值,那么创建1000个 threads ,将使用 1GB 的 RAM ,虽然 RAM 现在很便宜,但是如果要创建一亿个 threads ,就需要T级别的内存。
Go 语言的处理办法:动态大小的栈
Go 语言为了避免是使用过大的栈内存(大部分都是未使用的)导致内存溢出,使用了一个非常聪明的技巧:Go 的栈大小是动态的,随着存储的数据大小增长和收缩。这不是一件简单微小的事情,这个特性经过了好几个版本的迭代开发[4]。很多其他人的关于 Go 语言的文章中都已经做了详细的说明,本文不打算在这里讨论内部的细节。结果就是新建的一个 Goroutine 实际只占用 4KB 的栈空间。一个栈只占用 4KB,1GB 的内存可以创建 250 万个 Goroutine,相对于 Java 一个栈占用 1MB 的内存,这的确是一个很大的提高。
在 JVM 中上下文的切换是很慢的
使用操作系统的 threads 的最大能力一般在万级别,主要消耗是在上下文切换的延迟。
因为 JVM 是使用操作系统的 threads ,也就是说是由操作系统内核进行 threads 的调度。操作系统本身有一个所有正在运行的进程和线程的列表,同时操作系统给它们中的每一个都分配一个“公平”的使用 CPU 的时间片[5]。当内核从一个 thread 切换到另外一个时候,它其实有很多事情需要去做。新线程或进程的运行必须以世界的视角开始,它可以抽象出其他线程在同一 CPU 上运行的事实。本文不想在这里多说,但是如果你感兴趣的话,可以参考这里。(t 问题的关键点是上下文的切换大概需要消耗 1-100µ 秒。这个看上去好像不是很耗时,但是在现实中每次平均切换需要消耗10µ秒,如果想让在一秒钟内,所有的 threads 都能被调用到,那么 threads 在一个 core 上最多只能有 10 万个 threads,而事实上这些 threads 自身已经没有任何时间去做自己的有意义的工作了。
Go 语言完全不同的处理:运行多个 Goroutines 在一个 OS thread 上
Golang 语言本身有自己的调度策略,允许多个 Goroutines 运行在一个同样的 OS thread 上。既然 Golang 能像内核一样运行代码的上下文切换,这样它就能省下大量的时间来避免从用户态切换到 ring-0 的内核态再切换回来的过程。但是这只是表面上能看到的,事实上为 Go 语言支持 100 万的 goroutines,Go 语言其实还做了更多更复杂的事情。
即使 JVM 把 threads 带到了用户空间,它依然无法支持百万级别的 threads ,想象下在你的新的系统中,在 thread 间进行切换只需要耗费100 纳秒,即使只做上下文切换,有也只能使 100 万个 threads 每秒钟做 10 次上下文的切换,更重要的是,你必须要让你的 CPU 满负荷的做这样的事情。支持真正的高并发需要另外一种优化思路:当你知道这个线程能做有用的工作的时候,才去调度这个线程!如果你正在运行多线程,其实无论何时,只有少部分的线程在做有用的工作。Go 语言引入了 channel 的机制来协助这种调度机制。如果一个 goroutine 正在一个空的 channel 上等待,那么调度器就能看到这些,并不再运行这个 goroutine 。同时 Go 语言更进了一步。它把很多个大部分时间空闲的 goroutines 合并到了一个自己的操作系统线程上。这样可以通过一个线程来调度活动的 Goroutine(这个数量小得多),而是数百万大部分状态处于睡眠的 goroutines 被分离出来。这种机制也有助于降低延迟。
除非 Java 增加一些语言特性来支持调度可见的功能,否则支持智能调度是不可能实现的。但是你可以自己在“用户态”构建一个运行时的调度器,来调度何时线程可以工作。其实这就是构成Akka这种数百万 actors[6] 并发框架的基础概念。
结语思考
未来,会有越来越多的从操作系统层面的 thread 模型向轻量级的用户空间级别的 threads 模型迁移发生[7]。从使用角度看,使用高级的并发特性是必须的,也是唯一的需求。这种需求其实并没有增加过多的的复杂度。如果 Go 语言改用操作系统级别的 threads 来替代目前现有的调度和栈空间自增长的机制,其实也就是在 runtime 的代码包中减少数千行的代码。但对于大多数的用户案例上考虑,这是一个更好的的模式。复杂度被语言库的作者做了很好的抽象,这样软件工程师就可以写出高并发的程序了。
超线程技术(Hyperthreading)可以成倍的高效地使用cpu的核。指令流水线(Instruction pipelineing)也可以增加CPU的并行执行的能力,然而,到目前为止,它是 O(numCores)。
这个观点在某些特殊的场景下是不成立的,如果有这种场景,麻烦告知作者。
这其实是一种攻击媒介。Javascript 可以检测由键盘中断引起的时间微小差异。这可以被恶意网站用来侦听,而不是你的键盘中断,而是用于他们的时间。https://mlq.me/download/keystroke_js.pdf [
Go语言起初使用的是“分段栈模型“,即栈空间是被分割到内存中不同的区域(译者注:在其他语言中栈空间一般是连续的),同时使用一些非常聪明的 bookkeeping 机制进行栈追踪。后来的版本实现为了提升性能,在一些特殊的场景下, 使用连续的栈来替代“分割栈模型”。就像调整hash表一样,分配一个新的大的栈空间,并通过一些复杂的指针操作,把所有内容都复制到新的更大的栈空间去。
线程可以通过调用 nice(请参阅 man nice)来标记它们的优先级,以获取更多信息来控制它们被安排调度。
为了能实现大规模的高并发,Actor 和 Goroutines for Scala/Java 的用户相同。就和 Goroutines 一样,actors 的调度程序可以查看哪些 actors 在他们的邮箱中有消息,并且只运行准备好做有用工作的 actors。实际上你可以有更多的 actors,而不是你可以拥有的例程,因为 actors 不需要堆栈。然而,这意味着如果一个 actor 没有快速处理消息,调度器将被阻塞(因为 Actor 不具有它自己的堆栈,所以它不能在消息中间暂停)。阻塞的调度器意味着没有消息处理,事情会迅速停止。这是一种折衷的处理方案。
在 Apache 的 web 服务器上,每处理一个请求就需要一个 OS 级别的 Thread,所以一个 Apache 的 web 服务器的并发连接性能只有数千级别。Nginx 选择了另一种模型,即使用一个操作系统级别的 Thread 来处理成百甚至上千个并发连接,允许了更好程度的并发。Erlang 也使用了类似的模型,允许数百万个 actors 同时运行。Gevent 将 Python 的 greenlet(用户空间线程)带入 Python,从而实现比其他方式支持的更高程度的并发性(Python 线程是 OS 线程)。
超线程技术(Hyperthreading)可以成倍的高效地使用cpu的核。指令流水线(Instruction pipelineing)也可以增加CPU的并行执行的能力,然而,到目前为止,它是 O(numCores)。
这个观点在某些特殊的场景下是不成立的,如果有这种场景,麻烦告知作者。
这其实是一种攻击媒介。Java 可以检测由键盘中断引起的时间微小差异。这可以被恶意网站用来侦听,而不是你的键盘中断,而是用于他们的时间。https://mlq.me/download/keystroke_js.pdf
Go语言起初使用的是“分段栈模型“,即栈空间是被分割到内存中不同的区域(译者注:在其他语言中栈空间一般是连续的),同时使用一些非常聪明的 bookkeeping 机制进行栈追踪。后来的版本实现为了提升性能,在一些特殊的场景下, 使用连续的栈来替代“分割栈模型”。就像调整hash表一样,分配一个新的大的栈空间,并通过一些复杂的指针操作,把所有内容都复制到新的更大的栈空间去。
线程可以通过调用 nice(请参阅 man nice)来标记它们的优先级,以获取更多信息来控制它们被安排调度。
为了能实现大规模的高并发,Actor 和 Goroutines for Scala/Java 的用户相同。就和 Goroutines 一样,actors 的调度程序可以查看哪些 actors 在他们的邮箱中有消息,并且只运行准备好做有用工作的 actors。实际上你可以有更多的 actors,而不是你可以拥有的例程,因为 actors 不需要堆栈。然而,这意味着如果一个 actor 没有快速处理消息,调度器将被阻塞(因为 Actor 不具有它自己的堆栈,所以它不能在消息中间暂停)。阻塞的调度器意味着没有消息处理,事情会迅速停止。这是一种折衷的处理方案。
在 Apache 的 web 服务器上,每处理一个请求就需要一个 OS 级别的 Thread,所以一个 Apache 的 web 服务器的并发连接性能只有数千级别。Nginx 选择了另一种模型,即使用一个操作系统级别的 Thread 来处理成百甚至上千个并发连接,允许了更好程度的并发。Erlang 也使用了类似的模型,允许数百万个 actors 同时运行。Gevent 将 Python 的 greenlet(用户空间线程)带入 Python,从而实现比其他方式支持的更高程度的并发性(Python 线程是 OS 线程)。
超出 thread 限制导致内存溢出。在作者的笔记本的 linux 上运行,这种情况一般发生在创建了 11500 个左右的 thread 时候。
但如果你用 Go 语言来做类似的尝试,每创建一个 Goroutine ,并让它永久的 Sleep ,你会得到一个完全不同的结果。在作者的笔记本上,在作者等待的不耐烦之前,GO语言创建了大约7千万个 Goroutine 。为什么我们可以创建的 Goroutines 比 thread 多这么多呢?回答这个问题需要回到操作系统层面来进行一次愉快的探索。这不仅仅是一个学术问题—在现实世界中它也揭示了如何进行软件设计。事实上,作者碰到过很多次软件出现 JVM 的 Thread 达到上限的情况,要么是因为垃圾代码导致 Thread 泄露,要么就是因为一些开发工程师压根不知道 JVM 有 Thread 限制这回事。
那么到底什么是线程(Thread)?
“Thread” 本身其实可以代表很多不同的意义。在这篇文章中,作者把它描述为一种逻辑上的 Thread。Thread 由如下内容组成:
一系列按照线性顺序可以执行的指令(operations);
一个逻辑上可以执行的路径。CPUs 中的每一个 Core 在同一时刻只能真正并发执行一个 logic thread[1]。
这就产生了一个结论:如果你的 threads 个数大于 CPU 的 Core 个数的话,有一部分的 Threads 就必须要暂停来让其他 Threads 工作,直到这些 Threads 到达一定的时机时才会被恢复继续执行。而暂停和恢复一个线程,至少需要记录两件事情:
当前执行的指令位置。亦称为:说当前线程被暂停时,线程正在执行的代码行;
还需要一个栈空间。 亦可认为:这个栈空间保存了当前线程的状态。一个栈包含了 local 变量也就是一些指针指向堆内存的变量(这个是对于 Java 来说的,对于 C/C++ 可以存储非指针)。一个进程里面所有的 threads 是共享一个堆内存的[2]。
有了上面两样东西后,cpu 在调度 thread 的时候,就有了足够的信息,可以暂停一个 thread,调度其他 thread 运行,然后再将暂停的 thread 恢复,从而继续执行。这些操作对于 thread 来说通常是完全透明的。从 thread 的角度来看,它一直都在连续的运行着。thread 被取消调度这样的行为可以被观察的唯一办法就是测量后续操作的时间[3]。
让我们回到最初的问题,为什么我们可以创建那么多的 Goroutinues 呢?
JVM 使用的是操作系统的 Thread
尽管规范没有要求所有现代的通用 JVM,在我所知道的范围内,当前市面上所有的现代通用目的的 JVM 中的 thread 都是被设计成为了操作系统的thread。下面,我将使用“用户空间 threads” 的概念来指代被语言来调度而不是被操作系统内核调度的 threads。操作系统级别实现的 threads 主要有如下两点限制:首先限制了 threads 的总数量,其次对于语言层面的 thread 和操作系统层面的 thread 进行 1:1 映射的场景,没有支持海量并发的解决方案。
JVM 中固定大小的栈
使用操作系统层面的 thread,每一个 thread 都需要耗费静态的大量的内存
第二个使用操作系统层面的 thread 所带来的问题是,每一个 thread 都需要一个固定的栈内存。虽然这个内存大小是可以配置的,但在 64 位的 JVM 环境中,一个 thread 默认使用1MB的栈内存。虽然你可以将默认的栈内存大小改小一点,但是您会权衡内存使用情况, 从而增加堆栈溢出的风险。在你的代码中递归次数越大,越有可能触发栈溢出。如果使用1MB的栈默认值,那么创建1000个 threads ,将使用 1GB 的 RAM ,虽然 RAM 现在很便宜,但是如果要创建一亿个 threads ,就需要T级别的内存。
Go 语言的处理办法:动态大小的栈
Go 语言为了避免是使用过大的栈内存(大部分都是未使用的)导致内存溢出,使用了一个非常聪明的技巧:Go 的栈大小是动态的,随着存储的数据大小增长和收缩。这不是一件简单微小的事情,这个特性经过了好几个版本的迭代开发[4]。很多其他人的关于 Go 语言的文章中都已经做了详细的说明,本文不打算在这里讨论内部的细节。结果就是新建的一个 Goroutine 实际只占用 4KB 的栈空间。一个栈只占用 4KB,1GB 的内存可以创建 250 万个 Goroutine,相对于 Java 一个栈占用 1MB 的内存,这的确是一个很大的提高。
在 JVM 中:上下文切换的延迟
使用操作系统的 threads 的最大能力一般在万级别,主要消耗是在上下文切换的延迟。
因为 JVM 是使用操作系统的 threads ,也就是说是由操作系统内核进行 threads 的调度。操作系统本身有一个所有正在运行的进程和线程的列表,同时操作系统给它们中的每一个都分配一个“公平”的使用 CPU 的时间片[5]。当内核从一个 thread 切换到另外一个时候,它其实有很多事情需要去做。
新线程或进程的运行必须以世界的视角开始,它可以抽象出其他线程在同一 CPU 上运行的事实。这个问题的关键点是上下文的切换大概需要消耗 1-100µ 秒。这个看上去好像不是很耗时,但是在现实中每次平均切换需要消耗10µ秒,如果想让在一秒钟内,所有的 threads 都能被调用到,那么 threads 在一个 core 上最多只能有 10 万个 threads,而事实上这些 threads 自身已经没有任何时间去做自己的有意义的工作了。
Go 的行为有何不同:在一个操作系统线程上运行多个 Goroutines
Golang 语言本身有自己的调度策略,允许多个 Goroutines 运行在一个同样的 OS thread 上。既然 Golang 能像内核一样运行代码的上下文切换,这样它就能省下大量的时间来避免从用户态切换到 ring-0 的内核态再切换回来的过程。但是这只是表面上能看到的,事实上为 Go 语言支持 100 万的 goroutines,Go 语言其实还做了更多更复杂的事情。
即使 JVM 把 threads 带到了用户空间,它依然无法支持百万级别的 threads ,想象下在你的新的系统中,在 thread 间进行切换只需要耗费100 纳秒,即使只做上下文切换,有也只能使 100 万个 threads 每秒钟做 10 次上下文的切换,更重要的是,你必须要让你的 CPU 满负荷的做这样的事情。
支持真正的高并发需要另外一种优化思路:当你知道这个线程能做有用的工作的时候,才去调度这个线程!如果你正在运行多线程,其实无论何时,只有少部分的线程在做有用的工作。Go 语言引入了 channel 的机制来协助这种调度机制。如果一个 goroutine 正在一个空的 channel 上等待,那么调度器就能看到这些,并不再运行这个 goroutine 。同时 Go 语言更进了一步。它把很多个大部分时间空闲的 goroutines 合并到了一个自己的操作系统线程上。这样可以通过一个线程来调度活动的 Goroutine(这个数量小得多),而是数百万大部分状态处于睡眠的 goroutines 被分离出来。这种机制也有助于降低延迟。
除非 Java 增加一些语言特性来支持调度可见的功能,否则支持智能调度是不可能实现的。但是你可以自己在“用户态”构建一个运行时的调度器,来调度何时线程可以工作。其实这就是构成Akka这种数百万 actors[6] 并发框架的基础概念。
结语思考
未来,会有越来越多的从操作系统层面的 thread 模型向轻量级的用户空间级别的 threads 模型迁移发生[7]。从使用角度看,使用高级的并发特性是必须的,也是唯一的需求。这种需求其实并没有增加过多的的复杂度。如果 Go 语言改用操作系统级别的 threads 来替代目前现有的调度和栈空间自增长的机制,其实也就是在 runtime 的代码包中减少数千行的代码。但对于大多数的用户案例上考虑,这是一个更好的的模式。复杂度被语言库的作者做了很好的抽象,这样软件工程师就可以写出高并发的程序了。
Actor解决了什么问题?
Akka使用Actor模型来克服传统面向对象编程模型的局限性,并应对高并发分布式系统所带来的挑战。 充分理解Actor模型是必需的,它有助于我们认识到传统的编程方法在并发和分布式计算的领域上的不足之处。
封装的弊端
面向对象编程(OOP)是一种广泛采用的,熟悉的编程模型,它的一个核心理念就是封装,并规定对象封装的内部数据不能从外部直接访问,只允许相关的属性方法进行数据操作,比如我们熟悉的Javabean中的getX,setX等方法,对象为封装的内部数据提供安全的数据操作。
举个例子,有序二叉树必须保证树节点数据的分布规则,若你想利用有序二叉树进行查询相关数据,就必须要依赖这个约束。
当我们在分析面向对象编程在运行时的行为时,我们可能会绘制一个消息序列图,用来显示方法调用时的交互,如下图所示:
但上述图表并不能准确地表示实例在执行过程中的生命线。实际上,一个线程执行所有这些调用,并且变量的操作也在调用该方法的同一线程上。为刚才的序列图加上执行线程,看起来像这样:
seq chart thread
但当在面对多线程的情况下,会发现此前的图越来越混乱和变得不清晰,现在我们模拟多个线程访问同一个示例:
seq chart multi thread
在上面的这种情况中,两个线程调用同一个方法,但别调用的对象并不能保证其封装的数据发生了什么,两个调用的方法指令可以任意方式的交织,无法保证共享变量的一致性,现在,想象一下在更多线程下这个问题会更加严重。
解决这个问题最通常的方法就是在该方法上加锁。通过加锁可以保证同一时刻只有一个线程能进入该方法,但这是一个代价非常昂贵的方法:
锁非常严重的限制并发,它在现在的CPU架构上代价是非常大的,它需要操作系统暂停和重启线程。
调用者的线程会被阻塞,以致于它不能去做其他有意义的任务,举个例子我们希望桌面程序在后台运行的时候,操作UI界面也能得到响应。在后台,,线程阻塞完全是浪费的,有人可能会说可以通过启动新线程进行补偿,但线程也是一种非常昂贵的资源。
使用锁会导致一个新的问题:死锁。
这些现实存在的问题让我们只能两者选一:
不使用锁,但会导致状态混乱。
使用大量的锁,但是会降低性能并很容易导致死锁。
另外,锁只能在本地更好的利用,当我们的程序部署在不同的机器上时,我们只能选择使用分布式锁,但不幸的是,分布式锁的效率可能比本地锁低好几个量级,对后续的扩展也会有很大的限制,分布式锁的协议要求多台机器在网络上进行相互通信,因此延迟可能会变得非常高。
在面向对象语言中,我们很少会去考虑线程或者它的执行路径,我们通常将系统想象成许多实例对象连接成的网络,通过方法调用,修改实例对象内部的状态,然后通过实例对象之前的方法调用驱动整个程序进行交互:
object graph
然后,在多线程分布式环境中,实际上线程是通过方法调用遍历这个对象实例网络。因此,线程是方法调用驱动执行的:
object graph snakes
总结:
对象只能保证在单一线程中封装数据的正确性,在多线程环境下可能会导致状态混乱,在同一个代码段,两个竞争的线程可能导致变量的不一致。
使用锁看起来可以在多线程环境下保证封装数据的正确性,但实际上它在程序真是运行时是低效的并且很容易导致死锁。
锁在单机工作可能还不错,但是在分布式的环境表现的很不理想,扩展性很差。
共享内存在现代计算机架构上的弊端
在80-90年代的编程模型概念中,写一个变量相当于直接把它写入内存,但是在现代的计算机架构中,我们做了一些改变,写入相应的缓存中而不是直接写入内存,大多数缓存都是CPU核心的本地缓存,但是由一个CPU写入的缓存对其他CPU是不可见的。为了让本地缓存的变化对其他CPU或者线程可见的话,缓存必须进行交互。
在JVM上,我们必须使用volatile标识或者Atomic包装类来保证内存对跨线程的共享,否则,我们只能用锁来保证共享内存的正确性。那么我们为什么不在所有的变量上都加volatile标识呢?因为在缓存间交互信息是一个代价非常昂贵的操作,而且这个操作会隐式的阻止CPU核心不能去做其他的工作,并且会导致缓存一致性协议(缓存一致性协议是指CPU用于在主内存和其他CPU之间传输缓存)的瓶颈。
即使开发者认识到这些问题,弄清楚哪些内存位置需要使用volatile标识或者Atomic包装类,但这并非是一种很好的解决方案,可能到程序后期,你都不清楚自己做了什么。
总结:
没有真正的共享内存了,CPU核心就像网络上的计算机一样,将数据块(高速缓存行)明确地传递给彼此。CPU间的通信和网络通信有更多的共同点。 现在通过CPU或网络计算机传递消息是标准的。
使用共享内存标识或者Atomic数据结构来代替隐藏消息传递,其实有一种更加规范的方法就是将共享状态保存在并发实体内,并明确并发实体间通过消息来传递事件和数据。
调用堆栈的弊端
今天,我们还经常调用堆栈来进行任务执行,但是它是在并发并不那么重要的时代发明的,因为当时多核的CPU系统并不常见。调用堆栈不能跨线程,所以不能进行异步调用。
线程在将任务委托后台执行会出现一个问题,实际中,是将任务委托给另一个线程执行,这不是简单的方法调用,而是有本地的线程直接调用执行,通常来说,一个调用者线程将任务添加到一个内存位置中,具体的工作线程可以不断的从中选取任务进行执行,这样的话,调用者线程不必阻塞可以去做一些其他的任务了。
但是这里有几个问题,第一个就是调用者如何受到任务完成的通知?还有一个更重要的问题是当任务发生异常出现错误后,异常会被谁处理?异常将会被具体执行任务的工作线程所处理并不会关心是哪个调用者调用的任务:
exception prop
这是一个很严重的问题,具体执行任务的线程是怎么处理这种状况的?具体执行任务去处理这个问题并不是一个好的方案,因为它并不清楚该任务执行的真正目的,而且调用者应该被通知发生了什么,但是实际上并没有这样的结构去解决这个问题。假如并不能正确的通知,调用者线程将不会的到任何错误的信息甚至任务都会丢失。这就好比在网络上你的请求失败或者消息丢失却得不到任何的通知。
在某些情况,这个问题可能会变得更糟糕,工作线程发生了错误但是其自身却无法恢复。比如一个由bug引起的内部错误导致了线程的关闭,那么会导致一个问题,到底应该由谁来重启线程并且保存线程之前的状态呢?表面上看,这个问题是可以解决的,但又会有一个新的意外可能发生,当工作线程正在执行任务的时候,它便不能共享任务队列,而事实上,当一个异常发生后,并逐级上传,最终可能导致整个任务队列的状态全部丢失。所以说即使我们在本地交互也可能存在消息丢失的情况。
总结:
实现任何一个高并发且高效性能的系统,线程必须将任务有效率的委托给别的线程执行以至不会阻塞,这种任务委托的并发方式在分布式的环境也适用,但是需要引入错误处理和失败通知等机制。失败成为这种领域模型的一部分。
并发系统适用任务委托机制需要去处理服务故障也就意味需要在发生故障后去恢复服务,但实际情况下,重启服务可能会丢失消息,即使没有发生这种情况,调用者得到的回应也可能因为队列等待,垃圾回收等影响而延迟,所以,在真正的环境中,我们需要设置请求回复的超时时间,就像在网络系统亦或者分布式系统。
为什么在高并发,分布式系统需要Actor模型?
综上所述,通常的编程模型并不适用现代的高并发分布式系统,幸运的是,我们可以不必抛弃我们了解的知识,另外,Actor用很好的方式帮我们克服了这些问题,它让我们以一种更好的模型去实现我们的系统。
我们重点需求的是以下几个方面:
使用封装,但是不使用锁。
构建一种实体能够处理消息,更改状态,发送消息用来推动整个程序运行。
不必担心程序执行与真实环境的不匹配。
Actor模型能帮我们实现这些目标,以下是具体描述。
使用消息机制避免使用锁以防止阻塞
不同于方法调用,Actor模型使用消息进行交互。发送消息的方式不会将发送消息方的执行线程转换为具体的任务执行线程。Actor可以不断的发送和接收消息但不会阻塞。因此它可以做更多的工作,比如发送消息和接收消息。
在面对对象编程上,直到一个方法返回后,才会释放对调用者线程的控制。在这这一方面上,Actor模型跟面对对象模型类似,它对消息做出处理,并在消息处理完成后返回执行。我们可以模拟这种执行模式:
actor graph
但是这种方式与方法调用方式最大的区别就是没有返回值。通过发送消息,Actor将任务委托给另一Actor执行。就想我们之前说的堆栈调用一样,加入你需要一个返回值,那么发送Actor需要阻塞或者与具体执行任务的Actor在同一个线程中。另外,接收Actor以消息的方式返回结果。
第二个关键的变化是继续保持封装。Actor对消息处理就就跟调用方法一样,但是不同的是,Actor在多线程的情况下能保证自身内部的状态和变量不会被破坏,Actor的执行独立于发送消息的Actor,并且同一个Actor在同一个时刻只处理一个消息。每个Actor有序的处理接收的消息,所以一个Actor系统中多个Actor可以并发的处理自己的消息,充分的利用多核CPU。因为一个Actor同一时刻最多处理一个消息,所以它不需要同步机制保障变量的一致性。所以说它并不需要锁:
serialized timeline invariants
总而言之,Actor执行的时候会发生以下行为:
1.Actor将消息加入到消息队列的尾部。 2.假如一个Actor并未被调度执行,则将其标记为可执行。 3.一个(对外部不可见)调度器对Actor的执行进行调度。 4.Actor从消息队列头部选择一个消息进行处理。 5.Actor在处理过程中修改自身的状态,并发送消息给其他的Actor。 6.Actor
为了实现这些行为,Actor必须有以下特性:
邮箱(作为一个消息队列)
行为(作为Actor的内部状态,处理消息逻辑)
消息(请求Actor的数据,可看成方法调用时的参数数据)
执行环境(比如线程池,调度器,消息分发机制等)
位置信息(用于后续可能会发生的行为)
消息会被添加到Actor的信箱中,Actor的行为可以看成Actor是如何对消息做出回应的(比如发送更多消息或者修改自身状态)。执行环境提供一组线程池,用于执行Actor的这些行为操作。
Actor是一个非常简单的模型而且它可以解决先前提到的问题:
继续使用封装,但通过信号机制保障不需传递执行(方法调用需要传递执行线程,但发送消息不需要)。
不需要任何的锁,修改Actor内部的状态只能通过消息,Actor是串行处理消息,可以保障内部状态和变量的正确性。
因为不会再任何地方使用锁,所以发送者不会被阻塞,成千上万个Actor可以被合理的分配在几十个线程上执行,充分利用了现代CPU的潜力。任务委托这个模式在Actor上非常适用。
Actor的状态是本地的,不可共享的,变化和数据只能通过消息传递。
Actor优雅的处理错误
Actor不再使用共享的堆栈调用,所以它要以不同的方式去处理错误。这里有两种错误需要考虑:
第一种情况是当任务委托后再目标Actor上由于任务本身错误而失败了(典型的如验证错误,比如不存在的用户ID)。在这个情况下,Actor服务本身是正确的,只是相应的任务出错了。服务Actor应该想发送Actor发送消息,已告知错误情况。这里没什么特殊的,错误作为Actor模型的一部分,也可以当做消息。
第二种情况是当服务本身遇到内部故障时。Akka强制所有Actor被组织成一个树状的层次结构,即创建另一个Actor的Actor成为该新Actor的分级。 这与操作系统将流程组合到树中非常相似。就像进程一样,当一个Actor失败时,它的父actor被通知,并对失败做出反应。此外,如果父actor停止,其所有子Actor也被递归停止。这中形式被称为监督,它是Akka的核心:
actor tree supervision
监管者可以根据被监管者(子Actor)的失败的错误类型来执行不同的策略,比如重启该Actor或者停止该Actor让其它Actor代替执行任务。一个Actor不会无缘无故的死亡(除非出现死循环之类的情况),而是失败,并可以将失败传递给它的监管者让其做出相应的故障处理策略,当然也可能会被停止(若被停止,也会接收到相应的消息指令)。一个Actor总有监管者就是它的父级Actor。Actor重新启动是不可见的,协作Actor可以帮其代发消息直到目标Actor重启成功。