Asynchroner Code, gemeinsam genutzte Variablen, Thread-Pool-Threads und Thread-Sicherheit

8

Wenn ich asynchronen Code mit async / await schreibe, normalerweise ConfigureAwait(false)um den Kontext nicht zu erfassen, springt mein Code nacheinander von einem Thread-Pool-Thread zum nächsten await. Dies wirft Bedenken hinsichtlich der Gewindesicherheit auf. Ist dieser Code sicher?

static async Task Main()
{
    int count = 0;
    for (int i = 0; i < 1_000_000; i++)
    {
        Interlocked.Increment(ref count);
        await Task.Yield();
    }
    Console.WriteLine(count == 1_000_000 ? "OK" : "Error");
}

Die Variable iist ungeschützt und wird von mehreren Thread-Pool-Threads * aufgerufen. Obwohl das Zugriffsmuster nicht gleichzeitig ist, sollte es theoretisch möglich sein, dass jeder Thread einen lokal zwischengespeicherten Wert von erhöht i, was zu mehr als 1.000.000 Iterationen führt. Ich kann dieses Szenario jedoch nicht in die Praxis umsetzen. Der obige Code wird auf meinem Computer immer in Ordnung gedruckt. Bedeutet dies, dass der Code threadsicher ist? Oder sollte ich den Zugriff auf die iVariable mit a synchronisieren lock?

(* Laut meinen Tests erfolgt durchschnittlich alle 2 Iterationen ein Thread-Wechsel.)

Theodor Zoulias
quelle
1
Warum wird Ihrer Meinung inach in jedem Thread zwischengespeichert? Sehen Sie sich dieses SharpLab IL an, um tiefer zu graben.
AndreasHassing
1
@AndreasHassing Meine Bedenken werden durch folgende Aussagen aufgeworfen: Der Compiler, die CLR oder die CPU führen möglicherweise Caching-Optimierungen ein, sodass Zuweisungen zu Variablen für andere Threads nicht sofort sichtbar sind. Teil 4: Advanced Threading
Theodor Zoulias

Antworten:

2

Das Problem mit der Thread-Sicherheit besteht im Lesen / Schreiben von Speicher. Selbst wenn dies in einem anderen Thread fortgesetzt werden könnte, wird hier nichts gleichzeitig ausgeführt.

Jeroen van Langen
quelle
Ein Thread könnte theoretisch aus einem lokalen Cache anstelle des Haupt-RAM lesen und in diesen schreiben, wobei auf diese Weise ein von einem anderen Thread vorgenommenes Update fehlt. Die Variable iist weder deklariert volatilenoch durch eine Sperre geschützt. Nach meinem Verständnis dürfen der Compiler, der Jitter und die Hardware (CPU) eine solche Optimierung vornehmen.
Theodor Zoulias
@TheodorZoulias Das Austauschen eines Threads, um eine Fortsetzung fortzusetzen, ist nicht dasselbe wie der gleichzeitige Zugriff. In dem oben verlinkten Sharplab sehen Sie, dass die gesamte Zustandsmaschine, die die Einheimischen in privaten Feldern kapselt, an den Thread übergeben wird, der die Fortsetzung ausführt. Es wird jeweils nur 1 Thread aufgerufen i.
JohanP
@JohanP Das Feld private int <i>5__2in der Zustandsmaschine ist nicht deklariert volatile. Meine Bedenken beziehen sich nicht darauf, dass ein Thread einen anderen Thread unterbricht, der gerade aktualisiert wird i. Dies ist in diesem Fall nicht möglich. Meine Bedenken beziehen sich auf einen Thread, der einen veralteten Wert von verwendet i, der im lokalen Cache des Kerns der CPU zwischengespeichert ist und von einer vorherigen Schleife dort belassen wurde, anstatt einen neuen Wert von iaus dem Haupt-RAM abzurufen. Der Zugriff auf den lokalen Cache ist billiger als der Zugriff auf den Hauptspeicher. Bei eingeschalteten Optimierungen sind solche Dinge möglich (gemäß dem, was ich gelesen habe).
Theodor Zoulias
@TheodorZoulias Haben Sie die gleichen Bedenken, wenn diese Schleife keinen asyncCode enthält?
JohanP
2
@ TheodorZoulias Thread A wird in Schritten ausgeführt i. Code trifft await, Thread A übergibt den gesamten Status an Thread B und geht zurück in den Pool. Gewinde B - Schritten i. Treffer await. Thread B übergibt dann den gesamten Status an Thread C, geht zurück in den Pool usw. usw. Zu keinem Zeitpunkt ist ein gleichzeitiger Zugriff auf i, es ist keine Thread-Sicherheit erforderlich, es spielt keine Rolle, dass ein Thread-Wechsel stattgefunden hat Der benötigte Status wird an den neuen Thread übergeben, in dem die Fortsetzung ausgeführt wird. Es gibt keinen gemeinsamen Status, weshalb Sie keine Synchronisierung benötigen.
JohanP
0

Ich glaube, dieser Artikel von Stephen Toub kann etwas Licht ins Dunkel bringen. Dies ist insbesondere eine relevante Passage darüber, was während eines Kontextwechsels passiert:

Immer wenn Code auf einen Wartenden wartet, dessen Kellner angibt, dass er noch nicht vollständig ist (dh IsCompleted des Wartenden gibt false zurück), muss die Methode angehalten werden und wird über eine Fortsetzung des Wartenden fortgesetzt. Dies ist einer der asynchronen Punkte, auf die ich bereits hingewiesen habe. Daher muss ExecutionContext vom Code, der die Wartezeit ausgibt, bis zur Ausführung des Fortsetzungsdelegierten fließen. Dies wird vom Framework automatisch erledigt. Wenn die asynchrone Methode angehalten werden soll, erfasst die Infrastruktur einen ExecutionContext. Der Delegat, der an den Kellner übergeben wird, hat einen Verweis auf diese ExecutionContext-Instanz und verwendet ihn, wenn die Methode fortgesetzt wird. Auf diese Weise können die wichtigen „Umgebungsinformationen“, die in ExecutionContext dargestellt werden, über die erwarteten Daten fließen.

Bemerkenswert ist, dass der YieldAwaitablezurückgegebene von Task.Yield()immer zurückkehrt false.

Daniel Crha
quelle
Danke Daniel für die Antwort. Um ehrlich zu sein, wäre ich überrascht, wenn das Fließen ExecutionContextvon Thread zu Thread auch als Mechanismus zur Ungültigmachung der lokalen Caches des Threads dienen würde. Aber es ist auch nicht unmöglich.
Theodor Zoulias
Vielleicht könnte ein Experte wie @RaymondChen behaupten, ob Ihre Antwort richtig oder falsch ist. Ich glaube, dass nur sehr wenige Menschen auf der Welt als glaubwürdige Informationsquelle zu diesem Thema dienen können.
Theodor Zoulias
"Ungültigmachen der lokalen Caches des Threads" würde bedeuten, dass ein Thread, wenn er einen Kontextwechsel durchführt, irgendwie auch einen Cache verwaltet, der für diesen einen Kontext spezifisch ist. Das würde bedeuten, dass diese zwischengespeicherten Daten in etwas gespeichert werden müssen, das einem Kontext ähnelt ... aber warum, wenn der reale Kontext für den Thread verfügbar ist, der sie ausführen muss? Es würde auch das Problem mit sich bringen, zu bestimmen, welche zwei Kontexte "gleich" sind, aber nur einen späteren Punkt in der Ausführung darstellen. Natürlich behaupte ich nicht, ein Experte zu sein, sondern versuche nur, das Problem als mentale Übung zu betrachten.
Daniel Crha
Falls ich mich irre, könnte ich mich auch auf Cunninghams Gesetz berufen: "Der beste Weg, um die richtige Antwort im Internet zu erhalten, besteht darin, keine Frage zu stellen, sondern die falsche Antwort zu posten."
Daniel Crha
1
Ein Hardware-Cache ist jedoch nicht threadspezifisch. Tatsächlich könnte sogar Single-Threaded-Code durch präventives Multitasking von der Betriebssystemseite zum Nachgeben gezwungen werden, und es könnte die Ausführung auf einem anderen Prozessor (und damit einem anderen L1- und L2-Cache) wieder aufnehmen. Diese Cache-Ungültigmachung ist nicht spezifisch für asyncoder await. Eine Cache-Ungültigmachung während eines Kontextwechsels würde Single- und Multithread-Code auf dieselbe Weise beeinflussen.
Daniel Crha