可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
I am using async I/O to communicate with an HID device, and I would like to throw a catchable exception when there is a timeout. I've got the following read method:
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;
}
}
}
The exception thrown from the Token's callback is not caught by any try/catch blocks and I'm not sure why. I assumed it would be thrown at the await, but it is not. Is there a way to catch this exception (or make it catchable by the caller of Read())?
EDIT:
So I re-read the doc at msdn, and it says "Any exception the delegate generates will be propagated out of this method call."
I'm not sure what it means by "propagated out of this method call", because even if I move the .Register() call into the try block the exception is still not caught.
回答1:
EDIT: So I re-read the doc at msdn, and it says "Any exception the
delegate generates will be propagated out of this method call."
I'm not sure what it means by "propagated out of this method call",
because even if I move the .Register() call into the try block the
exception is still not caught.
What this means is that the caller of your cancellation callback (the code inside .NET Runtime) won't make an attempt to catch any exceptions you may throw there, so they will be propagated outside your callback, on whatever stack frame and synchronization context the callback was invoked on. This may crash the application, so you really should handle all non-fatal exceptions inside your callback. Think of it as of an event handler. After all, there may be multiple callbacks registered with ct.Register()
, and each might throw. Which exception should have been propagated then?
So, such exception will not be captured and propagated into the "client" side of the token (i.e., to the code which calls CancellationToken.ThrowIfCancellationRequested
).
Here's an alternative approach to throw TimeoutException
, if you need to differentiate between user cancellation (e.g., a "Stop" button) and a timeout:
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;
}
}
}
回答2:
I personally prefer to wrap the Cancellation logic into it's own method.
For example, given an extension method like:
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;
}
You can simplify your method down to:
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;
}
}
}
In this case, since your only goal is to perform a timeout, you can make this even simpler:
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
}
Then your method can be:
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;
}
}
回答3:
Exception handling for callbacks registered with CancellationToken.Register()
is complex. :-)
Token Cancelled Before Callback Registration
If the cancellation token is cancelled before the cancellation callback is registered, the callback will be executed synchronously by CancellationToken.Register()
. If the callback raises an exception, that exception will be propagated from Register()
and so can be caught using a try...catch
around it.
This propagation is what the statement you quoted refers to. For context, here's the full paragraph that that quote comes from.
If this token is already in the canceled state, the delegate will be
run immediately and synchronously. Any exception the delegate
generates will be propagated out of this method call.
"This method call" refers to the call to CancellationToken.Register()
. (Don't feel bad about being confused by this paragraph. When I first read it a while back, I was puzzled, too.)
Token Cancelled After Callback Registration
Cancelled by Calling CancellationTokenSource.Cancel()
When the token is cancelled by calling this method, cancellation callbacks are executed synchronously by it. Depending on the overload of Cancel()
that's used, either:
- All cancellation callbacks will be run. Any exceptions raised will be combined into an
AggregateException
that is propagated out of Cancel()
.
- All cancellation callbacks will be run unless and until one throws an exception. If a callback throws an exception, that exception will be propagated out of
Cancel()
(not wrapped in an AggregateException
) and any unexecuted cancellation callbacks will be skipped.
In either case, like CancellationToken.Register()
, a normal try...catch
can be used to catch the exception.
Cancelled by CancellationTokenSource.CancelAfter()
This method starts a countdown timer then returns. When the timer reaches zero, the timer causes the cancellation process to run in the background.
Since CancelAfter()
doesn't actually run the cancellation process, cancellation callback exceptions aren't propagated out of it. If you want to observer them, you'll need to revert to using some means of intercepting unhandled exceptions.
In your situation, since you're using CancelAfter()
, intercepting the unhandled exception is your only option. try...catch
won't work.
Recommendation
To avoid these complexities, when possible don't allow cancellation callbacks to throw exceptions.
Further Reading
- CancellationTokenSource.Cancel() - talks about how cancellation callback exceptions are handled
- Understanding Cancellation Callbacks - a blog post I recently wrote on this topic