Net-Base Magazine

06.06.2026

Serveur haute performance REST dans Delphi : limites de requêtes, pool de threads et gestion propre de la surcharge (extrait de code)

Un serveur haute performance REST dans Delphi n'est pas seulement rapide grâce à un « JSON rapide », mais grâce à une parallélisation contrôlée, des timeouts stricts et un comportement de surcharge maîtrisé. Cet article présente une solution opérationnelle de Concurrency-Gate basée sur un sémaphore, avec des réponses 429/503...

06.06.2026

Du thème du magazine à la pratique des projets

Pages de services et techniques pertinentes pour l'article

Pourquoi «High Performance» dans REST sous Delphi échoue souvent à cause du parallélisme

Un serveur REST Delphi haute performance Delphi n’est en pratique que rarement limité par le seul temps CPU par requête, mais par un parallélisme non contrôlé : trop de requêtes simultanées, trop de requêtes vers la base de données en parallèle ou des E/S bloquantes (fichier, réseau, base de données). Le résultat ne ressemble alors pas à «un peu plus lent», mais à une réaction en chaîne : plus de threads, plus de files d’attente, effondrement du connection-pool, latences croissantes, timeouts côté client et, au final, un serveur qui «vit» encore mais ne fournit plus de réponses stables.

Le contre-mesure n’est pas un truc isolé, mais un comportement d’«overload» volontaire : lorsque le serveur atteint ses limites, il doit rejeter tôt et de manière déterministe (typiquement HTTP 429 ou 503), au lieu de laisser les requêtes s’empiler dans une file d’attente infinie. C’est précisément pour cela que ce fragment de source est prévu : un Concurrency-Gate léger (sémaphore) avec timeouts, qui peut s’intégrer aux endpoints REST existants — que vous utilisiez Indy, WebBroker, Horse ou une couche HTTP propriétaire.

Idée d’architecture : Concurrency-Gate avant la «partie coûteuse»

L’idée de base est simple : avant la partie coûteuse (accès à la base de données, rapports complexes, réponses JSON volumineuses), on réserve un jeton d’une Semaphore. S’il n’y a aucun jeton libre, une réponse contrôlée est renvoyée immédiatement. Il est important que ce Gate soit libéré de façon fiable (try/finally), et qu’il soit placé dans le chemin de code qui est réellement coûteux — pas seulement tout au début du Request-Handler, si ensuite arrivent encore le parser/router/authentification.

De cette manière, la charge n’est pas «optimisée hors», mais canalisée : le serveur répond à moins de requêtes simultanément, mais avec des latences plus stables. Dans les applications d’entreprise sur mesure, cela est le plus souvent plus utile que des records ponctuels dans des benchmarks synthétiques.

Extrait de code : limiteur de requêtes avec timeout, 429/503 et hooks de télémétrie

Le code Delphi suivant implémente un Concurrency-Gate sous la forme de la classe TRestRequestGate. Il se base sur TSemaphore (depuis System.SyncObjs ; une Semaphore est un compteur pour accès simultanés limités). L’appel du Gate renvoie soit un objet «Lease» (type RAII : libération dans le destructeur), soit opte pour une réponse de surcharge immédiate. En complément, des hooks pour logging/monitoring sont prévus pour que vous puissiez en exploitation voir pourquoi des requêtes ont été rejetées.

Delphi
unit RESTRequestGate;

interface

uses
  System.SysUtils,
  System.Classes,
  System.SyncObjs,
  System.Diagnostics;

