Réaliser des événements précis en temps réel d’une milliseconde sans souffrir de la planification des threads

Problème

Je crée une application C # WPF basée sur Windows 7 en utilisant .Net 4.5 , et l’une de ses principales fonctionnalités est d’ appeler certaines fonctions qui s’interfacent avec du matériel personnalisé avec un ensemble de temps de cycle définis par l’utilisateur. Par exemple, l’utilisateur peut choisir deux fonctions à appeler toutes les 10 ou 20 millisecondes et une autre toutes les 500 millisecondes. La plus petite durée de cycle que l’utilisateur peut choisir est de 1 milliseconde.

Au début, il semblait que les temps étaient exacts et que les fonctions étaient appelées toutes les 1 millisecondes, selon les besoins. Mais nous avons remarqué plus tard qu’environ 1 à 2% des timings n’étaient pas précis, que certaines fonctions étaient appelées avec 5 millisecondes de retard et que d’autres pouvaient atteindre 100 millisecondes tardivement. Même avec des temps de cycle supérieurs à 1 ms, le problème était que le thread dormait au moment où il aurait dû appeler la fonction externe (une fonction de 20 ms peut être appelée avec 50 ms de retard car le thread ne dormait pas et n’appelait pas la fonction)

Après parsing, nous avons conclu que ces retards étaient sporadiques, sans motif notable, et que la principale raison possible de ces retards était la planification du système d’exploitation et le changement de contexte du thread, c’est-à-dire que .

Windows 7 n’étant pas un RTOS, nous devons trouver un moyen de contourner ce problème. Mais nous soaps avec certitude que ce problème est réparable sur Windows, car nous utilisons d’autres outils avec des fonctionnalités similaires capables de respecter ces contraintes de temps avec une tolérance maximale aux erreurs de 0,7 ms.

Notre application est multithreadée avec un maximum de 30 threads en cours d’exécution, son pic d’utilisation actuel est d’environ 13%

Solutions essayées

Nous avons essayé beaucoup de choses différentes, le chronométrage a été principalement mesuré à l’aide du chronomètre et IsHighResolution était vrai (d’autres timers ont été utilisés mais nous n’avons pas remarqué beaucoup de différence):

  1. Créer un thread séparé et lui donner une priorité élevée
    Résultat: inefficace (en utilisant à la fois le terrible Thread.Sleep() , et sans et en utilisant le sondage en continu)

  2. Utiliser une tâche C # (pool de threads)
    Résultat: très peu d’amélioration

  3. En utilisant une timer multimédia avec une périodicité de 1 ms
    Résultat: inefficace ou pire , les timers multimédias sont précises lors du réveil du système d’exploitation, mais le système d’exploitation peut choisir d’exécuter un autre thread, sans garantie de 1ms, mais même dans ce cas, les retards pourraient être beaucoup plus importants

  4. Création d’un projet C # autonome distinct contenant juste une boucle while et un chronomètre
    Résultat: la plupart du temps, la précision était excellente, même en microsecondes, mais le thread dort parfois

  5. Point 4 répété, mais définissez la priorité du processus sur Realtime / High
    Résultat: de très bons chiffres , presque pas un seul message a eu un retard significatif.

Conclusion:

A partir du précédent, nous avons constaté que nous avions 5 pistes d’action possibles, mais nous avons besoin d’une personne ayant une expérience de ces problèmes pour nous orienter dans la bonne direction:

  1. Notre outil peut être optimisé et les threads sont gérés d’une manière ou d’une autre pour assurer l’exigence en temps réel de 1 ms. Une partie de l’optimisation consiste peut-être à définir la priorité de processus de l’outil sur élevée ou temps réel, mais cela ne semble pas être une décision judicieuse, car les utilisateurs peuvent utiliser plusieurs autres outils en même temps.

  2. Nous divisons notre outil en deux processus, l’un contenant l’interface graphique et toutes les opérations non critiques, l’autre contenant le minimum d’opérations critiques et le définissant comme priorité temps réel / temps réel, et utilisant IPC (comme WCF) pour communication entre les processus. Cela pourrait nous être bénéfique de deux manières

    1. Moins de probabilité de famine pour d’autres processus, car les opérations sont beaucoup moins nombreuses.

    2. Le processus aurait moins de threads, donc (beaucoup moins ou pas de probabilité)

