Come consideri CancellationToken.Register le eccezioni di callback?

Sto usando I / O asincrono per comunicare con un dispositivo HID, e vorrei lanciare un’eccezione catchable quando c’è un timeout. Ho il seguente metodo di lettura:

public async Task 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; } } } 

L’eccezione generata dal callback del Token non viene catturata da nessun blocco try / catch e non sono sicuro del perché. Ho pensato che sarebbe stato gettato in attesa, ma non lo è. C’è un modo per cogliere questa eccezione (o renderla catchable dal chiamante di Read ())?

EDIT: Quindi rileggo il documento su msdn , e dice “Qualsiasi eccezione generata dal delegato verrà propagata fuori da questa chiamata di metodo”.

Non sono sicuro di cosa significhi “propagato fuori da questa chiamata di metodo”, perché anche se sposto la chiamata .Register () nel blocco try, l’eccezione non viene ancora rilevata.

EDIT: Quindi rileggo il documento su msdn, e dice “Qualsiasi eccezione generata dal delegato verrà propagata fuori da questa chiamata di metodo”.

Non sono sicuro di cosa significhi “propagato fuori da questa chiamata di metodo”, perché anche se sposto la chiamata .Register () nel blocco try, l’eccezione non viene ancora rilevata.

Ciò significa che il chiamante del tuo callback di annullamento (il codice all’interno di .NET Runtime) non tenterà di rilevare eventuali eccezioni che potresti lanciare lì, quindi verranno propagate all’esterno del callback, su qualsiasi frame dello stack e contesto di sincronizzazione la richiamata è stata invocata su. Questo potrebbe mandare in crash l’applicazione, quindi dovresti davvero gestire tutte le eccezioni non fatali all’interno della tua callback. Pensalo come un gestore di eventi. Dopo tutto, potrebbero esserci più callback registrati con ct.Register() , e ognuno potrebbe lanciare. Quale eccezione avrebbe dovuto essere propagata allora?

Quindi, tale eccezione non verrà catturata e propagata nel lato “client” del token (cioè, al codice che chiama CancellationToken.ThrowIfCancellationRequested ).

Ecco un approccio alternativo per lanciare TimeoutException , se è necessario distinguere tra cancellazione dell’utente (es. Un pulsante “Stop”) e un timeout:

 public async Task 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; } } } 

Personalmente preferisco avvolgere la logica di cancellazione nel proprio metodo.

Ad esempio, dato un metodo di estensione come:

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

Puoi semplificare il tuo metodo fino a:

 public async Task 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 questo caso, poiché il tuo unico objective è eseguire un timeout, puoi renderlo ancora più semplice:

 public static async Task TimeoutAfter(this Task 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 } 

Quindi il tuo metodo può essere:

 public async Task 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; } } 

La gestione delle eccezioni per i callback registrati con CancellationToken.Register() è complessa. 🙂

Token cancellato prima della registrazione della richiamata

Se il token di cancellazione viene cancellato prima della registrazione di annullamento, il callback verrà eseguito in modo sincrono da CancellationToken.Register() . Se la richiamata solleva un’eccezione, quell’eccezione verrà propagata da Register() e quindi può essere catturata usando una try...catch .

Questa propagazione è ciò a cui si riferisce la frase che hai citato. Per il contesto, ecco il paragrafo completo di quella citazione.

Se questo token è già nello stato annullato, il delegato verrà eseguito immediatamente e in modo sincrono. Qualsiasi eccezione generata dal delegato verrà propagata al di fuori di questa chiamata di metodo.

“Questa chiamata di metodo” si riferisce alla chiamata a CancellationToken.Register() . (Non sentirti a disagio nel sentirti confuso da questo paragrafo: quando lo lessi per la prima volta, rimasi perplesso anch’io).

Token cancellato dopo la registrazione della richiamata

Annullato da Calling CancellationTokenSource.Cancel ()

Quando il token viene annullato chiamando questo metodo, i callback di annullamento vengono eseguiti in modo sincrono da questo metodo. A seconda del sovraccarico di Cancel() che viene utilizzato, o:

  • Verranno eseguite tutte le richiamate di annullamento. Eventuali eccezioni sollevate verranno combinate in una AggregateException propagata da Cancel() .
  • Tutte le richiamate di annullamento verranno eseguite a meno che e fino a quando non si genera un’eccezione. Se una richiamata genera un’eccezione, tale eccezione verrà propagata da Cancel() (non racchiusa in un’eccezione AggregateException ) e tutte le richiamate di annullamento non eseguite verranno saltate.

In entrambi i casi, come CancellationToken.Register() , è ansible utilizzare un normale try...catch rilevare l’eccezione.

Annullato da CancellationTokenSource.CancelAfter ()

Questo metodo avvia un timer per il conto alla rovescia, quindi ritorna. Quando il timer raggiunge lo zero, il timer fa in modo che il processo di cancellazione venga eseguito in background.

Poiché CancelAfter() realtà non esegue il processo di annullamento, le eccezioni callback di annullamento non vengono propagate al di fuori di esso. Se si desidera osservarli, è necessario ripristinare l’utilizzo di alcuni mezzi per intercettare le eccezioni non gestite.

Nella tua situazione, dal momento che stai usando CancelAfter() , l’intercettazione dell’eccezione non gestita è la tua unica opzione. try...catch non funzionerà.

Raccomandazione

Per evitare queste complessità, quando ansible, non consentire alle chiamate di annullamento di generare eccezioni.

Ulteriori letture

  • CancellationTokenSource.Cancel () – parla di come vengono gestite le eccezioni di callback di cancellazione
  • Capire le richiamate di annullamento – un post sul blog che ho recentemente scritto su questo argomento