Async/Await: modaler IsBusy-Dialog, bis Nebenläufigkeit abgeschlossen

    • VB.NET
    • .NET (FX) 4.5–4.8

    Es gibt 4 Antworten in diesem Thema. Der letzte Beitrag () ist von giri.

      Async/Await: modaler IsBusy-Dialog, bis Nebenläufigkeit abgeschlossen

      Häufig muss der User warten, wenn er eine asynchrone Operation auslöst, und darf währenddessen nicht weiter im Programm herumklicksen.
      Da ist das einfachste, einen modalen Dialog anzuzeigen - der bewirkt ja automatisch genau das: Eine Sperre des Programms, bis er (der Dialog) geschlossen wird.

      Also Anforderungen:
      • einen Dialog modal anzeigen, während parallel eine asynchrone Operation läuft
      • der Dialog soll automatisch schliessen, wenn die Operation durchgelaufen ist
      • Und auch andersrum: Wird der Dialog vom User geschlossen soll die Operation canceln
      Wir wollen das lösen mit dem Async/Await-Pattern, inklusive Cancellation.
      Als erstes benötigt man natürlich überhaupt eine langdauernde Methode. Und zwar eine, die Cancellation unterstützt - hab ich beispielhaft mal so umgesetzt:

      VB.NET-Quellcode

      1. Private Shared Function LongFunc(arg As Integer, tk As CancellationToken) As Integer
      2. Console.WriteLine($"LongFunc - Thread-ID: {Thread.CurrentThread.ManagedThreadId}")
      3. For i = 0 To 99
      4. Thread.Sleep(20)
      5. tk.ThrowIfCancellationRequested()
      6. Next
      7. Return arg * 2
      8. End Function
      Also 100 mal schläft der Thread 20ms, um dann jeweils tk.ThrowIfCancellationRequested() aufzurufen.
      Das ist nämlich der Cancellation-Mechanismus: Von ausserhalb des Threads kann das Token auf Cancel gesetzt werden, und im Thread kann es dann eine Exception werfen.
      Das Exception-Werfen passiert also nicht von selbst, sondern des Tokens SelbstPrüfung muss in kurzen Abständen immer wieder aufgerufen werden!
      Der Async-Pattern ermöglicht nun, im Main-Thread diese Exception zu fangen.
      Er kann da übrigens alle Exceptions fangen. Also zusammen mit Cancellation ist threadübergreifendes Exception-Handling mit abgehandelt.

      Bleibt noch eine Hakeligkeit: Wie kriegen wir einen Dialog modal geöffnet und gleichzeitig eine asynchrone Operation gestartet?
      Öffnen wir den Dialog modal gehts erst weiter, wenn er geschlossen ist - keine Möglichkeit, noch Code auszuführen, der die async-Op dann startet.
      Andersrum genauso: Die Methode, die mit Await die async-Op startet, wartet bis fertig - keine Möglichkeit, den Dialog noch zu öffnen.

      Aber es gibt einen eleganten Trick: Mit Control.BeginInvoke() kann man das Öffnen des Dialogs in die Windows-MessageQueue stellen, und dann noch weiteren Code ausführen, bevor in der MessageQueue das DialogÖffnen dran kommt.
      (Control.BeginInvoke() ist eigentlich ein alter Bekannter, mit dem man von NebenThreads aus Aktionen an den MainThread delegiert: Control.BeginInvoke()
      Hier aber rufen wir es vom MainThread selbst auf - das ist eigenartig: Eine Aktion, delegiert vom MainThread an den MainThread.)
      Ich hab dazu im BusyDialog-Form eine Shared Methode gebastelt, die sich darum kümmert:

      VB.NET-Quellcode

      1. Partial Public Class dlgIsBusy : Inherits Form
      2. Public Sub New()
      3. InitializeComponent()
      4. End Sub
      5. Public CancellationToken As CancellationToken
      6. ''' <summary>returns a modal opened dlgIsBusy-Instance. dlgIsBusy provides a CancellationToken for usage in async methods.
      7. ''' It cancels, when the user closes the dialog. Note: the caller should dispose the dlgIsBusy-Instance.</summary>
      8. Public Shared Function ShowBusyDialog() As dlgIsBusy
      9. Dim cts = New CancellationTokenSource()
      10. Dim dlg = New dlgIsBusy() With {.CancellationToken = cts.Token}
      11. Dim dummi = dlg.Handle ' enforces to create a Handle. Which is required for successfully call .BeginInvoke()
      12. dlg.BeginInvoke(Sub()
      13. dlg.ShowDialog()
      14. cts.Cancel()
      15. cts.Dispose()
      16. End Sub)
      17. Return dlg
      18. End Function
      19. End Class
      Also sie erstellt und returnt ein IsBusyDialog-Objekt, und kümmert sich dabei um drei Dinge:
      • eine CancellationTokenSource erzeugen, und deren Token als von aussen abrufbares Feld bereitstellen (zeilen #12,13).
      • den Dialog per .BeginInvoke() modal anzeigen (#15-19)
        zuvor (#14) muss das Windows-Handle abgerufen werden - ist komisch, aber ist so, sonst funktioniert .BeginInvoke nicht.
      • Nach .ShowDialog() wird die TokenSource gecancelt (#17) - das war ja so geplant: Wenn der User den Dialog schließt, soll die async-OP gecancelt werden.
        (Ausserdem wird sie disposed (#18), weil sie ist ja disposable)

      Jo, dank dieser Unterstützung kann es im MainForm ganz straight-forward zugehen:

      VB.NET-Quellcode

      1. Imports System.Threading
      2. Public Class Form1
      3. Private Async Sub btnLongFunc_Click(sender As Object, e As EventArgs) Handles btnLongFunc.Click
      4. Console.WriteLine($"MainThread-ID: {Thread.CurrentThread.ManagedThreadId}")
      5. Using dlg = dlgIsBusy.ShowBusyDialog()
      6. Try
      7. Dim rslt = Await AsyncHelper.Run(Function() LongFunc(42, dlg.CancellationToken))
      8. Console.WriteLine($"Done! The result is: {rslt}")
      9. Catch ex As OperationCanceledException
      10. Console.WriteLine($"operation cancelled!")
      11. End Try
      12. End Using
      13. End Sub
      14. Private Shared Function LongFunc(arg As Integer, tk As CancellationToken) As Integer
      15. Console.WriteLine($"LongFunc - Thread-ID: {Thread.CurrentThread.ManagedThreadId}")
      16. For i = 0 To 99
      17. Thread.Sleep(20)
      18. tk.ThrowIfCancellationRequested()
      19. Next
      20. Return arg * 2
      21. End Function
      22. End Class
      Der Dialog wird geöffnet im Using-Block (#7). Das bedeutet bekanntlich, dass er zuverlässig auch wieder geschlossen wird - nämlich wenn der UsingBlock verlassen wird.
      Der TryCatch kann nun mögliche Exceptions behandeln, geworfen aus der asynchronen Methode. Hier nur die OperationCanceledException - weitere Exceptions werden nicht erwartet, und können daher auch nicht behandelt werden.
      Jedenfalls wird so unterschieden zwischen erfolgreicher async-Op (#10) und Cancellation (#12).
      Beachte, wie das CancellationToken des Dialogs beim Starten der ansync-Op mit eingeht (#9).

      Eine Bitterlichkeit ist ebenfalls in #9 angedeutet: Dort wird nicht wie eiglich zu erwarten wäre: Await Task.Run() aufgerufen. Stattdessen Await AsyncHelper.Run() - eine selbstgebastelte Helper-Klasse.
      Der Grund liegt darin, dass Task.Run() buggy ist! Es transportiert nur dann threadübergreifende Exceptions korrekt in den MainThread, wenn das Projekt als Release kompiliert wird. In Debug-Versionen funktioniert das Feature einfach nicht.
      Also hab ich folgende Helper-Klasse basteln müssen, die kann, was Task.Run nicht kann:

      VB.NET-Quellcode

      1. Imports System.Threading.Tasks
      2. Public Class AsyncHelper
      3. ''' <summary>Queues 'func' to the thread pool and returns a Task(of TResult), that represents that operation.</summary>
      4. Public Shared Function Run(Of TResult)(func As Func(Of TResult)) As Task(Of TResult)
      5. Dim tcs = New TaskCompletionSource(Of TResult)()
      6. Task.Run(Sub() ' invoke the long lasting func() in parallel...
      7. ' Setting either the result or the exception triggers the main-thread to re-enter and continue after the 'await'-keyword
      8. Try
      9. tcs.SetResult(func())
      10. Catch ex As Exception
      11. tcs.SetException(ex)
      12. End Try
      13. End Sub)
      14. Return tcs.Task ' but the Completion itself returns immediately!
      15. End Function
      16. ''' <summary>Queues 'action' to the thread pool and returns a Task, that represents that operation.</summary>
      17. Public Shared Function Run(action As Action) As Task
      18. Dim tcs = New TaskCompletionSource(Of Boolean)()
      19. Task.Run(Sub()
      20. Try
      21. action()
      22. tcs.SetResult(True)
      23. Catch ex As Exception
      24. tcs.SetException(ex)
      25. End Try
      26. End Sub)
      27. Return tcs.Task
      28. End Function
      29. End Class
      Es sind zwei Überladungen, einmal für mit Rückgabewert (Func(Of T)), einmal ohne (Action)

      Die Sourcen enthalten auch eine c#-Version



      Dank an @shad - er hat den Trick mit .BeginInvoke() eingebracht
      Und auch an @exc-jdbi - ich hätte es nie gemerkt, dass CancellationTokenSource IDisposable ist
      Dateien

      Dieser Beitrag wurde bereits 11 mal editiert, zuletzt von „ErfinderDesRades“ ()

      @giri Willkommen im Forum. :thumbup:
      Der Busy-Dialog weiß nichts von der Arbeit, während der er angezeigt wird.
      Der Prozess, der abläuft, wird im Beispiel vom @ErfinderDesRades durch die Prozedur LongFunc() simuliert, erstes Snippet in Post #1.
      Dein äquivalent dazu musst Du mit Deinem lange dauernden Prozess selbst implementieren.
      Falls Du diesen Code kopierst, achte auf die C&P-Bremse.
      Jede einzelne Zeile Deines Programms, die Du nicht explizit getestet hast, ist falsch :!:
      Ein guter .NET-Snippetkonverter (der ist verfügbar).
      Programmierfragen über PN / Konversation werden ignoriert!
      @giri: Das erforderte ja eine Kommunikation des IsBusyDialogs mit dem parallelen Prozess - solch ist in den hier definierten Anforderungen erstmal nicht vorgesehen.
      Hier gehts nur drum, irgendwas anzuzeigen, was den User zum Warten auffordert, und weiteres Rumgeklickse verhindert.
      Einzig den Vorgang abbrechen kann er.

      Die Kommunikation müsste ja auch threadübergreifend erfolgen - das erzeugt einigen Mehr-Aufwand.
      In Splash-Screen mit Status-Meldungen ist solche Kommunikation gezeigt - aber eben als Splash-Screen, nicht als IsBusyDialog.