开发进阶:Dotnet Core多路径异步终止

开发 前端
今天用一个简单例子说说异步的多路径终止。我尽可能写得容易理解吧,但今天的内容需要有一定的编程能力。

[[377099]]

本文转载自微信公众号「老王Plus」,作者老王Plus的老王 。转载本文请联系老王Plus公众号。

今天用一个简单例子说说异步的多路径终止。我尽可能写得容易理解吧,但今天的内容需要有一定的编程能力。

今天这个话题,来自于最近对gRPC的一些技术研究。

话题本身跟gRPC没有太大关系。应用中,我用到了全双工数据管道这样一个相对复杂的概念。

我们知道,全双工连接是两个节点之间的连接,但不是简单的“请求-响应”连接。任何一个节点都可以在任何时间发送消息。概念上,还是有客户端和服务端的区分,但这仅仅是概念上,只是为了区分谁在监听连接尝试,谁在建立连接。实际上,做一个双工的API比做一个“请求-响应”式的API要复杂得多。

由此,延伸出了另一个想法:做个类库,在库内部构建双工管道,供给消费者时,只暴露简单的内容和熟悉的方式。

一、开始

假设我们有这样一个API:

  • 客户端建立连接
  • 有一个SendAsync消息从客户端发送到服务器
  • 有一个TryReceiveAsync消息,试图等待来自服务器的消息(服务器有消息发送为True,返之为False)
  • 服务器控制数据流终止,如果服务器发送完最后一条消息,则客户端不再发送任何消息。

接口代码可以写成这样:

  1. interface ITransport<TRequest, TResponse> : IAsyncDisposable 
  2.     ValueTask SendAsync(TRequest request, CancellationToken cancellationToken); 
  3.     ValueTask<(bool Success, TResponse Message)> TryReceiveAsync(CancellationToken cancellationToken); 

忽略连接的部分,代码看起来并不复杂。

下面,我们创建两个循环,并通过枚举器公开数据:

  1. ITransport<TRequest, TResponse> transport; 
  2. public async IAsyncEnumerable<TResponse> ReceiveAsync([EnumeratorCancellation] CancellationToken cancellationToken) 
  3.     while (true
  4.     { 
  5.         var (success, message) = 
  6.             await transport.TryReceiveAsync(cancellationToken); 
  7.         if (!success) break; 
  8.         yield return message; 
  9.     } 
  10.  
  11. public async ValueTask SendAsync(IAsyncEnumerable<TRequest> data, CancellationToken cancellationToken) 
  12.     await foreach (var message in data.WithCancellation(cancellationToken)) 
  13.     { 
  14.         await transport.SendAsync(message, cancellationToken); 
  15.     } 

这里面用到了异步迭代器相关的概念。如果不明白,可以去看我的另一篇专门讨论异步迭代器的文章,【传送门】。

二、解决终止标志

好像做好了,我们用循环接收和发送,并传递了外部的终止标志给这两个方法。

真的做好了吗?

还没有。问题出在终止标志上。我们没有考虑到这两个流是相互依赖的,特别是,我们不希望生产者(使用SendAsync的代码)在任何连接失败的场景中仍然运行。

实际上,会有比我们想像中更多的终止路径:

  • 我们可能已经为这两个方法提供了一个外部的终止令牌,并且这个令牌可能已经被触发
  • ReceiveAsync的消费者可能已经通过WithCancellation提供了一个终止令牌给GetAsyncEnumerator,并且这个令牌可能已经被触发
  • 我们的发送/接收代码可能出错了
  • ReceiveAsync的消费者在数据获取到中途,要终止获取了 - 一个简单的原因是处理收到的数据时出错了
  • SendAsync中的生产者可能发生了错误

这只是一些可能的例子,但实际的可能会更多。

本质上,这些都表示连接终止,因此我们需要以某种方式包含所有这些场景,进而允许发送和接收路径之间传达问题。换句话说,我们需要自己的CancellationTokenSource。

显然,这种需求,用库来解决是比较完美的。我们可以把这些复杂的内容放在一个消费者可以访问的单一API中:

  1. public IAsyncEnumerable<TResponse> Duplex(IAsyncEnumerable<TRequest> request, CancellationToken cancellationToken = default); 

这个方法:

  • 允许它传入一个生产者
  • 通话它传入一个外部的终止令牌
  • 有一个异步的响应返回

使用时,我们可以这样做:

  1. await foreach (MyResponse item in client.Duplex(ProducerAsync())) 
  2.     // ... todo 
  3. async IAsyncEnumerable<MyRequest> ProducerAsync([EnumeratorCancellation] CancellationToken cancellationToken = default
  4.     for (int i = 0; i < 100; i++) 
  5.     { 
  6.         yield return new MyRequest(i); 
  7.         await Task.Delay(100, cancellationToken); 
  8.     } 

上面这段代码中,我们ProducerAsync还没有实现太多内容,目前只是传递了一个占位符。稍后我们可以枚举它,而枚举行为实际上调用了代码。

回到Duplex。这个方法,至少需要考虑两种不同的终止方式:

  • 通过cancellationToken传入的外部令牌
  • 使用过程中可能传递给GetAsyncEnumerator()的潜在的令牌

这儿,为什么不是之前列出的更多种终止方式呢?这儿要考虑到编译器的组合方式。我们需要的不是一个CancellationToken,而是一个CancellationTokenSource。

  1. public IAsyncEnumerable<TResponse> Duplex(IAsyncEnumerable<TRequest> request, CancellationToken cancellationToken = default) => DuplexImpl(transport, request, cancellationToken); 
  2.  
  3. private async static IAsyncEnumerable<TResponse> DuplexImpl(ITransport<TRequest, TResponse> transport, IAsyncEnumerable<TRequest> request, CancellationToken externalToken, [EnumeratorCancellation] CancellationToken enumeratorToken = default
  4.     using var allDone = CancellationTokenSource.CreateLinkedTokenSource(externalToken, enumeratorToken); 
  5.     // ... todo 

这里,DuplexImpl方法允许枚举终止,但又与外部终止标记保持分离。这样,在编译器层面不会被合并。在里面,CreateLinkedTokenSource反倒像编译器的处理。

现在,我们有一个CancellationTokenSource,需要时,我们可能通过它来终止循环的运行。

  1. using var allDone = CancellationTokenSource.CreateLinkedTokenSource(externalToken, enumeratorToken); 
  2. try 
  3.     // ... todo 
  4. finally 
  5.     allDone.Cancel(); 

通过这种方式,我们可以处理这样的场景:消费者没有获取所有数据,而我们想要触发allDone,但是我们退出了DuplexImpl。这时候,迭代器的作用就很大了,它让程序变得更简单,因为用了using,最终里面的任何内容都会定位到Dispose/DisposeAsync。

下一个是生产者,也就是SendAsync。它也是双工的,对传入的消息没有影响,所以可以用Task.Run作为一个独立的代码路径开始运行,而如果生产者出现错误,则终止发送。上边的todo部分,可以加入:

  1. var send = Task.Run(async () => 
  2.     try 
  3.     { 
  4.         await foreach (var message in request.WithCancellation(allDone.Token)) 
  5.         { 
  6.             await transport.SendAsync(message, allDone.Token); 
  7.         } 
  8.     } 
  9.     catch 
  10.     { 
  11.         allDone.Cancel(); 
  12.         throw; 
  13.     } 
  14. }, allDone.Token); 
  15.  
  16. // ... todo: receive 
  17.  
  18. await send; 

这里启动了一个生产者的并行操作SendAsync。注意,这里我们用标记allDone.Token把组合的终止标记传递给生产者。延迟await是为了允许ProducerAsync方法里可以使用终止令牌,以满足复合双工操作的生命周期要求。

这样,接收代码就变成了:

  1. while (true
  2.     var (success, message) = await transport.TryReceiveAsync(allDone.Token); 
  3.     if (!success) break; 
  4.     yield return message; 
  5.  
  6. allDone.Cancel(); 

最后,把这部分代码合在一起看看:

  1. private async static IAsyncEnumerable<TResponse> DuplexImpl(ITransport<TRequest, TResponse> transport, IAsyncEnumerable<TRequest> request, CancellationToken externalToken, [EnumeratorCancellation] CancellationToken enumeratorToken = default
  2.     using var allDone = CancellationTokenSource.CreateLinkedTokenSource(externalToken, enumeratorToken); 
  3.     try 
  4.     { 
  5.         var send = Task.Run(async () => 
  6.         { 
  7.             try 
  8.             { 
  9.                 await foreach (var message in request.WithCancellation(allDone.Token)) 
  10.                 { 
  11.                     await transport.SendAsync(message, allDone.Token); 
  12.                 } 
  13.             } 
  14.             catch 
  15.             { 
  16.                 allDone.Cancel(); 
  17.                 throw; 
  18.             } 
  19.         }, allDone.Token); 
  20.  
  21.         while (true
  22.         { 
  23.             var (success, message) = await transport.TryReceiveAsync(allDone.Token); 
  24.             if (!success) break; 
  25.             yield return message; 
  26.         } 
  27.  
  28.         allDone.Cancel(); 
  29.  
  30.         await send; 
  31.     } 
  32.     finally 
  33.     { 
  34.         allDone.Cancel(); 
  35.     } 

三、总结

相关的处理就这么多。这里实现的关键点是:

  • 外部令牌和枚举器令牌都对allDone有贡献
  • 传输中发送和接收代码使用allDone.Token
  • 生产者枚举使用allDone.Token
  • 任何情况下退出枚举器,allDone都会被终止
  • 如果传输接收错误,则allDone被终止
  • 如果消费者提前终止,则allDone被终止
  • 当我们收到来自服务器的最后一条消息后,allDone被终止
  • 如果生产者或传输发送错误,allDone被终止

最后多说一点,关于ConfigureAwait(false):

默认情况下,await包含一个对SynchronizationContext.Current的检查。除了表示额外的上下文切换之外,在UI应用程序的情况下,它也意味着在UI线程上运行不需要在UI线程上运行的代码。库代码通常不需要这样做。因此,在库代码中,通常应该在所有用到await的地方使用. configureawait (false)来绕过这个检查。而在一般应用程序的代码中,应该默认只使用await而不使用ConfigureAwait,除非你知道你在做什么。

 

责任编辑:武晓燕 来源: 老王Plus
相关推荐

2011-05-03 15:28:15

BlackBerryWidget

2018-10-30 13:10:34

ECMP技术数据中心网络

2023-08-01 08:52:03

WebRTC.Net线程

2011-05-13 14:55:13

负载均衡网关数据中心

2013-12-27 09:54:58

Android开发NDK

2010-12-23 09:11:17

读写Android文件

2012-02-07 10:05:40

jQuery MobijQuery Mobi

2019-07-29 12:53:39

Linux多路径multipath

2013-01-11 10:21:44

TRILL组网模型VLAN

2011-08-17 16:23:31

iPhone开发UIViewContr

2021-12-29 07:44:50

Dotnet 代码系统

2015-07-15 11:14:42

2022-08-12 15:41:11

神经网络架构

2016-12-12 12:00:47

MD3860i

2018-09-07 10:35:27

数据中心

2011-08-17 16:29:12

iPhone开发UIButton

2014-01-07 14:53:37

Android开发依赖注入Roboguice

2011-10-18 10:17:39

Android应用开发

2022-03-11 09:22:55

令牌Dotnet线程
点赞
收藏

51CTO技术栈公众号