.NET 4.5 中的多线程支持改进
简要概述 .NET 4.5 中对异步编程的新语言支持。
引言
这是对 .NET 4.5 中即将推出的多线程编程新语言支持的一次快速介绍。这些示例是使用去年 9 月发布的 Visual Studio 11 开发者预览版编程的。
多线程的演进
与 Java 一样,C# 从一开始就支持多线程。在早期,这意味着 CLR 中有一组同步类,以及 lock
语句。虽然这比调用系统库要好,但编写多线程代码的难度对于大多数开发人员和项目来说仍然太高。
但多线程编程越来越重要,因为计算机的未来发展方向是多核并行处理,而不是更快的时钟速度。因此,Microsoft 在几乎每个版本中都不断增强 .NET 的多线程支持。.NET 4 引入了任务并行库 (TPL),这是一个重要的概念性进步。.NET 4.5 通过将任务直接集成到语言中,在此基础上更进一步。
任务而非线程
这种最新的多线程方法是根本不考虑或处理线程!相反,您使用任务进行编程,任务只不过是将来某个时候可用的一个值(数据)(技术上讲,是“未来值”)。这有什么帮助?
目的是使并行代码尽可能像顺序代码一样编写和运行。考虑以下函数,它在显示结果之前调用另外两个耗时较长的函数。
// Calling code
..
Function(); // thread blocks here until Function completes
..
public void Function()
{
string s1 = GetExpensiveString();
string s2 = GetAnotherExpensiveString();
Console.WriteLine(s1 + s2);
}
这段代码的问题在于,GetAnotherExpensiveString
必须在 GetExpensiveString
完成后才能开始。此外,调用代码(以及程序的其余部分)在两个耗时函数返回之前都会被阻塞。
在这个简单示例中,'expensive' 方法只是模拟耗时
private static string GetExpensiveString()
{
for (int i=0; i < 5; i++)
Thread.Sleep(1000); // allow other threads to jump in
return DateTime.Now.ToLongTimeString();
}
// GetAnotherExpensiveString() the same
在 .NET 4.5 中,这段代码变为:
// Calling code
..
FunctionAsync(); // control returns well before FunctionAsync completes
..
public async Task FunctionAsync()
{
string s1 = await GetExpensiveStringAsync();
.. control yields; s1 assigned sometime later
string s2 = await GetAnotherExpensiveStringAsync();
.. more time passes
Console.WriteLine(s1 + s2);
}
private static Task<string> GetExpensiveStringAsync()
{
return Task<string>.Factory.StartNew( () => GetExpensiveString() );
}
// GetAnotherExpensiveStringAsync the same
我们引入了两个关键字和一个命名约定,以指示控制可以切换到另一个线程然后回退到原处的位置。
让我们先看一下耗时的方法。它们已更改为返回 Task<string>
而不是 string
,并且按照惯例,它们的名称现在以“Async”作为后缀。因此,它们不再返回我们想要的 string
,而是返回一个 Task
对象,这是一种代理或承诺,表示“我稍后会将字符串提供给您。”由于我的耗时函数不访问任何共享变量,因此没有锁定问题,所以异步版本只是在任务中调用同步函数。
但我仍然将返回值赋给字符串变量。这之所以可行,是因为在每个调用前面都有 await
关键字。这本质上表示“每当此任务完成时,请回到这里,为其赋值,然后继续。”
另一个更改是函数签名:我们用 async
关键字装饰了函数,将返回值更改为 Task
,并再次按照惯例添加了“Async”后缀。这些更改告诉编译器和其他程序员,此函数包含异步控制流。
异步控制流
我们的函数看起来很相似(这就是目的),但行为却截然不同。当您调用 Function
时,您会等待一段时间,直到写入控制台才返回。在 FunctionAsync
中,对 GetExpensiveStringAsync
的调用会在另一个线程上启动一个任务,然后立即返回,此时此线程上的控制流会继续。
过了一会儿,任务完成,CLR 会在 FunctionAsync
中恢复执行。在这种情况下,过程会重复:CLR 启动另一个后台任务,过了一段时间后再回到这里以最终执行控制台写入。
请注意,与 Function
不同,FunctionAsync
的调用者在快速返回后会继续执行。现在,您可以并行执行其他工作,并在需要时与 FunctionAsync
同步。这是修改后的调用代码:
// Original calling code
// Function(); // thread blocks here until Function completes ...
Task task = FunctionAsync();
// .. do other work here in parallel with FunctionAsync ..
task.Wait(); // block here in case FunctionAsync is still running
// FunctionAsync has finished. If it had a return value,
// retrieve it from the task like this:
// var result = task.Result;
那么,是什么使得这一切得以工作呢?
在底层,C# 已将我们的简单函数转换成了一个状态机,并创建了隐藏类,这些类基本上将调用堆栈保存在堆上。每个 await
表达式都标记了一个函数可以切换线程或“暂停”然后稍后恢复的位置。
这在概念上都很简单,但在细节上却相当复杂。这尤其适用于恢复复杂的控制流(例如嵌套的 if 和循环)以及传播异常。这与迭代器中 yield
语句的工作方式有相似之处,并且也利用了对最初通过 lambda 表达式引入的闭包中的局部变量的捕获。
更多异步
我们在这里实现了一些并行执行,但我们可以做得更好。我们将 GetExpensiveString
移到后台,以便其他处理可以并行进行。但按照目前的结构,GetAnotherExpensiveString
在 GetExpensiveString
完成之前无法开始执行。这个最终版本解决了这个问题。
public async Task FunctionAsync()
{
Task<string> t1 = GetExpensiveStringAsync();
Task<string> t2 = GetAnotherExpensiveStringAsync();
await Task.WhenAll(t1, t2);
string s1 = t1.Result;
string s2 = t1.Result;
Console.WriteLine(s1 + s2);
}
为了让两个耗时函数并行运行,我们必须更直接地处理任务对象。在这里,我们使用任务的 WhenAll
方法来等待所有其他任务完成。.NET 4.5 的 Task
API 添加了几个新方法,可以非常方便地组合和同步任务。
总结
不要被迷惑:这里没有魔法。异步编程**仍然**是困难和复杂的。这个简单的示例程序最终运行在四个不同的线程上。现在它不像以前那样容易重复,因为它受每次运行时的时序变化的影响,尤其是在多核处理器上。
顺序代码执行是每个程序员大脑中根深蒂固的基本期望。异步编程总是很困难,因为它违反了这一基本假设。它迫使您以不同的方式思考您的程序。在我开始掌握这些新特性之前,我花了大约一天的时间来摸索(挣扎)。从这个意义上说,它可能与学习 LINQ 或 lambda 表达式相似。一如既往,Microsoft 的工具、示例和文档支持将帮助您快速掌握。
这是值得的,因为收益非常显著:在这个例子中,每个耗时函数需要五秒钟才能执行,而最终的并行程序总共只需要五秒多一点,而原始版本需要十秒。除了速度提升之外,在生产环境中真正的优势是可以保持 UI 实时响应。
新的 .NET 异步编程模型是一个巨大的进步。与其他领域一样,它允许您在更高的层面进行思考和编程,同时它会管理细节。编译器和运行时中有很多复杂的机制来实现这一点,这也应该是这样的。
历史
- 2011 年 10 月 27 日 - 根据反馈进行了修订。
- 2011 年 10 月 26 日 - 原始文章。