L'essentiel
  • Entre 1985 et 1987, une race condition dans le Therac-25 a causé au moins 3 morts par surdosage de radiothérapie.
  • AECL avait retiré les protections matérielles indépendantes en se fiant uniquement au logiciel jugé 'fiable par héritage'.
  • Le bug n'était reproductible que si l'opérateur modifiait le mode de traitement suffisamment rapidement, ce qui le rendait presque invisible en test.
  • Un seul développeur sans documentation ni revue de code avait écrit le logiciel en assembleur PDP-11.
  • En Python, threading.Lock protège les variables partagées et les protections de sécurité matérielles ne doivent jamais être remplacées par du software seul.

Lecture complète : 14 min

Entre 1985 et 1987, une machine de radiothérapie a surdosé au moins six patients.

Trois d’entre eux sont morts. Les autres ont survécu avec des séquelles permanentes. Pas à cause d’une panne mécanique. Pas à cause d’une erreur humaine. À cause d’une race condition dans un logiciel que personne ne testait vraiment, parce que les ingénieurs étaient convaincus qu’il était correct.

Le Therac-25 est le cas d’école absolu sur ce qui se passe quand on retire des protections matérielles sans renforcer les protections logicielles, et quand on déploie du code médical sans tests de sécurité sérieux.

Ce qui s’est passé entre 1985 et 1987 se produit encore aujourd’hui dans des contextes moins dramatiques mais structurellement identiques. Voici l’histoire, la mécanique du bug, et comment Python t’expose aux mêmes pièges.


Le Therac-25 : contexte et chronologie

Le Therac-25 était une machine de radiothérapie fabriquée par Atomic Energy of Canada Limited. Elle remplaçait deux modèles précédents, le Therac-6 et le Therac-20, qui fonctionnaient avec des protections matérielles indépendantes du logiciel.

Sur les anciens modèles, si le logiciel ordonnait un dosage trop élevé, un interlock physique bloquait la machine. Sur le Therac-25, AECL a décidé que ces protections matérielles étaient redondantes. Le logiciel était jugé suffisamment fiable pour les remplacer seul.

C’était une erreur fatale.

Le logiciel du Therac-25 était écrit en assembleur PDP-11 par un seul développeur, sans documentation de conception, sans revue de code, et sans tests formels. Il était considéré comme une amélioration directe du logiciel des modèles précédents, donc présumé correct par héritage.

La chronologie des incidents :

  • Juin 1985 : premier surdosage documenté à Marietta, Georgie. La patiente survit avec des séquelles graves.
  • Juillet 1985 : deuxième incident à Hamilton, Ontario.
  • Mars 1986 : premier décès. Kenanga Butler, Yakima, Washington.
  • Avril 1986 : deuxième décès. Ray Cox, Yakima, quelques semaines après le premier.
  • Janvier 1987 : troisième décès. Vernon Kidd, Yakima.

Ce qui est frappant dans cette chronologie : après chaque incident, AECL a fourni des explications techniques insuffisantes aux hôpitaux et aux autorités. La machine était présentée comme sûre. Les opérateurs étaient implicitement mis en cause. Il a fallu des années de recoupements entre plusieurs hôpitaux différents pour établir le lien avec le logiciel.

Timeline des incidents Therac-25 de 1985 à 1987


Qu’est-ce qu’une race condition ?

Une race condition est un bug qui se produit quand deux processus ou threads accèdent à une ressource partagée en même temps, et que le résultat final dépend de l’ordre d’exécution, qui n’est pas garanti. Le bug n’est pas reproductible de façon déterministe : il apparaît ou n’apparaît pas selon des facteurs de timing hors de ton contrôle.

Dans le cas du Therac-25, la race condition fonctionnait comme ceci.

L’opérateur saisissait un mode de traitement, puis pouvait le modifier rapidement avant que la machine ne commence à traiter. Le logiciel utilisait une variable partagée pour stocker le mode sélectionné. Deux tâches concurrentes lisaient et écrivaient cette variable :

  • La tâche de saisie, qui mettait à jour la variable selon les touches pressées.
  • La tâche d’exécution, qui lisait la variable pour configurer la machine.

Quand l’opérateur modifiait le mode suffisamment vite, la tâche d’exécution lisait une valeur intermédiaire incohérente. Résultat : la machine configurait son faisceau en mode haute énergie sans activer le filtre atténuateur qui aurait rendu cette dose sans danger.

Le patient recevait une dose de rayonnement directe, sans atténuation, plusieurs dizaines de fois supérieure à la dose thérapeutique.

Ce que personne ne comprenait au début : les opérateurs expérimentés étaient plus en danger que les débutants. Plus ils tapaient vite, plus ils déclenchaient la race condition. Les signalements décrivaient une erreur aléatoire impossible à reproduire de façon fiable en laboratoire.


Reproduire une race condition en Python

Le module threading de Python expose exactement ce type de vulnérabilité.

import threading
import time

# Variable partagée : simule le registre de configuration du Therac-25
mode_traitement = {
    "type": "basse_energie",
    "filtre_actif": True,
    "dose_max": 200
}

def modifier_mode(nouveau_mode: str):
    """Simule la tâche de saisie opérateur"""
    global mode_traitement
    mode_traitement["type"] = nouveau_mode
    time.sleep(0.0001)  # Fenêtre de vulnérabilité entre les deux lignes
    if nouveau_mode == "haute_energie":
        mode_traitement["filtre_actif"] = True
        mode_traitement["dose_max"] = 200
    else:
        mode_traitement["filtre_actif"] = True
        mode_traitement["dose_max"] = 100

def executer_traitement():
    """Simule la tâche d'exécution machine"""
    global mode_traitement
    # Lecture pendant la modification : état incohérent possible
    if mode_traitement["type"] == "haute_energie" and not mode_traitement["filtre_actif"]:
        print("DANGER : haute énergie sans filtre actif")
    else:
        dose = mode_traitement["dose_max"]
        print(f"Traitement : {mode_traitement['type']}, dose {dose} cGy")

# Simulation de la race condition
threads = []
for _ in range(100):
    t1 = threading.Thread(target=modifier_mode, args=("haute_energie",))
    t2 = threading.Thread(target=executer_traitement)
    threads.extend([t1, t2])
    t1.start()
    t2.start()

for t in threads:
    t.join()

Lance ce code plusieurs fois. Le résultat change à chaque exécution. Parfois tout se passe bien. Parfois executer_traitement lit le dictionnaire pendant que modifier_mode est en plein milieu de ses mises à jour, et l’état est incohérent.

C’est exactement le comportement que les ingénieurs d’AECL ne parvenaient pas à reproduire de façon fiable en laboratoire.

Diagramme de race condition : deux threads accédant à la variable partagée, état incohérent mis en évidence


Comment écrire du code sécurisé : les leçons du Therac-25

Les leçons du Therac-25 tiennent en trois principes : ne jamais supprimer une protection matérielle sans protection logicielle équivalente et auditée, protéger toute ressource partagée entre threads avec des verrous explicites, et traiter toute valeur inattendue comme une erreur bloquante plutôt que de la corriger silencieusement.

La correction immédiate pour une race condition en Python : threading.Lock.

import threading
import time

class ConfigurationMachine:
    def __init__(self):
        self._lock = threading.Lock()
        self._mode = "basse_energie"
        self._filtre_actif = True
        self._dose_max = 100
    
    def modifier_mode(self, nouveau_mode: str) -> None:
        with self._lock:
            # Tout se passe à l'intérieur du verrou : état toujours cohérent
            if nouveau_mode == "haute_energie":
                self._mode = nouveau_mode
                self._filtre_actif = True
                self._dose_max = 200
            elif nouveau_mode == "basse_energie":
                self._mode = nouveau_mode
                self._filtre_actif = True
                self._dose_max = 100
            else:
                raise ValueError(f"Mode inconnu : {nouveau_mode}. Traitement bloqué.")
    
    def executer_traitement(self) -> None:
        with self._lock:
            if self._mode == "haute_energie" and not self._filtre_actif:
                raise RuntimeError(
                    "Configuration dangereuse détectée : haute énergie sans filtre. "
                    "Traitement annulé."
                )
            print(f"Traitement : {self._mode}, filtre={self._filtre_actif}, dose={self._dose_max} cGy")

# Test
machine = ConfigurationMachine()
threads = []
for _ in range(100):
    t1 = threading.Thread(target=machine.modifier_mode, args=("haute_energie",))
    t2 = threading.Thread(target=machine.executer_traitement)
    threads.extend([t1, t2])
    t1.start()
    t2.start()

for t in threads:
    t.join()

Deuxième principe : les valeurs inattendues bloquent, elles ne passent pas silencieusement.

def valider_configuration(mode: str, filtre: bool, dose: int) -> None:
    """Toute configuration invalide lève une exception. Jamais de correction silencieuse."""
    modes_valides = {"basse_energie", "haute_energie"}
    if mode not in modes_valides:
        raise ValueError(f"Mode '{mode}' invalide. Modes acceptés : {modes_valides}")
    
    if mode == "haute_energie" and not filtre:
        raise ValueError("Haute énergie sans filtre : configuration interdite.")
    
    if dose <= 0 or dose > 300:
        raise ValueError(f"Dose {dose} cGy hors plage autorisée [1, 300].")

Tests de sécurité logicielle : par où commencer

Le Therac-25 n’avait pas de tests formels. Ses développeurs considéraient que l’expérience terrain sur les anciens modèles valait une suite de tests.

Ce n’est pas suffisant. Voici trois niveaux de tests que chaque projet devrait avoir.

Niveau 1 : tests des cas limites et des états invalides

import pytest
from machine import ConfigurationMachine

def test_mode_invalide_leve_exception():
    m = ConfigurationMachine()
    with pytest.raises(ValueError, match="Mode inconnu"):
        m.modifier_mode("mode_inventé")

def test_haute_energie_sans_filtre_bloque():
    m = ConfigurationMachine()
    m._mode = "haute_energie"
    m._filtre_actif = False
    with pytest.raises(RuntimeError, match="Configuration dangereuse"):
        m.executer_traitement()

def test_transition_valide_ne_leve_pas_exception():
    m = ConfigurationMachine()
    m.modifier_mode("haute_energie")
    m.executer_traitement()

Niveau 2 : tests de concurrence avec concurrent.futures

import pytest
from concurrent.futures import ThreadPoolExecutor
from machine import ConfigurationMachine

def test_pas_de_race_condition_sous_charge():
    machine = ConfigurationMachine()
    erreurs = []
    
    def cycle_complet():
        try:
            machine.modifier_mode("haute_energie")
            machine.executer_traitement()
            machine.modifier_mode("basse_energie")
        except RuntimeError as e:
            erreurs.append(str(e))
    
    with ThreadPoolExecutor(max_workers=20) as executor:
        futures = [executor.submit(cycle_complet) for _ in range(200)]
        for f in futures:
            f.result()
    
    assert len(erreurs) == 0, f"Race condition détectée : {erreurs}"

Niveau 3 : fuzzing sur les entrées

from hypothesis import given, strategies as st
from machine import valider_configuration

@given(
    mode=st.text(),
    filtre=st.booleans(),
    dose=st.integers()
)
def test_validation_ne_crashe_jamais_silencieusement(mode, filtre, dose):
    """Toute entrée invalide doit lever une exception, jamais passer silencieusement."""
    try:
        valider_configuration(mode, filtre, dose)
    except (ValueError, TypeError):
        pass  # Comportement attendu sur entrée invalide