type
  // Contexte minimal pour le logging/tracing ; peut p. ex. être étendu avec utilisateur/route.
  TRESTGateContext = record
    RequestId: string;
    Route: string;
    RemoteIp: string;
  end;

  TRESTOverloadDecision = (odAccepted, odRejectedBusy, odRejectedTimeout);

  // Hook pour la télémétrie d'exploitation (p. ex. dans un fichier, Syslog, un exporteur Prometheus, etc.)
  TRESTGateEvent = reference to procedure(const Ctx: TRESTGateContext;
                                         Decision: TRESTOverloadDecision;
                                         WaitedMs: Integer;
                                         InFlight: Integer);

  // Objet de lease : libération du jeton dans le destructeur.
  TRESTGateLease = class
  private
    FSemaphore: TSemaphore;
    FInFlightCounter: PInteger;
    FReleased: Boolean;
  public
    constructor Create(ASem: TSemaphore; ACounter: PInteger);
    destructor Destroy; override;
    procedure Release;
  end;

  TRESTRequestGate = class
  private
    FSem: TSemaphore;
    FMaxInFlight: Integer;
    FInFlight: Integer;
    FOnEvent: TRESTGateEvent;
  public
    constructor Create(AMaxInFlight: Integer);
    destructor Destroy; override;

    // TimeoutMs = 0 : pas d'attente, renvoyer immédiatement 429/503
    function TryAcquire(const Ctx: TRESTGateContext; TimeoutMs: Cardinal;
                        out Lease: TRESTGateLease;
                        out WaitedMs: Integer;
                        out Decision: TRESTOverloadDecision): Boolean;

    property OnEvent: TRESTGateEvent read FOnEvent write FOnEvent;
    property MaxInFlight: Integer read FMaxInFlight;
    function InFlight: Integer;
  end;

implementation

uses
  System.Math;

{ TRESTGateLease }

constructor TRESTGateLease.Create(ASem: TSemaphore; ACounter: PInteger);
begin
  inherited Create;
  FSemaphore := ASem;
  FInFlightCounter := ACounter;
  FReleased := False;
end;

destructor TRESTGateLease.Destroy;
begin
  Release;
  inherited;
end;

procedure TRESTGateLease.Release;
begin
  if FReleased then
    Exit;
  FReleased := True;

  // D'abord décrémenter le compteur, puis libérer le sémaphore.
  TInterlocked.Decrement(FInFlightCounter^);
  FSemaphore.Release;
end;

{ TRESTRequestGate }

constructor TRESTRequestGate.Create(AMaxInFlight: Integer);
begin
  inherited Create;
  if AMaxInFlight <= 0 then
    raise EArgumentException.Create('AMaxInFlight doit être > 0');

  FMaxInFlight := AMaxInFlight;
  FInFlight := 0;

  // InitialCount = MaxCount = AMaxInFlight
  FSem := TSemaphore.Create(nil, AMaxInFlight, AMaxInFlight, '');
end;

destructor TRESTRequestGate.Destroy;
begin
  FSem.Free;
  inherited;
end;

function TRESTRequestGate.InFlight: Integer;
begin
  Result := TInterlocked.CompareExchange(FInFlight, 0, 0);
end;

function TRESTRequestGate.TryAcquire(const Ctx: TRESTGateContext; TimeoutMs: Cardinal;
  out Lease: TRESTGateLease; out WaitedMs: Integer; out Decision: TRESTOverloadDecision): Boolean;
var
  Sw: TStopwatch;
  WaitRes: TWaitResult;
  CurrentInFlight: Integer;
begin
  Lease := nil;
  WaitedMs := 0;
  Decision := odRejectedBusy;

  Sw := TStopwatch.StartNew;
  if TimeoutMs = 0 then
    WaitRes := FSem.WaitFor(0)
  else
    WaitRes := FSem.WaitFor(TimeoutMs);

  WaitedMs := Integer(Min(Sw.ElapsedMilliseconds, High(Integer)));

  case WaitRes of
    wrSignaled:
      begin
        CurrentInFlight := TInterlocked.Increment(FInFlight);
        Lease := TRESTGateLease.Create(FSem, @FInFlight);
        Decision := odAccepted;
        Result := True;

        if Assigned(FOnEvent) then
          FOnEvent(Ctx, Decision, WaitedMs, CurrentInFlight);
      end;

    wrTimeout:
      begin
        // wrTimeout pour TimeoutMs > 0 : attente ciblée mais limitée.
        Decision := odRejectedTimeout;
        Result := False;
        if Assigned(FOnEvent) then
          FOnEvent(Ctx, Decision, WaitedMs, InFlight);
      end;
  else
    begin
      // wrAbandoned/cas d'erreur : rejeter de manière conservatrice
      Decision := odRejectedBusy;
      Result := False;
      if Assigned(FOnEvent) then
        FOnEvent(Ctx, Decision, WaitedMs, InFlight);
    end;
  end;
