我有一个方法接受一个IAsyncEnumerable
作为参数,并且还返回一个IAsyncEnumerable
。它为输入流中的每个项目调用Web方法,并将结果传播到输出流。我的问题是,如果我的方法的调用者已停止枚举输出流,如何通知我,以便可以停止枚举我的方法内部的输入流?似乎应该能够收到通知,因为调用者默认情况下会处置从我的方法中获得的IAsyncEnumerator
。是否有任何内置机制为编译器生成的异步方法生成此类通知?如果没有,最简单的替代方案是什么?
示例。 Web方法验证URL是否有效。提供了永无止境的URL流,但是当发现两个以上无效URL时,调用者将停止枚举结果:
var invalidCount = 0;
await foreach (var result in ValidateUrls(GetMockUrls()))
{
Console.WriteLine($"Url {result.Url} is "
+ (result.IsValid ? "OK" : "Invalid!"));
if (!result.IsValid) invalidCount++;
if (invalidCount > 2) break;
}
Console.WriteLine($"--Async enumeration finished--");
await Task.Delay(2000);
URL的生成器。每300毫秒生成一个网址。
private static async IAsyncEnumerable<string> GetMockUrls()
{
int index = 0;
while (true)
{
await Task.Delay(300);
yield return $"https://mock.com/{++index:0000}";
}
}
URL的验证器。要求对输入流进行快速枚举,以便两个异步工作流并行运行。第一个工作流程将URL插入队列中,第二个工作流程一个接一个地选择URL并进行验证。 BufferBlock
用作异步队列。
private static async IAsyncEnumerable<(string Url, bool IsValid)> ValidateUrls(
this IAsyncEnumerable<string> urls)
{
var buffer = new System.Threading.Tasks.Dataflow.BufferBlock<string>();
_ = Task.Run(async () =>
{
await foreach (var url in urls)
{
Console.WriteLine($"Url {url} received");
await buffer.SendAsync(url);
}
buffer.Complete();
});
while (await buffer.OutputAvailableAsync() && buffer.TryReceive(out var url))
{
yield return (url, await MockValidateUrl(url));
}
}
说明:该队列是强制性的,而不能将其删除。这是这个问题的重要组成部分。
单个URL的验证器。验证过程平均需要300毫秒。
private static Random _random = new Random();
private static async Task<bool> MockValidateUrl(string url)
{
await Task.Delay(_random.Next(100, 600));
return _random.Next(0, 2) != 0;
}
输出:
Url https://mock.com/0001 received
Url https://mock.com/0001 is Invalid!
Url https://mock.com/0002 received
Url https://mock.com/0003 received
Url https://mock.com/0002 is OK
Url https://mock.com/0004 received
Url https://mock.com/0003 is Invalid!
Url https://mock.com/0005 received
Url https://mock.com/0004 is OK
Url https://mock.com/0005 is OK
Url https://mock.com/0006 received
Url https://mock.com/0006 is Invalid!
--Async enumeration finished--
Url https://mock.com/0007 received
Url https://mock.com/0008 received
Url https://mock.com/0009 received
Url https://mock.com/0010 received
Url https://mock.com/0011 received
Url https://mock.com/0012 received
...
问题在于,调用者/客户端完成异步枚举后,仍会生成并接收URL。我想解决此问题,以便在--Async enumeration finished--
之后,控制台中不再显示任何消息。
答案 0 :(得分:2)
修改
通过一个适当的示例,讨论将变得更加容易。验证URL并不那么昂贵。如果您需要输入100个URL并选择前3个响应,该怎么办?
在这种情况下,工作程序和缓冲区都有意义。
编辑2
其中一条注释增加了额外的复杂性-任务是同时执行的,结果需要在到达时发出。
对于初学者,ValidateUrl
可以重写为迭代器方法:
private static async IAsyncEnumerable<(string Url, bool IsValid)> ValidateUrls(
this IAsyncEnumerable<string> urls)
{
await foreach (var url in urls)
{
Console.WriteLine($"Url {url} received");
var isValid=await MockValidateUrl(url);
yield return (url, isValid);
}
}
因为所有方法都是异步的,所以不需要工作任务。除非使用者询问获得结果,否则迭代器方法将不会继续进行。即使MockValidateUrl
做一些昂贵的事情,它也可以自己使用Task.Run
或包裹在Task.Run
中。但这会产生很多任务。
出于完整性考虑,您可以添加CancellationToken
和ConfigureAwait(false)
:
public static async IAsyncEnumerable<(string Url, bool IsValid)> ValidateUrls(
IAsyncEnumerable<string> urls,
[EnumeratorCancellation]CancellationToken token=default)
{
await foreach(var url in urls.WithCancellation(token).ConfigureAwait(false))
{
var isValid=await MockValidateUrl(url).ConfigureAwait(false);
yield return (url,isValid);
}
}
无论如何,一旦呼叫者停止迭代,ValidateUrls
就会停止。
缓冲
缓冲是一个问题-不管如何编程,工人都不会停下来,直到缓冲区装满。缓冲区的大小是工作程序意识到需要停止之前要进行的迭代次数。对于Channel来说,这是一个很好的例子(是的,再次!):
public static IAsyncEnumerable<(string Url, bool IsValid)> ValidateUrls(
IAsyncEnumerable<string> urls,CancellationToken token=default)
{
var channel=Channel.CreateBounded<(string Url, bool IsValid)>(2);
var writer=channel.Writer;
_ = Task.Run(async ()=>{
await foreach(var url in urls.WithCancellation(token))
{
var isValid=await MockValidateUrl(url);
await writer.WriteAsync((url,isValid));
}
},token)
.ContinueWith(t=>writer.Complete(t.Exception));
return channel.Reader.ReadAllAsync(token);
}
最好还是传递ChannelReaders而不是IAsyncEnumerables。至少在有人尝试从ChannelReader读取之前,不会构造任何异步枚举器。将管道构造为扩展方法也更容易:
public static ChannelReader<(string Url, bool IsValid)> ValidateUrls(
this ChannelReader<string> urls,int capacity,CancellationToken token=default)
{
var channel=Channel.CreateBounded<(string Url, bool IsValid)>(capacity);
var writer=channel.Writer;
_ = Task.Run(async ()=>{
await foreach(var url in urls.ReadAllAsync(token))
{
var isValid=await MockValidateUrl(url);
await writer.WriteAsync((url,isValid));
}
},token)
.ContinueWith(t=>writer.Complete(t.Exception));
return channel.Reader;
}
此语法允许以流畅的方式构造管道。假设我们有这个辅助方法,可以将IEnumerables转换为channesl(或IAsyncEnumerables):
public static ChannelReader<T> AsChannel(
IEnumerable<T> items)
{
var channel=Channel.CreateUnbounded();
var writer=channel.Writer;
foreach(var item in items)
{
channel.TryWrite(item);
}
return channel.Reader;
}
我们可以写:
var pipeline=urlList.AsChannel() //takes a list and writes it to a channel
.ValidateUrls();
await foreach(var (url,isValid) in pipeline.ReadAllAsync())
{
//Use the items here
}
具有即时传播的并发呼叫
使用通道很容易,尽管此时的工作人员需要立即执行所有任务。本质上,我们需要多名工人。仅仅使用IAsyncEnumerable不能做到这一点。
首先,如果我们想使用例如5个并发任务来处理输入,我们可以编写
var tasks = Enumerable.Range(0,5).
.Select(_ => Task.Run(async ()=>{
///
},token));
_ = Task.WhenAll(tasks)(t=>writer.Complete(t.Exception));
而不是:
_ = Task.Run(async ()=>{
///
},token)
.ContinueWith(t=>writer.Complete(t.Exception));
使用大量工人就足够了。我不确定IAsyncEnumerable是否可以由多个工作人员使用,我真的不想找出答案。
提早取消
如果客户端使用了所有结果,则上述所有工作均有效。为了在例如前5个结果之后停止处理,我们需要CancellationToken:
var cts=new CancellationTokenSource();
var pipeline=urlList.AsChannel() //takes a list and writes it to a channel
.ValidateUrls(cts.Token);
int i=0;
await foreach(var (url,isValid) in pipeline.ReadAllAsync())
{
//Break after 3 iterations
if(i++>2)
{
break;
}
....
}
cts.Cancel();
此代码本身可以用接收ChannelReader的方法提取,在这种情况下,该方法是CancellationTokenSource:
static async LastStep(this ChannelReader<(string Url, bool IsValid)> input,CancellationTokenSource cts)
{
int i=0;
await foreach(var (url,isValid) in pipeline.ReadAllAsync())
{
//Break after 3 iterations
if(i++>2)
{
break;
}
....
}
cts.Cancel();
}
然后管道变成:
var cts=new CancellationTokenSource();
var pipeline=urlList.AsChannel()
.ValidateUrls(cts.Token)
.LastStep(cts);
答案 1 :(得分:0)
我想我应该回答自己的问题,因为我现在有一个足够简单的通用解决方案。
更新:由于我发现了一个更简单的解决方案,所以我抓了以前的答案。实际上,这非常简单。我需要做的就是将ValidateUrls
迭代器的yield部分封装到try-finally
块中。 finally
块将在每种情况下执行,要么由调用者正常完成枚举,要么由break
或异常异常地执行。因此,这是通过取消CancellationTokenSource
上的finally
来获得所需通知的方法:
private static async IAsyncEnumerable<(string Url, bool IsValid)> ValidateUrls(
this IAsyncEnumerable<string> urls)
{
var buffer = new System.Threading.Tasks.Dataflow.BufferBlock<string>();
var completionCTS = new CancellationTokenSource();
_ = Task.Run(async () =>
{
await foreach (var url in urls)
{
if (completionCTS.IsCancellationRequested) break;
Console.WriteLine($"Url {url} received");
await buffer.SendAsync(url);
}
buffer.Complete();
});
try
{
while (await buffer.OutputAvailableAsync() && buffer.TryReceive(out var url))
{
yield return (url, await MockValidateUrl(url));
}
}
finally // This runs when the caller completes the enumeration
{
completionCTS.Cancel();
}
}
我可能应该注意,不支持取消的异步迭代器不是一个好习惯。没有它,呼叫者就没有简单的方法来停止在消耗一个值和另一个值之间的等待。因此,对我的方法来说,更好的签名应该是:
private static async IAsyncEnumerable<(string Url, bool IsValid)> ValidateUrls(
this IAsyncEnumerable<string> urls,
[EnumeratorCancellation]CancellationToken cancellationToken = default)
{
然后可以将令牌传递到产生循环的等待方法OutputAvailableAsync
和MockValidateUrl
。
从调用者的角度来看,令牌可以直接传递,也可以通过链接扩展方法WithCancellation
传递。
await foreach (var result in ValidateUrls(GetMockUrls()).WithCancellation(token))