如何捕获CancellationToken.Register回调函数的异常?

10

我正在使用异步I/O与HID设备通信,当超时时,我想抛出一个可捕获的异常。我有以下读取方法:

public async Task<int> Read( byte[] buffer, int? size=null )
{
    size = size ?? buffer.Length;

    using( var cts = new CancellationTokenSource() )
    {
        cts.CancelAfter( 1000 );
        cts.Token.Register( () => { throw new TimeoutException( "read timeout" ); }, true );
        try
        {
            var t =  stream.ReadAsync( buffer, 0, size.Value, cts.Token );
            await t;
            return t.Result;
        }
        catch( Exception ex )
        {
            Debug.WriteLine( "exception" );
            return 0;
        }
    }
}

Token的回调函数抛出的异常没有被任何try/catch块捕获,我不确定为什么。我认为它会在await处抛出,但事实并非如此。有没有一种方法可以捕获这个异常(或使它能够被Read()的调用者捕获)?
编辑:因此,我重新阅读了msdn上的文档,它说“委托生成的任何异常都将传播到此方法调用之外”。
我不确定它所说的“传播到此方法调用之外”是什么意思,因为即使我将.Register()调用移动到try块中,异常仍然无法被捕获。
3个回答

16

我个人更喜欢将取消逻辑封装成自己的方法。

例如,给定一个扩展方法:

public static async Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<bool>();
    using (cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs))
    {
        if (task != await Task.WhenAny(task, tcs.Task))
        {
            throw new OperationCanceledException(cancellationToken);
        }
    }

    return task.Result;
}

您可以将您的方法简化为:
public async Task<int> Read( byte[] buffer, int? size=null )
{
    size = size ?? buffer.Length;

    using( var cts = new CancellationTokenSource() )
    {
        cts.CancelAfter( 1000 );
        try
        {
            return await stream.ReadAsync( buffer, 0, size.Value, cts.Token ).WithCancellation(cts.Token);
        }
        catch( OperationCanceledException cancel )
        {
            Debug.WriteLine( "cancelled" );
            return 0;
        }
        catch( Exception ex )
        {
            Debug.WriteLine( "exception" );
            return 0;
        }
    }
}

在这种情况下,由于您的唯一目标是执行超时操作,因此您可以使其更加简单:
public static async Task<T> TimeoutAfter<T>(this Task<T> task, TimeSpan timeout)
{
    if (task != await Task.WhenAny(task, Task.Delay(timeout)))
    {
        throw new TimeoutException();
    }

    return task.Result; // Task is guaranteed completed (WhenAny), so this won't block
}

然后你的方法可以是:

public async Task<int> Read( byte[] buffer, int? size=null )
{
    size = size ?? buffer.Length;

    try
    {
        return await stream.ReadAsync( buffer, 0, size.Value, cts.Token ).TimeoutAfter(TimeSpan.FromSeconds(1));
    }
    catch( TimeoutException timeout )
    {
        Debug.WriteLine( "Timed out" );
        return 0;
    }
    catch( Exception ex )
    {
        Debug.WriteLine( "exception" );
        return 0;
    }
}

@bj0 这个问题在于异常是异步的 - 你可以在那里捕获它,但是接下来你必须对其进行处理,而且此时你处于不同的上下文/线程中。 - Reed Copsey
是的,那很有道理。我一直在想如何消除每次写入时创建的额外任务/ TCS,但我想TCS非常轻量级。 - bj0
1
@bj0,如果您不想为取消创建专用的TCS,则可以使用Task.Delay(Timeout.Infinite, token) - noseratio - open to work
@Noseratio,没错,但Task.Delay会创建一个额外的任务而不是TCS。我可能错了,但我认为TCS更加“轻量级”? - bj0
1
我希望我能将这两个答案都标记为“答案”,因为它们都提供了很好的信息,但我只能选择点赞。我将把@Noseratio的答案标记为正确,因为他回答了有关捕获异常的问题。 - bj0
显示剩余4条评论

