<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Huzaifa Saifuddin</title>
    <description>The latest articles on DEV Community by Huzaifa Saifuddin (@huzaifa_saifuddin_370679c).</description>
    <link>https://dev.to/huzaifa_saifuddin_370679c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4195845%2F988acafd-23bf-4e0a-b74e-428da83ca0c3.jpg</url>
      <title>DEV Community: Huzaifa Saifuddin</title>
      <link>https://dev.to/huzaifa_saifuddin_370679c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/huzaifa_saifuddin_370679c"/>
    <language>en</language>
    <item>
      <title>High CPU gets throttled. High Memory gets killed.</title>
      <dc:creator>Huzaifa Saifuddin</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:34:45 +0000</pubDate>
      <link>https://dev.to/huzaifa_saifuddin_370679c/high-cpu-gets-throttled-high-memory-gets-killed-5g37</link>
      <guid>https://dev.to/huzaifa_saifuddin_370679c/high-cpu-gets-throttled-high-memory-gets-killed-5g37</guid>
      <description>&lt;p&gt;If you've worked on anything at scale, you've seen this in an incident channel: a pod is unhappy, dashboards are red, and someone says "it's hitting its limits." The usual fix is to bump the resource limits and move on.&lt;/p&gt;

&lt;p&gt;Sometimes that's the right call — traffic grew, infra needs to grow with it. But sometimes it's not an infra problem at all. It's a few lines of code doing more work, or holding onto more memory, than they need to. Scaling up just buys time before the same bug hits a bigger ceiling.&lt;/p&gt;

&lt;p&gt;In this article, we'll look at why a CPU limit and a memory limit fail in completely different ways, then walk through three real versions of the same Ruby script — each one fixing the previous version's problem while quietly creating, or finally avoiding, the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How CPU and Memory Limits Actually Work
&lt;/h2&gt;

&lt;p&gt;A Kubernetes resource block looks symmetric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512Mi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two limits, same shape, same file. But they're enforced by two completely different mechanisms in the kernel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CPU limits are enforced by the CFS quota&lt;/strong&gt; (&lt;code&gt;cpu.cfs_quota_us&lt;/code&gt; in the cgroup). If your process wants more CPU than its quota allows, the kernel scheduler just gives it fewer time slices. The process doesn't stop — it keeps running, keeps its state, keeps serving requests. It's just &lt;em&gt;slower&lt;/em&gt;. Requests take longer, queues back up, p99 latency climbs. Nothing crashes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory limits are enforced by the OOM killer.&lt;/strong&gt; There's no equivalent "slow down." Memory is a hard ceiling — either you're under it or you're not. The instant your process's RSS crosses &lt;code&gt;memory.max&lt;/code&gt;, the kernel sends SIGKILL. No warning, no graceful degradation, no "please free up some memory." The process is just gone, and whatever it was doing is gone with it. Kubernetes sees the container exit and restarts it — from zero.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjb27xwyzat1ne57gctbo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjb27xwyzat1ne57gctbo.png" alt=" " width="800" height="804"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same YAML, same-looking limits, completely different failure modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;CPU limit exceeded&lt;/th&gt;
&lt;th&gt;Memory limit exceeded&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mechanism&lt;/td&gt;
&lt;td&gt;CFS quota (scheduler throttling)&lt;/td&gt;
&lt;td&gt;OOM killer (SIGKILL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What happens&lt;/td&gt;
&lt;td&gt;Process keeps running, slower&lt;/td&gt;
&lt;td&gt;Process is killed instantly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symptom&lt;/td&gt;
&lt;td&gt;Rising latency, timeouts&lt;/td&gt;
&lt;td&gt;Restarts, &lt;code&gt;OOMKilled&lt;/code&gt;, exit 137&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recoverable mid-request?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A CPU limit slows your process down. It keeps running, keeps its state, and recovers on its own once pressure eases.&lt;/li&gt;
&lt;li&gt;A memory limit kills your process outright, the instant it crosses the line. There's no mid-request recovery — it restarts from zero.&lt;/li&gt;
&lt;li&gt;You can engineer graceful degradation for CPU pressure (queue, retry, shed load). You can't do that for memory pressure after the fact — it has to be kept under control before it happens.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three Attempts at the Same Problem
&lt;/h2&gt;

&lt;p&gt;To make this concrete, let's look at a boring, realistic task — find duplicate order IDs in a 50,000-row CSV export — written three times, the way most of us actually arrive at a "good" solution: badly, then fast-but-wrong, then actually right.&lt;/p&gt;

&lt;p&gt;The file looks like this (real columns, representative rows — &lt;code&gt;ORD-1042&lt;/code&gt; shows up twice, which is exactly the kind of duplicate we're hunting for):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order_id,customer,product,price,notes
ORD-1042,customer_1042,product_187,43.10,Deliver to front desk, ring bell twice.
ORD-1043,customer_1043,product_298,70.47,Leave with neighbor if not home.
ORD-1042,customer_2290,product_112,12.95,Gift wrap requested.
ORD-1044,customer_1044,product_044,6.97,No substitutions please.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Attempt 1: The Naive Version (CPU Problem)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# v1_naive.rb&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readlines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;chomp: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;
  &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;other_row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
    &lt;span class="n"&gt;other_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;other_row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;
    &lt;span class="n"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;other_id&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Explanation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every row gets compared against every other row — O(n²) comparisons, no shortcuts.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;order_id&lt;/code&gt; is pulled straight out of the real CSV line on every single comparison, so there's no pre-processing step hiding the work elsewhere.&lt;/li&gt;
&lt;li&gt;This is pure computation. No file is reopened, no network call is made, no extra memory is allocated beyond the lines already in hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured on an 8,000-row slice with &lt;code&gt;/usr/bin/time -l&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows: 8000, duplicate hits: 387
19.81 real   19.62 user   0.16 sys
maximum resident set size: 26MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;19.62 of 19.81 seconds was CPU time — this is almost &lt;strong&gt;entirely&lt;/strong&gt; CPU-bound, not I/O. Memory stays flat at Ruby's own baseline (~26MB). At the full 50,000-row file, this scales with n², so it would take roughly &lt;strong&gt;13 minutes&lt;/strong&gt; for a single dedup pass.&lt;/p&gt;

&lt;p&gt;In a pod with a 1-core CPU limit, this doesn't crash. It just sits there grinding, request handlers backing up behind it, until something upstream times out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: The "Fix" (CPU Solved, Memory Problem Introduced)
&lt;/h3&gt;

&lt;p&gt;The instinct here is right: stop comparing every row to every row, use a hash for O(1) lookups.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# v2_hash_full_records.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"csv"&lt;/span&gt;
&lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="no"&gt;CSV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;foreach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;headers: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;key?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;   &lt;span class="c1"&gt;# caches the WHOLE row, not just the id&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Explanation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A hash keyed by &lt;code&gt;order_id&lt;/code&gt; turns the lookup into O(1), so the whole scan becomes O(n) instead of O(n²).&lt;/li&gt;
&lt;li&gt;Every row is now parsed properly as a CSV row, not a raw split line — which is more correct, but also more expensive per row.&lt;/li&gt;
&lt;li&gt;The bug is one line: &lt;code&gt;records[id] = row.to_h&lt;/code&gt; caches every column of every unique row, not just the id. Nothing ever gets evicted, so the hash grows for the lifetime of the scan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured on the full 50,000-row (23MB) file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unique rows cached: 47480, duplicate hits: 2520
0.65 real   0.48 user   0.06 sys
maximum resident set size: 90MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;0.65 seconds.&lt;/strong&gt; The CPU problem is completely solved — roughly 1,190x faster than the naive version would be at this scale. But look at memory: &lt;strong&gt;90MB&lt;/strong&gt; to process a 23MB file. Every unique row's full data is cached in the hash for the lifetime of the scan, so memory now scales with &lt;em&gt;file size&lt;/em&gt;, not with what the job actually needs.&lt;/p&gt;

&lt;p&gt;If this pod had a 75MB memory limit (a reasonable limit for a small background worker), this version would get OOMKilled partway through the file — probably somewhere around row 39,000 of 50,000 — and never finish. No error message, no stack trace. Just a SIGKILL and a restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3: The Actual Fix (Both Problems Solved)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# v3_stream_set.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"set"&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;span class="n"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;foreach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;chomp: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_index&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;duplicates&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;   &lt;span class="c1"&gt;# only the id is ever kept in memory&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Explanation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same O(1) hash-lookup trick as Attempt 2 (a &lt;code&gt;Set&lt;/code&gt; is backed by a &lt;code&gt;Hash&lt;/code&gt;), so CPU stays fast.&lt;/li&gt;
&lt;li&gt;The file is streamed line-by-line with &lt;code&gt;File.foreach&lt;/code&gt;, instead of being parsed into row objects up front — nothing is ever held in memory beyond the current line.&lt;/li&gt;
&lt;li&gt;Only the &lt;code&gt;order_id&lt;/code&gt; string is ever stored, in the &lt;code&gt;Set&lt;/code&gt; — never the rest of the row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured on the full 50,000-row file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unique ids tracked: 47480, duplicate hits: 2520
0.08 real   0.06 user   0.01 sys
maximum resident set size: 29MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;0.08 seconds&lt;/strong&gt; — about 8x faster than Attempt 2, and roughly 9,700x faster than Attempt 1 would be at this scale. &lt;strong&gt;29MB&lt;/strong&gt; peak memory — close to Ruby's own interpreter baseline, and about a third of what Attempt 2 used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Peak RSS&lt;/th&gt;
&lt;th&gt;What happens in a real pod&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 · nested loop&lt;/td&gt;
&lt;td&gt;8,000 (full file: ~13 min projected)&lt;/td&gt;
&lt;td&gt;19.81s&lt;/td&gt;
&lt;td&gt;26MB&lt;/td&gt;
&lt;td&gt;CPU-bound — pod throttles, requests queue up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 · hash, full rows&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;0.65s&lt;/td&gt;
&lt;td&gt;90MB&lt;/td&gt;
&lt;td&gt;Fast, but OOMKilled crossing a 75MB limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 · stream + set&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;0.08s&lt;/td&gt;
&lt;td&gt;29MB&lt;/td&gt;
&lt;td&gt;Fast &lt;em&gt;and&lt;/em&gt; stays well under any reasonable limit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Going from Attempt 1 to Attempt 2 is the fix most of us reach for first, and it's not wrong — the algorithmic complexity (O(n²) → O(n)) was the right call.&lt;/li&gt;
&lt;li&gt;The bug in Attempt 2 was in &lt;em&gt;what&lt;/em&gt; got cached, not &lt;em&gt;how&lt;/em&gt; the lookup worked.&lt;/li&gt;
&lt;li&gt;Attempt 3 fixes both at once not by trading CPU for memory, but by shrinking the footprint everywhere: stream the input, cache only the minimum.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;A few practical signals for telling the two apart in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check CPU first:&lt;/strong&gt; &lt;code&gt;kubectl top pod&lt;/code&gt; shows CPU pegged near its limit while memory stays flat and boring. If you're scraping cgroup/cAdvisor metrics, &lt;code&gt;container_cpu_cfs_throttled_seconds_total&lt;/code&gt; climbing confirms it. The symptom is latency, not crashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for OOMKilled:&lt;/strong&gt; &lt;code&gt;kubectl describe pod&lt;/code&gt; shows &lt;code&gt;OOMKilled&lt;/code&gt; in the last state, with exit code 137, and the restart count ticking up. Memory usually climbs steadily right up until the kill — often a sawtooth pattern if you're graphing RSS over time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce locally with &lt;code&gt;/usr/bin/time&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;/usr/bin/time -l&lt;/code&gt; (macOS) or &lt;code&gt;/usr/bin/time -v&lt;/code&gt; (Linux) on a local repro gives you both &lt;code&gt;user&lt;/code&gt;/&lt;code&gt;sys&lt;/code&gt; CPU time and &lt;code&gt;maximum resident set size&lt;/code&gt; in one shot — it's usually the fastest way to tell the two apart before reaching for a profiler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go deeper when needed:&lt;/strong&gt; &lt;code&gt;stackprof&lt;/code&gt; or &lt;code&gt;rbtrace&lt;/code&gt; for CPU hot paths in Ruby, and &lt;code&gt;memory_profiler&lt;/code&gt; or &lt;code&gt;derailed_benchmarks&lt;/code&gt; for allocation and retention issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A CPU limit and a memory limit look identical in a YAML file, but they fail in opposite ways — one degrades gracefully, the other doesn't degrade at all. That asymmetry should shape how you design around each one: build retry and backoff for CPU pressure, but treat memory growth as something to prevent, not something to recover from.&lt;/p&gt;

&lt;p&gt;The three versions of the dedup script above show how easy it is to fix one and create the other without noticing. Going from a nested loop to a hash lookup is the right instinct — the trap is in what the hash ends up holding onto. Once you're deliberate about caching only the smallest piece of data you actually need, and streaming rather than loading, both problems tend to disappear at the same time.&lt;/p&gt;

&lt;p&gt;Next time a pod is unhappy and the instinct is to scale up, it's worth taking thirty seconds to check which limit it actually hit first. Sometimes it really is infra. Often, it's a line of code like &lt;code&gt;records[id] = row.to_h&lt;/code&gt;, quietly doing more than it needs to.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>Thread vs Fiber vs Ractor: I Measured Ruby's Concurrency Model</title>
      <dc:creator>Huzaifa Saifuddin</dc:creator>
      <pubDate>Tue, 20 Oct 2026 10:05:32 +0000</pubDate>
      <link>https://dev.to/huzaifa_saifuddin_370679c/thread-vs-fiber-vs-ractor-i-measured-rubys-concurrency-model-2577</link>
      <guid>https://dev.to/huzaifa_saifuddin_370679c/thread-vs-fiber-vs-ractor-i-measured-rubys-concurrency-model-2577</guid>
      <description>&lt;p&gt;A few weeks back, at RailsConf Pune (Deccan Queen), Shopify engineers gave a talk titled &lt;em&gt;"When Rails Met Fibers: Migrating Sidekiq from Puma to Falcon."&lt;/em&gt; Their challenge: an AI chatbot where every request was itself waiting on an external AI API call, and Puma's thread-per-request model was buckling under all that waiting.&lt;/p&gt;

&lt;p&gt;I've used &lt;code&gt;Thread&lt;/code&gt; for over a decade — Puma's thread pool, Sidekiq workers, the usual. But that talk made me want to go past the surface-level API and actually verify what's happening underneath: what the GVL really guarantees, where Fiber genuinely earns its place, and where Ractor fits in. So I sat down and did exactly that — measuring Thread, Fiber, and Ractor myself instead of taking any of it on faith.&lt;/p&gt;

&lt;p&gt;Every number in this post came from a script I ran. The full, runnable repo — one commit per finding — is here: &lt;strong&gt;&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor" rel="noopener noreferrer"&gt;thread-fiber-ractor on GitHub&lt;/a&gt;&lt;/strong&gt;. Clone it and run the scripts yourself; your numbers will differ from mine (different machine, different core count), and that's kind of the point — don't trust my numbers either, verify them.&lt;/p&gt;

&lt;p&gt;Built and run on Ruby 4.0.7.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8wv9m2w17ghiyyocqzi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8wv9m2w17ghiyyocqzi.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The GVL, the one idea everything else builds on
&lt;/h2&gt;

&lt;p&gt;MRI's Global VM Lock (GVL) lets exactly one thread execute a Ruby instruction at any given instant, in one process — no matter how many threads you spawn. That's the single fact that explains almost everything below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A thread blocked on I/O (a network call, a DB round-trip) &lt;strong&gt;releases the GVL&lt;/strong&gt;, so another thread can run while it waits.&lt;/li&gt;
&lt;li&gt;A thread doing pure CPU work &lt;strong&gt;won't release it voluntarily&lt;/strong&gt; — but it's not quite "holds the lock forever" either. MRI's VM has an internal timer that can still force a switch between CPU-bound threads. The catch: that forced switch just means CPU-bound threads take turns. It doesn't make them any faster, because only one of them is ever actually executing at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second point is the one worth sitting with, because it's exactly what separates Thread from Fiber later in this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thread: concurrency on I/O, nothing on CPU
&lt;/h2&gt;

&lt;p&gt;Here's the proof, straight from &lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/01_gvl_thread_basics.rb" rel="noopener noreferrer"&gt;&lt;code&gt;01_gvl_thread_basics.rb&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"benchmark"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;upto&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;is_prime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="no"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;none?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_prime&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="n"&gt;count&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;

&lt;span class="c1"&gt;# CPU-bound: does Thread give you parallelism?&lt;/span&gt;
&lt;span class="n"&gt;serial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realtime&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;N&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;count_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;parallel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realtime&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:join&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# I/O-bound: does Thread give you concurrency?&lt;/span&gt;
&lt;span class="n"&gt;serial_io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realtime&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;parallel_io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realtime&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:join&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---- CPU-bound: does Thread give you parallelism? ----
Two calls, one after another:   5.77s
Same two calls, on two Threads:  5.79s
speedup: 1.0x  (expect ~1.0x — the GVL serializes CPU-bound Ruby)

---- I/O-bound: does Thread give you concurrency? ----
Two sleeps, one after another:  2.0s
Same two sleeps, on two Threads: 1.01s
speedup: 1.98x  (expect ~2.0x — I/O releases the GVL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical CPU-bound work: &lt;strong&gt;zero speedup&lt;/strong&gt; across two threads — in fact marginally slower (5.79s vs 5.77s), which lines up with forced-switch overhead rather than one thread holding the lock uninterrupted. The same two threads doing I/O get a real ~2x speedup, because I/O actually releases the lock.&lt;/p&gt;

&lt;p&gt;This is Thread's whole story: great concurrency primitive for I/O you already have (that's what Puma's thread pool and Sidekiq workers are doing for you), zero help for CPU-bound work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden ceiling
&lt;/h3&gt;

&lt;p&gt;Threads map to real OS kernel threads, and the kernel hard-caps how many of those can exist. On my machine (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/02_thread_ceiling.rb" rel="noopener noreferrer"&gt;&lt;code&gt;02_thread_ceiling.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Creating Threads until the OS refuses...
Hit a hard OS wall at 2046 threads -- can't create Thread: Resource temporarily unavailable

Creating 50000 Fibers (not an OS construct — no equivalent ceiling)...
Created all 50000 Fibers with zero errors (24.4x past where Threads hit their wall)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2,046 threads and the OS refuses to create more. 50,000 Fibers, in the same process, zero errors — because a Fiber isn't an OS-level construct at all. Which is a good place to introduce Fiber properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fiber: cooperative, cheap, and not a speed trick
&lt;/h2&gt;

&lt;p&gt;A Fiber is a coroutine: pause and resume control flow, entirely inside one Ruby process, with no OS involvement. &lt;code&gt;Fiber.new&lt;/code&gt; creates one, &lt;code&gt;Fiber.yield&lt;/code&gt; pauses it, &lt;code&gt;#resume&lt;/code&gt; continues it (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/03_fiber_basics.rb" rel="noopener noreferrer"&gt;&lt;code&gt;03_fiber_basics.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;fiber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Fiber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"fiber: step 1"&lt;/span&gt;
  &lt;span class="no"&gt;Fiber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;
  &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"fiber: step 2"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; "fiber: step 1"&lt;/span&gt;
&lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; "fiber: step 2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that trips people up: a Fiber only ever runs on whichever OS thread calls &lt;code&gt;#resume&lt;/code&gt;. Resuming it from a different thread raises &lt;code&gt;FiberError: fiber called across threads&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fiber alone is not fast
&lt;/h3&gt;

&lt;p&gt;It's tempting to assume Fiber is a speed trick because it's a newer, fancier control-flow tool. It isn't. A Fiber switch has real overhead, and for a simple "stop when you hit a condition" loop, a plain loop wins every time (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/04_fiber_is_not_a_speed_trick.rb" rel="noopener noreferrer"&gt;&lt;code&gt;04_fiber_is_not_a_speed_trick.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain loop: 0.0824s
fiber:      0.5839s
fiber is 7.1x SLOWER than a plain loop for this
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fiber's value isn't raw speed. It's structural — which is exactly what Fiber::SchedulerInterface unlocks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real payoff: Fiber::SchedulerInterface
&lt;/h3&gt;

&lt;p&gt;Registering a scheduler (via &lt;code&gt;Fiber.set_scheduler&lt;/code&gt;) makes Ruby intercept blocking calls — &lt;code&gt;sleep&lt;/code&gt;, socket I/O — and hand them to the scheduler instead of actually blocking the OS thread. The scheduler registers interest with the kernel's own I/O multiplexer (epoll/kqueue), and the thread is immediately free to run other ready fibers. &lt;strong&gt;This is the exact mechanism behind Falcon&lt;/strong&gt; — the app server from the RailsConf talk that kicked this whole thing off.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;async&lt;/code&gt; gem is the reference implementation. Here's the proof (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/05_fiber_scheduler.rb" rel="noopener noreferrer"&gt;&lt;code&gt;05_fiber_scheduler.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"async"&lt;/span&gt;

&lt;span class="no"&gt;Async&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="mi"&gt;3_000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---- Threads: 20 concurrent 'slow I/O calls' (sleep 1 each) ----
  wall time: 1.01s
  OS threads used: 20 (one per call)

---- Fibers + scheduler: same 20 calls, one task.async each ----
  wall time: 1.0s
  OS threads used: 1 (every task cooperatively shared the same thread)

---- Now push past where Threads hit their wall (~2,046) ----
Running 3000 concurrent 'slow calls' as Fibers -- Thread.new would have crashed by now.
  wall time: 1.11s
  3000 concurrent I/O-bound calls, 1 OS thread, no crash.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;3,000 concurrent "I/O calls," one OS thread, 1.11 seconds. That's something Thread literally cannot attempt past its own ceiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cost: no preemption safety net
&lt;/h3&gt;

&lt;p&gt;Here's the part that doesn't make it into the pitch. Thread's GVL still has that forced timer switch we talked about earlier — even a pure CPU loop gets preempted eventually. Fiber has nothing like it. A fiber only ever gives up the thread when &lt;strong&gt;it&lt;/strong&gt; decides to, by hitting a blocking call. A CPU-bound fiber with zero yield points starves every other fiber on that thread for its entire run — even ones whose I/O already finished (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/06_fiber_has_no_preemption.rb" rel="noopener noreferrer"&gt;&lt;code&gt;06_fiber_has_no_preemption.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Async&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;busy_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;# pure CPU, no yield point at all&lt;/span&gt;
  &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;# "ready" almost immediately&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[CPU-bound fiber] finished after 2.0s
[I/O fiber]        finished after 2.1s (expected ~0.1s if it weren't starved)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The I/O fiber was ready in 0.1 seconds. It didn't actually finish until 2.1 seconds — starved the entire time by a CPU-bound fiber that never yielded. If that CPU-bound fiber ran forever, so would the wait. There's no timeout coming to rescue you.&lt;/p&gt;

&lt;p&gt;That tradeoff — massive I/O concurrency, zero CPU-starvation protection — is almost certainly the exact thing Shopify had to reason about migrating to Falcon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ractor: actual parallelism
&lt;/h2&gt;

&lt;p&gt;Thread gives you concurrency, never parallelism, on CPU-bound work — that's the GVL's whole deal. Ractor is the one primitive that breaks that rule entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ractor basics
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/07_ractor_basics.rb" rel="noopener noreferrer"&gt;07_ractor_basics.rb&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"hello from a ractor"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; "hello from a ractor"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth noting: Ractor's API is still marked experimental and &lt;strong&gt;has changed across Ruby versions&lt;/strong&gt; — an older &lt;code&gt;#take&lt;/code&gt; method from some tutorials doesn't exist anymore; &lt;code&gt;#value&lt;/code&gt; is the current way to get a Ractor's result. Don't trust a blog post's (including this one's) method names without checking against your own Ruby version.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sharing rules
&lt;/h3&gt;

&lt;p&gt;Since Ractors genuinely run at the same instant, Ruby has to prevent two of them from mutating the same object at once. It does this by making the setup physically impossible, not just "risky" (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/08_ractor_sharing_rules.rb" rel="noopener noreferrer"&gt;&lt;code&gt;08_ractor_sharing_rules.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# closures over a mutable outer variable are refused immediately&lt;/span&gt;
&lt;span class="n"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; ArgumentError: can not isolate a Proc because it accesses outer variables (arr)&lt;/span&gt;

&lt;span class="c1"&gt;# #send of a mutable object copies it&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;object_id&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; false, it's a copy&lt;/span&gt;

&lt;span class="c1"&gt;# a frozen object is shared by TRUE reference&lt;/span&gt;
&lt;span class="n"&gt;frozen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;
&lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object_id&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; true, zero-copy&lt;/span&gt;

&lt;span class="c1"&gt;# Ractor.make_shareable deep-freezes recursively&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;roles: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"engineer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make_shareable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frozen?&lt;/span&gt;          &lt;span class="c1"&gt;# =&amp;gt; true&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:roles&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;frozen?&lt;/span&gt;  &lt;span class="c1"&gt;# =&amp;gt; true, the nested array too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put together: there is no path to "two Ractors holding a live reference to the same mutable object." Not a risky one, not a rare one — none. If you need real shared &lt;em&gt;mutable&lt;/em&gt; state across Ractors, the only sanctioned path is message-passing — each Ractor owns its own private state, and they coordinate by sending copies (or transferring ownership via &lt;code&gt;move: true&lt;/code&gt;) across, never by two of them reaching into the same box at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  The payoff: real CPU speedup
&lt;/h3&gt;

&lt;p&gt;Same CPU benchmark as the Thread section, now split across Ractors instead (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/09_ractor_vs_thread_cpu.rb" rel="noopener noreferrer"&gt;&lt;code&gt;09_ractor_vs_thread_cpu.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;ractors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Ractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;ractors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---- Serial: 4 calls, one after another ----
  wall time: 5.79s

---- Threads: 4 concurrent, same GVL ----
  wall time: 5.82s
  speedup vs serial: 0.99x

---- Ractors: 4 concurrent, independent GVLs ----
  wall time: 1.73s
  speedup vs serial: 3.35x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same workload, same machine, same core count available the whole time. Thread: flat. Ractor: 3.35x faster. The only variable that changed is whether each worker shares one GVL or gets its own.&lt;/p&gt;

&lt;p&gt;Worth knowing before you reach for this in a Rails app: &lt;strong&gt;ActiveRecord isn't Ractor-safe yet.&lt;/strong&gt; I tried calling &lt;code&gt;Rails.application&lt;/code&gt; and &lt;code&gt;ApplicationRecord.connection&lt;/code&gt; from inside a Ractor and both hard-failed — &lt;code&gt;Rails.application&lt;/code&gt; raises &lt;code&gt;Ractor::IsolationError&lt;/code&gt; because &lt;code&gt;Rails&lt;/code&gt; itself holds the booted app in a mutable instance variable only the main Ractor can read. So the practical pattern is: pull data out of Rails/AR &lt;em&gt;first&lt;/em&gt;, on the main Ractor, then hand off only plain, frozen data to a pool of Ractors for the CPU-heavy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the Rails &lt;code&gt;Current.user&lt;/code&gt; gotcha under Fiber concurrency
&lt;/h2&gt;

&lt;p&gt;This one connects straight back to the Falcon migration. Most Rails apps keep request-scoped context — current user, request id — via &lt;code&gt;ActiveSupport::CurrentAttributes&lt;/code&gt;. It's built on &lt;code&gt;ActiveSupport::IsolatedExecutionState&lt;/code&gt;, which &lt;strong&gt;defaults to &lt;code&gt;isolation_level = :thread&lt;/code&gt;&lt;/strong&gt; — storing state as a plain attribute on the shared &lt;code&gt;Thread&lt;/code&gt; object, not scoped per-fiber.&lt;/p&gt;

&lt;p&gt;Under a Fiber-scheduler server like Falcon, multiple fibers share one OS thread. With the default &lt;code&gt;:thread&lt;/code&gt; isolation, they'll clobber each other's &lt;code&gt;Current&lt;/code&gt; state (&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor/blob/main/10_thread_current_gotcha.rb" rel="noopener noreferrer"&gt;&lt;code&gt;10_thread_current_gotcha.rb&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Current&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CurrentAttributes&lt;/span&gt;
  &lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:request_id&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;Async&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;async&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"req-A"&lt;/span&gt;
    &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_id&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; ???&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;async&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"req-B"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---- isolation_level = :thread (Rails' default) ----
  [A] woke back up. Current.request_id is now: "req-B"

---- isolation_level = :fiber ----
  [A] woke back up. Current.request_id is now: "req-A"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the default, fiber A's own request id got overwritten by fiber B's — a different request's data leaking into fiber A's execution. Rails actually ships a real fix for this: &lt;code&gt;config.active_support.isolation_level = :fiber&lt;/code&gt;. If your app ever moves to a Fiber-scheduler server, this is a config line worth knowing exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one do you actually need?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thread&lt;/strong&gt; — concurrency on I/O you already have (Puma, Sidekiq). Never parallelism on CPU-bound Ruby. Hard OS ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fiber&lt;/strong&gt; — I/O concurrency at a scale Thread can't reach (thousands of fibers, one OS thread). Cooperative, with zero preemption safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ractor&lt;/strong&gt; — compute parallelism Thread can never give you. The price is real isolation: no shared mutable state, enforced by the VM, and (for now) no ActiveRecord inside one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these replace the others. Pick based on where your actual bottleneck is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;All ten scripts, with real output and one commit per finding: &lt;strong&gt;&lt;a href="https://github.com/HuzaifaSaifuddin/thread-fiber-ractor" rel="noopener noreferrer"&gt;github.com/HuzaifaSaifuddin/thread-fiber-ractor&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/HuzaifaSaifuddin/thread-fiber-ractor
&lt;span class="nb"&gt;cd &lt;/span&gt;thread-fiber-ractor
bundle &lt;span class="nb"&gt;install
&lt;/span&gt;ruby 01_gvl_thread_basics.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your numbers will come out different from mine — different machine, different core count, different load. Run them anyway. That's the whole point of this post: stop trusting the docs, go measure it.&lt;/p&gt;

&lt;p&gt;If you've used Fiber or Ractor in a real app — or migrated a service to Falcon — I'd genuinely like to hear what you ran into. Drop it in the comments.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>performance</category>
      <category>ruby</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
