HighPerformance-Timer

von
Karsten Schneider





Motivation

In Visual-Basic ist ein Timer-Steuerelement implementiert, welches einige entscheidende Nachteile bezüglich seines Timer-Ereignisses hat, welche je nach Bedarf des Programmierers als wirkliche Unzulänglichkeiten des Steuerelementes erscheinen.

1. Nachteil:

Man Betrachte folgenden Code-Auschnitt:

Private Sub Timer1_Timer()
  .
  . (Code welcher abgearbeitet wird)
  .
End Sub
Das Timer-Ereigniss wird laut Dokumentation ausgelöst, wenn ein Zeitintervall, welches in Millisekunden angegeben wird, durchlaufen ist.
In Wirklichkeit kommt zu dieser Intervall ein Offset welcher durch die Abarbeitungszeit des Codes der Ereignissprozedur bestimmt ist.
 

2. Nachteil:

Das die Inervalllänge des Timer-Ereignisses läßt sich zwar bis auf eine Millisekunde einstellen, jedoch gibt es eine Plattformabhängige untere Schranke weit oberhalb einer Millisekunde. Unter Win95 ist diese Schranke 50 ms und unter WinNT4 10 ms. Das Timer-Ereigniss erreicht also unter WinNT eine maximale Frequenz von 100 Hz.
 

Lösung

Messen der Zeit

Das Windows-API enthält eine Funktion QueryPerformanceCounter, die eine LARGE_INTEGER-Variable mit dem aktuellen Zählerstand des Counters belegt. Visual Basic unterstützt jedoch keinen solchen Datentyp, jedoch den Datentyp Currency der intern als LARGE_INTEGER, also einer 64-Bit-Ganzzahl, gespeichert wird. Rechnet man mit dieser Variable so wird aus der 64-Bit-Ganzzahl eine Festkommazahl mit 4 Stellen hinter dem Komma indem sie durch 10000 geteilt wird.

Die Funktion wird wie folgt deklariert:

Declare Function QueryPerformanceCounter Lib "kernel32" (lpPerformanceCount As Currency) As Long
Um die Frequenz des Counters zu erfahren enthält das Windows-API die Funktion QueryPerformanceFrequency, die ebenfalls eine LARGE_INTEGER-Variable, in Visual Basic also eine Variable vom Typ Currency, mi der Frequenz des Counters belegt.
Auf den meisten x86-Installationen wird die Frequenzvariable wohl der Wert 119,3182 zugewiesen werden, welcher einer Frequenz von 1193182 Hz entspricht.
Dieser Wert läßt vermuten, das auf IBM-Kompatiblen Rechnern der Port des "Programmierbaren Intervall-Timers (PIT) 8253" ausgelesen wird.
Auf x86-Multiprozessor-Plattformen wird sogar der "Read Time Stamp Counter (RDTSC)" des Prozessors ausgelesen.
Diese Funktion sollte man am Anfang des Programmes aufrufen und den Rückgabewert auf Existenz eines HighPerformance-Counters überprüfen.

Die Funktion wird wie folgt deklariert:

Declare Function QueryPerformanceFrequency Lib "kernel32" (lpFrequency As Currency) As Long
Die vergangene Zeit zwischen zwei Aufrufen von QueryPerformancCounter berechnet sich dann aus der Differenz des Zählerstandes geteilt durch seine Frequenz.
Dim Frequency  as Currency
Dim Count0 as Currency
Dim Count1 as Currency
Dim HowLong as Double
Dim ErcL as Long

ErcL = QueryPerformanceFrequency Frequency
If  ErcL = 0 then End                   ' Ther is no high-performance-counter in the system

QueryPerformanceCounter Count0
          .
          . (code)
          .
QueryPerformanceCounter Count1

HowLong = Cdbl((Count1-Count0) / Frequency)        'Elapsed time in seconds

Obwohl die Frequenz des Counters sehr hoch ist gibt es auch hier eine untere Schranke, da beide Funktionen ca 5 µs benötigen um den Port auszulesen.

Implementierung

Da der Counter selbst keine Ereignisse auslöst implementiert man die Zählerstandsabfrage in einem Klassenmodul. In einer Do-Loop-Schleife vergleicht man anhand des Zählerstandes ob ein Zeitintervall verstrichen und löst gegebenenfalls ein Trigger-Ereigniss, analog dem Timer-Ereigniss des VB-Steuerelementes, aus.
Dadurch ist zwar der oben erwähnte 2. Nachteil des VB-Steuerelementes behoben, es tauchen aber viel größere Probleme auf.
Nach Aufruf einer Startfunktion der Klassenschnittstelle, welche den Timer starten soll, wird der Code im Standart- oder Formularmodul nicht weiter abgearbeitet, nur noch der Code im Trigger-Ereigniss des neuerschaffenen Timers erhält den Fokus.
Hier zeigt sich einer der größten Nachteile von Visual Basic. Man hat keine Möglichkeit dem Programm vorzuschreiben wann etwas quasiparallel abzuarbeiten ist.
Da wir auf einer Multithreading-Plattform arbeiten muss es aber eine Möglichkeit geben eigene Threads zu programmieren. Wo sonst als in der Windows-API findet man die entsprechenden Funktionen.

Das Thema "Multithreading" stellt für den VisualBasic-Programmierer ein heikles Thema dar bei dem viele Probleme auftauchen. Viele neue Begriffe stürzen auf ihn ein und es würde ein ganzes Buch füllen es ausreichend darzulegen. Dem Leser sei es überlassen sich darin an anderer Stelle einzuarbeiten da auch mir dieses Thema nur unzureichend geläufig ist und ich keine Quelle kenne die das Thema eingehend für Visual Basic beleuchtet. Eine kleine Einführung findet man in der c't 98.

Für dieses Problem  müssen wir jedoch nur einen Thread, indem die Timer-Schleife arbeitet, starten und ihn anschließen wieder löschen. Als Synchronisationselement bietet sich hier ein Event an, welches durch den Parentprozess signalisiert und vom Childthread auf dessen Zustand überprüft wird. Ist das Event signalisiert, so wird die Schleife mit einem Exit Do verlassen und der Childthread beendet sich selbst.
Gestartet wird der Thread mit der Funktion CreateThread, welche wie folgt deklariert wird:

Declare Function CreateThread Lib "kernel32" (lpThreadAttributes As SECURITY_ATTRIBUTES, ByVal dwStackSize As Long, _
                                                                              ByVal lpStartAddress As Long, lpParameter As Long, ByVal dwCreationFlags As Long, _
                                                                              lpThreadId As Long) As Long
Dem Argument LpTreadAttrbutes wird eine unbelegte Struktur übergeben, da der Handle auf den Thread nicht durch Childprozesse geerbt werden brauch, dwStacksize wird 0& übergeben damit Windows selbst die Stackgröße verwaltet, lpStartAddress wird die Adresse der Scheifenprozedur übergeben, lpParameter ist ein Argument welches der Schleifenprozedur übergeben wird, dwCreationFlags wird CREATE_SUSPENDED übergeben damit der Thread noch nicht ausgeführt wird und lpThreadId wird von der Funktion mit der Thread-ID belegt.
Die Zeider auf die Schleifenprozedur erhält man bekanntlich mit dem Operator AddressOff. Diese öffentliche Prozedur muß eine Funktion in einem Standartmodul sein und muß einen Long-Wert als Rückgabe liefern. Ebenso muß sie einen Long-Wert als Übergabeargument erwarten.
Rückgabewert von CreateThread ist ein Handle auf diesen Thread.
Anschließend kann man die Priorität des Threads mit der Funktion SetThreadPriority einstellen
Declare Function SetThreadPriority Lib "kernel32" (ByVal hThread As Long, ByVal nPriority As Long) As Long
Hier wird hThread der Handle auf den Thread und nPriority eine der folgenden Konstanten übergeben.
Konstanten für nPriority :    THREAD_PRIORITY_IDLE
                                           THREAD_PRIORITY_LOWEST
                                           THREAD_PRIORITY_BELOW_NORMAL
                                           THREAD_PRIORITY_NORMAL
                                           THREAD_PRIORITY_ABOVE_NORMAL
                                           THREAD_PRIORITY_HIGHEST
                                           THREAD_PRIORITY_TIME_CRITICAL