Note: Les deux points suivants traiteront de l’espace du kernel, notez que j’ai peu d’informations sur l’espace du kernel et sur les pilotes d’écriture, donc je pourrais faire des hypothèses erronées sur la façon dont il pourrait être utilisé.

  1. La création d’un pilote dans l’espace du kernel qui utilise des interruptions de niveau inférieur toutes les 1 ms pour déclencher un événement qui force le thread à exécuter sa tâche désignée dans le processus.

  2. En déplaçant les composants critiques du temps vers l’espace du kernel, toute interface avec le corps principal des programmes pourrait être réalisée via des API et des rappels.

  3. Peut-être que tout cela n’est pas valide, et nous pourrions avoir besoin d’utiliser une extension Windows RTOS comme la plate-forme IntervalZero RTOS?

La question elle-même

Il y a deux réponses que je recherche, et j’espère qu’elles sont soutenues par de bonnes sources.

  1. Est-ce vraiment un problème de changement de fil et de contexte? Ou avons-nous manqué quelque chose tout ce temps?

  2. Laquelle des 5 options est garantie pour résoudre ce problème, et si plusieurs le sont, quel est le plus facile? Si aucune de ces options ne peut le réparer, que peut-il? S’il vous plaît rappelez-vous que les autres outils que nous avons repérés atteignent effectivement la précision de synchronisation requirejse sur Windows, et lorsque le processeur est sous forte charge, une ou deux fois sur 100 000 pourraient être désactivées de moins de 2 millisecondes, ce qui est très acceptable.

Laquelle des 5 options est garantie pour résoudre ce problème?

Cela dépend de la précision que vous essayez d’atteindre. Si vous visez par exemple +/- 1ms, vous avez une chance raisonnable de le faire sans les points 3) à 5). La combinaison des points 1) et 2) est la voie à suivre:

  • Divisez votre code en parties critiques en termes de temps et de pièces critiques en termes de temps (GUI, etc.) et placez-les dans des processus distincts. Laissons-les communiquer par IPC décent (pipes, mémoire partagée, etc.).
  • Élevez la classe de priorité de processus et la priorité de thread du processus critique de temps. Malheureusement, l’ énumération c # ThreadPriority n’autorise que THREAD_PRIORITY_HIGHEST(2) comme priorité maximale. Par conséquent, vous devez examiner la fonction SetThreadPriority qui permet d’accéder à THREAD_PRIORITY_TIME_CRITICAL (15) . La propriété Process :: PriorityClass permet d’accéder à REALTIME_PRIORITY_CLASS (24) . Remarque: le code exécuté sur ces priorités repoussera tout autre code. Vous devriez faire le code avec très peu de calcul et très sûr.
  • Utilisez la propriété ProcessThread :: ProcessorAffinity pour ajuster l’utilisation correcte du kernel. Astuce: vous voudrez peut-être garder vos threads critiques de la CPU_0 (valeur de propriété 0x0001) car le kernel Windows préfère cette CPU pour des opérations spécifiques. Exemple: Sur une plate-forme avec 4 processeurs logiques, spécifiez la propriété ProcessoreAffinity avec 0x000E pour exclure CPU_0.
  • La résolution de la timer du système est souvent définie par d’autres applications. Par conséquent, il est uniquement prévisible lorsque vous dictez la résolution de la timer du système. Certaines applications / pilotes définissent même la résolution de la timer à 0,5 ms. Cela peut être au-delà de vos parameters et peut entraîner des problèmes dans votre application. Voir cette réponse sur comment définir la résolution de la timer à 0.5ms. (Remarque: le support de cette résolution dépend de la plate-forme.)

Remarques générales: Tout dépend de la charge. Windows peut très bien faire malgré le fait qu’il ne s’agit pas d’un “OS temps réel”. Cependant, les systèmes temps réel reposent également sur une faible charge. Rien n’est garanti, même sur un RT-OS, lorsqu’il est très chargé.

Je soupçonne que rien de ce que vous faites, en mode utilisateur, à la priorité ou à l’affinité d’un thread ne garantit le comportement recherché, donc je pense que vous pourriez avoir besoin de vos options 3 ou 4, ce qui signifie écrire un pilote en mode kernel.

En mode kernel, il y a la notion d’IRQL, où le code déclenché pour s’exécuter à des niveaux supérieurs préempte le code s’exécutant aux niveaux inférieurs. Le code en mode utilisateur s’exécute à IRQL 0, donc tout code en mode kernel à un niveau supérieur a priorité. Le planificateur de thread lui-même s’exécute à un niveau élevé 2, je crois (appelé DISPATCH_LEVEL), de sorte qu’il peut préempter tout code de mode utilisateur planifié de toute priorité, y compris, je pense, REALTIME_PRIORITY_CLASS. Les interruptions matérielles, y compris les timers, sont encore plus élevées.

Une timer matérielle invoquera son gestionnaire d’interruptions à peu près aussi précisément que la résolution du minuteur, s’il existe un processeur / cœur disponible à une IRQL inférieure (les gestionnaires d’interruptions de niveau supérieur ne s’exécutant pas).

S’il y a beaucoup de travail à faire, il ne faut pas le faire dans le gestionnaire d’interruption (IRQL> DISPATCH_LEVEL), mais utiliser le gestionnaire d’interruption pour planifier le plus gros travail à DISPATCH_LEVEL en utilisant un appel de procédure différé ( DPC). ), qui empêche toujours le programmateur de thread d’interférer, mais n’empêche pas les autres gestionnaires d’interruption de gérer leurs interruptions matérielles.

Un problème probable avec votre option 3 est que déclencher un événement pour réveiller un thread pour exécuter du code en mode utilisateur à IRQL 0 signifie qu’il permet à nouveau au programmateur de thread de décider quand le code de mode utilisateur sera exécuté. Vous devrez peut-être faire votre travail en temps réel en mode kernel sur DISPATCH_LEVEL.

Un autre problème est que les interruptions du feu sans tenir compte du contexte de processus que le cœur du processeur était en cours d’exécution. Ainsi, lorsque le minuteur se déclenche, le gestionnaire s’exécute probablement dans le contexte d’un processus sans rapport avec le vôtre. Vous devrez donc peut-être effectuer votre travail dans un pilote en mode kernel, en utilisant l’espace mémoire du kernel, indépendamment de votre processus, puis renvoyer les résultats à votre application plus tard, lorsqu’elle reprendra son fonctionnement et interagira avec le pilote. . (Les applications peuvent interagir avec les pilotes en transmettant des tampons via l’API DeviceIoControl.)

Je ne suggère pas que vous implémentiez un gestionnaire d’interruption de timer matérielle; le système d’exploitation le fait déjà. Utilisez plutôt les services du minuteur du kernel pour appeler votre code en fonction de la gestion du système d’exploitation de l’interruption du minuteur. Voir KeSetTimer et ExSetTimer . Les deux peuvent rappeler votre code à DISPATCH_LEVEL après le déclenchement de la timer.

Et (même en mode kernel) la résolution du temporisateur du système peut, par défaut, être trop grossière pour votre exigence de 1 ms.

https://msdn.microsoft.com/en-us/library/windows/hardware/dn265247(v=vs.85).aspx

Par exemple, pour Windows exécuté sur un processeur x86, l’intervalle par défaut entre les ticks de l’horloge du système est généralement d’environ 15 millisecondes.

Pour une meilleure résolution, vous pouvez

  1. changer la résolution de l’horloge du système

À partir de Windows 2000, un pilote peut appeler la routine ExSetTimerResolution pour modifier l’intervalle de temps entre les interruptions successives de l’horloge système. Par exemple, un pilote peut appeler cette routine pour modifier l’horloge système de son taux par défaut à son taux maximal afin d’améliorer la précision de la timer. Cependant, l’utilisation d’ExSetTimerResolution présente plusieurs inconvénients par rapport à l’utilisation de temporisateurs haute résolution créés par ExAllocateTimer.

  1. Utilisez des API en mode kernel plus récentes pour les temporisateurs haute résolution qui gèrent automatiquement la résolution d’horloge.

À partir de Windows 8.1, les pilotes peuvent utiliser les routines ExXxxTimer pour gérer les timers haute résolution. La précision d’une horloge haute résolution est limitée uniquement par la résolution maximale prise en charge de l’horloge système. En revanche, les temporisateurs limités à la résolution d’horloge par défaut du système sont nettement moins précis.

Cependant, les timers à haute résolution requièrent au moins temporairement des interruptions d’horloge du système à un taux plus élevé, ce qui a tendance à augmenter la consommation d’énergie. Ainsi, les pilotes doivent utiliser des temporisateurs haute résolution uniquement lorsque la précision de la timer est essentielle, et utiliser des temporisateurs de résolution par défaut dans tous les autres cas.