Dieser Artikel wurde maschinell übersetzt.

Windows mit C++

Das Streben nach effizienten und modularen asynchronen Systemen

Kenny Kerr

 

Kenny KerrDie Umsetzung von Computer-Hardware beeinflusst stark das Design der Programmiersprache C imperativen Ansatz zur Computerprogrammierung zu folgen. Dieser Ansatz beschreibt ein Programm als eine Abfolge von Anweisungen, die den Programmzustand verkörpern. Dies war eine absichtliche Wahl von C Designer Dennis Ritchie. Es erlaubte ihm, eine echte Alternative zur Assemblersprache zu produzieren. Ritchie hat auch eine strukturierte und verfahrenstechnische Design, das sich zur Verbesserung der Qualität und Wartbarkeit von Programmen bewährt hat, zur Entstehung von erheblich mehr hoch entwickelte und leistungsfähige System-Software.

Eines bestimmten Computers Assemblersprache besteht normalerweise aus dem Satz von Anweisungen, die vom Prozessor unterstützt. Der Programmierer kann auf Register verweisen — wörtlich kleinem Arbeitsspeicher des Prozessors selbst — ebenso wie Adressen im Hauptspeicher. Assemblersprache enthält auch einige Anweisungen für das Springen an verschiedenen Stellen im Programm, einen simplen Weg, wieder verwendbaren Routinen zu erstellen. Um die Funktionen in c zu implementieren, ist eine kleine Menge an Speicher genannt die "Stack" reserviert. Zum größten Teil, diese Stack oder Stapel, speichert Informationen über jede Funktion, die aufgerufen wird, so dass das Programm automatisch Zustand speichern kann – sowohl lokale als auch gemeinsam mit ihrer Anrufer — und wissen, wo die Ausführung fortsetzen sollte, sobald die Funktion abgeschlossen ist. Dies ist ein Grundbestandteil des computing heute, dass die meisten Programmierer nicht es einen zweiten Gedanken geben, aber es ein unglaublich wichtiger Teil es möglich ist macht, effiziente und nachvollziehbare Programme zu schreiben. Betrachten Sie den folgenden Code:

int sum(int a, int b) { return a + b; }
int main()
{
  int x = sum(3, 4);
  return sum(x, 5);
}

Angesichts die Annahme der sequenziellen Ausführung, es ist offensichtlich – wenn keine explizite — was der Zustand des Programms zu einem bestimmten Zeitpunkt sein wird. Diese Funktionen wäre sinnlos, ohne erste vorausgesetzt, es ist eine automatische Speicherung für Funktionsargumente und Rückgabewerte, aber auch irgendwie damit das Programm weiß, wo man die Ausführung fortsetzen, wenn die Funktion Rückkehr aufruft. Für C- und C++-Programmierer ist es dem Stapel, der macht dies möglich und erlaubt uns, die einfachen und effizienten Code schreiben. Leider ist es auch unsere Abhängigkeit von dem Stapel, die C- und C++-Programmierern einem World of Hurt, verursacht Wenn es darum zu asynchronen geht Programmierung. Herkömmliche Systemen Programmiersprachen wie c und C++ anpassen müssen, um wettbewerbsfähig und produktiv bleiben in einer Welt voller zunehmend asynchrone Vorgänge. Obwohl ich, dass C-Programmierer weiterhin werden auf traditionelle Techniken Parallelität für einige Zeit zu erreichen vermute, bin ich zuversichtlich, dass C++ entwickeln sich schneller und bieten eine reichere Sprache mit dem effizienten und kombinationsfähige asynchrone Systeme geschrieben wird.

