Was genau passiert, wenn ein Thread innerhalb einer while-Schleife auf eine Aufgabe wartet?

10

Nachdem ich mich eine Weile mit dem asynchronen / wartenden Muster von C # befasst hatte, wurde mir plötzlich klar, dass ich nicht wirklich weiß, wie ich erklären soll, was im folgenden Code passiert:

async void MyThread()
{
    while (!_quit)
    {
        await GetWorkAsync();
    }
}

GetWorkAsync()Es wird angenommen, dass ein Wartender zurückgegeben wird, Taskder möglicherweise einen Threadwechsel verursacht oder nicht, wenn die Fortsetzung ausgeführt wird.

Ich wäre nicht verwirrt, wenn das Warten nicht in einer Schleife wäre. Ich würde natürlich erwarten, dass der Rest der Methode (dh die Fortsetzung) möglicherweise auf einem anderen Thread ausgeführt wird, was in Ordnung ist.

Innerhalb einer Schleife wird das Konzept des "Restes der Methode" für mich jedoch etwas neblig.

Was passiert mit "dem Rest der Schleife", wenn der Thread auf Fortsetzung geschaltet ist oder wenn er nicht geschaltet ist? Auf welchem ​​Thread wird die nächste Iteration der Schleife ausgeführt?

Meine Beobachtungen zeigen (nicht endgültig verifiziert), dass jede Iteration auf demselben Thread (dem ursprünglichen) beginnt, während die Fortsetzung auf einem anderen ausgeführt wird. Kann das wirklich sein? Wenn ja, ist dies dann ein Grad unerwarteter Parallelität, der im Hinblick auf die Thread-Sicherheit der GetWorkAsync-Methode berücksichtigt werden muss?

UPDATE: Meine Frage ist kein Duplikat, wie von einigen vorgeschlagen. Das while (!_quit) { ... }Codemuster ist lediglich eine Vereinfachung meines tatsächlichen Codes. In Wirklichkeit ist mein Thread eine langlebige Schleife, die ihre Eingabewarteschlange mit Arbeitselementen in regelmäßigen Abständen (standardmäßig alle 5 Sekunden) verarbeitet. Die tatsächliche Beendigungsbedingungsprüfung ist auch keine einfache Feldprüfung, wie im Beispielcode vorgeschlagen, sondern eine Ereignishandle-Prüfung.

aoven
quelle
1
Siehe auch Wie kann der Kontrollfluss in .NET erzielt und abgewartet werden? für einige großartige Informationen darüber, wie dies alles miteinander verdrahtet ist.
John Wu
@ John Wu: Ich habe diesen SO-Thread noch nicht gesehen. Viele interessante Info-Nuggets gibt es. Vielen Dank!
Aoven

Antworten:

6

Sie können es tatsächlich bei Try Roslyn ausprobieren . Ihre Wartemethode wird in void IAsyncStateMachine.MoveNext()die generierte asynchrone Klasse umgeschrieben .

Was Sie sehen werden, ist ungefähr so:

            if (this.state != 0)
                goto label_2;
            //set up the state machine here
            label_1:
            taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter);
            label_2:
            if (!OuterClass._quit)
            {
               taskAwaiter = GetWorkAsync().GetAwaiter();
               //state machine stuff here
            }
            goto label_1;

Grundsätzlich spielt es keine Rolle, auf welchem ​​Thread Sie sich befinden. Die Zustandsmaschine kann ordnungsgemäß fortgesetzt werden, indem Ihre Schleife durch eine äquivalente if / goto-Struktur ersetzt wird.

Allerdings werden asynchrone Methoden nicht unbedingt auf einem anderen Thread ausgeführt. In Eric Lipperts Erklärung "Es ist keine Magie" wird erklärt, wie Sie async/awaitnur an einem Thread arbeiten können.

Mason Wheeler
quelle
2
Ich scheine das Ausmaß des Umschreibens zu unterschätzen, das der Compiler für meinen asynchronen Code ausführt. Im Wesentlichen gibt es nach dem Umschreiben keine "Schleife"! Das war der fehlende Teil für mich. Super und danke auch für den Link 'Try Roslyn'!
Aoven
GOTO ist das ursprüngliche Schleifenkonstrukt. Vergessen wir das nicht.
2

Erstens hat Servy einen Code in eine Antwort auf eine ähnliche Frage geschrieben, auf der diese Antwort basiert:

/programming/22049339/how-to-create-a-cancellable-task-loop

Servys Antwort enthält eine ähnliche ContinueWith()Schleife mit TPL-Konstrukten ohne explizite Verwendung der Schlüsselwörter asyncund await. Um Ihre Frage zu beantworten, überlegen Sie, wie Ihr Code aussehen könnte, wenn Ihre Schleife mit abgewickelt wirdContinueWith()

    private static Task GetWorkWhileNotQuit()
    {
        var tcs = new TaskCompletionSource<bool>();

        Task previous = Task.FromResult(_quit);
        Action<Task> continuation = null;
        continuation = t =>
        {
            if (!_quit)
            {
                previous = previous.ContinueWith(_ => GetWorkAsync())
                    .Unwrap()
                    .ContinueWith(_ => previous.ContinueWith(continuation));
            }
            else
            {
                tcs.SetResult(_quit);
            }
        };
        previous.ContinueWith(continuation);
        return tcs.Task;
    }

Es dauert einige Zeit, bis Sie Ihren Kopf umwickelt haben, aber zusammenfassend:

  • continuationstellt einen Abschluss für die "aktuelle Iteration" dar
  • previousstellt den TaskStatus der "vorherigen Iteration" dar (dh es weiß, wann die "Iteration" beendet ist und wird zum Starten der nächsten verwendet.)
  • Unter der Annahme , dass GetWorkAsync()a zurückgegeben Taskwird, bedeutet dies, dass a zurückgegeben ContinueWith(_ => GetWorkAsync())wird, Task<Task>daher der Aufruf Unwrap(), die 'innere Aufgabe' zu erhalten (dh das tatsächliche Ergebnis von GetWorkAsync()).

Damit:

  1. Anfangs gibt es keine vorherige Iteration, daher wird ihm einfach der Wert zugewiesen Task.FromResult(_quit) - sein Status beginnt als Task.Completed == true.
  2. Das continuationwird zum ersten Mal mit ausgeführtprevious.ContinueWith(continuation)
  3. Der continuationAbschluss wird aktualisiert previous, um den Abschlussstatus von widerzuspiegeln_ => GetWorkAsync()
  4. Wenn _ => GetWorkAsync()es abgeschlossen ist, wird es "fortgesetzt mit" _previous.ContinueWith(continuation)- dh das continuationLambda erneut aufrufen
    • Offensichtlich wurde zu diesem Zeitpunkt previousder Status aktualisiert, _ => GetWorkAsync()sodass das continuationLambda bei der GetWorkAsync()Rückkehr aufgerufen wird .

Das continuationLambda prüft immer den Zustand von _quit, wenn _quit == falsedann keine Fortsetzungen mehr vorhanden sind und das TaskCompletionSourceauf den Wert von gesetzt _quitwird und alles abgeschlossen ist.

Was Ihre Beobachtung bezüglich der Fortsetzung betrifft, die in einem anderen Thread ausgeführt wird, ist dies nicht das, was das Schlüsselwort async/ awaitfür Sie tun würde, wie in diesem Blog "Aufgaben sind (noch) keine Threads und Async ist nicht parallel" . - https://blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-are-still-not-threads-and-async-is-not-parallel/

Ich würde vorschlagen, dass es sich in der Tat lohnt, Ihre GetWorkAsync()Methode in Bezug auf Gewinde und Gewindesicherheit genauer zu betrachten. Wenn Ihre Diagnose ergibt, dass es infolge Ihres wiederholten asynchronen / wartenden Codes auf einem anderen Thread ausgeführt wurde, muss etwas innerhalb oder im Zusammenhang mit dieser Methode dazu führen, dass an anderer Stelle ein neuer Thread erstellt wird. (Wenn dies unerwartet ist, gibt es vielleicht .ConfigureAwaitirgendwo einen?)

Ben Cottrell
quelle
2
Der Code, den ich gezeigt habe, ist (sehr) vereinfacht. In GetWorkAsync () gibt es mehrere weitere Wartezeiten. Einige von ihnen greifen auf die Datenbank und das Netzwerk zu, was echte E / A bedeutet. Soweit ich weiß, ist der Thread-Wechsel eine natürliche Folge (wenn auch nicht erforderlich) solcher Wartezeiten, da der anfängliche Thread keinen Synchronisationskontext erstellt, der bestimmen würde, wo die Fortsetzungen ausgeführt werden sollen. Sie werden also in einem Thread-Pool-Thread ausgeführt. Ist meine Argumentation falsch?
Aoven
@aoven Guter Punkt - ich habe die verschiedenen Arten von nicht berücksichtigt SynchronizationContext- das ist sicherlich wichtig, da .ContinueWith()der SynchronizationContext zum Versenden der Fortsetzung verwendet wird; Es würde in der Tat das Verhalten erklären, das Sie sehen, wenn awaites in einem ThreadPool-Thread oder einem ASP.NET-Thread aufgerufen wird. Eine Fortsetzung könnte in diesen Fällen sicherlich an einen anderen Thread gesendet werden. Auf der anderen Seite sollte das Aufrufen awaiteines Single-Threaded-Kontexts wie eines WPF-Dispatchers oder eines Winforms-Kontexts ausreichen, um sicherzustellen, dass die Fortsetzung auf dem Original erfolgt. Thread
Ben Cottrell