end;

end.

Objet : stabilité sous charge plutôt que « tout en même temps »

Avec MaxInFlight vous définissez combien de requêtes peuvent simultanément atteindre la « partie coûteuse ». Ce n’est délibérément pas « nombre de cœurs CPU », mais une grandeur opérationnelle. Pour des endpoints lourds en base de données, il est souvent judicieux de régler MaxInFlight en relation avec le pool de connexions DB (par exemple Pool = 20, MaxInFlight = 12 à 16), afin que chaque requête ne bloque pas une connexion et que d’autres threads ne s’enchaînent ensuite.

Contraintes et pièges

  • Try/Finally est obligatoire : le Lease doit être libéré de manière garantie. Si vous avez des exceptions dans l’endpoint, le gate devient alors « perméable » et le serveur reste durablement en état « busy ».
  • Choisir un Timeout sensé : TimeoutMs=0 est une limite stricte (refus immédiat). Un timeout court (typiquement 50 à 150 ms) lisse les pics sans constituer de vraies files d’attente.
  • Ne pas ouvrir le gate trop tôt : l’authentification (par exemple Bearer/JWT) ou le routage peuvent être peu coûteux ; la sémaphore devrait intervenir avant la section réellement coûteuse. Inversement : si l’authentification devient lourde (p. ex. contre un système d’identité externe), elle doit aussi être limitée.
  • 429 vs 503 : HTTP 429 (« Too Many Requests ») convient lorsque les clients doivent reposter de façon ciblée. 503 (« Service Unavailable ») convient lorsque le service est temporairement incapable d’accepter des requêtes de manière utile. Dans les deux cas, un en-tête Retry-After est recommandé.

Intégration dans REST-Handler : Indy/WebBroker/Horse pragmatique

Le snippet est volontairement framework-neutral. Il vous faut simplement un point où les requêtes « traversent ». Typiquement un singleton global ou un gate par groupe de routes (par exemple «/reports» plus strict, «/health» sans gate). Exemple d’intégration modèle :

  • Remplir le contexte (RequestId, Route, RemoteIp)
  • TryAcquire avec un timeout court
  • En cas de refus, écrire immédiatement la response (429/503) et terminer
  • Le Lease vit dans le scope jusqu’après la partie coûteuse

Dans Horse (middleware) le gate se situe près d’un groupe de routes. Dans WebBroker vous pouvez agir dans l’action handler concerné. Avec Indy cela dépend si vous avez un thread par requête ; le gate fonctionne néanmoins tant que les sections coûteuses sont correctement bornées.

High Performance REST Server Delphi : réponses de surcharge qui n’« empoisonnent » pas les clients

Les réponses de surcharge sont plus que des codes d’état. Si les clients renvoient agressivement des requêtes immédiatement après un 429/503, vous provoquez une tempête de retries. Dans des paysages systèmes hétérogènes (apps mobiles, C# Services, clients legacy) un comportement cohérent aide :

  • Retry-After : par exemple 1 à 3 secondes, selon l’endpoint. C’est un métronome clair.
  • Corps de réponse court : un petit JSON comme {"error":"server_busy","requestId":"..."} suffit. De gros objets d’erreur coûtent à nouveau CPU et bande passante.
  • Health-Endpoint non bridé : le monitoring doit continuer à fournir des indications en charge (éventuellement avec un flag « degraded »).

Si vous placez un reverse proxy comme nginx en amont : alignez timeouts et buffering côté proxy. Un proxy peut décharger (TLS-Termination, Keep-Alive) mais aussi déplacer la charge (p. ex. en tamponnant de gros corps de requête). En production, l’important est que les limites soient cohérentes : Proxy-Timeout > App-Timeout, sinon les clients voient un « Gateway Timeout » alors que l’application aurait proprement refusé la requête.

Threading, pools de connexions DB et Keep-Alive : où ça bascule en pratique

Le Gate résout le problème du « trop de requêtes simultanées », mais n’empêche pas automatiquement qu’une seule requête monopolise excessivement des ressources. Trois points de basculement typiques issus de Delphi-projets surviennent précisément aux interfaces entre Threading, base de données et connexions HTTP :

  • Une requête bloque plusieurs ressources limitées : d’abord une connexion DB, puis un appel HTTP externe, puis un accès fichier. Si tout cela se produit dans le même thread de requête, le temps de blocage se multiplie. Le Gate limite alors la parallélisme, mais le débit chute drastiquement. Il est utile ici de découpler les dépendances (p.ex. appels externes asynchrones, pré-calcul via une file de jobs).
  • BDE – remplacement avec connexion native– Pooling et transactions : BDE-Ablosung mit nativer Anbindung peut gérer un pool de connexions, mais une transaction « longue » (p.ex. parce que la création de JSON ou des contrôles métier se situent entre StartTransaction et Commit) maintient la connexion inutilement. Une bonne pratique consiste à restreindre la transaction au plus près des statements effectifs et à sérialiser ou valider en dehors de la transaction lorsque le domaine le permet.
  • HTTP Keep-Alive comme consommateur de mémoire caché : Keep-Alive réduit les handshakes, mais peut, en présence de nombreux clients inactifs, conduire à trop de sockets ouverts. Surtout pour Windows- et Linux-services, on observe alors non pas une « montée CPU », mais des « handles/FDs saturés » ou de la RAM liée aux buffers. Des timeouts d’inactivité clairs côté serveur et côté reverse proxy ainsi qu’une limite par IP client, si l’environnement le permet, aident ici.

La conséquence : MaxInFlight n’est pas une valeur statique. Elle dépend de votre ressource la plus lente et la plus contrainte (DB, systèmes externes, storage) et de la capacité d’une requête à « maintenir » ces ressources ensemble.

Levier de performance à côté du Gate : ne pas mélanger JSON, DB et I/O

Le Gate stabilise, mais il ne remplace pas une conception d’endpoint efficace. Trois freins dans les serveurs Delphi REST reviennent régulièrement :

  • Construction de JSON avec des chaînes intermédiaires inutiles : la charge provient souvent de nombreuses chaînes Unicode temporaires. Dans la mesure du possible, construire en mode streaming (Writer/Stream) plutôt que d’utiliser de gros objets intermédiaires, en particulier pour les endpoints de listes.
  • Accès base de données « par item » : les N+1-Queries et les lookups par ligne sont le classique. Mieux : joins ciblés, requêtes en batch, agrégation côté serveur. Pour des résultats très volumineux, la pagination avec tri stable est en outre recommandée (pour éviter que les pages ne « sautent »).
  • I/O bloquant dans le thread de requête : les accès fichiers ou les appels HTTP externes doivent être soit strictement limités, soit déplacés dans un pipeline asynchrone. Sinon vous bloquez des threads coûteux pour « attendre ».

Pour des solutions numériques d’entreprise ayant évolué au fil du temps, c’est souvent le point critique : un endpoint a été ajouté « vite fait » et fonctionne, jusqu’à l’arrivée de la charge réelle et des volumes de données. C’est alors que l’on voit si les limites architecturales ont été correctement tracées (couche d’accès aux données, caching, stratégies bulk, timeouts clairs).

Debugging et exploitation : ce que vous devriez mesurer

Le hook OnEvent est volontairement simple. En pratique, vous devriez collecter au minimum les valeurs suivantes :

  • InFlight (parallélisme actuel au Gate)
  • WaitedMs (combien de mise en file d’attente vous tolérez)
  • Decision (accepted/busy/timeout)
  • Route/RemoteIp (analyse sommaire des causes, sans négliger la protection des données)

Cela vous donne un signal pour savoir si les limites sont trop strictes (trop de 429) ou trop laxistes (WaitedMs élevé, latences en hausse). Et vous voyez si certaines routes dominent. Pour Windows- et Linux-Services c’est décisif au quotidien : sans télémétrie, un problème de performance devient vite un jeu de devinettes entre réseau, base de données, proxy et application.

Inhabituel, mais extrêmement utile : «WaitedMs» comme indicateur d’alerte précoce

Beaucoup d’équipes ne regardent que le temps de réponse et la CPU. WaitedMs est souvent un meilleur indicateur, car il montre que les requêtes attendent déjà avant le travail effectif. Si WaitedMs augmente alors que la CPU reste modérée, la ressource contrainte n’est souvent pas la CPU mais un pool (connexions DB), un verrou dans la logique métier ou un service downstream externe. Cela fait gagner du temps dans l’analyse des causes, car vous pouvez cibler «Pool/Lock/I/O» plutôt que «optimisation du compilateur».

Variantes: Pro-Route-Gates, priorités et «Fast Lane»

Un gate pour tout est simple, mais pas toujours idéal. Variantes pertinentes :

  • Gate par groupe de routes : «/reports» strict, «/api/orders» modéré, «/health» ouvert. Ainsi, vous empêchez que des requêtes de reporting coûteuses n’évincèrent les processus centraux.
  • Fast Lane pour Admin/Monitoring : Gate séparé avec faible parallélisme, pour que les opérations d’exploitation restent possibles même en charge.
  • Limits basés sur un budget : Si les tailles de réponse varient fortement, un budget en octets peut aider en complément (p.ex. X MB maximum générés simultanément). C’est plus complexe, mais réaliste pour de gros téléchargements.

Important : la priorisation devient vite politique («mon endpoint est plus important»). Elle reste techniquement stable si les priorités sont liées à des processus (p.ex. prise de commande avant reporting), et non à des rôles ou des services.

Conclusion : le gate en vaut‑il la peine — et où l’approche bascule‑t‑elle ?

Un Concurrency-Gate est un élément pragmatique pour un serveur High Performance REST en Delphi, car il rend l’overload contrôlable et maintient vos systèmes stables lors des pics de charge. Il est particulièrement pertinent si vous avez des endpoints liés à une base de données, si un reverse proxy est en amont, ou si plusieurs clients (legacy, portails, services) génèrent la charge en vagues.

Les limites sont claires : si le travail par requête est intrinsèquement trop coûteux (requêtes inefficaces, gros objets JSON, systèmes externes bloquants), le gate ne fait que masquer les symptômes. Il faudra alors revoir l’accès aux données, les stratégies de caching, les timeouts et, si nécessaire, la transformation vers un traitement asynchrone (Queue/Job-System). En tant que ceinture de sécurité en exploitation, le gate fait souvent la différence entre «un peu lent» et «totalement inutilisable».

Si vous souhaitez intégrer un comportement d’overload dans une Delphi REST-API et REST-Server existante ou équilibrer proprement les limites avec les timeouts base de données et proxy : discutez le projet ou la modernisation avec Net-Base.

Dans le contexte métier, les Thread-Pool Delphi et HTTP 429 Too Many Requests jouent aussi un rôle important lorsque les intégrations, les flux de données et l’évolution doivent bien s’articuler.

Discuter d’un projet ou d’une modernisation avec Net-Base.

Étape suivante

Lorsque ce sujet devient un projet concret, l'architecture, l'existant et l'exploitation doivent être examinés ensemble dès le départ.

Nous n'intervenons pas seulement sur des questions ponctuelles, mais aussi lorsque des fragments de code source, des problématiques liées aux systèmes legacy ou des concepts de portail doivent se transformer en un projet d'entreprise robuste.

  • L'état des lieux, l'état cible et les risques techniques sont évalués conjointement.
  • REST, l'accès aux données, les portails et le déploiement ne sont pas repoussés en tant que conséquences ultérieures.
  • Vous identifiez tôt quelle voie est viable sur le plan économique et opérationnel.

Partager l'article

Partager directement cette publication

LinkedIn, X, XING, Facebook, WhatsApp et e-mail sont immédiatement disponibles. Pour Instagram, nous préparons directement le lien et un court texte.

Courriel

Instagram s'ouvre dans un nouvel onglet. Le lien et le court texte sont préalablement copiés dans le presse-papiers.