© Chr. Vogt, FH München 1 1 Was ist Systemprogrammierung

Transcrição

© Chr. Vogt, FH München 1 1 Was ist Systemprogrammierung
1
2
3
4
5
6
7
Was ist Systemprogrammierung? .....................................................................................2
Windows-Programmierung................................................................................................3
2.1
Konsolenbasierte Programme ...................................................................................3
2.2
GUI-Programme.........................................................................................................4
Das Win32 Application Programming Interface ................................................................5
3.1
Win32-Plattformen .....................................................................................................5
3.2
Erlernen von Win32 ...................................................................................................6
3.2.1
Die Microsoft Development Library (MSDN).......................................................6
3.2.2
Bücher ................................................................................................................6
3.2.3
Win32 Header-Dateien .......................................................................................7
3.2.4
Format der Win32-API-Dokumentation...............................................................7
3.2.5
Fehlerbehandlung...............................................................................................8
Objekte und Handles.......................................................................................................10
4.1
Objekte.....................................................................................................................10
4.1.1
Arten von Objekten ...........................................................................................11
4.1.2
Benannte Objekte .............................................................................................11
4.2
Handles....................................................................................................................11
4.2.1
Öffnen und Schließen von Handles ..................................................................12
4.2.2
Vererbung und Duplikation von Handles ..........................................................12
4.2.3
Pseudohandles .................................................................................................14
Prozesse und Threads ....................................................................................................15
5.1
Erzeugen von Prozessen.........................................................................................15
5.2
Beenden von Prozessen..........................................................................................18
5.3
Weitere Funktionen für Prozesse.............................................................................19
5.4
Erzeugen von Threads.............................................................................................19
5.5
Beenden von Threads..............................................................................................20
5.6
Weitere Funktionen für Threads ..............................................................................21
Synchronisation...............................................................................................................23
6.1
Warten auf Synchronisationsobjekte .......................................................................23
6.2
Critical Sections .......................................................................................................27
6.3
Mutexe .....................................................................................................................28
6.4
Semaphore ..............................................................................................................29
6.5
Events ......................................................................................................................31
6.6
Waitable Timer.........................................................................................................32
6.7
Funktionen für atomare Operationen .......................................................................36
Asynchrone I/Os..............................................................................................................38
7.1
Anfordern asynchroner I/Os.....................................................................................39
7.2
Synchronisation mit der I/O-Beendigung .................................................................41
7.3
APCs als I/O-Beendigungsroutine ...........................................................................42
7.4
I/O Completion Ports................................................................................................44
© Chr. Vogt, FH München
1
1 Was ist Systemprogrammierung?
Eine häufig sehr hilfreiche erste Informationsquelle, der Duden Informatik, schweigt sich unter diesem Stichwort aus.
Es gibt keine einheitliche Definition des Begriffes Systemprogrammierung. In der Windowsund Unix-Welt versteht man darunter das, was der Name nahelegt: die Programmierung
unter Verwendung von Systemaufrufen, also offengelegten Schnittstellen zu Routinen des
Betriebssystems. In der IBM- Mainframe-Welt (MVS, z/OS) versteht man darunter eher die
Systemverwaltung. Vielleicht nicht gerade das Einrichten eines Druckers, aber viele andere
Aufgaben der Systemverwaltung erfordern dort eine systemnahe (Assembler-)Programmierung. Dies erklärt, warum in dieser Welt für Systemverwaltungsaufgaben der Begriff
Systemprogrammierung verwendet wird.
In der Windows-Welt wie auch unter den verschiedenen Unix-Derivaten stehen für die
meisten Aufgaben der Systemverwaltung mächtige Dienstprogramme zur Verfügung, so
dass hier für diese Aufgaben eine (systemnahe) Programmierung nicht notwendig ist. (Unter
Unix heißt dieses „mächtige“ Dienstprogramm in sehr vielen Fällen vi.)
Im Wesentlichen wollen wir in dieser Vorlesung deshalb unter Systemprogrammierung (im
Umfeld von Windows) die Verwendung von systemnahen Funktionen in Programmen verstehen. Daneben würde ich aber auch einige Aufgaben der Systemverwaltung unter diesen
Begriff einordnen, für die keine oder nur sehr rudimentäre Dienstprogramme zur Verfügung
stehen, und die Kenntnisse über Interna des Betriebssystems erfordern, z.B. den Umgang
mit der Registrierungsdatenbank.
Auch das Installieren, Konfigurieren und Aktualisieren des Betriebssystems und sonstiger
Software, oder anspruchsvolle, fortgeschrittene Aufgaben wie das (hoffentlich selten nötige)
Auswerten von „Blue Screens“ würde ich unter den Begriff der Systemprogrammierung einordnen. Die Systembeobachtung und das „Tunen“ des Systems sind ebenfalls Aufgaben, für
die zwar Dienstprogramme zur Verfügung stehen, die aber nicht per Kochrezept, sondern
nur mit einem hinreichend tiefen Verständnis der Vorgänge am System ausgeführt werden
können.
Meine Privat-Definition von Systemprogrammierung lautet deshalb:
Alle Aufgaben der Programmierung und der Systemverwaltung (Installation, Konfiguration, Systembeobachtung und System-Tuning), die mehr oder weniger tiefe und
fundierte Kenntnisse des internen Aufbaus des Betriebssystems erfordern.
In diesem Sinne werden wir uns in dieser Vorlesung einige Rosinen der Systemprogrammierung näher betrachten. Auf jeden Fall werden dazu die Programmierung unter Verwendung
von (System-)Routinen für den Umgang mit Prozessen und Threads sowie die verschiedenen Synchronisationsmechanismen gehören.
© Chr. Vogt, FH München
2
2 Windows-Programmierung
Ein Programm, das Sie z.B. an einem Unix-System von einem Terminal aus starten, läuft an
einer bestimmten Stelle los (ein C-Programm mit der Routine main()) und führt dann die im
Programm stehenden Anweisungen der Reihe nach aus. Ein- und Ausgaben geschehen
dann, wenn das Programm eine entsprechende Anweisung enthält und ausführt. Die Benutzerschnittstelle besteht also aus Textein- und -ausgaben zu den vom Programm vorgesehenen Zeitpunkten. Ein solches Programm wird auch als prozedural bezeichnet.
Ein Programm unter Windows läuft üblicherweise ganz anders ab. Sie kennen alle das typische Aussehen eines Windows-Programms mit Fenstern, Menüs, Schaltflächen usw. Unabhängig vom momentan ausgeführten Programmcode können Sie Schaltflächen betätigen,
Menüs öffnen und Menüpunkte auswählen, zu einem anderen Fenster (Programm) wechseln
oder Fenster schließen usw. Das Programm muß dann geeignet auf diese asynchronen Eingaben reagieren. Man spricht in diesem Zusammenhang von ereignisorientierter Programmierung. Häufig ist ein Programm einfach in einem Wartezustand und wartet auf das nächste
Ereignis.
Win32 unterstützt zwei prinzipielle Arten von Programmen:
•
•
2.1
Konsolenbasierte Programme (CUI = console user interface)
Programme mit einer grafischen Oberfläche (GUI = graphical user interface)
Konsolenbasierte Programme
Ein konsolenbasiertes Programm ähnelt einem Programm, wie Sie es z.B. auch unter Unix
verwenden würden: der Programmablauf ist prozedural, Ein- und Ausgaben geschehen nur
aufgrund entsprechender Anweisungen im Programm. Aber wo geschehen diese Ein- und
Ausgaben? Auch ein konsolenbasiertes Programm benötigt ein Fenster, die sog. Konsole,
das jedoch nur herkömmliche Textzeichen enthält (wie z.B. das MS/DOS-Fenster unter
Windows). Aber auch für ein Konsolfenster sind natürlich gewisse Ereignisse möglich. Das
Fenster kann z.B. verkleinert oder vergrößert oder ganz geschlossen werden. Um diese
Standard-Fensterverwaltung kümmert sich das Windows-Betriebssystem und beendet z.B.
beim Schließen eines Konsolfensters das entsprechende Programm (das hoffentlich darauf
vorbereitet ist ...).
Und wer erzeugt das Konsolfenster beim Starten des Programms? Ein in der EXE-Datei gespeicherter Subsystem-Wert enthält die Information, ob es sich bei dem Programm um eine
konsolenbasierte oder um eine GUI-Anwendung handelt. Im Falle einer konsolenbasierten
Anwendung sorgt der Lader dafür, dass für das Programm ein Konsolfenster erzeugt wird.
Eine GUI-Anwendung dagegen wird direkt geladen, da sie ja die benötigten Fenster in ihrem
Programmcode selbst erzeugt.
Wenn Sie beim Erzeugen eines Programms mit Visual C++ oder Borland C++ angeben,
dass es sich um eine konsolenbasierte Anwendung handeln soll, wird ein Linker-Schalter
gesetzt, so dass der Subsystem-Wert in der EXE-Datei auf CUI gesetzt wird. Das Programm
muß dann eine main()-Funktion enthalten, die von der Startup-Routine crt0 der Laufzeitbibliothek angesprungen wird. Sie können also Ihr Programm genau so schreiben, wie Sie es
aus einer Nicht-Windows-Umgebung gewöhnt sind.
© Chr. Vogt, FH München
3
2.2
GUI-Programme
Bei einem GUI-Programm wird von der Startup-Routine wincrt0 der Laufzeitbibliothek die
Funktion WinMain aufgerufen. Der Prototyp von WinMain ist etwas komplizierter. Er soll
uns hier nicht näher interessieren, da die Entwicklungsumgebungen diesen Teil des Programms automatisch erzeugen.
GUI-Programme werden weiter unterteilt in die folgenden drei Arten von Anwendungen:
•
•
•
Single Document Interface (SDI)
Multiple Document Interface (MDI)
Dialogbasierte Anwendungen
SDI- und MDI-Anwendungen dienen – wie schon der Name sagt – der Bearbeitung von Dokumenten.
Beispiele für SDI-Anwendungen sind die Editoren Notepad und WordPad von Windows: Sie
können immer nur ein Dokument bearbeiten. Beim Öffnen eines neuen Dokuments wird das
zuvor geöffnete Dokument geschlossen.
Beispiele für MDI-Anwendungen sind übliche Textverarbeitungen oder Tabellenkalkulationen
oder auch Paint usw. Sie können dort mehrere Dokumente öffnen, die jeweils in einem eigenen Fenster innerhalb des Anwendungsfensters dargestellt werden, und können beliebig
zwischen den einzelnen Dokument-Fenstern hin- und herwechseln.
Dialogbasierte Anwendungen sind Programme, deren Hauptprogrammfenster eine Dialogbox ist. In einer solchen Anwendung werden keine Dokumente bearbeitet, sondern andere
Funktionen ausgeführt (z.B. Berechnungen), wobei die Steuerung der Funktionen mit Hilfe
der Dialogbox, also mit Eingabefeldern, Schaltflächen, Menüs usw. geschieht. Die meisten
Verwaltungsprogramme von Windows (Benutzermanager, Systemmonitor usw.) sind solche
dialogbasierten Anwendungen.
Die Gestaltung des Dialogfensters ist mit Hilfe der Entwicklungsumgebung sehr einfach. Es
gibt einen Ressourcen-Editor, mit dem Sie das Fenster gestalten können, ohne eine einzige
Zeile Code zu schreiben und somit ohne etwas über die Art der Implementierung der verschiedenen Elemente wissen zu müssen.
Wie hängen aber jetzt die einzelnen Elemente des Dialogfensters mit dem Programm zusammen? Dazu gehören zwei Dinge:
1. Ein- und Ausgabefelder müssen mit Variablen des Programms verknüpft werden.
2. Für jede mögliche Aktion, z.B. das Klicken auf eine Schaltfläche, muß eine Behandlungsroutine geschrieben werden.
Bei beiden Aufgaben unterstützt Sie wieder die Entwicklungsumgebung. Um vieles müssen
Sie sich überhaupt nicht kümmern, z.B. um die (programmtechnische) Verknüpfung der Aktionen im Dialogfenster mit den von Ihnen geschriebenen Routinen.
© Chr. Vogt, FH München
4
3 Das Win32 Application Programming Interface
Das Win32 Application Programming Interface (API) ist die wichtigste Schnittstelle zum Betriebssystem Windows. Obwohl Windows auch andere APIs unterstützt (z.B. POSIX und
OS/2, allerdings nicht mehr ab Windows XP), bietet das Win32 API Zugriff auf den größten
Teil der Möglichkeiten, die Windows bietet. So enthält z.B. das POSIX-Subsystem keine
Funktionen für Multithreading oder asynchrone Ein-/Ausgaben.
Das Win32 API besteht aus drei Hauptgruppen von Funktionen:
•
•
•
Base System Services
User Services (Windows management)
Graphics device interface (GDI) Services
Die Base System Services bieten Zugriff auf Funktionen des Betriebssystems, wie z.B. den
Umgang mit Prozessen und Threads, die Speicherverwaltung, die Durchführung von Ein-/Ausgaben oder das Verwenden von Sicherheitsmechanismen. Mit einigen dieser Funktionen
werden wir uns in dieser Vorlesung beschäftigen.
Bemerkung: Die Base System Services sind nicht die „eigentlichen“ Systemaufrufe des
Betriebssystems. Letztere wurden von Microsoft nicht offengelegt, so dass nur über die dokumentierten Subsystem-APIs, also z.B. das Win32-API, auf sie zugegriffen werden kann.
Die User Services beinhalten Funktionen zur Gestaltung der Benutzerschnittstelle, also für
die Verwaltung von Fenstern und von Tastatur- und Mauseingaben. Mit Hilfe der GDI Services können Applikationen grafische Ausgaben erzeugen. Keine dieser Funktionen wird in
dieser Vorlesung behandelt. Die Gestaltung der Benutzerschnittstelle wird durch Compiler
wie Borland C++ oder Microsoft Visual C++ stark vereinfacht, und der dafür notwendige
Code größtenteils automatisch generiert.
Insgesamt besteht das Win32 API aus über 1500 Funktionen!
3.1
Win32-Plattformen
Das Win32 API ist die Spezifikation einer Programmierschnittstelle. Diese Schnittstelle oder
Teilmengen davon sind nicht nur auf Windows NT / 2000 / XP implementiert, sondern auf
insgesamt sechs Plattformen:
•
•
•
•
•
•
Windows 3.1 (mit den Win32s-Bibliotheken).
Bietet dieselbe Funktionalität wie Windows 3.1, aber mit der 32-bit-Programmierschnittstelle. Bietet darüber hinaus einige Erweiterungen, z.B. einen linearen 32-bit-Adreßraum
sowie strukturierte Ausnahmebehandlung.
Windows 95 / 98 / ME
Bietet den größten Teil der Funktionalität wie unter Windows, aber z.B. keine Funktionen
für Sicherheit, event logging oder asynchrone Ein-/Ausgaben.
Windows NT / 2000 / XP
Macintosh
OpenVMS (Angebote von Bristol Technology und Mainsoft Corporation).
Die Laufzeit-Bibliotheken von Wind/U von Bristol sind sogar in VMS enthalten.
Unix (Angebote von Bristol Technology und Mainsoft Corporation).
© Chr. Vogt, FH München
5
3.2
Erlernen des Win32 API
3.2.1 Die Microsoft Developer’s Network Library
Die Microsoft Developer’s Network (MSDN) Library ist eine regelmäßige CD-ROMDistribution, die (für teures Geld) abonniert werden kann. Wenn man nur kleine Teile daraus
benötigt, findet man sie aber auch im Internet unter msdn.microsoft.com/library/.
In der MSDN Library findet man
•
•
•
•
die gesamte Win32-API-Dokumentation,
die Win32 „Knowledge Base“, eine Problemdatenbank,
jede Menge Beispielprogramme,
viele technische Artikel.
Die Fülle der Informationen in der MSDN ist erschlagend. Leider ist die Strukturierung nicht
allzu übersichtlich, so dass es einige Zeit und Mühe kostet, die Informationen zu finden, die
einen interessieren. Aber vorhanden sind sie höchstwahrscheinlich!
Für unsere Vorlesung am interessantesten und am wichtigsten ist der Abschnitt
Windows Development -> Windows Base Services. Die meisten Unterabschnitte gliedern
sich wiederum in „SDK Documentation“ und „Technical Articles“.
3.2.2 Bücher
Außerdem gibt es natürlich diverse Bücher zum Win32-API. Ein Buch, das als Begleitung zur
Vorlesung gut geeignet ist (mit seinen über 1000 Seiten aber auch noch jede Menge weitere
Informationen enthält) ist:
Jeffrey M. Richter: Windows Programmierung für Experten, 4. Auflage 2000, Microsoft Press
Deutschland
Es kann gut sein, dass eines der Beispielprogramme aus diesem Buch Ihnen in der Vorlesung bzw. im Praktikum wieder begegnet.
Weitere Bücher mit derselben Zielsetzung, aber unterschiedlicher Stoffauswahl und Darstellung sind:
Marshall Brain: Win32 System Services, 3rd ed., Prentice Hall 2001
Johnson M. Hart: Win32 System Programming, 2nd ed., Addison-Wesley 2000
Von Jeffrey M. Richter gibt es auch noch das Buch „Microsoft .NET Framework Programmierung“, Microsoft Press 2002.
© Chr. Vogt, FH München
6
3.2.3 Win32 Header-Dateien
Die Win32-API-Header-Dateien enthalten Informationen über vorhandene Konstanten und
Strukturen sowie Details der Programmierschnittstelle. Es handelt sich um viele kleine Dateien, die alle von der Datei Windows.h referenziert werden. Zum Beispiel enthält die
Datei WinBase.h die Definitionen der Base System Services.
Wenn Sie mit Visual Studio .NET 2003 arbeiten, finden Sie die Header-Dateien in
\Vc7\PlatformSDK\include unterhalb des Wurzelverzeichnisses für Visual Studio.
Da Sie in den Praktikumsaufgaben mit den Base System Services arbeiten werden, sollten
Sie in Ihren Programmen immer Windows.h inkludieren. Manchmal ist es auch interessant,
sich die eine oder andere Definition oder Deklaration in einer der Header-Dateien direkt
anzuschauen.
3.2.4 Format der Win32-API-Dokumentation
Die Dokumentation einer Win32-API-Funktion enthält Angaben über Bedeutung und Datentyp der Parameter und des Rückgabewertes der Funktion. Ein typisches Beispiel:
DWORD WaitForMultipleObjects(
DWORD nCount,
CONST HANDLE *lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds
);
// number of handles in the object handle array
// pointer to the object-handle array
// wait flag
// time-out interval in milliseconds
Die Angabe DWORD in der ersten Zeile ist der Datentyp des Rückgabewertes der Funktion,
in diesem Falle ein double word. Außerdem enthält die erste Zeile den Namen der Funktion
(fett). Die folgenden Zeilen enthalten den Datentyp (DWORD, BOOL usw.) (fett) der einzelnen Parameter, den Parameternamen (kursiv) und einen Kommentar, der den Parameter
beschreibt. Der Parameter wird in der sog. „ungarischen“ Notation geschrieben (die Bezeichnung kommt von der ungarischen Abstammung des Microsoft-Entwicklers, der diese
Notation erfunden hat):
datatypeParametername
So ist z.B. der Parameter nCount vom Datentyp „n“ (number); lpHandles ist ein longword
pointer auf ein Feld von Handles. Einige gebräuchliche Datentypen sind:
Zeichen
c
b
n
b oder f
w
l
dw
p
fn
sz
lph
Datentyp
char oder count
BYTE (unsigned char)
short oder int
BOOL (int)
WORD (unsigned int)
LONG (long)
DWORD (unsigned long)
pointer
function
mit 0 abgeschlossene Zeichenkette (string, zero terminated)
long pointer auf ein Handle
© Chr. Vogt, FH München
7
Natürlich enthält die Dokumentation einer Funktion neben der oben angegebenen Kurzform
auch noch ausführlichere Erläuterungen, z.B. zu
•
•
•
•
•
•
der Funktionalität der Funktion,
der Bedeutung von und den möglichen Angaben für die Parameter,
dem Rückgabewert der Funktion,
evtl. Einschränkungen (z.B. benötigten Rechten) oder Vorsichtsmaßnahmen,
den Windows-Versionen, unter denen die Funktion zur Verfügung steht,
den Header-Dateien und Bibliotheken, die für die Funktion benötigt werden.
3.2.5 Fehlerbehandlung
Es gibt keine standardisierte Methode um festzustellen, ob die Ausführung einer Funktion
einen Fehler produzierte. Einige Funktionen geben bei Erfolg eine 0 zurück, andere geben
bei einem Fehler eine 0 zurück, andere erzeugen einfach einen Rückgabewert. Die meisten
Funktionen geben bei einem Fehler NULL zurück und sagen, dass man GetLastError aufrufen soll, um den Grund für den Fehler herauszufinden.
DWORD GetLastError(VOID)
Auf den von GetLastError zurückgegebenen Fehlercode kann dann die Applikation geeignet reagieren, oder der Code kann mit FormatMessage in einen Nachrichtentext umgewandelt und z.B. ausgegeben werden. Leider gibt die Dokumentation einer Funktion jedoch
nicht an, welche Fehlercodes von dieser Funktion erzeugt werden können.
Ohne auf die einzelnen Parameter einzugehen hier ein Beispiel für die Verwendung von
FormatMessage:
FormatMessage(
FORMAT_MESSAGE_FROM_SYSTEM, // System Message Table verwenden
NULL,
GetLastError(),
0,
(LPTSTR) &lpMsgBuf,
// Zeiger auf einen Puffer
200,
// Größe des Puffers
NULL
);
In Visual Studio gibt es eine einfache Möglichkeit, zu einem numerischen Fehlercode eine
Kurzbeschreibung zu erhalten: wählen Sie einfach im Menü „Tools“ den Punkt „Error
Lookup“.
Die Header-Datei WinError.h enthält eine Liste von über 400 solchen Fehlercodes. Das
Format eines Fehlercodes wird dort wie folgt beschrieben:
© Chr. Vogt, FH München
8
// Values are 32 bit values layed out as follows:
//
// 3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1
// 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
// +---+-+-+-----------------------+-------------------------------+
// |Sev|C|R|
Facility
|
Code
|
// +---+-+-+-----------------------+-------------------------------+
//
// where
//
//
Sev - is the severity code
//
//
00 - Success
//
01 - Informational
//
10 - Warning
//
11 - Error
//
//
C - is the Customer code flag
//
//
R - is a reserved bit
//
//
Facility - is the facility code
//
//
Code - is the facility's status code
Gemäß dieser Beschreibung gibt das oberste Bit also an, ob die Funktion erfolgreich durchgeführt werden konnte (0 für Success oder Information) oder ob ein Fehler aufgetreten ist (1
für Warning oder Error). In Wirklichkeit steht in den obersten 16 Bit der Fehlercodes eine 0.
(Oder liefert GetLastError nur die unteren 16 Bit?)
Für jeden einzelnen Fehlercode folgt dann ein Eintrag wie z.B. der folgende:
// MessageId: ERROR_INVALID_HANDLE
//
// MessageText:
//
// The handle is invalid.
//
#define ERROR_INVALID_HANDLE
6L
In der MSDN finden Sie eine Liste der Error-Codes unter Windows Development -> Windows
Base Services -> Debugging and Error Handling -> SDK Documentation -> Error Handling ->
System Error Codes (wo sonst hätten Sie auch gesucht …).
© Chr. Vogt, FH München
9
4 Objekte und Handles
Um die Win32 Base System Services zu verwenden, müssen Sie die Konzepte von Objekten
und Handles verstehen.
Gemeinsam benutzbare System-Ressourcen werden von Windows als Objekte implementiert. Um auf ein Objekt zuzugreifen, muß ein Prozess zunächst ein Handle auf dieses Objekt
anfordern („öffnen“).
4.1
Objekte
Ein Objekt ist eine Datenstruktur mit bestimmten Eigenschaften, die gemeinsam benutzbare
System-Ressourcen repräsentiert, wie z.B. Dateien, Prozesse oder Geräte. Es gibt zwei
Hauptgründe, warum System-Ressourcen als Objekte implementiert werden:
1. Microsoft kann die Funktionalität von Windows verbessern, solange die Schnittstelle zu
den Objekten unverändert bleibt.
2. Die Benutzung von Objekten ermöglicht die Nutzung der Sicherheitsmerkmale von
Windows. Jedes Objekt hat eine „Access Control List“ (ACL), die festlegt, welcher
Prozess welche Aktionen mit dem Objekt ausführen darf.
Ein Objekt besteht aus einem Standard-Kopf und objekttypspezifischen Attributen. Der
Objekt-Kopf enthält z.B. den Namen des Objekts und einen Sicherheits-Beschreiber. Da alle
Objekte die gleiche grundlegende Struktur haben, gibt es nur einen einzigen Objekt-Manager, der alle Objekte handhabt. Zu den Aufgaben des Objekt-Managers gehören
•
•
•
•
Das Erzeugen von Objekten.
Die Überprüfung, ob ein Prozess das Recht hat, ein Objekt zu benutzen.
Das Erzeugen (Öffnen) und Schließen von Handles zu einem Objekt (s.u.).
Das Verwalten von Quoten für Ressourcen.
Das Win32-API enthält Funktionen für die folgenden Aufgaben:
•
•
•
•
•
Das Erzeugen von Objekten.
Das Erzeugen von Handles zu einem Objekt.
Das Einholen von Informationen über ein Objekt.
Das Setzen von Informationen (Attributen) eines Objekts.
Das Schließen eines Handles zu einem Objekt.
Objekte und Handles zu Objekten verbrauchen Speicher. Deswegen ist es wichtig, nicht
mehr benötigte Handles zu schließen und nicht mehr benötigte Objekte aus dem System zu
entfernen. Wenn ein Prozess endet, schließt das System automatisch seine Handles und
entfernt Objekte, die nur noch von diesem Prozess genutzt wurden. Dies geschieht jedoch
nicht bei der Beendigung eines einzelnen Threads (außer wenn dies der letzte Thread des
Prozesses ist, und somit auch der Prozess beendet wird).
© Chr. Vogt, FH München
10
4.1.1 Arten von Objekten
Unter Windows gibt es drei Arten von Objekten:
•
•
•
User Objekte
GDI (graphics device interface) Objekte
Kernel-Objekte
User Objekte sind z.B. Fenster und Menüs, GDI Objekte Bitmaps oder Fonts.
In dieser Vorlesung interessieren uns nur die Kernel-Objekte. Zu diesen gehören:
•
•
•
•
•
Prozesse und Threads,
Events, Mutexes, Semaphore und Timer (für die Synchronisation),
Mailslots und Pipes (für die Kommunikation),
Dateien
File mappings und Heaps (für die Speicherverwaltung).
Der Begriff „Kernel-Objekte“ ist etwas irreführend, da nicht alle diese Objekte auch Objekte
des Betriebssystem-Kernels sind. So sind z.B. Mailslots und Pipes „Erfindungen“ des Win32Subsystems, von denen der Kernel des Betriebssystems nichts weiß.
4.1.2 Benannte Objekte
Die einfachste Methode, um Kernel-Objekte über mehrere Prozesse hinweg zu verwenden,
liegt im Benennen der Objekte. Nicht alle, aber viele der Kernel-Objekte lassen sich benennen. Für benennbare Objekte hat die entsprechende Createxxx-Funktion einen Parameter
lpszName, für den die Adresse eines durch eine 0 abgeschlossenen Strings angegeben
werden kann. Andere Prozesse können dann mit einer Openxxx-Funktion unter Angabe des
Namens ein Handle zu diesem Objekt öffnen.
Zu beachten ist, dass all diese Objekte sich einen gemeinsamen, systemweiten Namensraum teilen. Bei der Namensvergabe sollte man also vorsichtig sein und sich am besten
vorab eine vernünftige Konvention überlegen.
4.2
Handles
Applikationen können Objekte mit Hilfe von API-Funktionen manipulieren. Da der Zugriff auf
die Datenstrukturen eines Kernel-Objekts nur dem Betriebssystem-Kernel möglich ist, müssen sie dazu allerdings erst ein sog. Handle zu dem Objekt anfordern („öffnen“).
Jeder Prozess hat eine Handle-Tabelle. Diese wird nur für Kernel-Objekte, nicht aber für
User- oder GDI-Objekte verwendet. Die Handle-Tabelle ist prozessweit und steht somit allen
Threads eines Prozesses gleichermaßen zur Verfügung. In der Handle-Tabelle stehen u.a.
die Zeiger auf die Datenstrukturen des Kernels für die Objekte. Der Handle-Wert mit dem ein
Prozess bzw. seine Threads arbeiten, ist einfach ein 32-bit-Wert, der als Index in diese
© Chr. Vogt, FH München
11
Tabelle verwendet wird. Als Datentyp sollte die Angabe HANDLE verwendet werden, die in
den Header-Dateien definiert wird.
Beim Erzeugen oder Öffnen eines Objekts wird ein freier Eintrag in der Handle-Tabelle des
Prozesses gesucht, und der entsprechende Index als Handle-Wert zurückgegeben.
Jedes Objekt enthält
•
•
einen Zähler für die von Prozessen geöffneten Handles für dieses Objekt,
einen weiteren Zähler für die Referenzen des Betriebssystems auf dieses Objekt.
Ein Objekt wird dann, und erst dann, automatisch vom System entfernt, wenn diese beiden
Zähler auf Null gehen.
4.2.1 Öffnen und Schließen von Handles
Ein Prozess öffnet ein Handle zu einem Objekt, indem er ein neues Objekt mit einer
objekttyp-spezifischen Createxxx-Funktion erzeugt, oder indem er ein existierendes Objekt
mit einer objekttyp-spezifischen Openxxx-Funktion öffnet.
Bei dem Versuch, ein benanntes Objekt zu erzeugen, das bereits existiert, wird das vorhandene Objekt geöffnet und ein Handle zurückgegeben. In diesem Fall ist der Rückgabewert
von GetLastError der Code ERROR_ALREADY_EXISTS.
Ein Handle wird mit der Win32-API-Funktion CloseHandle wieder geschlossen. Gibt es zu
einem Objekt kein Handle mehr, und wird das Objekt auch vom Betriebssystem nicht mehr
verwendet, so wird das Objekt selbst automatisch entfernt.
4.2.2 Vererbung und Duplikation von Handles
Handles können von einem Prozess, der mit CreateProcess neu erzeugt wird, geerbt
werden. Außerdem können Handles für die Benutzung durch einen anderen Prozess
dupliziert werden. In beiden Fällen besteht das Problem, dass der neue bzw. andere Prozess
zunächst nichts von den Handles weiß, die ihm auf einmal zur Verfügung stehen. Also muß
ihm dies auf irgendeine Art und Weise mitgeteilt werden.
4.2.2.1 Vererbung von Handles
Beim Öffnen eines Handles zu einem Objekt kann angegeben werden, dass dieses Handle
von später erzeugten Prozessen geerbt werden kann. Einige Funktionen haben dafür einen
Parameter inheritable (= vererbbar), bei den meisten Funktionen muß dies jedoch in
einem Flag in dem Parameter SECURITY_ATTRIBUTES angegeben werden. Die Struktur
SECURITY_ATTRIBUTES ist wie folgt definiert:
© Chr. Vogt, FH München
12
typedef struct _SECURITY_ATTRIBUTES { // sa
DWORD nLength;
LPVOID lpSecurityDescriptor;
BOOL
bInheritHandle;
} SECURITY_ATTRIBUTES;
Die Boole’sche Variable bInheritHandle gibt dabei an, ob das Handle vererbbar ist
(TRUE) oder nicht (FALSE). nLength muß die Größe der Struktur in Bytes enthalten
(sizeof(...)). SecurityDescriptor ist eine Struktur, die Sicherheitsinformationen für
das Objekt enthält, auf die wir hier nicht eingehen wollen.
Vererbbare Handles werden nicht automatisch bei der Erzeugung eines Prozesses mit
CreateProcess vererbt, sondern nur, wenn der Parameter InheritHandle auf TRUE
gesetzt wird. In diesem Fall werden die Einträge der Handle-Tabelle des Vater-Prozesses,
die vererbbare Handles beschreiben, in die Handle-Tabelle des neu erzeugten SohnProzesses kopiert, und zwar an genau dieselbe Position wie in der Ursprungstabelle.
Der erzeugte Prozess weiß dann allerdings noch nichts von den Handles, die er geerbt hat.
Eine Möglichkeit, ihm dies mitzuteilen ist, die Handle-Werte, die ja in beiden Prozessen
gleich sind, als ASCII-String in dem Parameter CommandLine der CreateProcess-Funktion zu übergeben (siehe auch das Kapitel über Prozesse und Threads).
4.2.2.2 Duplizieren von Handles
Ein Prozess kann eine Kopie eines existierenden Handles an einen anderen Prozess
weiterreichen. Dies wird mit der Funktion DuplicateHandle erreicht. Als erstes müssen
Quell- und Zielprozess geöffnet werden (mit OpenProcess). Danach kann das Handle
übergeben werden mit
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle,
HANDLE hSourceHandle,
// handle to duplicate
HANDLE hTargetProcessHandle,
LPHANDLE lpTargetHandle,
// pointer to duplicate handle
);
(Weitere mögliche Parameter wurden weggelassen.)
Es können hier insgesamt drei verschiedene Prozesse involviert sein:
• der Prozess, der DuplicateHandle aufruft,
• der Quellprozess, von dessen Handles einer dupliziert wird
• der Zielprozess, der das duplizierte Handle erhält.
Zu beachten ist auf jeden Fall die Bedeutung der verschiedenen Handle-Angaben: die
Angaben für den Quell- und den Zielprozess sind natürlich aus Sicht des Aufrufers von
DuplicateHandle. Der zweite Parameter (hSourceHandle) bezieht sich auf ein Handle
im Quellprozess, und der vierte Parameter (lpTargetHandle) enthält die Adresse einer
Handle-Variablen, die den Index des Eintrags in der Handle-Tabelle des Zielprozesses
aufnimmt, in den das duplizierte Handle geschrieben wird. Diese Angabe bezieht sich dann
also auf den durch lpTargetProcessHandle identifizierten Prozess. (Ufff ....).
Danach muß noch, mit Hilfe irgendeiner Interprozess-Kommunikationsmethode, dem
Zielprozess mitgeteilt werden, mit welchem Handle-Wert er dieses duplizierte Handle
ansprechen kann.
© Chr. Vogt, FH München
13
Wie man sieht ist die Sache nicht so ganz einfach. Dies ist ein weiterer Grund, lieber mit
mehreren Threads innerhalb eines Prozesses zu arbeiten, die sich ja automatisch alle
Handles teilen. Und um ein Objekt von mehreren Prozessen aus zu benutzen, ist es in der
Regel einfacher, mit benannten Objekten zu arbeiten, als Handles zu duplizieren.
Eine mögliche weitere Anwendung von DuplicateHandle besteht darin, für ein Objekt ein
Handle mit anderen Zugriffsanforderungen zu erhalten: Man dupliziere ein schon vorhandenes Handle in den eigenen Prozess unter Angabe des gewünschten Zugriffs (Parameter
dwDesiredAccess) und schließe das ursprüngliche Handle (oder gebe für den Parameter
dwOptions von DuplicateHandle den Wert DUPLICATE_CLOSE_SOURCE an).
Auch die Eigenschaft eines Handles, vererbbar zu sein oder nicht, kann auf diese Art geändert werden (Parameter bInheritHandle). Einfacher ist in diesem Fall aber die Benutzung
der Funktion SetHandleInformation.
Ein weiteres Beispiel für die Verwendung von DuplicateHandle ist die Situation, in der
zwei Threads eines Prozesses dasselbe Objekt verwenden. Wenn beide Threads mit der
Arbeit fertig sind, soll das Objekt entfernt werden. Da aber keiner der Threads weiß, ob der
andere bereits mit seiner Arbeit fertig ist, verwenden die Threads zwei unterschiedliche
Handles. Jeder Thread schließt „sein“ Handle, sobald er das Objekt nicht mehr braucht.
Wenn beide Handles geschlossen wurden, wird das Objekt automatisch vom System entfernt. (Natürlich kann man das zweite Handle auch mit einer Openxxx Funktion erhalten.)
4.2.3 Pseudohandles
Häufig ist es nötig, ein Handle für den eigenen Prozess oder Thread anzugeben, z.B. beim
Einholen von Prozess-Informationen mit GetProcessxxx-Funktionen oder dem Ändern von
Prozesseigenschaften mit SetProcessxxx-Funktionen, oder beim Duplizieren eines
Handles des momentanen Prozesses, wobei der Quellprozess identisch ist mit dem
Prozess, der DuplicateHandle aufruft. Als Handle-Angabe für den aktuellen Prozess bzw.
Thread können in all diesen Fällen die Funktionen
HANDLE GetCurrentProcess(VOID)
HANDLE GetCurrentThread(VOID)
verwendet werden. Diese liefern jedoch nur sog. „Pseudohandle“. Ein Pseudohandle kann
(und braucht somit) nicht wieder geschlossen werden, und kann nicht vererbt werden. Wenn
ein Thread ein „echtes“ Handle für sich oder seinen Prozess benötigt, z.B. um diesen zu vererben, kann er entweder das Pseudohandle duplizieren (was zu einem „echten“ Handle
führt), oder mit OpenThread bzw. OpenProcess unter Angabe der Thread- bzw. ProzessID (die er wiederum mit GetCurrentThreadId() bzw. GetCurrentProcessId()
bekommen kann) ein Handle zu sich oder zu seinem Prozess erhalten.
© Chr. Vogt, FH München
14
5 Prozesse und Threads
In diesem Kapitel betrachten wir einige der Win32-API-Funktionen zum Umgang mit Prozessen und Threads. Die Dokumentation nennt für diesen Bereich 67 Funktionen (plus 12 Funktionen für Fibers und 8 für Jobs), von denen wir nur einige wenige behandeln werden.
5.1
Erzeugen von Prozessen
Der Prototyp der Funktion CreateProcess zum Erzeugen eines Prozesses lautet:
BOOL CreateProcess(
LPCTSTR lpApplicationName,
// pointer to name of executable module
LPTSTR lpCommandLine,
// pointer to command line string
LPSECURITY_ATTRIBUTES lpProcessAttributes, // pointer to process security attributes
LPSECURITY_ATTRIBUTES lpThreadAttributes, // pointer to thread security attributes
BOOL bInheritHandles,
// handle inheritance flag
DWORD dwCreationFlags,
// creation flags
LPVOID lpEnvironment,
// pointer to new environment block
LPCTSTR lpCurrentDirectory,
// pointer to current directory name
LPSTARTUPINFO lpStartupInfo,
// pointer to STARTUPINFO
LPPROCESS_INFORMATION lpProcessInformation //pointer to PROCESS_INFORMATION
);
Das bedarf sicher noch ein paar Erklärungen.
Fangen wir mit der Beschreibung der Funktion an. Vielleicht haben Sie sich bereits über den
Parameter lpThreadAttributes gewundert, wo wir doch einen Prozess erzeugen wollen. Nun,
beim Erzeugen eines Prozesses geschieht folgendes:
1. Das Betriebssystem erzeugt ein internes Prozessobjekt.
2. Das Betriebssystem erzeugt einen 4 GB großen virtuellen Adreßraum für den neuen
Prozess und lädt ihn mit dem Programmcode und den Daten der ausführbaren Datei.
3. Das Betriebssystem erzeugt ein internes Threadobjekt für den primären Thread des
neuen Prozesses.
4. Der primäre Thread beginnt mit der Ausführung der Startroutine der C-Laufzeitbibliothek,
die schließlich die main- bzw. WinMain-Funktion aufruft.
Kommen wir nun zu einer näheren Betrachtung der Parameter von CreateProcess.
Die ersten beiden Parameter, lpApplicationName und lpCommandLine dienen der Angabe
des Programms, das in dem neuen Prozess ausgeführt werden soll, sowie möglicher
weiterer Angaben in der Kommandozeile. Üblicherweise wird für lpApplicationName der Wert
NULL übergeben, und die ausführbare Datei für den neuen Prozess als erstes Token (Wort)
der Kommandozeile angegeben. Enthält die Dateiangabe keinen Pfad, so wird die Datei in
folgenden Verzeichnissen gesucht:
1.
2.
3.
4.
5.
6.
Im Verzeichnis, das die EXE-Datei des aufrufenden Prozesses enthält.
Im aktuellen Verzeichnis des aufrufenden Prozesses.
Im 32-bit-Systemverzeichnis von Windows.
Im 16-bit-Systemverzeichnis von Windows.
Im Windows-Verzeichnis.
In den in der Umgebungsvariablen PATH aufgeführten Verzeichnissen.
© Chr. Vogt, FH München
15
Bemerkung: Wenn hier und im folgenden „aufrufender Prozess“ oder „aktueller Prozess“
steht ist immer der Prozess gemeint, zu dem der aufrufende oder aktuelle Thread gehört.
Der Rest der Kommandozeile, nach dem ersten Token, wird an die WinMain-Funktion übergeben und landet schließlich in den globalen Variablen argc und argv. Ein Programm kann
aber auch mit der Funktion GetCommandLine einen Zeiger auf die gesamte Kommandozeile erhalten.
Die Parameter lpProcessAttributes und lpThreadAttributes sind Zeiger auf Strukturen vom
Typ SECURITY_ATTRIBUTES für den Prozess bzw. seinen primären Thread (der ja gleich
miterzeugt wird). Zur Erinnerung: Diese Struktur enthält zwei Angaben:
1. Einen Security Descriptor, der Sicherheitsinformationen für das Objekt angibt.
2. Die Angabe, ob das Handle zu diesem Objekt vererbbar sein soll.
Bei Angabe von NULL wird ein Standard-Security-Descriptor verwendet, und das Handle ist
nicht vererbbar.
Der Parameter bInheritHandles gibt an, ob vererbbare Handles des aufrufenden Prozesses
an den zu erzeugenden Prozess vererbt werden sollen.
Der Parameter dwCreationFlags enthält einige Flags, die die Erzeugung des neuen Prozesses steuern. Eine vollständige Liste der möglichen Flags finden Sie in der Dokumentation.
Zwei wichtige Flags sind:
CREATE_NEW_CONSOLE Für Konsolen-Anwendungen: Der neue Prozess erhält ein
eigenes Konsolenfenster. Standardmäßig benutzt er das
Konsolenfenster des aufrufenden Prozesses mit.
CREATE_SUSPENDED
Der primäre Thread des Prozesses wird im Zustand
SUSPENDED erzeugt und läuft erst nach Aufruf der Funktion
ResumeThread los.
Außerdem steuert der Parameter dwCreationFlags die Prioritätsklasse des neuen Prozesses. Die möglichen Angaben hierfür sind:
•
•
•
•
IDLE_PRIORITY_CLASS
NORMAL_PRIORITY_CLASS
HIGH_PRIORITY_CLASS
REALTIME_PRIORITY_CLASS
Standardmäßig wird die NORMAL_PRIORITY_CLASS verwendet (mit Ausnahmen, siehe
Doku). Nähere Informationen über Prioritäten finden Sie in der Betriebssystemvorlesung.
Die Parameter lpEnvironment und lpCurrentDirectory dienen der Angabe eines Bereiches mit
Umgebungsvariablen bzw. des Standard-Verzeichnisses für den neuen Prozess. Bei Angabe
von NULL „erbt“ der neue Prozess diese Angaben vom aufrufenden Prozess.
Der Parameter lpStartupInfo zeigt auf eine Struktur des Typs STARTUPINFO, die vor allem
Angaben darüber enthält, wie das Hauptfenster der Applikation aussehen soll (Größe, Position etc.). Um Standardwerte zu verwenden, kann man alle Elemente in der Struktur auf Null
setzen und nur das Feld cb mit der Größe der Struktur initialisieren:
© Chr. Vogt, FH München
16
STARTUPINFO si;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
CreateProcess(..., &si, ...);
Last not least: der Parameter lpProcessInformation. Er verweist auf eine Struktur vom Typ
PROCESS_INFORMATION, die von der Funktion CreateProcess mit Informationen gefüllt
wird. Die Struktur ist folgendermaßen definiert:
typedef struct _PROCESS_INFORMATION { // pi
HANDLE hProcess;
HANDLE hThread;
DWORD dwProcessId;
DWORD dwThreadId;
} PROCESS_INFORMATION;
Hier finden wir nun also die Handles für den erzeugten Prozess und für dessen primären
Thread und außerdem deren numerische Identifikationen. Die Funktion CreateProcess
öffnet also ungefragt je ein Handle zu dem neuen Prozess und seinem primären Thread.
Vergessen Sie nicht, diese Handles wieder zu schließen! Wenn der aufrufende Prozess sie
überhaupt nicht benötigt, schließen Sie sie am besten sofort nach Aufruf von
CreateProcess. Sie wissen ja, dass das Prozess- und das Threadobjekt nicht entfernt
werden können, solange noch offene Handles für sie existieren!
Nach all diesen Erklärungen jetzt aber schnell ein Beispiel, um zu sehen, dass es gar nicht
so kompliziert ist:
STARTUPINFO si;
PROCESS_INFORMATION pi;
// Vorbereiten der STARTUPINFO-Struktur:
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
CreateProcess(
NULL,
"exe_file",
NULL,
NULL,
FALSE,
0,
NULL,
NULL,
&si,
&pi
);
//
//
//
//
//
//
//
//
//
//
EXE-Datei wird im 2. Argument angegeben
Keine weiteren Angaben in der Kommandozeile
Standard-Sicherheit, Handle nicht vererbbar
Standard-Sicherheit, Handle nicht vererbbar
Handles nicht vererben
Keine Flags, d.h. u.a. NORMAL_PRIORITY_CLASS
Umgebungsvariablen "vererben"
Standard-Verzeichnis "vererben"
STARTUPINFO
PROCESS_INFORMATION
// Handles schließen:
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
© Chr. Vogt, FH München
17
5.2
Beenden von Prozessen
Kommen wir nun zum Beenden eines Prozesses. Dies kann auf dreierlei Arten geschehen:
1. Ein Thread im Prozess ruft die Funktion ExitProcess auf.
2. Ein Thread in einem anderen Prozess ruft die Funktion TerminateProcess auf.
3. Alle Threads in einem Prozess arbeiten bis zu ihrem natürlichen Ende. Bei Beendigung
des letzten Threads eines Prozesses wird dann automatisch auch der Prozess beendet.
Der Aufruf von ExitProcess:
VOID ExitProcess(
UINT uExitCode
);
// exit code for all threads
beendet zunächst alle noch vorhandenen Threads des Prozesses und anschließend den
Prozess selbst. Das gleiche geschieht beim Aufruf von TerminateProcess:
BOOL TerminateProcess(
HANDLE hProcess,
// handle to the process
UINT uExitCode
// exit code for the process
);
wobei dieser Aufruf aber durch Angabe eines Handles auch für einen anderen als den
eigenen Prozess durchgeführt werden kann. Im Allgemeinen sollte man jedoch auf die
Verwendung von TerminateProcess verzichten, da dabei keine DLLs benachrichtigt werden, was zu einem fehlerhaften Abschluß des Prozesses führen kann.
Der Exit-Code, der bei diesen Funktionen angegeben werden kann, kann von anderen Prozessen mit GetExitCodeProcess abgefragt werden. Solange der Prozess noch läuft liefert
diese Funktion den Wert STILL_ACTIVE.
Bei der Terminierung eines Prozesses geschieht folgendes:
1. Alle Threads des Prozesses beenden ihre Ausführung.
2. Alle vom Prozess reservierten Benutzer- und GDI-Objekte werden freigegeben, und alle
Kernel-Objekte werden geschlossen (d.h. die Handles darauf werden geschlossen).
3. Das Prozessobjekt wechselt in den „Signalled State“ (ebenso seine Threadobjekte).
4. Der Exitcode des Prozesses wechselt von STILL_ACTIVE in den Code, der der Funktion
ExitProcess bzw. TerminateProcess übergeben wurde.
5. Der interne Benutzerzähler des Prozessobjekts wird um 1 dekrementiert.
Wenn ein Prozess terminiert, werden also sein Programmcode und die von ihm reservierten
Betriebsmittel aus dem Speicher entfernt. Das ihm entsprechende Prozessobjekt und der
dafür benötigte Speicher werden jedoch erst freigegeben, wenn alle noch existierenden
Bezüge auf das Objekt (also Handles anderer Prozesse zu diesem Objekt) geschlossen
worden sind.
© Chr. Vogt, FH München
18
5.3
Weitere Funktionen für Prozesse
Die folgende Tabelle beschreibt kurz einige weitere wichtige Funktionen beim Umgang mit
Prozessen. Bei Bedarf oder Interesse lesen Sie bitte Einzelheiten in der Dokumentation
nach.
Um ein Pseudohandle für den aktuellen Prozess zu
erhalten.
GetCurrentProcessId
Um die Process-Identification des aktuellen Prozesses
zu erhalten.
OpenProcess
Um ein Handle zu einem Prozess zu erhalten. Der
Prozess wird dabei durch seine Process-Identification
angegeben.
GetPriorityClass
Abfragen bzw. Ändern der Prioritätsklasse eines
SetPriorityClass
Prozesses.
GetProcessPriorityBoost Abfragen bzw. Einstellen, ob für alle Threads des ProSetProcessPriorityBoost zesses eine automatische Prioritätenanpassung durch
das Betriebssystem gemacht werden soll.
GetProcessTimes
Liefert vier Zeitangaben für einen Prozess:
• Den Erzeugungszeitpunkt des Prozesses.
• Den Beendigungszeitpunkt des Prozesses.
• Die Zeit, die alle Threads des Prozesses zusammen im User Mode verbracht haben.
• Die Zeit, die alle Threads des Prozesses zusammen im Kernel Mode verbracht haben.
GetProcessWorkingSetSize Anzeigen bzw. Ändern der Minimal- und Maximalgröße
SetProcessWorkingSetSize des Working Sets des Prozesses.
GetCurrentProcess
5.4
Erzeugen von Threads
Der Prototyp der Funktion CreateThread zum Erzeugen eines Threads lautet:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
// pointer to thread security attributes
// initial thread stack size, in bytes
// pointer to thread function
// argument for new thread
// creation flags
// pointer to returned thread identifier
Als erstes fällt auf, dass im Gegensatz zu CreateProcess der Rückgabewert von
CreateThread das Handle für den neuen Thread ist. Der neu erzeugte Thread wird also
wiederum ungefragt geöffnet, um Ihnen die Möglichkeit zu geben, den Lieblingsfehler aller
Windows-Programmierer zu machen und das Schließen des Handles zu vergessen.
Den Parameter lpThreadAttributes kennen wir schon von CreateProcess.
© Chr. Vogt, FH München
19
DwStackSize gibt die ursprüngliche Größe des Stacks des neuen Threads an. Wenn 0 angegeben wird, wird der Stack genauso groß angelegt wie der des primären Threads des
Prozesses.
lpStartAddress ist die Startadresse, an der der neue Thread mit der Ausführung beginnt. Typischerweise ist dies die Adresse einer Funktion, die folgendem Prototyp zu genügen hat:
DWORD WINAPI ThreadFunction(LPVOID lpThreadParameter);
die also einen 32-bit-Zeiger als Argument hat und einen 32-bit-Wert zurückgibt. Der Funktion
wird natürlich derjenige Parameter übergeben, der im Parameter lpParameter von
CreateThread angegeben wurde. Analog der Funktion WinMain wird auch diese Funktion
nicht direkt aufgerufen. Statt dessen ruft das Betriebssystem eine interne Thread-Start-Funktion aus KERNEL32.DLL auf, die die Thread-Funktion aufruft und nach deren Rückkehr den
Thread durch Aufruf von ExitThread beendet.
Die einzige mögliche Flag für den Parameter dwCreationFlags von CreateThread ist
CREATE_SUSPENDED. Bei Angabe von 0 für diesen Parameter beginnt der Thread sofort mit
der Ausführung. (In Windows XP und 2003 Server wurde eine weitere Flag eingeführt.)
Im letzten Parameter, lpThreadId, wird schließlich noch angegeben, wo die Thread-Identification des Threads abgelegt werden soll.
Schauen wir uns noch kurz an, welche Schritte das Betriebssystem bei einem Aufruf von
CreateThread ausführt:
1. Das Betriebssystem erzeugt ein internes Threadobjekt. Der Rückgabewert von
CreateThread ist ein Handle zu diesem Objekt.
2. Das Betriebssystem initialisiert den Exit-Code des Threads mit dem Wert
STILL_ACTIVE und setzt den Benutzungszähler des Thread-Objekts auf 1.
3. Das Betriebssystem reserviert eine Struktur vom Typ CONTEXT für den neuen Thread.
Diese Struktur enthält die Werte der vom Thread verwendeten CPU-Register zum Zeitpunkt der letzten Unterbrechung des Threads.
4. Das Betriebssystem erzeugt einen eigenen Stack für den neuen Thread. Es reserviert
dazu einen entsprechenden Bereich des Prozessadreßraums und ordnet diesem dann
(zunächst) zwei physikalische Speicherseiten zu.
5. Die Werte von lpStartAdress und lpParameter werden auf dem Stack abgelegt, so dass
es so aussieht, als ob dies an die interne Thread-Start-Funktion übergebene Parameter
wären.
6. Das Betriebssystem initialisiert das Stackpointer-Register der CONTEXT-Struktur des
Threads mit der Adresse der in Schritt 5 auf dem Stack plazierten Werte und weist dem
Befehlszähler die Adresse der internen Thread-Start-Funktion zu.
5.5
Beenden von Threads
Wie ein Prozess kann auch ein Thread auf dreierlei Arten beendet werden:
1. Der Thread beendet sich selbst durch Aufruf von ExitThread.
2. Ein anderer Thread im selben oder einem anderen Prozess ruft TerminateThread auf.
3. Der Prozess, zu dem der Thread gehört, endet.
© Chr. Vogt, FH München
20
Die Prototypen für ExitThread und TerminateThread lauten:
VOID ExitThread(
DWORD dwExitCode
);
// exit code for this thread
beendet den Thread, der diese Funktion aufruft. Dagegen beendet
BOOL TerminateThread(
HANDLE hThread,
DWORD dwExitCode
);
// handle to the thread
// exit code for the thread
den durch das Handle angegebenen Thread, der zum selben oder zu einem anderen
Prozess gehören kann. Diese Funktion sollte möglichst nicht verwendet werden, da dabei so
gut wie keine „Aufräumarbeiten“ in dem zu beendenden Thread durchgeführt werden. So
bleibt z.B. dessen Stack allokiert und eine evtl. von ihm benutzte Critical Section wird nicht
freigegeben.
Beim Beenden eines Threads werden die folgenden Schritte ausgeführt:
1. Alle dem Thread gehörenden Handles zu Fenstern und Hooks (was immer das ist) werden freigegeben. (Alle anderen Handles gehören sowieso nicht dem Thread, sondern
dem Prozess!)
2. Das Thread-Objekt geht in den Signalled State.
3. Der Exit-Code des Threads wechselt von STILL_ACTIVE in den Code, der der Funktion
ExitThread bzw. TerminateThread übergeben wurde.
4. Falls der betroffene Thread der letzte Thread seines Prozesses ist, wird auch der
Prozess beendet.
5. Der interne Benutzungszähler des Thread-Objekts wird um 1 dekrementiert.
Das Thread-Objekt und der dafür benötigte Speicher werden noch nicht unbedingt freigegeben sondern erst dann, wenn alle noch existierenden Bezüge auf das Objekt (also Handles
anderer Prozesse zu diesem Objekt) geschlossen worden sind.
5.6
Weitere Funktionen für Threads
Die folgende Tabelle beschreibt kurz einige weitere wichtige Funktionen beim Umgang mit
Threads. Bei Bedarf oder Interesse lesen Sie bitte Einzelheiten in der Dokumentation nach.
GetCurrentThread
GetCurrentThreadId
SuspendThread
ResumeThread
Sleep
© Chr. Vogt, FH München
Um ein Pseudohandle für den aktuellen Thread zu
erhalten.
Um die Thread-Identification des aktuellen Threads zu
erhalten.
Um die Ausführung eines Threads (vorübergehend)
anzuhalten bzw. wieder fortzuführen.
Unterbrechen der Ausführung des aktuellen Threads
für ein bestimmtes Intervall (Angabe in Millisekunden).
21
Abfragen bzw. Ändern der (relativen) Priorität eines
Threads. Die möglichen Werte sind:
• THREAD_PRIORITY_IDLE
(1 bzw. 16)
• THREAD_PRIORITY_LOWEST
(-2)
• THREAD_PRIORITY_BELOW_NORMAL
(-1)
• THREAD_PRIORITY_NORMAL
• THREAD_PRIORITY_ABOVE_NORMAL
(+1)
• THREAD_PRIORITY_HIGHEST
(+2)
• THREAD_PRIORITY_TIME_CRITICAL (15 bzw. 31)
GetThreadPriorityBoost Abfragen bzw. Einstellen, ob für einen Thread eine autoSetThreadPriorityBoost matische Prioritätenanpassung durch das Betriebssystem
gemacht werden soll.
GetThreadTimes
Liefert vier Zeitangaben für einen Thread:
• Den Erzeugungszeitpunkt des Threads.
• Den Beendigungszeitpunkt des Threads.
• Die Zeit, die der Thread im User Mode verbracht hat.
• Die Zeit, die der Thread im Kernel Mode verbracht
hat.
GetThreadPriority
SetThreadPriority
© Chr. Vogt, FH München
22
6 Synchronisation
In diesem Kapitel betrachten wir die im Kernel von Windows vorgesehenen Methoden für die
Synchronisation von Threads.
Im ersten Abschnitt werden alle Synchronisationsobjekte des Kernels aufgelistet und die
Funktionen behandelt, wie auf diese gewartet werden kann.
Im zweiten Abschnitt wird auf eine weitere Synchronisationsmethode eingegangen, die das
Win32-API anbietet, die Critical Sections. Diese greifen natürlich letztlich auf eine der Methoden des Kernels zurück.
Die weiteren Abschnitte behandeln die Benutzung der Kernel-Objekte, die speziell für die
Synchronisation zwischen Threads existieren, nämlich Mutexe, Semaphore, Events und
Waitable Timer.
Im letzten Abschnitt werden dann noch die API-Funktionen besprochen, die für die atomare
Ausführung von Operationen existieren.
6.1
Warten auf Synchronisationsobjekte
Von allen Objekttypen des Kernels ist eine Teilmenge als sog. Synchronisationsobjekte implementiert. Ein Synchronisationsobjekt befindet sich zu jedem Zeitpunkt in einem von zwei
Zuständen:
•
•
Signalled State
Nonsignalled State
Die Bedeutung dieser beiden Zustände ist vom Typ des Objektes abhängig (siehe die untenstehende Tabelle). Die Gemeinsamkeit ist, dass ein Thread mit Hilfe der Wait Services des
Objektmanagers darauf warten kann, dass ein Objekt in den Signalled State übergeht. Solange das Objekt im Nonsignalled State ist, ist der Thread blockiert. Sobald das Objekt in
den Signalled State übergeht, weckt das Betriebssystem einen oder alle (je nach Typ des
Objekts) auf dieses Objekt wartenden Threads auf.
Die folgende Tabelle zeigt die Objekttypen, die als Synchronisationsobjekte implementiert
sind sowie die Bedeutung des Signalled State für den speziellen Objekttyp und gibt an, ob
beim Übergang des Objekts in den Signalled State einer oder alle der wartenden Threads
geweckt werden. In der Tabelle nicht aufgeführt ist ein weiteres Synchronisationsobjekt des
Kernels, das nur dem Win32-Subsystem zur Verfügung steht: die event pairs.
© Chr. Vogt, FH München
23
Objekttyp
Signalled State, wenn
Prozess
Thread
Datei
der Prozess beendet wird
der Thread beendet wird
eine I/O-Operation für diese Datei
beendet wird
Change Notification
eine bestimmte Änderung in einem
(kein Objekt des Kernels) Directory geschieht
Konsoleingabe
ungelesene Eingaben im Konsol(kein Objekt des Kernels) Eingabepuffer sind
Mutex
der Mutex keinem Thread gehört
Semaphor
der Semaphorenwert größer 0 ist
Event
ein Thread das Event explizit setzt
Timer
der bestimmte Zeitpunkt erreicht ist
Geweckt werden
alle wartenden Threads
alle wartenden Threads
alle wartenden Threads
alle?
alle?
ein wartender Thread
mehrere wartende Threads
abhängig vom Event-Typ
abhängig vom Timer-Typ
Auf alle diese Objekte wird nun auf völlig einheitliche Art gewartet. Zunächst einmal wird ein
Handle zu dem Objekt benötigt. Dieses Handle wird dann in einer der Wartefunktionen des
Win32-APIs angegeben. Die beiden wichtigsten dieser Funktionen sind
WaitForSingleObject und WaitForMultipleObjects.
DWORD WaitForSingleObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
// handle of object to wait for
// time-out interval in milliseconds
Für den Parameter dwMilliseconds kann auch der Wert 0 angegeben werden, um eine nichtblockierende Ausführung der Funktion zu erreichen oder die Angabe INFINITE gemacht
werden, um ohne Timeout zu warten.
WaitForSingleObject liefert einen der folgenden Rückgabewerte:
Rückgabewert
Bedeutung
WAIT_OBJECT_0
Das Objekt ist in den Signalled State übergegangen.
WAIT_TIMEOUT
Der Timeout ist abgelaufen, das Objekt ist im Nonsignalled State.
WAIT_ABANDONED
Bei dem Objekt handelt es sich um einen Mutex, der „verlassen“
wurde (siehe unter Mutexe).
WAIT_FAILED
Es ist ein Fehler aufgetreten.
© Chr. Vogt, FH München
24
Und nun zur Funktion WaitForMultipleObjects:
DWORD WaitForMultipleObjects(
DWORD nCount,
CONST HANDLE *lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds
);
// number of handles in the object handle array
// pointer to the object-handle array
// wait flag
// time-out interval in milliseconds
Die wichtigste Angabe ist hier der Parameter bWaitAll, der angibt, ob darauf gewartet werden
soll, dass alle angegebenen Objekte den Signalled State erreicht haben (Angabe: TRUE)
oder darauf, dass dies für irgendeines der Objekte der Fall ist (Angabe: FALSE). Beim
Warten mit WaitForMultipleObjects unter Angabe von bWaitAll = TRUE wird also
atomar auf alle diese Objekte gewartet. Im Gegensatz zum aufeinanderfolgenden Warten auf
mehrere Objekte durch mehrere Aufrufe von WaitForSingleObject kann so häufig das
mögliche Auftreten eines Deadlocks verhindert werden (vgl. die Vorlesung Betriebssysteme).
Die Rückgabewerte der Funktion WaitForMultipleObjects sind wie folgt:
Rückgabewert
Bedeutung
WAIT_OBJECT_0
bis
(WAIT_OBJECT_0 +
nCount – 1)
bWaitAll = TRUE: Alle Objekte sind im Signalled State.
WAIT_TIMEOUT
Der Timeout ist abgelaufen.
bWaitAll = TRUE: nicht alle Objekte sind im Signalled State.
bWaitAll = FALSE: keines der Objekte ist im Signalled State.
WAIT_ABANDONED_0 bis
(WAIT_ABANDONED_0 +
nCount – 1)
bWaitAll = TRUE: Alle Objekte sind im Signalled State und
mindestens eines davon ist ein „verlassener“ Mutex.
bWaitAll = FALSE: (Rückgabewert - WAIT_OBJECT_0) gibt
den Index des Handles im Feld lpHandles an, dessen Objekt
im Signalled State ist.
bWaitAll = FALSE: (Rückgabewert - WAIT_OBJECT_0) gibt
den Index des Handles im Feld lpHandles an, dessen Objekt
im Signalled State ist, wobei es sich um einen „verlassenen“
Mutex handelt.
WAIT_FAILED
Es ist ein Fehler aufgetreten.
Es gibt noch ein paar weitere Funktionen, die das Warten auf ein oder mehrere Objekte mit
dem Warten auf andere „Ereignisse“ verbinden. Die Funktionen
© Chr. Vogt, FH München
25
•
•
WaitForSingleObjectEx
WaitForMultipleObjectsEx
kehren auch dann zurück, wenn ein asynchroner I/O beendet wird (Rückgabewert:
WAIT_IO_COMPLETION). Die Funktion
•
MsgWaitForMultipleObjects
wartet auch auf Eingaben vom im Parameter dwWakeMask angegebenen Typ (bei fWaitAll =
FALSE ist dann der Rückgabewert WAIT_OBJECT_0 + nCount bzw. WAIT_ABANDONED_0 +
nCount). Zum Beispiel wird mit dem folgenden Aufruf auf eine Eingabe von der Tastatur oder
der Maus gewartet:
MsgWaitForMultipleObjects(
0, NULL,
TRUE,
INFINITE,
QS_KEY | QS_MOUSE);
•
// keine Handles zu Synchronisationsobjekten
// auf alle der 0 Objekte warten (egal)
// kein Timeout
// Warte auf Tastatur- oder Mausmeldungen
MsgWaitForMultipleObjectsEx
kombiniert die beiden obigen Möglichkeiten. Wir wollen hier aber nicht näher auf diese Funktionen eingehen.
Eine weitere, mit Windows NT 4.0 eingeführte Funktion, ist
DWORD SignalObjectAndWait(
HANDLE hObjectToSignal,
HANDLE hObjectToWaitOn,
DWORD dwMilliseconds,
BOOL bAlertable
);
// handle of object to signal
// handle of object to wait for
// time-out interval in milliseconds
// alertable flag
Mit dieser Funktion kann man in atomarer Weise ein Objekt in den Signalled State versetzen
und auf ein anderes Objekt warten. Das durch den Parameter hObjectToSignal bezeichnete
zu signalisierende Objekt muß dabei ein Mutex, ein Semaphor oder ein Event sein (siehe
unten). Intern wird dann die für das entsprechende Objekt zuständige Funktion
(ReleaseMutex, ReleaseSemaphore, SetEvent) aufgerufen. Gewartet werden kann
dagegen auf ein beliebiges Synchronisationsobjekt. Der Parameter bAlertable gibt an, ob die
Funktion auch dann zurückkehren soll, wenn ein asynchroner I/O beendet wird (also die
Funktionalität, die bei anderen Funktionen durch eine eigene Funktion mit der Endung …Ex
erreicht wird).
© Chr. Vogt, FH München
26
6.2
Critical Sections
Das Win32-API stellt spezielle Funktionsaufrufe für die Verwendung kritischer Abschnitte zur
Verfügung. Letztlich arbeiten diese natürlich wieder mit Synchronisationsobjekten des Kernels. Die wichtigste Einschränkung bei den Critical Sections ist, dass diese nur zur Synchronisation zwischen den Threads eines einzelnen Prozesses verwendet werden können.
Das Vorgehen ist wie folgt:
Zunächst muß im Programm eine Datenstruktur vom Typ CRITICAL_SECTION reserviert
werden. Diese muß dann durch einen Aufruf der Funktion
VOID InitializeCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // address of critical section object
);
initialisiert werden, wobei lpCriticalSection ein Zeiger auf die Struktur vom Typ
CRITICAL_SECTION ist.
Zum Eintritt in die Critical Section wird die Funktion
VOID EnterCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);
// pointer to critical section object
aufgerufen. Wie Sie sehen, gibt es hierbei, im Gegensatz zu den oben besprochenen WaitFor-Funktionen, keine Möglichkeit einen Timeout anzugeben. Als Alternative gibt es deshalb
auch noch eine nicht-blockierende Funktion, um zu versuchen, in den kritischen Abschnitt
eintreten zu dürfen:
BOOL TryEnterCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);
// pointer to critical section object
Wenn der Eintritt in die Critical Section möglich war, ist der Rückgabewert nicht Null. Ist die
Critical Section jedoch bereits „belegt“, wird eine 0 zurückgegeben. Im Gegensatz zu der
Funktion EnterCriticalSection blockiert sich der Thread jedoch nicht.
Wenn die Critical Section „frei“ ist, wird bei beiden Funktionen keine Kernel-Funktion aufgerufen. Ist die Critical Section „belegt“, wird eine Wait-Funktion auf ein Synchronisationsobjekt
des Kernels aufgerufen, das mit der Critical Section verknüpft ist.
Die Freigabe der Critical Section erfolgt mit Hilfe der Funktion
VOID LeaveCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);
// address of critical section object
Wenn ein Thread bei seiner Beendigung noch eine Critical Section „hält“ wird diese (leider)
nicht automatisch freigegeben (die Dokumentation sagt dazu: If a thread terminates while it
has ownership of a critical section, the state of the critical section is undefined.). Wie wir
später noch sehen werden, deutet dies darauf hin, dass Critical Sections nicht mit Hilfe von
© Chr. Vogt, FH München
27
Mutex-Objekten implementiert sind, obwohl sie im Prinzip genau die Funktionalität eines
Mutex implementieren.
Wenn die Critical Section überhaupt nicht mehr benötigt wird, sollten mit
VOID DeleteCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);
// pointer to critical section object
die verwendeten System-Ressourcen wieder freigegeben werden.
Insgesamt ist die Funktionalität der Critical Sections genau dieselbe, die auch mit einem
Mutex erreicht wird: der gegenseitige Ausschluß. Vielleicht findet der eine oder andere die
Namen der verwendeten Funktionen suggestiver. Aber es gibt auch noch einen weiteren
Vorteil: wenn die Critical Section nicht „belegt“ ist, erfordert die Anforderung mit
EnterCriticalSection oder TryEnterCriticalSection keinen Aufruf an den Kernel
und ist somit schneller. Dem stehen die folgenden Nachteile gegenüber:
•
•
•
6.3
Critical Sections können nur von den Threads eines einzelnen Prozesses verwendet
werden (und haben deswegen auch keinen Namen und keine Schutzattribute).
Bei der Anforderung einer Critical Section kann kein Timeout angegeben werden. (Als
Ersatz dafür gibt es aber die nicht-blockierende Anforderung.)
Critical Sections werden nicht automatisch bei der Beendigung des Threads freigegeben.
Mutexe
Mutexe haben - wie die im vorigen Abschnitt besprochenen Critical Sections - die Aufgabe,
gegenseitigen Ausschluß (mutual exclusion) zu gewährleisten. Ein wesentlicher Unterschied
zu den Critical Sections ist, dass Mutexe auch von Threads mehrerer Prozesse benutzt
werden können.
Ein Mutex wird mit der Funktion
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes,
BOOL bInitialOwner,
LPCTSTR lpName
);
// pointer to security attributes
// flag for initial ownership
// pointer to mutex-object name
erzeugt. Im zweiten Parameter, bInitialOwner, geben Sie an, ob der aufrufende Thread
gleich Besitzer des Mutex werden soll (TRUE) oder nicht (FALSE). Im letzteren Fall ist der
Mutex im Signalled State. Im Parameter lpName können Sie einen Namen für den Mutex
angeben, was Sie in aller Regel auch wollen. Wenn es bereits einen Mutex mit dem angegebenen Namen gibt, wird ein Handle zu dem vorhandenen Mutex geöffnet, ansonsten wird ein
neues Mutex-Objekt erzeugt.
Ein Handle zu einem vorhandenen Mutex erhält man auch mit der Funktion
HANDLE OpenMutex(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
LPCTSTR lpName
);
© Chr. Vogt, FH München
// access flag
// inherit flag
// pointer to mutex-object name
28
Die möglichen Angaben für dwDesiredAccess sind MUTEX_ALL_ACCESS oder
SYNCHRONIZE. Beide erlauben die Verwendung des Mutex für die Synchronisation.
Für Threads innerhalb eines Prozesses ist es natürlich nicht notwendig (aber vielleicht dennoch sinnvoll!), dass jeder Thread ein eigenes Handle zu dem Mutex öffnet. Es genügt, wenn
ein Thread ein Handle öffnet, denn dieses steht dann auch allen anderen Threads des
Prozesses zur Verfügung. In diesem Fall kann auch auf einen Namen für den Mutex
verzichtet und für lpName einfach NULL angegeben werden.
Das Anfordern eines Mutex geschieht nun mit einer der schon besprochenen Wait-Funktionen unter Angabe des Handles. Wenn die Wait-Funktion erfolgreich beendet wird (d.h.
nicht mit WAIT_TIMEOUT oder WAIT_FAILED), „gehört“ der Mutex dem Thread und wurde
somit insbesondere in den Nonsignalled State versetzt.
Das Freigeben eines Mutex geschieht mit der Funktion
BOOL ReleaseMutex(
HANDLE hMutex
);
// handle of mutex object
die nur von dem Thread erfolgreich aufgerufen werden kann, der den Mutex hält. Dabei wird
der Mutex in den Signalled State versetzt. Falls ein oder mehrere Threads auf diesen Mutex
warten, wird einer von ihnen geweckt und wird zum Besitzer des Mutex.
Zu jeder Zeit kann immer nur ein Thread Besitzer eines Mutex sein. Das Betriebssystem
merkt sich, welche Mutexe ein Thread besitzt. Bei der Beendigung eines Threads werden
alle von ihm noch gehaltenen Mutexe freigegeben, d.h. in den Nonsignalled State versetzt,
und werden als „verlassene“ Mutexe („abandoned“) gekennzeichnet. Der nächste Thread,
der einen solchen „verlassenen“ Mutex erhält, bekommt dabei den alternativen Rückgabewert WAIT_ABANDONDED, wird also darüber informiert, dass der Mutex nicht „normal“ freigegeben wurde, sondern dass der vorherige Besitzer-Thread vor Freigabe des Mutex beendet
wurde, seine Bearbeitung der durch den Mutex geschützten Daten also möglicherweise nicht
abgeschlossen hat. Es ist dann Aufgabe des Programms, geeignet auf diese Situation zu
reagieren.
Wenn ein Thread einen Mutex nicht mehr benötigt, sollte er das Handle zu dem Mutex
schließen. Das Mutex-Objekt wird automatisch gelöscht, wenn das letzte Handle zu ihm geschlossen wird.
6.4
Semaphore
Ein Semaphor wird mit der Funktion
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
// pointer to security attributes
LONG lInitialCount,
// initial count
LONG lMaximumCount,
// maximum count
LPCTSTR lpName
// pointer to semaphore-object name
);
© Chr. Vogt, FH München
29
erzeugt. Die Bedeutung des Parameters lpName ist wie bei Mutexen: falls bereits ein Semaphor dieses Namens existiert, wird ein Handle zu dem bestehenden Semaphor geöffnet,
ansonsten wird ein neues Semaphor-Objekt erzeugt.
Der Parameter lMaximumCount gibt den maximalen Wert des Semaphors an, der Parameter
lInitialCount einen - möglicherweise vom Maximalwert abweichenden - Initialwert.
Ein Handle zu einem bereits existierenden Semaphor erhält man mit der Funktion
HANDLE OpenSemaphore(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
LPCTSTR lpName
);
// access flag
// inherit flag
// pointer to semaphore-object name
Die möglichen Angaben für dwDesiredAccess sind SEMAPHORE_ALL_ACCESS,
SEMAPHORE_MODIFY_STATE (wird für die Funktion ReleaseSemaphore benötigt) und
SYNCHRONIZE (wird für das Anfordern des Semaphors benötigt).
Wie geschieht nun das Anfordern eines Semaphors? Nun, natürlich mit den schon bekannten Wait-Funktionen. Bei der Gewährung einer Anforderung wird der Semaphorenwert
jeweils um 1 erniedrigt. (Hat der Semaphor den Wert 0 erreicht, geht er bekanntlich in den
Nonsignalled State über.) Was aber, wenn der Semaphor mehr als einmal angefordert, d.h.
der Semaphorenwert um mehr als 1 erniedrigt werden soll? In diesem Fall muß die Funktion
WaitForSingleObject entsprechend oft aufgerufen werden. Die Funktion
WaitForMultilpleObjects erlaubt nämlich nicht die mehrfache Angabe desselben
Handles. Die Anforderung durch mehrfachen Aufruf von WaitForSingleObject ist aber
natürlich nicht atomar, also nicht ununterbrechbar. (Ob der Ausweg funktioniert, der Ihnen
jetzt sofort in den Kopf kommt, sollen Sie im Praktikum ausprobieren.)
Anders ist dies bei der Freigabe eines Semaphors, bei der angegeben werden kann, um
welchen Betrag der Semaphorenwert erhöht werden soll:
BOOL ReleaseSemaphore(
HANDLE hSemaphore,
LONG lReleaseCount,
LPLONG lpPreviousCount
);
// handle of the semaphore object
// amount to add to current count
// address of previous count
Wenn der alte Semaphorenwert nicht benötigt wird, kann für lpPreviousCount auch NULL
angegeben werden. Wenn durch die Erhöhung des Semaphorenwertes um lReleaseCount
der Maximalwert des Semaphors überschritten würde, schlägt die Funktion fehl!
Es gibt übrigens leider keine Funktion, um den Wert eines Semaphors abzufragen, ohne
diesen gleichzeitig zu verändern. Für den Parameter lReleaseCount von
ReleaseSemaphore darf auch nicht der Wert 0 angegeben werden.
Eine Besonderheit beim Freigeben eines Semaphors ist, dass dies unabhängig von einer
vorhergehenden Anforderung geschehen kann! Ein Semaphor kann von mehreren Threads
ein- oder mehrfach angefordert worden sein. Das Betriebssystem unterhält keine Informationen darüber, welcher Thread welche Semaphoranforderungen gemacht hat. Dementsprechend kann auch jeder beliebige Thread einen Semaphor freigeben (solange dadurch dessen Maximalwert nicht überschritten wird), egal, ob oder wie oft er den Semaphor vorher
angefordert hatte. Es gibt Situationen, in denen dieses Verhalten unbedingt erforderlich ist
© Chr. Vogt, FH München
30
(siehe Betriebssystem-Vorlesung). Andererseits können Sie sich sicher vorstellen, welche
vielfältigen Fehlermöglichkeiten dies für Ihre Programme bedeutet!
Es gibt aber noch eine weitere Konsequenz der Tatsache, dass das Betriebssystem sich
nichts über die Semaphoranforderungen der Threads merkt. Wenn ein Thread sich beendet,
können – anders als bei einem Mutex – die Semaphoranforderungen des Threads nicht
rückgängig gemacht werden! Auch dieses Verhalten ist in manchen Situationen notwendig,
in anderen eher unangenehm.
6.5
Events
Events (zu gut deutsch Ereignisse) sind eine sehr einfache Form von Synchronisationsobjekten. Sie dienen der Information über das Auftreten einer bestimmten Situation, z.B. den
Abschluß irgendeiner Operation. Die Art der Operation oder des Ereignisses kann dabei
ganz beliebig sein, denn ein Event kann nur explizit von einem Programm „ausgelöst“, d.h.
das entsprechende Objekt in den Signalled State versetzt werden. Was ein Programm zur
Auslösung des Events veranlaßt, und welche Bedeutung das für auf das Event wartende
Threads hat, ist dem Betriebssystem völlig egal.
Ein Event-Objekt wird mit der Funktion
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // pointer to security attributes
BOOL bManualReset,
// flag for manual-reset event
BOOL bInitialState,
// flag for initial state
LPCTSTR lpName
// pointer to event-object name
);
erzeugt. Diese Funktion enthält zwei uns noch unbekannte Parameter: bInitialState gibt an,
ob das Event-Objekt ursprünglich im Signalled State (TRUE) oder im Nonsignalled State
(FALSE) sein soll. Der Parameter bManualReset legt fest, um welche von zwei verschiedenen Arten von Event-Objekten es sich handeln soll:
•
•
Auto-Reset Event
Manual-Reset Event
(Angabe: FALSE)
(Angabe: TRUE)
Die zwei Unterschiede zwischen diesen Arten von Events bestehen darin,
•
•
ob das Event-Objekt automatisch wieder in den Nonsignalled State versetzt wird, wenn
eine Wartefunktion für dieses Objekt beendet wird oder nicht,
ob ein oder alle wartenden Threads geweckt werden, wenn das Event-Objekt in den
Signalled State übergeht.
Bei einem Manual-Reset Event werden alle wartenden Threads geweckt, und das EventObjekt bleibt im Signalled State, bis es explizit - eben manuell - wieder in den Nonsignalled
State versetzt wird.
Dagegen verhält sich ein Auto-Reset Event ähnlich wie ein Mutex: geht das Objekt in den
Signalled State, so wird ein Thread, der auf das Objekt wartet, geweckt, und das EventObjekt wird automatisch wieder in den Nonsignalled State zurückgesetzt. Wenn im Moment
kein Thread auf das Auto-Reset Event wartet, so bleibt dieses im Signalled State, bis ein
© Chr. Vogt, FH München
31
Thread eine Wartefunktion für es beendet, und wird dann automatisch wieder in den Nonsignalled State gesetzt.
Welche Art von Event-Objekt benötigt wird, hängt ganz von der Logik der Applikation ab. Das
Buch von Jeffrey Richter enthält für beide Arten von Event-Objekten anschauliche Beispiele.
Natürlich gibt es auch wieder eine Funktion, um ein Handle zu einem bereits vorhandenen
Event-Objekt zu öffnen:
HANDLE OpenEvent(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
LPCTSTR lpName
);
// access flag
// inherit flag
// pointer to event-object name
Die möglichen Angaben für dwDesiredAccess sind dabei: EVENT_ALL_ACCESS,
EVENT_MODIFY_STATE und SYNCHRONIZE.
Um ein Event-Objekt explizit in den Signalled oder den Nonsignalled State zu setzen gibt es
die beiden Funktionen
BOOL SetEvent(
HANDLE hEvent
);
// handle of event object
BOOL ResetEvent(
HANDLE hEvent
);
// handle of event object
Manchmal möchte man ein Event signalisieren, damit die momentan auf dieses Event
wartenden Threads weiterlaufen können, und das Event dann sofort wieder in den NonSignalled State setzen. Für diese Funktionalität gibt es eine eigene Funktion:
BOOL PulseEvent(
HANDLE hEvent
);
// handle of event object
Diese Funktion kann für jede Art von Event aufgerufen werden. Bei einem Manual-Reset
Event werden dabei alle wartenden Threads geweckt, bei einem Auto-Reset Event nur einer.
Anschließend wird das Event-Objekt wieder in den Nonsignalled State gesetzt, auch, wenn
es gar keine auf das Objekt wartenden Threads gab.
6.6
Waitable Timer
Waitable Timer wurden mit der Version 4.0 von Windows NT neu eingeführt. Es handelt sich
dabei um Kernel-Objekte, die zu bestimmten Zeitpunkten und/oder in bestimmten Zeitabständen vom Betriebssystem in den Signalled State gesetzt werden.
Zur Erzeugung eines Waitable Timer Objektes dient die Funktion
© Chr. Vogt, FH München
32
HANDLE CreateWaitableTimer(
LPSECURITY_ATTRIBUTES lpTimerAttributes,
BOOL bManualReset,
LPCTSTR lpTimerName
);
// pointer to security attributes
// flag for manual reset state
// pointer to timer object name
Timer werden Sie häufig auch nur innerhalb eines Threads oder Prozesses verwenden. In
diesem Fall geben Sie für lpTimerName NULL an, um einen namenlosen Timer zu erzeugen.
Der Parameter bManualReset deutet schon an, dass es auch bei Timern zwei Arten gibt:
•
•
Manual-Reset Timer
Synchronization Timer
(Angabe: TRUE)
(Angabe: FALSE)
Die Bedeutung ist ähnlich zu der bei Events: geht ein Manual-Reset Timer in den Signalled
State, werden alle auf ihn wartenden Threads geweckt, und der Timer bleibt Signalled, bis er
entweder explizit zurückgesetzt oder auf eine neue Zeit eingestellt wird. Geht ein Synchronization Timer in den Signalled State, so wird einer der auf ihn wartenden Threads geweckt
und der Timer wieder in den Nonsignalled State gesetzt. Wartet im Moment kein Thread auf
den Synchronization Timer, so bleibt er im Signalled State, bis ein Thread eine Wartefunktion
für ihn beendet, und wird dann automatisch wieder in den Nonsignalled State gesetzt.
Beide Arten von Timern können außerdem noch als periodische Timer definiert werden, die
sich bei Ablauf eines Zeitintervalls automatisch wieder auf den nächsten Zeitpunkt einstellen.
Natürlich gibt es auch wieder die Funktion
HANDLE OpenWaitableTimer(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
LPCTSTR lpTimerName
);
// access flag
// inherit flag
// pointer to timer object name
Die möglichen Angaben für den gewünschten Zugriff sind TIMER_ALL_ACCESS,
TIMER_MODIFY_STATE und SYNCHRONIZE.
Exkurs: Zeitangaben in Windows
Bevor wir uns mit dem Einstellen von Timern beschäftigen können, müssen wir uns anschauen, wie in Windows Zeitangaben gemacht werden. Es gibt dafür die beiden Strukturen
FILETIME und SYSTEMTIME sowie Funktionen, um diese ineinander umzurechnen.
Die FILETIME-Struktur ist ein 64-bit-Wert, der die Anzahl von 100-Nanosekunden Intervallen seit dem 1. Januar 1601 angibt.
typedef struct _FILETIME { // ft
DWORD dwLowDateTime;
DWORD dwHighDateTime;
} FILETIME;
Beachten Sie, dass dies die Definition eines 64-bit-Wertes mit Vorzeichen ist. Nur die positiven Werte, also mit höchstem Bit 0, unterliegen der obigen Interpretation. Die negativen
Werte werden für relative Zeitangaben verwendet (s.u.).
© Chr. Vogt, FH München
33
Hausaufgabe: Wann läuft die FILETIME-Struktur über?
Die SYSTEMTIME-Struktur gibt Datum und Zeit mit Hilfe einzelner Felder für Tag, Monat,
Jahr, Stunde, Minute etc. an:
typedef struct _SYSTEMTIME {
WORD wYear;
WORD wMonth;
WORD wDayOfWeek;
WORD wDay;
WORD wHour;
WORD wMinute;
WORD wSecond;
WORD wMilliseconds;
} SYSTEMTIME;
// st
Die Monatsangabe, wMonth, erfolgt durch 1 für Januar, 2 für Februar, usw., die Angabe des
Wochentages durch 0 für Sonntag, 1 für Montag, usw.
Zum Umrechnen einer SYSTEMTIME in eine FILETIME oder umgekehrt gibt es die beiden
Funktionen
BOOL SystemTimeToFileTime(
CONST SYSTEMTIME *lpSystemTime,
LPFILETIME lpFileTime
);
// address of system time to convert
// address of buffer for converted file time
BOOL FileTimeToSystemTime(
CONST FILETIME *lpFileTime,
LPSYSTEMTIME lpSystemTime
);
// pointer to file time to convert
// pointer to structure to receive system time
Letztere Funktion funktioniert natürlich nur für eine „positive“ FILETIME.
„Intern“ arbeitet Windows immer mit UTC (Universal Time Coordinated). Wenn Sie eine
Zeitangabe machen wollen, werden Sie aber in aller Regel die lokale Zeit meinen. Für Zeitangaben im FILETIME-Format können Sie mit folgenden Funktionen die Umwandlung von
lokaler Zeit in UTC-Zeit oder umgekehrt vornehmen:
BOOL FileTimeToLocalFileTime(
CONST FILETIME *lpFileTime,
LPFILETIME lpLocalFileTime
);
// pointer to UTC file time to convert
// pointer to converted file time
BOOL LocalFileTimeToFileTime(
CONST FILETIME *lpLocalFileTime,
LPFILETIME lpFileTime
);
// address of local file time to convert
// address of converted file time
Um mit Zeiten zu rechnen, sollte weder eine SYSTEMTIME noch eine FILETIME verwendet
werden, sondern die FILETIME-Struktur sollte in eine LARGE_INTEGER-Struktur kopiert
werden:
typedef union _LARGE_INTEGER {
struct {
© Chr. Vogt, FH München
34
DWORD LowPart;
LONG HighPart;
};
LONGLONG QuadPart;
} LARGE_INTEGER;
Da LONGLONG nichts anderes ist als __int64 kann dann mit 64-bit-Integer-Arithmetik gearbeitet werden.
Ende des Exkurses
Jetzt können wir uns anschauen, wie ein Timer eingestellt wird:
BOOL SetWaitableTimer(
HANDLE hTimer,
const LARGE_INTEGER *pDueTime,
LONG lPeriod,
PTIMERAPCROUTINE pfnCompletionRoutine,
LPVOID lpArgToCompletionRoutine,
BOOL fResume
);
// handle to a timer object
// when timer will become signaled
// periodic timer interval
// pointer to the completion routine
// data passed to the completion routine
// flag for resume state
Der Parameter *pDueTime gibt an, wann der Timer in den Signalled State übergehen soll.
Die Angabe erfolgt als LARGE_INTEGER, wobei ein positiver Wert eine absolute Zeitangabe
(UTC-Zeit!) bedeutet, ein negativer Wert eine relative.
Achtung: Obwohl LARGE_INTEGER und FILETIME das gleiche Binärformat haben, haben
sie unterschiedliche Anforderungen an die Ausrichtung. Die Angabe einer FILETIME-Struktur für *pDueTime kann deshalb zu (schwer zu findenden!) Fehlern führen.
Mit dem Parameter lPeriod legen Sie fest, ob es sich um einen periodischen Timer handeln
soll und geben evtl. die Länge des Timer-Intervalls in Millisekunden an. Ein Wert von 0 für
lPeriod bedeutet, dass der Timer genau einmal zur durch *pDueTime angegebenen Zeit signalisiert wird. Ein periodischer Timer (lPeriod > 0) wird erstmals zur durch *pDueTime angegebenen Zeit signalisiert, und danach regelmäßig im Abstand von lPeriod Millisekunden
bis der Timer mit CancelWaitableTimer gelöscht oder mit SetWaitableTimer neu gesetzt wird.
Die Bedeutung der letzten drei Parameter von SetWaitableTimer soll hier nur kurz angedeutet werden: Mit pfnCompletionRoutine können Sie eine Routine angeben (und mit
lpArgToCompletionRoutine einen Parameter für diese Routine), die bei Ablauf des Timers
als APC (Asynchronous Procedure Call) für den Thread aufgerufen wird. Dies geschieht jedoch nur dann, wenn der Thread sich in einem sog. „alertable wait state“ befindet, d.h. in
einem Wartezustand ausgelöst durch eine der ...Wait...Ex-Funktionen. Für den Parameter fResume geben Sie in aller Regel FALSE an. Die Angabe von TRUE würde bedeuten,
dass der Rechner bei Ablauf des Timers aus einem evtl. Stromspar-Schlafzustand wieder in
den normalen Betrieb übergehen soll.
Das folgende Beispiel zeigt, wie ein Timer so gesetzt wird, dass er erstmals in einer
Sekunde, und dann periodisch jede Sekunde in den Signalled State geht:
© Chr. Vogt, FH München
35
LONG
Period = 1000;
LARGE_INTEGER
LITime;
LITime.QuadPart = -10000000;
SetWaitableTimer(
hTimer,
&LITime,
Period,
NULL,
NULL,
FALSE
);
//
//
//
//
//
//
// 1000 Millisekunden
// -10000000 * 100 Nanosekunden
// d.h. in einer Sekunde ab jetzt
Handle für den Timer
relativ: in 1 Sekunde
Periode: 1000 msec
keine APC-Routine
und kein Parameter dafür
kein Resume
Wenn Sie einen aktivierten Timer vor Ablauf des Fälligkeitszeitpunkts „ausschalten“ wollen,
können Sie ihn entweder mit SetWaitableTimer auf einen neuen Wert einstellen, wobei
der „alte“ Fälligkeitszeitpunkt hinfällig wird, oder Sie deaktivieren ihn mit
BOOL CancelWaitableTimer(
HANDLE hTimer
// handle to a timer object
);
6.7
Funktionen für atomare Operationen
Gerade beim Schreiben von multi-threaded Applikationen ist das gemeinsame Verwenden
von Variablen das Normalste der Welt. Entsprechend wichtig ist es, darauf zu achten, dass
die Zugriffe synchronisiert werden. Dafür haben wir jetzt schon eine ganze Reihe von Möglichkeiten kennengelernt. Es wäre allerdings ziemlich aufwändig (sowohl für den Programmierer als auch für das System), wenn Sie für jede häufig vorkommende Operation - wie z.B.
das Inkrementieren oder Dekrementieren einer Variablen - mit Critical Sections oder
Mutexes arbeiten müssten. Deswegen gibt es für einige einfache Operationen auf Variablen
vom Typ long eigene API-Funktionen, die eine atomare Ausführung dieser Operationen
garantieren: die Interlocked-Funktionen. Diese Funktionen sind nicht nur einfacher zu
benutzen, sondern auch sehr viel schneller als eine explizite Synchronisation. Je nach verwendeter Plattform kann eine solche Funktion z.B. einfach einen atomaren Maschinenbefehl
verwenden.
Mit den Funktionen
LONG InterlockedDecrement(
LPLONG lpAddend
// address of the variable to decrement
);
LONG InterlockedIncrement(
LPLONG lpAddend
// address of the variable to increment
);
LONG InterlockedExchange(
LPLONG Target,
// address of 32-bit value to exchange
LONG Value
// new value for the LONG value pointed to by Target
);
© Chr. Vogt, FH München
36
kann eine long-Variable atomar dekrementiert, inkrementiert oder auf einen expliziten Wert
gesetzt werden. Der Rückgabewert von InterlockedExchange ist der alte Wert der Variablen vor der Änderung. Der Rückgabewert von InterlockedDecrement bzw.
InterlockedIncrement ist der neue Wert der Variablen.
(Richter behauptet in der 3. Auflage seines Buches, dass der Rückgabewert nur die Information liefert, ob der neue Wert kleiner, größer oder gleich 0 ist. Dieses Verhalten beruhte
jedoch auf einem Fehler des i386-Prozessors. Die Funktionen funktionieren also ab i486 wie
dokumentiert.)
Die folgenden beiden Funktionen wurden mit Windows NT 4.0 neu eingeführt:
LONG InterlockedExchangeAdd (
PLONG Addend,
// pointer to the addend
LONG Increment
// increment value
);
ist eine Verallgemeinerung von InterlockedDecrement und InterlockedIncrement,
die das Addieren eines beliebigen, in Increment angegebenen Wertes ermöglicht (der natürlich auch negativ sein darf).
PVOID InterlockedCompareExchange(
PVOID *Destination,
// pointer to the destination pointer
PVOID Exchange,
// the exchange value
PVOID Comperand
// the value to compare
);
ist eine bedingte Variante von InterlockedExchange. Der Wert der durch *Destination
angegebenen Variablen wird mit dem in Comperand angegebenen Wert verglichen, und nur
wenn beide Werte gleich sind, wird der Wert der Variablen durch den in Exchange angegebenen Wert ersetzt. Der Rückgabewert ist der alte Wert der Variablen.
In Windows XP und Windows Server 2003 kamen außerdem Funktionen für das atomare
Einfügen und Entfernen von Elementen einer einfach verketteten Liste hinzu:
•
•
•
•
InitializeSListHead
InterlockedFlushSList
InterlockedPopEntrySList
InterlockedPushEntrySList
In Windows Server 2003 gibt es die Interlocked-Funktionen jeweils in drei weiteren
Ausführungen:
•
•
•
mit dem Zusatz „64“ für die Operation auf 64 Bit breiten Variablen
mit dem Zusatz „Acquire“
mit dem Zusatz „Release“
„Acquire“ und „Release“ sind Speicherzugriffs-Semantiken, die mit der HyperthreadingTechnologie sowie in der IA-64-Architektur eingeführt wurden, auf die hier aber nicht näher
eingegangen werden soll.
© Chr. Vogt, FH München
37
7 Asynchrone I/Os.
Wenn ein Thread eine Ein-/Ausgabe macht, z.B. durch Aufruf von ReadFile oder
WriteFile, so kehrt diese Funktion normalerweise erst zurück, wenn der I/O abgeschlossen ist. Dieses Verhalten nennt man synchrone Ein-/Ausgabe. Ein I/O dauert z.B. auf eine
Festplatte einige Millisekunden. Um die CPU für andere Aufgaben freizumachen, wird bei
einem synchronen I/O der Thread während dieser Zeit blockiert und erst nach Beendigung
des I/O wieder geweckt. (Wenn der I/O aus dem Cache befriedigt werden kann ist dies
natürlich nicht nötig.)
Vielleicht kann ja aber auch der Thread, der den I/O macht, während dessen Ausführung
sinnvollerweise andere Aufgaben erledigen, die nicht davon abhängen, dass der angeforderte I/O bereits abgeschlossen ist. In diesem Fall sollte der Thread nicht blockiert werden,
bis der I/O beendet ist. Andererseits muß es dann Mechanismen geben, dem Thread mitzuteilen (bzw. vom Thread abfragen zu lassen), wenn (bzw. ob) der I/O abgeschlossen ist, ob
er erfolgreich beendet wurde, oder welcher Fehler dabei aufgetreten ist. Diese Vorgehensweise nennt man asynchrone Ein-/Ausgabe.
Windows kennt mehrere Techniken für asynchrone I/Os, die sich in der Art unterscheiden,
wie dem Thread die Beendigung des I/O mitgeteilt wird:
Technik
Bemerkung
Signalisieren des
Geräte-Objektes
(z.B. Datei-Objekt)
Nicht für mehrere parallele Übertragungen geeignet, da das GeräteObjekt bei Abschluß irgendeines I/O signalisiert wird. Anfordern und
Bearbeiten des I/O können von verschiedenen Threads aus erfolgen.
Signalisieren eines
Event-Objektes
Parallele Übertragungen sind möglich, da für jeden I/O ein eigenes
Event verwendet werden kann. Anfordern und Bearbeiten des I/O
können von verschiedenen Threads aus erfolgen.
Anfordern eines
APC (asynchronous
procedure call)
Parallele Übertragungen sind möglich. Der Thread, der den I/O
angefordert hat, führt auch die APC-Routine aus.
Verwenden eines I/O Parallele Übertragungen sind möglich. Anfordern und Bearbeiten des
completion port
I/O können von verschiedenen Threads aus erfolgen. Beinhaltet
weitergehende Möglichkeiten zur Steuerung der Anzahl paralleler
Threads.
© Chr. Vogt, FH München
38
7.1
Anfordern asynchroner I/Os
Schon beim Öffnen einer Datei (oder eines Gerätes, einer Pipe, einer Konsole usw.) muß
festgelegt werden, ob mit synchronen oder asynchronen I/Os gearbeitet werden soll.
Windows nennt asynchrone I/Os auch „overlapped“, also sich überlappende I/Os. Deswegen
heißt die Flag, die beim Öffnen asynchrone Bearbeitung anfordert
FILE_FLAG_OVERLAPPED.
Zum Erzeugen und zum Öffnen von Dateien dient dieselbe Funktion, nämlich CreateFile
(die außerdem nicht nur für Dateien, sondern auch für Geräte, Pipes usw. verwendet wird):
HANDLE CreateFile(
LPCTSTR lpFileName,
// pointer to name of the file
DWORD dwDesiredAccess,
// access (read-write) mode
DWORD dwShareMode,
// share mode
LPSECURITY_ATTRIBUTES lpSecurityAttributes, // pointer to security attributes
DWORD dwCreationDisposition,
// how to create
DWORD dwFlagsAndAttributes,
// file attributes
HANDLE hTemplateFile
// handle to file with attributes to copy
);
Bis auf lpSecurityAttributes und hTemplateFile sind die Parameter obligatorisch. Die
einfachsten Angaben für dwDesiredAccess sind:
0
GENERIC_READ
GENERIC_WRITE
Beide
Für Abfragen an das Objekt (kein Datenaustausch)
Lesezugriff
Schreibzugriff
Schreib- und Lesezugriff
Die Angabe 0 für dwShareMode bedeutet, dass die Datei nicht gemeinsam benutzt werden
kann. Die verschiedenen Share-Modi sind in der Dokumentation beschrieben.
Die möglichen Angaben für dwCreationDisposition sind:
CREATE_NEW
CREATE_ALWAYS
OPEN_EXISTING
OPEN_ALWAYS
Die Funktion schlägt fehl, wenn die Datei bereits existiert.
Wenn die Datei bereits existiert wird sie überschrieben.
Die Funktion schlägt fehl, wenn die Datei noch nicht existiert.
Erzeugt die Datei, falls sie noch nicht existiert.
Mit dwFlagsAndAttributes können diverse Einstellungen gemacht, oder es kann einfach
FILE_ATTRIBUTE_NORMAL angegeben werden. Für asynchrone I/Os muß hier die Angabe
FILE_FLAG_OVERLAPPED stehen. Die Angabe von FILE_FLAG_OVERLAPPED bedeutet für
die späteren I/Os für diese Datei:
•
•
•
•
Die Applikation muß asynchrone I/Os anfordern.
Wenn der (asynchrone) I/O eine beträchtliche Zeit dauert, gibt die Funktion FALSE
zurück und GetLastError liefert ERROR_IO_PENDING.
Es sind auch mehrere gleichzeitige asynchrone I/Os möglich.
Das System unterhält keinen File Pointer! Der File Pointer muß bei jedem I/O vom
Thread zur Verfügung gestellt werden.
Der letzte Punkt macht natürlich die Arbeit mit asynchronen I/Os etwas mühsam. Aber wie
sollte es anders sein? Bei mehreren noch in Ausführung befindlichen I/Os kann das System
© Chr. Vogt, FH München
39
nicht wissen, wo der File Pointer gerade steht. Also müssen Sie explizit sagen, von wo in der
Datei gelesen, bzw. wohin geschrieben werden soll.
Das folgende Beispiel öffnet eine bereits existierende Datei zum synchronen Lesen:
HANDLE hFile;
hFile = CreateFile("MYFILE.TXT",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
ErrorHandler("Could not open file.");
}
//
//
//
//
//
//
//
open MYFILE.TXT
open for reading
share for reading
no security
existing file only
normal file
no attr. template
// process error
Das folgende Beispiel erzeugt eine neue Datei und öffnet sie zum asynchronen Schreiben:
HANDLE hFile;
hFile = CreateFile("MYFILE.TXT",
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL |
FILE_FLAG_OVERLAPPED,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
ErrorHandler("Could not open file.");
}
//
//
//
//
//
//
//
//
create MYFILE.TXT
open for writing
do not share
no security
overwrite existing
normal file
asynchronous I/O
no attr. template
// process error
Bei allen Funktionen zur Anforderung eines asynchronen I/Os muß eine initialisierte Datenstruktur angegeben werden, die OVERLAPPED-Struktur:
typedef struct _OVERLAPPED { // o
DWORD Internal;
DWORD InternalHigh;
DWORD Offset;
DWORD OffsetHigh;
HANDLE hEvent;
} OVERLAPPED;
Vor dem Aufruf einer I/O-Funktion müssen die Felder Offset, OffsetHigh und hEvent initialisiert werden. Offset und OffsetHigh geben den 64-bit File Pointer an. hEvent enthält entweder die Angabe NULL oder das Handle auf ein (manual-reset!) Event-Objekt, das bei Beendigung des I/O signalisiert werden soll. Die Felder Internal und InternalHigh werden vom System bei Beendigung des I/O ausgefüllt (siehe unten).
Jetzt kann der asynchrone Zugriff angefordert werden. Um die Form des asynchronen I/Os
mit Angabe eines APC zu verwenden, müssen die Funktionen ReadFileEx und
WriteFileEx benutzt werden, die weiter unten in einem eigenen Abschnitt behandelt
werden. Ansonsten nimmt man die üblichen Funktionen ReadFile und WriteFile, die
auch für synchrone I/Os verwendet werden:
© Chr. Vogt, FH München
40
BOOL ReadFile(
HANDLE hFile,
LPVOID lpBuffer,
DWORD nNumberOfBytesToRead,
LPDWORD lpNumberOfBytesRead,
LPOVERLAPPED lpOverlapped
);
// handle of file to read
// address of buffer that receives data
// number of bytes to read
// address of number of bytes read
// address of structure for data
BOOL WriteFile(
HANDLE hFile,
LPCVOID lpBuffer,
DWORD nNumberOfBytesToWrite,
LPDWORD lpNumberOfBytesWritten,
LPOVERLAPPED lpOverlapped
);
// handle to file to write to
// pointer to data to write to file
// number of bytes to write
// pointer to number of bytes written
// pointer to structure needed for overlapped I/O
Natürlich muß für lpOverlapped die initialisierte OVERLAPPED-Struktur übergeben werden.
Für die Parameter lpNumberOfBytesRead bzw. lpNumberOfBytesWritten sollte NULL
übergeben werden. Diese Information steht ja möglicherweise nach der Ausführung von
ReadFile bzw. WriteFile noch gar nicht zur Verfügung. Wie man diese Information nach
Beendigung des I/O erhält, werden wir weiter unten sehen.
Wenn die Funktionen ohne Fehler synchron ausgeführt werden (z.B. weil nur auf den Cache
zugegriffen wurde), so ist der Rückgabewert TRUE. Bei asynchroner Ausführung geben die
Funktionen FALSE zurück und GetLastError liefert ERROR_IO_PENDING.
Ein wichtiger Unterschied beim asynchronen Lesen gegenüber dem synchronen Lesen besteht in der Behandlung einer End-of-File-Situation. Beim synchronen Lesen liefert ReadFile
den Rückgabewert TRUE sowie 0 im Parameter lpNumberOfBytesRead. Beim asynchronen
Lesen gibt es zwei Fälle:
•
•
7.2
Wird die End-of-File-Situation schon von ReadFile festgestellt, so ist der Rückgabewert
FALSE und GetLastError liefert ERROR_HANDLE_EOF.
Wird End-of-File erst während der asynchronen Ausführung entdeckt, so erhält man
diese Information bei der späteren Auswertung des I/O-Ergebnisses (siehe unten).
Synchronisation mit der I/O-Beendigung
Bei der Beendigung eines asynchronen I/Os
•
•
signalisiert der Kernel das betreffende Gerät (Datei),
signalisiert der Kernel ein evtl. in der OVERLAPPED-Struktur angegebenes EreignisObjekt (außer wenn der I/O mit ReadFileEx bzw. WriteFileEx angefordert wurde).
Somit ist klar, dass das Synchronisieren mit der I/O-Beendigung mit den bekannten Funktionen WaitForSingleObject und WaitForMultipleObjects (Warten auf einen oder alle
von mehreren I/Os) geschehen kann. Bekanntlich können diese Funktionen sowohl blockierend (mit oder ohne Timeout) oder nicht-blockierend verwendet werden. Eine schnellere
Möglichkeit, nicht-blockierend zu überprüfen, ob ein I/O bereits beendet ist, bietet das Makro
© Chr. Vogt, FH München
41
BOOL HasOverlappedIoCompleted(
LPOVERLAPPED lpOverlapped
);
das wie folgt definiert ist:
#define HasOverlappedIoCompleted(lpOverlapped) \
((lpOverlapped)->Internal != STATUS_PENDING)
Nach Beendigung des I/Os sollte natürlich überprüft werden, ob er erfolgreich war, oder ob
ein Fehler aufgetreten ist. Außerdem wollen wir evtl. noch wissen, wieviele Bytes gelesen
bzw. geschrieben wurden. Für diese Aufgaben gibt es die Funktion
BOOL GetOverlappedResult(
HANDLE hFile,
LPOVERLAPPED lpOverlapped,
LPDWORD lpNumberOfBytesTransferred,
BOOL bWait
);
// handle of file, pipe, or communications device
// address of overlapped structure
// address of actual bytes count
// wait flag
Für hFile und lpOverlapped müssen dieselben Angaben gemacht werden wie bei der Anforderung mit ReadFile bzw. WriteFile. Für lpNumberOfBytesTransferred muß ein gültiger
Zeiger angegeben werden, auch wenn Sie an dieser Information nicht interessiert sind. Mit
bWait können Sie noch steuern, ob die Funktion blockierend (bWait = TRUE) oder nichtblockierend (bWait = FALSE) ausgeführt werden soll, falls der I/O noch nicht beendet ist.
GetOverlappedResult kann also auch als Alternative zu WaitForSingleObject (ohne
Timeout oder mit unendlichem Timeout) verwendet werden.
Bei nicht-blockierendem Aufruf vor Beendigung des I/O ist der Rückgabewert von
GetOverlappedResult FALSE und GetLastError liefert ERROR_IO_INCOMPLETE.
Auch wenn ein Lesezugriff auf End-of-File gestoßen ist liefert GetOverlappedResult
FALSE, das Ergebnis von GetLastError ist dann ERROR_HANDLE_EOF. Der Rückgabewert TRUE bedeutet, dass GetOverlappedResult erfolgreich ausgeführt wurde.
Aber was ist mit der Ausführung des asynchronen I/O? Dessen Status wurde vom System in
das Feld Internal der OVERLAPPED-Struktur geschrieben. (Im Feld InternalHigh steht übrigens noch einmal die Anzahl der transferierten Bytes.) Sie sollten deshalb unbedingt das
Feld Internal überprüfen.
7.3
APCs als I/O-Beendigungsroutine
Die Grundidee dieser Art des asynchronen I/Os ist, dass das Betriebssystem nach Beendigung des I/O dafür sorgt, dass der Thread eine bestimmte Routine ausführt. Der Thread
bräuchte sich dann nicht mehr um die Synchronisation mit der I/O-Beendigung zu kümmern.
Prinzipiell ist ein solcher Mechanismus in Windows implementiert, nicht nur für die Verwendung mit asynchronen I/Os: der APC-Mechanismus (Asynchronous Procedure Calls). Jeder
Thread hat eine APC-Warteschlange, in die Anforderungen an den Thread, bestimmte
Routinen auszuführen, eingereiht werden können. Aber nur die sog. Kernel-Mode APCs arbeiten wirklich asynchron: sie werden sofort nach dem Einreihen in die Warteschlange auf-
© Chr. Vogt, FH München
42
gerufen und ausgeführt (bzw. sobald der Thread die CPU erhält). Diese Kernel-Mode APCs
stehen aber nur dem Betriebssystem zur Verfügung.
Anwendungsprogrammen stehen nur die sog. User-Mode APCs zur Verfügung. Der wesentliche Unterschied zu den Kernel-Mode APCs ist, dass ein User-Mode APC nicht sofort zur
Ausführung gelangt, sondern erst dann, wenn der Thread in einem sog. „alertable wait state“,
also einem alarmierbaren Wartezustand ist. Ein Thread gelangt durch folgende Funktionen in
einen alertable wait state:
•
•
•
•
•
SleepEx
SignalObjectAndWait
MsgWaitForMultipleObjectsEx
WaitForMultipleObjectsEx
WaitForSingleObjectEx
Auf jeden Fall muß also der Thread explizit etwas tun, damit ein User-Mode APC zur Ausführung gelangt. Wenn er eigentlich keinen Grund hat, eine der obigen Funktionen aufzurufen,
kann er durch SleepEx(0,TRUE) explizit „nachschauen“, ob User-Mode APCs anstehen
und diese zur Ausführung bringen (allerdings gibt der Thread damit seine Zeitscheibe auf!).
Im Zusammenhang mit unseren asynchronen I/Os sehe ich deshalb keinen funktionalen
Unterschied zwischen der APC-Methode und der oben besprochenen Synchronisation über
das Geräte- oder ein Event-Objekt.
Ein asynchroner I/O unter Angabe einer APC-Routine wird mit ReadFileEx bzw.
WriteFileEx angefordert:
BOOL ReadFileEx(
HANDLE hFile,
// handle of file to read
LPVOID lpBuffer,
// address of buffer
DWORD nNumberOfBytesToRead,
// number of bytes to read
LPOVERLAPPED lpOverlapped,
// address of offset
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
// address of completion routine
);
BOOL WriteFileEx(
HANDLE hFile,
// handle to output file
LPCVOID lpBuffer,
// pointer to input buffer
DWORD nNumberOfBytesToWrite,
// number of bytes to write
LPOVERLAPPED lpOverlapped,
// pointer to async. i/o data
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
// ptr. to completion routine
);
Wichtigster Unterschied zu den Funktionen ReadFile bzw. WriteFile ist natürlich der
Parameter lpCompletionRoutine, der die Adresse einer Routine angibt, die nach Beendigung
des I/O über den User-Mode-APC-Mechanismus zur Ausführung gebracht wird. Diese Routine muß folgenden Prototyp haben:
VOID WINAPI FileIOCompletionRoutine(
DWORD dwErrorCode,
// completion code
DWORD dwNumberOfBytesTransfered,
// number of bytes transferred
LPOVERLAPPED lpOverlapped
// pointer to structure with I/O information
);
© Chr. Vogt, FH München
43
Der Parameter dwErrorCode erhält vom System entweder den Wert 0, wenn der I/O
ordnungsgemäß durchgeführt wurde („the I/O was successful“) oder aber
ERROR_HANDLE_EOF, ist also das Analogon zum Rückgabewert von
GetOverlappedResult. Der eigentliche Status des I/O steht im Feld Internal der
OVERLAPPED-Struktur. Ansonsten sieht man an den Parametern, dass die BeendigungsRoutine Zugriff auf die benötigten Daten erhält, nämlich dwNumberOfBytesTransfered und
lpOverlapped.
7.4
I/O Completion Ports
Mit Windows NT 3.5 wurde ein neues Kernel-Objekt namens „I/O Completion Port“ eingeführt. Die Idee dahinter ist nicht nur die Ausführung von asynchronen I/Os, sondern darüber
hinaus die Steuerung der Anzahl von Threads, die diese I/Os verarbeiten. Zunächst soll deshalb die Idee hinter den I/O Completion Ports etwas näher ausgeführt werden.
Für die Architektur von Server-Anwendungen gibt es prinzipiell zwei Modelle:
•
•
Im seriellen Modell wartet ein Thread auf die Anfrage eines Clients. Beim Eintreffen einer
Anfrage erwacht der Thread und bearbeitet diese.
Im Concurrent Modell wartet ein Thread auf die Anfrage eines Clients und legt dann zum
Bearbeiten der Anfrage einen neuen Thread an. Wenn der Thread zum Bearbeiten einer
Client-Anfrage seine Aufgabe erledigt hat, beendet er sich.
Das Concurrent Modell ist insbesondere für SMP-Maschinen gut geeignet, da es mit seinen
mehreren Threads zusätzliche CPUs ausnutzen kann. Wenn die Zahl gleichzeitig zu bearbeitender Client-Anfragen und somit die Anzahl von Threads jedoch zu groß wird, verbringt
das System aber zuviel Zeit mit Kontextwechseln zwischen den Threads.
I/O Completion Ports ermöglichen es, eine Obergrenze für die Anzahl von gleichzeitig aktiven Threads anzugeben. Das Programm erzeugt dann die Threads nicht mehr dynamisch,
sondern stellt einen Pool von Threads zur Verfügung, von denen maximal so viele aktiviert
werden, wie die Obergrenze zuläßt.
Zum Erzeugen eines I/O Completion Ports verwenden Sie die Funktion
HANDLE CreateIoCompletionPort (
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
DWORD CompletionKey,
DWORD NumberOfConcurrentThreads
// file handle to associate with I/O completion port
// optional handle to existing I/O completion port
// per-file completion key for I/O completion
// packets
// number of threads allowed to execute
// concurrently
);
Diese Funktion macht zweierlei:
•
•
Sie legt einen I/O Completion Port an.
Sie verbindet eine Datei mit dem I/O Completion Port. Bei der Beendigung eines asynchronen I/Os auf diese Datei wird dann der I/O Completion Port „benachrichtigt“.
© Chr. Vogt, FH München
44
FileHandle muß ein Handle auf ein für asynchrone I/Os geöffnetes Gerät (Datei) sein. Für
ExistingCompletionPort geben Sie beim Erzeugen eines neuen I/O Completion Ports NULL
an, beim Verbinden einer weiteren Datei mit einem vorhandenen Port das Handle zu diesem
Port. In CompletionKey können Sie eine dateispezifische Information angeben, die bei Beendigung eines asynchronen I/Os für diese Datei an den Port (und später an das Programm,
das den I/O verarbeitet) übergeben wird. Dies ist natürlich nur dann nützlich, wenn Sie
mehrere Dateien mit einem I/O Completion Port verbinden. NumberOfConcurrentThreads
legt fest, wieviele Threads maximal laufen können sollen. Die Angabe des Werts 0 bedeutet,
dass diese Maximalzahl an Threads auf die Zahl der CPUs des Rechners gesetzt wird.
Macht ein Programm asynchrone I/Os auf eine Datei, die mit einem I/O Completion Port verbunden wurde, so wird bei Beendigung des I/O ein „I/O completion notification packet“ an
den Port geschickt. Diese Pakete werden vom Port in einer FIFO-Warteschlange gespeichert. Ein Thread kann sich nun mit folgender Funktion eines dieser Pakete abholen:
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort,
LPDWORD lpNumberOfBytesTransferred,
LPDWORD lpCompletionKey,
LPOVERLAPPED *lpOverlapped,
DWORD dwMilliseconds
);
// the I/O completion port of interest
// to receive number of bytes
// transferred during I/O
// to receive file’s completion key
// to receive pointer to OVERLAPPED structure
// optional timeout value
Führen mehrere Threads gleichzeitig diese Funktion aus, so sorgt das System dafür, dass
maximal so viele Threads aktiviert werden, wie beim Erzeugen des I/O Completion Ports
angegeben wurde.
Zunächst sieht es so aus, als ob diese Funktionalität auch ohne I/O Completion Ports erreicht werden könnte: das Programm erzeugt einfach nur so viele Threads, wie maximal
laufen sollen. Aber die Implementierung der I/O Completion Ports ist noch schlauer. Wenn
ein Thread mit GetQueuedCompletionStatus auf eine I/O-Beendigung gewartet hat, wird
er nach Beendigung des Wartens als „fortgesetzter Thread“ betrachtet. Wenn er sich dann
allerdings während der Verarbeitung blockiert, wird er zu einem „angehaltenen Thread“. Bei
der Frage, ob bei Vorliegen eines beendeten I/Os ein mit GetQueuedCompletionStatus
wartender Thread geweckt werden darf, berücksichtigt der I/O Completion Port nun nur die
Zahl der „fortgesetzten Threads“, also nur die Threads im Zustand bereit oder laufend, nicht
jedoch die blockierten Threads. So wird eine maximale Auslastung der vorhandenen CPUs
erreicht, ohne übermäßige Kontextwechsel zu verursachen. Wird ein blockierter Thread wieder bereit, so können vorübergehend auch mehr aktive Threads existieren, als die Maximalzahl für den I/O Completion Port angibt. Spätestens wenn einer dieser Threads
GetQueuedCompletionStatus aufruft reduziert sich diese Zahl jedoch wieder.
Die Datenstrukturen, die zur Realisierung dieser Funktionalität benötigt werden, sind in
Richter’s Buch auf den Seiten 779-780 (3. Auflage) dargestellt.
Eine Anwendung, die mit I/O Completion Ports arbeitet, sollte also immer mehr Threads zur
Verfügung stellen, als gleichzeitig aktiv sein sollen. Richter empfiehlt als ersten Anhaltspunkt
die doppelte Anzahl an Threads (S. 782) und sodann eine Optimierung nach Gefühl oder
durch Ausprobieren (S. 786). Als Beispiel einer solchen Anwendung beschreibt er das Vorgehen des Microsoft Internet Information Server (IIS), der mit zehn Threads pro CPU startet,
diese Zahl aber auch dynamisch variiert.
© Chr. Vogt, FH München
45

Documentos relacionados