Autor Beitrag
lapadula
ontopic starontopic starontopic starontopic starontopic starontopic starontopic starhalf ontopic star
Beiträge: 180
Erhaltene Danke: 10



BeitragVerfasst: Di 06.11.18 22:54 
Hallo, ich würde mal gerne best practices im Bezug auf responsive UI mit Async / Await lernen.

Ich lese oft, dass die Schlüsselwörter Async und Await nicht unbedingt einen neuen Thread starten, sondern mit der Message Queue arbeitet, stimmt das?
Und inwiefern macht es einen unterschied für mich, ob das im eigenen Thread abläuft oder die Message Queue abgearbeitet wird, bezüglich Performance usw.

Mal angenommen ich habe eine Methode, welche damit beschäftigt ist ein Word-Dokument zu erstellen, dies dauert eine Zeit und der Benutzer merkt, dass das UI nicht reagiert. Kann ich nun den ganzen Rumpf in Task.Run( () => {} ); packen, die Methode als Async deklarieren und awaiten?

Was könnte dabei schief gehen, ich höre oft etwas von deadlock, wie entsteht sowas?
Palladin007
ontopic starontopic starontopic starontopic starontopic starontopic starontopic starofftopic star
Beiträge: 1282
Erhaltene Danke: 182

Windows 11 x64 Pro
C# (Visual Studio Preview)
BeitragVerfasst: Mi 07.11.18 01:51 
Viele, viele Fragen, die leider nicht ganz so leicht zu erklären sind :)
Ich lass Links Mal raus, wenn Du Fragen hast, frag Google oder hier :P Sind mir einfach zu Viele.
Außerdem sind die Code-Beispiele alles Andere als empfehlenswert. Die sollen kurz und klein sein, aber das entsprechende Verhalten zeigen, sie sind kein BestPractice.
Ich hoffe, es wird halbwegs klar, die das alles zusammen hängt :D

Zitat:
Mal angenommen ich habe eine Methode, welche damit beschäftigt ist ein Word-Dokument zu erstellen, dies dauert eine Zeit und der Benutzer merkt, dass das UI nicht reagiert. Kann ich nun den ganzen Rumpf in Task.Run( () => {} ); packen, die Methode als Async deklarieren und awaiten?

Kannst Du und das würde auch den Effekt erzielen, den Du haben willst: Das Word-Dokument wird in einem eigenen Thread erstellt.
Es kann an der Stelle aber sinnvoll sein, den Task als "LongRunning" (wenn die Arbeit länger als eine Sekunde dauert, einfach mal danach googlen) zu markieren, dann wird der Thread nicht vom Pool genommen.
Außerdem musst Du bedenken, dass Du in diesem Task keine Änderungen an der UI anstoßen darfst, denn die sind nur aus dem einen UI-Thread erlaubt. Solche Änderungen müssen nach dem await passieren.

Zitat:
Ich lese oft, dass die Schlüsselwörter Async und Await nicht unbedingt einen neuen Thread starten, sondern mit der Message Queue arbeitet, stimmt das?

Ein Task besteht im Prinzip aus einem Code-Block, für den irgendwo ein ContinueWith aufgerufen wurde, auf den ein ContinueWith aufgerufen wurde, etc. - einfach dargestellt.
Davon merkst Du nichts, die ganze Umformung übernommt der Compiler über das await-Schlüsselwort.
Im Grunde hast Du also viele Aufgaben, die nacheinander ausgeführt werden sollen und die ihren Kontext teilen. Wann jede Aufgabe ausgeführt wird, entscheidet dann nicht mehr dein Code, sondern .NET im Hintergrund, das heißt also auch, dass die Arbeit zwischen zwei Aufgaben angehalten werden kann, um z.B. die UI zu aktualisieren.

ausblenden C#-Quelltext
1:
2:
3:
4:
5:
6:
7:
// Aus dem:
await DoSomethingAsync();
DoSomethingSync();
// Wird sehr, sehr grob zusammengefasst das:
var task1 = DoSomethingAsync();
var task2 = task1.ContinueWith(_ => DoSomethingSync());
return task2;

