Threadsafety Konkretes Beispiel



  • Hi,

    in Anlehnung an das etwas unübersichtlich gewordene Thema: http://www.c-plusplus.net/forum/324639

    Folgendes NICHT THREADSAFES Beispiel:
    - In einem Thread erzeuge ich Zahlen und speichere diese in einer Liste
    - Vom anderen Thread möchte ich in bestimmten Zeitabständen eine Art Snapshot dieser Liste ausgeben

    Wie mache ich das ganze Threadsafe?

    public List<double> Zahlen { get; set; }
    
    public void Erzeugen()
    {
      for(int i=0; i<100; i++)
      {
        Zahlen.clear();
        for(int j=0; j<i; j++)
        {
          Zahlen.add(ZahlenFunktion());
        }
        Thread.Sleep(WarteZeit);
      }
    }
    

    Anderer Thread

    private List<double> ReferenzAufDaten;
    
    public void Snapshot()
    {
      for(int i=0; i<100; i++)
      {
        Thread.Sleep(AbstandAusgabe);
        foreach(double punkt in ReferenzAufDaten)
        {
          Console.Write();
        }
        Console.WriteLine();
      }
    }
    


  • Basierend auf der recht rudimentären Aufgabenstellung würde ich dafür einen immutable-Ansatz wählen, also eine Liste die nachdem sie erzeugt wurde nicht mehr verändert wird. Daher erstmal die Definition:

    public IEnumerable<double> Zahlen { get; set; }
    
    public void Erzeugen()
            {
                for (int i = 0; i < 100; ++i)
                {
                    List<double> zahlen = new List<double>();
                    for (int j = 0; j < i; ++j)
                    {
                        zahlen.Add(ZahlenFunktion());
                    }
                    Zahlen = zahlen;
    
                    Thread.Sleep(WarteZeit);
                }
            }
    
    public void Snapshot()
            {
                for (int i = 0; i < 100; i++)
                {
                    Thread.Sleep(AbstandAusgabe);
    
                    var zahlen = Zahlen;
    
                    foreach (double punkt in zahlen)
                    {
                        Console.Write(punkt);
                    }
                    Console.WriteLine();
                }
            }
    


  • @loks
    Ist das in C# ohne volatile wirklich OK?



  • hustbaer schrieb:

    @loks
    Ist das in C# ohne volatile wirklich OK?

    Bin ich mir in dem Fall nicht ganz sicher, da die Änderungen ja immer vom gleichen Thread gemacht werden. Im Bezug auf Thread-safety ist es erstmal unproblematisch. Was halt passieren könnte wäre, dass der lesende Thread noch eine alte Version bekommt obwohl die neue bereits fertig erzeugt, aber noch nicht zurück in den Speicher geschrieben wurde.



  • Ok, ich glaub ich hab die Antwort:

    http://igoro.com/archive/volatile-keyword-in-c-memory-model-explained/

    Scrollt man ein Stück runter steht da:

    One interesting point is that all writes in C# are volatile according to the memory model as documented here and here, and are also presumably implemented as such. The ECMA specification of the C# language actually defines a weaker model where writes are not volatile by default.

    So wie ich das verstehe müsste mein Beispiel auch ohne explizites volatile korrekt funktionieren weil der Cache nach dem Schreiben des Feldes geflushed wird.



  • Danke schonmal für den Ansatz.
    Ich muss aber wahrscheinlich doch noch mal etwas genauer erklären:
    Der Snapshot soll in Echtzeit erfolgen. Angenommen das füllen der Liste
    dauert eine Minute, dann habe ich in dieser Zeit keine Anzeige.



  • huddi schrieb:

    Der Snapshot soll in Echtzeit erfolgen. Angenommen das füllen der Liste
    dauert eine Minute, dann habe ich in dieser Zeit keine Anzeige.

    Dann musst du den Zugriff auf die List<T> Zahlen eben per lock-Statement steuern.

    @hustbaer
    Wenn ich volatile sehe, denke ich im ersten Moment immer daran, dass sich jemand den lock von einer threadübergreifend genutzten Variable sparen wollte. Insofern werde ich erstmal skeptisch, wenn ich ein volatile sehe..



  • @huddi
    Ich sehe da ein paar relativ einfache Möglichkeiten:

    1. Der Thread der die Daten erzeugt/modifiziert entscheidet wann ein Snapshot gemacht wird. Zu dem Zeitpunkt kopiert er die Liste, setzt die "shared" Variable auf die neue Kopie der Liste. Die Kopie wird dann nie mehr modifiziert.
      Entspricht im Prinzip der Version von loks, nur dass du dir den Zeitpunkt wo die Kopie erstellt und dem anderen Thread zur Verfügung gestellt wird aussuchen kannst.

    2.1) Du verwendest nen Lock für den Zugriff auf die Liste. Der Thread der die Daten erzeugt/modifiziert nimmt für jede Modifikation den Lock, modifiziert ein Element, und gibt den Lock dann wieder frei.
    Der lesende Thread nimmt den Lock, kopiert dann die ganze Liste, und gibt den Lock dann wieder frei. Danach kann der lesende Thread in aller Ruhe die Liste lesen und seine GUI Controls refreshen.

    2.2) Wie (2.1), nur dass der Thread der die Daten erzeugt/modifiziert die Modifikationen blockweise macht. D.h. er nimmt den Lock, modifiziert dann mehrere Elemente, und gibt danach erst den Lock wieder frei.

    Welche Variante besser ist, hängt wohl von mehreren Faktoren ab. z.B. wie aktuell die Daten sein müssen die in der GUI angezeigt werden, wie lange das Bearbeiten eines Elements dauert etc.

    ps:

    1. Schon viel weniger einfach. Und hier schnell direkt ins Forum reingeschrieben, könnte also durchaus Fehler enthalten:
    private volatile bool m_wantSnapshot;
    private readonly object m_newSnapshotCondition = new object();
    
    private int m_snapshotNumber;
    private List<T> m_snapshot;
    
    public List<T> GetSnapshot()
    {
        lock (m_newSnapshotCondition)
        {
            m_wantSnapshot = true;
            int oldSnapshotNumber = m_snapshotNumber;
    
            while (m_snapshotNumber == oldSnapshotNumber)
                Monitor.Wait(m_newSnapshotCondition);
    
            return m_snapshot;
        }
    }
    
    private void MakeSnapshotIfNecessary()
    {
        if (m_wantSnapshot)
        {
            lock (m_newSnapshotCondition)
            {
                m_snapshot = new List<T>(...); // Neuen Snapshot erstellen
                m_snapshotNumber++;
                m_wantSnapshot = false;
                Monitor.PulseAll(m_newSnapshotCondition);
            }
        }
    }
    

    Der Thread der die Daten erzeugt/modifiziert müsste dabei dann nur ausreichend oft MakeSnapshotIfNecessary() aufrufen.
    Wenn der GUI Thread keinen Snapshot angefordert hat, dann braucht MakeSnapshotIfNecessary() so-gut-wie keine Zeit. Und wenn doch, dann wird halt ein Snapshot gemacht, aber eben nur dann.

    Das ganze geht davon aus dass es nur genau zwei Threads gibt, einen der liest und einen der erzeugt/modifiziert. Bei mehreren Lesern müsste der Code evtl. angepasst werden.



  • @loks
    MMn. sollte man die Variable trotzdem volatile machen. Schon allein weil der ECMA Standard hier keine ausreichenden Garantien macht.

    Und non-volatile weil reads kein "acquire" sind. D.h. der lesende Thread könnte z.B. die neue Referenz auf das neue Objekt lesen, dann aber alte Werte im neuen Objekt lesen.

    @GPC
    Ja, geht mir ähnlich. Aber hauptsächlich weil ich überwiegend C++ mache, und die Verwendung von volatile in C++ ein fast sicheres Anzeichen dafür ist dass der Code von jemandem stammt der nicht weiss was er tut. (Ausgenommen in z.B. Treibercode, aber darum geht's ja hier nicht.)

    Ich bin mir so-gut-wie sicher dass volatile in diesem Fall (Beispiel von loks, mit ner volatile Variable als Backing-Member für die Property Zahlen ) in C# ausreicht.
    In C# sind Zugriffe auf Referenzen atomar (lesen und schreiben), volatile reads sind "acquire" und volatile writes sind "release".
    => Müsste reichen, meinst du nicht?
    Und wenn es reicht, wozu dann noch ein lock() drumrum machen?

    Bitte um Korrektur falls ich da was falsch verstanden habe!



  • hustbaer schrieb:

    Ich bin mir so-gut-wie sicher dass volatile in diesem Fall (Beispiel von loks, mit ner volatile Variable als Backing-Member für die Property Zahlen ) in C# ausreicht.

    Der Code von loks ist auch ohne volatile ok in der Hinsicht, keine kaputten Daten zu erzeugen/erhalten. Was passieren kann und auch schon angemerkt wurde, ist dass man halt veraltete Daten liest.

    In C# sind Zugriffe auf Referenzen atomar (lesen und schreiben), volatile reads sind "acquire" und volatile writes sind "release".
    => Müsste reichen, meinst du nicht?
    Und wenn es reicht, wozu dann noch ein lock() drumrum machen?

    In dem Fall wäre volatile ausreichend, denn es sind folgende Bedingungen gegeben:
    - Lesender Thread liest nur und schreibt nie.
    - Schreibender Thread schreibt nur und liest nie.
    - Der Read/Write auf die Variable ist dank Referenz atomar

    Wenn eine der drei Bedingungen nicht zutrifft, ist volatile ungenügend und man braucht entweder die Interlocked-Klasse oder ein lock, je nach dem.
    Weil volatile sagt ja nur "don't reorder and don't cache".. synchronisiert aber keine gleichzeitigen Read/Writes.



  • GPC schrieb:

    hustbaer schrieb:

    Ich bin mir so-gut-wie sicher dass volatile in diesem Fall (Beispiel von loks, mit ner volatile Variable als Backing-Member für die Property Zahlen ) in C# ausreicht.

    Der Code von loks ist auch ohne volatile ok in der Hinsicht, keine kaputten Daten zu erzeugen/erhalten. Was passieren kann und auch schon angemerkt wurde, ist dass man halt veraltete Daten liest.

    Das verstehe ich nicht.

    Ohne volatile hast du kein "Memory-Ordering", und ohne "Memory-Ordering" ist nicht garantiert dass z.B. der lesende Thread alle Änderungen mitbekommt.

    Angenommen das letzte Add auf die List führt zu einer Reallocation des privaten Arrays der List.
    Der lesende Thread könnte dann z.B. den altuellen Count Wert nach dem Add lesen, aber die alte Referenz auf das Array.
    => Er greift auf nen Array Index zu den es gar nicht gibt.
    => IndexOutOfRangeException beim Lesen von list[list.Count - 1]
    => Nicht gut



  • hustbaer schrieb:

    Angenommen das letzte Add auf die List führt zu einer Reallocation des privaten Arrays der List.
    Der lesende Thread könnte dann z.B. den altuellen Count Wert nach dem Add lesen, aber die alte Referenz auf das Array.
    => Er greift auf nen Array Index zu den es gar nicht gibt.
    => IndexOutOfRangeException beim Lesen von list[list.Count - 1]
    => Nicht gut

    Auch auf die Gefahr hin was zu übersehen, aber ich bin der Meinung, das Szenario kann gar nie vorkommen, weil Add immer auf eine andere Instanz von List<T> aufgerufen wird, als zum Zeitpunkt der inneren for-Schleife hinter der Property "Zahlen" hinterlegt ist. Die Zuweisung zur Property "Zahlen" erfolgt erst nach den ganzen Add-Aufrufen. Und in der nächsten Iteration der äußeren Schleife wird eine neue lokale List<T> erstellt. Insofern kommen sich Schreiber und Leser nicht in die Quere.



  • GPC schrieb:

    hustbaer schrieb:

    Angenommen das letzte Add auf die List führt zu einer Reallocation des privaten Arrays der List.
    Der lesende Thread könnte dann z.B. den altuellen Count Wert nach dem Add lesen, aber die alte Referenz auf das Array.
    => Er greift auf nen Array Index zu den es gar nicht gibt.
    => IndexOutOfRangeException beim Lesen von list[list.Count - 1]
    => Nicht gut

    Auch auf die Gefahr hin was zu übersehen, aber ich bin der Meinung, das Szenario kann gar nie vorkommen, weil Add immer auf eine andere Instanz von List<T> aufgerufen wird, als zum Zeitpunkt der inneren for-Schleife hinter der Property "Zahlen" hinterlegt ist. Die Zuweisung zur Property "Zahlen" erfolgt erst nach den ganzen Add-Aufrufen. Und in der nächsten Iteration der äußeren Schleife wird eine neue lokale List<T> erstellt. Insofern kommen sich Schreiber und Leser nicht in die Quere.

    Mit volatile : ja.
    Ohne voltaile gibt es aber kein "vor" oder "danach".
    Genau darum geht's doch 🙂



  • hustbaer schrieb:

    GPC schrieb:

    hustbaer schrieb:

    Angenommen das letzte Add auf die List führt zu einer Reallocation des privaten Arrays der List.
    Der lesende Thread könnte dann z.B. den altuellen Count Wert nach dem Add lesen, aber die alte Referenz auf das Array.
    => Er greift auf nen Array Index zu den es gar nicht gibt.
    => IndexOutOfRangeException beim Lesen von list[list.Count - 1]
    => Nicht gut

    Auch auf die Gefahr hin was zu übersehen, aber ich bin der Meinung, das Szenario kann gar nie vorkommen, weil Add immer auf eine andere Instanz von List<T> aufgerufen wird, als zum Zeitpunkt der inneren for-Schleife hinter der Property "Zahlen" hinterlegt ist. Die Zuweisung zur Property "Zahlen" erfolgt erst nach den ganzen Add-Aufrufen. Und in der nächsten Iteration der äußeren Schleife wird eine neue lokale List<T> erstellt. Insofern kommen sich Schreiber und Leser nicht in die Quere.

    Mit volatile : ja.
    Ohne voltaile gibt es aber kein "vor" oder "danach".
    Genau darum geht's doch 🙂

    Hoppla, sorry 😃 Hab mich vorher verlesen.
    Nun, wenn der Link von loks stimmt, dann reicht's auch one volatile. Allerdings verweist dieser Link wiederum auf http://msdn.microsoft.com/en-us/magazine/cc163715.aspx und da habe ich beim Überfliegen keine Bestätigung gefunden (ist aber viel Text und ich bin grad zu faul, es im Detail zu lesen).
    Mein Gefühl sagt mir nach längerer Überlegung, dass es ohne volatile nicht unbedingt funktionieren muss.

    Aber allein die Tatsache, dass wir da jetzt so rumraten, zeigt nur einmal mehr, dass volatile einfach scheiße ist 😃



  • GPC schrieb:

    Nun, wenn der Link von loks stimmt, dann reicht's auch one volatile. Allerdings verweist dieser Link wiederum auf http://msdn.microsoft.com/en-us/magazine/cc163715.aspx und da habe ich beim Überfliegen keine Bestätigung gefunden (ist aber viel Text und ich bin grad zu faul, es im Detail zu lesen).

    Im Link von loks steht bloss dass writes ohne volatile trotzdem "durch schreiben". Was auch immer das bedeuten soll. Von reads steht da aber nix.
    D.h. auch wenn man davon ausgeht dass ohne volatile der "release" Teil passt, so passt immer noch der "acquire" Teil nicht.
    => MMn. reicht es trotzdem nicht.

    Mein Gefühl sagt mir nach längerer Überlegung, dass es ohne volatile nicht unbedingt funktionieren muss.

    Genau.

    Aber allein die Tatsache, dass wir da jetzt so rumraten, zeigt nur einmal mehr, dass volatile einfach scheiße ist 😃

    Nö, wieso? Ohne volatile is scheisse.

    volatile ist in C# recht gut definiert. Ein volatile read ist acquire (=wie der lock-Teil eines lock() {}), und ein volatile write ist release (=wie der unlock-Teil eines lock() {}). Ist doch perfekt. Was will man noch mehr?



  • hustbaer schrieb:

    volatile ist in C# recht gut definiert. Ein volatile read ist acquire (=wie der lock-Teil eines lock() {}), und ein volatile write ist release (=wie der unlock-Teil eines lock() {}). Ist doch perfekt. Was will man noch mehr?

    Richtiges locking? 😉 Mal abgesehen davon dass volatile natürlich nicht mit allen built-ins funktioniert.
    Und volatile ist auch nur dann ok, wenn ein Thread nie eine volatile-Variable liest und schreibt, sondern ein Thread immer nur liest und ein anderer Thread immer nur schreibt. Ansonsten kann man es halt in die Tonne treten.

    Edit: Ach ja, der Link ist mir gerade eingefallen: http://www.albahari.com/threading/part4.aspx
    Der Abschnitt "The volatile keyword" zeigt ein sehr schönes Beispiel, bei dem bestimmt viele gesagt hätten, mit volatile ist alles korrekt 😉

    Insg. halte ich mich auch in C# von volatile fern. Selbst wenn ich halbwegs durchsteige, wann es ok ist und wann nicht, ist es doch immer ein Unsicherheitsfaktor. Also ne, da nutze ich lieber Interlocked und lock und bin auf der sicheren Seite. Shared memory across threads ist eh ein riesengroßes PITA, das muss man sich nicht mit volatile noch kniffliger machen imo.



  • Hmja, weiss nicht.

    Für so Sachen wie das Safe Publication Idiom würde ich schon volatile verwenden. Weil es da bekanntermassen reicht, und alles andere unnötiger Overhead wäre.

    Für andere, komplizietere Sachen nehme ich natürlich auch lock(). Irgendwie kompliziert mit volatile rumbasteln bringt da wirklich keinem was.

    Vor allem da die Interlocked Befehle eh mit jeder CPU Generation schneller werden. Bei Pentium 4 tat es echt noch weh. Auf den Core i's ist es dagegen schon fast uninteressant sich da den Kopf drüber zu zerbrechen. Das überlasse ich dann auch den Damen & Herren Low-Level-Threading-Library-Programmierern.



  • Ich gebe dir schon Recht, dass volatile seine Nischen-Daseinsberechtigung hat. Das Problem ist, dass ich es schon zu oft gesehen habe, dass es missbraucht und als "silver bullet to multithreading programming" benutzt wird 😃
    Das traumatisiert einen irgendwie schon und daher bin ich kein Freund davon, es zu verwenden, weil der nächste der daherkommt sich vllt. nicht bewusst ist, warum in dem Szenario volatile noch ok war, er jetzt aber was ändert und es eig. nicht mehr ok ist.
    Na ja, kann man halt so oder so sehen. Auf modernen CPUs ist lock und Interlocked ziemlich billig und idealerweise kommt man mit diesen Details eh nicht in Berührung und nimmt gleich die TPL. Dann macht man sich das Leben am Einfachsten 🙂


Log in to reply