CodeGym /Cours /Kotlin SELF /Race condition et operations composees sur un etat partag...

Race condition et operations composees sur un etat partage

Kotlin SELF
Niveau 55, Leçon 0
Disponible

1. Introduction

Quand on commence a ecrire des coroutines, on peut facilement tomber dans une illusion : « Ce ne sont pas des threads, donc il n’y a pas les dangers des threads ». Malheureusement (ou heureusement — sinon ce serait ennuyeux), les coroutines s’executent bel et bien de maniere concurrente, et sur Dispatchers.Default elles s’executent meme en parallele sur un pool de threads. Autrement dit, deux coroutines peuvent reellement executer votre code en meme temps sur des cœurs CPU differents.

Dans les ressources officielles sur les coroutines, on souligne que les coroutines sont lancees via des builders (launch, async), et que leur comportement est determine par le contexte, notamment par le CoroutineDispatcher, qui decide « ou la coroutine s’execute ».

Des qu’il y a une execution concurrente, on retrouve la « bonne vieille » classe de problemes du multithreading : des courses, des courses, et encore des courses. Les coroutines sont modernes et pratiques, mais elles n’abolissent pas les lois de l’ordonnanceur.

Shared mutable state

Avant de se plaindre d’une race condition, il faut se mettre d’accord sur les termes. Shared mutable state — c’est un « etat modifiable partage ». Autrement dit, il y a des donnees accessibles simultanement en ecriture depuis plusieurs coroutines. Le point important est bien « en ecriture » : si tout le monde ne fait que lire, c’est en general plus sur (meme s’il y a aussi des nuances).

Un exemple de shared mutable state : la variable var counter = 0, que de nombreuses coroutines incrementent en meme temps. Ou var balance = 100, sur laquelle plusieurs coroutines essaient de debiter de l’argent. Ou encore une MutableList dans laquelle certaines coroutines ajoutent des elements pendant que d’autres la parcourent.

Le probleme d’un shared mutable state, c’est qu’il casse non pas « selon la logique du code », mais selon la maniere exacte dont, a un instant donne, les etapes d’execution se sont entremelangees. Et la, on n’est deja plus vraiment dans la programmation, mais dans une tentative de negocier avec un chat qui decide lui-meme s’il sera affectueux aujourd’hui.

2. Operations composees : ou exactement se cache la course

counter++ — ce n’est pas une seule operation

Voici un moment qui brise la vie des debutants (et parfois meme des non-debutants) : counter++ ressemble a une seule operation, mais en realite c’est au minimum trois etapes :

1) lire counter depuis la memoire vers un registre/une variable,
2) ajouter 1,
3) ecrire la nouvelle valeur dans counter.

Si une seule coroutine effectue ces etapes — tout va bien. Mais si deux coroutines le font en meme temps, elles peuvent « se marcher dessus » au moment de l’ecriture.

Imaginez que les deux coroutines aient lu counter = 10. Les deux calculent 10 + 1. Et les deux ecrivent 11. Resultat : au lieu de deux increments, on n’en a qu’un. Aucune magie — juste une logique « ecrasee ».

Voici un petit exemple qui montre souvent le probleme deja sur un ordinateur portable ordinaire :

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    var counter = 0

    val jobs = List(1_000) {
        launch(Dispatchers.Default) {
            counter++ // course : read → add → write
        }
    }

    jobs.forEach { it.join() }
    println(counter) // souvent < 1000
}

Si vous avez de la chance, vous verrez quelque chose comme 972, 998, 1000… et ce « parfois 1000 » est une source de douleur morale a part entiere. Parce qu’un bug qui « parfois ne se manifeste pas » se manifeste en general pendant une demo devant un responsable.

Pourquoi les courses ressemblent a de la magie

Une particularite tres desagreable d’une race condition, c’est son imprevisibilite. L’ordonnanceur des threads et des coroutines (en simplifiant) decide a qui donner du temps CPU et quand. Il decide vite, souvent, et ne vous demande pas votre avis. Sur une machine, dans une execution — on obtient un entremelange d’etapes. A l’execution suivante — un autre. Et si vous ajoutez un println, tout peut « se reparer » (parce que l’affichage est lent, et l’entremelange change).

Pour le ressentir concretement, il est pratique de relancer plusieurs fois la meme experience et d’observer comment le resultat « fluctue » :

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    repeat(5) { attempt ->
        var counter = 0

        val jobs = List(1_000) {
            launch(Dispatchers.Default) { counter++ }
        }

        jobs.forEach { it.join() }
        println("attempt=$attempt counter=$counter") // les tentatives different
    }
}

Ici, l’important n’est pas que le resultat soit « parfois different de 1000 », mais qu’il peut etre different a chaque fois. C’est le principal marqueur emotionnel des courses : votre logique est unique, mais la realite change a chaque execution.

Check-then-act : « verifie → fait » comme piege

Si counter++ est une course classique, alors check-then-act est un parent plus sournois. C’est une situation ou vous verifiez d’abord une condition, puis vous faites une action, et cela vous semble sur parce que « j’ai quand meme verifie ». Mais entre la verification et l’action, une autre coroutine peut s’intercaler et modifier l’etat.

Imaginons un mini-scenario : une petite « caisse » de vente de billets. Il y a une variable tickets = 1. Deux coroutines essaient d’acheter un billet : « s’il y a des billets — on diminue de 1 ».

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    var tickets = 1

    val j1 = launch(Dispatchers.Default) {
        if (tickets > 0) {
            delay(10)         // augmente la chance d’entremelange
            tickets -= 1
        }
    }

    val j2 = launch(Dispatchers.Default) {
        if (tickets > 0) {
            delay(10)
            tickets -= 1
        }
    }

    j1.join()
    j2.join()
    println("tickets=$tickets") // peut devenir -1
}

Le truc, c’est que les deux coroutines peuvent avoir le temps de passer la verification tickets > 0, tant que tickets vaut encore 1. Ensuite, les deux feront le debit — et on obtient -1. Dans la vraie vie, cela s’appelle « vendre deux billets pour une seule place », et en programmation — « nous venons de creer un bug qui coutera de l’argent et une reputation ».

Notez : le probleme ici n’est pas delay. delay ne fait que rendre la course plus visible. La course existe aussi sans lui — elle peut juste mieux se cacher.

3. Invariants et sections critiques

Ce que nous protegens exactement

Quand on commence a parler de concurrence, on a tres envie de dire : « Ok, je vais tout proteger ». Mais c’est le chemin vers un code qui soit ralentit, soit se transforme en bouillie incomprehensible. Il faut donc introduire un mot important : invariant.

Un invariant est une regle d’integrite qui doit rester vraie en permanence, malgre la concurrence. Dans le cas de notre caisse, on peut avoir les invariants suivants :

Etat Invariant (regle) Pourquoi c’est important
tickets
tickets >= 0
on ne peut pas vendre plus de billets qu’il n’y en a
sold et tickets
sold + tickets == totalTickets
le nombre de billets ne doit pas « apparaitre de nulle part »
balance
balance >= 0
on ne peut pas passer en negatif si les regles l’interdisent

Ce qu’il y a de plus utile dans les invariants, c’est qu’ils repondent a la question : « quelle partie exacte du code faut-il rendre indivisible ? » Autrement dit, quelle logique ne doit pas pouvoir etre « dechiree » en plein milieu par d’autres coroutines.

Par exemple, dans check-then-act, l’invariant casse souvent parce que la verification et la modification doivent, par le sens, former un seul bloc « atomique » : « je verifie et j’agis tout de suite, sans rendre la main entre les deux ».

Comment trouver le « morceau minimal » qu’on ne doit pas interrompre

