Asynkron programmering i .NET

Inledning

– Vad är asynkron teknik?

Med några år i yrket som webbutvecklare har man som regel en bra insikt i vad dålig respons från webbsidor innebär. Tänk scenariot då användaren gjort en minimal ändring (exempelvis fyllt i ett formulär) på en webbsida. För att uppdatera var man tidigare hänvisad till att göra det med hela sidan, nu kan vi göra det med delar av den istället och avsevärt förbättra användarupplevelsen. Hur går det till? AJAX är nog den första tanke som dyker upp hos många? Visst, AJAX. AJAX är en asynkron teknik som hanterar datautbyte mellan server och klient med det definitiva målet att inte blockera klienten, eller med andra ord, se till att klienten inte behöver invänta servern. Asynkron teknik handlar om förbättrad prestanda.

Asynkron programmering och trådning är viktiga områden i realtidsprogrammering. Asynkron programmering kan, men behöver inte, använda trådning.

I den här posten behandlas följande ämnesområden

Asynkron programmering

Asynkrona processer innebär att förloppet sker oberoende av den huvudsakliga (eller andra) processen. Rent allmänt, ett C#-program börjar exekvera i Main();-metoden och avslutar när Main();-metoden är klar. Däremellan exekveras metoder och funktioner i tur och ordning, synkront. En process måste alltså invänta andra. Betrakta följande kodsnutt:

static void Main(string[] args)
{
    RunProcessOne();
    RunProcessTwo();
}

Som förväntat kommer RunProcessTwo() inte att exekvera förrän RunProcessOne() är färdig. Med andra ord kommer metoden RunProcessOne()att blockera exekveringen så länge som den kör.

Inom asynkron programmering anropas metoder så att de körs i bakgrunden utan att blockera tråden som anropar dem. Efter anrop återvänder exekveringsflödet omedelbart till den anropande tråden som i sin tur kan fortsätta och utföra andra arbetsuppgifter. Vanligtvis används Thread eller Task (vi kommer att återkomma till dessa senare).

Om vi i vårt fall skulle låta RunProcessOne() anropas asynkront så skulle exekveringsflödet omedelbart återvända till huvudtråden som i sin tur skulle starta RunProcessTwo().

Vi kan skapa vår egen tråd genom att använda Thread-klassen eller genom att använda något av de asynkrona programmeringsprinciperna (asynchronous patterns) som finns i .NET.  Det finns tre möjligheter:

  1. Asynchronous Programming Model (APM) pattern
  2. Event-based Asynchronous Pattern (EAP)
  3. Task-based Asynchronous Pattern (TAP)
    Task-baserad asynkron programmeringsprincip

Då ingen av de ovanstående metoderna 1 och 2 rekommenderas av Microsoft kommer de inte att diskuteras här.

[TOP]

Behövs trådning?

Om vi använder den asynkrona programmeringsprincipen som introducerades i .NET 4.5 som kommer vi sannolikt aldrig att behöva skapa egna trådar eftersom kompilatorn sköter allt det svåra arbetet åt oss.

Att skapa nya trådar är ett dyrbart arbete eftersom det tar tid. Om det inte är så att vi verkligen behöver kontrollera tråden kommer Task-based Asynchronous Pattern (TAP) respektive Task Parallel Library (TPL) att räcka för våra behov. TAP resp. TPL använder Task (vi kommer att diskutera klassen sernare). Allmänt gäller att Task använder tråd från ThreadPool.

 En thread pool är en collection trådar som skapats och styrs av .NET-ramverket. Om vi använder Task kommer vi i de flesta fallen inte behöva använda oss av ThreadPool, åtminstone inte direkt.

En Task kan exekvera i:

  1. i den aktuella tråden
  2. i en ny tråd
  3. i en tråd från från ThreadPool
  4. utan en tråd

Men om vi använder Task så behöver vi inte bekymra oss om hur trådar skapas eller hur de används eftersom ramverket sköter inre angelägenheter. Skulle det ändå vara så att vi behöver mer kontroll över tråden som t ex

  • att vi behöver tilldela tråden ett namn
  • att vi behöver tilldela tråden en prioritet
  • att vi har behov att bestämma om tråden ska exekvera i för- eller bakgrunden

Isåfall  kommer vi att behöva skapa vår egen tråd!

[TOP]

Skapa en tråd genom att använda Thread-klassen

Konstruktorn för System.Threading.Thread tar som parameter en funktionspekare (delegat) av typen

  1. ThreadStart: Funktionspekaren definierar en metod (void) utan parameter.
  2. ParameterizedThreadStart: Funktionspekaren definierar en metod (void) med en object-parameter.

Nedan förljer ett exempel som enkelt visar hur vi kan starta en ny tråd med Start()-metoden:

static void Main(string[] args)
{
    var thread = new Thread(DoTask);
    thread.Start();    //starta DoTask() metoden i en ny tråd

    //låt huvudtråden utföra andra uppgifter
}

static public void DoTask()
{
    //gör någonting i en ny tråd...
}

Man kan även använda lambda-uttryck istället för en namngiven metod

static void Main(string[] args)
{
    var thread = new Thread(() => {
       //gör någonting i en ny tråd...
    });
    thread.Start();  //starta en ny tråd

    //låt huvudtråden utföra andra uppgifter
}

Skulle det vara så att vi behöver kontrollera tråden i mera detalj skapar vi en instans av klassen Thread. På så sätt kan vi tilldela olika värden till instansens olika egenskaper:

static void Main(string[] args)
{
    Thread thread = new Thread(DoTask);

    thread.Name = "My new thread";  //tilldela tråden ett namn
    thread.IsBackground = false;  //kör tråden i förgrunden
    thread.Priority = ThreadPriority.AboveNormal;  //sätt trådens prioritet
    thread.Start();  //starta DoTask() i en ny tråd

    //låt huvudtråden utföra andra uppgifter
}

Men eftersom vi har instans av klassen Thread  så har vi också en referens till tråden. Med hjälp av den senare kan vi anropa olika metoder, exempelvis abort asom avslutar operationen som tråden håller på med, eller låta tråden göra färdigt sitt arbete genom att anropa join. Om vi anropar join på tråden kommer huvudtråden att blockeras till det att den anropade tråden har gjort klart sitt arbete.

Om vi vill skicka med data som inparameter så kan vi göra det med Start()-metoden. Eftersom parametern som ska tilldelas är av typen object är det nödvändigt att vi castar den till rätt datatyp.

static void Main(string[] args)
{
    Thread thread = new Thread(DoTaskWithParm);
    thread.Start("Passing string");  //starta DoTaskWithParm(...) i en ny tråd


    //låt huvudtråden utföra andra uppgifter
}

static public void DoTaskWithParm(object data)
{
    //vi måste casta data till rätt object
}

[TOP]

Nyckelorden async och await

Till vår hjälp för asynkron programmering har vi sedan ett tag tillbaka async och await inbygt i .NET. För att kunna använda await på en metod så måste metoden först vara dekorerad med nyckelordet (och modifieraren) async. await används före det att en asynkron metod anropas och gör att vidare exekvering skjuts upp, kontrollen återlämnas till den anropande tråden. Se nedanstående exempel:

//async-modifieraren används
private async static void CallerWithAsync()
{
    /*await används före ett metodanrop. Detta kommer att
     *skjuta upp exekveringen av CallerWithAsync()metoden
     *
med följden att kontrollen återförs till huvudtråden
     *som i sin tur kan göra andra saker.
     */

    var result = await GetSomethingAsync();
    //den här kodraden kommer inte att bli exekverad innan
    //GetSomethingAsync() blir klar

    Console.WriteLine(result);
}

async kan endast användas för funktioner vilkas returvärde är antingen Task eller void. Exempelvis får async inte användas på Main()-metoden (programmets startpunkt).  Vi kan inte heller göra await på vilka metoder som helst,  metoderna måste kunna returnera en awaitable datatyp:

  1. Task
  2. Task<T>
  3. Genom att skapa en special-awaitable-datatyp (en komplicerad historia som vi dock inte kommer att diskutera här).

[TOP]

Task-baserad asynkron programmerings-princip

(Task based Asynchronous Pattern)

Först och främst så behöver vi en asynkron metod som returnerar en System.Threading.Tasks.Task eller en Task<T>. En Task kan skapas på något av följande sätt:

  1. Metoden Task.Factory.StartNew(). I .NET-versioner före 4.5 (i .NET 4) var det denna metod som man använde för att skapa och schemalägga en task.
  2. Någon av metoderna Task.Run() eller Task.Run<T>(). Från och med .NET 4.5 är det denna metod som ska användas. Den är tillräcklig i de flesta fall.
  3. Metoden Task.FromResult(). Om resultatet redan beräknats kan vi använda denna metod för att skapa en task.

Task.Factory.StartNew() har dock fortfarande viktiga användningsområden i avancerade tillämpningar, se följande Microsoft-länk för mer information. För exempel som visar exempel på hur man kan skapa en  Task, se http://dotnetcodr.com/2014/01/01/5-ways-to-start-a-task-in-net-c/.

Skapa en Task och invänta densamma

I nedanstående exempel ska vi använda Task genom att använda Task.Run<T>(). Metoden kommer att köa det som ska utföras i ThreadPool och returnera en referens. Som vi kan se är följande steg nödvändiga för att skapa en asynkron Task med ursprung i en synkron metod:

  1. Antag att vi har en synkron metod som tar lite tid exekvera:

    static string Greeting(string name)
    {
        Thread.Sleep(3000);   
        return string.Format("Hello, {0}", name);
    }

  2. För att göra Greeting() asynkron måste vi kapsla in den i en asynkron metod. Vi tilldelar den nya metoden namnet GreetingAsync (det är en konvention att använda Async som suffix på namnet till asynkrona metoder):

    static Task<string> GreetingAsync(string name)
    {
        return Task.Run<string>(() =>
        {
             return Greeting(name);
        });
    }

  3. Efter ha gjort vår ursprungliga metod asynkron kan vi anropa den med await:

    private async static void CallWithAsync()
    {
        //andra uppgifter
        var result = await GreetingAsync("Bulbul");
        /*Det är fullt möjligt att ha flera "await" i samma "async"-metod.
         * 
         *  Exempelvis:
         *  var result1 = await GreetingAsync("Ahmed");
         *  var result2 = await GreetingAsync("Every Body");
         */
     
    Console.WriteLine(result);
    }

    När vi anropar CallWithAsync så kommer den att exekvera precis som en vanlig synkron metod fram till dess att processen når await som gör att metoden GreetingAsync()  inväntas. Under tiden återlämnas kontrollen till den som gjort anropet till CallWithAsync(), som i sin tur kan fortsätta sin exekvering.

    När GreetingAsync("Bulbul") är klar fortsätter CallWithAsync()-metoden efter await. I vårt fall betyder det Console.WriteLine(result).

  4. ContinueWith() är en metod i klassen Task som definierar kod som ska utföras direkt när tasken är färdig.

    private static void CallWithContinuationTask()
    {
        var t1 = GreetingAsync("Bulbul");
        t1.ContinueWith(t =>
        {
            var result = t.Result;
            Console.WriteLine(result);
        });
    }

    Vi kommer inte att behöva använda await om vi har ContinueWith() eftersom kompilatorn gör await på lämpligt ställe.

[TOP]

Göra await för flera asynkrona metoder

Betrakta följande kod:

private async static void CallWithAsync()
{
    string result = await GreetingAsync("Bulbul");
    string result1 = await GreetingAsync("Ahmed");
    Console.WriteLine(result);
    Console.WriteLine(result1);
}

Här gör vi await på flera asynchron metoder sekventiellt. Det andra anropet, GreetingAsync("Ahmed") kommer att ske när det första anropet mot GreetingAsync("Bulbul") är klart. Om result resp. result1 är oberoende av varandra så är inte sekventiell await-ing en bra idé! Isåfall är det bättre att anropa dem på ett ställe med en combinator så att exekveringen sker parallellt:

private async static void MultipleAsyncMethodsWithCombinators()
{
    Task<string> t1 = GreetingAsync("Bulbul");
    Task<string> t2 = GreetingAsync("Ahmed");

    await Task.WhenAll(t1, t2);

    Console.WriteLine("Finished both methods.\n " +"Result 1: {0}\n Result 2: {1}", t1.Result, t2.Result);
}

Här använder vi Task.WhenAll() som combinator. Task.WhenAll() skapar en task (en instans av Task<T> alltså) som avslutas när alla inskickade tasks har exekverat klart. Task<T> har ytterligare en combinator, Task.WhenAny() som returnera när någon av inskickad task är klar.

[TOP]

Felhantering – hantera Exceptions

Vi måste stoppa omsluta await i ett try { }-block för att hantera exceptions:

private async static void CallWithAsync()
{
    try
    {
        string result = await GreetingAsync("Bulbul");
    }
    catch (Exception ex)
    {
        Console.WriteLine("handled {0}", ex.Message);
    }
}

Skulle det vara så att vi har mer än en await i samma kodblock så kommer aldrig nästa await att exekvera. Om vi vill att alla metoder ska anropas även om någon kastar ett undantag, så måste vi anropa dem utan await. och invänta samtliga metoder genom användning av Task.WhenAll:

private async static void CallWithAsync()
{
    try
    {
        Task<string> t1 = GreetingAsync("Bulbul");
        Task<string> t2 = GreetingAsync("Ahmed");

        await Task.WhenAll(t1, t2);
    }
    catch (Exception ex)
    {
        Console.WriteLine("handled {0}", ex.Message);
    }
}

Även om samtliga task (t1, t2) kommer att få möjlighet att exekvera så kan vi bara se exceptions från t1. Det är inte säkert att det är därifrån ett exception härrör men t1 är första task i listan.

Ett sätt att komma år fel från samtliga task är att deklarera dem utanför try { }-blocket och därefter stämma av med respektive task’s IsFaulted-property (som kommer att vara true). Därefter kan vi komma åt Exception.InnerExceptions av taskarna (instanserna). Det finns dock ett bättre sätt:

static async void ShowAggregatedException()
{
    Task taskResult = null;
    try
    {
        Task<string> t1 = GreetingAsync("Bulbul");
        Task<string> t2 = GreetingAsync("Ahmed");

        await Task.WhenAll(t1, t2);
    }
    catch (Exception ex)
    {
        Console.WriteLine("handled {0}", ex.Message);

        foreach (var innerEx in taskResult.Exception.InnerExceptions)
        {
            Console.WriteLine("inner exception {0}", innerEx.Message);
        }
    }
}

[TOP]

Avbryta en Task

Om vi i det föregående använde en tråd från ThreadPool så var det inte möjligt att avbryta exekveringen. Task ger oss möjlgheten om en startad task tar in en CancellationTokenSource som parameter. Följande steg behöver utföras:

  1. Den asynkrona metoden måste ta in en parameter av typen CancellationTokenSource.

  2. Skapa en instans av CancellationTokenSource:
    var cts = new CancellationTokenSource();

  3. Skicka med instansen (CancellationToken) till den asynkrona metoden:
    Task<string> t1 = GreetingAsync("Bulbul", cts.Token);

  4. I den metod som har lång exekveringstid anropar vi ThrowIfCancellationRequested()-metoden i CancellationToken.

    static string Greeting(string name, CancellationToken token)
    {
        Thread.Sleep(3000);
        token. ThrowIfCancellationRequested();

        return string.Format("Hello, {0}", name);
    }

  5. Fånga OperationCanceledException där vi gjort await.

  6. Om vi avbryter exekveringen genom anrop till CancellationTokenSource kommer OperationCanceledException att kastas. Det är också möjligt att sätta hur lång tid det ska ta innan en exekvering skall avbrytas. (En mera detaljerad beskrivning av CancellationTokenSource kan återfinnas på följande länk: https://msdn.microsoft.com/en-us/library/system.threading.cancellationtokensource%28v=vs.110%29.aspx.)

    Nedan följer ett stycke exempelkod där vi avbryter exekvering efter 1 sekund:

    static void Main(string[] args)
    {
        CallWithAsync();
        Console.ReadKey();
    }

    async static void CallWithAsync()
    {
        try
        {
            CancellationTokenSource source = new CancellationTokenSource();
            source.CancelAfter(TimeSpan.FromSeconds(1));

            var t1 = await GreetingAsync("Bulbul", source.Token);
        }
        catch (OperationCanceledException ex)
        {
            Console.WriteLine(ex.Message);
        }
    }

    static Task<string> GreetingAsync(string name, CancellationToken token)
    {
        return Task.Run<string>(() =>
        {
            return Greeting(name, token);
        });
    }

    static string Greeting(string name, CancellationToken token)
    {
        Thread.Sleep(3000);
        token.ThrowIfCancellationRequested();

        return string.Format("Hello, {0}", name);
    }

[TOP]

Parallellprogrammering

I .NET 4.5 introducerades en klass Parallel (som är en abstraktion av Thread) som vi kan använda för att implementera parallellism. Parallellism skiljer sig från trådning såtillvida att all tillgänglig CPU-kraft används.

Dataparallellism

Om vi har en stor datamängd där vi vill utför någon slags parallell aktivitet på respektive datat kan vi utnyttja dataparallellism. Parallel-klassen har static metoderna For() respektive ForEach() för dataparallellism.
Exempel:

ParallelLoopResult result =
    Parallel.For(0, 100, async (int i) =>
    {
        Console.WriteLine("{0}, task: {1}, thread: {2}", i, Task.CurrentId, Thread.CurrentThread.ManagedThreadId);

        await Task.Delay(10);
    });

Om vi behöver stoppa parallella For()– respektive ForEach()-metoder kan vi skicka med ParallelLoopState. Baserat på tillståndet kan vi avsluta loopen:

ParallelLoopResult result =
    Parallel.For(0, 100, async (int i) =>
    {
        Console.WriteLine("{0}, task: {1}, thread: {2}", i, Task.CurrentId, Thread.CurrentThread.ManagedThreadId);

        await Task.Delay(10);
        if (i > 5) pls.Break();
    });

Task-parallesllism

Om vi vill köra flera aktiviteter samtidigt kan vi använda Task-parallesllism genom att anropa Invoke().Parallel.Invoke() tar som inparameter en array Action-funktionspekare (delegater):

static void ParallelInvoke()
{
    Parallel.Invoke(MethodOne, MethodTwo);
}

[TOP]

Källor

Asynchronous Programming in C# 5.0 Part 1: Understand Async and Await http://www.c-sharpcorner.com/UploadFile/dacca2/asynchronous-programming-in-C-Sharp-5-0-part-1-understand-async/

Asynchronous programming and Threading in C# (.NET 4.5) http://www.codeproject.com/Articles/996857/Asynchronous-programming-and-Threading-in-Csharp-N

Annonser