Vol. 9 · № 03 · Un atelier RubyA Ruby workshop

Concurrence & le GVL, ou attente contre calcul Concurrency & the GVL, or waiting versus computing

En MRI, le GVL n'autorise qu'un thread Ruby à la fois : les threads ne parallélisent pas le calcul, mais chevauchent l'attente (le GVL se libère pendant l'I/O). La question avant tout : mon goulot est-il de l'attente (threads, async) ou du calcul (Ractors, processus) ? Le piège : des threads sur du CPU, sans avoir mesuré. In MRI, the GVL allows only one Ruby thread at a time: threads don't parallelize computation, but overlap waiting (the GVL releases during I/O). The question before all: is my bottleneck waiting (threads, async) or computing (Ractors, processes)? The trap: threads on CPU, without having measured.

AudienceAudience
Dev qui ajoute des threads par réflexe et veut savoir quand ça aide Dev who adds threads by reflex and wants to know when it helps
Format
Self-paced
ChapitresChapters
5
Date
Juil 2026 Jul 2026
≈ 19 min ●●●○ GVLRactorConcurrence
SommaireContents ·
№ 01CadrageFraming3 min

Le GVL : un seul thread exécute du Ruby — l'attente, pas le calcul, se chevauche.The GVL: one thread runs Ruby — waiting, not computing, overlaps.

Le dernier coût, et le plus mal compris. En MRI — l'implémentation de référence — un verrou global, le GVL, garantit qu'un seul thread exécute du code Ruby à la fois. Conséquence frontale : des threads ne parallélisent pas le calcul. Lancer quatre threads sur un calcul CPU ne va pas quatre fois plus vite ; le GVL les fait passer chacun leur tour. Mais le GVL est relâché pendant les attentes — un appel réseau, une lecture disque, un sleep. Là, les threads brillent : pendant qu'un thread attend la réponse d'un serveur, un autre peut lancer sa requête. La question n'est donc jamais « threads ou pas » dans l'absolu, mais « mon goulot est-il de l'attente ou du calcul ? ».The last cost, and the most misunderstood. In MRI — the reference implementation — a global lock, the GVL, guarantees that only one thread runs Ruby code at a time. The frontal consequence: threads don't parallelize computation. Launching four threads on a CPU task doesn't go four times faster; the GVL takes them in turns. But the GVL is released during waits — a network call, a disk read, a sleep. There, threads shine: while one thread waits for a server's response, another can fire its request. So the question is never 'threads or not' in the absolute, but 'is my bottleneck waiting or computing?'.

Le GVL sérialise le calcul, libère l'attenteThe GVL serializes computing, releases waiting
# en MRI, le GVL (Global VM Lock) : UN seul thread exécute du Ruby à la fois
def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)

# CPU-bound : 4 threads ne vont PAS plus vite — le GVL les sérialise
ts = 4.times.map { Thread.new { fib(33) } }
ts.each(&:join)                  # ≈ aussi lent que 4 fib(33) en série

# I/O-bound : 4 threads VONT plus vite — le GVL est libéré pendant l'attente
urls = [...]                     # 4 requêtes réseau
ts = urls.map { |u| Thread.new { Net::HTTP.get(URI(u)) } }
ts.map(&:value)                  # les attentes se CHEVAUCHENT
Deux goulots, deux réponsesTwo bottlenecks, two answers
CPU-boundCPU-bound
calcul pur, en Rubypure Ruby computing
threads : aucun gain (GVL)threads: no gain (GVL)
I/O-boundI/O-bound
attente réseau / disquenetwork / disk waiting
threads : gain réelthreads: real gain

Le même outil — Thread — accélère un cas et pas l'autre, selon une seule variable : le goulot. Pour de l'attente, les threads chevauchent les temps morts. Pour du calcul, le GVL les met en file, et il faut un autre outil.The same tool — Thread — speeds up one case and not the other, by a single variable: the bottleneck. For waiting, threads overlap the dead time. For computing, the GVL queues them, and you need another tool.

Le réflexe du numéroThe issue's reflex

