Nebenläufigkeit

Threads in Lyx sind dünn: clone plus futex, kein Laufzeitsystem dazwischen. Ein Thread ist eine gewöhnliche Funktion, ein Mutex ein 32-Bit-Wort. Das macht das Verhalten vorhersagbar — und verlangt, dass man weiß, was man tut.

Für Echtzeit- und Embedded-Fragen (Scheduler, Prioritäten, POSIX-AE) siehe RTOS & Embedded Concurrency; hier geht es um den Alltag auf Linux.

std.thread · Threads (Sprachreferenz) · Speicher in der Praxis


Die Bausteine

Baustein Funktionen
Thread ThreadCreate(func, arg): Thread, ThreadJoin(t), ThreadIsRunning(t), ThreadSelf()
Mutex MutexNew(), MutexLock(m), MutexUnlock(m), MutexTryLock(m), MutexFree(m)
Bedingung CondNew(), CondWait(c, m), CondSignal©, CondBroadcast©, CondFree©
Atomar AtomicNew(start), AtomicLoad(a), AtomicStore(a, v), AtomicAdd(a, d), AtomicFree(a)
Geteilter Speicher SharedMemCreate(size), SharedMemFree(mem)
Warten Sleep(ms), SleepNs(ns), SleepUntil(clockId, absNs)

Die Thread-Funktion hat die Form fn(arg: int64): int64 und wird als int64 übergeben: ThreadCreate(Arbeiter as int64, 1).


Beispiel

Zwei Threads erhöhen denselben Zähler — einmal über einen Mutex, einmal atomar:

import std.io;
import std.alloc;
import std.thread;
import std.time;

// Gemeinsamer Zustand: hier liegt der Zaehler, den beide Threads erhoehen
var g_mutex: Mutex;
var g_zaehler: int64;
var g_atomar: Atomic;

// Ein Thread ist eine Funktion mit einem int64-Argument
fn Arbeiter(arg: int64): int64 {
  var i: int64 := 0;
  while (i < 10000) {
    // 1. Weg: Mutex um den kritischen Abschnitt
    MutexLock(g_mutex);
    g_zaehler := g_zaehler + 1;
    MutexUnlock(g_mutex);

    // 2. Weg: atomare Addition, ohne Sperre
    AtomicAdd(g_atomar, 1);
    i := i + 1;
  }
  return 0;
}

fn main(): int64 {
  g_mutex := MutexNew();
  g_atomar := AtomicNew(0);
  g_zaehler := 0;

  var t1: Thread := ThreadCreate(Arbeiter as int64, 1);
  var t2: Thread := ThreadCreate(Arbeiter as int64, 2);

  // Ohne Join endet main womoeglich vor den Threads
  ThreadJoin(t1);
  ThreadJoin(t2);

  PrintLn(StrConcat("mit Mutex:  ", IntToStr(g_zaehler)));
  PrintLn(StrConcat("atomar:     ", IntToStr(AtomicLoad(g_atomar))));

  AtomicFree(g_atomar);
  MutexFree(g_mutex);
  return 0;
}

mit Mutex:  20000
atomar:     20000

Übersetzt und ausgeführt mit lyxc 1.1.3I.


Warum das ohne Schutz nicht geht

Dasselbe Programm ohne MutexLock/MutexUnlock, dreimal gestartet:

mit Mutex:  16005
mit Mutex:  15624
mit Mutex:  16876

Erwartet wären 20 000. g_zaehler := g_zaehler + 1 ist drei Schritte — lesen, addieren, schreiben —, und dazwischen darf der andere Thread laufen. Die Ergebnisse sind nicht nur falsch, sie sind jedes Mal anders: Genau daran erkennt man ein Wettrennen.

Der atomare Zähler daneben bleibt in allen Läufen korrekt.


Mutex oder atomar?

Fall Mittel
Ein einzelner Zähler, eine Marke Atomic* — kein Umschalten in den Kern, kein Verklemmen möglich
Mehrere Werte, die zusammen stimmig bleiben müssen Mutex um den ganzen Abschnitt
Warten, bis etwas fertig ist Cond plus Mutex
Ergebnis eines Threads abholen ThreadJoin — er wartet und gibt den Stapel des Kindes frei

Faustregel: Ein Wert → atomar. Ein Zustand → Mutex.


Regeln, die Ärger ersparen

  • ThreadJoin für jeden Thread. Ohne Join endet main womöglich vor dem Thread — und ThreadJoin gibt außerdem den Stapel des Kindes frei.
  • Der kritische Abschnitt bleibt kurz. Alles, was im Mutex passiert, hält alle anderen auf. I/O gehört nicht hinein.
  • Immer dieselbe Sperrreihenfolge. Zwei Mutexe, in zwei Threads verschieden herum gesperrt, verklemmen sich zuverlässig.
  • MutexUnlock auf jedem Rückgabepfad — ein return mitten im Abschnitt ist die häufigste Verklemmung. defer ist hier der ruhigere Weg.
  • Kein Zeiger auf lokale Variablen an einen Thread übergeben: Der Rahmen ist womöglich schon weg, wenn der Thread liest.
  • Alles freigeben, was angelegt wurde: MutexFree, CondFree, AtomicFree.

Nebenläufigkeit in einer Oberfläche

Eine Vega-Anwendung hat bereits eine Ereignisschleife. Dort gilt:

  • Zeitgeber statt Thread, wenn etwas regelmäßig passieren soll — NewTimer(ms) plus app.AddTimer; siehe Vega: Anwendungen aufbauen.
  • Kein Control aus einem Thread anfassen. Der Zeichenpfad gehört der Ereignisschleife. Ein Arbeitsthread legt sein Ergebnis in eine geschützte Variable; ein Zeitgeber holt es ab und ruft Invalidate().
  • Lange Arbeit nicht im Handler. Solange ein Handler läuft, zeichnet nichts und reagiert nichts.

Fallstricke

  • Sleep als Synchronisation. Es macht das Wettrennen seltener, nicht unmöglich — und ist damit schlimmer als kein Schutz, weil der Fehler erst in Produktion auftritt.
  • Test hängt statt zu scheitern. Jeder nebenläufige Test gehört unter timeout.
  • Ergebnis „meistens richtig„. Ein Zähler, der bei 20 000 landen soll und 19 998 zeigt, ist kein Rundungsfehler.

Letzte Aktualisierung: 2026-08-19 — API gegen std/thread.lyx erhoben; Beispiel und Wettrennen mit lyxc 1.1.3I gemessen.