Natürlich sieht die Realität sehr viel komplexer aus, da gibt's nicht nur etwas andere Aufrufe, sondern ganze Klassen (AsyncStateMachines), die das alles managen. Das macht für das Grund-Verständnis aber keinen Unterschied, daher spare ich mir das.

Ein neuer Task (Task.Run oder über die Factory) ist tatsächlich ein neuer Task, der auf einem eigenen Thread ausgeführt wird, wenn es nicht anders eingestellt wurde. Ob eine Methode einen neuen Task erstellt, kannst Du von außen aber nicht sehen.
Jeder ContinueWith-Aufruf ist aber kein eigener Task mit eigenem Thread, das sind die Aufgaben, die nach dem "echten" Task und möglichst im ursprünglichen Thread laufen sollen - sie werden dahin zurück synchronisiert. Dafür gibt es den SynchronizationContext, der entscheidet, wo jeder ContinueWith-Aufruf ausgeführt wird. Bei WinForms und WPF gibt es einen solchen SynchronizationContext, der die Aufgaben dann an den Dispatcher vom jeweiligen UI-Framework übergibt, der die Aufgabe dann irgendwann ausführt, wenn die UI gerade Zeit hat. Gibt es keinen SynchronizationContext, dann entscheidet der TaskScheduler darüber. Das kann ein eigener Thread sein, es kann aber auch der selbe Thread sein, wo der vorherige Task lief.

Nun stell dir vor, Du hast eine async-Methode, auf die Du keinen Einfluss hast.
Wenn diese Methode einen neuen Task startet und Du awaitest darauf, dann landet der folgende Code am SynchronizationContext, also entweder beim Dispatcher (wenn es einen gibt) oder über den TaskScheduler im selben Thread wie der Task.
Wenn diese Methode aber keinen neuen Task startet, sondern einfach synchron arbeitet, dann läuft auch der folgende Code synchron im selben Thread weiter.

Beispiel-Console-Code:
ausblenden volle Höhe C#-Quelltext
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
static async Task TestMethod()
{
    Console.WriteLine("Ausführung startet eigenen Task:");
    Console.WriteLine("=================================");

    // Das ist für die Demonstration, so viele Freiheiten hast Du natürlich nicht:
    _externalLongRunningMethodCreatesNewTask = true;

    // Der tatsächliche Aufruf:
    await DoSomething();

    Console.WriteLine();
    Console.WriteLine("Ausführung startet keinen eigenen Task:");
    Console.WriteLine("=================================");

    // Jetzt ohne eigenen Task:
    _externalLongRunningMethodCreatesNewTask = false;
    await DoSomething();
}
static async Task DoSomething()
{
    Console.WriteLine(DateTime.Now.ToLongTimeString() + " | Vor Ausführung: CurrentManagedThreadId = " + Environment.CurrentManagedThreadId);
    await ExternalLongRunningMethod();
    Console.WriteLine(DateTime.Now.ToLongTimeString() + " | Nach Ausführung: CurrentManagedThreadId = " + Environment.CurrentManagedThreadId);
}

// Der externe Code, auf den Du keinen Einfluss hast:
static bool _externalLongRunningMethodCreatesNewTask = true;
static async Task ExternalLongRunningMethod()
{
    var wait = 2000;

    if (_externalLongRunningMethodCreatesNewTask)
    {
        await Task.Run(() =>
        {
            Thread.Sleep(wait);
        });
    }
    else
        Thread.Sleep(wait);
}

Führ das ein paar Mal aus ;)
Du kannst auch mehr awaits in DoSomething schreiben, je nachdem, was das ist, hast Du danach einen anderen Thread - oder den Selben, wie vorher.
Du kannst natürlich auch nicht sicher sein, was das für ein Thread ist, je komplexer die Methode wird, desto häufiger wirst Du einen anderen Thread haben, als Du erwartest.

Das ist der Unterschied, weshalb ein Task kein Thread ist.
Ein Task kann auf einem, aber auch auf mehreren Thread laufen, aber er muss nicht und das kann sich auch erst zur Laufzeit entscheiden.
Der Task ist sozusagen die Verwaltung der einzelnen Arbeitsschritte und wo diese Schritte abgearbeitet werden, entscheidet sich anhand des Kontextes (z.B. über den vorherigen Task) und der Umgebung (SynchronizationContext).

Zitat:
Und inwiefern macht es einen unterschied für mich, ob das im eigenen Thread abläuft oder die Message Queue abgearbeitet wird, bezüglich Performance usw.

In einem Task muss es nicht zwangsläufig in einem eigenen Thread laufen, je nachdem, wie der erste Task aussieht, kann das auch komplett synchron ablaufen. Das Beispiel dazu findest Du ja oben.
Daher sollte der lang andauernde Teil der Arbeit in einem eigenen Task landen (ggf. LongRunning) und der ganze Rest, wie z.B. UI-Aktualisierung läuft nach dem await. Es kann auch später noch ein lang dauernder Teil kommen, das ist egal, solange er die UI nicht ändert.
Wenn Du aber irgendeine kurze Arbeit in einem eigenen Thread hast, darauf awaitest und danach den lange dauernden Kram, hast Du nichts gewonnen, denn das landet wieder im UI-Thread.

Beispiel-WPF-Code:
ausblenden XML-Daten
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <StackPanel HorizontalAlignment="Center" Margin="50">
        <TextBlock x:Name="timeBox" Margin="0,0,0,50" />

        <Button Click="Button_Click1">Test1</Button>
        <Button Click="Button_Click2">Test2</Button>
    </StackPanel>
</Window>

Code-Behind dazu:
ausblenden volle Höhe C#-Quelltext
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
public partial class MainWindow : Window
{
    private readonly Timer _refreshTimeTextBoxTimer;

    public MainWindow()
    {
        InitializeComponent();
            
        // Das sorgt dafür, dass die Uhrzeit alle 100 Millisekunden aktualisiert wird.
        // Daran erkennst Du, ob der UI-Thread blockiert ist oder nicht.
        _refreshTimeTextBoxTimer = new Timer(_ => Dispatcher.Invoke(() => timeBox.Text = DateTime.Now.ToString("HH:mm:ss:ffff")), null0100);
    }

    private async void Button_Click1(object sender, RoutedEventArgs e)
    {
        ShortRunning();
        await Task.Run(() => LongRunning());
        MessageBox.Show("Test");
    }
    private async void Button_Click2(object sender, RoutedEventArgs e)
    {
        await Task.Run(() => ShortRunning());
        LongRunning();
        MessageBox.Show("Test");
    }

    private void LongRunning()
    {
        Thread.Sleep(2000);
    }
    private void ShortRunning()
    {
        Thread.Sleep(10);
    }
}

Wenn Du Test1 klickst, wird die Uhrzeit fleißig weiter aktualisiert, bis die 2 Sekunden rum sind. Auch, wenn die MessageBox angezeigt wird, wird sie weiter aktualisiert, darum kümmert sich WPF im Hintergrund.
Wenn Du Test2 klickst, tut die Uhrzeit erst Mal 2 Sekunden nichts, bis dann die MessageBox angezeigt wird und die Uhrzeit wieder aktuell ist.


Du solltest wirklich lang dauernde Aufgaben aber nicht zwingend in einem eigenen Task haben, für sehr lange dauernde Aufgaben (mehrere Sekunden, bis Minuten) kann es aber durchaus sinnvoll sein, einen eigenen Thread zu starten.
Bei kurzen Aufgaben macht das keinen Sinn, da ein neuer Thread immer viel Overhead verursacht und auch im Bezug auf die System-Ressourcen sehr teuer ist. Ein Task kann da sehr gut optimieren. Wenn so ein Thread aber erst Mal läuft, dann ist er performanter als ein Task.

Zitat:
Was könnte dabei schief gehen, ich höre oft etwas von deadlock, wie entsteht sowas?

Das Deadlock-Thema gibt's nicht nur bei Tasks, das gibt's immer, wenn Du lock (oder Monitor) verwendest.
Bei Tasks ist der Zusammenhang aber zugegeben schwer zu erkennen und tritt eigentlich nur auf, wenn Du einen Dispatcher hast (also bei UI).

Nehmen wir folgenden Code:

ausblenden C#-Quelltext
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
private async void Button_Click(object sender, RoutedEventArgs e)
{
    await DoSomething();
}
private async Task DoSomething()
{
    var zahl = await Task.Run(() =>
    {
        return 10;
    });

    MessageBox.Show("Did " + zahl);
}


Der läuft problemlos durch, und Du bekommst eine MessageBox mit "Did 10".

Aber was passiert nun, wenn Du die Beendigung von DoSomething nicht mit await abwartest, sondern der Wait-Methode? Die wartet synchron weiter, müsste da nicht trotzdem die selbe MessageBox kommen?
Falsch! Du hast ein Deadlock.

Die Abläufe sind grob die Folgenden:
WPF ruft die DoSomething-Methode aus dem UI-Thread auf und wartet synchron darauf, der UI-Thread wird also dadurch blockiert.
In der DoSomething-Methode wird nun der Task gestartet, er wird auf einem eigenen Thread ausgeführt und gibt 10 zurück.
Der folgende Code nach dem await (MessageBox.Show) wird dem SynchronizationContext übergeben und der wiederum übergibt das an den Dispatcher. Der Dispatcher führt das dann irgendwann auf dem UI-Thread aus, wenn der gerade Zeit hat.

Erkennst Du das Problem?
Beim Aufruf von DoSomething blockierst Du mit dem Wait() den UI-Thread.
In DoSomething hast Du aber eine Aufgabe für den UI-Thread.
Bevor diese Aufgabe beendet ist, wird DoSomething nicht beendet und der UI-Thread nicht frei gegeben.
Die Aufgaben, die auf den UI-Thread wartet, kann also nur weiter geführt werden, wenn die selbe Aufgabe auf eben diesem UI-Thread abgearbeitet wurde.

Für diesen Beitrag haben gedankt: lapadula
lapadula Threadstarter
ontopic starontopic starontopic starontopic starontopic starontopic starontopic starhalf ontopic star
Beiträge: 180
Erhaltene Danke: 10



BeitragVerfasst: Mi 07.11.18 21:31 
Vielen Dank für die ausführliche Erklärung!

Wenn ich nun aus einem anderen Thread auf den UI Thread zugreifen möchte und da die UI aktualisieren möchte, kann ich dann wie du gezeigt hast den Dispatcher dafür benutzen? Und lassen sich so nicht auch die Deadlocks verhindern?
Palladin007
ontopic starontopic starontopic starontopic starontopic starontopic starontopic starofftopic star
Beiträge: 1282
Erhaltene Danke: 182

Windows 11 x64 Pro
C# (Visual Studio Preview)
BeitragVerfasst: Do 08.11.18 00:58 
Zugriffe auf den UI-Thread haben nichts mit einem Deadlock zu tun.

Das await sorgt dafür, dass der Code nach dem await wieder in den UI-Thread synchronisiert wird.
Einen Deadlock hast Du, wenn zwei Threads aufeinander warten und dadurch nie weiter arbeiten können.

Wenn Du nun einen Thread hast, der aktiv irgendetwas an der UI ändern soll, dann ist der beste Weg über die Dispatcher.Invoke-Methode, die müsste es so oder so ähnlich auch bei WinForms geben. Diese Invoke-Methode sorgt dafür, dass die übergebene Aufgabe irgendwann auf dem UI-Thread ausgeführt wird.

Für diesen Beitrag haben gedankt: lapadula