在阐述并发时需要先了解一下并发与并行的区别。
- 在某一个时间点能同时执行多个任务即为并行。
- 在某一时间段能同时执行多个任务即为并发,即在某一时间点仅能执行一个任务。
在现代计算机中,为了并行执行多任务,硬件层面做了许多实现,比如多核、多发射和超标量等。但由于软件层面上,需要执行的任务很多,并行无法满足需求,所以采用了并发这一机制。在目前,笔者了解到有以下几种并发模型。
- 多进程模型。
- 多线程模型。
- 协程。
- Actor模型。
1、进程与线程
进程是比较重的东西,使用它来作为并发的执行单元是一件很耗资源的事,所以很多编程语言都采用更轻量的线程来做为并发的执行单元。
在Linux下,虽然线程与进程都是调用的同一个方法来创建,都是task_struct对象,但线程还是要比进程更轻量,线程间资源也能够共享,所以线程是目前许多主流编程语言支持的并发执行单元。但在使用多线程时,为保证多线程能够按照预期执行,不得不使用各种额外的机制来保证。
以Java为例,使用了synchronized
、CAS
等机制来保证原子性,volatile
来保证变量的可见性。尽管有这些机制,但使用不当也会导致很多问题,况且线程切换时,也会在用户态和内核态间切换,从而增加额外的性能消耗,所以需要更轻量级的并发模型。
2、协程
协程是1958年被马尔文·康威(Melvin Conway)提出来的,在20世纪60年代又被高德纳(Donald Ervin Knuth)总结为两种子过程(Subroutine)的模式之一。一种是我们常见的函数调用的方式,而另一种就是协程。
2.1、协程的控制流
在以前“自顶向下”的理念指导下,程序被切分成一个主程序和大大小小的子模块,每个子模块又可能调用更多子模块等等,所以函数调用这种有鲜明层次的子过程调用成为软件系统最自然的组织方式。其控制流如下。
想必都很熟悉。再来看协程,它是比线程更轻量的执行单元,由执行单元间相互协商进行调度,所以它发生在用户态,减少了进程/线程切换时用户态与内核态切换的资源消耗。其控制流如下。
从上图中可以看出,协程的思想本质上就是控制流的主动让出和恢复机制。那么下面再来看其实现原理。
2.2、Stackless与Stackful
当调用一个方法时,会创建对应的栈帧来存储方法的本地变量、参数等信息,然后将该栈帧加入栈中,当该方法执行完毕后,就会将该栈帧从栈中移除。如果使用协程来让出调度,则会将该栈帧中的相关信息保存在堆中,当下次激活该协程时,则从堆中恢复该栈帧并加入栈中。其调用协程时的控制流和栈桢管理如下。
由于上面协程未使用一个额外栈,所以被称为Stackless Coroutine,它依赖于当前线程,其生命周期也受制于当前线程。
如果协程中存在子程序,并且要求在任何一级子程序都可以暂停协程,那么就需要一个额外的栈来辅助运行。如下图这种情况。
这种使用额外栈来辅助运行协程的机制则被称为Stackful Coroutine,该机制的生命周期可以超越其创建者,也意味着可以从一个线程脱离并附加到另外一个线程。
2.3、对称与非对称
到目前为止,讲述的协程都是非对称的,即有一个主程序,而协程像是子程序。主程序和子程序控制程序执行的原语是不同的,一个用于激活协程,另一个用于暂停协程。而对称的协程,相互之间是平等的关系,它们使用相同的原语在协程之间移交控制权。目前实现协程的编程语言中大部分都是非对称协程,仅有Julia
、go
等少数编程语言实现的是对称协程。
3、Actor模型
Actor模型是1973年由Carl Hewitt提出的一种并发模型,它与线程、协程等最大的区别就是Actor间资源不共享,通过互发消息来实现协作,示例图如下。
看起来很像多进程模型,但我们可以把Actor做的比进程轻量很多很多。
如果对Flutter了解的话,相信也能猜出,isolate就是actor。
面向对象之父Alan Kay在谈论面向对象时也有如下说法:
I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning – it took a while to see how to do messaging in a programming language efficiently enough to be useful)
......
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.
即对象应该像生物的细胞,或者是网络上的计算机,它们只能通过消息互相通讯。对我来说 OOP 仅仅意味着消息传递、本地保留和保护以及隐藏状态过程,并且尽量推迟万物之间的绑定关系。
总结起来,Alan对面向对象的理解,强调消息传递、封装和动态绑定,没有谈多态、继承等。对照这个理解,你会发现Actor模式比现有的流行的面向对象编程语言,更加接近面向对象的实现。
下面再来看Actor模型在Erlang与Dart中的应用情况。
3.1、Erlang中Actor模型的应用
在Erlang中,每个Actor即是一个进程(Process),但这个“进程”其实并不是操作系统意义上的进程,而是Erlang运行时的并发调度单位。该“进程”远比操作系统意义上的进程轻量,初始堆大小仅为233个字,是非常小的,所以天然支持高并发。
通过下面代码就可以在Erlang中创建进程并查看系统上允许的最大进程数。
-module(helloworld).
-export([max/1,start/0]).
max(N) ->
Max = erlang:system_info(process_limit),
io:format("Maximum allowed processes:~p~n" ,[Max]),
statistics(runtime),
statistics(wall_clock),
// spawn是创建新进程的API
L = for(1, N, fun() -> spawn(fun() -> wait() end) end),
{_, Time1} = statistics(runtime),
{_, Time2} = statistics(wall_clock), lists:foreach(fun(Pid) -> Pid ! die end, L),
U1 = Time1 * 1000 / N,
U2 = Time2 * 1000 / N,
io:format("Process spawn time=~p (~p) microseconds~n" , [U1, U2]).
wait() ->
receive
die -> void
end.
for(N, N, F) -> [F()];
for(I, N, F) -> [F()|for(I+1, N, F)].
start()->
max(1000),
max(100000).
复制代码
运行结果如下:
Maximum allowed processes:262144
Process spawn time=4.0 (2.0) microseconds
Maximum allowed processes:262144
Process spawn time=3.34 (2.46) microseconds
不同设备运行结果可能不同,但最大进程数达到20w+也足以证明Erlang语言天然支持高并发。由于actor间资源不共享,所以也不会存在各种数据同步问题。
3.2、Flutter中Actor模型的应用
前面说过,在Flutter中,每个Actor即是一个isolate对象。作为Flutter中的并发执行单位,它没有Erlang中的进程轻量。
在Flutter之isolate的使用及通信原理一文中详细阐述了isolate的使用及通信原理,但此文是针对Flutter 1.x来讲述的。
根据最新的Flutter engine代码,isolate在对堆内存的处理上做了一些优化。
在最新flutter engine中,根据IsolateGroup将isolate分为以下两种情况。
- 当前isolate的IsolateGroup为null时,将会同时创建IsolateGroup与isolate,堆内存在IsolateGroup中创建并初始化。
- 当前isolate的IsolateGroup不为null时,仅创建isolate,与IsolateGroup下的所有isolate共用一块堆内存。
以上两点就是最新flutter engine中针对isolate的主要改动。
3.3、总结
Actor模型是一种更高层次的抽象,它避免了开发者处理数据同步时产生的错误,但在Flutter Engine中,Actor间通信还是靠线程来实现的。
相对于其他并发模型而言,Actor模型有如下优点。
- 由于Actor拥有独立的堆,所以每次进行gc时,避免了对整个应用回收回收,加快了gc的效率,减少了STW的时间。
- 当一个Actor生命周期结束后,可以立即释放内存。对于一些生命周期短的actor,根本就不需要进行垃圾回收。
- 当某个Actor出现故障时,不会影响其他Actor。
有优点就有缺点,再来看它的缺点。
- 由于actor间数据不共享,所以actor通信时不建议进行大数据的传输。
- actor间通信是异步的,所以对于习惯编写同步代码的同学是一个考验。
4、总结
相信到这里就会对协程与Actor模型有一个基本的了解,在写相关代码时也更得心应手,甚至都可以自己来实现一个协程与Actor模型。
但我们也还是不要忘了线程,它虽然在高并发上无法与协程与Actor模型媲美,但它拥有系统级别的支持,编译器实现也更简单,所以很多主流语言都能够使用线程。况且笔者认为协程与Actor模型是比线程更高层次的抽象,但底层的相关实现还得依赖线程。
最后大家也可以思考下协程与I/O多路复用结合的实现,可以参考腾讯开源的libco。
【参考资料】