2017-07-03 4 views
0

Ich bin mir bewusst, Sammlung wurde geändert Ausnahmeproblem, aber ich kann es nicht in diesem Fall zu sehen. Ich weiß, wie ich es beheben kann, ich möchte nur verstehen, warum es hier vorkommt.Sammlung wurde unabhängig von der Sperre geändert

Also, ich habe eine Reihe von TaskCompletionSources und ich habe ein LockObject, das den Zugriff auf diesen Satz schützt. In einer Aufgabe (T1) möchte ich TCS erstellen und bis zu 3 Sekunden warten, bis die Aufgabe abgeschlossen ist.

In der anderen Aufgabe (T2) möchte ich eine halbe Sekunde warten und dann die Aufgabe, auf die T1 wartet, abschließen.

Der Satz von TCSs ist nicht gerade von Nutzen in diesem Codeausschnitt, aber im eigentlichen Programm, an dem ich gerade arbeite, ist es die Liste einer bestimmten Anzahl verschiedener Kellner zu halten, die alle einmal benachrichtigt werden sollen ist abgeschlossen und dies sollte auch die Liste der Kellner löschen. In diesem Snippet haben wir nur einen Kellner (T1), aber die Menge der TCSs muss verwendet werden, um das Problem zu reproduzieren.

Das Programm erzeugt die folgende Ausgabe:

T1 start. 
Wait start. 
Add start. 
Add end. 
T2 start. 
CompleteAndClear start. 
Completing 1 TCSs. 
Remove start. 
Remove end. 
Wait end. 
Wait succeeded. 
T1 end. 

