在.NET Workflow 3.5中使用多线程提高工作流性能
最近在工作上碰到一个性能问题,由于项目是基于SOA的架构,使得整个系统完全依赖于各种各样的Service,其中用于处理业务逻辑的Business Services全部都用.NET Workflow 3.5实现(历史原因,项目还没升级到Workflow 4)。在众多的Business Service中,其中有一个的主要功能是,通过调用不同的Data Service来获取数据,然后根据业务逻辑来组织这些数据并返回给它的调用者。该Business Service的工作流(Workflow)主要包含三个活动组件(Activity),大致可以用下图表示:
需要说明一下,在实际项目中,这个Workflow本身不仅仅只是简单地包含上面三个Activity,通过性能测试的数据分析,瓶颈就在这三个Activity上,而每个Activity的执行时间又主要消耗在反复调用Data Service上。在此,为了简化问题的描述,我把其它不相干的Activity剔除了,于是就得到了上图的结构。
图中的三个Activity都会分别去调用不同的Data Service来获得数据,尤其在getNotesActivity中,Data Service会被循环调用,这使得系统性能大打折扣。原本有一个解决方案可以在一定程度上提高getNotesActivity的效率,就是修改被调用的Data Service,使得它能够一次性接收多个request的数据,处理完之后再将所有的结果一次性返回,这样就避免了Data Service的循环调用,有效地减少了数据在网络上的来回次数。但是,这种解决方案需要更改Data Service的Request Schema,这个改动是很大的,因为可能有很多其它的Business Service都在调用这个Data Service,牵涉的范围太广了。
根据实际项目,稍加分析不难发现,这个Workflow中的Activity有如下几个特点:
- 三个Activity的输入属性参数都来自于Workflow(即通过与Workflow中定义的DependencyProperty进行绑定而获得数据),并不存在下游的Activity的输入参数需要依赖上游Activity的输出参数的情况
- 每个Activity的输出属性参数都只关注某一种类型的数据,在Workflow Runtime执行完某个Activity后,也会通过DependencyProperty将处理结果传递给Workflow,而这些输出属性参数之间也并没有任何关联
- 三个Activity所调用的Data Service也比较独立,基本上可以说是在做三个完全不同的工作
- 时间主要消耗在Data Service的调用上,不存在由于复杂的运算逻辑导致CPU利用率近似100%的情况,也不存在由于物理内存用完而需要频繁读写虚拟内存的情形
上述的几个特点中,第四点为我们引入多线程或并行任务处理提供了主要依据。这里需要额外岔开一下。有很多软件人员认为,多线程一定能够提高系统性能,因为事务可以分派到多个线程中进行并行处理。我觉得,应该这样去看待这个问题:首先,根据Martin Fowler的《企业应用架构模式》(也就是著名的PoEAA)一书中有关性能的讨论认为,有很多术语可以描述性能,比如:响应时间、响应性、等待时间、吞吐率、负载、负载敏感度等。假设完成某个任务需要的时间很长,比如需要5秒,那么其响应时间就是5秒,而如果让用户等待这5秒过去后,再将系统的控制权交给用户,就会让用户明显感觉很不顺,于是响应性就很差;但如果能将这个任务交给另一个执行体去处理,而程序本身直接将系统控制权交给用户,等那个执行体完成任务处理后,再将结果提供给用户,那么,同样处理这个任务需要5秒钟,这种方式的响应性就明显要好于前者,这也是我们以前做Windows Forms开发的时候,要把耗时的处理交给另一个线程处理,以不至于因为主线程的阻塞而导致界面冻结的尴尬局面。因此,多线程的引入,可以提高系统的响应性。
其次,多线程是否能够提高系统的响应时间?这也未必,在单核处理器上,多线程是采用时间片轮循的方式实现的,也就是说,相同时间点上,只有一个线程在执行,只不过是时间片足够小,轮循频率足够高,才让我们感觉线程是并行执行的,在这样一种体系结构下,完成任务的处理还是需要那么长时间,甚至时间片的切换倒还会带来额外的开销。在多核系统中,或许真的可以提高响应时间,不过我目前没有实际的测试数据用来比较,因此在这个问题上,我还没有足够的发言权。
而对于目前项目的情况,Data Service是分布在网络上不同位置的资源,如果能让这些Data Service同时处理数据请求,再让Business Service去组织分别来自这些Data Service的处理结果,那么整个Business Service的响应时间是可以明显提高的,响应时间提高了,响应性也同样提高了。假设第一个Activity耗时t1,第二个Activity耗时t2,第三个Activity耗时t3,那么,如果按上图中的顺序方式执行,Business Service的响应时间就是t1+t2+t3。但如果让这些Activity并行处理(也就相当于并行调用Data Service使其同时处理数据请求),那么Business Service的响应时间应该就是max(t1, t2, t3)。
于是,我打算将上述的Workflow修改一下,采用多线程的方式来分别运行每个Activity,最后再将结果汇总。我修改后的Workflow如下所示:
在此需要对ParallelActivity说明一下。.NET Workflow 3.5的ParallelActivity并没有做到所谓的并行执行,因为Workflow Runtime是在单独的线程上执行Workflow Instance的,因此,要让多个Activity真正并行执行是做不到的。ParallelActivity的真正用意在于协调每个分支中的SequenceActivity(注意:ParallelActivity的每个分支只能接收一个SequenceActivity),使得其中的每个Activity都有一次执行的机会。
某个分支中的一个活动执行过后,就会轮到下一个分支。当这个分支执行了一个活动后,执行又会转移到再下一个分支,以此类推。当所有分支都有了执行机会之后,又会从第一个(最左侧)分支开始这一过程,继续执行第一个分支的下一个活动(如果存在的话)。因此,在我们的这个例子中,完全可以不用ParallelActivity,而仍然选择原来的结构即可。之前我并没有完全清楚地了解ParallelActivity,开始一直以为ParallelActivity的意思是,让Workflow Runtime同时安排(Schedule)每个分支的执行,以便当每个分支都以异步方式运行时,所有的分支可以实现并行处理。
不过也不要紧,在这里使用ParallelActivity,虽然没有有效地利用它的特性,但与原来的Workflow相比,从可读性上讲,这种结构更容易让人觉得这是一种并行的运行方式。
另一个变化是,原本每个操作都是写在一个自定义的Activity中的,通过重写Activity的Execute方法来做业务处理,而现在则是用CodeActivity来代替原来的Activity,这样做的好处是,可以将业务处理的代码放在同一个Context中,这也为线程同步提供了便利,降低了使用线程的复杂度。
以下是改进后的Workflow的代码,供参考。
1. using System;
2. using System.Collections.Generic;
3. using System.Threading;
4. using System.Workflow.Activities;
5. namespace WorkflowConsoleApplication3
6. {
7. public sealed partial class Workflow1 : SequentialWorkflowActivity
8. {
9. List<Thread> threads = new List<Thread>();
10. public Workflow1()
11. {
12. InitializeComponent();
13. }
14. private void getAdditionalInfoActivity_Execute(object sender, EventArgs e)
15. {
16. var t1 = new Thread(() =>
17. {
18. // Call Data Service 1 to implement business logic...
19. });
20. threads.Add(t1);
21. t1.Start();
22. }
23. private void getNotesActivity_Execute(object sender, EventArgs e)
24. {
25. var t2 = new Thread(() =>
26. {
27. // Call Data Service 2 in a loop to implement business
28. // logic...
29. });
30. threads.Add(t2);
31. t2.Start();
32. }
33.
34. private void getSpecialPointsActivity_Execute(object sender, EventArgs e)
35. {
36. var t3 = new Thread(() =>
37. {
38. // Call Data Service 3 to implement business logic...
39. });
40. threads.Add(t3);
41. t3.Start();
42. }
43.
44. private void syncCodeActivity_Execute(object sender, EventArgs e)
45. {
46. // Wait for all threads to terminate...
47. threads.ForEach(p => p.Join());
48. // TODO: Process with results and exceptions
49. }
50. }
51. }
52. 从上面的代码中可以看到,每个
从上面的代码中可以看到,每个CodeActivity在执行的时候都会启动一个线程,这个线程会调用相应的Data Service来实现其业务逻辑,线程创建以后,会被保存在一个线程列表里,用来在syncCodeActivity中进行线程同步。syncCodeActivity则通过线程的Join方法来等待所有线程全部完成各自的工作,最后对运行结果和异常进行处理。
此处线程的运用需要遵循.NET线程使用的最佳实践,应该尽量避免线程的阻塞,在访问临界资源的时候应作加锁处理以防止状态异常。由于在这个例子中,每个线程又会牵涉到其它Service的调用,因此在线程中捕获的异常,我建议还是先将其记录下来,然后温和地直接使用return语句终止线程执行,而不是随意抛出异常而使得线程进入一个不确定的状态。当然,读者朋友如果在多线程环境中有处理异常的经验,也恳请在本文留言指导。
对Workflow进行调整之后,重新编译、部署并运行这个Business Service,然后用已经写好的Client程序进行测试,我们得到了如下的结果(几个明显的噪音数据已经被划掉,没有包含在统计中)。从这个报表可以看到,针对我们的这个案例,在Workflow中引入多线程的确可以明显地提高系统性能。