Month: December 2019

  • Actor模型以及网络程序架构

    在去年实现我的Raft实现,考察过一些Raft算法的实现,包括Golang的一些实现。基于Golang的实现给人的第一感觉是,不知道有哪些协程是“持续”运行的,比如说监听网络请求,快照生成等等。而且由于CSP模型不关注发送者和接受者导致数据追踪比较困难,在没有IDE的情况下你可能几眼都看不出数据到了哪个模块的哪行代码。个人不确实是不是开发者的问题,导致这个网络程序架构那么松散,还是说CSP模型下的程序都那么松散。 由于个人长期在Web后端开发,对于MVC模型以及处理复杂业务逻辑的分层架构、组件化和依赖注入都比较熟悉,但是在开发一个传统意义上的网络程序时,还不确定什么是最佳架构。 假如用类似Web容器的做法,即每过来一个请求,开启一个thread或者使用线程池的话,程序逻辑可以采用分层架构。即使请求入口采用非阻塞IO的做法,比如说用Netty,程序逻辑也可以做在Netty的pipeline或者单独开线程处理复杂任务。但是,网络程序与很多没有额外线程的Web后端不同,会有定时任务,会有比较复杂的异步处理,所以可能一开始做成类似事件驱动模型可能更好。 个人在实现xraft时,用类似事件驱动的方式处理定时任务,各种外部的请求,把接受和发送等IO操作交给了Netty。整体上类似JavaScript,但是我把耗时的快照生成与应用发在主事件线程外,严格来说有一些区别。使用事件驱动除了理解起来简单之外,一些多线程下的数据访问问题利用单线程的线程封闭解决掉了。 如果问这种架构是否有改进空间,个人认为是有的。但是多线程下数据访问是一个难点,比起全局锁,个人更看好STM(软件事务内存)。除了数据访问,逻辑的封装也需要考虑,还有比较棘手的组件间双向访问问题。 在进一步找寻解决方案的时候,个人先找到了Cassandra所使用的SEGA,一种基于Stage驱动的架构模式。一个Stage绑定一个queue和相关处理逻辑。复杂逻辑被拆分成多个Stage,从第一个Stage执行到最后一个Stage。类似逻辑可以复用Stage。 老实说,这和Actor模型很像。Actor有一个Mailbox,Actor的内部根据收到的消息进行不同的处理。Actor同时其他一些特点,比如说树一样的管理结构,失败处理,热部署等等。类似组件间双向访问问题,Actor可以通过名字访问来解决。 整体来说,Actor符合我的要求。把Actor作为逻辑单位,有助于松耦合组件,但不是像我看到的CSP代码那么散。Actor除了“无法超过的前辈”Erlang之外(Actor+协程+函数式,理论上最好的并发编程语言),Scala和Java中可以使用Akka。BTW,Akka有针对STM的实现。 为了学习,个人选择了另一个Java下的Actor实现:Vert.x。严格来说,Vert.x并没有完全实现Actor模型,但是Vert.x作者把涉及到的绝大部分IO操作都异步化了,比如网络(基于Netty),文件,数据库(扩展形式)等等。之前个人也撰文分析过,协程的本质是异步编程,假如你把所有操作都异步化了,剩下的你可以用JavaScript的callback方式,或者更直观的ReactiveX。 Vert.x还有一个特点是把Actor的queue统一到一个eventBus中去,这样做的一个效果是用queue的名字代替了Actor。考虑到Actor在失败时以及多个Actor时的处理时,这么做并没有太大问题。 在使用过Vert.x+ReactiveX之后,个人觉得这可能是现代网络编程的一种比较好的组合。当然同时理解这两者会比较难,特别是ReactiveX。好在Vert.x不需要ReactiveX也可以使用,就像JavaScript没有async/await也可以使用。 个人不确定这是不是面向复杂网络程序的最佳架构方案,如果有更好的方案,欢迎留言。

  • 协程,异步式编程以及ReactiveX

    上半年主要在忙写书的事情,下半年由于工作有点忙以及自己犯懒没有写博客。不过个人还是在持续学习,接下来是个人关于协程等的学习的一个小结。 严格来说协程不是一个新名词,但是因为Golang变得很火。很多博客从进程-线程-协程模型讲Golang如何高效,比那些没有协程的对手,比如Java好不知道到哪里去。先不管这种想法有点偏颇,个人想从协程想要解决什么问题开始,分析各种语言如何解决这些问题,来更深入地了解现代编程。 协程可以解决什么问题?首先三层调度模型(进程-线程-协程)中协程属于用户态任务调度,原先程序用线程来考虑问题,现在用任务粒度。程序怎么划分任务可以由程序员指定,也可以在特定的地方划分,比如IO操作。 IO操作相比计算型操作不怎么需要计算型资源,所以在等待IO操作返回时CPU资源是被“浪费”掉的。其次,非阻塞型IO的出现,使得编程方式发生了很大的变化。当你在考虑用额外的线程来处理IO阻塞时,非阻塞型IO可以少用额外的线程,只需要等待完成事件。 问题是“等待完成事件”对很多人来说个全新的东西,你可能需要一个轮询IO事件的selector(典型的reactor)以及相应的基于事件(event)编程的模式。你可以在JavaScript的运行时中看到这种模式。一般来说,语言层通过callback绑定后续操作。如果你有多个IO操作的话,你可能会写出callback嵌套callback的代码(或称callback hell)。JavaScript针对callback hell的解决方案是Promise,以及之后的aysnc/await。Promise允许链式callback,async/await允许用近似同步的方式编写异步的代码。 不管你是否感觉到,但是async/await确实做了类似协程的事情。 任务开始:IO操作开始,对应await开始 任务结束:IO操作结束,对应await结束 任务后续:callback,对应await之后的代码 注意,JavaScript由于是单线程模型,本身又有event loop,在实现async/await时并不需要对运行模型进行修改。其他语言想要实现async/await的话,除了语法层面,运行层也需要修改(在Erlang和Golang中,使用的协程运行时是一个“多线程版本的event loop”:work-stealing thread pool)。 小结一下,协程可以用于解决异步IO的callback hell问题。事实上,个人认为这是协程的优势。对于同样使用异步IO的程序来说,使不使用协程在性能上没有太大差距,但是用协程写法上会简单很多。至于Golang中使用go块开启协程的写法,个人认为是线程运行模式的补偿。比如你有一个非IO的长期计算任务,你没有IO操作的切入点和结束点,必须由程序员指明(用go关键字。BTW,因为这种任务的存在,协程运行时必须使用work stealing thread pool,否则被放到这个线程末尾的任务很长时间内不会被执行到)。 虽然协程可以简化异步IO调用,但是存在一些问题无法直接解决。比如说,我希望同时执行IO调用1,IO操作2并等待两个结果返回。如果你使用顺序IO操作1,IO操作2写的话,实际执行不会是并行的。在JavaScript下,你需要退回Promise并使用特定的API Promise.all。在Golang下,你需要主动开启两个go块执行IO操作,然后使用channel获取结果。 个人认为这其实显示了基于协程编程的脆弱性,一旦遇到不是简单的单次IO操作的场景,复杂性就会显现。又比如说,执行一个操作,你希望设置一个超时时间,在Golang中你必须同时使用channel/select,外加一个timer才能实现。相对的,同步编程下只需要在参数里加一个timeout。 可以看到,这里的复杂性其实是异步编程,或者说多线程编程的复杂性。JavaScript由于不是多线程,可以干脆放弃async/await的写法回归Promise+链式callback的做法。Golang使用了CSP编程的核心要素channel来处理多线程编程(BTW,Golang的channel可以与协程整合在一起)。 从个人角度来说,JavaScript的做法可能更好:假如你没法用协程了,那就退一步用不那么直接但是足够简单的方式,而不是退十步,用通用(即CSP)的方式。Golang很多人说简洁,但是由于其设计目标(比如工程化),只对基本的场景做了对应,剩下的场景就显得很简陋(C语言风格)。 除去Promise.all这种专用API之外,其他语言和框架中比较容易理解的,个人觉得是ReactiveX的zip,比如如下代码 Observable<Result1> source1 = operation1(); Observable<Result2> source2 = operation2(); Observable.zip(source1, source2, (r1, r2) -> { // do stuff }).subscribe(System.out::println); 这里没有暴露出任何和多线程相关的信息,但是可以达到同时执行的效果。进一步,假如你需要同时执行多个操作,只要有一个返回结果了就结束,在JavaScript、Golang和ReactiveX中你可以考虑下分别该怎么做(一个提示是,ReactiveX可以用amb替换zip)。 你可以把ReactiveX的subscribe当作JavaScript的callback,它会在获取到结果后被执行。和JavaScript的event loop不同,ReactiveX在多线程环境下执行时,subscribe的代码可能会在操作线程中执行。这对于Android开发来说可能会有问题(不能在GUI线程之外修改GUI元素),但是你可以通过简单增加一行observeOn(xxx)来解决这个问题。 注意,ReactiveX的数据流模型是一种PUSH模型,即数据流负责提交数据,触发下游操作。这某种程度和协程结束触发后续代码很像。其次ReactiveX允许你使用函数式语言的方式处理错误,集中精力在核心处理代码上。 当然ReactiveX不是万能的,在多线程编程方面,ReactiveX只能提供有限的帮助。更多的时候,你仍旧需要使用各种针对多线程处理的工具。以及,多种编程范式有助于你用各种高效的方法来解决问题,比如说Kotlin既有协程,也有async/await,也有ReactiveX的实现,当然你也可以用传统线程。个人认为这是现代编程对于愈来愈复杂的问题的解决方案,相应地这也需要程序员对语言使用对问题分析有足够的能力。 最后,以一句个人最近看到的话结尾“协程的本质是异步编程”。假如你能看到协程所要解决的问题,以及了解如何解决这些问题,那么相信你的学识会让你比其他人有更多手段和方法来更好地解决问题。