Pourquoi préférer l'attente sur événement à Sleep(1) sous Windows

· Mis à jour le: · · Développement Windows, Synchronisation, Événements, Timer, Conception

Dans notre article précédent, Guide pratique du soft real-time sous Windows, nous avions abordé le fait d’éviter les boucles périodiques qui s’appuient sur Sleep. Cette fois, nous nous concentrons sur un point précis de cette discussion : pourquoi il vaut mieux préférer une attente sur événement à une attente courte sur timer.

Sous Windows, une conception qui « vérifie à intervalle fixe » à l’aide de Sleep(1) ou d’attentes avec des délais courts est inévitablement affectée par la granularité de l’horloge système et par le délai d’ordonnancement qui s’ensuit. Avec les réglages courants, une résolution de timer de la plateforme de l’ordre de 15,6 ms est la référence habituelle, si bien que même en pensant « je revérifie dans 1 ms », l’attente réellement obtenue est souvent bien plus grossière.

À l’inverse, si ce que l’on veut vraiment attendre est un « événement » plutôt que le « temps » — l’arrivée d’un travail, la fin d’une E/S, une demande d’arrêt, un changement d’état — il n’est pas nécessaire d’aller vérifier à intervalle fixe. Le côté où l’événement se produit signale, et le côté qui attend attend sur l’événement. Cette approche est plus naturelle, aussi bien pour la latence que pour le CPU et la consommation.

Les questions auxquelles cet article souhaite répondre sont ces quatre-là :

  • Pourquoi Sleep(1) et les attentes courtes sur timer sont-elles moins précises qu’on ne le pense ?
  • Pourquoi les attentes sur événement sont-elles moins soumises à cette limite ?
  • Dans quelles situations faut-il choisir un événement plutôt qu’un timer ?
  • Dans quels cas faut-il quand même utiliser un timer ?

1. La conclusion d’abord

  • Si vous attendez l’arrivée d’un travail ou la fin d’une E/S, attendez sur un événement, pas sur un timer.
  • Les attentes chronométrées sous Windows sont inévitablement affectées par la granularité de l’horloge système.
  • Sleep(1) ne signifie pas « se réveiller exactement 1 ms plus tard ».
  • Et même une fois le délai écoulé, le thread devient d’abord simplement prêt — son exécution immédiate n’est pas garantie.
  • C’est pourquoi une conception qui « attend en réalité un événement mais va vérifier avec un timer » perd à la fois en latence et en consommation.
  • Il est plus propre de réserver les timers aux cas où le temps lui-même est réellement la condition.

En termes pratiques, cela revient à peu près à ceci :

  • « Envoyer des métriques toutes les 5 secondes » -> le travail d’un timer
  • « Se déclencher dès qu’un travail arrive dans la file » -> le travail d’un événement / sémaphore / variable de condition / WaitOnAddress
  • « Poursuivre une fois l’E/S terminée » -> le travail d’une complétion / d’un événement
  • « S’arrêter quand une demande d’arrêt arrive » -> le travail d’un stop event / d’une annulation

2. Où est le problème

2.1 Les attentes chronométrées sont liées à la granularité de l’horloge système

La précision du délai des wait functions de Windows dépend de la résolution de l’horloge système. C’est la même chose pour Sleep : les millisecondes indiquées ne sont pas garanties d’être respectées « exactement telles quelles ».

Le point important ici est que spécifier 1 ms ne veut pas dire se réveiller 1 ms plus tard.

2.2 Même l’échéance atteinte, l’exécution n’est pas forcément immédiate

Ce qui complique encore les choses, c’est que le thread ne commence pas à s’exécuter au moment même où le délai s’écoule.

Comme le rappelle la documentation de Sleep, une fois l’intervalle d’attente terminé, le thread devient prêt (ready), mais rien ne garantit qu’il obtienne le CPU et s’exécute immédiatement. Il est influencé par les autres threads, les priorités, les états d’inactivité du CPU, les DPC / ISR, la contention de verrous, etc.

Autrement dit, une attente courte sur timer comporte au moins deux niveaux d’incertitude :

  1. La détermination même du délai est tirée par la granularité du timer
  2. Même après le délai, le moment où l’exécution démarre dépend de l’ordonnanceur

2.3 Sleep(1) ne signifie pas une période de 1 ms

En voyant Sleep(1), on est tenté de le lire comme « une boucle qui tourne toutes les 1 ms ». Mais en réalité, il ne faut pas le lire ainsi.

while (!g_stop)
{
    Step();
    Sleep(1);
}

Ce que fait réellement cette boucle est ceci :

  • Le temps d’exécution de Step() s’ajoute à chaque itération
  • Le temps d’attente de Sleep(1) lui-même est tiré par la granularité
  • Même après le réveil, rien ne garantit une exécution immédiate

3. Pourquoi l’attente sur événement a l’avantage

3.1 La fin de l’attente devient un « signal », pas une « expiration de délai »

L’attente sur événement a l’avantage de changer le sens même de l’attente.

Une attente sur timer fonctionne ainsi :

  • Même si rien ne s’est encore produit
  • On se réveille quand un temps fixe s’est écoulé
  • Une fois réveillé, on vérifie si quelque chose s’est produit

Une attente sur événement fonctionne ainsi :

  • Le côté où quelque chose s’est produit signale
  • Une fois signalée, l’attente est satisfaite
  • Au moment du réveil, il y a déjà une raison
qu'un moment arrivel'arrivée d'un travailun changement de valeurla fin d'une E/Sune demande d'arrêtthread en attentequ'attendez-vous réellement ?timer / waitable timerevent / semaphore / condition variableWaitOnAddresscompletion / eventstop event / cancellation

3.2 Choisir l’outil selon ce que l’on attend

En première approche, ce tableau couvre la plupart des décisions.

Ce que vous voulez attendre Mauvais exemple Premier choix
L’arrivée d’un travail dans une file TryPop avec Sleep(1) event / semaphore
La fin d’une E/S Surveiller l’état avec un timer event d’E/S recouvrante / IOCP
L’arrivée d’une demande d’arrêt Vérifier un stop flag toutes les 100 ms stop event / cancellation
Un changement de valeur au sein du même processus while (flag == 0) Sleep(1) WaitOnAddress
L’arrivée d’un moment précis Forcer cela sur un événement timer / waitable timer

3.3 L’événement n’est pas non plus magique

L’attente sur événement a l’avantage de ne pas avoir besoin de se réveiller à la granularité du timer, mais cela ne signifie pas que le thread s’exécute avec un délai strictement nul dès l’instant où il est signalé.

Même une attente sur événement subit ces influences :

  • la latence de l’ordonnanceur
  • la priorité du thread
  • l’état d’alimentation du CPU
  • la contention de verrous
  • les défauts de page
  • les DPC / ISR

Cela dit, on élimine au moins le type d’attente superflu où le thread « dort jusqu’au prochain tick du timer ».

4. Anti-patterns typiques

4.1 Scruter une file avec Sleep(1)

C’est ce que l’on voit le plus souvent.

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

Cette écriture paraît simple au premier abord, mais elle pose trois problèmes :

  1. Le thread se réveille périodiquement même quand la file est vide
  2. La latence est tirée par la granularité du timer
  3. Elle est également désavantageuse en consommation

4.2 Surveiller un état avec Thread.Sleep(1) / Task.Delay(1)

La même odeur se retrouve aussi en C# / .NET.

while (!stoppingToken.IsCancellationRequested)
{
    if (_queue.TryDequeue(out WorkItem? item))
    {
        await ProcessAsync(item, stoppingToken);
        continue;
    }

    await Task.Delay(1, stoppingToken);
}

L’apparence est douce et async, mais l’essence de la conception reste du polling.

5. Comment corriger cela

5.1 Le producteur signale à l’arrivée

Si vous attendez l’arrivée dans une file, changez la conception pour que le producteur signale, au lieu de faire du polling.

  • Le producteur dépose un item dans la file
  • Juste après l’avoir déposé, il appelle SetEvent
  • Le consommateur attend avec WaitForSingleObject ou WaitForMultipleObjects
  • À son réveil, il vide la file

5.2 Attendre work et stop en même temps avec WaitForMultipleObjects

Pour un worker simple, cette forme est facile à suivre.

HANDLE waits[2] = { _stopEvent, _workEvent };

for (;;)
{
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    if (rc == WAIT_OBJECT_0)
    {
        return;
    }

    if (rc != WAIT_OBJECT_0 + 1)
    {
        throw std::runtime_error("WaitForMultipleObjects failed.");
    }

    DrainQueue();
}

Les points clés de cet exemple sont au nombre de trois :

  • Sleep(1) a disparu
  • Le producteur appelle SetEvent quand un item arrive
  • Le worker attend simultanément sur stop et sur work

5.3 Au sein d’un même processus, WaitOnAddress est aussi une option

Si, au sein d’un même processus, tout ce que l’on veut est « attendre qu’une valeur change », WaitOnAddress est également une option solide.

Comme repère pour choisir, cela donne à peu près ceci :

  • Entre processus ou pour des cibles d’attente générales -> event / semaphore / waitable object
  • Changement de valeur léger au sein du même processus -> WaitOnAddress

6. Les cas où l’on utilise quand même un timer

6.1 Quand le temps lui-même est la condition

Bien sûr, il existe de vrais cas d’usage pour les timers.

  • Envoyer des métriques toutes les 5 secondes
  • Réessayer après 200 ms
  • Nettoyer un cache toutes les minutes
  • Attendre jusqu’à une échéance et traiter cela comme un timeout

Dans ces cas, ce que l’on veut attendre est vraiment le temps.

6.2 Utiliser un waitable timer

Si vous attendez « le temps lui-même » sous Windows, utiliser un waitable timer rend l’intention plus claire que d’empiler des appels à Sleep de manière désordonnée.

6.3 Ne pas faire de timeBeginPeriod une habitude

Quand la précision des attentes courtes sur timer commence à poser problème, on est tenté d’ajouter timeBeginPeriod(1). Mais ce ne devrait pas être le premier choix par défaut.

Il y a trois raisons à cela :

  1. Cela a un coût en énergie / performance
  2. Sur les versions récentes de Windows, le comportement est un peu plus complexe
  3. Cela signifie souvent que la cause racine n’a pas été corrigée

7. Liste de vérification pour la revue

  • Construisez-vous une boucle de vérification avec Sleep(1) / Thread.Sleep(1) / Task.Delay(1) ?
  • Faites-vous du polling par timer alors que ce que vous attendez réellement est l’arrivée dans une file, la fin d’une E/S ou une demande d’arrêt ?
  • La conception permet-elle au producteur / au côté complétion de signaler ?
  • Peut-on attendre stop et work ensemble en une seule attente ?
  • Pour un changement de valeur au sein du même processus, peut-on l’écrire avec WaitOnAddress ?
  • Là où un timer est utilisé, ce que vous voulez vraiment attendre est-il réellement « le temps » ?

8. Résumé

Sous Windows, une conception qui utilise des attentes courtes sur timer pour « vérifier à intervalle fixe » est inévitablement affectée par la granularité du timer et par l’ordonnanceur. De ce fait, Sleep(1) et les délais courts ne constituent pas une attente aussi précise qu’il n’y paraît.

À l’inverse, si ce que l’on veut vraiment attendre est un « événement » — l’arrivée d’un travail, la fin d’une E/S, une demande d’arrêt, un changement d’état — l’attente sur événement est le choix le plus naturel.

En résumé, tout tient en cette seule ligne :

Attendez sur un timer pour le temps, attendez sur un événement pour les événements.

Le simple fait de clarifier cette frontière apporte les bénéfices suivants :

  • la latence devient plus facile à raisonner
  • les réveils périodiques inutiles diminuent
  • la consommation d’énergie s’améliore
  • l’intention du code devient plus lisible

9. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Pourquoi Sleep(1) ne se réveille-t-il pas exactement 1 milliseconde plus tard sous Windows ?
Les attentes chronométrées sous Windows sont limitées par la granularité de l'horloge système, et avec les réglages courants, la résolution du timer de la plateforme est de l'ordre de 15,6 ms. Ainsi, même en spécifiant 1 ms, l'attente réelle est souvent bien plus grossière. De plus, quand le délai s'écoule, le thread devient simplement prêt à s'exécuter ; rien ne garantit qu'il obtienne le CPU immédiatement, car l'ordonnancement est influencé par les autres threads, les priorités, les états d'inactivité du CPU et les DPC/ISR.
Quand faut-il utiliser une attente sur événement plutôt qu'un timer sous Windows ?
Utilisez une attente sur événement dès que ce que vous attendez réellement est un événement plutôt qu'un point dans le temps : un travail qui arrive dans une file, une E/S qui se termine, une demande d'arrêt, ou un changement de valeur. Le côté où l'événement se produit signale, et le côté qui attend se réveille avec une raison déjà présente, ce qui est préférable pour la latence, le CPU et la consommation. Réservez les timers aux cas où le temps lui-même est réellement la condition, comme l'envoi de métriques toutes les 5 secondes ou une nouvelle tentative après 200 ms.
Quel est le problème avec le fait de scruter une file avec Sleep(1) ou Task.Delay(1) ?
Ce schéma pose trois problèmes : le thread se réveille périodiquement même quand la file est vide, la latence est tirée par la granularité du timer, et cela gaspille de l'énergie. La solution consiste à faire signaler un événement par le producteur immédiatement après l'ajout dans la file, tandis que le consommateur attend avec WaitForSingleObject ou WaitForMultipleObjects et vide la file à son réveil. En C#, une boucle async avec Task.Delay(1) paraît douce en apparence mais reste du polling dans son essence.
Faut-il utiliser timeBeginPeriod(1) pour rendre les attentes courtes plus précises ?
Ce ne devrait pas être votre premier choix par défaut, pour trois raisons : cela a un coût en énergie et en performance, son comportement sur les versions récentes de Windows est devenu plus complexe, et le fait d'en avoir besoin signifie souvent que la cause racine n'a pas été corrigée. Si ce que vous attendez est réellement un événement, basculez plutôt vers une attente sur événement. Si vous avez véritablement besoin d'attendre un point dans le temps, un waitable timer exprime l'intention plus clairement qu'un empilement de Sleep.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog