In meinem neuen Team, das ich verwalte, besteht der Großteil unseres Codes aus Plattform-, TCP-Socket- und HTTP-Netzwerkcode. Alles in C ++. Das meiste davon stammt von anderen Entwicklern, die das Team verlassen haben. Die derzeitigen Entwickler im Team sind sehr schlau, aber in Bezug auf die Erfahrung meist jünger.
Unser größtes Problem: Multithread-Bugs. Die meisten unserer Klassenbibliotheken sind unter Verwendung einiger Thread-Pool-Klassen asynchron geschrieben. Methoden für die Klassenbibliotheken reihen häufig lange laufende Takes von einem Thread in den Threadpool ein, und dann werden die Rückrufmethoden dieser Klasse in einem anderen Thread aufgerufen. Infolgedessen gibt es eine Vielzahl von Fehlern in Randfällen, die falsche Threading-Annahmen beinhalten. Dies führt zu subtilen Fehlern, die über das bloße Vorhandensein kritischer Abschnitte und Sperren zum Schutz vor Parallelitätsproblemen hinausgehen.
Was diese Probleme noch schwerer macht, ist, dass die Versuche, sie zu beheben, oft falsch sind. Einige Fehler, die ich beim Versuch des Teams (oder im Legacy-Code selbst) beobachtet habe, umfassen Folgendes:
Häufiger Fehler Nr. 1 - Behebung des Problems der Parallelität durch einfaches Sperren der gemeinsam genutzten Daten, wobei jedoch vergessen wird, was passiert, wenn Methoden nicht in der erwarteten Reihenfolge aufgerufen werden. Hier ist ein sehr einfaches Beispiel:
void Foo::OnHttpRequestComplete(statuscode status)
{
m_pBar->DoSomethingImportant(status);
}
void Foo::Shutdown()
{
m_pBar->Cleanup();
delete m_pBar;
m_pBar=nullptr;
}
Jetzt haben wir einen Fehler, bei dem Shutdown aufgerufen werden könnte, während OnHttpNetworkRequestComplete ausgeführt wird. Ein Tester findet den Fehler, erfasst den Absturzspeicherauszug und weist den Fehler einem Entwickler zu. Er behebt den Fehler wie folgt.
void Foo::OnHttpRequestComplete(statuscode status)
{
AutoLock lock(m_cs);
m_pBar->DoSomethingImportant(status);
}
void Foo::Shutdown()
{
AutoLock lock(m_cs);
m_pBar->Cleanup();
delete m_pBar;
m_pBar=nullptr;
}
Die obige Korrektur sieht gut aus, bis Sie feststellen, dass es einen noch subtileren Randfall gibt. Was passiert, wenn Shutdown aufgerufen wird, bevor OnHttpRequestComplete zurückgerufen wird? Die realen Beispiele, die mein Team hat, sind noch komplexer und die Randfälle sind während des Codeüberprüfungsprozesses noch schwerer zu erkennen.
Häufiger Fehler Nr. 2 - Beheben von Deadlock-Problemen durch blindes Verlassen der Sperre, Abwarten, bis der andere Thread beendet ist, und erneutes Aufrufen der Sperre - jedoch ohne den Fall zu behandeln, dass das Objekt gerade vom anderen Thread aktualisiert wurde!
Allgemeiner Fehler Nr. 3 - Obwohl die Objekte referenziert sind, gibt die Abschaltsequenz ihren Zeiger "frei". Vergisst jedoch zu warten, bis der noch laufende Thread seine Instanz freigegeben hat. Als solches werden Komponenten sauber heruntergefahren, und dann werden falsche oder verspätete Rückrufe für ein Objekt in einem Zustand aufgerufen, in dem keine weiteren Aufrufe erwartet werden.
Es gibt andere Randfälle, aber die Quintessenz lautet:
Multithreading-Programmierung ist selbst für kluge Köpfe eine harte Angelegenheit.
Während ich diese Fehler auffange, diskutiere ich die Fehler mit jedem Entwickler, um eine geeignetere Lösung zu entwickeln. Ich vermute jedoch, dass sie häufig verwirrt sind, wie sie die einzelnen Probleme lösen sollen, da die "richtige" Korrektur eine enorme Menge an Legacy-Code enthält.
Wir werden bald ausliefern und ich bin mir sicher, dass die Patches, die wir anwenden, für die kommende Veröffentlichung verfügbar sein werden. Danach haben wir etwas Zeit, um die Codebasis zu verbessern und gegebenenfalls zu überarbeiten. Wir werden keine Zeit haben, einfach alles neu zu schreiben. Und der Großteil des Codes ist gar nicht so schlecht. Ich bin jedoch bestrebt, Code so umzugestalten, dass Threading-Probleme vollständig vermieden werden können.
Ein Ansatz, über den ich nachdenke, ist dieser. Verfügen Sie für jede wichtige Plattformfunktion über einen eigenen Thread, in dem alle Ereignisse und Netzwerkrückrufe zusammengefasst werden. Ähnlich wie bei COM-Apartment-Threading in Windows mit Verwendung einer Nachrichtenschleife. Lange Blockierungsvorgänge können immer noch an einen Arbeitspool-Thread gesendet werden, der Beendigungs-Callback wird jedoch für den Thread der Komponente aufgerufen. Möglicherweise teilen sich Komponenten sogar den gleichen Thread. Dann können alle im Thread ausgeführten Klassenbibliotheken unter der Annahme einer einzigen Thread-Welt geschrieben werden.
Bevor ich diesen Weg beschreite, bin ich auch sehr interessiert, ob es andere Standardtechniken oder Entwurfsmuster für den Umgang mit Multithread-Problemen gibt. Und ich muss betonen - etwas jenseits eines Buches, das die Grundlagen von Mutexen und Semaphoren beschreibt. Was denkst du?
Ich interessiere mich auch für andere Ansätze für einen Refactoring-Prozess. Einschließlich einer der folgenden:
Literatur oder Papiere über Designmuster um Fäden. Etwas jenseits einer Einführung in Mutexe und Semaphoren. Wir brauchen auch keine massive Parallelität, nur Möglichkeiten, ein Objektmodell so zu entwerfen, dass asynchrone Ereignisse von anderen Threads korrekt verarbeitet werden .
Möglichkeiten, das Threading verschiedener Komponenten grafisch darzustellen, so dass es einfach ist, Lösungen für das Threading zu studieren und zu entwickeln. (Dies ist eine UML-Entsprechung zum Erläutern von Threads über Objekte und Klassen hinweg.)
Informieren Sie Ihr Entwicklungsteam über die Probleme mit Multithread-Code.
Was würdest du tun?
quelle
Antworten:
Abgesehen davon weist Ihr Code wichtige andere Probleme auf. Zeiger manuell löschen? Aufrufen einer
cleanup
Funktion? Owch. Wie im Fragekommentar genau ausgeführt, verwenden Sie RAII nicht für Ihre Sperre, was ebenfalls ein ziemlich epischer Fehler ist und garantiert, dassDoSomethingImportant
schreckliche Dinge passieren , wenn eine Ausnahme ausgelöst wird.Die Tatsache, dass dieser Multithread-Fehler auftritt, ist nur ein Symptom des Kernproblems: Ihr Code hat in jeder Threading-Situation eine äußerst schlechte Semantik und Sie verwenden völlig unzuverlässige Tools und Ex-Idiome. Wenn ich Sie wäre, wäre ich erstaunt, dass es mit einem einzigen Thread funktioniert, geschweige denn mit mehr.
Der springende Punkt bei der Referenzzählung ist, dass der Thread seine Instanz bereits freigegeben hat . Wenn nicht, kann es nicht zerstört werden, da der Thread noch einen Verweis hat.
Verwenden Sie
std::shared_ptr
. Wenn alle Threads freigegeben wurden (und daher niemand die Funktion aufrufen kann, da er keinen Zeiger darauf hat), wird der Destruktor aufgerufen. Dies ist garantiert sicher.Verwenden Sie zweitens eine echte Threading-Bibliothek, z. B. die Thread-Bausteine von Intel oder die Parallel Patterns Library von Microsoft. Das Schreiben Ihres eigenen Codes ist zeitaufwändig und unzuverlässig, und der Code steckt voller Threading-Details, die er nicht benötigt. Das Erstellen eigener Sperren ist genauso schlimm wie das Erstellen einer eigenen Speicherverwaltung. Sie haben bereits viele allgemeine, sehr nützliche Threading-Redewendungen implementiert, die für Ihre Verwendung korrekt funktionieren.
quelle
Andere Poster haben gut kommentiert, was getan werden sollte, um die Kernprobleme zu beheben. Dieser Beitrag befasst sich mit dem unmittelbareren Problem, den alten Code so gut zu patchen, dass Sie Zeit haben, alles richtig zu wiederholen. Mit anderen Worten, dies ist nicht der richtige Weg , Dinge zu tun, es ist nur ein Weg, vorerst zu humpeln.
Ihre Idee, wichtige Ereignisse zu konsolidieren, ist ein guter Anfang. Ich würde so weit gehen, einen einzelnen Dispatch-Thread zu verwenden, um alle Schlüsselsynchronisationsereignisse zu behandeln, wo immer es eine Auftragsabhängigkeit gibt. Richten Sie eine thread-sichere Nachrichtenwarteschlange ein und veranlassen Sie die Ausführung oder den Auslöser des Vorgangs, wo immer Sie gegenwärtig parallele Vorgänge ausführen (Zuordnungen, Bereinigungen, Rückrufe usw.). Senden Sie stattdessen eine Nachricht an diesen Thread. Die Idee ist, dass dieser eine Thread alle Starts, Stopps, Zuordnungen und Aufräumarbeiten der Arbeitseinheit steuert.
Der Dispatch-Thread löst die von Ihnen beschriebenen Probleme nicht , sondern konsolidiert sie nur an einer Stelle. Sie müssen sich immer noch um Ereignisse / Nachrichten sorgen, die in unerwarteter Reihenfolge auftreten. Ereignisse mit erheblichen Laufzeiten müssen weiterhin an andere Threads gesendet werden, sodass weiterhin Probleme mit der gemeinsamen Nutzung freigegebener Daten auftreten. Eine Möglichkeit, dies zu verringern, besteht darin, die Weitergabe von Daten als Referenz zu vermeiden. Wann immer möglich, sollten die Daten in Versandnachrichten Kopien sein, die dem Empfänger gehören. (Dies entspricht der von anderen erwähnten Unveränderlichkeit von Daten.)
Der Vorteil dieses Dispatch-Ansatzes besteht darin, dass Sie innerhalb des Dispatch-Threads eine Art sicheren Hafen haben, in dem Sie zumindest wissen, dass bestimmte Vorgänge nacheinander ausgeführt werden. Der Nachteil ist, dass es zu einem Engpass und zusätzlichem CPU-Overhead kommt. Ich schlage vor, sich zunächst nicht um eines dieser Dinge zu kümmern: Konzentrieren Sie sich zunächst darauf, ein gewisses Maß an korrekter Funktionsweise zu erreichen, indem Sie so viel wie möglich in den Versand-Thread verschieben. Führen Sie dann eine Profilerstellung durch, um festzustellen, was die meiste CPU-Zeit in Anspruch nimmt, und verschieben Sie es mithilfe der richtigen Multithreading-Techniken wieder aus dem Dispatch-Thread.
Wiederum beschreibe ich nicht die richtige Vorgehensweise, sondern einen Prozess, der Sie in Schritten auf den richtigen Weg bringen kann, die klein genug sind, um die kommerziellen Fristen einzuhalten.
quelle
Basierend auf dem angezeigten Code haben Sie einen Stapel WTF. Es ist äußerst schwierig, wenn nicht unmöglich, eine schlecht geschriebene Multithread-Anwendung schrittweise zu reparieren. Teilen Sie den Eigentümern mit, dass die Anwendung ohne erhebliche Nacharbeit niemals zuverlässig ist. Geben Sie ihnen eine Schätzung basierend auf der Überprüfung und Überarbeitung jedes einzelnen Teils des Codes, der mit gemeinsam genutzten Objekten interagiert. Geben Sie ihnen zuerst einen Kostenvoranschlag für die Inspektion. Dann können Sie einen Kostenvoranschlag für die Nacharbeit abgeben.
Wenn Sie den Code überarbeiten, sollten Sie planen, den Code so zu schreiben, dass er nachweislich korrekt ist. Wenn Sie nicht wissen, wie man das macht, finden Sie jemanden, der es tut, oder Sie werden am selben Ort enden.
quelle
Wenn Sie etwas Zeit für die Umgestaltung Ihrer Anwendung haben, empfehle ich Ihnen, sich das Akteurmodell anzusehen (siehe z. B. Theron , Casablanca , libcppa , CAF für C ++ - Implementierungen).
Akteure sind Objekte, die gleichzeitig ausgeführt werden und nur über den asynchronen Nachrichtenaustausch miteinander kommunizieren. Alle Probleme des Thread-Managements, der Mutexe, Deadlocks usw. werden von einer Actor-Implementierungsbibliothek behandelt, und Sie können sich darauf konzentrieren, das Verhalten Ihrer Objekte (Actors) zu implementieren, was darauf hinausläuft, die Schleife zu wiederholen
Ein Ansatz für Sie könnte darin bestehen, zuerst etwas über das Thema zu lesen und sich möglicherweise eine oder zwei Bibliotheken anzusehen, um zu sehen, ob das Akteurmodell in Ihren Code integriert werden kann.
Ich benutze dieses Modell (eine vereinfachte Version) seit einigen Monaten in einem meiner Projekte und bin erstaunt, wie robust es ist.
quelle
Der Fehler ist hier nicht das "Vergessen", sondern das "Nicht-Reparieren". Wenn Dinge in unerwarteter Reihenfolge passieren, haben Sie ein Problem. Sie sollten es lösen, anstatt zu versuchen, es zu umgehen (ein Schloss auf etwas zu klopfen, ist normalerweise ein Workaround).
Sie sollten versuchen, das Darstellermodell / Messaging bis zu einem gewissen Grad anzupassen und eine getrennte Betroffenheit zu haben. Die Aufgabe von
Foo
ist eindeutig, irgendeine Art von HTTP-Kommunikation zu handhaben. Wenn Sie Ihr System so gestalten möchten, dass dies parallel erfolgt, muss die darüber liegende Ebene den Objektlebenszyklus behandeln und entsprechend auf die Synchronisierung zugreifen.Der Versuch, mehrere Threads mit denselben veränderlichen Daten zu betreiben, ist schwierig. Es ist aber auch selten notwendig. Alle gängigen Fälle, in denen dies erforderlich ist, wurden bereits in übersichtlicheren Konzepten zusammengefasst und mehrmals für etwa alle wichtigen Imperativsprachen implementiert. Sie müssen sie nur benutzen.
quelle
Ihre Probleme sind ziemlich schlimm, aber typisch für die schlechte Nutzung von C ++. Die Codeüberprüfung behebt einige dieser Probleme. 30 Minuten, ein Augapfel-Set ergibt 90% der Ergebnisse. (Zitat dafür ist googleable)
# 1 Problem Sie müssen sicherstellen, dass es eine strikte Sperrhierarchie gibt, um zu verhindern, dass das Sperren blockiert.
Wenn Sie Autolock durch einen Wrapper und ein Makro ersetzen, können Sie dies tun.
Behalten Sie eine statische globale Karte der auf der Rückseite Ihres Wrappers erstellten Sperren bei. Sie verwenden ein Makro, um die Informationen zu Finename und Zeilennummer in den Autolock-Wrapper-Konstruktor einzufügen.
Sie benötigen außerdem ein statisches Dominator-Diagramm.
Jetzt müssen Sie innerhalb der Sperre das Dominator-Diagramm aktualisieren. Wenn Sie eine Bestelländerung erhalten, machen Sie einen Fehler geltend und brechen ab.
Nach ausgiebigen Tests sind Sie möglicherweise von den meisten latenten Deadlocks befreit.
Der Code wird als Übung für den Schüler hinterlassen.
Problem Nr. 2 wird dann (meistens) verschwinden
Ihre archientualische Lösung wird funktionieren. Ich habe es schon in missions- und lebenskritischen Systemen verwendet. Ich nehme es so an
Teilen Sie keine Daten über öffentliche Variablen oder Getter.
Externe Ereignisse gehen über einen Multithread-Versand in eine Warteschlange ein, die von einem Thread bedient wird. Jetzt können Sie eine Art Grund für die Ereignisbehandlung angeben.
Datenänderungen, bei denen Cross-Threads in eine thread-sichere Warteschlange geraten, werden von einem Thread verarbeitet. Abonnements machen. Jetzt können Sie eine Art Grund für Datenflüsse angeben.
Wenn Ihre Daten stadtübergreifend sein müssen, veröffentlichen Sie sie in der Datenwarteschlange. Dadurch wird es kopiert und asynchron an die Abonnenten übergeben. Bricht auch alle Datenabhängigkeiten im Programm.
Dies ist so ziemlich ein billiges Schauspielermodell. Giorgios Links werden helfen.
Schließlich Ihr Problem mit heruntergefahrenen Objekten.
Bei der Referenzzählung haben Sie 50% gelöst. Die anderen 50% beziehen sich auf die Anzahl der Rückrufe. Pass-Rückruf-Inhaber erhalten eine Referenz. Der Abschaltaufruf muss dann auf die Nullzählung auf der Nachzählung warten. Löst keine komplizierten Objektgraphen; das ist immer in echte Müllabfuhr. (Was ist die Motivation in Java, keine Versprechungen darüber zu machen, wann oder ob finalize () aufgerufen wird; um Sie davon abzuhalten, auf diese Weise zu programmieren.)
quelle
Für zukünftige Entdecker: Um die Antwort zum Akteurmodell zu ergänzen, möchte ich CSP ( Communicating Sequential Processes ) hinzufügen , mit einer Anspielung auf die größere Familie von Prozesskalkülen, in denen CSP enthalten ist. CSP ähnelt dem Akteurmodell, ist jedoch unterschiedlich aufgeteilt. Sie haben immer noch eine Reihe von Threads, die jedoch nicht spezifisch miteinander, sondern über bestimmte Kanäle kommunizieren, und beide Prozesse müssen zum Senden bzw. Empfangen bereit sein, bevor dies geschieht. Es gibt auch eine formalisierte Sprache für den Nachweis des korrekten CSP-Codes. Ich arbeite immer noch intensiv mit CSP, aber ich verwende es jetzt seit einigen Monaten in einigen Projekten, und es ist stark vereinfacht.
Die University of Kent hat eine C ++ - Implementierung ( https://www.cs.kent.ac.uk/projects/ofa/c++csp/ , geklont unter https://github.com/themasterchef/cppcsp2 ).
quelle
Ich lese gerade das und es erklärt alle Probleme, die Sie bekommen können und wie Sie sie vermeiden können, in C ++ (unter Verwendung der neuen Threading-Bibliothek, aber ich denke, die globalen Erklärungen sind für Ihren Fall gültig): http: //www.amazon. com / C-Concurrency-Action-Practical-Multithreading / dp / 1933988770 / ref = sr_1_1? ie = UTF8 & qid = 1337934534 & sr = 8-1
Ich persönlich verwende eine vereinfachte UML und gehe einfach davon aus, dass Nachrichten asynchron verarbeitet werden. Dies gilt auch zwischen "Modulen", aber innerhalb von Modulen möchte ich nicht wissen müssen.
Das Buch würde helfen, aber ich denke, Übungen / Prototyping und erfahrener Mentor wären besser.
Ich würde völlig vermeiden, dass Leute, die Parallelitätsprobleme nicht verstehen, an dem Projekt arbeiten. Aber ich denke, Sie können das nicht tun. In Ihrem speziellen Fall habe ich keine Ahnung, außer dass Sie versuchen, das Team besser auszubilden.
quelle
Sie sind bereits unterwegs, indem Sie das Problem erkennen und aktiv nach einer Lösung suchen. Folgendes würde ich tun:
quelle
Betrachten Sie Ihr Beispiel: Sobald Foo :: Shutdown ausgeführt wird, darf es nicht mehr möglich sein, OnHttpRequestComplete aufzurufen, um ausgeführt zu werden. Das hat nichts mit einer Implementierung zu tun, es kann einfach nicht funktionieren.
Sie könnten auch argumentieren, dass Foo :: Shutdown nicht aufrufbar sein sollte, während ein Aufruf von OnHttpRequestComplete ausgeführt wird (definitiv wahr) und wahrscheinlich nicht, wenn ein Aufruf von OnHttpRequestComplete noch aussteht.
Das Erste, was richtig ist, ist nicht das Sperren usw., sondern die Logik dessen, was erlaubt ist oder nicht. Ein einfaches Modell wäre, dass Ihre Klasse keine oder mehr unvollständige Anforderungen, keine oder mehr noch nicht aufgerufene Abschlüsse, keine oder mehr laufende Abschlüsse hat und dass Ihr Objekt heruntergefahren werden soll oder nicht.
Es wird erwartet, dass Foo :: Shutdown die Ausführung von Abschlüssen abschließt, unvollständige Anforderungen so weit ausführt, dass sie nach Möglichkeit heruntergefahren werden können, dass keine weiteren Abschlüsse mehr gestartet werden können und dass keine weiteren Anforderungen gestartet werden können.
Was Sie tun müssen: Fügen Sie Ihren Funktionen Spezifikationen hinzu, die genau angeben, was sie tun werden. (Das Starten einer http-Anforderung kann beispielsweise fehlschlagen, nachdem Shutdown aufgerufen wurde.) Und dann schreiben Sie Ihre Funktionen so, dass sie den Spezifikationen entsprechen.
Sperren werden am besten nur für den kleinstmöglichen Zeitraum verwendet, um die Änderung gemeinsam genutzter Variablen zu steuern. Sie haben also möglicherweise eine Variable "performingShutDown", die durch eine Sperre geschützt ist.
quelle
Um ehrlich zu sein; Ich würde schnell weglaufen.
Nebenläufigkeitsprobleme sind SCHLECHT . Etwas kann monatelang perfekt funktionieren und dann (aufgrund des spezifischen Timings mehrerer Dinge) plötzlich im Gesicht des Kunden aufblähen, ohne herauszufinden, was passiert ist, ohne die Hoffnung, jemals einen schönen (reproduzierbaren) Fehlerbericht zu sehen und ohne die Möglichkeit um sicherzugehen, dass es sich nicht um einen Hardwarefehler handelte, der nichts mit der Software zu tun hatte.
Das Vermeiden von Parallelitätsproblemen muss während der Entwurfsphase beginnen und genau mit der Vorgehensweise beginnen ("globale Sperrreihenfolge", Akteurmodell, ...). Es ist nicht etwas, das Sie versuchen, in einer wahnsinnigen Panik zu beheben, in der Hoffnung, dass sich nach einer bevorstehenden Veröffentlichung nicht alles selbst zerstört.
Beachten Sie, dass ich hier nicht scherze. Ihre eigenen Worte ("Das meiste stammt von anderen Entwicklern, die das Team verlassen haben. Die derzeitigen Entwickler im Team sind sehr schlau, aber in Bezug auf die Erfahrung meist jünger. ") Weisen darauf hin, dass all diese Erfahrungen, die die Leute bereits gemacht haben, was ich getan habe schlage vor.
quelle