La synchronisation en mode noyau permet de protéger des données partagées et de coordonner l'exécution de plusieurs threads ou chemins d'exécution dans un driver. Dans le noyau Windows, le choix d'une primitive de synchronisation dépend notamment du contexte d'exécution, du niveau IRQL, de la durée pendant laquelle la ressource doit rester protégée et de la possibilité ou non de bloquer le thread courant.
Une primitive de synchronisation mal choisie peut provoquer des corruptions de données, des deadlocks, des attentes impossibles à satisfaire ou un crash système.
1. Le problème de concurrence
Un driver peut être exécuté simultanément depuis plusieurs contextes : plusieurs threads peuvent entrer dans une routine de dispatch, plusieurs processeurs peuvent exécuter du code du driver en parallèle et certaines routines peuvent être appelées à des niveaux IRQL différents.
Une opération apparemment simple telle que :
Code: Select all
Counter++;
- lecture de Counter ;
- incrément dans un registre ;
- écriture du nouveau résultat en mémoire.
Exemple :
Code: Select all
Counter = 0
CPU 0 lit 0
CPU 1 lit 0
CPU 0 calcule 1
CPU 1 calcule 1
CPU 0 écrit 1
CPU 1 écrit 1
C'est une race condition.
La synchronisation sert donc à garantir qu'une ressource partagée est manipulée de manière cohérente.
2. Opérations atomiques Interlocked
Pour les opérations simples sur des entiers ou des pointeurs, Windows fournit la famille des fonctions
Code: Select all
Interlocked*Ces opérations sont atomiques vis-à-vis des autres processeurs et évitent d'utiliser un verrou plus lourd pour une modification très courte.
Exemples courants :
Code: Select all
InterlockedIncrement
InterlockedDecrement
InterlockedExchange
InterlockedExchangeAdd
InterlockedCompareExchange
InterlockedCompareExchangePointer
Code: Select all
volatile LONG Counter = 0;
InterlockedIncrement(&Counter);
Interlocked est particulièrement adapté aux compteurs, flags, références et petits changements d'état.
Il ne remplace cependant pas un verrou lorsqu'une section critique contient plusieurs opérations qui doivent être considérées comme un ensemble indivisible.
3. Spin locks
Un spin lock protège une ressource partagée sans endormir le thread qui attend.
Si le verrou est déjà détenu, le processeur attend activement dans une boucle jusqu'à ce que le verrou soit libéré.
C'est donc une primitive adaptée aux sections critiques très courtes.
Structure :
Code: Select all
KSPIN_LOCK Lock;
Code: Select all
KeInitializeSpinLock(&Lock);
Code: Select all
KIRQL oldIrql;
KeAcquireSpinLock(&Lock, &oldIrql);
// accès à la ressource protégée
KeReleaseSpinLock(&Lock, oldIrql);
Code: Select all
KeAcquireSpinLockCode: Select all
DISPATCH_LEVELLors de la libération,
Code: Select all
KeReleaseSpinLockConséquences importantes :
- la section protégée doit être très courte ;
- aucune attente bloquante ne doit être effectuée ;
- le code et les données utilisés doivent être accessibles à cet IRQL ;
- la mémoire paginée ne doit pas être utilisée dans cette section ;
- le verrou doit toujours être libéré.
4. Spin locks à DISPATCH_LEVEL
Si le code s'exécute déjà à
Code: Select all
DISPATCH_LEVELLes variantes correspondantes peuvent être utilisées :
Code: Select all
KeAcquireSpinLockAtDpcLevel(&Lock);
// accès protégé
KeReleaseSpinLockFromDpcLevel(&Lock);
Elles sont notamment pertinentes dans du code exécuté par une DPC.
5. Queued spin locks
Windows fournit également des queued spin locks.
Ils sont destinés à réduire certains coûts lorsque plusieurs processeurs sont fortement en compétition pour le même verrou.
Au lieu que tous les processeurs attendent directement sur la même variable de verrouillage, les attentes peuvent être organisées sous forme de file.
Une structure de handle est utilisée lors de l'acquisition :
Code: Select all
KLOCK_QUEUE_HANDLE Handle;
- acquérir le verrou ;
- accéder très brièvement à la ressource ;
- libérer le verrou.
6. Objets dispatcher du noyau
Windows possède plusieurs objets de synchronisation appelés dispatcher objects.
Un dispatcher object possède généralement un état :
- signalé ;
- non signalé.
Parmi les objets courants :
- event ;
- mutex ;
- sémaphore ;
- timer ;
- thread ;
- processus.
Code: Select all
KeWaitForSingleObject
KeWaitForMultipleObjects
Cette fonction permet à un thread d'attendre qu'un objet dispatcher devienne signalé.
Prototype conceptuel :
Code: Select all
KeWaitForSingleObject(
Object,
WaitReason,
WaitMode,
Alertable,
Timeout
);
Code: Select all
KeWaitForSingleObject(
&MyMutex,
Executive,
KernelMode,
FALSE,
NULL
);
Code: Select all
NULLUn timeout peut également être fourni sous forme d'une valeur
Code: Select all
LARGE_INTEGERLes délais relatifs utilisent une valeur négative exprimée en unités de 100 nanosecondes.
Exemple d'attente d'environ une seconde :
Code: Select all
LARGE_INTEGER timeout;
timeout.QuadPart = -10LL * 1000 * 1000;
KeWaitForSingleObject(
Object,
Executive,
KernelMode,
FALSE,
&timeout
);
8. KeWaitForMultipleObjects
Code: Select all
KeWaitForMultipleObjectsDeux comportements principaux existent :
- : l'attente se termine lorsqu'au moins un objet est signalé ;
Code: Select all
WaitAny - : l'attente se termine lorsque tous les objets sont signalés.
Code: Select all
WaitAll
9. Events noyau
Un event représente une condition qui peut être signalée.
Structure :
Code: Select all
KEVENT Event;
Code: Select all
KeInitializeEvent(
&Event,
NotificationEvent,
FALSE
);
- ;
Code: Select all
NotificationEvent - .
Code: Select all
SynchronizationEvent
Code: Select all
NotificationEventUn
Code: Select all
SynchronizationEventSignalement :
Code: Select all
KeSetEvent(&Event, IO_NO_INCREMENT, FALSE);
Code: Select all
KeResetEvent(&Event);
10. Mutex noyau
Le noyau Windows fournit l'objet
Code: Select all
KMUTEXUn mutex garantit qu'un seul thread propriétaire accède à une ressource à un instant donné.
Déclaration :
Code: Select all
KMUTEX Mutex;
Code: Select all
KeInitializeMutex(&Mutex, 0);
Code: Select all
KeWaitForSingleObject(
&Mutex,
Executive,
KernelMode,
FALSE,
NULL
);
Code: Select all
KeReleaseMutex(&Mutex, FALSE);
Le mutex est destiné à des contextes où le blocage du thread est autorisé.
11. Fast mutex
Windows fournit également un
Code: Select all
FAST_MUTEXIl est optimisé pour des exclusions mutuelles simples en mode noyau.
Déclaration :
Code: Select all
FAST_MUTEX FastMutex;
Code: Select all
ExInitializeFastMutex(&FastMutex);
Code: Select all
ExAcquireFastMutex(&FastMutex);
Code: Select all
ExReleaseFastMutex(&FastMutex);
Ils conviennent lorsque l'on veut protéger une section critique dans un contexte où une attente est acceptable.
12. Sémaphores noyau
Un sémaphore maintient un compteur.
Il permet à plusieurs threads d'accéder simultanément à une ressource, jusqu'à une limite définie.
Structure :
Code: Select all
KSEMAPHORE Semaphore;
Code: Select all
KeInitializeSemaphore(
&Semaphore,
InitialCount,
Limit
);
Code: Select all
KSEMAPHORE Semaphore;
KeInitializeSemaphore(
&Semaphore,
2,
2
);
L'attente se fait avec :
Code: Select all
KeWaitForSingleObject(
&Semaphore,
Executive,
KernelMode,
FALSE,
NULL
);
Code: Select all
KeReleaseSemaphore(
&Semaphore,
IO_NO_INCREMENT,
1,
FALSE
);
- un pool limité de ressources ;
- une file de travail ;
- un nombre maximal d'opérations concurrentes.
Un timer noyau est également un dispatcher object.
Structure :
Code: Select all
KTIMER Timer;
Code: Select all
KeInitializeTimer(&Timer);
Les timers peuvent être :
- one-shot ;
- périodiques.
14. Executive Resources
Une executive resource, représentée par
Code: Select all
ERESOURCEElle permet :
- plusieurs lecteurs simultanés ;
- un seul écrivain exclusif.
Le principe est :
Code: Select all
ERESOURCE Resource;
Code: Select all
ExInitializeResourceLite(&Resource);
Code: Select all
ExAcquireResourceSharedLite(
&Resource,
TRUE
);
Code: Select all
ExAcquireResourceExclusiveLite(
&Resource,
TRUE
);
Code: Select all
ExReleaseResourceLite(&Resource);
15. Régions critiques et APC
Certaines opérations de synchronisation doivent empêcher la livraison d'APC noyau normaux pendant une section critique.
Windows permet d'entrer et sortir d'une région critique.
Principe :
Code: Select all
KeEnterCriticalRegion();
// opération protégée
KeLeaveCriticalRegion();
Le but est de contrôler l'interruption logique du thread par certains APC pendant l'exécution d'une séquence sensible.
16. Deadlocks
Un deadlock apparaît lorsque plusieurs chemins d'exécution attendent mutuellement des ressources qui ne seront jamais libérées.
Exemple classique :
Code: Select all
Thread A :
acquiert Lock1
attend Lock2
Thread B :
acquiert Lock2
attend Lock1
Pour réduire les risques :
- toujours acquérir plusieurs verrous dans le même ordre global ;
- éviter les sections critiques trop longues ;
- ne pas appeler du code inconnu en tenant un verrou ;
- éviter de bloquer en tenant un spin lock ;
- libérer systématiquement tous les verrous ;
- documenter clairement les règles de verrouillage.
17. Spin lock ou mutex ?
Le choix dépend du contexte.
Spin lock :
- attente active ;
- adapté aux sections extrêmement courtes ;
- utilisable à IRQL élevé selon la variante ;
- pas d'opération bloquante ;
- pas de mémoire paginée dans la section protégée lorsqu'on exécute à DISPATCH_LEVEL.
- peut bloquer le thread ;
- adapté aux sections plus longues ;
- réservé aux contextes où une attente est autorisée ;
- évite de faire tourner inutilement le processeur pendant une attente longue.
- sert surtout à signaler qu'une condition ou une opération est terminée.
- permet un nombre limité d'accès simultanés.
- adaptée aux scénarios lecteurs/rédacteur.
- idéal pour une opération atomique très courte sur une valeur simple.
Le niveau IRQL impose des restrictions directes sur les primitives utilisables.
En particulier :
- les attentes bloquantes nécessitent un niveau suffisamment bas ;
- les spin locks classiques élèvent généralement l'exécution à ;
Code: Select all
DISPATCH_LEVEL - à , une faute de page ne peut pas être traitée normalement ;
Code: Select all
DISPATCH_LEVEL - le code et les données accessibles dans une section protégée par spin lock doivent donc être résidents ;
- une fonction de synchronisation doit toujours être utilisée conformément à ses contraintes IRQL.
19. Exemple simple : compteur partagé
Pour un simple compteur partagé, un spin lock serait souvent excessif.
Code: Select all
volatile LONG Counter = 0;
void IncrementCounter()
{
InterlockedIncrement(&Counter);
}
Code: Select all
typedef struct _MY_DATA
{
LONG A;
LONG B;
KSPIN_LOCK Lock;
} MY_DATA;
void UpdateData(MY_DATA* Data)
{
KIRQL oldIrql;
KeAcquireSpinLock(&Data->Lock, &oldIrql);
Data->A++;
Data->B += 10;
KeReleaseSpinLock(&Data->Lock, oldIrql);
}
20. Exemple simple : mutex
Code: Select all
KMUTEX Mutex;
void Initialize()
{
KeInitializeMutex(&Mutex, 0);
}
void AccessSharedData()
{
KeWaitForSingleObject(
&Mutex,
Executive,
KernelMode,
FALSE,
NULL
);
// accès à une ressource partagée
KeReleaseMutex(&Mutex, FALSE);
}
21. Exemple simple : event
Code: Select all
KEVENT WorkFinished;
void Initialize()
{
KeInitializeEvent(
&WorkFinished,
NotificationEvent,
FALSE
);
}
Code: Select all
KeSetEvent(
&WorkFinished,
IO_NO_INCREMENT,
FALSE
);
Code: Select all
KeWaitForSingleObject(
&WorkFinished,
Executive,
KernelMode,
FALSE,
NULL
);
- Utiliser pour les opérations atomiques simples.
Code: Select all
Interlocked* - Utiliser un spin lock pour une section extrêmement courte devant être protégée entre plusieurs processeurs.
- Ne jamais effectuer une attente bloquante en tenant un spin lock.
- Ne pas accéder à de la mémoire paginée lorsqu'une section s'exécute à .
Code: Select all
DISPATCH_LEVEL - Utiliser mutex, fast mutex ou executive resource lorsque le code peut attendre.
- Utiliser un event pour signaler un changement d'état ou la fin d'une opération.
- Utiliser un sémaphore pour limiter un nombre d'accès simultanés.
- Toujours respecter les contraintes IRQL de chaque primitive.
- Toujours libérer les verrous sur tous les chemins de sortie.
- Définir un ordre d'acquisition des verrous afin d'éviter les deadlocks.
La synchronisation en mode noyau repose sur plusieurs familles de mécanismes répondant à des besoins différents.
Les opérations
Code: Select all
Interlocked*Le point essentiel n'est pas seulement de savoir utiliser une primitive, mais de choisir la bonne en fonction du contexte d'exécution, de la durée de la section critique et du niveau IRQL.
