前言
异步操作是强大的,它是许多高伸缩性架构的基石。异步操作在许多情况下是必须的,例如在客户端保持用户界面的响应能力,以及在日益兴起的云计算场景中。但是,异步编程又是十分困难的,它让这让许多程序员敬而远之。因此,越来越多的编程语言都对异步编程提供了相当程度的支持,其中的典型代表便是F#中的异步工作流以及Scala的Actor模型。不过目前的一些主流编程语言,如C#或是JavaScript,它们在设计之时并没有在异步编程上考虑太多,我们便会根据它们的语言特性,提供合适的异步编程模型及其实现。而本文介绍的便是其中一例:响应式编程(Reactive
Programming)模型及响应式框架(Reactive
Framework,简称Rx)。
异步编程的难点
异步编程之所以困难,主要有三大难点。
首先是对于状态的维护。在普通编程中,我们已经习惯了根据各种状态采取不同做法的编程方式。在异步编程中,状态对于操作的影响则往往更为复杂。例如,我们在编写一个鼠标“拖动及绘图”的行为时,一般会采用这样的逻辑:
在MouseDown事件中将isDragging标记设为true,表示“拖动开始”,并记录当前鼠标位置prevPos。
在MouseUp事件中将isDragging标记设为false,表示“拖动结束”。
在MouseMove事件中检查isDragging标记,如果为true,根据鼠标当前位置currPos和之前记录的prevPos进行绘图,并将currPos的值写入prevPos。
仅在这样一个最基本的场景中,我们便需要编写三个事件处理器(Event
Handler),控制isDragging,prevPos等外部状态,并根据这些状态决定事件触发时的效果。这样的例子数不胜数,尤其是在各式拖放操作中,几乎都会涉及大量状态的控制(例如,判断物体是否进入某个特定区域)。
异步编程的另一个难点,在于异步操作之间的组合及交互。例如在如上的简单拖放操作中,我们便涉及到了MouseDown,MouseUp及MouseMove三个事件。从某些角度来说,客户端的UI事件还是比较容易处理的,因为它们往往都是在单一线程上依次执行。但是在另外一些场景中,如云计算时,我们往往会同时发起多个异步操作,并根据这些操作的结果进行后续处理,甚至还会有一个额外的超时监控,这样便很有可能会出现并发操作的竞争(Race)情况,这将会成为程序复杂度的灾难。
此外,异步操作还会破坏“代码局部性?ode
Locality)”,这可能也是异步操作中最为常见的阻碍。程序员早已习惯了“线性”地表达逻辑,但即便是多个顺序执行的异步操作,也会因为大量的回调函数而将算法拆得支离破碎,更何况还会出现各种循环及条件判断。同时,在线性的代码中,我们可以使用“局部变量”保存状态,而在编写异步代码时则需要手动地在多个函数中传递状态。此外,由于逻辑被拆分至多个方法,因此我们也无法使用传统的try/catch进行统一异常处理。
推模型与拉模型
平时我们使用最多的便是“交互式(Interactive)”的编程方式,采用的是组件之间的相互调用来表现逻辑。例如,对象A向对象B请求数据并等待返回,待对象B完成并返还数据之后A才继续进行后面的操作。交互式编程的一个典型应用便是GoF23中的迭代器(Iterator)模式,它在.NET中的实现为IEnumerable及IEnumerator接口,例如:
void Traverse(IEnumerable
source)
{
var
etor
=
source.GetEnumerator();
while
(etor.MoveNext())
{
Console.WriteLine(etor.Current);
}
}
为了更好地说明问题,这里我们将标准的foreach操作展开为传统的迭代器使用形式,并省略了using语句。在使用时,我们先调用一个IEnumerable对象的GetEnumerator方法,获得一个迭代器,再根据MoveNext及Current进行遍历。在调用MoveNext时,迭代器会去“准备”下一个元素,并根据存在与否返回true或者false。试想,如果其中某个MoveNext的“准备”工作涉及到一个耗时较长的操作,则迭代器的使用者也必须眼巴巴地等待其返回。
这是一种“拉(Pull)”模型,数据由消费者?onsumer)从生产者(Producer)那里主动“拉”来。这是一种同步的交互方式,数据消费者会依赖于数据生产者的表现。这就好比我们去食堂吃饭时必须主动去取餐,此时则必须从队伍的最后排起,我们什么时候能结束等待并进行下一步操作(即“吃饭”),则要看食堂的生产速度如何。很显然,有些时候这种交互方式是不可接受的,例如我们在实现一个搜索引擎的“关键字提示”功能时,不可能让用户在输入一个字符后,必须等待远程的提示请求返回才能继续输入下一个字符。
而与交互式编程对应的便是“响应式(Reactive)”编程。响应式编程是一种基于“改变”的编程方式。例如在交互式编程中,A
=
B
C这样的表达式意味着将B与C之和赋给A,而此后B与C的改变都与A无关。而在响应式编程中,A会去“响应”B或C的变化,即一旦B或C改变之后,A的值也会随之变化。响应式编程的一个典型应用便是GoF23中的观察者(Observer)模式。与迭代器的IEnumerable/IEnumerator不同,在之前的.NET框架中并没有对这样一种编程模型指定“标准化(Formallized)”接口,不过在.NET
4.0的基础类库中增加了IObservable及IObserver接口,签名如下:
public interface
IObservable
{
IDisposable
Subscribe(IObserver
observer);
}
public interface
IObserver
{
void
OnCompleted();
void
OnError(Exception
error);
void
OnNext(T
value);
}
如果我们仔细比较“迭代器”与“观察者”的标准化接口,则会发现它们是完全“对偶(dual)”的:
IEnumerable.GetEnumerator方法“输出”一个IEnumerater对象;IObservable.Subscribe方法“输入”一个IObserver对象。
在遍历元素用尽时,IEnumerator.MoveNext方法返回false;在响应内容用尽时,IObserver.OnCompleted方法被调用。
在有新元素需要遍历时,IEnumerator.MoveNext方法返回true,并通过Current属性“输出”;在有新元素需要响应时,IObserver.OnNext方法被调用,并通过参数“输入”。
在出现错误时,IEnumerator.MoveNext方法会“抛出”一个异常;在出现错误时,IObserver.OnError方法会被调用,并通过参数“接受”异常信息。
至于IObservable.Subscribe方法返回的IDisposable对象,则用于“退定”操作,即让输入的IObserver对象再也不需要继续响应IObservable对象的新元素了。
从比较中可以看出,如果说IEnumerator对象是由数据消费者使用的话,那么IObserver对象则是由数据的生产者,即IObservable对象使用的。换句话说,数据是由数据的生产者“推”给数据消费者的,是一种“推(Push)”模型。在这种异步的交互方式中,数据消费者不必依赖于数据生产者的表现。这就好比我们去饭店吃饭,点菜后便可坐下和同伴聊聊天或是用手机上上网,而作为菜品的生产者,饭店,则会在产出之后主动端上桌来。这么做无疑解放了数据的消费者,例如用户可以在文本框里不断地输入字符,而只需等远程服务器将提示结果“推”给客户端后再显示即可。
许多模型都可以统一至标准的生产者接口IObservable上,如MouseMove事件便可以认做是“永不停止的MouseEventArgs对象的生产者”。而另一方面,单个异步操作则可以被视为“只产出单个数据便结束的生产者”。
LINQ to
Observable
在许多人眼中,C#
3.0中新增的LINQ特性只是一种用于操作数据的DSL,它的主要作用也仅仅是针对IEnumerable或是IQueryable的数据操作。事实上,LINQ本身的能力远不止此。LINQ是一种非常简单的语言特性,编译器只是将LINQ查询语句转化为“字面等价”的“LINQ标准方法”调用(如Where,Select等等)和“和Lambda表达式”参数(如x
=>
x
>
0)而已。但是这里的关键便是“字面等价”四个字,LINQ本身并不规定“LINQ标准方法”是对象的实例方法还是扩展方法,“Lambda表达式”是构造出一个匿名函数还是表达式树,这一切都是由利用LINQ的类库来决定的。因此,微软能够基于LINQ实现了PLINQ这样的并行类库。直到最近的访谈中,LINQ的设计者Erik
Meijer依旧认为LINQ还是被低估了,他们自己也还在继续挖掘LINQ的更多能力。
虽然LINQ本身在语法上只是一些方法调用,但是它在语义上是针对数据流的一系列操作,因此LINQ
to
Object,LINQ
to
SQL以及PLINQ可以认为是最自然,最符合LINQ语义的应用。如今,微软的“云编程能力团队?loud
Programmability
Team)”在其“响应式框架(Reactive
Framework,简称Rx)”提供了LINQ
to
Observable,这又是另一个LINQ的经典使用案例。IObservable本身可以被认为是一股“推送数据”的数据流,因此也可以对其进行“过滤”或是“投影”等操作,这便是LINQ的应用场景,LINQ
to
Observable是对IObservable接口实现的一系列LINQ标准方法。因此,我们可以认为,LINQ
to
Observable是一套与LINQ
to
Object对偶的类库,事实上在响应式框架中,还有一套与LINQ
to
Queryable对偶的LINQ
to
Qbservable,请注意第一个字母是Q,我们可以把它看作是LINQ
to
Observable的“远程查询”版本。
利用LINQ
to
Object可以编写出声明式(表示“做什么”)的代码,其可读性往往远高于等价的命令式(表示“怎么做”)代码。LINQ
to
Observable也有类似的效果。假设现在有一个需求:利用ADWS键控制小球的位置。传统的写法可能是这样的:
void OnKeyPress(object
sender,
KeyPressEventArgs
e)
{
//
如果游戏已经开始
if
(isPlaying)
{
//
向左且小球没有超出边界
if
(e.KeyChar
==
\'a\'
&&
ball.Left
>
0)
{
ball.Left
-=
5;
}
//
向上且小球没有超出边界
else
if
(e.KeyChar
==
\'w\'
&&
ball.Top
>
0)
{
ball.Top
-=
5;
}
else
...
}
else
...
}
由于KeyPress事件总是不断触发,因此我们只能它的事件处理器中进行判断各种状态,采取不同措施。而如果我们利用LINQ
to
Observable,则几乎是另外一种思维方式:
// 过滤出isPlaying时的keyPress事件
var keyPress
=
GetKeyPress().Where(_
=>
isPlaying);
// 过滤出向左移动的事件
var moveLeft
=
from
ev
in
keyPress
where
ev.EventArgs.KeyChar
==
\'a\'
where
ball.Left
>
0
select
ev;
moveLeft.Subscribe(_ =>
ball.Left
-=
5);
// 过滤出向上移动的事件
var moveTop
=
from
ev
in
keyPress
where
ev.EventArgs.KeyChar
==
\'w\'
where
ball.Top
>
0
select
ev;
moveTop.Subscribe(_ =>
ball.Top
-=
5);
我们可以将“KeyPress事件”视为“推送KeyPressEventArgs对象”这一数据流的数据源(由GetKeyPress方法返回),那么如今的代码便是使用LINQ过滤出“需要”的数据,并针对真正需要的那部分进行响应。这么做,便将“条件”与“操作”解耦,显著增强了代码的语义表达能力。事实上,只要补充一些辅助方法,可以利用LINQ表示更为完整复杂的逻辑。例如,微软咨询师Matthew
Podwysocki便在博客中展示过一段代码,基于LINQ
to
Observable实现了创建一个WebRequest对象,设置属性,异步发送及下载数据的一系列操作。
更多扩展
.NET基础类库针对IEnumerable定义了大量的函数式的辅助方法,开发人员可以直接将它们组合运用在项目中。除了标准的LINQ操作方法之外,响应式框架中同样定义了大量辅助方法,可以配合LINQ
to
Observable组合使用。例如本文开头所设想的鼠标“拖动及绘图”功能,便可以使用如下代码完成:
var mouseMove
=
GetMouseMove();
var mouseDiff
=
mouseMove.Zip(mouseMove.Skip(1),
(prev,
curr)
=>
new
{
PrevPos
=
new
Point(prev.EventArgs.X,
prev.EventArgs.Y),
CurrPos
=
new
Point(curr.EventArgs.X,
curr.EventArgs.Y)
});
var mouseDrag
=
from
_
in
GetMouseDown()
from
diff
in
mouseDiff.TakeUntil(GetMouseUp())
select
diff;
mouseDrag.Subscribe(diff =>
DrawLine(diff.PrevPos,
diff.CurrPos));
在这段代码中,我们首先将mouseMove事件使用Skip跳开一项,再与自身通过Zip方法组合成mouseDiff,这是一个输出相邻两次MouseMove事件坐标的数据源;接着,我们利用LINQ从触发MouseDown事件开始,向mouseDiff数据源获取每一项diff,直至(TakeUntil)触发MouseUp事件,以此生成最终的mouseDrag;最后再将绘图功能订阅至这个数据源上。您会发现此时我们已经无须手动维护操作过程中的各种状态了,从事件的“开始”到“结束”均使用响应式框架的辅助方法“声明”而来。
以上便是一个利用了Skip,Zip,TakeUntil等辅助方法的例子。当然,这些辅助方法在IEnumerable上都有语义相同的对应操作,而在响应式框架中还有更多辅助方法是针对特性异步场景的。假设我们现在要编写一个即时翻译功能,同时发起三个请求,将中文分别翻译至英语、法语及西班牙语,并显示最先返回的两个结果(真是个奇怪的需求)。此外,我们不会在用户输入每个字符的时候便发起一个远程请求,而是在用户停止输入0.5秒之后才根据当前的输入框中的文字进行提示。于是我们可以编写这样的代码:
var limit
=
TimeSpan.FromSeconds(0.5);
var translate
=
from
_
in
GetKeyPress().Throttle(limit)
let
text
=
this.txtInput.Text
where
text.Length
>
0
let
english
=
Bing.Translate(text,
"en")
let
french
=
Bing.Translate(text,
"fr")
let
spanish
=
Bing.Translate(text,
"es")
from
result
in
Observable.Join(
english.And(french).Then((en,
fr)
=>
new
{
English
=
en,
French
=
fr,
Spanish
=
""
}),
english.And(spanish).Then((en,
es)
=>
new
{
English
=
en,
French
=
"",
Spanish
=
es
}),
french.And(spanish).Then((fr,
es)
=>
new
{
English
=
"",
French
=
fr,
Spanish
=
es
}))
select
result;
translate.Subscribe(...);
这里用到了Throottle方法,它会过滤某个数据源的输出,确保在该数据源“静默”特定时间之后,才将最近的一条数据推送至外部。此外,这里还使用了Observable.Join方法控制多个数据源,根据返回结果的先后获得合适的结果。响应式框架提供了大量针对某种异步场景的辅助方法,例如用于定期推送数据的Interval方法,从一个数据源根据特定条件进行采样的Sample方法,合并多个数据源的ForkJoin方法,以及表示流程控制的For,While,If等等。这些方法内部会维护各种所需要的状态,为我们打理各种复杂的竞争情况,以此节省了开发人员的精力。
如果这些还不能满足我们的要求,我们也可以根据自己的需要开发特定的辅助方法,就像我们在使用LINQ
to
Object时为IEnumerable所作的各种扩展那样。响应式框架也提供了一系列Subject类型,简化了IObservable自定义扩展的开发过程。由于响应式框架尚未正式发布,微软目前建立了一个Wiki,用于展示关于各辅助方法及Subject类的使用示例及其他相关信息。
响应式框架的JavaScript版本
响应式编程的重要使用场景之一便是与用户交互的GUI界面。例如,Silverlight禁止任何阻塞的IO操作,换言之Silverlight中的所有网络操作都是异步的,微软也正是出于简化异步开发的目的才设计了响应式框架(事实上响应式框架已经集成到Silverlight
Toolkit中)。不过与Silverlight相比,基于浏览器的原生JavaScript应用程序无疑使用地更为广泛。对于这样的应用程序来说,动画是异步的,AJAX请求也是异步的,我们几乎可以断言,如果有一套面向JavaScirpt应用程序的响应式框架,一定会比面向Silverlight的框架更有意义得多。
微软也想到了这一点。之前我们讨论的“响应式框架”,其实只是响应式编程模型的一种实现。更确切地说,我们只是讨论了这套框架的.NET版本,微软还提供了JavaScript版本的响应式框架。JavaScript版本的API与.NET版本几乎完全一致,例如我们之前讨论的拖放操作,使用JavaScript即可写作:
var target
=
$("#dragTarget");
var mouseMove
=
target.toObservable("mousemove");
var mouseDiff
=
mouseMove.Zip(mouseMove.Skip(1),
function(prev,
curr)
{
return
{
PrevPos:
{
x:
prev.clientX,
y:
prev.clientY
},
CurrPos:
{
x:
curr.clientX,
y:
curr.clientY
}
};
});
var mouseDown
=
target.toObservable("mousedown");
var mouseUp
=
target.toObservable("mouseup");
var mouseDrag
=
mouseDown.SelectMany(function()
{
mouseDiff.TakeUntil(mouseUp);
});
mouseDrag.Subscribe(...);
由于没有C#中的LINQ查询语言,我们只能直接使用展开后的方法,如SelectMany来编写逻辑。JavaScript版本的响应式框架还提供了一系列的“胶合”层,能够与jQuery,Dojo,MooTools,Prototype等流行框架同时使用。例如,上一段代码中的toObservable便是在jQuery根对象上扩展的方法。
总结
异步编程在用户交互式界面及一些云计算场景中尤其重要。微软的云编程能力团队针对.NET平台和JavaScirpt分别提供了一套响应式框架,希望以此简化异步程序的开发。不过,这套响应式框架所表现出的理念是通用的。而且,事实上只要是拥有匿名函数及闭包的语言,例如Scala,Python,Ruby等等,实现这样一套框架其实都不是十分困难的事情。
异步编程的难点
在MouseDown事件中将isDragging标记设为true,表示“拖动开始”,并记录当前鼠标位置prevPos。
在MouseUp事件中将isDragging标记设为false,表示“拖动结束”。
在MouseMove事件中检查isDragging标记,如果为true,根据鼠标当前位置currPos和之前记录的prevPos进行绘图,并将currPos的值写入prevPos。
推模型与拉模型
void
{
}
public
{
}
public
{
}
IEnumerable.GetEnumerator方法“输出”一个IEnumerater对象;IObservable.Subscribe方法“输入”一个IObserver对象。
在遍历元素用尽时,IEnumerator.MoveNext方法返回false;在响应内容用尽时,IObserver.OnCompleted方法被调用。
在有新元素需要遍历时,IEnumerator.MoveNext方法返回true,并通过Current属性“输出”;在有新元素需要响应时,IObserver.OnNext方法被调用,并通过参数“输入”。
在出现错误时,IEnumerator.MoveNext方法会“抛出”一个异常;在出现错误时,IObserver.OnError方法会被调用,并通过参数“接受”异常信息。
LINQ
void
{
}
//
var
//
var
moveLeft.Subscribe(_
//
var
moveTop.Subscribe(_
更多扩展
var
var
var
mouseDrag.Subscribe(diff
var
var
translate.Subscribe(...);
响应式框架的JavaScript版本
var
var
var
var
var
var
});
mouseDrag.Subscribe(...);
总结