Macht async (launch :: async) in C ++ 11 Thread-Pools überflüssig, um teure Thread-Erstellung zu vermeiden?

117

Es hängt lose mit dieser Frage zusammen: Sind std :: thread in C ++ 11 zusammengefasst? . Obwohl die Frage unterschiedlich ist, ist die Absicht dieselbe:

Frage 1: Ist es immer noch sinnvoll, eigene Thread-Pools (oder Bibliotheken von Drittanbietern) zu verwenden, um eine teure Thread-Erstellung zu vermeiden?

Die Schlussfolgerung in der anderen Frage war, dass Sie sich nicht darauf verlassen können, zusammengefasst std::threadzu werden (es könnte sein oder es könnte nicht sein). Allerdings std::async(launch::async)scheint eine viel höhere Chance zu haben , gebündelt werden.

Es glaubt nicht, dass es vom Standard erzwungen wird, aber meiner Meinung nach würde ich erwarten, dass alle guten C ++ 11-Implementierungen Thread-Pooling verwenden, wenn die Thread-Erstellung langsam ist. Nur auf Plattformen, auf denen es kostengünstig ist, einen neuen Thread zu erstellen, würde ich erwarten, dass sie immer einen neuen Thread erzeugen.

Frage 2: Dies ist genau das, was ich denke, aber ich habe keine Fakten, um dies zu beweisen. Ich kann mich sehr gut irren. Ist es eine fundierte Vermutung?

Schließlich habe ich hier einen Beispielcode bereitgestellt, der zunächst zeigt, wie die Thread-Erstellung meiner Meinung nach ausgedrückt werden kann durch async(launch::async):

Beispiel 1:

 thread t([]{ f(); });
 // ...
 t.join();

wird

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Beispiel 2: Faden feuern und vergessen

 thread([]{ f(); }).detach();

wird

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Frage 3: Würden Sie die asyncVersionen den threadVersionen vorziehen ?


Der Rest ist nicht mehr Teil der Frage, sondern nur zur Klarstellung:

Warum muss der Rückgabewert einer Dummy-Variablen zugewiesen werden?

Leider erzwingt der aktuelle C ++ 11-Standard, dass Sie den Rückgabewert von erfassen std::async, da andernfalls der Destruktor ausgeführt wird, der blockiert, bis die Aktion beendet wird. Es wird von einigen als Fehler in der Norm angesehen (z. B. von Herb Sutter).

Dieses Beispiel von cppreference.com veranschaulicht es gut:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Eine weitere Klarstellung:

Ich weiß, dass Thread-Pools andere legitime Verwendungszwecke haben können, aber in dieser Frage interessiert mich nur der Aspekt, teure Kosten für die Thread-Erstellung zu vermeiden .

Ich denke, es gibt immer noch Situationen, in denen Thread-Pools sehr nützlich sind, insbesondere wenn Sie mehr Kontrolle über Ressourcen benötigen. Beispielsweise kann ein Server entscheiden, nur eine feste Anzahl von Anforderungen gleichzeitig zu verarbeiten, um schnelle Antwortzeiten zu gewährleisten und die Vorhersagbarkeit der Speichernutzung zu erhöhen. Thread-Pools sollten hier in Ordnung sein.

Thread-lokale Variablen können auch ein Argument für Ihre eigenen Thread-Pools sein, aber ich bin mir nicht sicher, ob sie in der Praxis relevant sind:

  • Erstellen eines neuen Threads mit std::threadStarts ohne initialisierte threadlokale Variablen. Vielleicht ist das nicht was du willst.
  • In Threads, die von erzeugt wurden async, ist es für mich etwas unklar, da der Thread möglicherweise wiederverwendet wurde. Nach meinem Verständnis wird nicht garantiert, dass threadlokale Variablen zurückgesetzt werden, aber ich kann mich irren.
  • Wenn Sie dagegen Ihre eigenen Thread-Pools (mit fester Größe) verwenden, haben Sie die volle Kontrolle, wenn Sie diese wirklich benötigen.
Philipp Claßen
quelle
8
"Es std::async(launch::async)scheint jedoch eine viel höhere Chance zu haben, gepoolt zu werden." Nein, ich glaube std::async(launch::async | launch::deferred), das kann zusammengefasst werden. Mit nur launch::asyncder Aufgabe soll auf einem neuen Thread gestartet werden, unabhängig davon, welche anderen Aufgaben ausgeführt werden. Mit der Richtlinie kann launch::async | launch::deferreddie Implementierung dann auswählen, welche Richtlinie, aber was noch wichtiger ist, sie kann die Auswahl der Richtlinie verzögern. Das heißt, es kann warten, bis ein Thread in einem Thread-Pool verfügbar wird, und dann die asynchrone Richtlinie auswählen.
Bames53
2
Soweit ich weiß, verwendet nur VC ++ einen Thread-Pool mit std::async(). Ich bin immer noch gespannt, wie sie nicht triviale thread_local-Destruktoren in einem Thread-Pool unterstützen.
Bames53
2
@ bames53 Ich habe das mit gcc 4.7.2 gelieferte libstdc ++ durchgearbeitet und festgestellt, dass die Startrichtlinie, wenn sie nicht genau launch::async ist, so behandelt wird, als ob sie nur wäre launch::deferredund sie niemals asynchron ausführt. Tatsächlich wählt diese Version von libstdc ++ "aus". immer aufgeschoben zu verwenden, sofern nicht anders erzwungen.
Doug65536
3
@ doug65536 Mein Punkt zu thread_local-Destruktoren war, dass die Zerstörung beim Thread-Beenden bei Verwendung von Thread-Pools nicht ganz korrekt ist. Wenn eine Aufgabe asynchron ausgeführt wird, wird sie gemäß der Spezifikation wie in einem neuen Thread ausgeführt. Dies bedeutet, dass jede asynchrone Aufgabe ihre eigenen thread_local-Objekte erhält. Eine Thread-Pool-basierte Implementierung muss besonders darauf achten, dass sich Aufgaben, die denselben Backing-Thread verwenden, weiterhin so verhalten, als hätten sie ihre eigenen thread_local-Objekte. Betrachten Sie dieses Programm: pastebin.com/9nWUT40h
bames53
2
@ bames53 Die Verwendung von "wie in einem neuen Thread" in der Spezifikation war meiner Meinung nach ein großer Fehler. std::asynckönnte eine schöne Sache für die Leistung gewesen sein - es könnte das Standard-Ausführungssystem für kurzfristige Aufgaben gewesen sein, das natürlich von einem Thread-Pool unterstützt wird. Im Moment ist es nur ein std::threadMist, der angeheftet wird, damit die Thread-Funktion einen Wert zurückgeben kann. Oh, und sie haben redundante "verzögerte" Funktionen hinzugefügt, die den Job von std::functionvollständig überlappen .
Doug65536

Antworten:

54

Frage 1 :

Ich habe dies gegenüber dem Original geändert, weil das Original falsch war. Ich hatte den Eindruck, dass die Erstellung von Linux-Threads sehr billig war, und nach dem Testen stellte ich fest, dass der Aufwand für den Funktionsaufruf in einem neuen Thread im Vergleich zu einem normalen Thread enorm ist. Der Aufwand für das Erstellen eines Threads zur Verarbeitung eines Funktionsaufrufs ist ungefähr 10000 oder mehr Mal langsamer als bei einem einfachen Funktionsaufruf. Wenn Sie also viele kleine Funktionsaufrufe ausführen, ist ein Thread-Pool möglicherweise eine gute Idee.

Es ist ziemlich offensichtlich, dass die mit g ++ gelieferte Standard-C ++ - Bibliothek keine Thread-Pools hat. Aber ich kann definitiv einen Fall für sie sehen. Selbst mit dem Aufwand, den Anruf durch eine Art Inter-Thread-Warteschlange schieben zu müssen, wäre dies wahrscheinlich billiger als das Starten eines neuen Threads. Und der Standard erlaubt dies.

Meiner Meinung nach sollten die Linux-Kernel-Leute daran arbeiten, die Thread-Erstellung billiger zu machen als derzeit. Die Standard-C ++ - Bibliothek sollte jedoch auch die Verwendung von Pool zur Implementierung in Betracht ziehen launch::async | launch::deferred.

Und das OP ist korrekt. ::std::threadWenn Sie einen Thread starten, wird natürlich die Erstellung eines neuen Threads erzwungen, anstatt einen aus einem Pool zu verwenden. Also ::std::async(::std::launch::async, ...)ist bevorzugt.

Frage 2 :

Ja, im Grunde startet dies "implizit" einen Thread. Aber wirklich, es ist immer noch ziemlich offensichtlich, was passiert. Ich denke also nicht, dass das Wort implizit ein besonders gutes Wort ist.

Ich bin auch nicht davon überzeugt, dass es notwendigerweise ein Fehler ist, Sie zu zwingen, vor der Zerstörung auf eine Rückkehr zu warten. Ich weiß nicht, dass Sie den asyncAufruf verwenden sollten, um 'Daemon'-Threads zu erstellen, von denen nicht erwartet wird, dass sie zurückkehren. Und wenn erwartet wird, dass sie zurückkehren, ist es nicht in Ordnung, Ausnahmen zu ignorieren.

Frage 3 :

Persönlich mag ich es, wenn Thread-Starts explizit sind. Ich lege großen Wert auf Inseln, auf denen Sie den seriellen Zugriff garantieren können. Andernfalls erhalten Sie den veränderlichen Status, dass Sie immer irgendwo einen Mutex umwickeln und daran denken müssen, ihn zu verwenden.

Ich mochte das Modell der Arbeitswarteschlange viel besser als das "zukünftige" Modell, da "Inseln der Serien" herumliegen, damit Sie den veränderlichen Zustand effektiver handhaben können.

Aber es kommt wirklich darauf an, was Sie genau tun.

Leistungstest

Also habe ich die Leistung verschiedener Methoden zum Aufrufen von Dingen getestet und diese Nummern auf einem 8-Kern-System (AMD Ryzen 7 2700X) gefunden, auf dem Fedora 29 ausgeführt wird, das mit clang Version 7.0.1 und libc ++ (nicht libstdc ++) kompiliert wurde:

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

Und nativ, auf meinem MacBook Pro 15 "(Intel (R) Core (TM) i7-7820HQ-CPU bei 2,90 GHz) Apple LLVM version 10.0.0 (clang-1000.10.44.4)unter OSX 10.13.6 bekomme ich Folgendes:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Für den Arbeitsthread habe ich einen Thread gestartet, dann eine sperrenlose Warteschlange verwendet, um Anforderungen an einen anderen Thread zu senden, und dann darauf gewartet, dass eine Antwort "Es ist erledigt" zurückgesendet wird.

Das "Nichts tun" dient nur zum Testen des Overheads des Testkabels.

Es ist klar, dass der Aufwand für das Starten eines Threads enorm ist. Und selbst der Worker-Thread mit der Warteschlange zwischen den Threads verlangsamt die Arbeit unter Fedora 25 in einer VM um den Faktor 20 und unter nativem Betriebssystem X um etwa 8.

Ich habe ein Bitbucket-Projekt erstellt, das den Code enthält, den ich für den Leistungstest verwendet habe. Es kann hier gefunden werden: https://bitbucket.org/omnifarious/launch_thread_performance

Allgegenwärtig
quelle
3
Ich stimme dem Modell der Arbeitswarteschlange zu, dies erfordert jedoch ein "Pipeline" -Modell, das möglicherweise nicht für jede Verwendung des gleichzeitigen Zugriffs anwendbar ist.
Matthieu M.
1
Scheint mir , wie Ausdrucksvorlagen (für Betreiber) könnten die Ergebnisse, für Funktionsaufrufe zu komponieren verwendet werden , würden Sie eine brauchen Anruf Methode , die ich denke , aber wegen Überlastung könnte es etwas schwieriger sein.
Matthieu M.
3
"sehr billig" ist relativ zu Ihrer Erfahrung. Ich finde, dass der Aufwand für die Erstellung von Linux-Threads für meine Verwendung erheblich ist.
Jeff
1
@ Jeff - Ich dachte, es wäre viel billiger als es ist. Ich habe meine Antwort vor einiger Zeit aktualisiert, um einen Test widerzuspiegeln, den ich durchgeführt habe, um die tatsächlichen Kosten zu ermitteln.
Omnifarious
4
Im ersten Teil unterschätzen Sie etwas, wie viel getan werden muss, um eine Bedrohung zu erzeugen, und wie wenig getan werden muss, um eine Funktion aufzurufen. Ein Funktionsaufruf und eine Rückgabe bestehen aus einigen CPU-Anweisungen, die einige Bytes oben im Stapel bearbeiten. Eine Bedrohungserstellung bedeutet: 1. Zuweisen eines Stapels, 2. Durchführen eines Systemaufrufs, 3. Erstellen von Datenstrukturen im Kernel und Verknüpfen dieser, Aufheben von Sperren auf dem Weg, 4. Warten auf die Ausführung des Threads durch den Scheduler, 5. Wechseln Kontext zum Thread. Jeder dieser Schritte an sich dauert viel länger als die komplexesten Funktionsaufrufe.
cmaster - wieder einsetzen Monica