PDC 09:并行和异步编程中的挑战及 F# 的应对方案

  • 赵劼

2009 年 11 月 22 日

话题:.NET编程语言语言 & 开发架构

在最近举办的 PDC 09 大会中,F# 的程序经理 Luke Hoban 发表了一场名为“F# 并行与异步编程”的演讲,其中提出了并行与异步编程的多个重点与挑战,并解释了 F# 是如何从语言特性及类库框架两方面来给出合适应对方案。

F# 是.NET 平台上的又一门编程语言,结合了函数式编程及面向对象编程两种编程范式。微软研究院在 5 至 6 年开始着手设计并开发 F#,并且将随 Visual Studio 2010 一起发布其稳定版本及相关工具包,可用于产品的开发。与 C# 和 VB 一样,F# 是一门强类型的静态语言。Luke 指出,F# 是一门通用语言,可用于各种程序的开发,不过它的许多特性非常适用于开发那些算法性强,或是并行和异步占较大比重的应用程序。

Luke 在演讲中提出了并行编程的四项挑战,其中第 1 个是“状态共享”。这里的共享状态特指可变(mutable)的状态,即可能被多个并行的组件所同时修改的内存。当遇到这样的情况,这意味着每次修改都可能影响多个组件,如果处理不当便可能造成难以预料的情况。而此时,从逻辑上分割各组件事实上也并非互相独立的,这给系统维护带来了困难。这种情况也很难测试,因为重现某个测试需要各并行的组件都处在特定的状况下。最大的问题可能是这样的代码很难高度并行,多个并行的组件如果需要共享同一块内存,则几乎一定会用到锁。锁很难处理,因为这非常依赖于各个组件是如何使用,以及何时使用某块内存的。如果程序新增了一个组件,甚至只是对现有组件做出少量修改,这可能就会让并行的应用程序对新的内存造成共享,于是便不知不觉地破坏了线程安全。

F# 提出在语言特性上强化不可变的(immutable)程序开发方式。不可变的编程方式表示尽可能避免那些可修改的内存数据。在 F# 中,默认情况下的所有变量、函数、参数等等,一旦绑定(bind)至某个标识符后都是不可修改的。F# 中的一些常用的数据类型,如 Record,Tuple 或 Discriminated Union 都是不可变的。如果想要一个状态不同的对象,开发人员只能“新建”而不能“修改”,而 F# 也提供了一定的语言特性来辅助此类操作。由于不可变性,在 F# 中便可以轻松使用各种方式进行并行计算,而不必担心线程安全问题。例如,可以使用.NET 4.0 中的 PLinq——在 F# 则被封装为 PSeq 模块进行序列的映射,过滤或求和等操作。此外,F# 还提供了如 List,Set,Map 等不可变的常用数据结构。对于它的面向对象编程的部分,Luke 指出 F# 也拥有一些特性,鼓励开发人员构建不可变的类型,即没有 set 操作,每个方法都只是根据参数进行计算并返回结果,而不是改变内部状态的类型。

Luke 提出的第 2 个挑战是异步编程中的控制切换(Inversion of Control)问题。他认为,开发人员一直习惯于编写顺序的程序,即使用一行代码接着另一行代码的方式来实现逻辑。但是对于一些耗时很长的操作来说,这么做会阻塞程序的主线程,如在 UI 程序中阻塞主线程则会引起界面的僵死,此时往往需要异步调用。但是,异步调用需要将程序逻辑分为两个或多个阶段,在执行完一个阶段之后,再将结果通过回调函数传递给下一个。但编写这样的代码非常困难,往往需要为异步程序的控制编写大量代码,例如异常处理或任务取消等等。传统.NET 异步编程模型,如解耦的 Begin/End 方法都无法解决这个问题。当需要异步调用的逻辑越来越多,甚至需要在其中加入一些循环或判断等逻辑,那程序的编写很容易变得越来越复杂。

F# 中提供了一个名叫工作流(Workflow)的语言特性来应对这个问题。Workflow 可以被认为是 F# 版本的 monad 实现,它的主要特色便是由编译器对顺序编写的代码进行 desugar 操作,形成回调的方式便于异步执行其中某些步骤。Luke 演示了一个使用 C# 编写的,从 Azure 云中下载图片的 WPF 应用程序,其中长时间同步操作导致界面僵死。而将这段同步逻辑转化为异步则需要好几页的代码,其中的主要问题便是原本简单的 for 操作必须交由额外的上下文对象来保存,这样逻辑便在业务部分及异步控制部分中不断切换,造成难以实现和维护的代码。而使用 F# 实现相同的工作时,只需要使用 async {...}将原有的逻辑包装起来,便形成了一个异步工作流。然后再将其中的一些耗时操作的 let 和 do 指令修改为 let! 或 do!,这样便告知 F# 这两个步骤在执行时需要将控制权交还给框架,在得到结果之后才通过回调函数继续执行后面的逻辑。代码中原本的 for 循环可以被 F# 正确的处理,其表现形式和顺序的代码逻辑可谓毫无二致。值得一提的是,演示中 Luke 使用 F# 构建的类库可以直接被 C# 编写的 WPF 应用程序使用,唯一的修改只是引入了不同的命名空间而已。

第 3 个挑战是应用程序与 I/O 设备的交互,例如磁盘或是远程的云,这便是 I/O 密集型(I/O Bound)逻辑。由于各种 I/O 设备(如硬盘及网卡)往往是独立的,因此需要同时发起多个 I/O 请求才能够充分利用资源,提高程序的性能及响应能力。这便涉及到 I/O 并行(I/O Parallelism)。而使用 async { ... }所形成的多个异步工作模块可以由 F# 组合成单个异步工作块,然后作为.NET 4.0 中的任务(Task)执行。每个异步工作块中的 I/O 异步操作使用 let! 指令,在工作时可以将控制权交由 F#,而保持原有逻辑的顺序性。由于每个 I/O 操作都是异步的,它并不会占用应用程序的工作线程。因此,即便是同时发起许多 I/O 请求,从任务管理器中也可以发现应用程序其实只使用了少量的线程。

最后一个挑战,是指并行应用程序往往只能简单实现向上扩展(Scale Up),而难以扩展至许多廉价机器所组成的集群。如果要有良好的向外扩展(Scale Out)能力,必须从程序设计初期便抱有这样的想法。这往往意味着使用消息和代理(agent)进行编程,它是一种为并行程序提供扩展能力的基础方式。Erlang及微软的Axum都使用了类似的思想,F# 也提供了 Agent 组件,在每次发布过程中这个组件也在不断演化。使用基于 Agent 的方式,各组件的依赖便消失了,它们完全通过消息传递进行通信。F# 的 Agent 组件是 MailboxProcessor,它的 Start 函数会提供一个 inbox。开发人员可以使用 inbox 的 Receive 方法发起一个非阻塞的接受操作,由于使用了异步工作块及 let! 指令,这行代码并不会阻塞线程,而是把控制权交由 F#,直至获得一个消息。每个 Agent 对象都是非常轻量的对象,它与线程并没有对应关系。因此,即便是创建了大量的 Agent 对象也不会占用太多系统资源,F# 会基于.NET 4.0 中的 TPL 来合理并充分利用计算能力。

你可以在PDC 2009 的网站上浏览或下载本次演讲的完整录像及幻灯片等资源。你也可以访问InfoQ 中的 F# 栏目来获得更多相关内容。

.NET编程语言语言 & 开发架构