aujourd’hui on va voir un composant central du noyau Windows : le scheduler, c’est-à-dire le mécanisme qui décide quel thread s’exécute, sur quel processeur, dans quel ordre, et pendant combien de temps. C’est un sujet très important, parce qu’en pratique dès qu’un programme devient multithread, orienté performance, réactif, ou simplement un peu complexe, on finit par rencontrer directement les effets du scheduler.
Si on comprend mal le scheduler, on comprend mal :
- pourquoi un thread ne tourne pas quand on l’attend
- pourquoi une appli devient lente sous charge
- pourquoi certains threads “prennent toute la machine”
- pourquoi des blocages ou des famines CPU apparaissent
- pourquoi les réglages de priorité peuvent parfois aider… ou empirer la situation
1) Le modèle mental fondamental
La première idée à fixer, c’est celle-ci :
Code: Select all
Windows ne planifie pas les processus.
Windows planifie les threads.
Mais ce n’est jamais le processus lui-meme qui reçoit le CPU. Ce sont toujours ses threads.
En pratique, pour le scheduler, l’unité d’exécution réelle est donc le thread.
Les grands états que tu dois connaitre sont :
Code: Select all
Initialized
Ready
Standby
Running
Waiting
Transition
Terminated
Code: Select all
Ready
Running
Waiting
Terminated
Résumé utile :
- Ready : le thread peut tourner, il attend un CPU
- Standby : il a été choisi pour tourner très bientôt sur un processeur donné
- Running : il s’exécute réellement
- Waiting : il attend un objet, un délai, une I/O, un event, un mutex, etc.
- Transition : état intermédiaire, souvent lié à la mémoire ou à une préparation
- Terminated : le thread a fini
Le scheduler répond en permanence à plusieurs questions :
- quel thread READY doit être choisi
- sur quel CPU il faut l’exécuter
- faut-il préempter le thread courant
- combien de temps le thread peut garder le processeur
- faut-il favoriser le cache CPU ou la répartition de charge
3) Priorités : base du modèle de scheduling
Le scheduling Windows repose très fortement sur les priorités.
Le niveau logique global va de :
Code: Select all
1 → 31
- 1 à 15 : priorités variables / normales
- 16 à 31 : priorités temps réel
Règle fondamentale :
Code: Select all
un thread READY de priorité plus haute passe avant un thread READY de priorité plus basse
4) Classe de priorité du processus
Le processus possède une classe de priorité. Cette classe influence la priorité de base de ses threads.
Les classes importantes sont :
Code: Select all
IDLE_PRIORITY_CLASS
BELOW_NORMAL_PRIORITY_CLASS
NORMAL_PRIORITY_CLASS
ABOVE_NORMAL_PRIORITY_CLASS
HIGH_PRIORITY_CLASS
REALTIME_PRIORITY_CLASS
Code: Select all
BOOL SetPriorityClass(
HANDLE hProcess,
DWORD dwPriorityClass
);
DWORD GetPriorityClass(
HANDLE hProcess
);
Il faut bien comprendre que la classe de priorité du processus ne “remplace” pas la priorité du thread. Elle sert de base au calcul de la priorité de base des threads de ce processus.
5) Priorité du thread
Chaque thread possède lui aussi une priorité relative au sein de la classe de priorité du processus.
Constantes les plus connues :
Code: Select all
THREAD_PRIORITY_IDLE
THREAD_PRIORITY_LOWEST
THREAD_PRIORITY_BELOW_NORMAL
THREAD_PRIORITY_NORMAL
THREAD_PRIORITY_ABOVE_NORMAL
THREAD_PRIORITY_HIGHEST
THREAD_PRIORITY_TIME_CRITICAL
Code: Select all
BOOL SetThreadPriority(
HANDLE hThread,
int nPriority
);
int GetThreadPriority(
HANDLE hThread
);
6) Priorité de base et priorité dynamique
Il faut absolument distinguer deux notions :
Code: Select all
Base Priority
Dynamic Priority
- la classe de priorité du processus
- la priorité relative du thread
C’est la priorité dynamique qui peut être temporairement boostée ou réduite par le système selon certains événements.
Règle importante :
- pour les threads à priorité variable, le scheduler utilise une priorité dynamique
- pour les threads en temps réel, le comportement est plus rigide et beaucoup plus dangereux si mal utilisé
Windows utilise des boosts temporaires de priorité pour améliorer la réactivité ou éviter certaines formes de famine.
Cas fréquents :
- fin d’I/O
- réveil d’un thread GUI
- sortie d’un wait sur certains objets
- éviter qu’un thread reste trop longtemps sans CPU
Code: Select all
un thread qui se réveille peut recevoir temporairement une priorité plus favorable
APIs utiles à connaitre :
Code: Select all
BOOL SetThreadPriorityBoost(
HANDLE hThread,
BOOL DisablePriorityBoost
);
BOOL GetThreadPriorityBoost(
HANDLE hThread,
PBOOL pDisablePriorityBoost
);
8) Ready queues
Le scheduler maintient des files de threads prêts à s’exécuter.
Vision classique :
Code: Select all
32 ready queues
1 par niveau de priorité
Code: Select all
on choisit le thread READY de plus haute priorité
Au niveau noyau, cette logique est liée aux structures internes du scheduler et des processeurs, notamment autour des processeurs logiques et de leurs structures de contrôle.
9) Round Robin
Quand plusieurs threads de meme priorité sont READY, Windows applique une logique de rotation.
Code: Select all
Round Robin scheduling
Autrement dit :
- un thread tourne
- son quantum expire
- il retourne READY si rien ne le bloque
- un autre thread de meme priorité peut passer
10) Quantum
Le quantum est la tranche de temps maximale qu’un thread peut garder avant qu’un changement de scheduling soit envisagé.
Le quantum n’est pas une constante universelle gravée dans le marbre. Il dépend de plusieurs facteurs historiques et de configuration système. Il a varié selon les éditions, les politiques serveur/client et certains réglages.
L’idée importante à retenir n’est pas la valeur exacte, mais le rôle :
- le quantum limite la monopolisation du CPU
- quand il expire, le thread peut être réinséré en file READY
- un thread plus prioritaire peut évidemment préempter avant la fin du quantum
Code: Select all
thread tourne
↓
quantum expire
↓
retour en ready queue si toujours exécutable
Le scheduler Windows est préemptif.
Cela signifie qu’un thread courant peut être interrompu si un thread plus prioritaire devient READY.
Code: Select all
Higher priority thread → preemption
Exemple typique :
- thread A tourne à une priorité moyenne
- thread B, plus prioritaire, se réveille après une I/O
- thread A est préempté
- thread B prend le CPU
12) Starvation
La starvation, ou famine CPU, se produit quand un thread reste prêt mais obtient trop rarement le processeur parce que d’autres plus prioritaires lui passent toujours devant.
Exemple typique :
- un thread de faible priorité est READY
- des threads plus prioritaires arrivent sans cesse
- le thread faible priorité ne tourne presque jamais
Les boosts dynamiques existent justement aussi pour réduire certains cas de starvation, mais ils ne réparent pas toutes les mauvaises conceptions.
13) Scheduling multiprocesseur
Sur une machine multiprocesseur, le problème devient plus riche.
Le scheduler doit alors raisonner non seulement en priorité, mais aussi en distribution entre cœurs et processeurs logiques.
Points importants :
- chaque CPU logique possède son contexte de scheduling
- le scheduler cherche à choisir le bon thread pour le bon CPU
- il tient compte de la charge
- il tient compte de l’affinité
- il tient compte de la localité du cache
14) Localité cache, migration et équilibrage
Un thread qui a déjà tourné sur un CPU donné peut bénéficier de la chaleur du cache de ce CPU. Le scheduler essaie donc souvent d’éviter des migrations inutiles.
Mais à l’inverse, il doit aussi équilibrer la charge entre processeurs.
Il y a donc toujours une tension entre :
- localité processeur / cache affinity
- équilibrage global de la charge
15) Ideal Processor
Chaque thread peut avoir un processeur idéal, c’est-à-dire une préférence de placement.
APIs importantes :
Code: Select all
DWORD SetThreadIdealProcessor(
HANDLE hThread,
DWORD dwIdealProcessor
);
BOOL SetThreadIdealProcessorEx(
HANDLE hThread,
PPROCESSOR_NUMBER lpIdealProcessor,
PPROCESSOR_NUMBER lpPreviousIdealProcessor
);
BOOL GetThreadIdealProcessorEx(
HANDLE hThread,
PPROCESSOR_NUMBER lpIdealProcessor
);
Structure importante :
Code: Select all
PROCESSOR_NUMBER
- Group
- Number
- Reserved
16) Hard Affinity
L’affinité dure limite les CPU sur lesquels un thread ou un processus a le droit de tourner.
APIs importantes :
Code: Select all
BOOL SetProcessAffinityMask(
HANDLE hProcess,
DWORD_PTR dwProcessAffinityMask
);
BOOL GetProcessAffinityMask(
HANDLE hProcess,
PDWORD_PTR lpProcessAffinityMask,
PDWORD_PTR lpSystemAffinityMask
);
DWORD_PTR SetThreadAffinityMask(
HANDLE hThread,
DWORD_PTR dwThreadAffinityMask
);
Il faut l’utiliser avec précaution, parce qu’une affinité mal pensée peut :
- réduire les performances
- nuire au load balancing
- créer des goulots d’étranglement
Sur les machines ayant beaucoup de CPU logiques, Windows utilise la notion de Processor Groups.
Règle générale :
Code: Select all
1 group = max 64 CPU logiques
APIs importantes :
Code: Select all
BOOL GetProcessGroupAffinity(
HANDLE hProcess,
PUSHORT GroupCount,
PUSHORT GroupArray
);
BOOL SetThreadGroupAffinity(
HANDLE hThread,
const GROUP_AFFINITY* GroupAffinity,
PGROUP_AFFINITY PreviousGroupAffinity
);
BOOL GetThreadGroupAffinity(
HANDLE hThread,
PGROUP_AFFINITY GroupAffinity
);
Code: Select all
GROUP_AFFINITY
- Mask
- Group
18) CPU Sets
Windows moderne propose aussi les CPU Sets, plus souples que certains anciens mécanismes d’affinité.
APIs importantes :
Code: Select all
GetSystemCpuSetInformation()
SetProcessDefaultCpuSets()
SetThreadSelectedCpuSets()
GetProcessDefaultCpuSets()
GetThreadSelectedCpuSets()
SetThreadSelectedCpuSetMasks()
Code: Select all
SYSTEM_CPU_SET_INFORMATION
C’est très utile dans certains scénarios de performance, de conteneurisation, ou d’isolation logique.
19) Background Mode
Windows permet aussi de basculer un processus en mode background.
Exemple :
Code: Select all
SetPriorityClass(
GetCurrentProcess(),
PROCESS_MODE_BACKGROUND_BEGIN
);
Code: Select all
PROCESS_MODE_BACKGROUND_BEGIN
PROCESS_MODE_BACKGROUND_END
THREAD_MODE_BACKGROUND_BEGIN
THREAD_MODE_BACKGROUND_END
Code: Select all
SetPriorityClass()
SetThreadPriority()
C’est utile pour des tâches discrètes, peu urgentes, qui ne doivent pas gêner l’interactivité.
20) Suspend / Resume
Ces APIs permettent de suspendre ou reprendre l’exécution de threads, et parfois d’un processus entier via l’API native.
APIs importantes :
Code: Select all
DWORD SuspendThread(HANDLE hThread);
DWORD ResumeThread(HANDLE hThread);
NtSuspendProcess()
NtResumeProcess()
- suspendre un thread peut figer un verrou détenu par ce thread
- suspendre au mauvais moment peut provoquer des blocages logiques
- NtSuspendProcess est puissant, mais plus brutal au niveau global
21) Sleep, yield et cession volontaire du CPU
APIs importantes :
Code: Select all
Sleep(ms)
SleepEx(ms, alertable)
SwitchToThread()
YieldProcessor()
- Sleep : le thread passe en attente pendant au moins la durée indiquée
- Sleep(0) : cède le reste du quantum à un thread prêt de priorité appropriée
- SwitchToThread : tente de céder le CPU à un autre thread prêt sur le processeur courant
- YieldProcessor : hint très léger pour certaines boucles d’attente active
22) Waits et impact sur le scheduler
Une grande partie du scheduling réel tourne autour des waits.
Quand un thread appelle par exemple :
- WaitForSingleObject
- WaitForMultipleObjects
- Sleep
- MsgWaitForMultipleObjects
- SignalObjectAndWait
C’est un point fondamental :
Code: Select all
un thread en attente ne consomme pas de CPU
23) Structures et notions noyau à connaitre
Même si en user-mode on manipule surtout des APIs, il faut connaitre quelques noms du noyau pour bien lire la doc avancée, WinDbg, Windows Internals ou certains outils.
Noms importants à connaitre :
Code: Select all
KTHREAD
ETHREAD
KPROCESS
EPROCESS
KPRCB
DISPATCHER_HEADER
GROUP_AFFINITY
PROCESSOR_NUMBER
SYSTEM_CPU_SET_INFORMATION
CONTEXT
- KTHREAD : structure noyau centrale du thread coté scheduler
- ETHREAD : enveloppe exécutive autour du thread
- KPROCESS / EPROCESS : structures liées au processus
- KPRCB : contrôle local par processeur, très important dans le scheduling multiprocesseur
- DISPATCHER_HEADER : base commune de nombreux objets de synchronisation
24) APIs utiles autour de la topologie CPU
Pour comprendre et interroger la machine, plusieurs APIs valent la peine d’être connues :
Code: Select all
GetSystemInfo()
GetNativeSystemInfo()
GetLogicalProcessorInformation()
GetLogicalProcessorInformationEx()
GetSystemCpuSetInformation()
GetCurrentProcessorNumber()
GetCurrentProcessorNumberEx()
GetNumaHighestNodeNumber()
GetNumaProcessorNodeEx()
Code: Select all
SYSTEM_INFO
SYSTEM_LOGICAL_PROCESSOR_INFORMATION
SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX
PROCESSOR_RELATIONSHIP
NUMA_NODE_RELATIONSHIP
CACHE_RELATIONSHIP
GROUP_RELATIONSHIP
PROCESSOR_NUMBER
25) NUMA et scheduler
Sur certaines machines, la topologie mémoire est NUMA.
Cela signifie que tous les accès mémoire ne coûtent pas pareil selon le nœud NUMA auquel le CPU et la mémoire sont liés.
Le scheduler et l’OS essaient d’en tenir compte, surtout sur les grosses machines.
APIs utiles à connaitre de nom :
Code: Select all
GetNumaHighestNodeNumber()
GetNumaNodeProcessorMaskEx()
GetNumaProcessorNodeEx()
VirtualAllocExNuma()
SetThreadIdealProcessorEx()
26) APIs utiles autour des priorités et infos thread
En complément des APIs déjà vues, il faut aussi connaitre :
Code: Select all
GetThreadPriority()
GetThreadPriorityBoost()
SetThreadPriorityBoost()
GetPriorityClass()
SetPriorityClass()
GetThreadTimes()
GetProcessTimes()
GetThreadIOPendingFlag()
SetThreadInformation()
GetThreadInformation()
QueryThreadCycleTime()
QueryProcessCycleTime()
- GetThreadTimes / GetProcessTimes donnent des temps noyau et user utiles pour le diagnostic
- QueryThreadCycleTime / QueryProcessCycleTime sont utiles pour mesurer de l’activité CPU en cycles
- SetThreadInformation / GetThreadInformation donnent accès à certaines options modernes selon la classe d’information
Le niveau temps réel existe, mais il ne faut surtout pas le prendre à la légère.
On parle notamment de :
- REALTIME_PRIORITY_CLASS
- THREAD_PRIORITY_TIME_CRITICAL
- niveaux 16 à 31
- affamer des threads normaux
- rendre la machine peu réactive
- bloquer des composants essentiels si des waits ou boucles sont mal pensés
28) Ce qu’il faut retenir sur les performances
Quand on observe des performances CPU étranges, plusieurs facteurs scheduler doivent venir à l’esprit :
- priorité trop haute ou trop basse
- quantum et round robin entre threads de meme niveau
- préemption par des threads plus prioritaires
- affinité mal réglée
- migration trop fréquente entre CPU
- starvation
- trop de threads pour trop peu de cœurs
- boucles actives au lieu de waits propres
29) Erreurs classiques
Parmi les erreurs fréquentes :
- croire que Windows planifie les processus
- abuser de SetThreadPriority ou REALTIME_PRIORITY_CLASS
- mettre une hard affinity sans vraie raison
- utiliser Sleep comme primitive de synchronisation
- suspendre des threads sans réfléchir aux verrous qu’ils détiennent
- ignorer les processor groups sur grosses machines
- confondre processeur idéal et affinité dure
- supposer qu’un thread “qui existe” a forcément du CPU
Pour les priorités :
Code: Select all
GetPriorityClass
SetPriorityClass
GetThreadPriority
SetThreadPriority
GetThreadPriorityBoost
SetThreadPriorityBoost
Code: Select all
SetProcessAffinityMask
GetProcessAffinityMask
SetThreadAffinityMask
SetThreadIdealProcessor
SetThreadIdealProcessorEx
GetThreadIdealProcessorEx
GetCurrentProcessorNumber
GetCurrentProcessorNumberEx
Code: Select all
GetProcessGroupAffinity
SetThreadGroupAffinity
GetThreadGroupAffinity
GetSystemCpuSetInformation
SetProcessDefaultCpuSets
SetThreadSelectedCpuSets
Code: Select all
SuspendThread
ResumeThread
NtSuspendProcess
NtResumeProcess
Sleep
SleepEx
SwitchToThread
YieldProcessor
Code: Select all
GetSystemInfo
GetNativeSystemInfo
GetLogicalProcessorInformation
GetLogicalProcessorInformationEx
GetThreadTimes
GetProcessTimes
QueryThreadCycleTime
QueryProcessCycleTime
Code: Select all
GROUP_AFFINITY
PROCESSOR_NUMBER
SYSTEM_INFO
SYSTEM_LOGICAL_PROCESSOR_INFORMATION
SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX
PROCESSOR_RELATIONSHIP
GROUP_RELATIONSHIP
NUMA_NODE_RELATIONSHIP
CACHE_RELATIONSHIP
CONTEXT
KTHREAD
ETHREAD
KPRCB
Le scheduler Windows repose sur quelques idées simples à énoncer, mais très riches dans leurs conséquences :
- Windows planifie les threads, pas les processus
- la priorité est la base du choix
- les ready queues organisent les threads prêts
- le quantum limite la monopolisation du CPU
- le round robin partage le CPU entre threads de meme priorité
- la préemption donne immédiatement la main aux threads plus prioritaires
- sur les machines modernes, affinité, groupes, cache et topologie comptent énormément
Et surtout, ça permet de passer d’un raisonnement du type :
Code: Select all
"mon thread ne tourne pas, Windows bug"
Code: Select all
"quel est son état, sa priorité, son affinité, sa concurrence, son wait, son CPU cible, et qu’est-ce que le scheduler est en train de faire avec lui ?"
