Problema
Nello sviluppare l'esecuzione di un operazione asincrona con l'ausilio dei nuovi operatori async e await mi sono trovato in una situazione in cui avevo bisogno di cancellare o comunque invalidare una precedente operazione avviata.
In particolare il task asincrono consisteva nel validare la connessione ad un database SQL Server via ADO.NET, si voleva che da un apposita form di configurazione, tutte le volte fosse modificata la stringa, alla perdita del fuoco fosse verificata la connessione e quindi colorato lo sfondo di verde oppure di rosso a secondo che la stringa fosse valida o meno. Essendo il progetto sviluppato in VS. 2012 (con l'ausilio di .NET 4.5) ho deciso di dilettarmi nell'utilizzare gli operatori async e await.
Il problema è questo: cosa succede se l'utente modifica prima la stringa con un nome di server errato poi si accorge e quindi corregge il nome del server.
In effetti senza nessun particolare accorgimento il risultato che si ha è il seguente: quando l'utente digita la stringa corretta, pochi istanti dopo il task di verifica termina e la casella viene colorata di verde, ma essendo in esecuzione un precedente task con una stringa non valida, quando quest'ultimo termina perché la richiesta di connessione va in timeout, successivamente la casella viene erroneamente colorata di rosso.
Di seguito riporto un test che esemplifica il problema
Caso che evidenzia il problema
public
class
MultipleAsyncOps
{
readonly
List<Task>
m_tsks
=
new
List<Task>();
public
bool
TestResult { get; private
set; }
public
void
Test(bool
input)
{
TestAsync(input);
}
async
void
TestAsync(bool
input)
{
var
tsk
=
Task.Run(() =>
CheckTest(input));
m_tsks.Add(tsk);
TestResult
=
await
tsk;
}
bool
CheckTest(bool
input)
{
if (!input)
Thread.Sleep(100); //Simulate error with timeout
Console.WriteLine("Test completed - result: "
+
input);
return
input;
}
public
void
WaitForCompletion()
{
m_tsks.ForEach(tsk
=>
tsk.Wait());
}
}
[TestFixture]
public
class
TestMultipleAsyncOps
{
[Test]
public
void
WhenMultileOperationsCompletedTheResultsIsFromLastOperation()
{
var
mao
=
new
MultipleAsyncOps();
mao.Test(false);
mao.Test(true);
mao.WaitForCompletion();
Assert.IsTrue(mao.TestResult); //Failed
}
}
NOTA: Il membro di classe m_tsks e il metodo WaitForCompletion() servono solo ai fini della sincronizzazione per i test.
Il test fallisce e sulla console viene prodotto il seguente outupt:
Test completed - result: True
Test completed - result: False
Expected: True
But was: False
Soluzione – Utilizzo della classe CancellationTokenSource
Per risolvere questo problema si può utilizzare la classe CancellationTokenSource (http://msdn.microsoft.com/en-us/library/dd321629.aspx) attraverso il quale passare un token di cancellazione al task e segnalare quindi che l'operazione è stata cancellata.
Soluzione con CancellationTokenSource e TokenSource passato all'operazione
public
class
MultipleAsyncOpsV2
{
readonly
List<Task>
m_tsks
=
new
List<Task>();
CancellationTokenSource
m_cts;
public
bool
TestResult { get; private
set; }
public
void
Test(bool
input)
{
TestAsync(input);
}
async
void
TestAsync(bool
input)
{
if (m_cts
!=
null)
m_cts.Cancel(); //Cancel previous operation
m_cts
=
new
CancellationTokenSource();
var
tsk
=
Task.Run(() =>
TestSync(input, m_cts.Token));
m_tsks.Add(tsk);
var
result
=
await
tsk;
if (result.HasValue)
TestResult
=
result.Value; //if result has value the task is not cancelled
}
bool?
TestSync(bool
input, CancellationToken
tk)
{
bool
result
=
CheckTest(input);
if (tk.IsCancellationRequested)
{
Console.WriteLine("Cancellation requested");
return
null;
}
Console.WriteLine("Test completed - result: "
+
input);
return
result;
}
bool
CheckTest(bool
input)
{
if (!input)
Thread.Sleep(100); //Simulate error with timeout
return
input;
}
public
void
WaitForCompletion()
{
m_tsks.ForEach(tsk
=>
tsk.Wait());
}
}
[TestFixture]
public
class
TestMultipleAsyncOps
{
[Test]
public
void
WhenMultileOperationsCompletedTheResultsIsFromLastOperationV2()
{
var
mao
=
new
MultipleAsyncOpsV2();
mao.Test(false);
mao.Test(true);
mao.WaitForCompletion();
Assert.IsTrue(mao.TestResult); //Ok
}
} |
Il test ha esito positivo e sulla console viene prodotto il seguente outupt:
Test completed - result: True
Cancellation requested
Oltre a questo è possibile e consigliato (a mio avviso non in tutti i casi) passare il token di cancellazione all'operazione associata al task, il vantaggio è in termini di prestazioni e di carico del pool dei thread. In questo caso la cancellazione ha l'effetto di evitare l'avvio del task se questa è invocata dopo che il task è stato avviato ma prima che sia in esecuzione. Allora acquista significato il parametro throwOnFirstException, che determina il throw dell'eccezione OperationCanceledException. Per uniformare il comportamento è utile all'interno del nostro task verificare la cancellazione e quindi generare l'eccezione OperationCanceledException. Se immaginiamo inoltre che il nostro task preveda un ciclo o un certo numero di iterazioni, potremo interrompere prima l'esecuzione migliorando le performance oppure passare ad un metodo asincrono innestato, che lo supporta, il Token di cancellazione.
Soluzione con CancellationTokenSource e TokenSource passato all'operazione e al task
public
class
MultipleAsyncOpsV3
{
readonly
List<Task>
m_tsks
=
new
List<Task>();
CancellationTokenSource
m_cts;
public
bool
TestResult { get; private
set; }
public
void
Test(bool
input)
{
TestAsync(input);
}
async
void
TestAsync(bool
input)
{
if (m_cts
!=
null)
m_cts.Cancel(true); //Cancel previous operation with exception
m_cts
=
new
CancellationTokenSource();
var
tsk
=
Task.Run(() =>
TestSync(input, m_cts.Token), m_cts.Token);
m_tsks.Add(tsk);
try
{
TestResult
=
await
tsk;
}
catch (OperationCanceledException
e)
{
Console.WriteLine("Test cancelled : "
+
e);
}
}
bool
TestSync(bool
input, CancellationToken
tk)
{
bool
result
=
CheckTest(input);
if (tk.IsCancellationRequested)
throw
new
OperationCanceledException(tk);
Console.WriteLine("Test completed - result: "
+
input);
return
result;
}
bool
CheckTest(bool
input)
{
if (!input)
Thread.Sleep(100); //Simulate error with timeout
return
input;
}
public
void
WaitForCompletion()
{
foreach (var
tsk
in
m_tsks)
{
try
{
tsk.Wait();
}
catch (AggregateException)
{
}
}
}
}
[TestFixture]
public
class
TestMultipleAsyncOps
{
[Test]
public
void
WhenMultileOperationsCompletedTheResultsIsFromLastOperationV3()
{
var
mao
=
new
MultipleAsyncOpsV3();
mao.Test(false);
mao.Test(true);
mao.WaitForCompletion();
Assert.IsTrue(mao.TestResult); //Ok
}
} |
Il test ha esito positivo e sulla console viene prodotto il seguente outupt:
Test completed - result: True
Test cancelled : System.OperationCanceledException: The operation was canceled.
at ClassLibrary1.MultipleAsyncOpsV3.TestSync(Boolean input, CancellationToken tk) in c:\TEMP\ClassLibrary1\ClassLibrary1\MultipleAsyncOpsV3.cs:line 48
at ClassLibrary1.MultipleAsyncOpsV3.<>c__DisplayClass1.<TestAsync>b__0() in c:\TEMP\ClassLibrary1\ClassLibrary1\MultipleAsyncOpsV3.cs:line 27
at System.Threading.Tasks.Task`1.InnerInvoke()
at System.Threading.Tasks.Task.Execute()
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
at ClassLibrary1.MultipleAsyncOpsV3.<TestAsync>d__3.MoveNext() in c:\TEMP\ClassLibrary1\ClassLibrary1\MultipleAsyncOpsV3.cs:line 33
Vedi anche