1. Introduction
Livelock — c'est une situation dans une application multithread où deux threads ou plus essaient d'éviter un blocage mutuel (Deadlock), mais finissent par réagir sans cesse aux actions l'un de l'autre sans avancer. Contrairement au Deadlock, où les threads sont figés et ne peuvent rien faire, avec le Livelock le programme semble vivant : les threads s'exécutent, mais aucun ne réalise un travail utile.
C'est comme si toi et un collègue dans un couloir étroit reculiez sans cesse pour vous laisser passer, et à force de reculer vous n'avancez pas. Drôle vu de l'extérieur, mais terrible dans un programme.
Exemple de Livelock : des programmeurs dans une porte
Imagine deux programmeurs très polis qui avancent l'un vers l'autre dans un couloir étroit. Ils se retrouvent devant une porte en même temps et décident tous les deux de céder : ils reculent pour laisser passer l'autre. Ils voient ensuite que rien n'a changé et retentent de céder — indéfiniment. Personne ne reste immobile, tout le monde bouge, mais personne ne passe la porte.
Dans un programme multithread, ça ressemble à : un thread voit la ressource occupée, recule, vérifie encore, cède à nouveau, et ainsi de suite à l'infini.
2. Livelock en pratique
Implémentons une situation analogue dans du code. Supposons qu'on ait deux ressources (par exemple, deux comptes bancaires) et deux threads qui essaient en même temps de transférer de l'argent l'un vers l'autre en essayant de ne pas «se coincer».
class Account
{
public int Balance { get; set; }
public object LockObj { get; } = new object();
}
public class Program
{
static void Transfer(Account from, Account to, int amount)
{
while (true)
{
bool lockedFrom = false;
bool lockedTo = false;
try
{
Monitor.TryEnter(from.LockObj, 100, ref lockedFrom);
Monitor.TryEnter(to.LockObj, 100, ref lockedTo);
if (lockedFrom && lockedTo)
{
from.Balance -= amount;
to.Balance += amount;
break; // Transfert effectué !
}
}
finally
{
if (lockedFrom) Monitor.Exit(from.LockObj);
if (lockedTo) Monitor.Exit(to.LockObj);
}
// Ça n'a pas marché ? On recule et on réessaye — avec politesse !
Thread.Sleep(1);
}
}
// ... Lancement de deux threads avec Transfer(A, B, ...); Transfer(B, A, ...);
}
Dans cet exemple, les deux threads tentent à plusieurs reprises de prendre les locks. Mais si les deux voient constamment que les locks sont occupés et cèdent, tout se réduit à un «céder poli» et aucun transfert d'argent n'a lieu. Compter sur la chance du scheduler est une mauvaise stratégie pour un programme fiable. Ajoutez des pauses et du backoff, n'enchaînez pas des boucles «vides» courtes sur Monitor.TryEnter et Thread.Sleep.
Comparaison Livelock vs Deadlock
| Deadlock | Livelock | |
|---|---|---|
| Threads | Sont arrêtés, attendent | Tournent activement, attendent |
| Ressources | Bloquées pour toujours | Clairement pas bloquées |
| CPU | Presque pas sollicité | Peut être à 100% |
| Solution | Timeouts, ordre des locks | Aléa, pauses, backoff |
3. Starvation (famine) : quand la file n'atteint pas la ressource
Starvation (littéralement «famine») — c'est une situation où un ou plusieurs threads sont systématiquement négligés et n'obtiennent pas l'accès à la ressource nécessaire, parce que d'autres threads passent toujours devant.
Contrairement au Deadlock et au Livelock, ici personne n'est bloqué pour toujours et personne ne cède indéfiniment — c'est juste que certains threads n'obtiennent jamais leur «part de gâteau».
Illustration : analogie dans la vie réelle
Imagine une cantine avec deux files : «VIP» (par exemple, une caisse pour les employés de l'année) et «pour tout le monde». La file VIP est courte, mais quelqu'un y arrive toujours avant. Les visiteurs «normaux» attendent une demi-heure et regardent les VIP repasser devant. Voilà la Starvation !
4. Starvation en C# : pratique et debug
Regardons une situation avec lock, où un thread avec une priorité élevée prend constamment la section critique, et un autre «prend du retard» :
private static readonly object _locker = new object();
static void Greedy()
{
while (true)
{
lock (_locker)
{
Console.WriteLine("Le thread gourmand a pris la ressource...");
Thread.Sleep(10); // garde le lock plus longtemps
}
Thread.Sleep(1);
}
}
static void Poor()
{
while (true)
{
lock (_locker)
{
Console.WriteLine("Le thread pauvre a tenté d'entrer...");
}
}
}
Si on lance les deux threads, le "Greedy" («Gourmand») garde le lock longtemps et le récupère rapidement, tandis que "Poor" («Pauvre») entre presque jamais et «meurt de faim» sans ressource. Dans les vrais scénarios, la starvation peut être moins évidente : par exemple, si un thread a une priorité plus basse ou est souvent préempté par l'OS.
Où rencontre-t-on la starvation ?
- Dans les threads à faible priorité.
- Quand les files de tâches sont mal organisées (pas de politique FIFO juste).
- Dans ReaderWriterLockSlim avec les réglages par défaut : si un writer arrive rarement mais les readers sont infinis, le writer va constamment «crever de faim», parce que les readers prennent le Read Lock et n'autorisent pas le Write Lock.
5. Pourquoi la Starvation n'est pas toujours un bug, mais toujours un problème
La Starvation, contrairement au Deadlock, ne stoppe pas complètement le programme, mais le résultat devient imprévisible et injuste : les données sont traitées de manière inégale, certaines tâches attendent indéfiniment, la performance chute. C'est critique sur des serveurs où tout le monde doit avoir des chances équitables.
Comment détecter et diagnostiquer Livelock et Starvation
- Utilisation CPU fortement augmentée, mais la tâche «stagne» — peut-être vous avez un Livelock.
- Les threads semblent fonctionner, mais certaines tâches ne se terminent jamais — Starvation probable.
- Logs : si le journal montre que certains threads ou tâches n'obtiennent jamais la ressource — c'est la famine sans doute.
Comment éviter le Livelock
- N'utilisez pas uniquement des tentatives non bloquantes du type Monitor.TryEnter. Si ça échoue, ajoutez des pauses aléatoires (Thread.Sleep(Random.Next(1, 10)))), pour éviter que les threads ne s'alignent temporellement.
- Évitez les boucles courtes et permanentes qui essaient de prendre la ressource — sinon le cycle «recule — réessaie» devient infini.
- Parfois changer l'algorithme aide : introduire un «droit du plus ancien», pour que seul un thread puisse céder tandis que l'autre n'y est pas obligé.
Comment éviter la Starvation
- Surveillez les priorités des threads : les tâches background ou prioritaires ne doivent pas empêcher indéfiniment les autres.
- Utilisez des files (ConcurrentQueue<T>, des channels, une queue de tâches) avec une politique FIFO juste pour servir les tâches dans l'ordre.
- Dans ReaderWriterLockSlim, on peut suspendre de nouveaux readers au profit des writers pour que le writer obtienne quand même le Write Lock.
- Appliquez des timeouts et des logs : si un thread essaie d'obtenir un lock trop longtemps, émettez un avertissement.
- Réduisez la durée de maintien des locks et fractionnez les sections critiques.
Comparaison Deadlock, Livelock et Starvation
| Scénario | Deadlock | Livelock | Starvation |
|---|---|---|---|
| Threads | Sont figés | Coursent, mais en vain | Certains travaillent, d'autres non |
| Accès à la ressource | Non | Non | Inégal, parfois non |
| CPU | Peu sollicité | Beaucoup sollicité | Solicité, mais pas par tous |
| Erreur critique ? | Oui | Oui | Parfois |
GO TO FULL VERSION