« Mon goulot est-il une attente (threads, async) ou du calcul (Ractors, processus, ou une autre implémentation) ? » C'est la question avant tout choix de concurrence. Et comme tout le volume : on n'y répond pas à l'intuition — on profile pour savoir si le temps part dans le réseau ou dans le CPU."Is my bottleneck a wait (threads, async) or computing (Ractors, processes, or another implementation)?" It's the question before any concurrency choice. And like the whole volume: you don't answer it by intuition — you profile to know whether time goes into the network or the CPU.

Concurrence n'est pas parallélisme.Concurrency isn't parallelism.

Deux notions qu'on confond. La concurrence, c'est gérer plusieurs tâches qui progressent en s'entrelaçant — ce que les threads MRI font très bien pour l'I/O, même sous GVL. Le parallélisme, c'est exécuter plusieurs calculs en même temps, sur plusieurs cœurs — ce que le GVL interdit aux threads, et que seuls les Ractors ou les processus offrent en Ruby. Le mythe « Ruby est lent en concurrence » naît de cette confusion : il est excellent en concurrence d'attente, contraint en parallélisme de calcul.Two notions we confuse. Concurrency is managing several tasks that progress by interleaving — what MRI threads do well for I/O, even under the GVL. Parallelism is running several computations at once, on several cores — what the GVL forbids threads, and only Ractors or processes offer in Ruby. The myth 'Ruby is slow at concurrency' is born of this confusion: it's excellent at wait-concurrency, constrained at compute-parallelism.

№ 02La surpriseThe surprise4 min

Quatre threads sur un calcul : aucun gain. Sur de l'attente : quadruple.Four threads on a computation: no gain. On waiting: fourfold.

La surprise qui désarçonne tout le monde une fois. On apprend que « les threads accélèrent », on en met quatre sur un calcul lourd, on chronomètre… et c'est aussi lent qu'en série, parfois pire. Le GVL a fait passer les quatre threads chacun leur tour : il n'y a jamais eu deux calculs en même temps. Bascule maintenant sur de l'I/O — quatre requêtes réseau — et le même code à quatre threads va presque quatre fois plus vite, parce que le GVL se libère à chaque attente et laisse les temps morts se superposer. Même outil, résultats opposés, selon une seule chose : le goulot. Et la seule façon de le savoir, fidèle au volume, c'est de mesurer les deux.The surprise that throws everyone once. You learn 'threads speed things up', you put four on a heavy computation, you time it… and it's as slow as serial, sometimes worse. The GVL took the four threads in turns: there were never two computations at once. Now switch to I/O — four network requests — and the same four-thread code goes nearly four times faster, because the GVL releases on each wait and lets the dead times overlap. Same tool, opposite results, by one thing: the bottleneck. And the only way to know, true to the volume, is to measure both.

Le GVL sérialise les quatre calculsThe GVL serializes the four computations
require "benchmark"
def crunch = (1..5_000_000).sum { |i| Math.sqrt(i) }

# en série
serial = Benchmark.realtime { 4.times { crunch } }

# « parallélisé » avec 4 threads
threaded = Benchmark.realtime do
  4.times.map { Thread.new { crunch } }.each(&:join)
end

serial    # => ~1.6 s
threaded  # => ~1.6 s (parfois PIRE) — le GVL a sérialisé les 4 calculs
#           les threads se sont relayés, pas superposés. Zéro gain CPU.

Le calcul est du Ruby pur : à aucun instant le GVL n'est relâché, donc les quatre threads se relaient sur un seul cœur. Le total ne bouge pas — et le coût du va-et-vient entre threads peut même le rendre plus lent. C'est le contre-pied du chapitre suivant : Thread sur du CPU est une fausse parallélisation.The computation is pure Ruby: the GVL is never released, so the four threads take turns on a single core. The total doesn't move — and the cost of switching between threads can even make it slower. It's the next chapter's counter-take: Thread on CPU is a fake parallelization.

Bac à sable — mets des threads là où il ne faut pasSandbox — put threads where they don't belong

Les deux chronos ci-dessus se lisent ; celui-ci se fabrique. Un seul curseur sépare les deux cas — la part d'attente d'une tâche. Mets-la à 0 %, monte les workers, et regarde l'accélération descendre sous ×1 : c'est le premier bloc de code, en direct. Remonte-la à 80 % sans toucher aux workers : c'est le second. Les presets sont des réglages déjà faux ; ensuite pousse les curseurs pendant que ça tourne, la simulation ne repart pas de zéro.The two timings above are read; this one you make. A single slider separates the two cases — a task's share of waiting. Set it to 0%, push the workers up, and watch the speed-up drop below ×1: that's the first code block, live. Raise it back to 80% without touching the workers: that's the second. The presets are settings already wrong; then push the sliders while it runs, the simulation doesn't restart.

⚙ Le GVL, en vraiThe GVL, for real
Réglage libre — pousse un curseur pendant que ça tourne, la simulation ne repart pas de zéro.Free tuning — push a slider while it runs, the simulation doesn't restart.
accélération vs Ruby séquentiel (0 → ×20)speed-up vs sequential Ruby (0 → ×20)les workers accélèrentworkers speed things upils ne changent rienthey change nothingils ralentissentthey slow things down
▬ le verrou global (libre / saturé / absent)the global lock (free / saturated / absent)▬ les workers (au calcul / en attente)the workers (computing / waiting)
débitthroughput
0.0 /s
accélération vs 1 workerspeed-up vs 1 worker
×0.00
cœurs réellement utiliséscores actually used
1 / 4
CPU perdu en bascules de verrouCPU lost to lock hand-offs
9 %
plafond de la même machine sans verrousame machine, no lock at all
67 /s
Lance la simulation pour voir le verdict de ton réglage.Run the simulation to see your setting's verdict.

Files idéales, pas d'ordonnanceur, pas de cache : le modèle est juste sur le sens des pentes, pas sur les chiffres. Aucune valeur d'ici ne se recopie dans un dimensionnement — c'est ta propre application qu'il faut mesurer, et c'est tout le propos du volume.Ideal queues, no scheduler, no cache: the model is right about the direction of the slopes, not about the numbers. Nothing here transfers to a capacity plan — it's your own application you have to measure, and that's the whole point of this volume.

« Les threads accélèrent » est vrai pour l'attente, faux pour le calcul. Le GVL fait toute la différence — et seul le profil dit laquelle tu as."Threads speed things up" is true for waiting, false for computing. The GVL makes all the difference — and only the profile says which you have.
№ 03La mécaniqueThe mechanics5 min

Quatre outils, un critère : attendre (thread, fiber) ou calculer (ractor, process).Four tools, one criterion: wait (thread, fiber) or compute (ractor, process).

Le goulot identifié, l'outil suit. Pour de l'attente : Thread suffit le plus souvent — simple, supporté partout, le GVL se libère pendant l'I/O. Pour beaucoup d'I/O concurrentes (des milliers de connexions), les Fibers avec un scheduler — la gem async — offrent une concurrence coopérative ultra-légère sur une seule thread OS. Pour du calcul : il faut sortir du GVL. Les Ractors (Ruby 3) donnent un vrai parallélisme dans le process, au prix d'une contrainte que tout le Vol 4 a préparée — aucune mémoire mutable partagée, les objets échangés sont copiés ou figés. Les processus (fork) parallélisent sans partage du tout, mémoires séparées, au prix de la duplication et de l'IPC. Un critère, quatre réponses.With the bottleneck identified, the tool follows. For waiting: Thread is usually enough — simple, supported everywhere, the GVL releases during I/O. For many concurrent I/Os (thousands of connections), Fibers with a scheduler — the async gem — offer ultra-light cooperative concurrency on a single OS thread. For computing: you must leave the GVL. Ractors (Ruby 3) give true in-process parallelism, at the price of a constraint all of Vol 4 prepared — no shared mutable memory, exchanged objects are copied or frozen. Processes (fork) parallelize with no sharing at all, separate memories, at the cost of duplication and IPC. One criterion, four answers.

Attente : Thread, ou Fiber/async pour le grand nombreWaiting: Thread, or Fiber/async for the large scale
# I/O-bound → faire chevaucher les attentes

