【问题标题】:How to return a canceled ValueTask<T> that propagates an OperationCanceledException, without async/await?如何在没有异步/等待的情况下返回传播 OperationCanceledException 的已取消 ValueTask<T>?
【发布时间】:2021-10-31 19:26:41
【问题描述】:

我正在编写一个返回类型为ValueTask&lt;T&gt; 并接受CancellationToken 的API。如果在调用该方法时CancellationToken 已经被取消,我想返回一个取消的ValueTask&lt;T&gt; (IsCanceled == true),它会在等待时传播OperationCanceledException。用异步方法做这件事很简单:

async ValueTask<int> MyMethod1(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    //...
    return 13;
}

ValueTask<int> task = MyMethod1(new CancellationToken(true));
Console.WriteLine($"IsCanceled: {task.IsCanceled}"); // True
await task; // throws OperationCanceledException

我决定切换到非async 实现,但现在我无法重现相同的行为。将Task.FromCanceled 正确包装为已取消的ValueTask&lt;T&gt;,但异常类型为TaskCanceledException,这是不可取的:

ValueTask<int> MyMethod2(CancellationToken token)
{
    if (token.IsCancellationRequested)
        return new ValueTask<int>(Task.FromCanceled<int>(token));
    //...
    return new ValueTask<int>(13);
}

ValueTask<int> task = MyMethod2(new CancellationToken(true));
Console.WriteLine($"IsCanceled: {task.IsCanceled}"); // True
await task; // throws TaskCanceledException (undesirable)

另一个不成功的尝试是包装Task.FromException。这个传播了正确的异常类型,但任务是错误的而不是取消的:

ValueTask<int> MyMethod3(CancellationToken token)
{
    if (token.IsCancellationRequested)
        return new ValueTask<int>(
            Task.FromException<int>(new OperationCanceledException(token)));
    //...
    return new ValueTask<int>(13);
}

ValueTask<int> task = MyMethod3(new CancellationToken(true));
Console.WriteLine($"IsCanceled: {task.IsCanceled}"); // False (undesirable)
await task; // throws OperationCanceledException

这个问题有什么解决方案吗,或者我应该接受我的 API 行为不一致,有时会传播TaskCanceledExceptions(当令牌已经取消时),有时会传播OperationCanceledExceptions(当令牌稍后被取消)?

Try it on Fiddle.


更新:作为我试图避免的不一致的一个实际示例,这是一个来自内置 Channel&lt;T&gt; 类的示例:

Channel<int> channel = Channel.CreateUnbounded<int>();

ValueTask<int> task1 = channel.Reader.ReadAsync(new CancellationToken(true));
await task1; // throws TaskCanceledException

ValueTask<int> task2 = channel.Reader.ReadAsync(new CancellationTokenSource(100).Token);
await task2; // throws OperationCanceledException

第一个ValueTask&lt;int&gt; 抛出TaskCanceledException,因为令牌已经被取消。第二个ValueTask&lt;int&gt; 抛出OperationCanceledException,因为令牌在100 毫秒后被取消。

Try it on Fiddle.

【问题讨论】:

  • @GSerg 在实际使用中可能无关紧要,但如果您通过其一致性评估 API 则很重要。目前我对此 API 有两个不同的单元测试,Assert.ThrowsException&lt;TaskCanceledException&gt;(() =&gt; 用于立即取消,Assert.ThrowsException&lt;OperationCanceledException&gt;(() =&gt; 用于延迟取消,至少可以说看起来很笨重。
  • 您可以使用断言库来处理将派生类型与指定异常类型匹配的情况。一个例子是 xUnit 的 Assert.ThrowAny 来处理你的两种情况
  • @Moho 老实说,我更担心我的 API 行为不一致,并且切换到提供更宽松断言的单元测试库不在我的优先级列表中。 :-)

标签: c# async-await task-parallel-library cancellation-token valuetask


【解决方案1】:

这里最好的方法可能是简单地拥有一个您可以遵循的async 辅助方法,这里;即:

ValueTask<int> MyMethod3(CancellationToken token)
{
    if (token.IsCancellationRequested) return Cancelled(token);
    // ... the rest of your non-async code here

    static async ValueTask<int> Cancelled(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        // some dummy await, or just suppress the compiler warning about no await
        await Task.Yield(); // should never be reached
        return 0; // should never be reached
    }
}

第三个​​选项可能会起作用,但它要复杂得多并且不能避免分配(IValueTaskSource&lt;TResult&gt; - 请注意您仍然需要在某个地方存放相关令牌)

运动鞋版本:


#pragma warning disable CS1998
static async ValueTask<int> Cancelled(CancellationToken token)
#pragma warning restore CS1998
{
    token.ThrowIfCancellationRequested();
    return 0; // should never be reached
}

【讨论】:

  • 你会问为什么不让这一切都是异步的开始,编译器没有分配免费通行证,或者我在这里缺少什么?
  • @TheGeneral 在某些热门路径场景中,如果事实证明您可以同步回答查询,那么避免async 机制确实很有用 - 并且只需添加你需要实际上await一些不完整的东西,或者如果你需要抛出并且你关心返回一个错误的任务/值任务而不是简单地抛出同步路径
  • 谢谢 Marc,这看起来很有希望。我的 API 可能会使用相同的 CancellationToken 引发多个连续异常。你知道缓存一个已取消的ValueTask&lt;T&gt; 是否明智,并将相同的值返回给多个调用者吗?
  • @TheodorZoulias 绝对不会那样做;您必须等待 ValueTask&lt;T&gt; 一次 - 多次等待的行为未定义,主要是由于 IValueTaskSource&lt;TResult&gt;,并且将相同的值任务分发给多个调用者会使该行为无效。您可以使用相同的技巧创建一个共享的Task&lt;T&gt;,并返回一个new ValueTask&lt;int&gt;(GetSharedTask(token)),但是 - 即相同的底层Task 很好
  • 我测试了多个线程等待相同的取消Task,堆栈跟踪到处都是。我得到了与this 问题中显示的相似的结果。将缓存的 Task 包装在 ValueTask 中会导致同样的混乱。所以每次都调用Cancelled()方法似乎是不可避免的。现在我要测量分配开销有多少。希望它与Exception 实例的大小相比相形见绌。
猜你喜欢
  • 2020-01-16
  • 2013-07-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-11-16
  • 1970-01-01
相关资源
最近更新 更多