C# 并发编程秘籍第二版(三)
原文:
zh.annas-archive.org/md5/94f6d64de2f76d3e98d9e7e8e4ee1394译者:飞龙
第十章:取消
.NET 4.0 框架引入了详尽且设计良好的取消支持。这种支持是协作性的,这意味着可以请求取消但不能强制执行取消。由于取消是协作性的,除非编写支持取消的代码,否则不可能取消代码。因此,我建议尽可能在自己的代码中支持取消。
取消是一种信号类型,有两个不同的方面:触发取消的源和响应取消的接收器。在.NET 中,源是 CancellationTokenSource,接收器是 CancellationToken。本章的配方涵盖了取消的来源和接收器在正常使用中的应用,并描述了如何使用取消支持与非标准取消形式进行交互。
取消被视为一种特殊的错误。约定是取消的代码将抛出 OperationCanceledException 类型的异常(或其派生类型,如 TaskCanceledException)。这样调用代码就知道已观察到取消。
为了向调用代码指示您的方法支持取消,您应该将 CancellationToken 作为参数。该参数通常是最后一个参数,除非您的方法还报告进度(配方 2.3)。您还可以考虑为不需要取消的消费者提供重载或默认参数值:
public void CancelableMethodWithOverload(CancellationToken cancellationToken)
{
// Code goes here.
}
public void CancelableMethodWithOverload()
{
CancelableMethodWithOverload(CancellationToken.None);
}
public void CancelableMethodWithDefault(
CancellationToken cancellationToken = default)
{
// Code goes here.
}
CancellationToken.None 表示一个永远不会被取消的取消标记,是一个特殊值,等同于 default(CancellationToken)。当消费者不希望操作被取消时,会传递这个值。
异步流处理取消方式类似,但更复杂。有关异步流的取消详细信息,请参见配方 3.4。
10.1 发出取消请求
问题
您的代码调用可取消的代码(接受 CancellationToken 参数),而您需要取消它。
解决方案
CancellationTokenSource 类型是 CancellationToken 的源头。它仅使代码能够响应取消请求;CancellationTokenSource 的成员允许代码请求取消。
每个 CancellationTokenSource 都是独立的(除非将它们链接在一起,如配方 10.8 所述)。Token 属性返回该源的 CancellationToken,Cancel 方法则发出实际的取消请求。
以下代码演示了如何创建 CancellationTokenSource,以及如何使用 Token 和 Cancel。该代码使用了一个 async 方法,因为在短代码示例中更容易说明;相同的 Token/Cancel 对被用于取消所有类型的代码:
void IssueCancelRequest()
{
using var cts = new CancellationTokenSource();
var task = CancelableMethodAsync(cts.Token);
// At this point, the operation has been started.
// Issue the cancellation request.
cts.Cancel();
}
在上面的示例代码中,task 变量在启动后被忽略;在真实的代码中,该任务可能会被存储在某个地方,并等待其完成,以便最终用户能够看到最终结果。
当您取消代码时,几乎总会存在竞态条件。可取消的代码可能在取消请求发出时几乎要完成,如果它在完成之前没有检查其取消令牌,它将实际上成功完成。实际上,当您取消代码时,有三种可能的结果:它可能响应取消请求(抛出 OperationCanceledException),它可能成功完成,或者它可能由于与取消无关的错误而完成(抛出其他异常)。
下面的代码与上一个示例相似,但它等待任务完成,展示了所有三种可能的结果:
async Task IssueCancelRequestAsync()
{
using var cts = new CancellationTokenSource();
var task = CancelableMethodAsync(cts.Token);
// At this point, the operation is happily running.
// Issue the cancellation request.
cts.Cancel();
// (Asynchronously) wait for the operation to finish.
try
{
await task;
// If we get here, the operation completed successfully
// before the cancellation took effect.
}
catch (OperationCanceledException)
{
// If we get here, the operation was canceled before it completed.
}
catch (Exception)
{
// If we get here, the operation completed with an error
// before the cancellation took effect.
throw;
}
}
通常,设置 CancellationTokenSource 和执行取消操作是在不同的方法中完成的。一旦取消了 CancellationTokenSource 实例,它就会永久取消。如果您需要另一个源,必须创建另一个实例。以下代码是一个更实际的基于 GUI 的示例,使用一个按钮启动异步操作,另一个按钮取消它。它还禁用和启用 StartButton 和 CancelButton,以确保一次只能进行一个操作:
private CancellationTokenSource _cts;
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
StartButton.IsEnabled = false;
CancelButton.IsEnabled = true;
try
{
_cts = new CancellationTokenSource();
CancellationToken token = _cts.Token;
await Task.Delay(TimeSpan.FromSeconds(5), token);
MessageBox.Show("Delay completed successfully.");
}
catch (OperationCanceledException)
{
MessageBox.Show("Delay was canceled.");
}
catch (Exception)
{
MessageBox.Show("Delay completed with error.");
throw;
}
finally
{
StartButton.IsEnabled = true;
CancelButton.IsEnabled = false;
}
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
_cts.Cancel();
CancelButton.IsEnabled = false;
}
讨论
本配方中最真实的示例使用了一个 GUI 应用程序,但不要认为取消仅适用于用户界面。取消在服务器上同样适用;例如,ASP.NET 提供了一个表示请求超时或客户端断开连接的取消令牌。在服务器端,取消令牌源可能更为罕见,但您仍然可以使用它们;如果需要取消某些 ASP.NET 取消范围之外的操作(例如请求处理的某部分的额外超时),这些令牌非常有用。
参见
Recipe 10.4 讲述了如何在 async 代码中传递令牌。
Recipe 10.5 讲述了如何在并行代码中传递令牌。
Recipe 10.6 讲述了如何在响应式代码中使用令牌。
Recipe 10.7 讲述了如何在数据流网络中传递令牌。
10.2 通过轮询响应取消请求
问题
您的代码中有一个需要支持取消操作的循环。
解决方案
当您的代码中有一个处理循环时,没有更低级别的 API 可以传递 CancellationToken。在这种情况下,您应该定期检查令牌是否已取消。以下代码在执行 CPU 绑定的循环时定期观察令牌:
public int CancelableMethod(CancellationToken cancellationToken)
{
for (int i = 0; i != 100; ++i)
{
Thread.Sleep(1000); // Some calculation goes here.
cancellationToken.ThrowIfCancellationRequested();
}
return 42;
}
如果你的循环非常紧凑(即,循环体执行非常快),那么你可能希望限制检查取消令牌的频率。如常,在进行此类更改之前和之后,请先测量性能,然后决定哪种方式最佳。以下代码与之前的示例类似,但循环迭代更多,因此我添加了对令牌检查频率的限制:
public int CancelableMethod(CancellationToken cancellationToken)
{
for (int i = 0; i != 100000; ++i)
{
Thread.Sleep(1); // Some calculation goes here.
if (i % 1000 == 0)
cancellationToken.ThrowIfCancellationRequested();
}
return 42;
}
应该使用的适当限制完全取决于你正在执行的工作量和取消需求的响应速度。
讨论
大多数情况下,你的代码应该将 CancellationToken 直接传递给下一层。在配方 10.4、10.5、10.6 和 10.7 中有此类示例。本配方中的轮询技术只有在需要支持取消的处理循环时才应使用。
CancellationToken 上还有另一个成员叫做 IsCancellationRequested,当令牌被取消时开始返回 true。有些人使用此成员来响应取消,通常通过返回默认值或 null。我不建议大多数代码使用此方法。标准的取消模式是引发 OperationCanceledException,由 ThrowIfCancellationRequested 处理。如果调用堆栈上游的代码想要捕获异常并像结果是 null 一样处理,那么可以这样做,但是任何使用 CancellationToken 的代码都应该遵循标准的取消模式。如果你决定不遵循取消模式,请务必清楚地记录下来。
ThrowIfCancellationRequested 通过 轮询 取消令牌来工作;你的代码必须定期调用它。还有一种方法可以注册在请求取消时调用的回调函数。回调方法更多地是为了与其他取消系统进行交互;10.9 配方 讲解了在取消时使用回调的方法。
另请参阅
10.4 配方 讲解了将令牌传递给 async 代码。
10.5 配方 讲解了将令牌传递给并行代码的方法。
10.6 配方 讲解了在响应式代码中使用令牌的方法。
10.7 配方 讲解了将令牌传递给数据流网络的方法。
10.9 配方 讲解了使用回调而非轮询来响应取消请求。
10.1 配方 讲解了发出取消请求。
10.3 由于超时而取消
问题
你有一些代码需要在超时后停止运行。
解决方案
取消是超时情况的自然解决方案。超时只是取消请求的一种类型。需要取消的代码只需像处理任何其他取消请求一样观察取消令牌;它既不应该知道也不关心取消源是定时器。
还有一些方便的取消令牌源方法,它们基于计时器自动发出取消请求。您可以将超时传递给构造函数:
async Task IssueTimeoutAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
CancellationToken token = cts.Token;
await Task.Delay(TimeSpan.FromSeconds(10), token);
}
或者,如果您已经有一个CancellationTokenSource实例,您可以为该实例启动超时:
async Task IssueTimeoutAsync()
{
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
cts.CancelAfter(TimeSpan.FromSeconds(5));
await Task.Delay(TimeSpan.FromSeconds(10), token);
}
讨论
要使用超时执行代码,请使用CancellationTokenSource和CancelAfter(或构造函数)。还有其他方法可以做同样的事情,但使用现有的取消系统是最简单和最有效的选择。
记住,需要取消的代码需要观察取消令牌;不可能轻易取消不可取消的代码。
另请参阅
配方 10.4 涵盖了向async代码传递令牌。
配方 10.5 涵盖了向并行代码传递令牌。
配方 10.6 涵盖了在响应式代码中使用令牌。
配方 10.7 涵盖了向数据流网格传递令牌。
10.4 取消异步代码
问题
您正在使用async代码并且需要支持取消。
解决方案
在异步代码中支持取消的最简单方法是将CancellationToken直接传递给下一层。以下示例代码执行异步延迟,然后返回一个值;通过将令牌传递给Task.Delay来支持取消:
public async Task<int> CancelableMethodAsync(CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
return 42;
}
许多异步 API 支持CancellationToken,因此自己启用取消通常只需要简单地获取一个令牌并传递它。作为一般规则,如果您的方法调用使用CancellationToken的 API,则您的方法也应该接受一个CancellationToken并将其传递给每个支持它的 API。
讨论
不幸的是,一些方法不支持取消。当您遇到这种情况时,没有简单的解决方案。除非将代码包装在单独的可执行文件中,否则无法安全地停止任意代码。如果您的代码调用不支持取消的代码,并且不想将该代码包装在单独的可执行文件中,您始终可以通过假装取消操作来选择忽略结果。
尽可能地提供取消选项是很重要的。这是因为在更高级别正确地取消依赖于更低级别的正确取消。因此,当您编写自己的async方法时,请尽量包含取消支持;您永远不知道哪个更高级别的方法将要调用您的方法,而它可能需要取消功能。
另请参阅
配方 10.1 涵盖了发出取消请求。
配方 10.3 涵盖了使用取消作为超时。
10.5 取消并行代码
问题
您正在使用并行代码并且需要支持取消。
解决方案
支持取消操作的最简单方法是通过CancellationToken传递给并行代码。Parallel方法通过接受ParallelOptions实例来支持此操作。您可以通过以下方式在ParallelOptions实例上设置CancellationToken:
void RotateMatrices(IEnumerable<Matrix> matrices, float degrees,
CancellationToken token)
{
Parallel.ForEach(matrices,
new ParallelOptions { CancellationToken = token },
matrix => matrix.Rotate(degrees));
}
或者,可以直接在循环体中观察CancellationToken:
void RotateMatrices2(IEnumerable<Matrix> matrices, float degrees,
CancellationToken token)
{
// Warning: not recommended; see below.
Parallel.ForEach(matrices, matrix =>
{
matrix.Rotate(degrees);
token.ThrowIfCancellationRequested();
});
}
另一种方法工作量更大,并且不太灵活,因为并行循环会在AggregateException中包装OperationCanceledException。此外,如果将CancellationToken作为ParallelOptions实例的一部分传递,Parallel类可能会更智能地决定多久检查该令牌。因此,最好将令牌作为选项传递。如果将令牌作为选项传递,还可以将令牌传递给循环体,但不要仅仅将令牌传递给循环体。
并行 LINQ(PLINQ)还具有使用WithCancellation操作符的内置取消支持:
IEnumerable<int> MultiplyBy2(IEnumerable<int> values,
CancellationToken cancellationToken)
{
return values.AsParallel()
.WithCancellation(cancellationToken)
.Select(item => item * 2);
}
讨论
对于良好的用户体验,支持并行工作的取消很重要。如果您的应用程序正在进行并行工作,至少在短时间内会使用大量 CPU。即使不会干扰同一台机器上的其他应用程序,用户也会注意到高 CPU 使用率。因此,建议在进行并行计算(或任何其他 CPU 密集型工作)时支持取消操作,即使高 CPU 使用率的总时间不会非常长。
参见
配方 10.1 涵盖了发出取消请求。
10.6 取消 System.Reactive 代码
问题
您有一些响应式代码,需要使其支持取消。
解决方案
System.Reactive 库中有一个对可观察流的订阅概念。您的代码可以释放该订阅以取消对流的订阅。在许多情况下,这已足以逻辑上取消流。例如,以下代码在按下一个按钮时订阅鼠标点击事件,并在按下另一个按钮时取消订阅(取消订阅):
private IDisposable _mouseMovesSubscription;
private void StartButton_Click(object sender, RoutedEventArgs e)
{
IObservable<Point> mouseMoves = Observable
.FromEventPattern<MouseEventHandler, MouseEventArgs>(
handler => (s, a) => handler(s, a),
handler => MouseMove += handler,
handler => MouseMove -= handler)
.Select(x => x.EventArgs.GetPosition(this));
_mouseMovesSubscription = mouseMoves.Subscribe(value =>
{
MousePositionLabel.Content = "(" + value.X + ", " + value.Y + ")";
});
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
if (_mouseMovesSubscription != null)
_mouseMovesSubscription.Dispose();
}
使用所有其他部分用于取消的CancellationTokenSource/CancellationToken系统使 System.Reactive 与之一起工作非常方便。本配方的其余部分介绍了 System.Reactive 可观察对象如何与CancellationToken交互。
主要用例之一是将可观察代码包装在异步代码中。基本方法已在配方 8.5 中介绍过,现在您想要添加CancellationToken支持。一般来说,最简单的方法是使用响应式操作执行所有操作,然后调用ToTask将最后产生的元素转换为可等待任务。以下代码展示了如何异步获取序列中的最后一个元素:
CancellationToken cancellationToken = ...
IObservable<int> observable = ...
int lastElement = await observable.TakeLast(1).ToTask(cancellationToken);
// or: int lastElement = await observable.ToTask(cancellationToken);
取第一个元素非常类似;只需在调用ToTask之前修改可观察对象即可:
CancellationToken cancellationToken = ...
IObservable<int> observable = ...
int firstElement = await observable.Take(1).ToTask(cancellationToken);
将整个可观察序列异步转换为任务同样类似:
CancellationToken cancellationToken = ...
IObservable<int> observable = ...
IList<int> allElements = await observable.ToList().ToTask(cancellationToken);
最后,让我们考虑相反的情况。我们已经讨论了几种处理方式,这些方式在 System.Reactive 代码响应 CancellationToken — 即,CancellationTokenSource 取消请求被转换为该订阅的处置。也可以反过来:响应处置而发出取消请求。
FromAsync、StartAsync 和 SelectMany 运算符都支持取消,就像在 Recipe 8.6 中所示的那样。这些运算符涵盖了绝大多数的使用情况。Rx 还提供了一个 CancellationDisposable 类型,当其被处置时取消一个 CancellationToken。你可以直接使用 CancellationDisposable,就像这样:
using (var cancellation = new CancellationDisposable())
{
CancellationToken token = cancellation.Token;
// Pass the token to methods that respond to it.
}
// At this point, the token is canceled.
讨论
System.Reactive (Rx) 有其自己的取消概念:处理订阅的释放。本文介绍了如何使 Rx 在 .NET 4.0 引入的通用取消框架中良好运作的几种方式。只要您在代码的 Rx 部分,使用 Rx 订阅/释放系统;如果仅在边界引入 CancellationToken 支持,则更为清晰。
参见
Recipe 8.5 讲述了围绕 Rx 代码的异步包装(不带取消支持)。
Recipe 8.6 讲述了围绕异步代码的 Rx 包装(带取消支持)。
Recipe 10.1 讲述了发出取消请求的过程。
10.7 取消数据流网格
问题
您正在使用数据流网格,并且需要支持取消。
解决方案
在您的代码中支持取消的最佳方式是通过将 CancellationToken 传递给可取消的 API。数据流网格中的每个块都支持取消作为其 DataflowBlockOptions 的一部分。如果您想扩展您的自定义数据流块以支持取消,设置块选项的 CancellationToken 属性:
IPropagatorBlock<int, int> CreateMyCustomBlock(
CancellationToken cancellationToken)
{
var blockOptions = new ExecutionDataflowBlockOptions
{
CancellationToken = cancellationToken
};
var multiplyBlock = new TransformBlock<int, int>(item => item * 2,
blockOptions);
var addBlock = new TransformBlock<int, int>(item => item + 2,
blockOptions);
var divideBlock = new TransformBlock<int, int>(item => item / 2,
blockOptions);
var flowCompletion = new DataflowLinkOptions
{
PropagateCompletion = true
};
multiplyBlock.LinkTo(addBlock, flowCompletion);
addBlock.LinkTo(divideBlock, flowCompletion);
return DataflowBlock.Encapsulate(multiplyBlock, divideBlock);
}
在这个例子中,我在网格中的每个块上应用了CancellationToken,虽然这并非完全必要。因为我同时在链接中传播完成,我可以将其应用于第一个块并允许其传播。取消被认为是错误的一种特殊形式,因此管道中更深层的块将会因为这个错误而完成。话虽如此,如果我取消一个网格,我可能也会同时取消每个块,所以在这种情况下,我通常会设置每个块的CancellationToken选项。
讨论
在数据流网格中,取消不是刷新的一种形式。当取消一个块时,它会丢弃其所有的输入并拒绝接收任何新项。因此,如果在块运行时取消它,您将会丢失数据。
参见
Recipe 10.1 讲述了发出取消请求的过程。
10.8 注入取消请求
问题
您的代码层需要响应取消请求,并向下一层发出自己的取消请求。
解决方案
.NET 4.0 取消系统内置支持这种情况,称为链接取消标记。可以创建一个与一个(或多个)现有标记相关联的取消标记源。当创建链接取消标记源时,当任何现有标记被取消或链接源被显式取消时,结果标记就会被取消。
下面的代码执行异步 HTTP 请求。传递给GetWithTimeoutAsync方法的标记表示用户请求的取消,并且GetWithTimeoutAsync方法还为请求应用超时:
async Task<HttpResponseMessage> GetWithTimeoutAsync(HttpClient client,
string url, CancellationToken cancellationToken)
{
using CancellationTokenSource cts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(2));
CancellationToken combinedToken = cts.Token;
return await client.GetAsync(url, combinedToken);
}
当用户取消现有的cancellationToken或链接源通过CancelAfter取消时,生成的combinedToken会被取消。
讨论
尽管前面的示例仅使用了单个CancellationToken源,但CreateLinkedTokenSource方法可以接受任意数量的取消标记作为参数。这使您可以从中实现逻辑取消的单个组合标记。例如,ASP.NET 提供了一个取消标记,表示用户断开连接(HttpContext.RequestAborted);处理程序代码可以创建一个链接标记,响应用户断开连接或自己的取消原因,如超时。
要记住链接取消标记源的生命周期。前面的示例是通常的用例,其中一个或多个取消标记传递给方法,然后将它们链接在一起并作为组合标记传递。还要注意,示例代码使用了using语句,确保在操作完成时(并且不再使用组合标记时),链接的取消标记源会被处理。考虑一下,如果代码没有处理链接的取消标记源会发生什么:可能GetWithTimeoutAsync方法会多次使用相同的(长期存在的)现有标记调用,这种情况下,每次调用方法都会链接一个新的标记源。即使 HTTP 请求完成(而且没有任何使用组合标记的东西),链接的源仍然附加到现有的标记上。为了防止这种内存泄漏,请在不再需要组合标记时处理链接的取消标记源。
参见
食谱 10.1 概述了一般情况下发出取消请求的操作。
食谱 10.3 涵盖了使用取消作为超时的情况。
10.9 与其他取消系统的互操作
问题
您有一些带有自己取消观念的外部或遗留代码,并且希望使用标准的CancellationToken来控制它。
解决方案
CancellationToken 有两种主要方式来响应取消请求:轮询(在 第 10.2 节 中讨论)和回调(本节的主题)。轮询通常用于 CPU 绑定的代码,如数据处理循环;而回调通常用于其他所有场景。您可以使用 CancellationToken.Register 方法为令牌注册回调。
例如,假设您正在封装 System.Net.NetworkInformation.Ping 类型,并且希望能够取消 ping。Ping 类已经具有基于 Task 的 API,但不支持 CancellationToken。相反,Ping 类具有自己的 SendAsyncCancel 方法,您可以使用该方法取消 ping。为此,请注册一个调用该方法的回调:
async Task<PingReply> PingAsync(string hostNameOrAddress,
CancellationToken cancellationToken)
{
using var ping = new Ping();
Task<PingReply> task = ping.SendPingAsync(hostNameOrAddress);
using CancellationTokenRegistration _ = cancellationToken
.Register(() => ping.SendAsyncCancel());
return await task;
}
现在,当请求取消时,CancellationToken 将为您调用 SendAsyncCancel 方法,取消 SendPingAsync 方法。
讨论
CancellationToken.Register 方法可用于与任何类型的替代取消系统进行交互。但请注意,当方法接受 CancellationToken 时,取消请求应仅取消该操作。某些替代取消系统通过关闭某些资源来实现取消,这可能会取消多个操作;这种取消系统与 CancellationToken 不太匹配。如果决定将此类取消封装在 CancellationToken 中,则应记录其不寻常的取消语义。
要记住回调注册的生命周期。Register 方法返回一个可处置对象,在不再需要该回调时应予以处理。前面的示例代码使用 using 语句在异步操作完成时进行清理。如果代码没有该 using 语句,那么每次使用相同(长期存在的)CancellationToken 调用代码时,都会添加另一个回调(这反过来会使 Ping 对象保持活动状态)。为避免内存和资源泄漏,请在不再需要回调时处置回调注册。
参见
第 10.2 节 涵盖了通过轮询而非回调来响应取消令牌。
第 10.1 节 概述了一般的取消请求发出。
第十一章:友好的函数式面向对象编程
现代程序需要异步编程;如今,服务器必须比以往更好地扩展,终端用户应用程序必须比以往更具响应性。开发人员发现他们必须学习异步编程,当他们探索这个世界时,他们发现它经常与他们习惯的传统面向对象编程相冲突。
这样做的核心原因是因为异步编程是函数式的。通过“函数式”,我不是指“它有效”;我指的是它是一种函数式编程风格,而不是过程式编程风格。很多开发人员在大学学习了基本的函数式编程,之后几乎没有再碰过。如果像(car (cdr '(3 5 7)))这样的代码让你感到不安,因为被压抑的记忆涌入脑海,那么你可能属于这一类别。但不要害怕;一旦习惯了,现代异步编程并不那么难。
async的主要突破在于你仍然可以在编写和理解异步方法时以过程化方式思考。这使得编写和理解异步方法变得更容易。然而,在底层,异步代码仍然具有函数式的特性,当人们试图将async方法强行融入传统面向对象设计时,这会导致一些问题。本章的示例处理异步代码与面向对象编程相冲突的摩擦点。
当将现有的面向对象编码基础转换为友好的async编码基础时,这些摩擦点尤为明显。
11.1 异步接口和继承
问题
你有一个在接口或基类中的方法,你想要将其变成异步的。
解决方案
理解这个问题及其解决方案的关键是意识到async是一个实现细节。async关键字只能应用于具有实现的方法;不可能将其应用于抽象方法或接口方法(除非它们有默认实现)。但是,你可以定义一个与async方法具有相同签名的方法,只是没有async关键字。
记住类型是可等待的,而不是方法。你可以await一个方法返回的Task,无论该方法是否使用async实现。因此,一个接口或抽象方法可以直接返回一个Task(或Task<T>),并且该方法的返回值是可等待的。
以下代码定义了一个带有异步方法的接口(不带async关键字),该接口的实现(带有async),以及一个独立的方法,该方法通过await消耗接口的方法:
interface IMyAsyncInterface
{
Task<int> CountBytesAsync(HttpClient client, string url);
}
class MyAsyncClass : IMyAsyncInterface
{
public async Task<int> CountBytesAsync(HttpClient client, string url)
{
var bytes = await client.GetByteArrayAsync(url);
return bytes.Length;
}
}
async Task UseMyInterfaceAsync(HttpClient client, IMyAsyncInterface service)
{
var result = await service.CountBytesAsync(client, "http://www.example.com");
Trace.WriteLine(result);
}
这个模式也适用于基类中的抽象方法。
异步方法签名仅意味着实现可能是异步的。如果实际实现没有真正的异步工作要做,那么它也可能是同步的。例如,一个测试存根可以通过使用类似FromResult的东西来实现相同的接口(不带async):
class MyAsyncClassStub : IMyAsyncInterface
{
public Task<int> CountBytesAsync(HttpClient client, string url)
{
return Task.FromResult(13);
}
}
讨论
在撰写本文时,async 和 await 仍在不断普及中。随着异步方法变得更加普遍,接口和基类上的异步方法也将变得更加常见。只要记住可等待的是返回类型(而不是方法),以及异步方法定义可以是异步的或同步的,它们并不难处理。
参见
Recipe 2.2 介绍了返回已完成任务,使用同步代码实现异步方法签名。
11.2 异步构造:工厂
问题
您正在编写一个需要在其构造函数中完成一些异步工作的类型。
解决方案
构造函数不能是async,也不能使用await关键字。在构造函数中使用await肯定会很有用,但这将大大改变 C#语言。
一种可能性是拥有一个构造函数和一个async初始化方法,以便可以像这样使用该类型:
var instance = new MyAsyncClass();
await instance.InitializeAsync();
这种方法有一些缺点。很容易忘记调用InitializeAsync方法,并且在构造完成后实例不能立即可用。
更好的解决方案是使类型成为其自身的工厂。以下类型展示了异步工厂方法模式:
class MyAsyncClass
{
private MyAsyncClass()
{
}
private async Task<MyAsyncClass> InitializeAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
return this;
}
public static Task<MyAsyncClass> CreateAsync()
{
var result = new MyAsyncClass();
return result.InitializeAsync();
}
}
构造函数和InitializeAsync方法都是private的,以防其他代码误用它们;因此,创建实例的唯一方式是通过静态的CreateAsync工厂方法。调用代码在初始化完成之前无法访问实例。
其他代码可以像这样创建一个实例:
MyAsyncClass instance = await MyAsyncClass.CreateAsync();
讨论
此模式的主要优点是其他代码无法获得未初始化的MyAsyncClass实例。这就是为什么我在可以使用它时更喜欢这种模式而不是其他方法的主要原因。
不幸的是,这种方法在某些场景下不起作用,特别是当您的代码使用依赖注入提供程序时。没有主要的依赖注入或控制反转库能与async代码配合工作。如果您发现自己处于这些情况之一,那么可以考虑几种替代方案。
如果您正在创建的实例实际上是一个共享资源,那么您可以使用 Recipe 14.1 中讨论的异步延迟类型。否则,您可以使用 Recipe 11.3 中讨论的异步初始化模式。
这是一个不推荐的示例:
class MyAsyncClass
{
public MyAsyncClass()
{
InitializeAsync();
}
// BAD CODE!!
private async void InitializeAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
}
}
乍一看,这似乎是一个合理的方法:您得到一个启动异步操作的常规构造函数;然而,由于使用了async void,存在几个缺点。第一个问题是当构造函数完成时,实例仍在异步初始化中,并且没有明显的方法来确定异步初始化何时完成。第二个问题是错误处理:InitializeAsync引发的任何异常不能被围绕对象构造的任何catch子句捕获。
参见
Recipe 11.3 讲述了异步初始化模式,这是一种与依赖注入/控制反转容器一起使用的异步构造方式。
Recipe 14.1 讲述了异步延迟初始化,这是如果实例在概念上是共享资源或服务,则是一种可行的解决方案。
11.3 异步构造:异步初始化模式
问题
您正在编写一个类型,其构造函数需要进行一些异步工作,但不能使用异步工厂模式(Recipe 11.2)因为实例是通过反射创建的(例如,依赖注入/控制反转库,数据绑定,Activator.CreateInstance等)。
解决方案
当您遇到这种情况时,必须返回一个未初始化的实例,尽管可以通过应用常见模式来缓解这种情况:异步初始化模式。每个需要异步初始化的类型都应定义一个属性,如下所示:
Task Initialization { get; }
我通常喜欢在需要异步初始化的类型的标记接口中定义这个:
/// <summary>
/// Marks a type as requiring asynchronous initialization
/// and provides the result of that initialization.
/// </summary>
public interface IAsyncInitialization
{
/// <summary>
/// The result of the asynchronous initialization of this instance.
/// </summary>
Task Initialization { get; }
}
当您实现此模式时,应在构造函数中启动初始化(并分配Initialization属性)。异步初始化的结果(包括任何异常)通过该Initialization属性公开。以下是使用异步初始化实现简单类型的示例实现:
class MyFundamentalType : IMyFundamentalType, IAsyncInitialization
{
public MyFundamentalType()
{
Initialization = InitializeAsync();
}
public Task Initialization { get; private set; }
private async Task InitializeAsync()
{
// Asynchronously initialize this instance.
await Task.Delay(TimeSpan.FromSeconds(1));
}
}
如果您使用依赖注入/控制反转库,可以使用以下代码创建和初始化此类型的实例:
IMyFundamentalType instance = UltimateDIFactory.Create<IMyFundamentalType>();
var instanceAsyncInit = instance as IAsyncInitialization;
if (instanceAsyncInit != null)
await instanceAsyncInit.Initialization;
您可以将此模式扩展到允许异步初始化的类型组合。在以下示例中,定义了另一种依赖于IMyFundamentalType的类型:
class MyComposedType : IMyComposedType, IAsyncInitialization
{
private readonly IMyFundamentalType _fundamental;
public MyComposedType(IMyFundamentalType fundamental)
{
_fundamental = fundamental;
Initialization = InitializeAsync();
}
public Task Initialization { get; private set; }
private async Task InitializeAsync()
{
// Asynchronously wait for the fundamental instance to initialize,
// if necessary.
var fundamentalAsyncInit = _fundamental as IAsyncInitialization;
if (fundamentalAsyncInit != null)
await fundamentalAsyncInit.Initialization;
// Do our own initialization (synchronous or asynchronous).
...
}
}
组合类型在所有组件初始化完成之前会等待。遵循的规则是每个组件都应在InitializeAsync结束时初始化完成。这确保了所有依赖类型作为组合初始化的一部分被初始化。任何组件初始化引发的异常会传播到组合类型的初始化过程中。
讨论
如果可能的话,我建议使用异步工厂(Recipe 11.2)或异步延迟初始化(Recipe 14.1)而不是这个解决方案。这些是最佳方法,因为您永远不会暴露未初始化的实例。然而,如果您的实例是由依赖注入/控制反转、数据绑定等创建的,那么您将被迫暴露未初始化的实例,在这种情况下,我建议使用本配方中的异步初始化模式。
从异步接口的配方(Recipe 11.1)记住,异步方法签名只意味着该方法可能是异步的。MyComposedType.InitializeAsync代码是一个很好的例子:如果IMyFundamentalType实例不同时实现IAsyncInitialization且MyComposedType本身没有异步初始化,则其InitializeAsync方法将同步完成。
检查实例是否实现IAsyncInitialization并对其进行初始化的代码有点笨拙,当有一个依赖于更多组件的组合类型时,情况会变得更加复杂。可以很容易地创建一个辅助方法来简化代码:
public static class AsyncInitialization
{
public static Task WhenAllInitializedAsync(params object[] instances)
{
return Task.WhenAll(instances
.OfType<IAsyncInitialization>()
.Select(x => x.Initialization));
}
}
您可以调用InitializeAllAsync并传入您想要初始化的任何实例;该方法将忽略不实现IAsyncInitialization的实例。然后,依赖于三个注入实例的组合类型的初始化代码可以看起来像以下内容:
private async Task InitializeAsync()
{
// Asynchronously wait for all 3 instances to initialize, if necessary.
await AsyncInitialization.WhenAllInitializedAsync(_fundamental,
_anotherType, _yetAnother);
// Do our own initialization (synchronous or asynchronous).
...
}
另请参阅
Recipe 11.2 介绍了异步工厂,这是一种进行异步构建而不暴露未初始化实例的方法。
Recipe 14.1 介绍了异步延迟初始化,如果实例是共享资源或服务,则可以使用该方法。
Recipe 11.1 介绍了异步接口。
11.4 异步属性
问题
您有一个您想要使async的属性。该属性未用于数据绑定。
解决方案
这是在将现有代码转换为使用async时经常遇到的问题;在这种情况下,您有一个其 getter 调用现在是异步的方法的属性。然而,“异步属性”这种东西并不存在。不能在属性上使用async关键字,而这是一个好事。属性的 getter 应该返回当前值;它们不应该启动后台操作:
// What we think we want (does not compile).
public int Data
{
async get
{
await Task.Delay(TimeSpan.FromSeconds(1));
return 13;
}
}
当您发现您的代码需要一个“异步属性”时,您的代码真正需要的是略有不同。解决方案取决于您的属性值是否需要被评估一次或多次;您可以在以下语义之间做出选择:
-
每次读取时异步评估的值
-
一次异步评估并为将来访问缓存的值
如果你的“异步属性”在每次读取时都需要启动一个新的(异步)评估,那么它不是一个属性;它是一个伪装成方法的属性。如果在将同步代码转换为异步时遇到了这种情况,那么现在是时候承认原始设计实际上是错误的了;该属性本来应该是一个方法:
// As an asynchronous method.
public async Task<int> GetDataAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
return 13;
}
返回Task<int>直接从属性中是可能的,如下面的代码所示:
// This "async property" is an asynchronous method.
// This "async property" is a Task-returning property.
public Task<int> Data
{
get { return GetDataAsync(); }
}
private async Task<int> GetDataAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
return 13;
}
然而,我不建议这种方法。如果每次访问属性都会启动一个新的异步操作,那么这个“属性”实际上应该是一个方法。它是一个异步方法使得每次都启动一个新的异步操作更加清晰,因此 API 不会误导。食谱 11.3 和 11.6 确实使用返回任务的属性,但这些属性适用于整个实例;它们不会在每次读取时启动新的异步操作。
有时候你希望每次检索属性值时都对其进行评估。其他时候,你希望属性仅启动一次(异步)评估并缓存该结果以供将来使用。在这种情况下,你可以使用异步懒初始化。这种解决方案在食谱 14.1 中有详细说明,但与此同时,这里是一个示例,展示了代码的样子:
// As a cached value
public AsyncLazy<int> Data
{
get { return _data; }
}
private readonly AsyncLazy<int> _data =
new AsyncLazy<int>(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1));
return 13;
});
代码将仅执行一次异步评估,然后将同一值返回给所有调用者。调用代码看起来像下面这样:
int value = await instance.Data;
在这种情况下,属性语法是适当的,因为只有一个评估在进行。
讨论
自问一个重要的问题是是否读取属性应该启动一个新的异步操作;如果答案是肯定的,那么使用异步方法而不是属性。如果属性应该作为延迟评估的缓存,则使用异步初始化(参见食谱 14.1)。在这个食谱中,我没有涵盖用于数据绑定的属性;我在食谱 14.3 中涵盖了这些内容。
当你将同步属性转换为“异步属性”时,这里有一个不要做的示例:
private async Task<int> GetDataAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
return 13;
}
public int Data
{
// BAD CODE!!
get { return GetDataAsync().Result; }
}
当谈论在async代码中的属性时,值得考虑状态如何与异步代码相关联。如果你正在将同步代码库转换为异步,这一点尤为重要。考虑你在 API 中暴露的任何状态(例如通过属性);对于每个状态,问自己,具有异步操作进行中的对象的当前状态是什么?并没有正确的答案,但重要的是考虑你想要的语义,并进行文档记录。
例如,考虑Stream.Position,它表示流指针的当前偏移量。使用同步 API 时,当你调用Stream.Read或Stream.Write时,读取/写入完成并且Stream.Position更新以反映新位置,然后Read或Write方法返回。这对同步代码的语义很清晰。
现在,考虑Stream.ReadAsync和Stream.WriteAsync:何时应更新Stream.Position?当读取/写入操作完成时,还是在实际发生之前?如果在操作完成之前更新它,Stream.Position会在ReadAsync/WriteAsync返回时同步更新,还是可能稍后才会发生?
这是一个很好的例子,展示了一个公开状态的属性在同步代码中具有完全清晰的语义,但在异步代码中却没有明显正确的语义。这并不是世界末日——你只需要在使你的类型支持异步时考虑整个 API,并记录你选择的语义。
另请参阅
Recipe 14.1 详细介绍了异步延迟初始化。
Recipe 14.3 介绍了需要支持数据绑定的“异步属性”。
11.5 异步事件
问题
当你需要使用可能是async的处理程序处理事件,并且需要检测处理程序是否已完成时,你有一个事件。请注意,在引发事件时,这是一个罕见的情况;通常在引发事件时,你并不关心处理程序何时完成。
解决方案
检测async void处理程序何时返回是不可行的,因此你需要一些替代方法来检测异步处理程序何时完成。Universal Windows 平台引入了一个称为deferrals的概念,你可以使用它来跟踪异步处理程序。异步处理程序在其第一个await之前分配一个延迟,并在完成时通知延迟。同步处理程序不需要使用延迟。
Nito.AsyncEx库包含一个名为DeferralManager的类型,被引发事件的组件使用。这个延迟管理器允许事件处理程序分配延迟,并跟踪所有延迟何时完成。
对于每个需要等待处理程序完成的事件,你首先扩展你的事件参数类型:
public class MyEventArgs : EventArgs, IDeferralSource
{
private readonly DeferralManager _deferrals = new DeferralManager();
... // Your own constructors and properties
public IDisposable GetDeferral()
{
return _deferrals.DeferralSource.GetDeferral();
}
internal Task WaitForDeferralsAsync()
{
return _deferrals.WaitForDeferralsAsync();
}
}
当处理异步事件处理程序时,最好使你的事件参数类型是线程安全的。实现这一点的最简单方法是使其不可变(即,其所有属性都是只读的)。
然后,每次你引发事件时,你可以(异步地)等待所有异步事件处理程序完成。以下代码将在没有处理程序时返回一个已完成的任务;否则,它将创建你事件参数类型的新实例,传递给处理程序,并等待任何异步处理程序完成:
public event EventHandler<MyEventArgs> MyEvent;
private async Task RaiseMyEventAsync()
{
EventHandler<MyEventArgs> handler = MyEvent;
if (handler == null)
return;
var args = new MyEventArgs(...);
handler(this, args);
await args.WaitForDeferralsAsync();
}
然后,异步事件处理程序可以在using块内使用延期;延期在被处理时通知延期管理器:
async void AsyncHandler(object sender, MyEventArgs args)
{
using IDisposable deferral = args.GetDeferral();
await Task.Delay(TimeSpan.FromSeconds(2));
}
这与 Universal Windows 延期的工作方式略有不同。在 Universal Windows API 中,每个需要延期的事件定义其自己的延期类型,并且该延期类型有一个显式的Complete方法,而不是IDisposable。
讨论
在 .NET 中,有两种逻辑上不同的事件类型,它们的语义差别很大。我称之为通知事件和命令事件;这不是官方术语,只是我为了清晰起见选择的术语。通知事件是为了通知其他组件某种情况而引发的事件。通知是单向的;事件的发送者不在乎是否有任何事件接收者。在通知事件中,发送者和接收者可以完全断开连接。大多数事件都是通知事件;一个例子是按钮点击。
相反,命令事件是为了代表发送组件实现某些功能而引发的事件。命令事件在术语的真正意义上并不是“事件”,尽管它们通常被实现为 .NET 事件。命令的发送者必须等待接收者处理完它才能继续。如果你使用事件来实现访问者模式,那么这些就是命令事件。生命周期事件也是命令事件,因此 ASP.NET 页面生命周期事件和许多 UI 框架事件,如 Xamarin 的Application.PageAppearing,属于这一类别。任何实际上是实现的 UI 框架事件也是命令事件(例如BackgroundWorker.DoWork)。
通知事件不需要任何特殊的代码来启用异步处理程序;事件处理程序可以是async void并且能够正常工作。当事件发送者引发事件时,异步事件处理程序不会立即完成,但这并不重要,因为它们只是通知事件。所以,如果你的事件是通知事件,你需要做的工作总量是:什么都不用做。
命令事件是另一回事。当你有一个命令事件时,你需要一种方法来检测处理程序何时完成。前面使用延期的解决方案应该仅用于命令事件。
提示
Nito.AsyncEx NuGet 包中的DeferralManager类型。
参见
第二章介绍了异步编程的基础知识。
11.6 异步处理
问题
你有一个具有异步操作但还需要启用其资源释放的类型。
解决方案
在处理实例的释放时,有几个常见的选项:你可以将释放视为应用于所有现有操作的取消请求,或者你可以实现真正的异步释放。
将释放视为取消在 Windows 上有着历史悠久的先例;例如文件流和套接字在关闭时取消任何现有的读取或写入。通过定义自己的私有CancellationTokenSource并将该令牌传递给内部操作,你可以在 .NET 中实现类似的效果。通过以下代码,Dispose将取消操作但不会等待这些操作完成:
class MyClass : IDisposable
{
private readonly CancellationTokenSource _disposeCts =
new CancellationTokenSource();
public async Task<int> CalculateValueAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2), _disposeCts.Token);
return 13;
}
public void Dispose()
{
_disposeCts.Cancel();
}
}
代码展示了围绕Dispose的基本模式。在实际应用程序中,你应该进行检查以确保对象尚未被释放,并允许用户提供自己的CancellationToken(使用来自菜谱 10.8 的技术):
public async Task<int> CalculateValueAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource combinedCts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
await Task.Delay(TimeSpan.FromSeconds(2), combinedCts.Token);
return 13;
}
当调用Dispose时,正在进行的操作将被取消:
async Task UseMyClassAsync()
{
Task<int> task;
using (var resource = new MyClass())
{
task = resource.CalculateValueAsync(default);
}
// Throws OperationCanceledException.
var result = await task;
}
对于某些类型,将Dispose实现为取消请求完全可以(例如,HttpClient具有这些语义)。然而,其他类型需要知道所有操作何时完成。对于这些类型,你需要某种形式的异步处理。
异步释放是在 C# 8.0 和 .NET Core 3.0 中引入的一种技术。BCL 引入了一个新的IAsyncDisposable接口,它是IDisposable的异步等价物。同时,语言还引入了一个await using语句,它是using的异步等价物。因此,现在希望在释放期间执行异步工作的类型现在具备了这种能力:
class MyClass : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2));
}
}
DisposeAsync的返回类型是ValueTask而不是Task,但标准的async和await关键字在处理ValueTask时与处理Task一样有效。
实现IAsyncDisposable的类型通常由await using来消耗:
await using (var myClass = new MyClass())
{
...
} // DisposeAsync is invoked (and awaited) here.
如果需要避免使用ConfigureAwait(false),是可行的,但有点麻烦,因为你必须在await using语句之外声明变量:
var myClass = new MyClass();
await using (myClass.ConfigureAwait(false))
{
...
} // DisposeAsync is invoked (and awaited) here with ConfigureAwait(false).
讨论
异步释放肯定比将Dispose实现为取消请求更容易,只有在确实需要时才应使用更复杂的方法。事实上,大多数情况下你甚至可以不释放任何东西,这显然是最简单的方法,因为你不必做任何事情。
本篇介绍了两种处理释放的模式;如果需要,也可以同时使用它们。同时使用可以给你的类型赋予使用await using时的干净关闭语义,以及使用Dispose时的“取消”语义。总体上我不建议这样做,但这确实是一种选择。
另请参阅
菜谱 10.8 介绍了链接的取消令牌。
菜谱 11.1 介绍了异步接口。
菜谱 2.10 讨论了实现返回ValueTask的方法。
菜谱 2.7 介绍了如何使用ConfigureAwait(false)来避免上下文。
第十二章:同步
当您的应用程序使用并发(几乎所有.NET 应用程序都会这样)时,您需要注意一种情况:一段代码需要更新数据,同时其他代码需要访问相同的数据。每当发生这种情况时,您都需要同步对数据的访问。本章的配方涵盖了用于同步访问的最常见类型。但是,如果您适当地使用本书中的其他配方,您会发现许多常见的同步已经由相应的库为您完成。在深入研究同步配方之前,让我们更详细地看看可能需要或不需要同步的一些常见情况。
提示
本节中的同步解释略有简化,但结论都是正确的。
同步有两种主要类型:通信和数据保护。当一段代码需要通知另一段代码某些条件(例如,新消息已到达)时使用通信。我将在本章的配方中更详细地讨论通信;本介绍的其余部分讨论数据保护。
当以下所有三个条件都为真时,您需要使用同步来保护共享数据:
-
多段代码同时运行。
-
这些段代码正在访问(读取或写入)相同的数据。
-
至少有一段代码正在更新(写入)数据。
第一个条件的原因显而易见;如果您的整个代码从头到尾顺序运行,并且从不并发发生,那么您永远不必担心同步。这对一些简单的控制台应用程序来说是成立的,但绝大多数.NET 应用程序确实会使用某种并发。第二个条件意味着,如果每段代码都有自己的本地数据,而它们不共享,则无需同步;本地数据从未从任何其他代码访问。如果存在共享数据但数据永远不会更改,比如使用不可变类型定义数据,也不需要同步。第三个条件涵盖的是像配置值之类的在应用程序开始时设置然后从不更改的情况。如果仅读取共享数据,则不需要同步;只有共享且更新的数据需要同步。
数据保护的目的是为每段代码提供数据的一致视图。如果一段代码正在更新数据,那么您可以使用同步使这些更新对系统的其他部分看起来是原子的。
学习何时需要同步需要一些实践,因此在开始本章的配方之前,我们将通过几个例子来讲解。首先,考虑以下代码:
async Task MyMethodAsync()
{
int value = 10;
await Task.Delay(TimeSpan.FromSeconds(1));
value = value + 1;
await Task.Delay(TimeSpan.FromSeconds(1));
value = value - 1;
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine(value);
}
如果MyMethodAsync方法是从线程池线程调用的(例如,在Task.Run内部),那么访问value的代码行可能会在不同的线程池线程上运行。但是它是否需要同步?不需要,因为它们都不可能同时运行。该方法是异步的,但也是顺序执行的(意味着它一次进行一个部分的进展)。
好的,让我们稍微复杂化这个例子。这次我们将运行并发的异步代码:
private int value;
async Task ModifyValueAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
value = value + 1;
}
// WARNING: may require synchronization; see discussion below.
async Task<int> ModifyValueConcurrentlyAsync()
{
// Start three concurrent modifications.
Task task1 = ModifyValueAsync();
Task task2 = ModifyValueAsync();
Task task3 = ModifyValueAsync();
await Task.WhenAll(task1, task2, task3);
return value;
}
上述代码正在启动三个并发运行的修改。它是否需要同步?这要看情况。如果你知道该方法是从 GUI 或 ASP.NET 上下文(或任何只允许一段代码同时运行的上下文)调用的,那么不需要同步,因为实际的data修改代码运行时,它会在不同的时间运行于其他两个data修改之外。例如,如果前面的代码在 GUI 上下文中运行,那么只有一个 UI 线程会执行每个data修改,因此必须一个接一个地执行它们。因此,如果你知道上下文是一次一个的上下文,那么就不需要同步。然而,如果相同的方法是从线程池线程(例如,从Task.Run)调用的,那么就需要同步。在这种情况下,三个data修改可以在不同的线程池线程上运行并同时更新data.Value,因此你需要同步访问data.Value。
现在让我们考虑另一个问题:
private int value;
async Task ModifyValueAsync()
{
int originalValue = value;
await Task.Delay(TimeSpan.FromSeconds(1));
value = originalValue + 1;
}
考虑如果ModifyValueAsync被同时调用多次会发生什么。即使它是从一个一次一个的上下文中调用的,数据成员在每次ModifyValueAsync调用之间是共享的,并且当该方法执行await时,值可能随时更改。如果你想要避免这种共享,即使在一次一个的上下文中,你也可能需要应用同步。换句话说,为了确保每次调用ModifyValueAsync都等到所有先前的调用完成,你需要添加同步。即使上下文确保所有代码只使用一个线程(比如 UI 线程),在这种情况下也是如此。在这种情况下,同步是异步方法的一种限流方式(参见 Recipe 12.2)。
让我们再看一个async示例。你可以使用Task.Run来执行我称之为“简单并行处理”的操作——这是一种基本的并行处理方式,不提供Parallel/PLINQ 真正并行处理所具有的效率和可配置性。以下代码使用简单并行处理更新一个共享值:
// BAD CODE!!
async Task<int> SimpleParallelismAsync()
{
int value = 0;
Task task1 = Task.Run(() => { value = value + 1; });
Task task2 = Task.Run(() => { value = value + 1; });
Task task3 = Task.Run(() => { value = value + 1; });
await Task.WhenAll(task1, task2, task3);
return value;
}
这段代码在线程池上运行了三个单独的任务(通过Task.Run),它们都修改同一个value。因此,我们的同步条件适用,并且在这里确实需要同步。请注意,尽管value是一个局部变量,我们仍然需要同步;尽管它是局部于一个方法,但它仍然在多个线程之间共享。
转向真正的并行代码,让我们考虑一个使用Parallel类型的示例:
void IndependentParallelism(IEnumerable<int> values)
{
Parallel.ForEach(values, item => Trace.WriteLine(item));
}
由于这段代码使用了Parallel,我们必须假设并行循环的主体(item => Trace.WriteLine(item))可能在多个线程上运行。然而,循环的主体只从自己的数据中读取;这里没有线程之间的数据共享。Parallel类将数据分配给线程,以便它们中的任何一个不必共享其数据。每个运行其循环主体的线程都与运行相同循环主体的所有其他线程是独立的。因此,前述代码不需要同步。
让我们看一个聚合示例,类似于食谱 4.2 中涵盖的示例:
// BAD CODE!!
int ParallelSum(IEnumerable<int> values)
{
int result = 0;
Parallel.ForEach(source: values,
localInit: () => 0,
body: (item, state, localValue) => localValue + item,
localFinally: localValue => { result += localValue; });
return result;
}
在这个例子中,代码再次使用多个线程;这次,每个线程从其本地值初始化为 0 开始(() => 0),并且对于每个线程处理的输入值,它将输入值添加到其本地值中((item, state, localValue) => localValue + item)。最后,所有本地值都添加到返回值中(localValue => { result += localValue; })。前两个步骤没有问题,因为没有线程之间共享的内容;每个线程的本地和输入值与所有其他线程的本地和输入值是独立的。然而,最后一步是有问题的;当每个线程的本地值添加到返回值时,这是一个共享变量(result)被多个线程访问并更新的情况。因此,在最后一步中,您需要使用同步(参见食谱 12.1)。
PLINQ、数据流和响应式库与Parallel示例非常相似:只要您的代码只处理自己的输入,就无需担心同步。我发现如果我适当地使用这些库,大多数代码几乎不需要我添加同步。
最后,让我们讨论集合。请记住,需要同步的三个条件是多段代码,共享数据和数据更新。
不可变类型自然是线程安全的,因为它们无法更改;不可能更新不可变集合,因此不需要同步。例如,以下代码不需要同步,因为每个单独的线程池线程将值推送到堆栈时,它正在创建一个新的具有该值的不可变堆栈,不会改变原始的stack:
async Task<bool> PlayWithStackAsync()
{
ImmutableStack<int> stack = ImmutableStack<int>.Empty;
Task task1 = Task.Run(() => Trace.WriteLine(stack.Push(3).Peek()));
Task task2 = Task.Run(() => Trace.WriteLine(stack.Push(5).Peek()));
Task task3 = Task.Run(() => Trace.WriteLine(stack.Push(7).Peek()));
await Task.WhenAll(task1, task2, task3);
return stack.IsEmpty; // Always returns true.
}
当您的代码使用不可变集合时,通常会有一个共享的“根”变量,该变量本身不是不可变的。在这种情况下,您需要使用同步。在以下代码中,每个线程将一个值推送到堆栈(创建一个新的不可变堆栈),然后更新共享的根变量;代码需要同步以更新stack变量:
// BAD CODE!!
async Task<bool> PlayWithStackAsync()
{
ImmutableStack<int> stack = ImmutableStack<int>.Empty;
Task task1 = Task.Run(() => { stack = stack.Push(3); });
Task task2 = Task.Run(() => { stack = stack.Push(5); });
Task task3 = Task.Run(() => { stack = stack.Push(7); });
await Task.WhenAll(task1, task2, task3);
return stack.IsEmpty;
}
线程安全集合(例如,ConcurrentDictionary)与不可变集合非常不同。与不可变集合不同,线程安全集合可以被更新。但它们内置了所有需要的同步,所以你不必担心同步集合更改。如果以下代码更新了一个Dictionary而不是ConcurrentDictionary,它将需要同步;但由于它更新了一个ConcurrentDictionary,所以不需要同步:
async Task<int> ThreadsafeCollectionsAsync()
{
var dictionary = new ConcurrentDictionary<int, int>();
Task task1 = Task.Run(() => { dictionary.TryAdd(2, 3); });
Task task2 = Task.Run(() => { dictionary.TryAdd(3, 5); });
Task task3 = Task.Run(() => { dictionary.TryAdd(5, 7); });
await Task.WhenAll(task1, task2, task3);
return dictionary.Count; // Always returns 3.
}
12.1 阻塞锁
问题
你有一些共享数据,需要安全地从多个线程读取和写入。
解决方案
这种情况的最佳解决方案是使用lock语句。当一个线程进入锁时,它将阻止任何其他线程进入该锁,直到锁被释放为止:
class MyClass
{
// This lock protects the _value field.
private readonly object _mutex = new object();
private int _value;
public void Increment()
{
lock (_mutex)
{
_value = _value + 1;
}
}
}
讨论
.NET 框架中还有许多其他类型的锁,例如Monitor、SpinLock和ReaderWriterLockSlim。在大多数应用程序中,几乎不应直接使用这些锁类型。特别是当开发人员没有必要使用ReaderWriterLockSlim时,会很自然地跳到它。基本的lock语句可以很好地处理 99%的情况。
在使用锁时有四个重要的指导原则:
-
限制锁的可见性。
-
记录锁保护的内容。
-
最小化锁定代码。
-
在持有锁时不要执行任意代码。
首先,你应该努力限制锁的可见性。在lock语句中使用的对象应该是一个私有字段,绝不应该暴露给类外的任何方法。通常每种类型最多只有一个锁成员;如果你有多个,请考虑将该类型重构为不同的类型。你可以锁定任何引用类型,但我更倾向于专门为lock语句使用一个字段,就像最后一个例子中那样。如果你在另一个实例上进行锁定,请确保它是私有的,只能在你的类内部使用;它不应该从构造函数传入或从属性获取器返回。你绝不应该lock(this)或锁定任何Type或string的实例;这些锁定可能会导致死锁,因为它们可以从其他代码中访问。
其次,记录锁保护的内容。在初次编写代码时很容易忽视这一步,但随着代码复杂性的增加,它变得更加重要。
第三,尽量减少在持有锁时执行的代码。要注意的一件事是阻塞调用;理想情况下,你的代码在持有锁时不应该阻塞。
最后,绝对不要在锁内调用任意代码。任意代码可能包括触发事件、调用虚方法或调用委托。如果必须执行任意代码,请在释放锁之后执行。
参见
Recipe 12.2 介绍了与async兼容的锁。lock语句不兼容await。
Recipe 12.3 介绍了线程间的信号传递。lock语句旨在保护共享数据,而不是在线程间发送信号。
食谱 12.5 讲解了节流,这是锁的一种泛化。锁可以被视为一次只允许一个线程通过。
12.2 异步锁
问题
如果你有一些共享数据,并且需要安全地从多个代码块中进行读写,这些代码块可能使用 await。
解决方案
.NET 框架中的 SemaphoreSlim 类型已在 .NET 4.5 中更新为与 async 兼容。以下是如何使用它的方法:
class MyClass
{
// This lock protects the _value field.
private readonly SemaphoreSlim _mutex = new SemaphoreSlim(1);
private int _value;
public async Task DelayAndIncrementAsync()
{
await _mutex.WaitAsync();
try
{
int oldValue = _value;
await Task.Delay(TimeSpan.FromSeconds(oldValue));
_value = oldValue + 1;
}
finally
{
_mutex.Release();
}
}
}
你还可以使用 Nito.AsyncEx 库中的 AsyncLock 类型,它具有稍微更加优雅的 API:
class MyClass
{
// This lock protects the _value field.
private readonly AsyncLock _mutex = new AsyncLock();
private int _value;
public async Task DelayAndIncrementAsync()
{
using (await _mutex.LockAsync())
{
int oldValue = _value;
await Task.Delay(TimeSpan.FromSeconds(oldValue));
_value = oldValue + 1;
}
}
}
讨论
与 食谱 12.1 中提到的相同准则同样适用于这里,特别是:
-
限制锁的可见性。
-
记录锁保护的内容。
-
最小化在锁内的代码。
-
在持有锁时绝不执行任意代码。
保持你的锁实例私有;不要在类外部暴露它们。确保清楚地记录(并仔细考虑)锁实例到底保护什么。最小化在持有锁时执行的代码。特别地,不要调用任意代码,包括触发事件、调用虚方法和调用委托。
提示
Nito.AsyncEx NuGet 包中的 AsyncLock 类型。
参见
食谱 12.4 讲解了与 async 兼容的信号处理。锁的作用是保护共享数据,而不是作为信号。
食谱 12.5 讲解了节流,这是锁的一种泛化。锁可以被视为一次只允许一个线程通过。
12.3 阻塞信号
问题
你需要从一个线程向另一个线程发送通知。
解决方案
最常见和通用的跨线程信号是 ManualResetEventSlim。手动重置事件可以处于两种状态中的一种:信号或未信号。任何线程都可以将事件设置为信号状态或将事件重置为未信号状态。线程还可以等待事件信号。
下面两个方法由不同的线程调用;一个线程等待另一个线程的信号:
class MyClass
{
private readonly ManualResetEventSlim _initialized =
new ManualResetEventSlim();
private int _value;
public int WaitForInitialization()
{
_initialized.Wait();
return _value;
}
public void InitializeFromAnotherThread()
{
_value = 13;
_initialized.Set();
}
}
讨论
ManualResetEventSlim 是一个很好的通用信号,用于一个线程向另一个线程发送信号,但只有在适当时才应使用它。如果“信号”实际上是通过线程之间发送某些数据的 消息,那么考虑使用生产者/消费者队列。另一方面,如果信号只是用于协调对共享数据的访问,则应使用锁。
.NET 框架中还有其他较少使用的线程同步信号类型。如果 ManualResetEventSlim 不符合你的需求,考虑使用 AutoResetEvent、CountdownEvent 或 Barrier。
ManualResetEventSlim 是一个同步信号,因此 WaitForInitialization 将阻塞调用线程,直到信号被发送。如果你想等待信号而不阻塞线程,则需要一个异步信号,如 食谱 12.4 中描述的那样。
参见
食谱 9.6 讲解了阻塞生产者/消费者队列。
12.1 配方 涵盖阻塞锁。
12.4 配方 涵盖 async 兼容的信号。
12.4 异步信号
问题
您需要从代码的一部分发送通知到另一部分,并且通知的接收者必须异步等待它。
解决方案
使用 TaskCompletionSource<T> 异步发送通知,如果通知只需发送一次。发送代码调用 TrySetResult,接收代码等待其 Task 属性:
class MyClass
{
private readonly TaskCompletionSource<object> _initialized =
new TaskCompletionSource<object>();
private int _value1;
private int _value2;
public async Task<int> WaitForInitializationAsync()
{
await _initialized.Task;
return _value1 + _value2;
}
public void Initialize()
{
_value1 = 13;
_value2 = 17;
_initialized.TrySetResult(null);
}
}
TaskCompletionSource<T> 类型可用于异步等待任何类型的情况 —— 在本例中,来自代码其他部分的通知。如果信号仅发送一次,则此方法效果很好,但如果需要同时关闭和打开信号,则效果不佳。
Nito.AsyncEx 库包含 AsyncManualResetEvent 类型,这是用于异步代码的 ManualResetEvent 的近似等效物。以下示例是编造的,但展示了如何使用 AsyncManualResetEvent 类型:
class MyClass
{
private readonly AsyncManualResetEvent _connected =
new AsyncManualResetEvent();
public async Task WaitForConnectedAsync()
{
await _connected.WaitAsync();
}
public void ConnectedChanged(bool connected)
{
if (connected)
_connected.Set();
else
_connected.Reset();
}
}
讨论
信号是一种通用的通知机制。但如果这个“信号”是消息,用于从一段代码发送数据到另一段代码,则考虑使用生产者/消费者队列。同样,不要仅仅为了协调对共享数据的访问而使用通用信号;在这种情况下,请使用异步锁。
提示
AsyncManualResetEvent 类型位于 Nito.AsyncEx NuGet 包中。
另请参阅
9.8 配方 涵盖异步生产者/消费者队列。
12.2 配方 涵盖异步锁。
12.3 配方 涵盖阻塞信号,可用于跨线程的通知。
12.5 节流
问题
您有高度并发的代码,实际上是过于并发,需要一些方法来限制并发。
当应用程序的某些部分无法跟上其他部分时,导致数据项累积并消耗内存时,代码过于并发。在这种情况下,通过节流部分代码可以防止内存问题。
解决方案
解决方案根据代码正在执行的并发类型而异。这些解决方案都将并发限制为特定值。反应式扩展具有更强大的选项,例如滑动时间窗口;有关 System.Reactive observables 的节流更详尽地介绍在 6.4 配方 中。
Dataflow 和并行代码都具有内置选项用于节流并发:
IPropagatorBlock<int, int> DataflowMultiplyBy2()
{
var options = new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = 10
};
return new TransformBlock<int, int>(data => data * 2, options);
}
// Using Parallel LINQ (PLINQ)
IEnumerable<int> ParallelMultiplyBy2(IEnumerable<int> values)
{
return values.AsParallel()
.WithDegreeOfParallelism(10)
.Select(item => item * 2);
}
// Using the Parallel class
void ParallelRotateMatrices(IEnumerable<Matrix> matrices, float degrees)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 10
};
Parallel.ForEach(matrices, options, matrix => matrix.Rotate(degrees));
}
并发异步代码可以通过使用 SemaphoreSlim 进行节流:
async Task<string[]> DownloadUrlsAsync(HttpClient client,
IEnumerable<string> urls)
{
using var semaphore = new SemaphoreSlim(10);
Task<string>[] tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();
try
{
return await client.GetStringAsync(url);
}
finally
{
semaphore.Release();
}
}).ToArray();
return await Task.WhenAll(tasks);
}
讨论
当您发现代码使用了过多资源(例如 CPU 或网络连接)时,可能需要进行节流。请记住,最终用户通常拥有比开发者更弱的计算机,因此最好进行稍微多一点的节流,而不是太少。
另请参阅
6.4 配方 涵盖反应式代码的节流。
第十三章:调度
当一段代码执行时,它必须在某个线程上运行。调度器 是一个决定某段代码在哪里运行的对象。在 .NET 框架中有几种不同的调度器类型,它们稍微有些不同地被并行和数据流代码使用。
我建议在可能的情况下不指定调度器;默认情况通常是正确的。例如,在异步代码中,await 操作符会自动在相同的上下文中恢复方法,除非你覆盖了这个默认行为,如 Recipe 2.7 中所述。类似地,响应式代码对于引发其事件有合理的默认上下文,你可以通过 ObserveOn 覆盖它们,如 Recipe 6.2 中所述。
如果你需要其他代码在特定上下文(例如 UI 线程上下文或 ASP.NET 请求上下文)中执行,则可以使用本章中的调度配方来控制代码的调度。
13.1 在线程池中安排工作
问题
当你有一段代码,明确希望在线程池线程上执行时。
解决方案
绝大多数情况下,你会想使用Task.Run,这非常简单。以下代码会阻塞线程池线程 2 秒:
Task task = Task.Run(() =>
{
Thread.Sleep(TimeSpan.FromSeconds(2));
});
Task.Run 也完全理解返回值和异步 lambda。以下代码中 Task.Run 返回的任务将在 2 秒后完成,结果为 13:
Task<int> task = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return 13;
});
Task.Run 返回一个 Task(或 Task<T>),可以自然地被异步或响应式代码消耗。
讨论
Task.Run 在 UI 应用程序中非常理想,当你有耗时的工作需要执行,而不能在 UI 线程上完成时。例如,Recipe 8.4 使用 Task.Run 将并行处理推送到线程池线程。然而,在 ASP.NET 上除非你非常确定自己知道在做什么,否则不要在 ASP.NET 上使用 Task.Run。在 ASP.NET 上,请求处理代码已经在线程池线程上运行,因此将其推送到另一个线程池线程通常是适得其反的。
Task.Run 是BackgroundWorker、Delegate.BeginInvoke 和 ThreadPool.QueueUserWorkItem 的有效替代品。这些旧的 API 不应在新代码中使用;使用Task.Run 的代码编写起来更容易且随时间维护起来也更简单。此外,Task.Run 处理了大多数 Thread 的使用案例,因此大多数 Thread 的使用也可以替换为 Task.Run(只有单线程公寓线程是个例外)。
并行和数据流代码默认在线程池上执行,因此通常不需要在使用Parallel、Parallel LINQ 或 TPL Dataflow 库执行的代码中使用Task.Run。
如果你正在进行动态并行处理,则应该使用Task.Factory.StartNew而不是Task.Run。这是必要的,因为Task.Run返回的Task已经配置为用于异步使用(即,被异步或响应式代码消耗)。它也不支持高级概念,如父/子任务,在动态并行代码中更为常见。
参见
Recipe 8.6 讲解了如何使用响应式代码消耗异步代码(例如从Task.Run返回的任务)。
Recipe 8.4 讲解了如何通过Task.Run异步等待并行代码,这是最简单的方式。
Recipe 4.4 讲解了动态并行处理,这种情况下应使用Task.Factory.StartNew而不是Task.Run。
13.2 使用任务调度器执行代码
问题
你有多段代码需要以某种特定的方式执行。例如,你可能需要所有代码在 UI 线程上执行,或者可能只需要同时执行一定数量的代码片段。
本篇介绍了如何定义和构造用于这些代码片段的调度器。如何实际应用该调度器是接下来两篇的主题。
解决方案
.NET 中有很多不同的类型可以处理调度;本篇重点介绍TaskScheduler,因为它是可移植且相对容易使用的。
最简单的TaskScheduler是TaskScheduler.Default,它将工作排队到线程池中。你很少会在自己的代码中指定TaskScheduler.Default,但要意识到它的重要性,因为它是许多调度场景的默认值。Task.Run、并行和数据流代码都使用TaskScheduler.Default。
你可以通过使用TaskScheduler.FromCurrentSynchronizationContext来捕获特定的上下文,然后稍后将工作安排回去:
TaskScheduler scheduler = TaskScheduler.FromCurrentSynchronizationContext();
这段代码创建了一个TaskScheduler来捕获当前的SynchronizationContext并将代码安排到该上下文中。SynchronizationContext是一个表示通用调度上下文的类型。在.NET 框架中有几种不同的上下文;大多数 UI 框架提供了代表 UI 线程的SynchronizationContext,而 ASP.NET Core 之前提供了代表 HTTP 请求上下文的SynchronizationContext。
ConcurrentExclusiveSchedulerPair是.NET 4.5 中引入的另一种强大类型;实际上,它是两个相关的调度器。ConcurrentScheduler成员是一个允许多个任务同时执行的调度器,只要没有任务在ExclusiveScheduler上执行。ExclusiveScheduler一次只执行一个任务,并且只有在ConcurrentScheduler上没有任务正在执行时才执行:
var schedulerPair = new ConcurrentExclusiveSchedulerPair();
TaskScheduler concurrent = schedulerPair.ConcurrentScheduler;
TaskScheduler exclusive = schedulerPair.ExclusiveScheduler;
ConcurrentExclusiveSchedulerPair的一个常见用途是只使用ExclusiveScheduler来确保一次只执行一个任务。在ExclusiveScheduler上执行的代码将在线程池上运行,但将被限制为仅对使用同一ExclusiveScheduler实例的所有其他代码执行独占。
另一个ConcurrentExclusiveSchedulerPair的用途是作为一个限流调度器。你可以创建一个ConcurrentExclusiveSchedulerPair来限制其并发性。在这种情况下,通常不使用ExclusiveScheduler:
var schedulerPair = new ConcurrentExclusiveSchedulerPair(
TaskScheduler.Default, maxConcurrencyLevel: 8);
TaskScheduler scheduler = schedulerPair.ConcurrentScheduler;
注意,这种限流仅在执行时限流代码;这与 Recipe 12.5 中涵盖的逻辑限流有很大不同。特别是,异步代码在等待操作时不被认为正在执行。ConcurrentScheduler限制执行代码;其他限流,如SemaphoreSlim,在更高级别(即整个async方法)限流。
讨论
您可能已经注意到,最后一个代码示例将TaskScheduler.Default传递给ConcurrentExclusiveSchedulerPair的构造函数中。这是因为ConcurrentExclusiveSchedulerPair在现有的TaskScheduler周围应用其并发/独占逻辑。
本教程介绍了TaskScheduler.FromCurrentSynchronizationContext,用于在捕获的上下文中执行代码。也可以直接使用SynchronizationContext在该上下文中执行代码;然而,我不建议这种方法。尽可能使用await运算符在隐式捕获的上下文中恢复,或者使用TaskScheduler包装器。
永远不要使用特定于平台的类型在 UI 线程上执行代码。WPF、Silverlight、iOS 和 Android 都提供Dispatcher类型,Universal Windows 使用CoreDispatcher,而 Windows Forms 有ISynchronizeInvoke接口(即Control.Invoke)。不要在新代码中使用这些类型;只需假装它们不存在。SynchronizationContext是围绕这些类型的通用抽象。
System.Reactive (Rx)引入了更通用的调度器抽象:IScheduler。Rx 调度器能够包装任何其他类型的调度器;TaskPoolScheduler将包装任何TaskFactory(其中包含TaskScheduler)。Rx 团队还定义了一个可以手动控制用于测试的IScheduler实现。如果确实需要使用调度器抽象,我建议使用 Rx 中的IScheduler;它设计良好,定义明确,易于测试。然而,大多数情况下不需要调度器抽象,早期的库(如任务并行库(TPL)和 TPL Dataflow)仅理解TaskScheduler类型。
参见
Recipe 13.3 涵盖了将TaskScheduler应用于并行代码的方法。
Recipe 13.4 讲述了如何在数据流代码中应用 TaskScheduler。
Recipe 12.5 讲述了更高级别的逻辑限流。
Recipe 6.2 讲述了用于事件流的 System.Reactive 调度器。
Recipe 7.6 讲述了 System.Reactive 测试调度器。
13.3 调度并行代码
问题
你需要控制并行代码中各个代码片段的执行方式。
解决方案
一旦创建了适当的 TaskScheduler 实例(参见 Recipe 13.2),你可以将其包含在传递给 Parallel 方法的选项中。以下代码接受一个矩阵序列的序列。它启动了一些并行循环,并希望限制所有循环的总并行度,而不管每个序列中有多少矩阵:
void RotateMatrices(IEnumerable<IEnumerable<Matrix>> collections, float degrees)
{
var schedulerPair = new ConcurrentExclusiveSchedulerPair(
TaskScheduler.Default, maxConcurrencyLevel: 8);
TaskScheduler scheduler = schedulerPair.ConcurrentScheduler;
ParallelOptions options = new ParallelOptions { TaskScheduler = scheduler };
Parallel.ForEach(collections, options,
matrices => Parallel.ForEach(matrices, options,
matrix => matrix.Rotate(degrees)));
}
讨论
Parallel.Invoke 还接受 ParallelOptions 的实例,因此你可以像对待 Parallel.ForEach 一样向 Parallel.Invoke 传递 TaskScheduler。如果你正在进行动态并行代码,你可以直接将 TaskScheduler 传递给 TaskFactory.StartNew 或 Task.ContinueWith。
无法将 TaskScheduler 传递给并行 LINQ(PLINQ)代码。
参见
Recipe 13.2 讲述了常见的任务调度器以及如何在它们之间进行选择。
13.4 数据流同步使用调度器
问题
你需要控制数据流代码中各个代码片段的执行方式。
解决方案
一旦创建了适当的 TaskScheduler 实例(参见 Recipe 13.2),你可以将其包含在传递给数据流块的选项中。当从 UI 线程调用时,以下代码创建一个数据流网格,通过线程池将其所有输入值乘以二,然后将结果值附加到列表框的项目上(在 UI 线程上):
var options = new ExecutionDataflowBlockOptions
{
TaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(),
};
var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var displayBlock = new ActionBlock<int>(
result => ListBox.Items.Add(result), options);
multiplyBlock.LinkTo(displayBlock);
讨论
在协调数据流网格不同部分的块操作时,指定 TaskScheduler 特别有用。例如,你可以使用 ConcurrentExclusiveSchedulerPair.ExclusiveScheduler 确保块 A 和 C 永远不会同时执行代码,同时允许块 B 在任何时候执行。
请注意,通过 TaskScheduler 进行同步仅适用于代码执行时。例如,如果你有一个运行异步代码并应用独占调度器的动作块,那么在等待时代码不被认为是正在运行。
你可以为任何类型的数据流块指定 TaskScheduler。即使一个块可能不执行你的代码(例如 BufferBlock<T>),它仍然有一些必要的内部任务需要完成,它将使用提供的 TaskScheduler 进行所有内部工作。
参见
Recipe 13.2 讲述了常见的任务调度器以及如何在它们之间进行选择。
第十四章:场景
在本章中,我们将介绍各种类型和技术,以解决编写并发程序时的一些常见场景。这些类型的情况可能填满另一本完整的书,因此我只选择了一些我认为最有用的情况。
14.1 初始化共享资源
问题
您有一个在代码的多个部分之间共享的资源。第一次访问该资源时需要对其进行初始化。
解决方案
.NET 框架包括一种专门用于此目的的类型:Lazy<T>。您可以使用用于初始化实例的工厂委托构造Lazy<T>类型的实例。然后,通过Value属性使实例可用。以下代码演示了Lazy<T>类型:
static int _simpleValue;
static readonly Lazy<int> MySharedInteger = new Lazy<int>(() => _simpleValue++);
void UseSharedInteger()
{
int sharedValue = MySharedInteger.Value;
}
无论多少线程同时调用UseSharedInteger,工厂委托只执行一次,并且所有线程都等待相同的实例。创建后,实例被缓存,并且所有对Value属性的未来访问都返回相同的实例(在上面的示例中,MySharedInteger.Value始终为0)。
如果初始化需要异步工作,可以使用Lazy<Task<T>>,可以使用类似的方法:
static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
new Lazy<Task<int>>(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
return _simpleValue++;
});
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Value;
}
在这个示例中,委托返回一个Task<int>,即一个确定整数值的异步操作。无论代码的哪些部分同时调用Value,Task<int>只创建一次并返回给所有调用者。然后,每个调用者可以选择(异步地)等待任务完成,方法是将任务传递给await。
前面的代码是一种可接受的模式,但还有一些额外的考虑因素。首先,异步委托可能在调用Value的任何线程上执行,并且该委托将在该上下文内执行。如果可能有不同类型的线程调用Value(例如,UI 线程和线程池线程,或两个不同的 ASP.NET 请求线程),则始终在线程池线程上执行异步委托可能更好。通过将工厂委托包装在Task.Run调用中,可以很容易地实现这一点:
static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
new Lazy<Task<int>>(() => Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
}));
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Value;
}
另一个考虑因素是,Task<T>实例只创建一次。如果异步委托抛出异常,则Lazy<Task<T>>将缓存该失败的任务。这很少是可取的;通常最好的做法是在下次请求懒惰值时重新执行委托,而不是缓存异常。没有办法“重置”Lazy<T>,但可以创建一个新的类来处理重新创建Lazy<T>实例的情况:
public sealed class AsyncLazy<T>
{
private readonly object _mutex;
private readonly Func<Task<T>> _factory;
private Lazy<Task<T>> _instance;
public AsyncLazy(Func<Task<T>> factory)
{
_mutex = new object();
_factory = RetryOnFailure(factory);
_instance = new Lazy<Task<T>>(_factory);
}
private Func<Task<T>> RetryOnFailure(Func<Task<T>> factory)
{
return async () =>
{
try
{
return await factory().ConfigureAwait(false);
}
catch
{
lock (_mutex)
{
_instance = new Lazy<Task<T>>(_factory);
}
throw;
}
};
}
public Task<T> Task
{
get
{
lock (_mutex)
return _instance.Value;
}
}
}
static int _simpleValue;
static readonly AsyncLazy<int> MySharedAsyncInteger =
new AsyncLazy<int>(() => Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
}));
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Task;
}
讨论
此配方中的最终代码示例是异步延迟初始化的通用代码模式,有些笨拙。AsyncEx 库包含一个名为 AsyncLazy<T> 的类型,它就像一个 Lazy<Task<T>>,在线程池上执行其工厂委托,并具有失败重试选项。它也可以直接等待,因此声明和使用看起来如下所示:
static int _simpleValue;
private static readonly AsyncLazy<int> MySharedAsyncInteger =
new AsyncLazy<int>(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
},
AsyncLazyFlags.RetryOnFailure);
public async Task UseSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger;
}
提示
AsyncLazy<T> 类型位于 Nito.AsyncEx NuGet 包中。
另请参阅
第一章 涵盖了基本的 async/await 编程。
Recipe 13.1 涵盖了将工作调度到线程池的方法。
14.2 System.Reactive 延迟评估
问题
您希望每次有人订阅时都创建一个新的源可观察对象。例如,您希望每个订阅都代表对 web 服务的不同请求。
解决方案
System.Reactive 库具有一个名为 Observable.Defer 的操作符,该操作符每次订阅可观察对象时都会执行一个委托。该委托充当创建可观察对象的工厂。以下代码使用 Defer 来在每次有人订阅可观察对象时调用一个异步方法:
void SubscribeWithDefer()
{
var invokeServerObservable = Observable.Defer(
() => GetValueAsync().ToObservable());
invokeServerObservable.Subscribe(_ => { });
invokeServerObservable.Subscribe(_ => { });
Console.ReadKey();
}
async Task<int> GetValueAsync()
{
Console.WriteLine("Calling server...");
await Task.Delay(TimeSpan.FromSeconds(2));
Console.WriteLine("Returning result...");
return 13;
}
如果执行此代码,应该会看到以下输出:
Calling server...
Calling server...
Returning result...
Returning result...
讨论
您自己的代码通常不会多次订阅可观察对象,但某些 System.Reactive 操作符在其实现中会这样做。例如,Observable.While 操作符会在条件为 true 时重新订阅源序列。Defer 允许您定义一个每次新订阅时都会重新评估的可观察对象。如果需要刷新或更新该可观察对象的数据,则这非常有用。
另请参阅
Recipe 8.6 涵盖了在可观察对象中包装异步方法。
14.3 异步数据绑定
问题
您正在异步检索数据,并需要将结果数据绑定(例如,在 Model-View-ViewModel 设计的 ViewModel 中)。
解决方案
当数据绑定使用属性时,必须立即且同步返回某种结果。如果实际值需要异步确定,可以返回默认结果,稍后使用正确的值更新属性。
请记住,异步操作可能会失败,也可能会成功。由于您正在编写 ViewModel,因此可以使用数据绑定来更新 UI 以反映错误条件。
Nito.Mvvm.Async library 中有一个名为 NotifyTask 的类型可用于此目的:
class MyViewModel
{
public MyViewModel()
{
MyValue = NotifyTask.Create(CalculateMyValueAsync());
}
public NotifyTask<int> MyValue { get; private set; }
private async Task<int> CalculateMyValueAsync()
{
await Task.Delay(TimeSpan.FromSeconds(10));
return 13;
}
}
可以将数据绑定到NotifyTask<T>属性的各种属性,如本示例所示:
<Grid>
<Label Content="Loading..."
Visibility="{Binding MyValue.IsNotCompleted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
<Label Content="{Binding MyValue.Result}"
Visibility="{Binding MyValue.IsSuccessfullyCompleted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
<Label Content="An error occurred" Foreground="Red"
Visibility="{Binding MyValue.IsFaulted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
</Grid>
MvvmCross 库中有一个 MvxNotifyTask,与 NotifyTask<T> 非常相似。
讨论
您也可以编写自己的数据绑定包装器,而不使用库中的一个。以下代码提供了基本思路:
class BindableTask<T> : INotifyPropertyChanged
{
private readonly Task<T> _task;
public BindableTask(Task<T> task)
{
_task = task;
var _ = WatchTaskAsync();
}
private async Task WatchTaskAsync()
{
try
{
await _task;
}
catch
{
}
OnPropertyChanged("IsNotCompleted");
OnPropertyChanged("IsSuccessfullyCompleted");
OnPropertyChanged("IsFaulted");
OnPropertyChanged("Result");
}
public bool IsNotCompleted { get { return !_task.IsCompleted; } }
public bool IsSuccessfullyCompleted
{
get { return _task.Status == TaskStatus.RanToCompletion; }
}
public bool IsFaulted { get { return _task.IsFaulted; } }
public T Result
{
get { return IsSuccessfullyCompleted ? _task.Result : default; }
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
注意,这里有一个空的catch子句是有意为之:该代码明确希望捕获所有异常,并通过数据绑定处理这些情况。此外,该代码明确不希望使用ConfigureAwait(false),因为应在 UI 线程上引发PropertyChanged事件。
提示
NotifyTask类型位于Nito.Mvvm.Async NuGet 包中。MvxNotifyTask类型位于MvvmCross NuGet 包中。
另请参阅
第一章 讨论了基本的async/await编程。
配方 2.7 讨论了如何使用ConfigureAwait。
14.4 隐式状态
问题
你有一些状态变量,需要在调用堆栈的不同点访问。例如,你有一个当前操作标识符,你希望用于日志记录,但不想将其添加为每个方法的参数。
解决方案
最佳解决方案是向方法添加参数,将数据存储为类的成员,或使用依赖注入为代码的不同部分提供数据。然而,在某些情况下,这样做会使代码变得过于复杂。
AsyncLocal<T>类型使您能够为状态提供一个对象,可以在逻辑“上下文”中存储它。以下代码展示了如何使用AsyncLocal<T>设置稍后由日志记录方法读取的操作标识符:
private static AsyncLocal<Guid> _operationId = new AsyncLocal<Guid>();
async Task DoLongOperationAsync()
{
_operationId.Value = Guid.NewGuid();
await DoSomeStepOfOperationAsync();
}
async Task DoSomeStepOfOperationAsync()
{
await Task.Delay(100); // Some async work
// Do some logging here.
Trace.WriteLine("In operation: " + _operationId.Value);
}
许多时候,在单个AsyncLocal<T>实例中拥有更复杂的数据结构(如值堆栈)是很有用的。这是可能的,但有一个重要注意事项:您应该只在AsyncLocal<T>中存储不可变数据。每当需要更新数据时,应该覆盖现有值。通常有助于将AsyncLocal<T>隐藏在一个助手类型中,以确保存储的数据是不可变的并且正确更新:
internal sealed class AsyncLocalGuidStack
{
private readonly AsyncLocal<ImmutableStack<Guid>> _operationIds =
new AsyncLocal<ImmutableStack<Guid>>();
private ImmutableStack<Guid> Current =>
_operationIds.Value ?? ImmutableStack<Guid>.Empty;
public IDisposable Push(Guid value)
{
_operationIds.Value = Current.Push(value);
return new PopWhenDisposed(this);
}
private void Pop()
{
ImmutableStack<Guid> newValue = Current.Pop();
if (newValue.IsEmpty)
newValue = null;
_operationIds.Value = newValue;
}
public IEnumerable<Guid> Values => Current;
private sealed class PopWhenDisposed : IDisposable
{
private AsyncLocalGuidStack _stack;
public PopWhenDisposed(AsyncLocalGuidStack stack) =>
_stack = stack;
public void Dispose()
{
_stack?.Pop();
_stack = null;
}
}
}
private static AsyncLocalGuidStack _operationIds = new AsyncLocalGuidStack();
async Task DoLongOperationAsync()
{
using (_operationIds.Push(Guid.NewGuid()))
await DoSomeStepOfOperationAsync();
}
async Task DoSomeStepOfOperationAsync()
{
await Task.Delay(100); // some async work
// Do some logging here.
Trace.WriteLine("In operation: " +
string.Join(":", _operationIds.Values));
}
封装类型确保底层数据是不可变的,并且新值被推送到堆栈上。它还提供了一种便利的IDisposable方法来从堆栈中弹出值。
讨论
旧代码可能使用ThreadStatic属性来处理同步代码使用的上下文状态。将旧代码转换为异步时,AsyncLocal<T>是替换ThreadStaticAttribute的首选。AsyncLocal<T>可用于同步和异步代码,并应该是现代应用程序中隐式状态的默认选择。
另请参阅
第一章 讨论了基本的async/await编程。
第九章 讨论了几种不可变集合,用于在需要将复杂数据存储为隐式状态时使用。
14.5 同步和异步代码相同
问题
你有一些代码需要通过同步和异步 API 公开,但你不想重复逻辑。在更新代码以支持异步时,经常会遇到这种情况,但现有的同步消费者不能(暂时)改变。
解决方案
如果可能的话,请尝试按照现代设计指南组织您的代码,例如端口和适配器(六边形架构),将业务逻辑与 I/O 等副作用分离。如果能够达到这种情况,那么不需要为任何事情同时暴露同步和异步 API;您的业务逻辑总是同步的,而 I/O 总是异步的。
然而,这是一个非常崇高的目标,在现实世界中,棕地代码可能会很混乱,在采用异步代码之前很少有时间使其完美。即使现有的 API 设计不佳,也经常需要维护以保持向后兼容性。
在这种情况下,没有完美的解决方案。许多开发人员尝试使同步代码调用异步代码,或使异步代码调用同步代码,但这两种方法都是反模式。在这种情况下,我倾向于使用布尔参数黑客。这是一种在单个方法中保持所有逻辑的方法,同时暴露同步和异步 API。
布尔参数黑客的主要思想是,有一个包含逻辑的私有核心方法。该核心方法具有异步签名,并带有布尔参数,确定核心方法是否应该是异步的。如果布尔参数指定核心方法应该是同步的,那么它必须返回一个已完成的任务。然后,您可以编写同时转发到核心方法的异步和同步 API 方法:
private async Task<int> DelayAndReturnCore(bool sync)
{
int value = 100;
// Do some work.
if (sync)
Thread.Sleep(value); // Call synchronous API.
else
await Task.Delay(value); // Call asynchronous API.
return value;
}
// Asynchronous API
public Task<int> DelayAndReturnAsync() =>
DelayAndReturnCore(sync: false);
// Synchronous API
public int DelayAndReturn() =>
DelayAndReturnCore(sync: true).GetAwaiter().GetResult();
异步 API DelayAndReturnAsync 调用带有布尔参数 sync 设置为 false 的 DelayAndReturnCore;这意味着 DelayAndReturnCore 可能会异步执行,并使用底层异步的“延迟”API Task.Delay。从 DelayAndReturnCore 返回的任务会直接返回给 DelayAndReturnAsync 的调用者。
同步 API DelayAndReturn 调用带有布尔参数 sync 设置为 true 的 DelayAndReturnCore;这意味着 DelayAndReturnCore 必须同步执行,并使用底层同步的“延迟”API Thread.Sleep。DelayAndReturnCore 返回的任务必须已经完成,因此可以安全地提取结果。DelayAndReturn 使用 GetAwaiter().GetResult() 从任务中检索结果;这样做可以避免使用 Task<T>.Result 属性时可能出现的 AggregateException 包装器。
讨论
这不是一个理想的解决方案,但它可以帮助处理现实世界的应用场景。
对于这个解决方案,现在需要注意一些注意事项。如果Core方法未能正确地尊重其sync参数,可能会出现最严重的问题。如果Core方法在sync为true时返回了一个不完整的任务,那么同步 API 很容易会发生死锁;同步 API 可以阻塞其任务的唯一原因是它知道任务已经完成。类似地,如果Core方法在sync为false时阻塞了线程,那么应用程序的效率就不如预期。
可以对这个解决方案进行改进的一个方法是在同步 API 中添加一个检查,验证返回的任务实际上是已完成的。如果它曾经未完成过,那么这就是一个严重的编码错误。
另请参见
第一章介绍了基本的async/await编程,包括讨论一般情况下在异步代码中阻塞可能导致的死锁问题。
14.6 数据流网格中的铁路编程
问题
您已经建立了一个数据流网格,但有些数据项未能处理。您希望以一种方式响应这些错误,以保持数据流网格的正常运行。
解决方案
默认情况下,如果一个块在处理数据项时遇到异常,那么该块将会故障,导致无法继续处理任何数据项。这个解决方案的核心思想是将异常视为另一种数据。如果数据流网格操作的类型可以是异常或数据,那么即使出现异常,网格仍然可以继续运行并处理其他数据项。
这种编程方式有时被称为“铁路”编程,因为网格中的项目可以被视为沿着两条单独的轨道行驶。第一条是正常的“数据”轨道:如果一切顺利,项目将留在“数据”轨道上,并通过网格进行转换和操作,直到到达网格的末端。第二条轨道是“错误”轨道;在任何块中,如果处理项目时出现异常,该异常将转移到“错误”轨道并通过网格传递。异常项目不会被处理;它们只是从块传递到块,因此它们也会到达网格的末端。网格中的终端块最终会接收到一系列项目,每个项目都是数据项或异常项;数据项表示已成功完成整个网格的数据,异常项表示网格某个点的处理错误。
要设置这种“铁路”编程,首先需要定义一个表示数据项或异常的类型。如果要使用预先构建的类型,有几种可用。这种类型在函数式编程社区中很常见,通常称为Try或Error或Exceptional,是Either单子的特例。我定义了自己的Try<T>类型作为示例;它在Nito.Try NuGet 包中,源代码在GitHub 上。
一旦您有某种Try<T>类型,设置网格有点繁琐,但并不可怕。每个数据流块的类型应从T更改为Try<T>,并且该块中的任何处理都应通过将一个Try<T>值映射到另一个来完成。使用我的Try<T>类型,通过调用Try<T>.Map来完成这一点。我发现定义小工厂方法用于铁路导向数据流块而不是在行内添加额外代码会很有帮助。以下代码是一个帮助方法的示例,它构造一个在Try<T>值上操作的TransformBlock,通过调用Try<T>.Map:
private static TransformBlock<Try<TInput>, Try<TOutput>>
RailwayTransform<TInput, TOutput>(Func<TInput, TOutput> func)
{
return new TransformBlock<Try<TInput>, Try<TOutput>>(t => t.Map(func));
}
有了这些帮助程序,数据流网格创建代码会更加简单:
var subtractBlock = RailwayTransform<int, int>(value => value - 2);
var divideBlock = RailwayTransform<int, int>(value => 60 / value);
var multiplyBlock = RailwayTransform<int, int>(value => value * 2);
var options = new DataflowLinkOptions { PropagateCompletion = true };
subtractBlock.LinkTo(divideBlock, options);
divideBlock.LinkTo(multiplyBlock, options);
// Insert data items into the first block.
subtractBlock.Post(Try.FromValue(5));
subtractBlock.Post(Try.FromValue(2));
subtractBlock.Post(Try.FromValue(4));
subtractBlock.Complete();
// Receive data/exception items from the last block.
while (await multiplyBlock.OutputAvailableAsync())
{
Try<int> item = await multiplyBlock.ReceiveAsync();
if (item.IsValue)
Console.WriteLine(item.Value);
else
Console.WriteLine(item.Exception.Message);
}
讨论
铁路编程是避免数据流块故障的好方法。由于铁路编程是基于单子的函数式编程构造,将其转换为.NET 时有些笨拙,但可用。如果您有一个需要容错的数据流网格,那么铁路编程绝对值得一试。
参见
Recipe 5.2 讲述了异常如何影响块的正常方式,并可以通过网格传播,如果不使用铁路编程。
14.7 节流进度更新
问题
您有一个长时间运行的操作,报告进度,并在 UI 中显示进度更新。但进度更新过于频繁,导致 UI 无响应。
解决方案
考虑以下代码,它非常快速地报告进度:
private string Solve(IProgress<int> progress)
{
// Count as quickly as possible for 3 seconds.
var endTime = DateTime.UtcNow.AddSeconds(3);
int value = 0;
while (DateTime.UtcNow < endTime)
{
value++;
progress?.Report(value);
}
return value.ToString();
}
你可以通过将其包装在Task.Run中并传入IProgress<T>,从 GUI 应用程序执行此代码。以下示例代码适用于 WPF,但相同的概念适用于任何 GUI 平台(WPF、Xamarin 或 Windows Forms):
// For simplicity, this code updates a label directly.
// In a real-world MVVM application, those assignments
// would instead be updating a ViewModel property
// which is data-bound to the actual UI.
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
MyLabel.Content = "Starting...";
var progress = new Progress<int>(value => MyLabel.Content = value);
var result = await Task.Run(() => Solve(progress));
MyLabel.Content = $"Done! Result: {result}";
}
这段代码会导致 UI 在我的机器上变得无响应相当长的时间,大约 20 秒,然后突然 UI 重新响应并且只显示"Done! Result:"消息。中间的进度报告从未被看到。发生的情况是后台代码非常快地向 UI 线程发送进度报告,以至于在运行仅 3 秒后,UI 线程需要大约额外的 17 秒来处理所有这些进度报告,一遍又一遍地更新标签。最后,UI 线程最后一次更新标签的值为"Done! Result:",然后最终有时间重新绘制屏幕,向用户显示更新后的标签值。
首先要意识到的是,我们需要节流进度报告。这是确保 UI 在进度更新之间有足够时间重新绘制自身的唯一方法。接下来要意识到的是,我们希望基于时间而不是报告数量来进行节流。虽然您可能会试图通过仅发送每百个或更多报告中的一个来节流进度报告,但出于“讨论”部分所述的原因,这并不理想。
我们希望处理时间表明我们应该考虑 System.Reactive。事实上,System.Reactive 具有专门设计用于按时间节流的操作符。因此,System.Reactive 似乎在这个解决方案中将发挥作用。
要开始,您可以定义一个IProgress<T>实现,该实现为每个进度报告触发一个事件,然后通过包装该事件创建一个可观察对象来接收这些进度报告:
public static class ObservableProgress
{
private sealed class EventProgress<T> : IProgress<T>
{
void IProgress<T>.Report(T value) => OnReport?.Invoke(value);
public event Action<T> OnReport;
}
public static (IObservable<T>, IProgress<T>) Create<T>()
{
var progress = new EventProgress<T>();
var observable = Observable.FromEvent<T>(
handler => progress.OnReport += handler,
handler => progress.OnReport -= handler);
return (observable, progress);
}
}
方法ObservableProgress.Create<T>将创建一对:一个IObservable<T>和一个IProgress<T>,其中所有发送到IProgress<T>的进度报告将发送到IObservable<T>的订阅者。现在我们有了进度报告的可观察流;下一步是对其进行节流。
我们希望更新 UI 的速度足够慢,以使其保持响应,并且我们希望更新 UI 的速度足够快,以便用户能看到更新。人类感知远远慢于计算机显示,因此有很大的可能性。如果您更喜欢真实的可读性,每秒节流一次更新可能足够了。如果您更喜欢更实时的反馈,我发现每 100 或 200 毫秒(ms)节流一次更新足够快,以至于用户看到事情正在迅速发生,并获得进度详细信息的一般感觉,同时仍然足够慢以使 UI 保持响应。
另一个要记住的点是,进度报告可能会从其他线程引发—在这种情况下,它们是从后台线程引发的。节流应尽可能靠近源头完成,因此我们希望在后台线程上保持节流。然而,更新 UI 的代码需要在 UI 线程上运行。考虑到这一点,您可以定义一个 CreateForUi 方法,处理节流和转换到 UI 线程:
public static class ObservableProgress
{
// Note: this must be called from the UI thread.
public static (IObservable<T>, IProgress<T>) CreateForUi<T>(
TimeSpan? sampleInterval = null)
{
var (observable, progress) = Create<T>();
observable = observable
.Sample(sampleInterval ?? TimeSpan.FromMilliseconds(100))
.ObserveOn(SynchronizationContext.Current);
return (observable, progress);
}
}
现在,您有一个辅助方法,可以在更新到达用户界面之前对其进行节流。您可以在前面的代码示例中的按钮点击处理程序中使用这个辅助方法:
// For simplicity, this code updates a label directly.
// In a real-world MVVM application, those assignments
// would instead be updating a ViewModel property
// which is data-bound to the actual UI.
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
MyLabel.Content = "Starting...";
var (observable, progress) = ObservableProgress.CreateForUi<int>();
string result;
using (observable.Subscribe(value => MyLabel.Content = value))
result = await Task.Run(() => Solve(progress));
MyLabel.Content = $"Done! Result: {result}";
}
新代码调用了我们的辅助方法 ObservableProgress.CreateForUi,它创建了 IObservable<T> 和 IProgress<T> 对。代码订阅进度更新,并保持到 Solve 完成为止。最后,它将 IProgress<T> 传递给长时间运行的 Solve 方法。当 Solve 调用 IProgress<T>.Report 时,这些报告首先在 100 毫秒的时间窗口内进行采样,每 100 毫秒转发一次更新到 UI 线程,并用于更新标签文本。现在,UI 完全响应!
讨论
这个配方是本书中其他配方的有趣组合!没有引入新技术;我们只是介绍了如何组合这些配方以得出这个解决方案。
在野外经常见到的这个问题的另一种替代解决方案是“模数解决方案”。这个解决方案的背后思想是 Solve 本身必须节流自己的进度更新;例如,如果代码只想处理每 100 个实际更新的一个更新,那么代码可能使用一些模数技术,如 if (value % 100 == 0) progress?.Report(value);。
采用模数方法存在几个问题。首先,没有“正确”的模数值;通常,开发人员会尝试各种值,直到在他们自己的笔记本电脑上运行良好。然而,同样的代码在运行在客户的大型服务器或不足的虚拟机内时可能表现不佳。此外,不同的平台和环境缓存方式差异很大,这可能导致代码运行比预期快得多(或慢得多)。当然,“最新”的计算机硬件的能力随时间而变化。因此,模数值最终只是一个猜测;它不会在所有地方和所有时间都正确。
模数方法的另一个问题是,它试图在代码的错误部分修复问题。这个问题纯粹是一个 UI 问题;UI 存在问题,并且 UI 层应该为其提供解决方法。在这个配方的示例代码中,Solve 表示一些后台业务处理逻辑;它不应关心 UI 特定的问题。控制台应用程序可能希望使用与 WPF 应用程序非常不同的模数。
模数方法正确的一点是,在将更新发送到 UI 线程之前最好对更新进行节流。本示例中的解决方案也是如此:在将更新发送到 UI 线程之前,它立即在后台线程上同步地节流更新。通过注入自己的IProgress<T>实现,UI 能够在不需要对Solve方法本身进行任何更改的情况下进行自己的节流。
参见
Recipe 2.3 涵盖了使用IProgress<T>从长时间运行的操作报告进度。
Recipe 13.1 涵盖了使用Task.Run在线程池线程上运行同步代码。
Recipe 6.1 涵盖了使用FromEvent将.NET 事件包装成可观察对象。
Recipe 6.4 涵盖了使用Sample按时间节流可观察对象。
Recipe 6.2 涵盖了使用ObserveOn将可观察通知移动到另一个上下文。