# Thread : concurrence d'attente, simple, partout
urls.map { |u| Thread.new { fetch(u) } }.each(&:value)

# Fiber + scheduler (gem async) : des MILLIERS d'I/O concurrentes, léger
require "async"
Async do
  urls.each { |u| Async { fetch(u) } }   # coopératif, une seule thread OS
end
Calcul : Ractor (in-process) ou processusComputing: Ractor (in-process) or processes
# CPU-bound → exécuter VRAIMENT en parallèle, hors GVL

# Ractor : parallélisme dans le process, SANS mémoire mutable partagée (Vol 4)
rs = 4.times.map { Ractor.new { heavy_compute } }
rs.map(&:value)            # 4 cœurs, pour de vrai — les objets passés sont
                           # copiés ou figés : pas d'état partagé, pas d'aliasing
                           # (#value depuis Ruby 4.0 ; #take en Ruby 3.x)

# Processus (fork) : parallélisme total, mémoires séparées
readers = 4.times.map do
  r, w = IO.pipe
  fork { w.write(Marshal.dump(heavy_compute)); w.close }
  r
end
Le Ractor referme le Vol 4.The Ractor closes Vol 4.

Le vrai parallélisme exige de renoncer à l'état mutable partagé — et c'est exactement le danger que le Vol 4 a nommé. Un Ractor ne peut recevoir que des objets figés (frozen) ou copiés : impossible que deux Ractors mutent le même tableau dans le dos l'un de l'autre, car l'aliasing entre eux est interdit par construction. La concurrence sûre, en Ruby comme ailleurs, c'est l'absence de partage mutable — freeze et l'immuabilité du Vol 4 reviennent ici comme la condition du parallélisme, pas comme une option. La boucle du modèle objet se referme sur elle-même.True parallelism demands giving up shared mutable state — exactly the danger Vol 4 named. A Ractor can only receive frozen or copied objects: two Ractors can't mutate the same array behind each other's backs, because aliasing between them is forbidden by construction. Safe concurrency, in Ruby as elsewhere, is the absence of shared mutation — Vol 4's freeze and immutability return here as the condition of parallelism, not an option. The object model's loop closes on itself.

Un dernier recours quand le calcul est vraiment le mur : changer d'implémentation. JRuby et TruffleRuby n'ont pas de GVL et parallélisent les threads pour de bon ; le YJIT de MRI accélère le code chaud sans rien changer au modèle. Mais — refrain du volume — on ne dégaine rien de tout cela sans un profil qui montre que le CPU, et lui seul, est le goulot.A last resort when computation is truly the wall: change implementation. JRuby and TruffleRuby have no GVL and parallelize threads for real; MRI's YJIT speeds up hot code with no change to the model. But — the volume's refrain — you reach for none of this without a profile showing that the CPU, and it alone, is the bottleneck.

№ 04Le piègeThe trap4 min

Des threads sur un calcul CPU — et l'étonnement que ça n'aille pas plus vite.Threads on a CPU computation — and the surprise it isn't faster.

Le contre-exemple obligatoire, et le contre-pied le plus frontal de la série. Une tâche est lente — un traitement d'images, un calcul lourd — alors on découpe en quatre et on lance quatre threads, certain de diviser le temps par quatre sur une machine multicœur. On mesure : rien n'a changé. C'était du CPU pur, et le GVL a fait passer les quatre threads chacun leur tour. On a ajouté de la complexité, et le risque de bugs de partage mutable (Vol 4), pour un gain nul — exactement le mauvais outil pour le bon problème. Le calcul ne se parallélise qu'en sortant du GVL : Ractors ou processus. Mais avant même de changer d'outil, le réflexe du volume : profiler pour confirmer que le goulot est bien le CPU, pas une attente déguisée.The mandatory counter-example, and the series' most frontal counter-take. A task is slow — image processing, a heavy computation — so you split it in four and launch four threads, certain you'll divide the time by four on a multicore machine. You measure: nothing changed. It was pure CPU, and the GVL took the four threads in turns. You added complexity, and the risk of shared-mutation bugs (Vol 4), for zero gain — exactly the wrong tool for the right problem. Computation parallelizes only by leaving the GVL: Ractors or processes. But before even changing tools, the volume's reflex: profile to confirm the bottleneck is really the CPU, not a disguised wait.

Threads sur du CPU : le GVL annule le gainThreads on CPU: the GVL cancels the gain
# Piège : « j'ajoute des threads pour que ce calcul aille plus vite »
def render_thumbnails(images)
  images.each_slice(images.size / 4).map do |batch|
    Thread.new { batch.each { |img| resize_and_filter(img) } }  # CPU pur
  end.each(&:join)
end

# on s'attend à ÷4 sur une machine 4 cœurs… et le temps ne bouge pas.
# resize_and_filter est du calcul Ruby : le GVL sérialise les 4 threads.
# on a ajouté de la complexité (et des bugs de partage) pour ZÉRO gain.
Ractor pour le calcul — après avoir mesuréRactor for computing — after measuring
# Le CPU se parallélise hors GVL : Ractor (ou processus)
def render_thumbnails(images)
  images.each_slice(images.size / 4).map do |batch|
    Ractor.new(batch) { |b| b.map { |img| resize_and_filter(img) } }
  end.flat_map(&:value)        # 4 cœurs RÉELLEMENT occupés (#take en 3.x)
end

# mais d'abord : MESURER que le goulot est bien le CPU, pas le décodage I/O.
require "benchmark"
Benchmark.realtime { render_thumbnails(images) }   # avant / après, on compare
Le contre-exemple obligatoire.The mandatory counter-example.

« Ajouter des threads » est le réflexe par défaut de la performance, et il est faux une fois sur deux : sur du CPU, le GVL en fait une illusion coûteuse. Le bon enchaînement est toujours le même : profile (où part le temps : attente ou calcul ?), choisis l'outil selon le goulot (thread/async pour l'attente, Ractor/processus pour le calcul), re-mesure le gain. Et garde en tête le prix caché des threads sur du CPU : non seulement zéro accélération, mais une porte ouverte aux bugs d'état partagé que le Vol 4 a appris à craindre. Le bon outil n'est pas le plus familier — c'est celui qui répond au goulot que tu as mesuré."Add threads" is performance's default reflex, and it's wrong half the time: on CPU, the GVL makes it a costly illusion. The right sequence is always the same: profile (where does time go: waiting or computing?), choose the tool by the bottleneck (thread/async for waiting, Ractor/process for computing), re-measure the gain. And keep in mind threads' hidden price on CPU: not only zero speedup, but an open door to the shared-state bugs Vol 4 taught you to fear. The right tool isn't the most familiar — it's the one that answers the bottleneck you measured.

« J'ajoute des threads » n'est pas une stratégie — c'est un pari sur la nature du goulot. Sur du calcul, le GVL te le fait perdre."I'll add threads" isn't a strategy — it's a bet on the bottleneck's nature. On computing, the GVL makes you lose it.
№ 05Bilan & éditoWrap-up & editorial3 min

Le goulot décide l'outil : attente ou calcul — et tu le mesures.The bottleneck decides the tool: waiting or computing — and you measure it.

En MRI, le GVL n'autorise qu'un thread Ruby à la fois : les threads ne parallélisent pas le calcul, mais chevauchent l'attente, car le GVL se libère pendant l'I/O. D'où une seule question avant tout : mon goulot est-il de l'attente ou du calcul ? Pour l'attente, Thread ou les Fibers/async. Pour le calcul, sortir du GVL — Ractors (sans état mutable partagé, l'héritage du Vol 4) ou processus, voire une autre implémentation. Et le piège qui résume la série : ajouter des threads sur du CPU, par réflexe, sans avoir mesuré le goulot.In MRI, the GVL allows only one Ruby thread at a time: threads don't parallelize computation, but overlap waiting, because the GVL releases during I/O. Hence one question before all: is my bottleneck waiting or computing? For waiting, Thread or Fibers/async. For computing, leave the GVL — Ractors (no shared mutable state, Vol 4's legacy) or processes, even another implementation. And the trap that sums up the series: adding threads on CPU, by reflex, without having measured the bottleneck.

Le goulotThe bottleneckL'outilThe toolLe réflexeThe reflex
Attente (I/O, réseau, disque)Waiting (I/O, network, disk)Thread — le GVL se libère pendant l'attente.Thread — the GVL releases during the wait.Les attentes se chevauchent : gain réel.The waits overlap: real gain.
Beaucoup d'I/O concurrentesMany concurrent I/OsFibers + scheduler (async).Fibers + scheduler (async).Coopératif, léger, des milliers de connexions.Cooperative, light, thousands of connections.
Calcul (CPU pur)Computing (pure CPU)Ractor ou processus — hors GVL.Ractor or processes — outside the GVL.Pas de Thread : le GVL le sérialiserait.No Thread: the GVL would serialize it.
« j'ajoute des threads »'I'll add threads'D'abord : profiler le goulot.First: profile the bottleneck.Le bon outil répond au goulot mesuré.The right tool answers the measured bottleneck.
Ruby n'est pas « lent en concurrence » — il est excellent pour l'attente, contraint pour le calcul. Confondre les deux, c'est se battre contre le GVL.Ruby isn't "slow at concurrency" — it's excellent at waiting, constrained at computing. Confusing the two is fighting the GVL.
Mot de l'éditeurFrom the editor

Fin de la série : un seul envoi de message, et tout en découle.End of the series: a single message send, and all of it follows.

Neuf volumes pour une seule règle. On a commencé par 1 + 2, c'est-à-dire 1.+(2) : tout est objet, tout est message. De là, sans rien ajouter d'autre, tout s'est déplié. Les blocs sont des messages confiés à un objet qui décide quand les rappeler. Le duck typing, c'est demander si l'objet répond, pas de quel type il est. La mutabilité, le partage, le gel : qui tient le même objet, et a-t-on le droit de le changer. Les modules et la chaîne d'ancêtres : par où le message cherche sa réponse. La métaprog : manipuler à l'exécution les classes et méthodes, qui sont elles-mêmes des objets. Les exceptions : des objets levés et triés par type. Enumerable : un each, et cinquante messages répondent. Et le coût, enfin : résoudre un message, allouer un objet, exécuter sous le GVL — des prix réels, localisés, mesurables. Pas une fonctionnalité qui ne soit une conséquence de la première règle.Nine volumes for a single rule. We began with 1 + 2, that is 1.+(2): everything is an object, everything a message. From there, adding nothing else, it all unfolded. Blocks are messages handed to an object that decides when to call them back. Duck typing is asking whether the object answers, not what type it is. Mutability, sharing, freezing: who holds the same object, and may you change it. Modules and the ancestor chain: where the message looks for its answer. Metaprogramming: manipulating at runtime the classes and methods, which are themselves objects. Exceptions: objects raised and sorted by type. Enumerable: one each, and fifty messages answer. And the cost, finally: resolving a message, allocating an object, running under the GVL — real, localized, measurable prices. Not one feature that isn't a consequence of the first rule.

La promesse de départ est tenue. Au début, obj.foo se lisait comme un accès à un champ ou l'appel d'une fonction. À la fin, on voit autre chose : un message qui part vers un objet, lequel décide, librement, quoi en faire — le résoudre le long de sa chaîne, le déléguer, l'intercepter, le refuser. Ce déplacement du regard est tout ce que la série voulait installer. Il ne se grind pas, il se comprend une fois — et ensuite il relit pour toi tout le langage. Le reste, blocs, mixins, métaprog, lazy, Ractors, ce ne sont plus des incantations à collectionner : ce sont les formes que prend une seule idée quand on la suit jusqu'au bout. Bon code à toi — et regarde toujours qui reçoit le message.The opening promise is kept. At first, obj.foo read like a field access or a function call. At the end, you see something else: a message leaving toward an object, which decides, freely, what to do with it — resolve it along its chain, delegate it, intercept it, refuse it. That shift of gaze is all the series meant to install. It isn't grinded, it's understood once — and then it rereads the whole language for you. The rest — blocks, mixins, metaprogramming, lazy, Ractors — are no longer incantations to collect: they're the shapes a single idea takes when you follow it to the end. Good code to you — and always look at who receives the message.