Letzten Monat erkundete ich ein einfaches Verfahren, mit denen Sie heute mit jedem c oder C++ Compiler leichtes kooperatives Multitasking durch Simulation Coroutinen mit Makros implementiert. Obwohl ausreichend für die C-Programmierer, präsentiert es einige Herausforderungen für die C++-Programmierer, der natürlich und zu Recht auf lokale Variablen unter anderen Konstrukten beruht, die die Abstraktion zu brechen. In diesem Artikel werde ich eine mögliche zukünftige Richtung für C++ auf direkte Unterstützung für die asynchrone Programmierung in einer natürlichen und kombinationsfähige Weise zu erkunden.

Aufgaben und Rippen der Stack

Wie ich in meinem letzten Artikel bereits (msdn.microsoft.com/magazine/jj553509), Parallelität nicht Gewinde Programmierung bedeuten. Dies ist eine Verschmelzung von zwei Dingen aber verbreitet genug, um einige Verwirrung stiften. Da die Sprache C++ ursprünglich explizite Parallelität unterstützen nicht, verwendet Programmierer natürlich unterschiedliche Techniken zur Erreichung des gleichen. Da die Programme immer komplexer wurden, wurde es notwendig — und vielleicht offensichtlich — Programme in logische Aufgaben unterteilen. Jede Aufgabe wäre eine Art Mini-Programm mit einen eigenen Stapel. In der Regel ein OS implementiert dies mit Fäden, und jeder Thread erhält einen eigenen Stapel. Dadurch können Aufgaben unabhängig und oft präventiv abhängig von der Terminplanung Politik und die Verfügbarkeit von mehreren Prozessorkernen ausgeführt. Jede Aufgabe oder Mini C++-Programm ist jedoch einfach zu schreiben und können dank der Stack-Isolation und den Staat der Stapel verkörpert nacheinander ausführen. Dieser ein-Thread-pro-Task-Ansatz hat einige offensichtlichen Beschränkungen, jedoch. Der pro Thread Overhead ist in vielen Fällen unerschwinglich. Auch wenn es nicht so, führt die mangelnde Zusammenarbeit zwischen Threads zu viel Komplexität wegen der Notwendigkeit zu synchronisieren Zugriff auf freigegebene Zustand oder Kommunikation zwischen Threads.

Ein weiterer Ansatz, der viel Popularität erlangte ist ereignisgesteuerte Programmierung. Vielleicht ist es offensichtlich, dass Parallelität nicht Gewinde programmieren, wenn man bedenkt das viele Beispiele für ereignisgesteuerte Programmierung in UI-Entwicklung und Bibliotheken, die unter Berufung auf eine Form der kooperativen Task-Management Implementieren von Rückruffunktionen bedeuten. Aber die Grenzen dieses Ansatzes sind mindestens so problematisch wie für den Ansatz einer-Thread-pro-Aufgabe. Sofort Ihr saubere, sequenzielle Programm ein Web wird — oder, optimistisch, eine Spaghetti-Stack — Rückruffunktionen statt eine zusammenhängende Folge von Anweisungen und Funktionsaufrufe. Dies wird manchmal genannt Stack zu zerreißen, weil eine Routine, die bisher einen einzigen Funktionsaufruf war nun in zwei oder mehr Funktionen zerrissen ist. Dies führt wiederum auch häufig zu einen Kräuselungeffekt innerhalb eines Programms. Stack Rippen ist katastrophal, wenn Sie überhaupt über Komplexität Pflege. Anstelle einer Funktion haben Sie jetzt mindestens zwei. Standardvorrang automatische Speicherung lokaler Variablen auf dem Stapel, müssen Sie jetzt explizit die Speicherung für diesen Zustand verwalten wie es zwischen einem Stapel Lage Überleben muss. Einfache Sprachkonstrukte wie Schleifen umgeschrieben werden müssen, um diese Trennung zu berücksichtigen. Debuggen von Programmen Stack-Riss ist schließlich viel schwieriger, weil der Staat des Programms nicht mehr im Stapel verkörpert ist und muss oft manuell "zusammengesetzt" in der programmer's Kopf. Betrachten Sie das Beispiel eines einfachen flash-Speicher-Treibers für ein eingebettetes System aus meinem letzten Artikel, ausgedrückt mit synchronen Vorgängen offensichtlich sequenzielle Ausführung bereitstellen:

void storage_read(void * buffer, uint32 size, uint32 offset);
void storage_write(void * buffer, uint32 size, uint32 offset);
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0);
  storage_write(buffer, sizeof(buffer), 1024);
}

Es ist nicht schwer herauszufinden, was hier vor sich geht. Ein 1 KB-Puffer, der der Stack unterstützt wird wird übergeben, der Storage_read-Funktion, die das Programm anhalten, bis die Daten in den Puffer gelesen wurde. Diese gleichen Puffer wird dann übergeben, der Storage_write-Funktion, die das Programm anhalten, bis die Übertragung abgeschlossen ist. An dieser Stelle das Programm gibt sicher, automatisch Freigeben der Stapelspeicher, der für den Kopiervorgang verwendet wurde. Der offensichtliche Nachteil ist, dass das Programm nicht tut, nützliche Arbeit während der angehaltenen, Wartezeit für die e/A abgeschlossen.

In meinem letzten Artikel zeigte ich eine einfache Technik für relevantes­Menting kooperatives Multitasking in C++ in einer Weise, mit dem Sie zurück zu einer sequenziellen Art der Programmierung. Allerdings ohne die Möglichkeit, lokale Variablen verwenden, ist es ein wenig begrenzt. Obwohl Stack Management automatische soweit Funktionsaufrufe bleibt und zurück gehen, ist der Verlust der automatische Stapelvariablen eine ziemlich schwere Einschränkung. Dennoch schlägt sie ausgewachsene Stack Rippen. Betrachten Sie, wie der vorhergehende Code aussehen könnte, wie mit einem traditionellen ereignisgesteuerten Ansatz und Sie können deutlich Stack Rippen in Aktion sehen. Erstens müssten die Speicherfunktionen neu deklariert werden, um irgendeine Art der Ereignisbenachrichtigung, häufig durch eine Callback-Funktion aufzunehmen:

typedef void (* storage_done)(void * context);
void storage_read(void * b, uint32 s, uint32 o, storage_done, void * context);
void storage_write(void * b, uint32 s, uint32 o, storage_done, void * context);

Als nächstes wäre das Programm selbst müssen umgeschrieben werden, um die entsprechenden Ereignishandlern zu implementieren:

void write_done(void *)
{
  ...
signal completion ...
}
void read_done(void * b)
{
  storage_write(b, 1024, 1024, write_done, nullptr);
}
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0, read_done, buffer);
  ...
wait for completion signal ...
}

Dies ist eindeutig sehr viel komplexer als die zuvor synchrone Methode, aber es ist sehr viel die Norm heute zwischen C- und C++-Programmen. Beachten Sie, wie der Kopiervorgang, der ursprünglich auf der main-Funktion beschränkt war nun über drei Funktionen verteilt ist. Nicht nur das, sondern Sie müssen fast Grund über das Programm in umgekehrter Richtung, wie der Write_done-Rückruf muss vor Read_done deklariert werden und es muss vor der main-Funktion deklariert werden. Noch, dieses Programm ist etwas vereinfacht, und Sie sollten schätzen, wie dies nur bekommen würden schwerfälliger als die "Kette von Ereignissen" in einer realen Anwendung vollständig realisiert wurde.

C ++ 11 hat einige bedeutende Schritte in Richtung auf eine elegante Lösung, aber wir sind noch nicht ganz da. Obwohl C ++ 11 jetzt hat viel zu sagen über die Parallelität in der Standardbibliothek, noch ist es weitgehend still in der Sprache selbst. Die Bibliotheken selbst gehen nicht auch weit genug ermöglicht die Programmierer, komplexere zusammensetzbare und asynchrone Programme leicht zu schreiben. Trotzdem tolle Arbeit getan wurde, und C ++ 11 bietet eine gute Grundlage für weitere Verbesserungen. Zunächst werde ich Ihnen zeigen, was C ++ 11 Angebote, dann was fehlt und schließlich eine mögliche Lösung.

