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