Author:放翁(文初)
Date: 2010/11/25
Email:fangweng@taobao.com
mblog: http://t.sina.com.cn/fangweng
blog: http://blog.csdn.net/cenwenchu79/
这篇文章将会从问题,技术背景,设计实现,代码范例这些角度去谈基于管道化和事件驱动模型的Web请求处理。建议从头看,能够从概念上更多的去理解和碰撞,其中的一些描述和例子也许不是很恰当,也希望得到更多的反馈。
业务架构设计:
基于上述问题,通过两步走来解决。首先采用支持打破传统http request生命周期管理的Web容器(很多人说可以自己写,其实Web容器写起来并不是最麻烦的,如何做好兼容和照顾好每一个细节才是漫长发展的道路)。其次在容器新的线程生命周期管理基础上封装业务框架,为开发者屏蔽底层异步化和事件驱动模式带来的复杂流程管理内容。
Pipe Service Framework:
基础管道体系:
很多时候设计和实现都会有很多细节上的差异,而这些差异往往是在事实验证后对体系的一种修订,也许修订后的结构不如修订前的清晰和优雅,但是确实在性能和结构上找到了平衡点,下面就看看两个基础管道体系的设计,后一个是前一个的演进。
流程与角色说明:
角色分成:Container(传统的容器),dispatcher(任务派发线程数量根据性能要求可以是1-m个),job pool(存储任务数据的本地缓存),event queue(任务状态发生变化的事件存储队列),pipe register center(管道链注册中心,根据job的自描述信息给出相关处理的单个管道或者管道链),thread pool(用于处理业务请求的线程池)
流程描述如下:
1. 容器解析请求数据。
2. 创建任务并存储到job pool。
3. 发送job执行消息到消息队列。
4. 释放容器线程,挂起请求资源。
5. Dispatcher阻塞方式的从event queue获取事件消息。
6. 如果是删除任务事件消息,则将剩余未发送数据flush到客户端,结束本次Http会话。(删除任务消息是在任务走完所有管道或者任务执行超时或者任务执行失败产生)
7. 如果是执行任务消息事件,则从job pool获取任务数据。
8. 根据任务信息去pipe register center获取pipe或者pipe chain。
9. 将任务数据和管道信息发送给线程池。
10. 线程池分配线程执行任务,如果当前pipe chain执行后并没有完成job,则将job信息存储到job pool。(这块后面可以参看一下job 执行逻辑图)
11. 如果没有执行完毕,则可以创建一个或者多个执行事件激发下一次的处理,如果执行完毕,则创建一个删除任务消息激发任务结束处理。
问题:
1. 规范化带来的消息事件过多,线程切换消耗的问题。
2. Dispatcher自身任务是否繁重导致处理速度变慢。同时两套线程池管理麻烦(如果Dispatcher的个数为M也就可以看作另一个线程池)。
细节:
1. 利用容器本身支持请求挂起的方式,将容器线程池和业务线程池分割开来。
2. 如果所有子任务都是串行化且没有一个子任务是由外部系统来实施状态迁移,则可以在一个线程中完成所有子任务,减少线程切换和事件分发带来的消耗。最极端是退化到任务交由容器线程一并完成。
3. 当允许并行多个子任务执行时,只需要在并行子任务执行前的那个任务完成后,分发多个任务执行事件,并且任务执行事件指定要求处理的Pipe,就可以让分发器将当前任务分发给多个线程并行执行子任务,后续详细介绍子任务并行处理的过程。
4. Job会被多线程访问,因此必要的属性需要做成线程安全的。另一种模式就是抓取job的数据是个快照(clone),在结果产生后再锁住合并。
角色和流程说明:
上图角色将线程池和消息队列做了合并,去掉了dispatcher,event queue合并到了 Thread Pool中。
1. 容器解析请求参数。
2. 创建任务并放置到任务缓存中。
3. 发送执行任务事件到线程池。
4. 释放容器线程资源。
5. 线程池从自身事件队列中获取事件。
6. 如果是删除事件,则直接删除任务,并发送数据到客户端,结束本地会话。
7. 如果是执行事件,则从pipe register center获取pipe或者pipe chain。
8. 本地执行pipe或者pipe chain。
9. 更新job 数据到缓存。
10. 创建执行或者删除消息事件到本地线程池队列或者直接连续执行。
差异:
1. 将分发器的功能散落到各个实际业务操作线程上,提升处理效率。(增加了对于消息队列的竞争,不过这个代价不是很大)
2. 线程可以连续执行子任务,减少任务事件数量,减少线程切换代价。(类似于自旋锁的方式,自己可以尽量的完成可以完成的任务,带来的问题就是对于不同任务多阶段并行执行的策略有所减弱)
细节:
和第一种模式一样,可以退化这个模型到传统的一个web容器线程处理所有的子任务,减少线程切换代价。
四种方式的子任务执行说明:
传统的串行化任务执行模式,这种模式下可以交由单个线程全部执行,减少线程切换代价,另一方面假如3这个环节将会等待外部系统来更新状态并继续执行,那么到2执行完毕可以将job放入缓冲区,不产生事件消息,等外部操作完成后,创建执行事件消息,激发后续管道执行任务。(这种方式可以直接利用容器的挂起,来释放容器线程,而后续操作交由后台业务线程池执行)
这里有点说明一下,也是很多朋友问起的,关于上下文,原来的模式中上下文一种方式是通过方法参数不断传递,另一种方式保存在ThreadLocal中,而现在因为要切换线程可能就需要做拷贝或者线程之间传递。在后面几种模式中都建议直接将状态存储在本地缓存中共享,带来的问题就是多线程安全,一种方式是都获取此对象,然后操作时候做锁,一种是获得对象快照,然后合并结果时锁定。(这还是取决于多个线程之间处理是否需要看到对方的数据变化)
3,4两个任务可以并行完成,同时任何一个完成即可进入5,此时在2完成后,将会产生两个执行任务消息,并且自描述后续的Pipe,此时两个线程可以分别执行3,4,任何一个完毕后创建执行消息,激发任务处理进入到5流程中。(当发现已经进入5状态时,则忽略某个过期任务消息)
与上一个图的区别就是,3,4将不再是二选一,而是必须全执行完毕后才可以进入下一个阶段,因此job在执行后会先判断是否被并行的另一个任务执行过,确定全部都Ready,则发起创建执行消息。(在完成3或者4后都会判断当前合并结果是否符合进入下一环节的要求,符合再发起新的执行任务消息)
此图是2,3两种方案的结合,因此参照3的做法完成。
支持异步化请求处理模型:
上面的管道模型是较为通用的模型,但考虑到TOP现有业务状况和资源消耗在上述框架下定制了简单的异步支持模型:
角色及流程说明:
App第三方ISV软件,Container Web容器,PipeManager管道注册管理者(区别于通用的管道注册中心在于他对于所有请求都只管理一套Pipe Chain,由他将请求数据传入,并管理整个子任务的执行和分发),AsynTaskChecker是异步执行任务状态变更事件的检查者(类似于前面的事件分发器角色),ResultQueue保存事件及事件所带的上下文,workerThead是工作线程池。
1. 应用发起服务请求。
2. 容器调用管道管理器去执行任务管道链。(解析参数通过Lazy方式解析字节流被离散放到了各个管道环节中)
3. 检查容器是否对异步支持。(便于多容器兼容)
4. 创建上下文和输入输出对象(输入输出是管道基本传递参数,后面给出类图结构可知,上下文则是放置在ThreadLocal的数据,在多个管道逻辑中共享)。
5. 设置管道链执行的起始点(为了异步化后再次进入管道链无需重新执行前面执行过的管道作处理)。
6. 循环执行管道链。
如有异步管道在管道链中:
a) 复制管道上下文,保存当前执行的管道位置。
b) 挂起请求,释放容器线程资源。
c) 创建线程执行异步化管道。
d) 保存任务到队列,等待外部处理结束改变任务状态。
e) 推出循环执行后续管道
7. 判断是否是异步执行后的重入,如果是则提交异步结束事件,让容器在这次管道链执行后自动提交数据到客户端,结束本地Http请求会话。
8. 释放上下文等线程本地资源。
9. 返回容器,容器判断是否有挂起请求,如果请求结束则返回结果到客户端。
10. 容器自检查从挂起到当前是否处于执行超时(每次挂起请求就会产生一个超时事件,容器循环的校验这些事件)
11. AsynTaskChecker循环的检查队列中的任务是否已经完成,如果状态变更为完成,则提交到给线程池继续执行后续的管道链。(处于性能考虑,可以将未完成的对象先不放入队列,等到后端服务处理完毕再放入,这样AsynTaskChecker消耗会大大降低,任务超时完全交给容器来处理,不由业务方来处理)
细节:
主要目的是将容器和业务线程池分开,这样业务线程池可以采用后面提到的权重线程池,通过对权重线程池的权重模型设置来满足根据业务或者根据服务健康状况来不均衡的分配线程执行不同的业务请求。
后端系统的NIO异步方式能够利用操作系统的中断来激发改变对象状态,节省前端业务线程等待消耗。(如果后端是非异步化的操作,那么执行线程只是从容器线程变为了业务线程,当然可以让业务线程更加轻量)
系统中尽量减少线程切换(能够一个线程干完的,尽量一个线程执行多个子任务),尽量减少内存拷贝复用对象(当然复用的代价就是同步问题,因此取决于数据操作冲突的概率选择使用快照还是引用)。
如果看不清楚,可以直接访问:http://www.blogjava.net/cenwenchu/archive/2010/11/25/339024.html
上图的设计省略了队列和检查者,直接交由业务线程阻塞方式等待返回,并直接执行后续的管道,其实也就是对第一种场景的简化,在后端服务非异步方式的情况下,推荐这种方式。
总的来说,任务切割执行在设计上会觉得很清晰,但是还是要看整体处理时间的分布,如果整个事务处理消耗的时间很短,那么切割带来的复杂度和内部消耗就会得不偿失,采用简单的方式来实现可以满足业务上的需求(分离容器和业务线程,根据业务需求和系统动态性能决定线程资源分配),也能保证性能。
权重线程池:
将请求全程处理从容器线程池分离到业务线程池后,可以使用带权重的线程池来动态调整请求线程资源分配,下面是一个简单的权重线程池的实现。
目标:执行的任务实现接口getkey来用于判断是否有空余线程可以执行请求处理任务。资源被分成两种:默认全局可使用资源,给特定请求预留资源。配置分成两种,限制最大使用线程数,预留特定请求的线程数。
上图是简单的请求任务执行流程图,不多解释了。下图是状态转换图:
Wait到doing的转换和init到doing的转换一样,就没有重复画了。内部的一些标识解释(totalCounter全局的计数器,maxThreadPoolSize线程池最大线程数,defaultCounter是没有设置预留或者限制的请求的计数器,defaultThreshold是maxThreadPoolSize – sum(预留线程),keyCounter表示设置了预留或者限制的请求自身标识(自身标识通过getkey接口获得)计数器,leave表示某一类请求设置的预留的数值,limit表示某一类请求设置的限制的数值)
上图中大括号中的是场景描述,例如:{Limit Mode}keyCounter <= limit && defaultCounter <= defaultThreshold表示在设置了限制模式的场景下符合当前请求类型计数器(当前请求类型通过请求实现getkey接口返回数据来区别)小于限制且默认计数器小于默认阀值时状态转变。
一点小技巧:在存储预留和限制的阀值时,因为存储在一个map中,通过将阀值设置为负数来区分开,这样节省了区分阀值类型的工作。(这点可以在很多场景中考虑,比如说有多个类型的数据配置需要存储,可以通过数据区间的划分来判断是什么类型的,提高判断效率)
Comet Push Framework:
服务端实现:这期做了很简单的服务端实现,也是为了验证原型,标准的REST实现。
POST操作,用于新增资源,操作后得到资源返回,会话非长连接。
GET操作,获得当前请求的资源,会被加入到资源关注者列表中,保持长连接,用于资源变更后推送变更后的资源对象。
PUT或者Delete操作,短链接,同时产生变化事件,交由后台线程执行通知动作。
批量执行通知消息。
1. ResourceBoard阻塞式的从队列中获取事件通知。
2. 创建临时事件存储Map。
3. 如果存在通知事件,判断是否属于删除事件(此类事件发生在异常发生或者正常结束),如果是删除事件,立刻提交给后台线程池执行删除动作。(删除动作就是获取删除资源的follow列表,然后关闭所有follow的长连接)
4. 如果属于修改事件,判断当前资源的删除事件是否已经保存在临时存储Map中,如果有就不再加入修改事件直接忽略,否则就放入Map。
5. 判断当前循环累积事件是否超过一定时间或者存储的消息量已经超过一定值,如果是就跳出循环,如果否,则继续从队列中获取数据循环判断,直到队列为空。
6. 批量执行临时存储中的事件消息,如果是修改,则获取资源的follows来推送变更后的数据。
细节:
内部对于follow的有效性管理是在发送数据时判断的,如果出错就会产生删除事件。
对于消息批量处理主要是针对数据不断被修改,合并这些无用消息而作,但是某些场景也许就需要所有的修改痕迹,那就不能简单合并,因此资源需要提供类似合并的接口实现来保证获取的正确性。
问题:
海量长连接的支持。
采用简单的Http InnerFrame + js实现客户端增量展现会使得页面数据越来越多,到一定程度需要放弃连接重新建立follow,减轻客户端和服务端双重压力。XHR的方式在各种浏览器中支持的不一致。
代码实现,Demo及测试效果
待续….