Die Benutzung von THREAD_PRIORITY_TIME_CRITICAL ist wenn  möglich zu vermeiden, da kein anderer Thread Prozessorzeit bekommt.
Bei meinen Anwendungen reichte schon THREAD_PRIORITY_LOWEST  um eine Triggerfrequenz über 10000Hz zu erreichen. Man sollte jedoch beachten, je höher die Priorität des Timer-Threads ist, desto weniger Prozessorzeit gibt der Scheduler dem eigentlichen Programm welches den Timer verwendet. Die optimale Einstellung muß jeder Programmierer für seine Wünsche und die verwendete Plattform selbst herausfinden.
Durch die Funktion ResumeThread wird der Thread auf den der Handle hThread zeigt gestartet. ResumeThread wird wie folgt deklariert:
Private Declare Function ResumeThread Lib "kernel32" (ByVal hThread As Long) As Long
Die Schleifenfunktion läuft nun als unabhängiger Thread quasiparallel zum Parentprozess und wird vom Scheduler mit Prozessorzeit versorgt.
Doch wie hält man diesen Thread wieder an?

Hierzu habe ich als Synchronisationsobjekt ein Event gewählt. Ein Event ist ein Objekt welches Systemweit gültig ist und zwei Zustände , signalisiert und nicht signalisiert, kennt.
Nachdem ein Event mit der Funktion CreateEvent erschaffen wurde, soll es  Systemweit als unsignalisiert mit seinen Handle zu "sehen" sein, also auch in unserem unabhängigem Thread. Die Schleifenfunktion soll nun bei jedem Schleifendurchlauf den Zustand dieses Events abfragen und solange es unsignalisiert ist mit der Schleife fortfahren. Ist es jedoch  signalisiert, so soll die Schleife verlassen werden, der Zustand des Events wieder unsignalisiert und der Thread verlassen werden.
Wie schon erwähnt wird dieses Event durch die Funktion CreateThread erzeugt, welche einen Handle auf das Event liefert.

Declare Function CreateEvent Lib "kernel32" Alias "CreateEventA" (lpEventAttributes As SECURITY_ATTRIBUTES, _
                                                                                                                 ByVal bManualReset As Long, ByVal bInitialState As Long, _
                                                                                                                 ByVal lpName As String) As Long
Dem Argument lpEventAttrbutes wird eine unbelegte Struktur übergeben, da der Handle auf das Event nicht durch Childprozesse geerbt werden brauch, bManualReset wird 1& übergeben damit es explizit zurückgesetzt werden muß, bInitialState wird 0& übergeben um das Event unsignalisiert zu erschaffen und lpName wird vbNullString übergeben, da der Name uns nicht interessiert.
Die Funktion gibt einen Handle auf das Event zurück.
Das Event kann explizit mit der Funktion SetEvent, der der Eventhandle mit der Variable hEvent übergeben wird, in den signalisierten Zustand versetzt werden.
Declare Function SetEvent Lib "kernel32" (ByVal hEvent As Long) As Long
Die Schleife im unabhängigen Thread fragt nun in jedem Schleifendurchlauf mit der Function WaitForSingleObject den Zustand des Events ab.
Declare Function WaitForSingleObject Lib "kernel32" (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long
Hierbei ist hHandle der Handle auf das Event und dwMilliseconds wird zu 0& gesetzt damit die Funktion ohne Wartepause zurückgibt. Ist der Rückgabewert gleich WAIT_OBJECT_0, dann ist das Event signalisiert und die Schleife wird mit einem Exit Do verlassen. Die Konstante WAIT_OBJECT_0 hat den Wert &H0 und ist im API-Viewer von Visual Basic nicht zu finden.
Nach dem Verlassen der Schleife wird das Event mit der Funktion ResetEvent in den unsignalisierten Zustand gesetzt und der Thread mit der Funktion ExitThread verlassen. Die Deklaration sieht wie folgt aus:
Declare Function ResetEvent Lib "kernel32" (ByVal hEvent As Long) As Long
Declare Sub ExitThread Lib "kernel32" (ByVal dwExitCode As Long)
Dem Argument hEvent wird der Eventhandle übergeben und dwExitCode ein Long-Wert der uns aber nicht weiter interessiert.
Das Timer-Objekt läßt sich nun startet, stoppen und es kann VB-Events auslösen. Will man jedoch Eigenschaften implementieren, die dem Schleifenthread zugänglich sind, so muß man ein neues Synchronisationsobjekt einführen um einen gleichzeitigen Zugriff, speziell ein gleichzeitiges Schreiben auf die selbe Speicheradresse, zu verhindern. Ich habe mich hier für ein Mutex entschieden.
Ein Mutex ist, ebenso wie ein Event, ein Synchronisationsobjekt mit den beiden Zuständen signalisiert und unsignalisiert. Im Unterschied zum Event kann ein Thread von ihm "Besitz" ergreifen und den "Besitz" wieder abgeben. Ist ein Mutex signalisiert, so hat er keinen "Besitzer" und kann mit der Funktion WaitForSingleObject auf seinem Zustand abgefragt werden. Dazu wird die Funktion mit dem Handle auf den Mutex hMutex und einer Wartezeit von INFINITE aufgerufen, damit die Funktion erst zurückgibt wenn der Mutex sich im signalisiertem Zustand befindet. Gibt die Funktion zurück, so wird der Mutex in den unsignalisierten Zustand gesetzt, und der Thread muß den Mutex mit der Funktion ReleaseMutex wieder freigeben.
Mit diesen beiden Funktionen kann man also Code einkapseln, zu dem gleichzeitig nicht mehrere Threads zutritt bekommen.
Deklaration der Mutex-Funktionen:
Declare Function CreateMutex Lib "kernel32" Alias "CreateMutexA" (lpMutexAttributes As SECURITY_ATTRIBUTES, _
                                                                                                          ByVal bInitialOwner As Long, ByVal lpName As String) As Long
Declare Function ReleaseMutex Lib "kernel32" (ByVal hMutex As Long) As Long
Auch hier wird lpMutexAttributes ein eine unbelegte Struktur übergeben, da der Mutexhandle nicht von Childprozessen geerbt werden brauch. Dem Argument bInitialOwner wird 0& übergeben, da der Mutex keinen voreingestellten "Besitzer" haben soll und lpName wird vbNullString übergeben, da uns sein Name nicht interessiert. Rückgabewert von CreateMutex ist der Handle hHandle auf den Mutex.

Mit dem hier dargestellten Weg lassen sich auch sehr gut sogenannte Working-Threads implementieren, wie das Einlesen von Daten und das Drucken. Er stellt jedoch keine allgemeingültige Möglichkeit des Multithreadings unter Visual Basic dar, da nicht alle Objekte und Funktionen von Visual Basic threadsicher sind.
Zudem ist der Weg nicht mit dem OLE-Threading-Modell kompatibel, so das hier Abstürze vorprogrammiert sind. Wenn möglich sollte man auf API-Funktionen ausweichen, da diese auf jeden Fall threadsicher sind. Auf das Testen und Debuggen in der VB-IDE muß man ganz verzichten da diese nur den Hauptthread unterstützt und fast immer beim Aufruf von CreateThread zusammenbricht. Man implementiert also erst die Working-Threads ohne separatem Thread und wenn alles läuft so implementiert man den unabhängigen Thread.
Gelobt sei wer nun einen Debugger besitzt welcher mehrere Threads debuggen kann wie z.B. der Debugger in Visual C++ 5.
Man darf auf keinen Fall vergessen, das der wichtigste Aspekt beim Multithreading die Synchronisation der einzelnen Threads ist. Kleine Denkfehler in der Planung sorgen hier unweigerlich zum Crash. Mit einem 200 MHz AMD-Prozessor lief eine ältere Version dieses Timers ohne Probleme, auf einem 266 MHz AMD-Prozessor gab es immer einem GPF. Dabei hatte ich den Mutex nur an einer falschen Steller erschaffen. So hatte zwar jede Instanz einen eigenen Mutex jedoch keinen gemeinsamen mehr.

Ich hoffe das andere Leute diesen Code verwenden oder erweitern können und würde mich über ein Feedback freuen.
 
Hier nun die Module:

Download der Projektdateien
 

(C) 1998 by Karsten Schneider