Le fuzzing avec hypothesis génère automatiquement des centaines de cas limites auxquels tu n’aurais pas pensé. C’est la méthode qui se rapproche le plus de ce qu’aurait dû faire AECL avant de déployer le Therac-25.


Questions fréquentes

Qu’est-ce que le Therac-25 et pourquoi est-il important pour les développeurs ?

Le Therac-25 était une machine de radiothérapie qui a causé au moins 3 morts entre 1985 et 1987 à cause d’une race condition dans son logiciel. C’est l’un des cas les plus étudiés en génie logiciel parce qu’il illustre les conséquences concrètes d’un code sans tests formels, d’une confiance excessive dans l’héritage logiciel, et de la suppression de protections matérielles sans équivalent logiciel rigoureux.

Qu’est-ce qu’une race condition en Python et comment la détecter ?

Une race condition survient quand deux threads accèdent à une ressource partagée en même temps sans synchronisation. En Python, le module threading y expose directement. La détection passe par des tests de concurrence avec ThreadPoolExecutor, des outils comme ThreadSanitizer pour les extensions C, et des revues de code ciblant toute variable globale ou partagée modifiée dans plusieurs threads.

Comment threading.Lock protège contre les race conditions ?

threading.Lock garantit qu’une seule thread peut exécuter le bloc de code protégé à la fois. Toutes les autres threads qui tentent d’entrer dans le même bloc attendent que le verrou soit libéré. Cela transforme une opération de lecture-modification-écriture en opération atomique, éliminant la fenêtre de vulnérabilité où l’état est incohérent.

Le GIL Python protège-t-il contre les race conditions ?

Non. Le GIL de Python empêche l’exécution parallèle du bytecode Python sur plusieurs coeurs, mais il n’empêche pas les race conditions. Le GIL est relâché lors des opérations I/O et peut être relâché entre deux instructions Python. Des opérations qui semblent atomiques, comme dict["key"] = value, ne le sont pas nécessairement. Il faut utiliser threading.Lock explicitement.

Comment écrire du code sécurisé en Python pour des applications critiques ?

Trois règles fondamentales issues du Therac-25 : protéger toute ressource partagée entre threads avec des verrous explicites, traiter toute valeur inattendue comme une erreur bloquante plutôt que de la corriger silencieusement, et tester systématiquement les cas limites et les états invalides avant de tester les scénarios normaux. Pour les applications médicales ou financières, ajouter du fuzzing avec hypothesis et des tests de stress sous charge concurrente.


Ce que le Therac-25 change à ta façon de coder

Le Therac-25 n’était pas un projet bâclé par des développeurs incompétents. C’était un projet développé par des ingénieurs expérimentés qui faisaient confiance à leur code parce qu’il ressemblait à du code qui avait déjà fonctionné.

C’est précisément là que ça a mal tourné.

La confiance aveugle dans l’héritage, l’absence de tests formels sur les cas limites, la suppression de protections “redondantes” sans audit rigoureux. Ces trois erreurs sont reproductibles dans n’importe quel projet, à n’importe quelle échelle.

Si tu construis quelque chose que des gens utilisent, voici ce que je retiens :

  1. Toute ressource partagée entre threads prend un verrou. Pas d’exception.
  2. Les valeurs inattendues lèvent des exceptions explicites. Jamais de correction silencieuse.
  3. Les tests de cas limites passent avant les tests de scénarios normaux.

Le code parfait n’existe pas. Mais du code qui échoue bruyamment sur les entrées invalides, plutôt que silencieusement sur les états incohérents, c’est déjà une différence entre un bug visible et un bug qui tue.

Lance un audit gratuit de ta copie sur copyboost.io : si ton texte convertit mal, autant le savoir avant de payer pour l’acquisition.

Dernière mise à jour : mai 2026

Cet article fait partie de la série 10 bugs informatiques qui ont tué, crashé et coûté des milliards.