Verschlüsse und Lambda-Ausdrücke

Im Allgemeinen ist ein Verschluss einer Funktion, gepaart mit einigen Staat identifizieren nichtlokale Informationen, die die Funktion zur Ausführung benötigt. Sollten Sie die TrySubmitThreadpoolCallback-Funktion, die ich in meinem Thread-Schwimmbad-Serie im vergangenen Jahr fallen (msdn.microsoft.com/magazine/hh335066):

void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * state) { ...
}
int main()
{
  void * state = ...
TrySubmitThreadpoolCallback(callback, state, nullptr);
  ...
}

Beachten Sie, wie die Windows-Funktion eine Funktion sowie einige Zustand akzeptiert. Dies ist in der Tat ein Verschluss im Unglück; Es sieht sicherlich nicht wie Ihre typischen Verschluss, aber die Funktion ist die gleiche. Funktionsobjekte erreichen wohl aus dem gleichen Grund. Verschlüsse wie ein erstklassiges Konzept zum Ruhm in der funktionalen Programmierung Welt, aber c stieg ++ 11 hat Fortschritte, das Konzept als auch in Form von Lambda-Ausdrücken zu unterstützen:

void submit(function<void()> f) { f(); }
int main()
{
  int state = 123;
  submit([state]() { printf("%d\n", state); });
}

In diesem Beispiel gibt es eine einfache Absenden-Funktion, die wir vorgeben kann, verursacht das bereitgestellte Funktion-Objekt in einen anderen Kontext ausführen. Das Function-Objekt wird aus einem Lambda-Ausdruck in der main-Funktion erstellt. Dieser einfache Lambda-Ausdruck enthält die notwendigen Attribute als eine Schließung und der Prägnanz überzeugend zu qualifizieren. Der Teil [Staat] gibt an, welchen Status "gefangen sein", und der Rest ist effektiv eine anonyme Funktion für den Zugriff auf diesen Zustand. Sie können deutlich sehen, dass der Compiler das moralische Äquivalent ein Funktionsobjekt, das abziehen zu erstellen. Die Sendefunktion Vorlage gewesen war, der Compiler möglicherweise sogar entfernt das Funktionsobjekt selbst, führt zu der Leistung neben der syntaktischen Gewinne optimiert haben. Die größere Frage ist jedoch, ob dies wirklich eine gültige Schließung. Schließt der Lambda-Ausdruck wirklich den Ausdruck durch die nichtlokale Variable binden? In diesem Beispiel sollte klären, zumindest einen Teil des Puzzles:

int main()
{
  int state = 123;
  auto f = [state]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

Dieses Programm druckt "123" und nicht "0" weil die Zustandsvariable durch Wert nicht durch Verweis erfasst wurde. Ich kann natürlich sagen es die Variable durch Verweis zu erfassen:

int main()
{
  int state = 123;
  auto f = [&]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

Hier bin ich den Standard-Capture-Modus zum Erfassen der Variablen durch Verweis und Vermietung der Compiler Abbildung heraus, dass ich die Zustandsvariable meine angeben. Wie zu erwarten ist, druckt das Programm nun pflichtgemäß "0" statt "123". Das Problem ist natürlich, dass der Speicher für die Variable noch auf den Stack-Frame gebunden ist, in der sie deklariert wurde. Wenn die Sendefunktion verzögert die Ausführung und der Stapel entlädt, dann der Staat wäre verloren und Ihr Programm wäre falsch.

Dynamische Sprachen wie JavaScript umgehen dieses Problem durch die Zusammenlegung der imperativen Welt von c mit einem funktionalen Stil, der stützt sich weit weniger auf dem Stapel mit den einzelnen Objekten wird im Wesentlichen eine ungeordnete assoziative Container. C ++ 11 stellt die Shared_ptr und Make_shared Vorlagen, die effiziente Alternativen zu bieten, auch wenn sie nicht ganz so präzise. Also, Lambda-Ausdrücke und intelligente Zeiger Teil des Problems lösen so dass Schließungen im Kontext definiert werden und damit die Zustand aus dem Stapel ohne zuviel syntaktische Overhead befreit sein. Es ist nicht ideal, aber es ist ein Anfang.

Versprechungen und Futures

Auf den ersten Blick ein weiteres C ++ 11 Feature namens Future erscheinen mag die Antwort geben. Sie können Futures als explizit asynchrone Funktionsaufrufe aktivieren vorstellen. Natürlich ist die Herausforderung bei der Definition, was genau das bedeutet und wie es umgesetzt wird. Es ist einfacher, die Zukunft mit einem Beispiel erklären. Eine Zukunft-fähige Version der ursprünglichen synchrone Storage_read Funktion kann wie folgt aussehen:

// void storage_read(void * b, uint32 s, uint32 o);
future<void> storage_read(void * b, uint32 s, uint32 o);

Beachten Sie, dass der einzige Unterschied ist, dass der Rückgabetyp einer künftigen Vorlage umschlossen wird. Die Idee ist, dass die neue Storage_read-Funktion wird beginnen oder die Übertragung vor der Rückgabe eines zukünftigen-Objekts in eine Warteschlange. Diese Zukunft kann dann als ein Synchronisierungsobjekt verwendet werden, um zu warten, bis der Vorgang abgeschlossen:

int main()
{
  uint8 buffer[1024];
  auto f = storage_read(buffer, sizeof(buffer), 0);
  ...
f.wait();
  ...
}

Diese könnten das Verbraucher Ende der asynchronen Gleichung aufgerufen werden. Die Storage_read-Funktion abstrahiert entfernt Provider Ende und ist genauso einfach. Die Storage_read-Funktion müsste erstellen ein Versprechen und es zusammen mit den Parametern der Anforderung eine Warteschlange und die zugehörige Zukunft. Auch ist dies leichter zu verstehen, im Code:

future<void> storage_read(void * b, uint32 s, uint32 o)
{
  promise<void> p;
  auto f = p.get_future();
  begin_transfer(move(p), b, s, o);
  return f;
}

Sobald der Vorgang abgeschlossen ist, kann die Treiber in die Zukunft signalisieren, dass sie bereit:

p.set_value();

Ist was das? Nun, Nein Wert überhaupt, weil wir das Versprechen und zukünftige Spezialisierungen für Void verwenden, aber Sie können sich vorstellen, eine Datei-System-Abstraktion baut auf diesen Speichertreiber, die eine File_read-Funktion enthalten könnte. Diese Funktion möglicherweise aufgerufen werden, ohne die Größe einer bestimmten Datei kennen. Sie könnten dann die tatsächliche Anzahl der übertragenen Bytes zurückgeben:

future<int> file_read(void * b, uint32 s, uint32 o);

In diesem Szenario würde auch ein Versprechen mit dem Typ Int verwendet werden, wodurch einen Kanal, durch die die Anzahl der Bytes zu kommunizieren tatsächlich übertragen:

promise<int> p;
auto f = p.get_future();
...
p.set_value(123);
...
f.wait();
printf("bytes %d\n", f.get());

Die Zukunft bietet die Get-Methode, durch die das Resultat erzielt werden kann. Toll, wir haben einen Weg des Wartens auf die Zukunft, und all unsere Probleme sind gelöst! Na ja, nicht so schnell. Löst dies wirklich unser Problem? Können wir mehrere Vorgänge gleichzeitig kick-off? Ja, Können wir komponieren einfach Aggregatvorgänge zu oder auch nur auf einzelne oder alle ausstehende Operationen warten? Nr. In der synchronen Beispiel begann der Lesevorgang unbedingt vor der Schreibvorgang abgeschlossen. Also Future nicht in der Tat uns sehr weit kommen. Das Problem ist, dass der Akt des Wartens auf eine Zukunft immer noch eine synchrone Operation ist und es kein Standardverfahren gibt, eine Kette von Ereignissen zu komponieren. Es gibt auch keine Möglichkeit ein Aggregat von Futures zu erstellen. Vielleicht möchten nicht eine, sondern jede Anzahl der Futures warten. Sie müssen möglicherweise warten, bis alle Futures oder nur die erste, die bereit ist.

Zukunft in die Zukunft

Das Problem mit Futures und Versprechen ist, dass sie nicht weit genug gehen und sind wohl völlig mangelhaft. Methoden wie z. B. warten und bekommen, beide welche Block, bis das Ergebnis fertig ist, sind antithetisch Parallelität und asynchrone Programmierung. Anstelle von Get-wir etwas wie Try_get, die versucht brauchen, das Ergebnis abrufen, wenn es ist verfügbar, aber wieder sofort, unabhängig davon:

int bytes;
if (f.try_get(bytes))
{
  printf("bytes %d\n", bytes);
}

Weiter zu gehen, sollten Future Fortsetzung Mechanismus bereitgestellt, so dass wir einfach einen Lambda-Ausdruck mit dem Abschluss des asynchronen Vorgangs zuordnen können. Dies ist, wenn wir damit beginnen, die Kombinierbarkeit der Futures finden Sie unter:

int main()
{
  uint8 buffer[1024];
  auto fr = storage_read(buffer, sizeof(buffer), 0);
  auto fw = fr.then([&]()
  {
    return storage_write(buffer, sizeof(buffer), 1024);
  });
  ...
}

Die Storage_read-Funktion gibt das zukünftige lesen (fr) und ein Lambda-Ausdruck wird verwendet, um eine Fortsetzung dieser Zukunft mit seiner dann Methode, wodurch eine Zukunft schreiben (Fw) zu konstruieren. Da Future immer zurückgegeben werden, könnten Sie eine mehr implizite bevorzugen aber gleicher Stil:

auto f = storage_read(buffer, sizeof(buffer), 0).then([&]()
{
  return storage_write(buffer, sizeof(buffer), 1024);
});

In diesem Fall gibt es nur eine einzige explizite Zukunft, den Höhepunkt aller Vorgänge darstellen. Das sequentielle Komposition aufgerufen werden könnte, sondern parallel and-und OR-Zusammensetzung wäre auch notwendig für die meisten nicht trivialen Systeme (glaube WaitForMultipleObjects). In diesem Fall bräuchten wir ein paar Variadic-Funktionen Wait_any und Wait_all. Diese würde wieder, Future, ermöglicht es uns, einen Lambda-Ausdruck als Fortsetzung des Aggregats Messungennachdem dann wie zuvor zur Verfügung stellen zurückgeben. Es könnte auch nützlich sein, abgeschlossene Zukunft an die Fortsetzung in Fällen zu übergeben, wo bestimmte Zukunft, die abgeschlossen ist offensichtlich nicht.

Für eine umfassendere Blick auf die Zukunft der Futures, einschließlich das wesentliche Thema Storno, bitte schauen bei Artur Laksberg und Niklas Gustafsson Papier, "A Standard programmgesteuerte Schnittstelle für asynchrone Operationen," bit.ly/MEgzhn.

Stay tuned für die nächste Rate, wo ich tiefer zu graben, in die Zukunft der Futures und zeigen Ihnen einen noch mehr Flüssigkeit Ansatz zu schreiben, effiziente und kombinationsfähige asynchrone Systeme.

Kenny Kerr ist ein Software-Handwerker mit einer Leidenschaft für native Windows-Entwicklung. Sie erreichen ihn unter kennykerr.ca.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Artur Laksberg