Threading & Performance in C#-Actions
Eine C#-Action, die ein paar Millisekunden läuft, fällt nie auf. Eine, die zehn Sekunden mit CPH.Wait blockiert oder einen langsamen API-Call inline macht, kann bei einem Raid den ganzen Bot zäh wirken lassen. Diese Seite erklärt das Ausführungsmodell von Streamer.bot und zeigt, wie du Actions schreibst, die auch unter Last sauber durchlaufen.
Doku: docs.streamer.bot · C# Methoden
Das Queue-Modell
Abschnitt betitelt „Das Queue-Modell“Streamer.bot arbeitet Actions in Queues ab. Eine Queue ist eine Warteschlange mit einer festen Anzahl gleichzeitiger Worker. Löst ein Event eine Action aus, landet sie in einer Queue und ein Worker-Thread führt sie aus. Solange dieser Worker mit deiner Action beschäftigt ist, kann er nichts anderes aus derselben Queue abarbeiten.
Das ist die Kern-Einsicht für Performance: Solange dein C#-Code läuft, blockiert er den Worker, der ihn ausführt. Kurze Actions geben den Worker schnell wieder frei, lange Actions halten ihn fest.
Wie ein C#-Aufruf eine andere Action startet, steuert der Parameter runImmediately. Die offizielle RunAction-Doku beschreibt das vollständige Verhalten. Hier die Kurzform aus Performance-Sicht:
public class CPHInline { public bool Execute() { // runImmediately = true (Default): // laeuft als Sub-Action im aktuellen Ausfuehrungsstrang, der Code wartet auf das Ende. CPH.RunAction("Sound abspielen", true);
// runImmediately = false: // wird in eine eigene Queue gestellt, der Code laeuft ohne zu warten weiter. CPH.RunAction("Lange Aufgabe", false);
return true; }}Die verifizierten Signaturen lauten RunAction(string actionName, bool runImmediately = true) und RunActionById(string actionId, bool runImmediately = true), beide geben bool zurück. Bei runImmediately = true läuft die Ziel-Action wie eine Sub-Action im aktuellen Ausführungsstrang, der aufrufende Code wartet auf ihr Ende. Bei false wandert sie in ihre eigene gesetzte Queue und der aufrufende Code läuft sofort weiter.
Warum CPH.Wait den Worker blockiert
Abschnitt betitelt „Warum CPH.Wait den Worker blockiert“CPH.Wait(int milliseconds) ist die häufigste Performance-Falle. Die Signatur ist void Wait(int milliseconds), der Aufruf lässt den Code für die angegebene Zeit pausieren.
Wichtig: Das ist kein asynchrones Warten, das den Worker freigibt. CPH.Wait hält den ausführenden Worker-Thread für die ganze Dauer fest. Wartet eine Action zehn Sekunden, ist der Worker zehn Sekunden lang für alles andere in derselben Queue blockiert.
public class CPHInline { public bool Execute() { CPH.SendMessage("Countdown laeuft..."); CPH.Wait(10000); // 10 Sekunden: Worker ist die ganze Zeit blockiert CPH.SendMessage("Zeit abgelaufen!"); return true; }}Solange diese Action läuft, stauen sich andere Actions, die in dieselbe Queue gehören, dahinter auf. Bei einem einzelnen Trigger ist das egal. Kommen aber gleichzeitig viele Events herein, wird der Stau sichtbar.
Wann das wirklich weh tut
Abschnitt betitelt „Wann das wirklich weh tut“Drei Szenarien bringen viele Actions in kurzer Zeit:
- Raids und Hosts: Auf einen Schlag strömen viele Zuschauer herein, jeder kann Begrüßungs- oder Shoutout-Actions auslösen.
- Bits- und Cheer-Spam: In Highlight-Momenten feuern Zuschauer Bits im Sekundentakt.
- Follow-Wellen: Nach einem Shoutout von einem großen Kanal kommen Follows in dichter Folge.
Wenn jede dieser Actions ein CPH.Wait von mehreren Sekunden enthält oder einen langsamen API-Call inline macht, addiert sich die Wartezeit. Die letzten Zuschauer in der Welle sehen ihre Begrüßung dann erst Minuten später, oder gar nicht, falls Streamer.bot die Queue begrenzt.
Die Lösung ist fast immer: schwere oder wartende Arbeit aus dem Haupt-Trigger herausziehen.
public class CPHInline { public bool Execute() { // Schnell quittieren, dann die langsame Arbeit in eine eigene Queue auslagern. CPH.RunAction("Shoutout langsam", false); // false: laeuft eigenstaendig, blockiert hier nichts return true; // Worker ist sofort wieder frei fuer den naechsten Raider }}Die Action “Shoutout langsam” darf dann ruhig ein CPH.Wait oder einen API-Call enthalten. Sie blockiert nur ihre eigene Queue, nicht den Trigger-Pfad, in dem die Welle ankommt.
Code-Instanzen und Precompile
Abschnitt betitelt „Code-Instanzen und Precompile“Jede C#-Action wird zu einer Klasse kompiliert und als Instanz ausgeführt. Beim allerersten Aufruf nach dem Start von Streamer.bot muss diese Klasse erst kompiliert werden, was den ersten Lauf spürbar verzögern kann.
Dagegen gibt es die Einstellung Precompile on Application Start. Ist sie aktiv, kompiliert Streamer.bot deine C#-Actions schon beim Programmstart, statt erst beim ersten Trigger. Der erste echte Aufruf läuft dann ohne Kompilier-Verzögerung. Bei vielen oder großen C#-Actions ist das empfehlenswert. Den genauen Ort der Einstellung und das aktuelle Verhalten findest du in der offiziellen Doku, da sich UI-Details zwischen Versionen ändern können.
Statische Felder sind geteilter State
Abschnitt betitelt „Statische Felder sind geteilter State“Ein static-Feld gehört zur Klasse, nicht zur einzelnen Ausführung. Es lebt so lange, wie die Code-Instanz geladen ist, und wird über alle Aufrufe derselben Action hinweg geteilt. Das ist mal Feature, mal Falle.
Sinnvoll genutzt: ein HttpClient, der wiederverwendet werden soll, statt bei jedem Aufruf neu erzeugt zu werden.
using System.Net.Http;
public class CPHInline { // Statisch und wiederverwendet: vermeidet Socket-Leaks durch staendiges Neuerzeugen. private static readonly HttpClient client = new HttpClient();
public bool Execute() { // Execute() ist synchron (bool), also kein await direkt darin. // Async-Aufrufe blockierend bridgen: string body = client.GetStringAsync("https://api.example.com/status") .GetAwaiter().GetResult(); CPH.LogInfo($"Antwort: {body}"); return true; }}Gefährlich wird es, wenn du ein static-Feld versehentlich als veränderlichen Zustand nutzt. Bei mehreren parallelen Aufrufen, etwa während einer Bits-Welle, greifen mehrere Worker gleichzeitig auf dasselbe Feld zu. Das führt zu Race Conditions, verfälschten Zählern und schwer reproduzierbaren Bugs. Wenn du Zustand zwischen Aufrufen brauchst, nimm lieber Persistenz-Mechanismen wie CPH.SetGlobalVar und CPH.GetGlobalVar<T> (siehe Globale Variablen). Die sind für geteilten Zustand gedacht und überleben auch einen Neustart.
Schwere Arbeit auslagern: vollständiges Muster
Abschnitt betitelt „Schwere Arbeit auslagern: vollständiges Muster“Das Zusammenspiel aus schnellem Trigger und ausgelagerter Arbeit, mit einem POST-Request als Beispiel für die langsame Action:
using System.Net.Http;using System.Text;using Newtonsoft.Json;
public class CPHInline { private static readonly HttpClient client = new HttpClient();
public bool Execute() { // API-Key NIE hardcoden, aus einem Global lesen: string apiKey = CPH.GetGlobalVar<string>("myApiKey", true); if (string.IsNullOrEmpty(apiKey)) { CPH.LogWarn("Kein API-Key gesetzt, Abbruch."); return false; }
var payload = new { user = args["user"]?.ToString(), points = CPH.Between(10, 100) }; string json = JsonConvert.SerializeObject(payload);
using (var content = new StringContent(json, Encoding.UTF8, "application/json")) { content.Headers.Add("Authorization", $"Bearer {apiKey}"); // PostAsync blockierend bridgen, da Execute() synchron ist: var response = client.PostAsync("https://api.example.com/reward", content) .GetAwaiter().GetResult(); string result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); CPH.LogInfo($"Reward-API: {result}"); }
return true; }}Diese Action gehört in eine eigene Queue, ausgelöst per CPH.RunAction("...", false) aus dem schnellen Trigger heraus. So bleibt der Pfad, in dem die Events ankommen, jederzeit frei. CPH.Between(int min, int max) liefert übrigens eine Zufallszahl mit inklusivem Minimum und Maximum, CPH.NextDouble() einen double zwischen 0.0 und 1.0.
Best Practices
Abschnitt betitelt „Best Practices“- Actions kurz halten. Was im Trigger-Pfad läuft, sollte in Millisekunden fertig sein. Alles Langsame auslagern.
- Wait und API-Calls in eigene Queues. Per
CPH.RunAction("...", false)startest du sie eigenständig, ohne den aufrufenden Worker zu blockieren. HttpClientstatisch und wiederverwendet. Ein Feld für die ganze Klasse, kein neuer Client pro Aufruf, sonst drohen Socket-Leaks.static-Felder bewusst einsetzen. Nur für geteilte, lesende Ressourcen. Veränderlichen Zustand überCPH.SetGlobalVarundCPH.GetGlobalVar<T>halten.- Precompile aktivieren bei vielen C#-Actions, damit der erste Lauf nicht hängt.
- Secrets aus Globals lesen, nie im Code hardcoden.
Häufige Fallen
Abschnitt betitelt „Häufige Fallen“- Langer Wait blockiert alles. Ein
CPH.Waitvon mehreren Sekunden im Trigger-Pfad hält den Worker fest. Kommt eine Raid- oder Bits-Welle, stauen sich alle weiteren Actions dahinter. Lange Waits gehören in eine ausgelagerte Action mitrunImmediately = false. static-Feld als versehentlich geteilter State. Ein veränderlichesstatic-Feld wird über alle parallelen Aufrufe geteilt. Bei gleichzeitigen Events entstehen Race Conditions und falsche Werte. Für Zustand zwischen AufrufenCPH.GetGlobalVar<T>undCPH.SetGlobalVarnutzen.- Schwere API-Calls inline. Ein langsamer
client.PostAsync(...).GetAwaiter().GetResult()direkt im Trigger blockiert den Worker, bis die Antwort da ist. Wandert der Call in eine eigene Queue, leidet nur diese Queue, nicht der Event-Pfad. - Neuer
HttpClientpro Aufruf. Erzeugst du den Client inExecute()statt alsstatic-Feld, sammeln sich offene Sockets an. Bei vielen Aufrufen führt das zu Verbindungsproblemen.