Le terme section critique sonne comme si l’on allait construire un bunker. En realite, c’est plus simple : une section critique est un morceau de code qui doit s’executer comme s’il etait « le seul et unique » a cet instant. C’est-a-dire que pendant qu’une coroutine execute ce morceau, les autres ne doivent pas executer en meme temps un morceau conflictuel sur le meme etat.

Mais dans ce cours, l’important n’est pas « comment proteger une section critique » (les outils viendront plus tard), mais comment bien la delimiter. Et ici l’exercice « au crayon sur le code » est utile :

Vous prenez un endroit ou l’etat partage est lu et ecrit, et vous vous demandez : « Si on m’interrompt juste apres cette ligne, l’invariant peut-il deja etre viole ? » Si oui, c’est que la frontiere de la section critique est mal placee, et il faut etendre le bloc pour inclure les etapes dependantes.

Pour tickets, cela ressemble a ceci :

if (tickets > 0) {   // on a lu tickets et pris une decision
    tickets -= 1     // on a ecrit tickets
}

Si l’on est interrompu entre les lignes, une autre coroutine peut prendre une decision sur l’ancienne valeur. Donc les deux lignes doivent etre percues comme un seul morceau « indivisible » — une operation logique unique d’achat.

Une bonne regle pour un debutant : si la logique ressemble a “j’ai lu → j’ai decide → j’ai ecrit”, c’est presque surement un candidat a une section critique. Meme si le code ne fait que deux lignes.

4. Terrain d’exercices TicketOffice

Pour que les exemples ne restent pas dans le vide, mettons en place une petite application d’apprentissage que nous ferons evoluer dans les cours suivants. Pour l’instant, elle sera volontairement non sure — c’est normal : nous construisons un « velo casse » pour comprendre ensuite quelles vis il faut serrer.

Commencons par le modele le plus simple : il y a une caisse qui stocke tickets et sold. La vente est un check-then-act. Pour l’instant, sans aucune protection.

import kotlinx.coroutines.delay

suspend fun buyTicketUnsafe(): Boolean {
    if (tickets > 0) {
        delay(1)          // renforce la chance de course, NE "repare" PAS le probleme
        tickets -= 1
        sold += 1
        return true
    }
    return false
}

Mais il y a une nuance : Kotlin n’aime pas les var globaux « nus », et c’est une bonne chose. Faisons donc une « petite boite » ultra simple pour l’etat via IntArray (pour pouvoir modifier des elements en les passant comme une reference), sans plonger tout de suite profondement dans l’OOP. Cela parait un peu artisanal, mais c’est transparent pour la comprehension.

import kotlinx.coroutines.delay

suspend fun buyTicketUnsafe(state: IntArray): Boolean {
    val tickets = state[0]
    if (tickets > 0) {
        delay(1)
        state[0] = tickets - 1  // tickets
        state[1] += 1           // sold
        return true
    }
    return false
}

Maintenant, ecrivons un main qui lance beaucoup « d’acheteurs » en parallele. Nous nous attendons a ce que sold ne depasse pas le nombre initial de billets. Et la realite peut nous surprendre.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val state = intArrayOf(10, 0) // [tickets=10, sold=0]

    val buyers = List(1_000) {
        launch(Dispatchers.Default) {
            buyTicketUnsafe(state)
        }
    }

    buyers.forEach { it.join() }
    println("tickets=${state[0]}, sold=${state[1]}") // peut etre bizarre
}

Pourquoi le resultat peut-il etre « bizarre » ? Parce que nous n’avons protege rien. Nous lisons tickets dans une variable locale, puis nous attendons delay(1), puis nous reecrivons. Pendant ce temps, une autre coroutine a pu faire la meme chose, et une partie des ventes sera « ecrasee ». Ou bien, si vous changez la logique pour quelque chose de plus direct (state[0] -= 1 apres la verification), vous pouvez obtenir d’autres types de casse.

L’idee principale : des que vous voyez un shared mutable state et une logique composee, vous devez suspecter une course.

5. Comment une mise a jour « se perd »

Parfois, il est utile non seulement de croire sur parole, mais de voir le « dessin anime » de l’entremelange des etapes. Voici schematiquement comment un increment se perd avec counter++ :

sequenceDiagram
    participant A as Coroutine A
    participant B as Coroutine B
    participant M as counter (memory)

    A->>M: read counter (=10)
    B->>M: read counter (=10)
    A->>A: compute 10+1 (=11)
    B->>B: compute 10+1 (=11)
    A->>M: write 11
    B->>M: write 11
    Note over M: Resultat = 11, alors que "devrait" etre 12

Pour check-then-act, le schema est similaire, sauf qu’au lieu de « ajouter 1 » on a « verifier une condition et debiter ». Et dans les deux cas, le probleme cle est le meme : entre des etapes liees logiquement, une autre coroutine peut s’intercaler.

6. Erreurs typiques

Erreur no 1 : penser que “les coroutines ne sont pas des threads, donc il n’y a pas de courses”.
Les coroutines sont effectivement plus legeres et moins couteuses que les threads, mais elles peuvent tres bien s’executer sur un pool de threads, surtout sur Dispatchers.Default, et donc tourner en parallele. Si plusieurs coroutines ecrivent dans une meme variable sans coordination, vous obtenez une course classique.

Erreur no 2 : croire qu’une course n’arrive que dans counter++.
++ n’est qu’un exemple tres parlant, parce qu’il ressemble a « une seule ligne ». Mais toute logique composee du type “j’ai lu → j’ai calcule → j’ai ecrit” ou “j’ai verifie → j’ai fait” est soumise aux memes problemes. Surtout check-then-act : cela parait « raisonnable », mais c’est ce qui casse le plus souvent.

Erreur no 3 : conclure “une fois ca a affiche 1000, donc tout va bien”.
Une race condition est sournoise justement parce qu’elle peut ne pas se manifester a une execution donnee. Elle depend des timings, de la charge CPU, des processus en arriere-plan et meme du fait qu’un onglet de navigateur avec de la musique soit ouvert. Une fiabilite construite sur la chance se termine en general soudainement.

Erreur no 4 : tenter de « reparer » une course en ajoutant delay() ou « un println de plus ».
delay et println modifient l’ordonnancement d’execution : le bug peut donc « se cacher » ou « ressortir » plus souvent. Mais ils n’eliminent pas la cause. C’est comme traiter une fissure dans un mur avec un autocollant “ne te fissure pas” : le mur s’en fiche.

Erreur no 5 : ne pas formuler l’invariant et, a cause de cela, proteger « tout et n’importe quoi ».
Si vous ne pouvez pas dire quelle regle d’integrite doit toujours etre respectee (tickets >= 0, sold + tickets == total, etc.), vous ne pourrez pas delimiter correctement la section critique. En general, cela finit soit par proteger un morceau trop petit (la course reste), soit un morceau trop grand (le programme devient lent et peu pratique).

Erreur no 6 : confondre « acces partage » et « ecriture partagee ».
Parfois, un etudiant voit qu’une variable est accessible depuis differents endroits et commence a paniquer. Mais la vraie zone de risque, c’est l’ecriture concurrente sur les donnees. La lecture seule est plus souvent sans danger (meme s’il existe aussi des subtilites), alors que l’ecriture sans coordination est presque garantie d’etre un candidat aux problemes.

1
Mission
Kotlin SELF, niveau 55, leçon 0
Bloqué
Compteur cassé
Compteur cassé
1
Mission
Kotlin SELF, niveau 55, leçon 0
Bloqué
Billets en négatif
Billets en négatif
1
Mission
Kotlin SELF, niveau 55, leçon 0
Bloqué
Invariants de la billetterie
Invariants de la billetterie
1
Mission
Kotlin SELF, niveau 55, leçon 0
Bloqué
Planificateur manuel
Planificateur manuel
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION