Des Prozessors Kern – Parallelisierung auf dem Raspberry Pi Pico mit C und C++

Der Pragmatische Architekt Michael Stal  –  16 Kommentare

Leistungsstarke Prozessoren wie der im Pico verwendete ARM-M0+ haben mehrere Kerne. Dadurch ist die parallele Abarbeitung verschiedener Aufgaben durch Threads möglich. Von sehr seltenen Situationen abgesehen, müssen sich Threads miteinander synchronisieren, sobald sie auf gemeinsame Ressourcen zugreifen. Der vorliegende Beitrag erläutert, welche Mechanismen das Pico-SDK dafür bereitstellt und wie sie Entwickler einsetzen können.

Der Mikrocontroller des Raspberry Pi Pico besitzt zwei Rechenkerne, genannt Core0 und Core1, die eine Parallelisierung über Threads ermöglichen.

Während das Hauptprogramm, egal ob bei Verwendung von Python oder C beziehungsweise C++, im Kern "Core0" läuft, steht der Kern "Core1" für einen zweiten Thread zur Verfügung. Dafür gibt es im Pico-SDK die Funktion multicore_launch_core1(), die als Argument eine Funktion ohne Rückgabewert erwartet. Diese Funktion enthält dementsprechend den vom zweiten Thread durchlaufenen Code. In MicroPython erfolgt der Start eines zweiten Threads übrigens über die Bibliotheksfunktion _thread.start_new_thread().

Das Pico-SDK erlaubt nur einen Thread pro Rechenkern, insgesamt also zwei in einem Programm. Um mehrere Threads pro Kern zu verwalten, bräuchte es ein geeignetes Betriebssystem, das Kernel-Komponenten für Task-Scheduling und Prioritätsmanagement umfasst.

Für Pico-Entwickler könnte sich alles durch das Echtzeitbetriebssystems FreeRTOS ändern, das in Zukunft auch den RP2040 unterstützen soll.

FIFO-Schlangen

Multithreading macht im Allgemeinen nur dann Sinn, wenn Threads in der Lage sind, sich miteinander zu koordinieren. Zu diesem Zweck existiert für jeden Kern eine Warteschlange (FIFO), in die ein Thread eines Kerns Nachrichten platziert (push) und von der der Thread des anderen Kerns Nachrichten lesen (pop) kann. Folglich gibt es insgesamt zwei Warteschlangen. Lesen geschieht über Funktionen wie multicore_fifo_pop_blocking(), Schreiben über Funktionen wie multicore_fifo_push_blocking().

Das "blocking" besagt, dass der jeweilige Thread so lange warten muss, bis er eine Nachricht lesen kann – das heißt, es sind Nachrichten vorhanden – beziehungsweise eine Nachricht schreiben kann – das heißt, die Warteschlange ist nicht voll.

Das C-Programmbeispiel unten instanziiert zwei Threads, nämlich den im Kern laufenden Main-Thread, und einen im Kern 1 laufenden Thread, der die Funktion playerOne() abarbeitet. Beide Threads spielen miteinander ein simples Würfelspiel mit zwei Würfeln. Die Regeln sind denkbar einfach: Wer die höhere Augensumme erzielt, gewinnt. Dafür nutzt das Programm die rand()-Funktion zur Würfelsimulation. Der Ausdruck rand() % 6 + 1 liefert einen ganzzahligen Wert zwischen 1 und 6. Der initiale Seed lautet im Programmbeispiel 42, was im Normalfall keine gute Idee ist, aber für den illustrativen Zweck genügt.

Das Ergebnis als Summe ihrer zwei Würfel übermitteln die Threads blockierend über die Warteschlange an den Gegenspieler. In jeder Spielrunde passiert Folgendes: Spieler 2 (main()-Funktion im Kern 0) würfelt, und schreibt sein Ergebnis blockierend in die Warteschlange. Spieler 1 liest das Ergebnis ebenfalls blockierend aus derselben Warteschlange.

Dann dreht sich das Verfahren um: Jetzt schreibt Spieler 1 (Kern 1) die eigene Augenzahl blockierend, während sie Spieler 2 (Kern 0) blockierend liest. Nur durch diese Reihenfolge lässt sich ein Deadlock verhindern.

#include <stdio.h>
#include "pico/stdlib.h"
#include "pico/multicore.h"

void playerOne() {
uint32_t my_result; // own sum of dice
uint32_t opponent_result; // opponent's result
uint32_t dice1, dice2;
while (true) {
dice1 = rand() % 6 + 1;
dice2 = rand() % 6 + 1;
printf("Player 1: I got %d and %d\n", dice1, dice2);
my_result = dice1 + dice2;
opponent_result = multicore_fifo_pop_blocking(); // obtain result from player 1
sleep_ms(1000);
multicore_fifo_push_blocking(my_result); // send own result to player 1
sleep_ms(1000);
if (my_result > opponent_result) {
puts("Player 1: I won :-)");
}
else
if (my_result < opponent_result) {
puts("Player 1: I lost :-(");
}
else
puts("Player 1: Draw :-!");

}
}

int main() { // player 2
stdio_init_all();
int32_t seed = 42; // note: this is a bad seed in general. Use time()-function instead
srand(seed); // initialize random number generator
uint32_t dice1, dice2; // own dice
multicore_launch_core1(playerOne); // start player One as opponent
uint32_t my_result; // own sum of dice
uint32_t opponent_result; // opponent's result
while (true) {
dice1 = rand() % 6 + 1;
dice2 = rand() % 6 + 1;
my_result = dice1 + dice2;
printf("Player 2: I got %d and %d\n", dice1, dice2);
multicore_fifo_push_blocking(my_result); // send own result to player 2
sleep_ms(1000);
opponent_result = multicore_fifo_pop_blocking(); // obtain result from player 2
sleep_ms(1000);
if (my_result > opponent_result) {
puts("Player 2: I won :-)");
}
else
if (my_result < opponent_result) {
puts("Player 2: I lost :-(");
}
else
puts("Player 2: Draw :-!");

}
return 0;
}

Zum Beobachten des Spielverlaufs kommt ein serieller Monitor zum Einsatz. Auf dem Mac benutze ich dafür die (kostenpflichtige) App Serial 2 von decisivetactics. Natürlich tut es jeder andere serielle Monitor auch.

Die Würfel sind gefallen

Das Pico-Würfelspiel-Programm definiert einen fest vorgegebenen Seed für die Zufallsverteilung. Für eine echte Anwendung würde man natürlich einen zufälligen Seed nutzen. Andernfalls wäre die Zufallsverteilung vorhersagbar. Um den Seed zu ermitteln, können Entwickler zum Beispiel die Echtzeituhr (RTC) des Pico einsetzen. Im nachfolgenden Programmbeispiel geben Entwickler das aktuelle Datum und die aktuelle Zeit fest in den Code ein (Datenstruktur datetime_t) und können damit die Echtzeituhr initialisieren.

Aus der von rtc_get_datetime() zurückgelieferten Struktur lässt sich damit ein geeigneter Seed berechnen, etwa durch Addieren aller Datums- und Zeitwerte bei vorhergehender Normalisierung des Jahres (nur die letzten zwei Ziffern des Jahres finden Berücksichtigung).

#include <stdio.h>
#include "hardware/rtc.h"
#include "pico/stdlib.h"
#include "pico/util/datetime.h"

int main() {
stdio_init_all();

char datetime_buf[256];
char *datetime_str = &datetime_buf[0];

// Start jetzt: am 23.3.2021 um 16:29
datetime_t t = {
.year = 2021,
.month = 03,
.day = 23,
.dotw = 2, // 0 = Sonntag, 1 = Montag, 2 = Dienstag, ...
.hour = 16,
.min = 29,
.sec = 00
};
// Initialisieren der Echtzeituhr
rtc_init();
rtc_set_datetime(&t);

// ……………… weiterer Code ………………

rtc_get_datetime(&t);
datetime_to_str(datetime_str, sizeof(datetime_buf), &t);
printf("\r%s ", datetime_str);

// ……………… weiterer Code ………………

}

Die bisherigen Funktionen für Multithreading beziehungsweise Thread-Management stellen nur die Spitze des Eisbergs dar. In der zugehörigen Bibliothek gibt es weitere Möglichkeiten.

Die in der Pico-SDK angebotenen Funktionen für Multithreading

Das Würfel-Programm nutzt eine der Funktionen zum Start eines Threads in Kern 1 namens multicore_launch_core1().

Zusätzlich existiert die Möglichkeit, den Thread auch mittels multicore_launch_core1_with_stack() zu starten, wobei diese Funktion als Argumente einen Zeiger auf die auszuführende Funktion sowie die Adresse eines benutzerdefinierten Laufzeitstacks und dessen Startadresse erwartet. Eine weitere Funktion multicore_launch_core1_raw() startet Kern 1 neu, führt darauf die als erstes Argument angegebene Funktion aus. Wichtig: Hier gibt es allerdings keinen Stack Guard.

Es liegt in der Verantwortung der Programmierer, dass der Stack nicht überläuft. Schließlich existiert noch eine Funktion multicore_reset_core1(), die Kern 1 zurücksetzt.

FIFO-Queues

FIFO-Warteschlangen zur Inter-Core-Kommunikation besitzen acht Einträge zu je 32 Bit. Für das Verwenden dieser FIFO-Warteschlangen gibt es ebenfalls noch weitere Funktionen in der Bibliothek.

multicore_fifo_rvalid() prüft nichtblockierend, ob Daten zum Lesen bereits sind. Falls ja, kann ein Thread beim Vorliegen von Einträgen mit multicore_fifo_pop_blocking() die Daten beziehen. Liegen keine Daten vor, kann er sich inzwischen um andere Angelegenheiten kümmern und später erneut den Zugriff versuchen.

Für das Schreiben von Daten gibt es analog das nichtblockierende multicore_fifo_wready(), dem ein multicore_fifo_push_blocking() folgt. Mittels multicore_fifo_status() ermitteln Programmierer, ob die Warteschlange nicht voll (->1) oder nicht leer ist (-> 0) beziehungsweise ob es einen Versuch gab, die leere FIFO zu lesen (->3) oder auf die volle FIFO zu schreiben (->2).

Funktionalität für FIFOs

Mr. and Mrs. Queue

Übrigens liegt in der Bibliothek pico_util (Pico-SDK) auch der Datentyp Queue vor (siehe hier), der allgemeiner als die oben verwendete FIFO-Queue, aber ebenfalls Thread-sicher ist. "Allgemeiner" soll heißen, dass Queues keine Längenbeschränkung besitzen und beliebige Arten von Elementen enthalten können. Dieser Datentyp aus der Bibliothek lässt sich entsprechend als Alternative zu den systemnahen FIFO-Queues nutzen.

Hier sehen wir das auf die Thread-sicheren Queues umgestellte Programmbeispiel. Gegenüber obigen Beispiel hat sich nicht viel geändert. Es läuft, semantisch gesehen, analog ab:

#include <stdio.h>
#include "pico/stdlib.h"
#include "pico/util/queue.h"
#include "pico/multicore.h"


typedef struct
{
uint32_t dice1;
uint32_t dice2;
} queue_entry_t;

queue_t playerOneQueue; // queue used by playerOne to send results
queue_t playerTwoQueue; // queue used by playerTwo to send results




void playerOne() {
queue_entry_t playerTwoResult, playerOneResult;

while (true) {
sleep_ms(1000);
playerOneResult.dice1 = rand() % 6 + 1;
playerOneResult.dice2 = rand() % 6 + 1;

printf("Player 1: I got %d and %d\n", playerOneResult.dice1, playerOneResult.dice2);
queue_remove_blocking(&playerTwoQueue, &playerTwoResult); // obtain result from player 2
sleep_ms(1000);
queue_add_blocking(&playerOneQueue, &playerOneResult);
sleep_ms(1000);
if ((playerTwoResult.dice1 + playerTwoResult.dice2) < (playerOneResult.dice1 + playerOneResult.dice2)) {
puts("Player 1: I won :-)");
}
else
if ((playerTwoResult.dice1 + playerTwoResult.dice2) > (playerOneResult.dice1 + playerOneResult.dice2)) {
puts("Player 1: I lost :-(");
}
else
puts("Player 1: draw :-!");
}
}

void playerTwo() { // player 2
queue_entry_t playerTwoResult, playerOneResult;

while (true) {
playerTwoResult.dice1 = rand() % 6 + 1;
playerTwoResult.dice2 = rand() % 6 + 1;

printf("Player 2: I got %d and %d\n", playerTwoResult.dice1, playerTwoResult.dice2);
queue_add_blocking(&playerTwoQueue, &playerTwoResult);
sleep_ms(1000);
queue_remove_blocking(&playerOneQueue, &playerOneResult); // obtain result from player 1
sleep_ms(1000);

if ((playerTwoResult.dice1 + playerTwoResult.dice2) > (playerOneResult.dice1 + playerOneResult.dice2)) {
puts("Player 2: I won :-)");
}
else
if ((playerTwoResult.dice1 + playerTwoResult.dice2) < (playerOneResult.dice1 + playerOneResult.dice2)) {
puts("Player 2: I lost :-(");
}
else
puts("Player 2: draw :-!");
}
}

int main() {
stdio_init_all();

int32_t seed = 42; // note: this is a bad seed in general. Use time()-function instead
srand(seed); // initialize random number generator
queue_init(&playerOneQueue, sizeof(queue_entry_t), 2); // initialize queues
queue_init(&playerTwoQueue, sizeof(queue_entry_t), 2);
multicore_launch_core1(playerOne); // start player One as opponent
playerTwo(); // start player Two

return 0;
}

Ein Hinweis, wenn statt C die Programmiersprache C++ zum Einsatz kommen sollte: In diesem Fall ergeben sich beim abgedruckten Beispiel Fehlermeldungen während des Kompilierens und Bindens. Daher müssen die betreffenden Include-Anweisungen im Code wie etwa

#include "pico/util/queue.h"

folgendermaßen "umklammert" werden:

extern "C" {
#include "pico/util/queue.h"
}

Queues besitzen nicht nur die oben benutzten Funktionen, sondern auch weitere, etwa solche zum nichtblockierenden Prüfen wie queue_is_empty(), queue_is_full(), queue_try_peek() und Funktionalität zum nichtblockierenden Hinzufügen oder Entnehmen von Elementen wie queue_try_add() oder queue_try_remove().

Der abstrakte Datentyp Queue aus dem Pico-SDK ist thread-sicher

Do it yourself

Falls Entwickler selbst Thread-sichere Datentypen wie Queues erstellen wollen, benötigen sie dafür geeignete Mechanismen. Zur Synchronisation von Threads stellt die Bibliothek pico_sync des Pico-SDKs verschiedene Primitive bereit, insbesondere Mutexes, Semaphores und Critical Sections. Ein wichtiger Hinweis in diesem Zusammenhang: Die vorgestellten Mechanismen schützen bei oberflächlicher Betrachtung Teile des Programmes vor parallelem Betreten. Das ist aber nicht ihr eigentlicher Zweck. Ihr Zweck ist es vielmehr, gemeinsame Ressourcen vor inkonsistenten Änderungen abzusichern und inkonsistente Sichten zu verhindern. Unter Ressourcen sind Speicherbereiche zu verstehen, aber auch andere gemeinsam genutzte Entitäten wie serielle Schnittstellen, Sensoren oder Aktuatoren.

Kritische Bereiche

Kritische Bereiche (Critical Sections) basieren auf Spinlocks. Diese sind im PICO-SDK systemnah implementiert und nutzen ARM-Maschineninstruktionen. Sie stellen akquirierbare Ressourcen dar, auf die ein Thread in einer Schleife so lange aktiv warten muss, bis der Spinlock nicht mehr belegt ist. Daher auch der Name – der Thread "spint" also in gewisser Weise. Nach Beendigung seiner Tätigkeit gibt er den Spinlock wieder frei. Weil die Akquisition eines Spinlocks wie die jedes Synchronisationsprimitive mit aktivem Warten verbunden ist, sollte ihn jeder Thread nur sehr kurz behalten und auch schnell wieder freigeben. Insbesondere sollten alle hier vorgestellten Synchronisationsmechanismen nicht oder nur in sehr wenigen und vor allem nur in berechtigten Ausnahmefällen innerhalb von Interrupt-Handlern auftauchen.

Kritische Bereiche definieren Programmteile, die immer nur ein Thread zu einem Zeitpunkt exklusiv durchlaufen kann, nämlich der, der gerade im Besitz des zugehörigen Spinlocks ist. Sie erinnern sich sicher noch an den Begriff Mutual Exclusion (= gegenseitiger Ausschluss), der diesen Sachverhalt beschreibt. Sinn des kritischen Bereichs ist der exklusive Zugriff auf eine geschützte Ressource durch maximal einen Thread. Das Betreten eines kritischen Bereichs erfolgt mittels

critical_section_enter_blocking()

und dessen Verlassen mit

critical_section_exit()

In diesen Funktionen läuft dementsprechend die Akquisition eines Spinlocks beziehungswesie dessen Freigabe ab. Das Beispiel der Critical Sections zeigt, dass sich letztlich alle Synchronisationsmechanismen tief im Inneren auf atomare Operationen wie das exklusive Prüfen und Schreiben (Test-and-Set) von Variablen zurückführen lassen. Innerhalb des kritischen Bereichs greift der jeweilige Thread exklusiv auf gemeinsame Ressourcen zu. Insofern fasst der kritische Bereich die in ihm durchgeführten Aktivitäten zu einer atomaren Operation zusammen.

Die Funktionen der Critical Sections gestatten das Anlegen geschützter kritischer Codeabschnitte

Im dazugehörigen Programmbeispiel schreiben zwei Threads über die Methode changeText() immer denselben Text in die Variable buffer. Der Thread in Core 0 (Funktion: entry_core0()) schreibt 00000, während der Thread in Core 1 (Funktion: entry_core1()) 11111 schreibt. Ohne den kritischen Bereich zwischen

critical_section_enter_blocking()

und

critical_section_exit() (siehe changeText())

kommen sich die Threads beim Schreiben von buffer in die Quere, sodass dessen Inhalt bisweilen auch Werte wie 00110 oder 01000 statt immer nur entweder 00000 oder 11111 annimmt. Probieren Sie das einfach mal selbst aus, indem Sie die Funktionen für kritische Abschnitte in Kommentare setzen und an interessanten Stellen Ausgaben auf die serielle stdio-Schnittstelle schreiben.

Erklärung für das beobachtete Phänomen: Die Buchstaben des Texts werden in der Funktion changeText() einzeln geschrieben und jeweils mit einer zufälligen Wartezeit abgeschlossen, um reale Situationen zu simulieren. In der "echten" Welt würde jeder Thread hier nützliche Schritte durchführen, etwa das Einlesen von Sensorwerten oder die Ansteuerung angeschlossener Komponenten. Der Sachverhalt bleibt der gleiche. Nur durch Schutzmechanismen ist zu gewähleisten, dass ein Thread seine Aktivitäten ausführen kann, ohne dass ihm ein anderer Thread dazwischenfunkt. Viele Köche verderben den Brei.

Das Initialisieren eines kritischen Bereichs erfordert entweder die Funktion critical_section_init() oder critical_section_init_with_lock_num(). In ersterer Funktion stellt das Laufzeitsystem implizit einen unsichtbaren Spinlock zur Verfügung. Der letzteren Funktion können Programmierer explizit einen eigenen Spinlock übergeben.

#include "pico/stdlib.h"
#include "pico/multicore.h"
#include "pico/sync.h"
#include <string.h>
#include <stdio.h>


// Declaration of a critical section:
critical_section_t cs1;

// buffer containing 5 characters:
char buffer[5] = "-----";

// The function used to overwrite buffer:
void changeText(char *text) {
// Wait until door opens:
critical_section_enter_blocking(&cs1);
// Overwrite the buffer char by char
for (uint16_t i = 0; i < 5; i++) {
buffer[i] = text[i];
// random delay:
sleep_ms(100 + rand() % 100);
}
// leave room:
critical_section_exit(&cs1);
}

void core1_entry() {
// core 1 thread writes 5 ones to the buffer
while (1) {
changeText("11111");
}
}

void core0_entry() {
// core 0 thread writes 5 zeros to the buffer
while(1) {
changeText("00000");
}
}


int main() {
stdio_init_all();
// initialize random number generator:
srand(42);
// Initialize critical section:
critical_section_init(&cs1);
// Start new thread in core 1:
multicore_launch_core1(core1_entry);
// Start main routine of core 0 thread:
core0_entry();

return 0;
}

Mutexes

Genau genommen basieren kritische Abschnitte auf Mutexes (Mutex = Mutual Exclusion). Daher können Programmierer Mutexe anstelle von Critical Sections verwenden. Im oberen Programm ließe sich ein Mutex mittels

mutex_t mx1;

vereinbaren, um es über

mutex_init(&mx1);

zu initialisieren.

Um das Mutex blockierend zu akquirieren, ist der Aufruf

mutex_enter_blocking(&mx1);

notwendig, während für die Freigabe ein

mutex_exit(&mx1);

genügt.

Das Pico-SDK bietet beim Einsatz von Mutexes wesentlich mehr Möglichkeiten als bei kritischen Abschnitten. Beispielsweise erlaubt der Aufruf von

mutex_enter_timeout_ms(&mx1, timeout)

ein zeitlich beschränktes Warten auf die Verfügbarkeit des Mutex, während

mutex_enter_block_until(&mx1, abstime);

das Warten bis zu einer absoluten Zeit erlaubt. Durch den Rückgabewert erfahren die Aufrufer, ob sie den Mutex erfolgreich akquiriert haben oder nicht. Auch Deadlocks sind dadurch vermeidbar. Es gibt noch weitere Funktionen im Pico-SDK, aber das soll es zum Thema Mutex gewesen sein.

Mutexes bieten ähnliche Möglichkeiten wie Critical Sections, sind aber flexibler

Semaphores

Semaphore (altgriechisch für Signalgeber) dienen zum Signalisieren von Zuständen. Im Gegensatz zu Mutexes erlauben sie mehrere parallele Zugänge zu kritischen Bereichen, die sogenannten permits (Zugangsberechtigungen). Theoretisch könnte man binäre Semaphore, also solche mit einer einzigen Zugangsberechtigung, als Mutex betrachten – in manchen Echtzeitbetriebssystemen wie FreeRTOS basieren Mutex und Semaphor sogar auf einer gemeinsamen Implementierung. Allerdings unterscheiden sich beide Abstraktionen in der Praxis, was aber nicht Gegenstand der Diskussion sein soll (für nähere Details siehe Wikipedia-Artikel zu Semaphore).

Auf dem Raspberry Pi Pico erlaubt das Pico-SDK ohnehin nur einen Thread pro Kern, weshalb die volle Flexibilität von Semaphores nur beschränkt nutzbar ist.

Was sind die wichtigen Semaphore-Funktionen in der Bibliothek?

Über

semaphore_t mySignal; 

sem_init(&mySignal, 1, 2);

erfolgt eine Definition eines Semaphore und seine Initialisierung. Im vorliegenden Fall besitzt das Semaphore initial eine einzige Zugangsberechtigung, kann aber maximal zwei vergeben. Die Zahl der noch nicht vergebenen Berechtigungen errechnet sich durch Aufruf von

sem_available(mySignal);

Um ein Semaphore blockierend zu akquirieren, nutzt man:

sem_acquire_blocking(&mySignal);

Soll keine Blockierung bei Nichtverfügbarkeit erfolgen, ist ein Timeout festzulegen:

sem_acquire_timeout_ms(&mySignal, 1000); // Timeout == 1000 Millisekunden

Soll sich die Zahl der Berechtigungen zur Laufzeit ändern, lässt sich folgende Funktionalität einsetzen:

sem_reset(&mySignal, newNumberOfPermits);

Zur Freigabe einer Berechtigung reicht der Aufruf:

sem_release(&mySignal);

Semaphore eignen sich übrigens auch für das Verwenden in Interrupt-Handlern, ganz im Gegensatz zu ihren Pendants wie FIFO-Queues, Queues, kritische Abschnitte und Mutexes.

Semaphores haben meist andere Einsatzgebiete als Mutexes

Das illustrative Anwendungsbeispiel für Semaphore stammt von der Stanford University (siehe Seite 8 des Dokuments). Es handelt sich um ein Erzeuger-Verbraucher-Problem. Der Erzeuger (Thread in Core 0) erzeugt zufällige Großbuchstaben in der Funktion Writer() mittels der Funktion PrepareData(). Ihm steht der Verbraucher (Thread in Core 1) gegenüber, der die Buchstaben mittels der Funktion Reader()ausliest. Zur temporären Speicherung der Buchstaben existiert ein Buffer (buffers) mit einer Kapazität von NUM_TOTAL_BUFFERS (im Beispiel auf 5 gesetzt). Für das Signalisieren der Lese- beziehungsweise Schreibmöglichkeit definiert das Programm zwei Semaphore:

    emptyBuffers signalisiert, ob der Buffer geschrieben werden kann, und fullBuffers, ob der Buffer gelesen werden kann. Beide Semaphore vergeben fünf Berechtigungen. Zum Beginn der Programmausführung verfügt emptyBuffers über fünf Berechtigungen, fullBuffers über 0.

Der Writer akquiriert bei jedem Schreibvorgang blockierend eine Berechtigung von emptyBuffers und gibt nach dem Schreiben eines Buchstabens eine Berechtigung von fullBuffers frei. Der Reader akquiriert umgekehrt bei jedem Lesevorgang blockierend eine Berechtigung von fullBuffers und gibt nach jedem Buchstabenlesen eine Berechtigung von emptyBuffers frei.

Da das Programm mit Berechtigungen von fullBuffers startet, ist garantiert, dass der lesende Thread erst dann Zugriff auf den Buffer erhält, sobald der schreibende Thread dort etwas hineinschreiben konnte. Die vom Reader nach jedem Lesevorgang aufgerufene Funktion ProcessData() symbolisiert eine mögliche Verarbeitung der gelesenen Daten. Im Beispiel verbringt sie lediglich eine zufällige Zeitdauer mit Warten.

Analoges gilt für PrepareData(), das die Vorverarbeitung beziehungsweise das Heranschaffen der Daten illustriert. Im Beispiel erzeugt sie einfach einen zufälligen Großbuchstaben, nachdem sie zuvor eine zufällige Zeitspanne untätig gewartet hat:

/**
* readerWriter.c
* --------------
* The canonical consumer-producer example. This version has just one reader
* and just one writer (although it could be generalized to multiple readers/
* writers) communicating information through a shared buffer. There are two
* generalized semaphores used, one to track the num of empty buffers, another
* to track full buffers. Each is used to count, as well as control access.
*/
#include "pico/stdlib.h"
#include "pico/multicore.h"
#include "pico/sync.h"
#include <stdio.h>

#define NUM_TOTAL_BUFFERS 5
#define DATA_LENGTH 20

char buffers[NUM_TOTAL_BUFFERS]; // the shared buffer
semaphore_t emptyBuffers, fullBuffers; // semaphores used as counters

/**
* ProcessData
* -----------
* This just stands in for some lengthy processing step that might be
* required to handle the incoming data. Processing the data can be done by
* many reader threads simultaneously since it doesn't access any global state
*/
static void ProcessData(char data)
{
sleep_ms(rand() % 500);
// sleep random amount
}

/**
* PrepareData
* -----------
* This just stands in for some lengthy processing step that might be
* required to create the data. Preparing the data can be done by many writer
* threads simultaneously since it doesn't access any global state. The data
* value is just randomly generated in our simulation.
*/
static char PrepareData(void)
{
sleep_ms(rand() % 500);
return (65 + rand() % 26); // random uppercase letter
// sleep random amount
// return random character
}

/**
* Writer
* ------
* This is the routine forked by the Writer thread. It will loop until
* all data is written. It prepares the data to be written, then waits
* for an empty buffer to be available to write the data to, after which
* it signals that a full buffer is ready.
*/
static void Writer()
{
int i, writePt = 0;
char data;
for (i = 0; i < DATA_LENGTH; i++) {
data = PrepareData();
sem_acquire_blocking(&emptyBuffers);
buffers[writePt] = data;
printf("%s: buffer[%d] = %c\n", "Writer", writePt, data);
writePt = (writePt + 1) % NUM_TOTAL_BUFFERS;
sem_release(&fullBuffers);
// announce full buffer ready
}
}

/**
* Reader
* ------
* This is the routine forked by the Reader thread. It will loop until
* all data is read. It waits until a full buffer is available and the
* reads from it, signals that now an empty buffer is ready, and then
* goes off and processes the data.
*/
static void Reader()
{
int i, readPt = 0;
char data;
for (i = 0; i < DATA_LENGTH; i++) {
sem_acquire_blocking(&fullBuffers); // announce empty buffer
// wait til something to read
data = buffers[readPt]; // pull value out of buffer
printf("\t\t%s: buffer[%d] = %c\n", "Reader", readPt, data);
readPt = (readPt + 1) % NUM_TOTAL_BUFFERS;
sem_release(&emptyBuffers); // announce empty buffer
ProcessData(data); // now go off & process data
}
}

/**
* Initially, all buffers are empty, so our empty buffer semaphore starts
* with a count equal to the total number of buffers, while our full buffer
* semaphore begins at zero. We create two threads: one to read and one
* to write, and then start them off running. They will finish after all
* data has been written & read.
*/


int main()
{
stdio_init_all();

sem_init(&emptyBuffers, NUM_TOTAL_BUFFERS, NUM_TOTAL_BUFFERS);
sem_init(&fullBuffers, 0, NUM_TOTAL_BUFFERS);

multicore_launch_core1(Reader);

Writer(); // runs in this thread
sem_release(&fullBuffers);
sem_release(&emptyBuffers);
printf("All done!\n");
while(true);
return 0;
}

In der Ausgabe des Beispiels am seriellen Monitor ist Folgendes zu beobachten:

    Alles, was der “Writer" in den Buffer hineinschreibt, liest der “Reader” in exakt derselben Reihenfolge aus. Der "Writer" kann in einem Schreibvorgang maximal bis zu fünf Daten hintereinander schreiben, der Reader entsprechend maximal bis zu fünf Daten hintereinander lesen. Nach 20 Iterationen (konfiguriert in DATA_LENGTH) beendet sich das Programm.
Das Beispielsprogramm zeigt in der Bildschirm-Ausgabe, dass Semaphores richtig zur Synchronisation verwendet werden

Das alles funktioniert nur deshalb so gut und abgestimmt, weil die Semaphore dafür sorgen, dass der Buffer als kritische Resource keine Inkonsistenzen aufweist. Die Semaphore dienen in diesem Kontext als Signale für "Du darfst jetzt (nicht) lesen" oder "Du darfst jetzt (nicht) schreiben".

Fazit

Im Pico-SDK ist Parallelisierung nur eingeschränkt möglich. Das Hauptprogramm läuft in einem Thread auf Core und kann zusätzlich einen Thread erzeugen, der auf Core 1 läuft. Erst die Verfügbarkeit von Echtzeitbetriebssystemen auf dem Pico überwindet diese Beschränkung. Trotzdem ergibt auch Multithreading mit zwei Threads Sinn. Wichtig ist dabei allerdings, die vorhandenen Synchronisationsmechanismen zu verstehen. Das gilt für die Hardware-basierte FIFO-Queue, den abstrakten und Thread-sicheren Datentyp Queue aus der Pico-Bibliothek und selbstredend auch für die bereitgestellten Synchronisationsprimitive Critical Sections, Mutexes, und Semaphores, mit denen sich eigene Bibliotheken Thread-sicher machen lassen.

Manche Entwickler scheuen sich vor dem Einsatz von Multithreading. Zu schwierig erscheint das Aufspüren von Fehlerquellen in Problemcode. Durch die Berücksichtigung bekannter Best Practices überwiegt der potenzielle Nutzen aber weit die möglichen Herausforderungen.

Damit wäre der kurze Rundgang durch Multithreading und Koordination von Threads beendet, und das Rüstzeug bereitgestellt, um responsive und effiziente Mikrocontroller-Anwendungen für den Pico zu erstellen.