Unhandled Exception: System.AggregateException: One or more errors occurred. (Collection was modified; enumeration operation may not execute.) ---> System.InvalidOperationException: Collection was modified; enumeration operation may not execute. 
    at System.Collections.Generic.HashSet`1.Enumerator.MoveNext() 
    at ConsoleApp1.Program.CompleteAndClear() in Program.cs:line 104 
    at ConsoleApp1.Program.<T2Async>d__5.MoveNext() in Program.cs:line 45 
... 

Was ich nicht verstehe:

  • Warum die Ausnahme ausgelöst wird?
  • Wie kann die Remove-Methode starten und enden, während CompleteAndClear noch die Sperre hält?

Für mich scheint es, wie TrySetResult das Warten verursacht mit dem gleichen Gewinde fertig gestellt werden, der die Sperre hält - so der aktuelle Thread springt auf die WaitAsync Funktion, geht zu entfernen, wird Sperre dann dadurch umgangen, dass Dieser Thread enthält die Sperre von CompleteAndClear (Sperren werden vom selben Thread wiedereingeführt), und seit das Entfernen den HashSet geändert hat, wird die Ausnahme aufgerufen. Aber die Absicht war, dass der Thread, der CompleteAndClear ausführt, nur die Aufgaben als abgeschlossen markiert, indem er ihre Ergebnisse setzt, dann löscht er die Menge und gibt die Sperre frei und erst dann kann Remove die Sperre eingeben und es sollte "TCS not found."

Die triviale fix im Code ist

 Remove(tcs); 

mit

 if (!res) Remove(tcs); 

, die perfekt funktioniert zu ersetzen, aber geht nicht mit der Absicht zusammen. Eine andere besteht darin, eine Kopie des Satzes zu erstellen, bevor sie gelöscht wird, und Ergebnisse auf die Kopie zu setzen, die den Fall vollständig löst.

Code:

using System; 
using System.Collections.Generic; 
using System.Threading.Tasks; 
using System.Threading; 

namespace ConsoleApp1 
{ 
    class Program 
    { 
    static object lockObject = new object(); 

    static HashSet<TaskCompletionSource<bool>> completionSources = new HashSet<TaskCompletionSource<bool>>(); 

    static void Main(string[] args) 
    { 
     MainAsync().Wait(); 
    } 

    static async Task MainAsync() 
    { 
     Task t1 = T1Async(); 
     Task t2 = T2Async(); 
     await t1; 
     await t2; 
    } 

    static async Task T1Async() 
    { 
     Console.WriteLine("T1 start."); 

     if (await WaitAsync()) Console.WriteLine("Wait succeeded."); 
     else Console.WriteLine("Wait failed."); 

     Console.WriteLine("T1 end."); 
    } 

    static async Task T2Async() 
    { 
     Console.WriteLine("T2 start."); 

     await Task.Delay(500); 
     CompleteAndClear(); 

     Console.WriteLine("T2 end."); 
    } 


    static async Task<bool> WaitAsync() 
    { 
     Console.WriteLine("Wait start."); 
     bool res = false; 
     TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>(); 
     using (CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(3000)) 
     { 
     using (CancellationTokenRegistration cancellationTokenRegistration = cancellationTokenSource.Token.Register(() => { tcs.TrySetResult(false); })) 
     { 
      Add(tcs); 

      res = await tcs.Task; 

      Remove(tcs); 
     } 
     } 
     Console.WriteLine("Wait end."); 
     return res; 
    } 

    static void Add(TaskCompletionSource<bool> TaskCompletionSource) 
    { 
     Console.WriteLine("Add start."); 

     lock (lockObject) 
     { 
     completionSources.Add(TaskCompletionSource); 
     } 

     Console.WriteLine("Add end."); 
    } 

    static void Remove(TaskCompletionSource<bool> TaskCompletionSource) 
    { 
     Console.WriteLine("Remove start."); 

     lock (lockObject) 
     { 
     if (!completionSources.Remove(TaskCompletionSource)) Console.WriteLine("TCS not found."); 
     } 

     Console.WriteLine("Remove end."); 
    } 


    static void CompleteAndClear() 
    { 
     Console.WriteLine("CompleteAndClear start."); 
     lock (lockObject) 
     { 
     if (completionSources.Count > 0) 
     { 
      Console.WriteLine("Completing {0} TCSs.", completionSources.Count); 
      foreach (TaskCompletionSource<bool> tcs in completionSources) 
      tcs.TrySetResult(true); 

      Console.WriteLine("Clearing TCS list."); 
      completionSources.Clear(); 
     } 
     } 
     Console.WriteLine("CompleteAndClear end."); 
    } 

    } 
} 

Antwort

1

Der Kern des Problems ist, dass TaskCompletionSource<T>.TrySetResult wird synchron keine Aufgabe Fortsetzungen aufrufen, die mit der Tatsache, mit TaskContinuationOption.ExecuteSynchronously, kombiniert registriert wurden, dass await does use that flag.

So nehmen CompleteAndClear das Schloss und dann TrySetResultaufrufen, während diese Sperre halten.Since this is in a free-threaded context10, TrySetResult wird synchron wieder die WaitAsync Methode, die dann ruft Remove, die die Sperre (erfolgreich, weil lock ermöglicht rekursive Sperren erlaubt) und ändert die Sammlung. Wenn TrySetResult zurückkehrt (nachdem die Remove erledigt ist), wird der Enumerator das Problem erkennen und die Ausnahme auslösen.

Es gibt ein paar (IMO fragwürdige) Designentscheidungen, die hier zusammenarbeiten. Ich habe Einwände gegen await using ExecuteSynchronously sowie recursive locks in general (der Abschnitt "Inkonsistente Invarianten" gilt besonders für dieses Szenario).

Sie können jedoch die mit diesen Designentscheidungen verbundenen Probleme vermeiden, indem Sie strikt einem der key principles of multithreading: never invoke arbitrary code while holding a lock folgen. Natürlich ist es nicht offensichtlich, dass TaskCompletionSource<T>.TrySetResultkann beliebigen Code aufrufen.

Nun zu den Lösungen.

Wenn Sie einen neuen genug Laufzeit-Targeting (ich glaube netstandard1.3/.NET Core 1.0 und höher), dann können Sie Task​Creation​Options.​Run​Continuations​Asynchronously in den TaskCompletionSource<T> Konstruktor übergeben. Dies gibt Ihnen das wünschenswerteste Verhalten: Die Aufgabe wird sofort und synchron abgeschlossen, aber alle Fortsetzungen sind gezwungen, asynchron zu sein. Für ältere Plattformen können Sie die "Completion" -Arbeit (dh den Aufruf von TrySetResult) innerhalb eines Delegaten kapseln (ich empfehle das weitere Wrapping in einem IDisposable) und diese Arbeit verzögern, bis die Methode ihre Sperre aufgehoben hat .

Schließlich empfehle ich zuerst Async-kompatible Koordinationsprimitiven zu schreiben und diese dann zu verwenden, um komplexere Strukturen wie Arbeitswarteschlangen zu erstellen. Es ist viel einfacher, mit diesen kniffligen Situationen in nur einem Teil Ihres Codes umzugehen oder ihn möglicherweise sogar auszulagern. Zum Beispiel hat meine AsyncEx library eine vollständige Folge von Async-kompatiblen Koordinationsprimitiven; v5 uses the new RunContinuationsAsynchronously flag, während v4 uses the delay-completion-with-IDisposable workaround.

+0

Danke für schöne Erklärung. Du hast mich auf "' TrySetResult' kann beliebigen Code aufrufen. " - Das wusste ich nicht. Die Doku ist zu kurz für meinen Geschmack. Wenn ich verstehe, was Sie hier sagen, ist das Standardverhalten von 'erwarten'' 'ExecuteSynchronously' und Sie können das überschreiben, wenn Sie' TaskCreationOptions' beim Erstellen von TCS verwenden. Ist das richtig? Wenn es jedoch richtig ist, erwähnen Sie in den Artikeln, die Sie verlinkt haben, dass diese Optionen manchmal nicht respektiert werden (aber diese scheinen Randfälle zu sein), also können wir uns hier darauf verlassen, dass wir 'Continuations Asynchronous' niemals ausführen im Synchronisierungsmodus laufen? – Wapac

+0

Zwei weitere Fragen, wenn ich darf: Sie haben geschrieben, dass rekursive Sperren schlecht waren, also empfehlen Sie generell zu vermeiden, 'lock()' construct zu verwenden? Wenn ja, was empfehlen Sie stattdessen? Und die zweite ist - wenn ich etwas nicht verpasse, würde die Verwendung eines nicht-rekursiven Sperrmechanismus hier überhaupt nicht helfen, oder? – Wapac

+0

Ich meine meine erste Frage im zweiten Kommentar hier ist wirklich - wenn ich meine 'lock()' s nicht-rekursiv (durch Design meines Codes), ist es immer noch besser, sie zu vermeiden und verschiedene Konstrukte verwenden? – Wapac

Verwandte Themen