10
编辑:我重新阅读了msdn中的文档,它说:“委托引发的任何异常都将传播到此方法调用之外。”
我不确定“传播到此方法调用之外”是什么意思,因为即使我将.Register()调用移动到try块中,异常仍未被捕获。
这意味着您的取消回调函数(即.NET Runtime内的代码)的调用者不会尝试捕获您在其中可能抛出的任何异常,因此它们将在调用回调时所在的任何堆栈帧和同步上下文之外传播。这可能会导致应用程序崩溃,因此您确实应该在回调函数中处理所有非致命异常。把它想象成一个事件处理程序。毕竟,可能已经向ct.Register()注册了多个回调,并且每个回调可能都会抛出异常。那么应该传播哪个异常呢?
因此,这样的异常将不会被捕获并传播到令牌的“客户端”(即调用CancellationToken.ThrowIfCancellationRequested的代码)中。
如果您需要区分用户取消(例如“停止”按钮)和超时,则可以使用以下替代方法抛出TimeoutException:
public async Task<int> Read( byte[] buffer, int? size=null, 
    CancellationToken userToken)
{
    size = size ?? buffer.Length;

    using( var cts = CancellationTokenSource.CreateLinkedTokenSource(userToken))
    {
        cts.CancelAfter( 1000 );
        try
        {
            var t =  stream.ReadAsync( buffer, 0, size.Value, cts.Token );
            try
            {
                await t;
            }
            catch (OperationCanceledException ex)
            {
                if (ex.CancellationToken == cts.Token)
                    throw new TimeoutException("read timeout", ex);
                throw;
            }
            return t.Result;
        }
        catch( Exception ex )
        {
            Debug.WriteLine( "exception" );
            return 0;
        }
    }
}

4
重要说明:Register 的目的在于与其他取消系统进行交互。 - Stephen Cleary
嗨@Noseratio!重新阅读那段话,我觉得是它与后面的句子结合在一起让我感到困惑。抱歉没有表达得更清楚。:-/ 我同意不要在取消回调中抛出异常是一个好主意。 :-) 然而,在技术层面上,如果回调由Register同步运行(引用的上下文),那么只有该回调被Register运行,并且引发的任何异常都会传播出Register。 "毕竟,可能有多个回调已注册...应该传播哪个异常..."似乎不适用于这种情况。 - Ben Gribaudo

5

CancellationToken.Register() 注册回调函数的异常处理比较复杂。:-)

在回调函数注册之前取消令牌

如果取消令牌在取消回调函数注册之前被取消,回调函数将由 CancellationToken.Register() 同步执行。 如果回调函数引发异常,则该异常将从 Register() 传播出来,因此可以使用 try...catch 来捕获它。

这种传播是您引用的语句所指的。为了提供上下文,以下是包含该引用的完整段落。

如果此标记已处于取消状态,则会立即同步运行委托。 委托生成的任何异常都将从此方法调用中传播出去。

“此方法调用”是指对 CancellationToken.Register() 的调用。(不要因为这段话而感到困惑。当我一段时间以前第一次阅读它时,我也感到困惑。)

在回调函数注册之后取消令牌

通过调用 CancellationTokenSource.Cancel() 取消

当通过调用此方法取消令牌时,它会同步执行取消回调函数。根据使用的 Cancel() 的重载方式,以下情况可能发生:

  • 将运行所有取消回调函数。引发的任何异常都将组合成一个从 Cancel() 传播出去的 AggregateException
  • 除非其中一个回调函数引发异常,否则将运行所有取消回调函数。如果回调函数引发异常,则该异常将从 Cancel() 传播出去(不包装在 AggregateException 中),并且任何未执行的取消回调函数将被跳过。

在任一情况下,与 CancellationToken.Register() 一样,可以使用普通的 try...catch 来捕获异常。

通过 CancellationTokenSource.CancelAfter() 取消

此方法启动倒计时定时器然后返回。当计时器到达零时,计时器会导致取消进程在后台运行。

由于 CancelAfter() 实际上并没有运行取消进程,因此取消回调函数异常不会从中传播出去。如果您想观察它们,您需要返回使用某些拦截未处理异常的方法。

在您的情况下,由于您正在使用 CancelAfter(),拦截未处理异常是您唯一的选择。try...catch 无法正常工作。

推荐

为了避免这些复杂性,如果可能的话,请不要允许取消回调抛出异常。

进一步阅读


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接