Teil 2: Gefahren der asynchronen Entwicklung

Richtig Async
Kommentare

Im ersten Teil des InkCasts haben Sie erfahren, wie Sie Async und Await einsetzen können, um asynchrone Aufrufe sauber zu implementieren. Wenn Sie die dort beschriebenen Vorgehensweisen beachten, kann eigentlich nichts passieren – eigentlich. Aber der Teufel steckt wie immer im Detail. Wie Sie Race Conditions – den schlimmsten, aber leider auch am häufigsten vorkommenden Patzer – vermeiden können, erfahren Sie in diesem Teil.

Teil 1: Async und Await richtig anwenden Teil 2: Gefahren der asynchronen Entwicklung

Der erste Teil des InkCasts endete mit einer Demo, deren Laufzeitverhalten ein Desaster darstellt. Warum? Weil die App sich manchmal richtig und manchmal falsch verhält, was das Testing bzw. Debugging zum Glücksspiel macht. Hinter Fällen wie in dem Video, das sich hinter Abbildung 1 verbirgt, steckt aber eigentlich immer derselbe Grund und der lautet: Async Sub (bzw. Async Void in C#).

Abb. 1: http://youtu.be/JK-3gYIKNN8

Schauen wir uns das dem Videoclip zugrunde liegende Programm ein wenig genauer an: Vereinfacht gesagt besteht das Beispiel aus zwei Teilen: einer Windows-8-Anwendung, deren Verhalten und Bedienung Sie im Clip sehen können, und einem Backend, einem Web-API-Dienst, der die Daten für diese Anwendung liefert. Bei diesem Web API handelt es sich im Übrigen um einen Self-Hosting-Service. Um diesen Datenlieferservice zu verwenden, braucht es also keinen Internet-Information-Service oder sonstige umfangreiche Installationen bzw. Voraussetzungen. Der Self-Hosting-Service, der wiederum den HTTP-basierenden RESTful-basierenden Service hält, ist im Grunde genommen nichts weiter als eine Windows-Forms-Anwendung, die zur Laufzeit zusammen mit der Windows-8-Anwendung gestartet wird. Die Daten generiert diese Anwendung auf einfache Weise zur Laufzeit aus dünner Luft, und sobald ein HTTP-Request von der Windows-8-Anwendung kommt, liefert das Web API sie als JSON aus. In der Praxis sieht das wie folgt aus: Der Web-API-Service verfügt über zwei so genannte Controller, die Demodaten liefern. Der eine wartet auf das Ausliefern seiner Daten unter dem URL /api/customers – er liefert eine Liste mit Kundendaten –, beim anderen funktioniert dasselbe mit dem URL /api/customerswithrevenue, und er liefert nicht nur Kundendaten, sondern auch Informationen über deren Umsätze. Diese Controller sind vergleichsweise einfach gehalten. Listing 1 zeigt beispielhaft den Code des Controllers, der die Customer-Daten einschließlich ihrer Umsätze zurückliefert.

Imports System.Web.Http

Public Class CustomerWithRevenueController
    Inherits ApiController

    Private Const GET_FOO_BAR_PRODUCE_TIME = 1000
    Private Shared myExceptionCounter As Integer = 0

    Public Function GetAllCustomers() As IEnumerable(Of Customer)

        Dim customersToReturn = Customer.GenerateRandomCustomers(100)
        Order.GenerateOrders(customersToReturn)
        Return customersToReturn

    End Function

End Class

Customers selbst ist dabei nicht nur das DTO (das Data Transfer Object, mit dem die Daten über den Draht zum Client gelangen), sondern stellt auch Code zur Verfügung, mit dem die Demodaten (mitsamt Umsatzdaten) generiert werden können. Interessant ist nun der Aufruf des Web API vom Windows-8-Client aus, denn das ist ja exakt der Code, der – wenn Sie sich den Clip nochmals aufmerksam anschauen – im einen Fall funktioniert und im anderen nicht. Mir sei bei Listing 2 übrigens verziehen, dass sich dieser Code in der Code-Behind der Windows-8-Client-Anwendung befindet und nicht, wie unter Anwendung des MVVM-Entwurfsmusters eigentlich richtig wäre, in einem ViewModel, oder noch besser: in einem DataLocator, der durch das ViewModel gesteuert würde. Hier habe ich der Einfachheit halber darauf verzichtet und rufe das Web API also direkt auf.

Imports System.Net.Http

' The Blank Page item template is documented at http://go.microsoft.com/fwlink/?LinkId=234238

''' 
''' An empty page that can be used on its own or navigated to within a Frame. 
''' 
Public NotInheritable Class MainPage
    Inherits Page

    Friend Const BASE_ADDRESS = "http://localhost:9000/"

    Private myCustomers As ObservableCollection(Of Customer)
    Private myCurrentViewModel As CustomerViewModel

    Private Async Sub ViewAllCustomersButton_Click(sender As Object, e As RoutedEventArgs)
        Dim allCustomersView As New AllCustomersView
        SubViewTarget.Content = allCustomersView
        Dim viewModel = Await CustomerViewModel.GetInstanceWithAllCustomersAsync
        viewModel.ViewTitel = "All Customers"
        allCustomersView.DataContext = viewModel
        If viewModel.Customers IsNot Nothing Then
            viewModel.SelectedCustomerIndex = 0
        End If
    End Sub

Kurz zur Funktionsweise: Bei der Klasse AllCustomersView handelt es sich um eine View (um ein Steuerelement, wenn Sie so wollen), die in den sichtbaren Bereich der Windows-8-Anwendung eingeblendet wird, sobald der Anwender rechts auf den Menüeintrag klickt. SuvViewTarget ist hier der ViewContainer, der dann die View anzeigt. CustomerViewModel stellt anschließend eine statische Funktion, die asynchron die entsprechenden Daten aufruft – GetInstanceWithAllCustomers wird deswegen auch „awaitet“. Die ermittelten Daten werden in die ViewModel-Objektvariable übernommen und schließlich der DataContext-Eigenschaft der allCustomersView zugewiesen. Auf diese Weise sieht der Anwender dann dank XAML-Binding eine Repräsentation der ermittelten Daten auf seinem Windows-8-Tablet. Die asynchrone Ermittlung der Daten ihrerseits ist nun ebenfalls sehenswert: Sie zeigt, wie der asynchrone Aufruf zum Ermitteln der Daten implementiert wird (Listing 3).

   Public Shared Async Function GetInstanceWithAllCustomersAsync() _ 
               As Task(Of CustomerViewModel)
        Dim client As New HttpClient
        Dim url = MainPage.BASE_ADDRESS & "api/customer"

        'Web API aufrufen! 
        'Let's call the Web API! 
        Dim results = Await client.GetAsync(url)
        Dim viewModToReturn As New CustomerViewModel
        viewModToReturn.Customers = New ObservableCollection(Of Customer)(
                Await results.Content.ReadAsAsync(Of IEnumerable(Of Customer)))

        Return viewModToReturn

    End Function

Bevor wir uns die Erklärung dieses Codes näher zu Gemüte führen, erinnern wir uns noch einmal an die Grundsätze zur Anwendung von Async/Await aus dem ersten InkCast-Teil:

  1. Stellen Sie sicher, dass es eine entsprechende asynchron aufrufbare Methode gibt. In der Regel endet solch eine Methode auf die Zeichenfolge „Async“. In unserem Beispiel implementieren wir die Methode GetInstanceWithAllCustomersAsync.
  2. Methoden, die Await verwenden, müssen mit „Async“ im Methodenrumpf gekennzeichnet sein. Wichtig: Bei überschriebenen Methoden ist es kein Problem, wenn die Methode der Basisklasse nicht mit „Async“ gekennzeichnet war. „Async“ an sich hat nämlich keine Funktion außer dem Compiler beizubringen, Await zuzulassen und den Rückgabetyp von Return innerhalb der Async-Methode von Task(Of t) in t zu ändern. In unserem Beispiel haben wir Async verwendet.
  3. Wenn Sie eigene „awaitbare“ Methoden formulieren, sollten diese deswegen unbedingt einen Task zurückliefern, wenn sie Sub (void) Character haben, ansonsten Task(Of t) (Task in C#). In unserem Fall liefern wir dementsprechend einen Task(Of CustomerViewModel) (Task in C#) zurück.
  4. In der Standardeinstellung brauchen Sie alle nach dem „Await“ folgenden Methoden nicht wie im manuellen Fall auf den UI-Thread zurückdelegieren, der Compiler schreibt den entsprechenden Code für Sie automatisch.
  5. Eine asynchrone Funktion, die als Task definiert ist, hat Sub (void) Character und benötigt kein Return. Soll die Funktion einen Rückgabewert vom Typ t zurückliefern, wird sie als Task(Of t) definiert. Diese Regel gilt in unserem Fall nicht.

In unserem Code haben wir uns exakt an diese Vorgaben gehalten, und mit dem Anzeigen der Daten ohne deren Umsatzranking gibt es im Beispiel auch überhaupt keine Probleme. Bei der zweiten Version sieht das aber ganz anders aus: Schauen wir uns nämlich den Teil des Codes an, der die etwas umfangreicheren Demodaten über das Web API stellt (Listing 4), werden wir außer der Tatsache, dass diese Web-API-Methode ein paar mehr Daten liefert, keine Abweichung zur bereits kennen gelernten Web-API-Methode finden.

Imports System.Web.Http

Public Class CustomerWithRevenueController
    Inherits ApiController

    Private Const GET_FOO_BAR_PRODUCE_TIME = 1000
    Private Shared myExceptionCounter As Integer = 0

    Public Function GetAllCustomers() As IEnumerable(Of Customer)

        Dim customersToReturn = Customer.GenerateRandomCustomers(100)
        Order.GenerateOrders(customersToReturn)
        Return customersToReturn

    End Function

End Class

Abgesehen davon, dass hier durch die Methode Order.GenerateOrders für die Zufallskundendaten auch Zufallsbestellungen generiert werden (Achtung: bei entsprechend vielen Bestellungen kann das schon mal ein paar Millisekunden länger dauern), schaut diese Methode nicht groß anders aus als die, die Sie schon kennen gelernt haben. Das Problem muss also im Client liegen. Ein Blick auf diese Methode verrät vergleichsweise schnell, was hier schief läuft (Listing 5).

 'Das ist die Methode, die ab und an fehlschlägt! 
    Private Sub TopBottomCustomers_Click(sender As Object, e As RoutedEventArgs)

        Dim allCustomersView As New AllCustomersView
        SubViewTarget.Content = allCustomersView

        myCurrentViewModel = New CustomersAndOrdersReportViewModel

        'Calls the Web Api and populates ViewModel. 
        LoadCustomersAndOrdersIntoViewModel()
        SomeWorkLoad(150)
        myCurrentViewModel.ViewTitel = "Customers with Revenue (Best/Worst Report)"
        allCustomersView.DataContext = myCurrentViewModel

        If myCurrentViewModel.Customers IsNot Nothing Then
            myCurrentViewModel.SelectedCustomerIndex = 0
        End If

    End Sub

Klickt der Anwender auf den Menüeintrag, funktioniert das Abrufen der Daten auf den ersten Blick noch genau wie im ersten Beispiel. Doch beim genaueren Hinsehen wird deutlich, dass wir hier etwas Entscheidendes vergessen haben: Die Methode, die wir aufrufen, um asynchron Daten vom Web Service abzurufen, wird nicht „awaitet“, und das gleicht in etwa dem gleichzeitigen Lostreten eines zusätzlichen Threads: Hier passiert jetzt ein so genannter „Fire und Forget“ mit der Methode LoadCustomerViewIntoViewModel(). So etwas ist beim Refaktorieren von synchronen zu asynchronen Methoden sehr leicht passiert! Die Frage bleibt aber: Wieso geht das in manchen Fällen gut und in manchen nicht? Erst wenn Sie verstanden haben, wieso dieser Fehler nur in manchen Fällen auftritt, werden Sie in der Fehlersuche im eigenen asynchronen Code wirklich trittsicher. Des Rätsels Lösung liegt hier in der Tatsache begründet, dass die nicht korrekt aufgerufene Methode direkt das ViewModel manipuliert, mit dem der Hauptthread (der UI-Thread) im Anschluss weiterarbeitet. Da es hier – wie im richtigen Leben – auch noch die Methode SomeWorkLoad gibt, die 150 Millisekunden Ausführungszeit in Anspruch nimmt, kommt es an dieser Stelle darauf an, was schneller vonstattengeht:

  1. Wenn LoadCustomerAndOrdersIntoViewModel schneller beendet ist, als SomeWorkLoad dauert, ist alles in Butter. Dann steht das ViewModel anschließend zur Verfügung, und das sind die Fälle, in denen der Anwender die Daten auf dem Bildschirm sieht.
  2. Wenn jedoch SomeWorkLoad schneller fertig ist, als der Rücksprung vom Laden der Daten über das WebAPI erfolgt, gibt es ein Problem. Die Methode schreibt die Daten nämlich erst in das ViewModel, nachdem dieses schon verwendet wird, und ein leerer Bildschirm ist für den Anwender die Folge.

Ganz klar: Wenn wir uns hier an die Regeln halten, klappt alles ohne Probleme. Listing 6 zeigt, welche Nachlässigkeiten wir korrigieren müssen.

Private Async Sub TopBottomCustomers_Click(sender As Object, e As RoutedEventArgs)

        Dim allCustomersView As New AllCustomersView
        SubViewTarget.Content = allCustomersView

        myCurrentViewModel = New CustomersAndOrdersReportViewModel

        'Calls the Web-Api and populates ViewModel
        Await LoadCustomersAndOrdersIntoViewModelAsync()
        SomeWorkLoad(150)
        myCurrentViewModel.ViewTitel = "Customers with Revenue (Best/Worst Report)"
        allCustomersView.DataContext = myCurrentViewModel

        If myCurrentViewModel.Customers IsNot Nothing Then
            myCurrentViewModel.SelectedCustomerIndex = 0
        End If

    End Sub

    Private Async Function LoadCustomersAndOrdersIntoViewModelAsync() As Task

        Dim client As New HttpClient
        Dim url = MainPage.BASE_ADDRESS & "api/customerwithrevenue"

        'Web API aufrufen! 
        'Let's call the Web API! 
        Dim results = Await client.GetAsync(url).ConfigureAwait(False)
        myCurrentViewModel.Customers = New ObservableCollection(Of Customer)(
                Await results.Content.ReadAsAsync(Of IEnumerable(Of Customer))())
        Debug.WriteLine("Loaded Viewmodel.")

    End Function

Um „Await“ in TopBottomCustomers_Click verwenden zu können, ergänzen wir das „Async“-Schlüsselwort. Wichtig: Nur in Ereignisroutinen (oder ereignisähnlichen Routinen) ist Aync Sub (Void Async in C#) erlaubt. Gleichzeitig verändern wir die Methode LoadCustomersAndOrdersIntoView so, dass sie einen Task zurückliefert, damit wir sie „awaiten“ können. In diesem Kontext hängen wir den Namenszusatz „Async“ hinter den Namen, damit sofort zu erkennen ist, dass es sich um eine „awaitbare“ Methode handelt. Beherzigen Sie diese Schritte, wenn Sie bei der asynchronen Entwicklung in Ihren eigenen Anwendungen solche Race-Konditionen entdecken!

Aufmacherbild: Button with update or syncrhonize icon on a modern computer keyboard. von Shutterstock / Urheberrecht: Bloomua

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -