SignalRC und Ping
Der DDC-Truck, Teil 10
Wer ein Fahrzeug in Echtzeit steuert wie das, welches wir als DDC-Truck im Artikel „SignalRC – in Echtzeit ans Steuer“ [1] ausführlich vorgestellt haben, will nicht raten, wie schnell die Steuerungsverbindung ist. Er will es wissen. Am besten live, dauerhaft und auf die Millisekunde genau.
SignalRC misst die Latenz mit dem ältesten Trick der Netzwerktechnik: Ping-Pong. Ein Paket geht raus, kommt zurück, die Zeit dazwischen ist der Roundtrip. Klingt simpel. Ist es auch. Aber die Umsetzung mit SignalR enthält einige interessante Details, besonders wenn der Ping über zwei Verbindungen und drei Stationen laufen muss.
Drei Werte, ein Request
Wenn der Browser einen Steuerbefehl an das Fahrzeug schickt, durchläuft das Signal mehrere Stationen:
- Vom Browser zum Server: Der Client schickt den Befehl per SignalR-WebSocket an den ASP.NET Server.
- Vom Server zum Fahrzeug: Der Server leitet den Befehl über eine zweite SignalR-Verbindung an den Onboard-Client weiter.
- Ins Fahrzeug zur Hardware: Der Onboard-Client gibt seine Zeit zurück.
Das ergibt drei messbare Latenzen aus einem einzigen Request:
- RTT (Roundtrip Time): Die gesamte Strecke Browser, Server, Fahrzeug und denselben Weg zurück zum Browser, gemessen im Browser mit performance.now(). Das ist die Zahl, die den Fahrer am meisten interessiert.
- SRT (Server Request Time): Die Strecke Server, Fahrzeug, Server. Gemessen auf dem Server mit einer Stopwatch. Das zeigt isoliert, wie schnell die Mobilfunkverbindung des Fahrzeugs reagiert.
- CAT (Car Accept Time): Der Browser merkt sich Date.now() vor dem Senden. Das Fahrzeug stempelt DateTime.UtcNow beim Empfang und gibt den Wert zurück. Der Browser errechnet carTimestamp minus sendTimestamp. Das ergibt die One-Way-Latenz zum Fahrzeug – also wie lange ein Steuerbefehl braucht, bis er ankommt. Für die Fernsteuerung ist nur der Hinweg relevant, der Rückweg ist egal.
Warum drei Werte? Weil jeder eine andere Frage beantwortet. Die RTT sagt: Wie lange dauert alles zusammen? Die SRT sagt: Wie viel davon ist die LTE-Verbindung? Und die Differenz RTT minus SRT zeigt: Wie viel entfällt auf meine eigene Internetanbindung?
Das Schöne: Alles passiert in einem einzigen invoke-Aufruf. Ein Request raus, drei Antworten drin.
Der Request – einmal durch die ganze Kette
Die Ping-Messung läuft über den bestehenden CarControlHub. Es gibt also keinen eigenen Hub, keinen extra Endpoint. Der Ping nutzt exakt denselben Pfad wie die echten Steuerbefehle. So misst man nicht eine theoretische Latenz, sondern die tatsächliche.
Das Ergebnis-Modell
Der Server gibt ein kompaktes Record zurück:
// Shared/PingCarResult.cs public record PingCarResult(long CarTimestamp, double ServerRequestMs);
CarTimestamp ist der DateTime.UtcNow des Fahrzeugs, konvertiert in Unix-Epoch-Millisekunden. ServerRequestMs ist die Stopwatch-Messung auf dem Server. Zwei Felder, minimaler Overhead.
Der Server-Interface-Vertrag
SignalR-Hubs in SignalRC folgen einem klaren Pattern: Interfaces im Shared-Projekt definieren den Vertrag. Für den Ping erweitert sich der bestehende ICarControlServer:
// Shared/Hubs/ICarControlServer.cs
public interface ICarControlServer
{
// ... bestehende Methoden (AquireCarControl, UpdateChannel, etc.)
Task<PingCarResult?> PingCar(int carId);
}Und das ICarControlClient-Interface, also was der Server auf dem Fahrzeug aufrufen kann, bekommt eine Ping-Methode:
// Shared/HubClients/ICarControlClient.cs
public interface ICarControlClient
{
// ... bestehende Methoden
Task<long> Ping();
}Der Rückgabewert von PingCar ist ein PingCarResult?. Nullable? Ja, falls das Fahrzeug nicht verbunden ist, kommt null zurück. Das sollte im Idealfall nie passieren, denn die Ping-Messung läuft nur während einer aktiven Fahrsession. Falls doch, dann ist man wohl gerade in ein Funkloch gefahren.
Der Server – Stopwatch und Relay
Im CarControlHub passiert die eigentliche Relay-Logik:
// Server/Hubs/CarControlHub.cs
public async Task<PingCarResult?> PingCar(int carId)
{
if (!_connectionMap.TryGetByKey(carId.ToString(), out var carClientId))
{
Logger.LogDebug($"PingCar: Car {carId} not found in connection dictionary.");
return null;
}
var sw = Stopwatch.StartNew();
var carTimestamp = await Clients.Client(carClientId).Ping();
sw.Stop();
return new PingCarResult(carTimestamp, sw.Elapsed.TotalMilliseconds);
}Die _connectionMap ist ein statisches BiDictionary<string, string>, das die Zuordnung carId zur connectionId hält. Das Fahrzeug registriert sich dort beim Verbindungsaufbau über RegisterForControl. Exakt derselbe Mechanismus, den auch UpdateChannel und AquireCarControl nutzen.
Das Fahrzeug – eigener Timestamp
Auf dem Fahrzeug ist die Implementierung kompakt. Der ControlService implementiert ICarControlClient:
// Onboard/Control/ControlService.cs
public Task<long> Ping()
{
return Task.FromResult(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}Kein Logging, keine Seiteneffekte. Das Fahrzeug gibt seinen eigenen DateTime.UtcNow als Unix-Epoch-Millisekunden zurück. Der Browser rechnet carTimestamp minus sendTimestamp und erhält so die One-Way-Latenz zum Fahrzeug.
Das Onboard-Projekt nutzt TypedSignalR.Client. Da der ControlService bereits ICarControlClient implementiert und sich selbst über _connection.Register<ICarControlClient>(this) registriert, wird die neue Ping-Methode automatisch erkannt. Es ist keine zusätzliche Konfiguration nötig.
Der Client – drei Messungen aus einem Aufruf
Der Browser nutzt die bestehende CarControlHub-Verbindung aus dem ControlFlowStore. Keine eigene Connection, kein zusätzlicher Hub. Ein invoke, drei Ergebnisse.
Die Messung im Web-Client
const sendPing = useCallback(async () => {
if (!carId || !controlConnection || controlConnection.state !== "Connected") return;
const perfStart = performance.now();
const result = await controlConnection.invoke("PingCar", carId);
const rttMs = Math.round((performance.now() - perfStart) * 100) / 100;
const srtMs = Math.round(result.serverRequestMs * 100) / 100;
const catMs = result.carTimestamp - jsSendTimestamp;
}, [carId, controlConnection]);Ein Request, drei Uhren:
- RTT: performance.now() vor dem invoke, performance.now() danach. Das ist die hochauflösende, monotone Uhr des Browsers mit einer Auflösung bis zu 5 Mikrosekunden. Sie misst die gesamte Strecke: vom Browser zum Fahrzeug und zurück.
- SRT: Kommt als serverRequestMs aus dem PingCarResult und wird Server-seitig mit Stopwatch gemessen. Enthalten ist nur die Strecke Server zum Fahrzeug und zurück zum Server, jedoch keine Browser-Latenz.
- CAT: Date.now() vor dem Senden merken, das Fahrzeug stempelt DateTime.UtcNow beim Empfang und rechnet result.carTimestamp minus jsSendTimestamp nach dem Empfang. Das ist die One-Way-Latenz zum Fahrzeug: Wie lange braucht ein Befehl, bis er ankommt?
Warum performance.now() für RTT und Date.now() für CAT? Weil performance.now()die genauere Messung ist: monoton (springt nicht bei Systemzeitänderungen), Sub-Millisekunden-Auflösung, ideal für Differenzmessungen auf derselben Maschine.
CAT nutzt Date.now(), weil es einen Zeitstempel einer anderen Maschine vergleicht. Der Browser merkt sich Date.now() vor dem Senden, das Fahrzeug stempelt DateTime.UtcNow beim Empfang. Die Differenz carTimestamp minus sendTimestamp ergibt die One-Way-Latenz zum Fahrzeug. Für die Fernsteuerung ist das die entscheidende Zahl: Wie lange dauert es, bis das Fahrzeug den Lenkbefehl hat? Der Rückweg ist irrelevant – das Auto muss nicht antworten, um zu lenken.
Die CAT-Messung setzt voraus, dass Browser und Fahrzeug einigermaßen synchrone Uhren haben. Bei NTP-synchronisierten Systemen liegt die Abweichung typischerweise unter 10 ms. Wenn CAT deutlich von RTT/2 abweicht, ist das ein Hinweis auf Clock-Drift.
invoke versus send
SignalR bietet zwei Wege, eine Server-Methode aufzurufen: send und invoke.
- send schickt die Nachricht und kehrt sofort zurück: Fire and Forget. Der Client weiß nicht, ob der Server die Nachricht verarbeitet hat. Das reicht für Steuerbefehle: Wenn das Auto den Lenkeinschlag nicht bekommt, kommt der nächste 16 ms später.
- invoke schickt die Nachricht und wartet auf die Antwort. Das Promise wird erst aufgelöst, wenn der Server die Methode ausgeführt und den Rückgabewert zurückgeschickt hat.
// Fire-and-Forget: Für Steuerbefehle
await connection.send("UpdateChannel", carId, session, channelId, value);
// Request-Response: Für Ping
const result = await connection.invoke("PingCar", carId);Unter der Haube generiert SignalR bei invoke eine Invocation-ID. Der Server sendet die Antwort mit derselben ID zurück. So ordnet der Client die Antwort der richtigen Anfrage zu, selbst wenn mehrere invoke-Aufrufe gleichzeitig unterwegs sind.
Für die Ping-Messung ist invoke zwingend erforderlich. send würde nur die Zeit messen, die der Browser braucht, um die Nachricht in den WebSocket zu schreiben. Das geschieht quasi instant, und der Wert wäre komplett nutzlos.
Dasselbe Prinzip nutzt der Server intern. Clients.Client(carClientId).Ping() ist ebenfalls ein invoke mit Rückgabewert. Der Server wartet, bis das Fahrzeug seinen Timestamp zurückgibt. Genau das macht die serverseitige Stopwatch-Messung möglich.
Periodische Messung
Die Netzwerklatenz schwankt. Deshalb misst SignalRC kontinuierlich:
const PING_HISTORY_SIZE = 20; const PING_INTERVAL_MS = 2000; intervalRef.current = setInterval(sendPing, PING_INTERVAL_MS);
Alle zwei Sekunden ein Ping. Die letzten 20 Ergebnisse landen in einer History. Bei einem Intervall von zwei Sekunden deckt das Fenster also die jeweils zurückliegenden 40 Sekunden ab. Diese Einstellung kann vom Benutzer nachträglich geändert werden.
Das UI – drei Werte, ein Bild
Die Ping-Komponente zeigt die drei Kennzahlen und ein Liniendiagramm:
<div className="grid grid-cols-3 gap-1 mb-2 text-center">
<div>
<div className="text-zinc-500 text-xs">RTT</div>
<div className={`font-mono font-bold text-xs ${getLatencyColor(currentRtt)}`}>
{currentRtt}ms
</div>
</div>
<div>
<div className="text-zinc-500 text-xs">SRT</div>
<div className="font-mono font-bold text-xs text-purple-400">
{currentSrt}ms
</div>
</div>
<div>
<div className="text-zinc-500 text-xs">CAT</div>
<div className="font-mono font-bold text-xs text-yellow-400">
{currentCat}ms
</div>
</div>
</div>Die Farbe der RTT codiert die Qualität der Gesamtverbindung:
- Grün (< 30 ms): Exzellent. Lokales Netzwerk oder naher Server.
- Gelb (30–150 ms): Akzeptabel. Typisch für LTE oder entfernte Server.
- Rot (> 150 ms): Problematisch. Spürbare Verzögerung bei der Fahrzeugsteuerung.
SRT und CAT haben feste Farben: Lila für SRT, Gelb für CAT. Sie dienen der Diagnose, nicht der Bewertung. Die Ampel-Logik liegt allein auf der RTT, weil sie die Gesamtlatenz widerspiegelt, die der Fahrer erlebt.
Das Liniendiagramm zeigt den zeitlichen Verlauf aller drei Werte. Es wird als SVG-Polylines gerendert. Die RTT-Linie wechselt ihre Farbe basierend auf dem Durchschnitt der letzten 20 Messungen. Die gleiche Grün-Gelb-Rot-Logik wie bei der Zahl oben, nur geglättet über das gesamte Fenster. So springt die Farbe nicht bei jedem einzelnen Ausreißer, sondern zeigt den Trend.
Liegt die RTT-Linie nah an der SRT-Linie, ist die eigene Internetanbindung schnell. Liegt sie deutlich darüber, ist die Strecke vom Browser zum Server der Flaschenhals. Die gestrichelte CAT-Linie sollte grob bei der Hälfte der RTT liegen – weicht sie stark ab, deutet das auf Clock-Drift hin.
Was die Zahlen bedeuten
Die gemessene RTT ist der komplette Roundtrip durch den gesamten Stack:
- JavaScript serialisiert die Nachricht (SignalR-Protokoll).
- WebSocket sendet die Bytes an den Server.
- ASP.NET empfängt, deserialisiert, routet zum Hub.
- Stopwatch.Start() — ab hier zählt die SRT.
- Server serialisiert den Aufruf zum Fahrzeug.
- Fahrzeug empfängt, gibt seinen DateTime.UtcNow zurück.
- Server empfängt Antwort — Stopwatch.Stop().
- Server serialisiert PingCarResult.
- WebSocket sendet die Antwort zum Browser.
- Browser empfängt, deserialisiert, Promise wird aufgelöst.
Das ist kein einfacher Netzwerk-Ping wie ICMP. Es ist ein kompletter Application Roundtrip, der die gesamte Middleware-Kette über zwei SignalR-Verbindungen einschließt. Realistischer als ein roher Netzwerk-Ping, weil es genau die Latenz misst, die auch die Steuerbefehle haben.
In der Praxis sieht man folgende Werte:
- RTT (lokales LAN + WLAN-Fahrzeug): 5–15 ms
- SRT (LTE gut): 30–80 ms
- SRT (LTE mäßig): 80–200 ms
- SRT (LTE schlecht): 200–500 ms
- Gesamt-RTT: Netzwerk-Latenz + SRT.
Ab 200 ms Gesamt-RTT wird die Steuerung merklich träge. Die Aufschlüsselung zeigt, wo das Problem liegt. Wenn die Netzwerk-Latenz 5 ms beträgt und die SRT 180 ms, dann hilft ein schnellerer Server nichts. Das LTE ist der limitierende Faktor.
Fazit
Drei Zahlen aus einem Request erzählen die ganze Geschichte:
- RTT: Wie lange dauert alles zusammen?
- SRT: Wie viel davon ist die LTE-Verbindung?
- CAT: Wie lange braucht ein Befehl bis zum Fahrzeug?
Der Fahrer sieht: Grün unten, lila oben, zusammen unter 100 ms. Alles gut. Wird der lila Anteil größer, liegt es am LTE. Wird der untere Anteil größer, liegt es an der eigenen Verbindung. Wenn alles grün ist: Vollgas.
Das Schöne an der Architektur: Kein eigener Hub, kein extra Endpoint. Der Ping nutzt exakt denselben CarControlHub und dieselbe Verbindung wie die echten Steuerbefehle. Was der Ping misst, braucht auch der Lenkbefehl. Und genau diese Ehrlichkeit macht die Zahl nützlich.
Praxisbeispiel
Pingt mal im Projekt auf GitHub vorbei.
[1] Georg Poweleit, SignalRC – in Echtzeit ans Steuer, dotnetpro 10-11/2025, Seite 46 ff.
Georg Poweleit
ist Softwarearchitekt mit Fokus auf Microsoft und beschäftigt sich bereits seit mehr als 15 Jahren mit der Softwareentwicklung, davon 12 Jahre im Umfeld der .NET-(Web-)Technologien.