如何强制IAsyncEnumerable遵守CancellationToken

时间:2019-10-04 10:39:49

标签: c# cancellation c#-8.0 iasyncenumerable

编辑:此问题的要求已更改。请参阅下面的更新部分。

我有一个异步迭代器方法,该方法生成一个IAsyncEnumerable<int>(数字流),每200毫秒一个数字。此方法的调用方使用流,但要在1000毫秒后停止枚举。因此,使用CancellationTokenSource,并且令牌作为 WithCancellation扩展方法的参数。但是令牌不被尊重。枚举继续进行,直到所有数字都被消耗为止:

static async IAsyncEnumerable<int> GetSequence()
{
    for (int i = 1; i <= 10; i++)
    {
        await Task.Delay(200);
        yield return i;
    }
}

var cts = new CancellationTokenSource(1000);
await foreach (var i in GetSequence().WithCancellation(cts.Token))
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} > {i}");
}

输出:

  

12:55:17.506> 1
  12:55:17.739> 2
  12:55:17.941> 3
  12:55:18.155> 4
  12:55:18.367> 5
  12:55:18.570> 6
  12:55:18.772> 7
  12:55:18.973> 8
  12:55:19.174> 9
  12:55:19.376> 10

预期的输出是在数字5之后出现的TaskCanceledException。似乎我误解了WithCancellation的实际功能。该方法仅将提供的令牌传递给迭代器方法(如果该方法接受一个)。否则,就像在我的示例中使用方法GetSequence()一样,令牌将被忽略。我想我的解决方案是手动查询枚举主体内的令牌:

var cts = new CancellationTokenSource(1000);
await foreach (var i in GetSequence())
{
    cts.Token.ThrowIfCancellationRequested();
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} > {i}");
}

这很简单,效果很好。但是无论如何,我想知道是否有可能创建一个扩展方法来实现我期望的WithCancellation,以烘烤随后枚举中的令牌。这是所需方法的签名:

public static IAsyncEnumerable<T> WithEnforcedCancellation<T>(
    this IAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
    // Is it possible?
}

更新:似乎当我问这个问题时,我对整个取消概念的目的有不正确的理解。我给人的印象是,取消的目的是打破MoveNextAsync的等待中的 循环,而真正的目的是取消等待中的 。在我的琐碎示例中,等待仅持续200毫秒,但是在实际示例中,等待可能会更长,甚至是无限的。意识到这一点之后,我目前的问题几乎没有任何价值,我必须删除它并打开一个具有相同标题的新问题,或者更改现有问题的要求。两种选择都以一种或另一种方式是不好的。

我决定采用第二种选择。因此,我不接受当前接受的答案,我正在寻求一种新的解决方案,以解决更棘手的问题,即以立即生效的方式实施取消。换句话说,取消令牌应导致在大约毫秒内完成异步枚举。让我们举一个实际的例子来区分期望和不期望的行为:

var cts = new CancellationTokenSource(500);
var stopwatch = Stopwatch.StartNew();
try
{
    await foreach (var i in GetSequence().WithEnforcedCancellation(cts.Token))
    {
        Console.WriteLine($"{stopwatch.Elapsed:m':'ss'.'fff} > {i}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine($"{stopwatch.Elapsed:m':'ss'.'fff} > Canceled");
}

输出(理想):

  

0:00.242> 1
  0:00.467> 2
  0:00.500>已取消

输出(不希望的):

  

0:00.242> 1
  0:00.467> 2
  0:00.707>已取消

GetSequence与初始示例中的方法相同,每200毫秒流送一个数字。此方法不支持取消,前提是我们无法更改。 WithEnforcedCancellation是解决此问题的必需扩展方法。

2 个答案:

答案 0 :(得分:5)

IAsyncEnumerable通过EnumeratorCancellation属性为该机制明确提供:

static async IAsyncEnumerable<int> GetSequence([EnumeratorCancellation] CancellationToken ct = default) {
    for (int i = 1; i <= 10; i++) {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(200);    // or `Task.Delay(200, ct)` if this wasn't an example
        yield return i;
    }
}

实际上,如果为该方法提供一个CancellationToken参数,但不添加该属性,则编译器足以发出警告。

请注意,传递给.WithCancellation的令牌将覆盖传递给方法的所有本地令牌。 specs对此有详细说明。

当然,这仅在枚举实际上接受CancellationToken的情况下仍然有效-但是任何async工作都只有在合作完成的情况下取消才有效。 Yeldar's answer有利于“强制”将取消措施引入不支持它的枚举,但是首选解决方案应该是修改枚举以单独支持取消—编译器会尽一切努力为您提供帮助

答案 1 :(得分:2)

您可以将逻辑提取到如下扩展方法中:

public static async IAsyncEnumerable<T> WithEnforcedCancellation<T>(
    this IAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
    if (source == null)
        throw new ArgumentNullException(nameof(source));

    cancellationToken.ThrowIfCancellationRequested();

    await foreach (var item in source)
    {
        cancellationToken.ThrowIfCancellationRequested();
        yield return item;
    }
}