<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged concurrency</title>
<link>https://coderlegion.com/tag/concurrency</link>
<description>Powered by Question2Answer</description>
<item>
<title>How to Lock a Function Across Processes with a Decorator in Python</title>
<link>https://coderlegion.com/30240/how-to-lock-a-function-across-processes-with-a-decorator-in-python</link>
<description>&lt;p&gt;Sometimes several processes can call the same function at once, even though its logic is completely unsuited to that. One worker changes the password for an external system, another does exactly the same thing, and a third is trying to log in. Everyone meant well, but now nobody knows the current password XD.&lt;/p&gt;
&lt;p&gt;A similar problem comes up when refreshing shared data, generating the same report, or modifying a file. If these operations get in each other's way, we get a classic &lt;em&gt;race condition&lt;/em&gt;: a race where the winner sometimes turns out to be an error message.&lt;/p&gt;
&lt;p&gt;I wanted to solve this with a decorator that lets you choose between two behaviors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;skip&lt;/code&gt;&lt;/strong&gt; — if someone is already executing the function, skip the next call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;wait&lt;/code&gt;&lt;/strong&gt; — wait until the function is available, then execute it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also needed an optional delay after acquiring a lock that had previously been held. Some external systems need a moment to digest a change. Apparently, they enjoy coffee breaks too.&lt;/p&gt;
&lt;h3 id=&quot;the-decorator&quot;&gt;The Decorator&lt;/h3&gt;
&lt;p&gt;For locking, I used &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.python.org/3/library/fcntl.html#fcntl.flock&quot;&gt;&lt;code&gt;fcntl.flock&lt;/code&gt;&lt;/a&gt;, which lets you place an operating system lock on an open file. The &lt;code&gt;fcntl&lt;/code&gt; module is available on Unix systems, so this example is primarily intended for Linux. The type parameter syntax requires Python 3.12 or later.&lt;/p&gt;
&lt;p&gt;Here's the complete implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import fcntl
from collections.abc import Callable
from functools import wraps
from inspect import getfile
from pathlib import Path
from tempfile import gettempdir
from time import sleep
from typing import Literal


def lock_function[**P, R](
    mode: Literal[&quot;skip&quot;, &quot;wait&quot;] = &quot;skip&quot;,
    delay: float = 0,
) -&amp;gt; Callable[[Callable[P, R]], Callable[P, R | None]]:
    def decorator(func: Callable[P, R]) -&amp;gt; Callable[P, R | None]:
        source = str(Path(getfile(func)).resolve()).replace(&quot;/&quot;, &quot;_&quot;).replace(&quot;.py&quot;, &quot;&quot;)
        filename = f&quot;{source}_{func.__name__}.lock&quot;
        lock_path = Path(gettempdir()) / &quot;locks&quot; / filename

        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -&amp;gt; R | None:
            lock_path.parent.mkdir(parents=True, exist_ok=True)
            with lock_path.open(&quot;a&quot;) as lock_file:
                try:
                    fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
                except BlockingIOError:
                    if mode == &quot;skip&quot; and delay == 0:
                        return None
                    fcntl.flock(lock_file, fcntl.LOCK_EX)
                    contended = True
                else:
                    contended = False

                try:
                    if contended:
                        sleep(delay)
                        if mode == &quot;skip&quot;:
                            return None
                    return func(*args, **kwargs)
                finally:
                    fcntl.flock(lock_file, fcntl.LOCK_UN)

        return wrapper

    return decorator
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;how-to-use-it&quot;&gt;How to Use It&lt;/h3&gt;
&lt;p&gt;When another concurrent call is unnecessary, the default &lt;code&gt;skip&lt;/code&gt; mode is enough:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@lock_function()
def refresh_shared_cache() -&amp;gt; None:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first process acquires the lock and refreshes the data. If the second encounters a lock that's already held, it skips the function body and receives &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can also add a delay:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@lock_function(mode=&quot;skip&quot;, delay=60)
def change_password() -&amp;gt; None:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, a call that encounters a lock that's already held &lt;strong&gt;waits to acquire it, waits another 60 seconds, and only then returns without executing the function&lt;/strong&gt;. This behavior is intentional: the process resumes its other work after the competing operation has finished and the extra pause has elapsed.&lt;/p&gt;
&lt;p&gt;If every call should be executed, choose &lt;code&gt;wait&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@lock_function(mode=&quot;wait&quot;, delay=2)
def update_shared_file(value: str) -&amp;gt; None:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Suppose three processes try to run the function. The first executes it immediately. The other two wait. Once the lock is released, one of them acquires it, waits two seconds, and executes the function. Then it's the last process's turn.&lt;/p&gt;
&lt;p&gt;All three calls will execute, but one at a time. Don't assume they'll run in the order they arrived, though — a lock isn't a queue with numbered tickets.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;delay&lt;/code&gt; applies only when the first attempt to acquire the lock fails.&lt;/strong&gt; If the lock was free, the function starts without a delay. This parameter isn't a timeout either: in &lt;code&gt;wait&lt;/code&gt; mode, a process can wait for as long as the lock remains held.&lt;/p&gt;
&lt;h3 id=&quot;how-it-all-works&quot;&gt;How It All Works&lt;/h3&gt;
&lt;h4 id=&quot;a-shared-lock-file&quot;&gt;A Shared Lock File&lt;/h4&gt;
&lt;p&gt;When decorating the function, I first determine the file path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;source = str(Path(getfile(func)).resolve()).replace(&quot;/&quot;, &quot;_&quot;).replace(&quot;.py&quot;, &quot;&quot;)
filename = f&quot;{source}_{func.__name__}.lock&quot;
lock_path = Path(gettempdir()) / &quot;locks&quot; / filename
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;getfile()&lt;/code&gt; returns the function's location, and &lt;code&gt;resolve()&lt;/code&gt; produces an absolute path. I replace slashes with underscores and append the function name to produce a filename in the shared temporary directory.&lt;/p&gt;
&lt;p&gt;This means functions with the same name in different files will usually get separate locks. Call arguments don't affect the name: &lt;code&gt;update_shared_file(&quot;a&quot;)&lt;/code&gt; and &lt;code&gt;update_shared_file(&quot;b&quot;)&lt;/code&gt; compete for the same lock.&lt;/p&gt;
&lt;h4 id=&quot;wrapping-the-original-function&quot;&gt;Wrapping the Original Function&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;decorator&lt;/code&gt; takes a function, and &lt;code&gt;wrapper&lt;/code&gt; replaces it when it's called. Inside &lt;code&gt;wrapper&lt;/code&gt;, we acquire the lock and, if appropriate, run the original body:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;return func(*args, **kwargs)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@wraps(func)&lt;/code&gt; preserves the function's metadata, while the type parameters &lt;code&gt;P&lt;/code&gt; and &lt;code&gt;R&lt;/code&gt; describe its arguments and return value. The decorated function can also return &lt;code&gt;None&lt;/code&gt;, because &lt;code&gt;skip&lt;/code&gt; mode allows execution to be skipped.&lt;/p&gt;
&lt;h4 id=&quot;attempting-to-acquire-the-lock&quot;&gt;Attempting to Acquire the Lock&lt;/h4&gt;
&lt;p&gt;On each call, I create the directory if it's missing and open the file. Then I try to acquire the lock:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LOCK_EX&lt;/code&gt; means an exclusive lock, and &lt;code&gt;LOCK_NB&lt;/code&gt; disables waiting. If the lock is already held, I get a &lt;code&gt;BlockingIOError&lt;/code&gt;. I then either skip the call immediately or retry without &lt;code&gt;LOCK_NB&lt;/code&gt;, this time waiting for access. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://man7.org/linux/man-pages/man2/flock.2.html&quot;&gt;&lt;code&gt;flock&lt;/code&gt; documentation&lt;/a&gt; describes these flags in detail.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;contended&lt;/code&gt; variable records whether the first attempt encountered a lock that was already held.&lt;/p&gt;
&lt;h4 id=&quot;pausing-and-executing&quot;&gt;Pausing and Executing&lt;/h4&gt;
&lt;p&gt;After acquiring a lock that was previously held, I run &lt;code&gt;sleep(delay)&lt;/code&gt; and then either skip the function or execute it, depending on the mode.&lt;/p&gt;
&lt;p&gt;I &lt;strong&gt;hold the lock&lt;/strong&gt; throughout the pause. This prevents another process from jumping ahead of me while I wait.&lt;/p&gt;
&lt;h4 id=&quot;releasing-the-lock&quot;&gt;Releasing the Lock&lt;/h4&gt;
&lt;p&gt;Finally, the &lt;code&gt;finally&lt;/code&gt; block runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;finally:
    fcntl.flock(lock_file, fcntl.LOCK_UN)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lock is released even if the function raises an exception. The exception itself propagates to the caller, and &lt;code&gt;with&lt;/code&gt; closes the file.&lt;/p&gt;
&lt;p&gt;I don't delete the file. Its existence doesn't mean the lock is held — that's determined by the operating system lock. Deleting and recreating the file could cause processes to lock different files at the same path.&lt;/p&gt;
&lt;h3 id=&quot;the-limits-of-this-approach&quot;&gt;The Limits of This Approach&lt;/h3&gt;
&lt;p&gt;The decorator handles exceptions, but it isn't prepared for a function getting stuck forever. If a network connection to an external system hangs without a timeout, the code won't reach &lt;code&gt;finally&lt;/code&gt;, and the process will keep holding the lock. Subsequent calls in &lt;code&gt;wait&lt;/code&gt; mode or &lt;code&gt;skip&lt;/code&gt; mode with a delay can then wait forever too. That's why timeouts for these operations need to be configured separately — the decorator guards the door, but it won't rescue anyone from a never-ending phone call.&lt;/p&gt;
&lt;p&gt;The processes must see the same lock file. Separate temporary directories in containers or different code locations can mean separate locks. This is a solution for coordinating processes in a shared environment, rather than a ready-made distributed lock.&lt;/p&gt;
&lt;p&gt;A similar trap awaits in systemd: a service with &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateTmp=&quot;&gt;&lt;code&gt;PrivateTmp=yes&lt;/code&gt;&lt;/a&gt; gets its own &lt;code&gt;/tmp&lt;/code&gt; and &lt;code&gt;/var/tmp&lt;/code&gt;. Two services on the same host, each with a private &lt;code&gt;/tmp&lt;/code&gt;, will therefore create different lock files by default, even if the path in the code looks identical. Each politely guards its own door, but those are two different entrances. If they need to block each other, give them a shared lock directory and adjust &lt;code&gt;lock_path&lt;/code&gt; accordingly, or deliberately share their private temporary directories through &lt;code&gt;JoinsNamespaceOf=&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;All competing calls should also use the decorator. The lock is advisory: code that ignores it can still modify the shared resource. &lt;a rel=&quot;nofollow&quot; href=&quot;https://man7.org/linux/man-pages/man2/flock.2.html&quot;&gt;&lt;code&gt;flock&lt;/code&gt;&lt;/a&gt; won't keep the entire application in check for us.&lt;/p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;A few lines of decorator code are enough to move lock handling out of the function body. &lt;code&gt;skip&lt;/code&gt; lets you skip a competing call, &lt;code&gt;wait&lt;/code&gt; executes it after acquiring the lock, and &lt;code&gt;delay&lt;/code&gt; provides a little extra breathing room after encountering a lock that was already held.&lt;/p&gt;
&lt;p&gt;This won't solve every race condition in the project, but at least the processes will stop jostling in the doorway to this one function 😉.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30240/how-to-lock-a-function-across-processes-with-a-decorator-in-python</guid>
<pubDate>Thu, 08 Oct 2026 08:35:18 +0000</pubDate>
</item>
<item>
<title>Why Local LLM Inference Needs Admission Control Before It Needs More Hardware</title>
<link>https://coderlegion.com/26773/why-local-llm-inference-needs-admission-control-before-it-needs-more-hardware</link>
<description>&lt;p&gt;Running an LLM locally is easy to demonstrate.&lt;/p&gt;
&lt;p&gt;Start Ollama or vLLM, load a model, send a request, and watch it generate tokens.&lt;/p&gt;
&lt;p&gt;The interesting problems start when you send more than one kind of request.&lt;/p&gt;
&lt;p&gt;Imagine a local inference server handling two workloads:&lt;/p&gt;
&lt;p&gt;Interactive requests where a user is waiting for an answer.&lt;br&gt;
Background jobs generating longer responses that can tolerate delay.&lt;/p&gt;
&lt;p&gt;With plenty of spare capacity, both workloads coexist peacefully.&lt;/p&gt;
&lt;p&gt;Under contention, they don't.&lt;/p&gt;
&lt;p&gt;A few large background requests can occupy enough inference capacity that interactive requests wait behind work that was never latency-sensitive in the first place.&lt;/p&gt;
&lt;p&gt;At that point, adding another HTTP worker or increasing a concurrency limit doesn't necessarily solve the problem.&lt;/p&gt;
&lt;p&gt;You have a scheduling problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concurrency Is Not Capacity Management&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A common first approach is to put a semaphore around inference:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;maximum concurrent requests = 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's useful. It prevents unlimited work from entering the system.&lt;/p&gt;
&lt;p&gt;But it doesn't answer a more important question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which four requests should be allowed to run?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Suppose all four slots are occupied by long-running batch generations.&lt;/p&gt;
&lt;p&gt;An interactive request arrives.&lt;/p&gt;
&lt;p&gt;The concurrency limit is working exactly as designed, yet the user still waits.&lt;/p&gt;
&lt;p&gt;The system protected itself from overload, but it didn't protect the workload that mattered most.&lt;/p&gt;
&lt;p&gt;This is where admission control becomes different from simple concurrency limiting.&lt;/p&gt;
&lt;p&gt;Concurrency asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;How much work can execute simultaneously?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Admission control asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which work should consume scarce capacity right now?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tokens Make the Problem More Interesting&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;LLM workloads also aren't uniform.&lt;/p&gt;
&lt;p&gt;Consider two requests:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Request A
input: 500 tokens
maximum output: 200 tokens
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Request B
input: 2,000 tokens
maximum output: 4,000 tokens
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Counting both as &quot;one request&quot; hides a large difference in potential resource consumption.&lt;/p&gt;
&lt;p&gt;A better admission decision can account for both request concurrency and estimated token footprint.&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;estimated footprint = input tokens + maximum output tokens
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before admitting a request, the controller asks whether enough capacity remains for that footprint.&lt;/p&gt;
&lt;p&gt;The estimate doesn't need to predict the exact number of generated tokens perfectly. It needs to provide a useful bound for deciding whether admitting more work is safe.&lt;/p&gt;
&lt;p&gt;When the request finishes, unused capacity can be returned.&lt;/p&gt;
&lt;p&gt;This gives the system another dimension of protection beyond request count alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Static Limits Still Leave a Policy Problem&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can go further and reserve capacity.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Interactive: 3 slots
Batch:       1 slot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now interactive traffic is protected.&lt;/p&gt;
&lt;p&gt;But you've created another problem.&lt;/p&gt;
&lt;p&gt;What happens when there are no interactive requests?&lt;/p&gt;
&lt;p&gt;If three reserved slots sit unused while batch work waits, utilization suffers.&lt;/p&gt;
&lt;p&gt;The obvious improvement is borrowing.&lt;/p&gt;
&lt;p&gt;Let batch jobs temporarily consume unused interactive capacity:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Interactive demand low
        ↓
Batch borrows spare capacity
        ↓
Interactive demand returns
        ↓
Borrowed capacity must be restored
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last step is where things become interesting.&lt;/p&gt;
&lt;p&gt;Borrowing idle capacity is easy.&lt;/p&gt;
&lt;p&gt;Safely reclaiming it is the hard part.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Borrowing Without Restoration Isn't Really a Reservation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Suppose an interactive class is guaranteed three units of capacity.&lt;/p&gt;
&lt;p&gt;During an idle period, batch workloads borrow two of them.&lt;/p&gt;
&lt;p&gt;Then interactive traffic suddenly returns.&lt;/p&gt;
&lt;p&gt;If those batch requests can't be interrupted, the interactive reservation doesn't immediately exist anymore. It exists only after the borrowers finish.&lt;/p&gt;
&lt;p&gt;That means the actual guarantee depends on restoration behavior.&lt;/p&gt;
&lt;p&gt;A useful reservation policy therefore needs to define more than:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interactive floor = 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also needs to answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;How quickly can that floor become usable again?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This distinction matters especially with inference because some resources can be reclaimed immediately while others cannot.&lt;/p&gt;
&lt;p&gt;A controller may stop granting new batch work immediately.&lt;/p&gt;
&lt;p&gt;But an upstream inference request already running on a GPU may continue until completion.&lt;/p&gt;
&lt;p&gt;So &quot;capacity restored&quot; can mean at least two different things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The scheduler has restored the entitlement.&lt;/li&gt;
&lt;li&gt;The physical resource is actually available again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Those are not necessarily the same moment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Testing This Against a Real Local Model&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I've been experimenting with this problem while building MoFlux, an admission-control system for shared workloads.&lt;/p&gt;
&lt;p&gt;One of the useful milestones was running the controller against a real local Ollama instance instead of only synthetic workloads.&lt;/p&gt;
&lt;p&gt;The first result wasn't interesting because MoFlux somehow made the model faster.&lt;/p&gt;
&lt;p&gt;It didn't.&lt;/p&gt;
&lt;p&gt;The interesting result was that the admission policy could operate in front of real local inference and observe the same contention problem that appears in simulations.&lt;/p&gt;
&lt;p&gt;That changes what can be tested.&lt;/p&gt;
&lt;p&gt;Instead of asking only:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Does the controller make the correct decision?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;we can ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What happens to real inference when interactive and batch workloads&lt;br&gt;
compete for the same model?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now measurements such as these become meaningful:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Time to first token&lt;/li&gt;
&lt;li&gt;End-to-end latency&lt;/li&gt;
&lt;li&gt;Interactive success rate&lt;/li&gt;
&lt;li&gt;Batch throughput&lt;/li&gt;
&lt;li&gt;Admission rejection rate&lt;/li&gt;
&lt;li&gt;Capacity restoration time&lt;/li&gt;
&lt;li&gt;Protected-capacity violations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is where benchmarks start becoming more useful than raw throughput numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Throughput Is Not Always the Goal&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Imagine two configurations.&lt;/p&gt;
&lt;p&gt;Configuration A completes 110 requests per minute.&lt;/p&gt;
&lt;p&gt;Configuration B completes 100.&lt;/p&gt;
&lt;p&gt;At first glance, A wins.&lt;/p&gt;
&lt;p&gt;But suppose under A:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;interactive latency becomes unpredictable,&lt;/li&gt;
&lt;li&gt;user-facing requests frequently miss their SLO,&lt;/li&gt;
&lt;li&gt;background work dominates capacity during bursts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Configuration B might complete fewer total requests while completing substantially more useful requests within their objectives.&lt;/p&gt;
&lt;p&gt;That's the difference between throughput and goodput.&lt;/p&gt;
&lt;p&gt;For shared inference infrastructure, the question isn't simply:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;How many generations can this GPU complete?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A more useful question is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;How much useful work can this GPU complete while preserving the&lt;br&gt;
guarantees of the workloads sharing it?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is a capacity-allocation problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why This Matters for Local Inference&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Local inference is becoming increasingly practical.&lt;/p&gt;
&lt;p&gt;Developers are running models on workstations, edge machines, private servers, and internal GPU clusters.&lt;/p&gt;
&lt;p&gt;The first optimization instinct is often hardware-oriented:&lt;/p&gt;
&lt;p&gt;Need more throughput?&lt;br&gt;
Buy a faster GPU.&lt;/p&gt;
&lt;p&gt;Sometimes that's the right answer.&lt;/p&gt;
&lt;p&gt;But if the actual problem is uncontrolled contention between workloads, more hardware can simply postpone the same failure mode.&lt;/p&gt;
&lt;p&gt;Before scaling hardware, it's worth understanding:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Who is consuming capacity?&lt;/p&gt;
&lt;p&gt;How much are they allowed to consume?&lt;/p&gt;
&lt;p&gt;Which workloads need protection?&lt;/p&gt;
&lt;p&gt;Can idle capacity be borrowed?&lt;/p&gt;
&lt;p&gt;How quickly can it be reclaimed?&lt;/p&gt;
&lt;p&gt;What happens when demand suddenly changes?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Those questions exist whether your inference server is a laptop running Ollama or a production cluster serving thousands of requests.&lt;/p&gt;
&lt;p&gt;The scale changes.&lt;/p&gt;
&lt;p&gt;The scheduling problem doesn't.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Bigger Lesson&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A single local LLM request is mostly a model-serving problem.&lt;/p&gt;
&lt;p&gt;A shared inference server is a distributed-systems problem.&lt;/p&gt;
&lt;p&gt;Once multiple workloads compete for a finite model, GPU, memory budget, or concurrency pool, you need policy.&lt;/p&gt;
&lt;p&gt;Rate limiting controls how often work arrives.&lt;/p&gt;
&lt;p&gt;Concurrency limiting controls how much work runs simultaneously.&lt;/p&gt;
&lt;p&gt;Admission control decides whether the system should accept the work at all, given its current obligations.&lt;/p&gt;
&lt;p&gt;And workload isolation decides whose work gets protected when capacity becomes scarce.&lt;/p&gt;
&lt;p&gt;You may eventually need another GPU.&lt;/p&gt;
&lt;p&gt;But before buying one, send your local model two very different workloads at the same time.&lt;/p&gt;
&lt;p&gt;You might discover that the first thing you need isn't more capacity.&lt;/p&gt;
&lt;p&gt;It's a better way to decide who gets to use the capacity you already have.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26773/why-local-llm-inference-needs-admission-control-before-it-needs-more-hardware</guid>
<pubDate>Fri, 11 Sep 2026 02:47:45 +0000</pubDate>
</item>
<item>
<title>Profiling a Go Service in Production: pprof in 10 Minutes</title>
<link>https://coderlegion.com/23088/profiling-a-go-service-in-production-pprof-in-10-minutes</link>
<description>&lt;p&gt;Go ships with a profiler built in. No third-party tools, no agents to install, no instrumentation to add upfront. If your service imports &lt;code&gt;net/http/pprof&lt;/code&gt;, you can profile it live in production right now — CPU usage, memory allocations, goroutine counts, blocking operations. The data is available over HTTP, readable with standard Go tooling.&lt;/p&gt;
&lt;p&gt;Most engineers know pprof exists. Fewer have actually used it under pressure, on a real service, to find a real problem. This post walks through the mechanics — how to enable it, how to collect a profile, how to read a flame graph — and then shows the three classes of problems it catches most often.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;enabling-pprof&quot;&gt;Enabling pprof&lt;/h3&gt;
&lt;p&gt;For an HTTP service, one import is all it takes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;import (
    &quot;log&quot;
    &quot;net/http&quot;

    _ &quot;net/http/pprof&quot; // registers handlers on DefaultServeMux
)

func main() {
    // Your service setup...

    // pprof endpoints are now available on DefaultServeMux
    go func() {
        log.Println(http.ListenAndServe(&quot;localhost:6060&quot;, nil))
    }()

    // ... rest of main
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The blank import registers the pprof HTTP handlers automatically. They’re available at:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /debug/pprof/&lt;/code&gt; — index of available profiles&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /debug/pprof/goroutine&lt;/code&gt; — all current goroutines&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /debug/pprof/heap&lt;/code&gt; — memory allocations&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /debug/pprof/profile?seconds=30&lt;/code&gt; — CPU profile (30-second sample)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /debug/pprof/block&lt;/code&gt; — goroutine blocking events&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /debug/pprof/mutex&lt;/code&gt; — mutex contention&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; bind pprof to &lt;code&gt;localhost&lt;/code&gt; only, or a private interface. Never expose it on your public port. If your service runs in Kubernetes, use &lt;code&gt;kubectl port-forward&lt;/code&gt; to reach it.&lt;/p&gt;
&lt;p&gt;For services not using &lt;code&gt;DefaultServeMux&lt;/code&gt;, register the handlers explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;mux := http.NewServeMux()
mux.HandleFunc(&quot;/debug/pprof/&quot;, pprof.Index)
mux.HandleFunc(&quot;/debug/pprof/cmdline&quot;, pprof.Cmdline)
mux.HandleFunc(&quot;/debug/pprof/profile&quot;, pprof.Profile)
mux.HandleFunc(&quot;/debug/pprof/symbol&quot;, pprof.Symbol)
mux.HandleFunc(&quot;/debug/pprof/trace&quot;, pprof.Trace)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;collecting-a-cpu-profile&quot;&gt;Collecting a CPU profile&lt;/h3&gt;
&lt;p&gt;With the service running under load (profiling a quiet service tells you nothing useful), collect a 30-second CPU sample:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This downloads the profile and drops you into an interactive shell. Or skip the shell and go straight to a flame graph:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Collect the profile to a file
curl -o cpu.prof &quot;http://localhost:6060/debug/pprof/profile?seconds=30&quot;

# Open the flame graph in a browser
go tool pprof -http=:8080 cpu.prof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-http&lt;/code&gt; flag serves a web UI with multiple views. The flame graph is the most useful starting point.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;reading-a-flame-graph&quot;&gt;Reading a flame graph&lt;/h3&gt;
&lt;p&gt;A flame graph shows where your program spends time. Each horizontal bar is a function. Width represents time — wider means more CPU. The vertical axis is the call stack — functions lower down called the ones above them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;|          processJob          |     |  encode  |
|       fetchFromDB      |other|
|         sql.Query            |
|          net.Read            |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You’re looking for wide bars near the top — those are the functions consuming the most CPU. If &lt;code&gt;json.Marshal&lt;/code&gt; is unexpectedly wide, you’re spending a lot of time serializing. If &lt;code&gt;runtime.mallocgc&lt;/code&gt; is wide, you’re allocating heavily. If &lt;code&gt;sync.Mutex.Lock&lt;/code&gt; is wide, you have lock contention.&lt;/p&gt;
&lt;p&gt;The three most common findings in worker pool services:&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;problem-1-excessive-allocations&quot;&gt;Problem 1: Excessive allocations&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;runtime.mallocgc&lt;/code&gt; is prominent in the CPU profile. Heap profile shows high allocation rate with short-lived objects.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Collect a heap profile
curl -o heap.prof &quot;http://localhost:6060/debug/pprof/heap&quot;
go tool pprof -http=:8080 heap.prof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the heap profile, switch to “alloc_objects” or “alloc_space” view (not “inuse” — that shows what’s live, not what’s being allocated). Look for hot allocation sites.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common cause in worker pools:&lt;/strong&gt; allocating a new buffer or slice on every job iteration.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// ❌ Allocates a new byte slice for every job
func processJob(ctx context.Context, job Job) error {
    buf := make([]byte, 4096)
    // use buf...
    return nil
}

// ✅ Reuse buffers with sync.Pool
var bufPool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 4096)
        return &amp;amp;b
    },
}

func processJob(ctx context.Context, job Job) error {
    bufPtr := bufPool.Get().(*[]byte)
    defer bufPool.Put(bufPtr)
    buf := *bufPtr
    // use buf...
    return nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sync.Pool&lt;/code&gt; maintains a pool of reusable objects. Objects not retrieved before the next GC cycle are collected, so it doesn’t prevent garbage collection — it reduces the &lt;em&gt;rate&lt;/em&gt; of allocation, which reduces GC pressure.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;problem-2-goroutine-leak&quot;&gt;Problem 2: Goroutine leak&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; goroutine count grows over time and never decreases.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Check current goroutine count
curl &quot;http://localhost:6060/debug/pprof/goroutine?debug=1&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;debug=1&lt;/code&gt; parameter returns a text listing of all goroutines with stack traces. Look for hundreds or thousands of goroutines all blocked at the same location — that’s your leak.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;goroutine 1042 [chan receive, 3 minutes]:
main.worker()
    /app/worker.go:45 +0x68
created by main.startWorkers
    /app/pool.go:23 +0x4c

goroutine 1043 [chan receive, 3 minutes]:
...
(997 more like this)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A thousand goroutines blocked on &lt;code&gt;chan receive&lt;/code&gt; for 3 minutes means the jobs channel was never closed. The fix is covered in the concurrency mistakes post in this series — the producer must own &lt;code&gt;close(jobs)&lt;/code&gt; via &lt;code&gt;defer&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can also check the goroutine count programmatically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// In your health check endpoint
func healthHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, &quot;goroutines: %d\n&quot;, runtime.NumGoroutine())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that number grows continuously over hours of runtime and never stabilizes, you have a leak.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;problem-3-lock-contention&quot;&gt;Problem 3: Lock contention&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; throughput is lower than CPU utilization suggests it should be. Workers are running but not making progress.&lt;/p&gt;
&lt;p&gt;Mutex profiling is off by default because it has overhead. Enable it from inside the process only while investigating contention:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;import &quot;runtime&quot;

runtime.SetMutexProfileFraction(1)
defer runtime.SetMutexProfileFraction(0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then collect the mutex profile:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -o mutex.prof &quot;http://localhost:6060/debug/pprof/mutex&quot;
go tool pprof -http=:8080 mutex.prof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Common cause in worker pools:&lt;/strong&gt; a shared results map protected by a single mutex, written to by all workers simultaneously.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// ❌ All workers contend on one mutex
type ResultCollector struct {
    mu      sync.Mutex
    results map[string]Result
}

func (c *ResultCollector) Add(id string, result Result) {
    c.mu.Lock()
    c.results[id] = result
    c.mu.Unlock()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Solutions, in order of preference:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// ✅ Option 1: Use a results channel instead of shared state
results := make(chan Result, 1000)

// Workers send to channel (no lock)
go func() {
    for job := range jobs {
        result := processJob(ctx, job)
        results &amp;lt;- result
    }
}()

// Single collector goroutine reads from channel (no contention)
go func() {
    for result := range results {
        collected[result.ID] = result
    }
}()

// ✅ Option 2: sync.Map for concurrent read-heavy workloads
var results sync.Map
results.Store(id, result)
val, ok := results.Load(id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The channel approach is idiomatic Go — share memory by communicating rather than communicating by sharing memory. The &lt;code&gt;sync.Map&lt;/code&gt; approach is better when you need random access to results across goroutines.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-quick-reference-for-which-profile-to-use&quot;&gt;A quick reference for which profile to use&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;High CPU, unclear where&lt;/strong&gt; — CPU profile: &lt;code&gt;pprof .../profile?seconds=30&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory growing over time&lt;/strong&gt; — heap profile (inuse_space): &lt;code&gt;pprof .../heap&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;High GC pressure&lt;/strong&gt; — heap profile (alloc_objects): &lt;code&gt;pprof .../heap&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Goroutine count growing&lt;/strong&gt; — goroutine profile: &lt;code&gt;pprof .../goroutine?debug=1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Low throughput despite low CPU&lt;/strong&gt; — mutex or block profile: &lt;code&gt;pprof .../mutex&lt;/code&gt; or &lt;code&gt;.../block&lt;/code&gt;. Enable mutex profiling with &lt;code&gt;runtime.SetMutexProfileFraction&lt;/code&gt;; enable block profiling with &lt;code&gt;runtime.SetBlockProfileRate&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;one-workflow-for-production-incidents&quot;&gt;One workflow for production incidents&lt;/h3&gt;
&lt;p&gt;When something is wrong and you need to understand it quickly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Check goroutine count (cheap, immediate)
curl localhost:6060/debug/pprof/goroutine?debug=2 | head -50

# 2. If CPU is high, get a 30s CPU profile
curl -o cpu.prof localhost:6060/debug/pprof/profile?seconds=30

# 3. If memory is growing, get a heap snapshot
curl -o heap.prof localhost:6060/debug/pprof/heap

# 4. Open whichever profile is relevant
go tool pprof -http=:8080 cpu.prof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole process takes under 5 minutes. You don’t need to reproduce the problem locally, you don’t need to redeploy with extra instrumentation, and you don’t need to restart the service. The profiler runs against the live process.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;summary-1&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;pprof is built into Go’s standard library and available with a single import. The three problems it catches most reliably in worker pool services are excessive allocations (fix: &lt;code&gt;sync.Pool&lt;/code&gt;), goroutine leaks (fix: close channels, use context), and mutex contention (fix: channel-based result collection or &lt;code&gt;sync.Map&lt;/code&gt;). The flame graph view in &lt;code&gt;go tool pprof -http&lt;/code&gt; is the fastest path from profile to finding.&lt;/p&gt;
&lt;p&gt;The only prerequisite is running the service under realistic load when you collect the profile. A profile from a quiet service tells you where your code &lt;em&gt;could&lt;/em&gt; spend time, not where it &lt;em&gt;does&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Previous in this series: &lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;context.Context Is Not Optional&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Next: Container-aware resource management in Go.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23088/profiling-a-go-service-in-production-pprof-in-10-minutes</guid>
<pubDate>Mon, 20 Jul 2026 19:45:14 +0000</pubDate>
</item>
<item>
<title>context.Context Is Not Optional: A Practical Guide to Cancellation in Go Services</title>
<link>https://coderlegion.com/23087/context-context-is-not-optional-a-practical-guide-to-cancellation-in-go-services</link>
<description>&lt;p&gt;Every Go service that does I/O — database calls, HTTP requests, queue polling, file reads — should be passing a &lt;code&gt;context.Context&lt;/code&gt; through every layer. In practice, a large number of codebases treat &lt;code&gt;context.Context&lt;/code&gt; as optional plumbing that gets added later, or as something only the HTTP handler layer needs to worry about.&lt;/p&gt;
&lt;p&gt;That’s the mistake. By the time you realize you need cancellation deep in a worker pool, retrofitting it is painful. This post shows what context propagation looks like in a real worker pool, what the failure modes are when you skip it, and how to do it correctly from the start.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-context-context-actually-does&quot;&gt;What context.Context actually does&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;context.Context&lt;/code&gt; carries three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A cancellation signal&lt;/strong&gt; — a channel that’s closed when the work should stop&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A deadline or timeout&lt;/strong&gt; — an absolute time after which the context is automatically cancelled&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key-value pairs&lt;/strong&gt; — request-scoped values like trace IDs (use sparingly)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When a context is cancelled, any blocking operation that’s watching that context should stop and return an error. The context propagates &lt;em&gt;down&lt;/em&gt; the call stack — a parent context cancelled cancels all children. It never propagates up.&lt;/p&gt;
&lt;p&gt;The critical point: &lt;strong&gt;the context has to be passed all the way down to the code that actually blocks&lt;/strong&gt;. Holding it at the HTTP handler layer and never passing it to your database call means cancellation never reaches the work.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-happens-without-context&quot;&gt;What happens without context&lt;/h3&gt;
&lt;p&gt;Here is a worker pool that ignores context:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func runWorkers(jobs &amp;lt;-chan Job) {
    var wg sync.WaitGroup
    for i := 0; i &amp;lt; 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                processJob(job) // no context
            }
        }()
    }
    wg.Wait()
}

func processJob(job Job) error {
    result, err := fetchFromDatabase(job.ID) // no context
    if err != nil {
        return err
    }
    return sendToDownstream(result) // no context
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now consider what happens when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The calling service shuts down&lt;/li&gt;
&lt;li&gt;A client disconnects&lt;/li&gt;
&lt;li&gt;A deadline passes&lt;/li&gt;
&lt;li&gt;An operator sends SIGTERM&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The workers keep running. &lt;code&gt;fetchFromDatabase&lt;/code&gt; keeps waiting. &lt;code&gt;sendToDownstream&lt;/code&gt; keeps trying. The process can’t exit cleanly because goroutines are blocked on operations that have no way of knowing they should stop.&lt;/p&gt;
&lt;p&gt;In the best case, the process takes 30 seconds to shut down waiting for goroutines to unblock naturally. In the worst case, it never terminates and has to be killed, potentially leaving in-flight work in an inconsistent state.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-correct-structure-context-flows-in-errors-flow-out&quot;&gt;The correct structure: context flows in, errors flow out&lt;/h3&gt;
&lt;p&gt;The pattern is simple. Every function that does I/O or blocks takes a context as its &lt;strong&gt;first parameter&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func runWorkers(ctx context.Context, jobs &amp;lt;-chan Job) error {
    var wg sync.WaitGroup
    errs := make(chan error, 10)

    for i := 0; i &amp;lt; 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case &amp;lt;-ctx.Done():
                    return // context cancelled, stop cleanly
                case job, ok := &amp;lt;-jobs:
                    if !ok {
                        return // channel closed, no more jobs
                    }
                    if err := processJob(ctx, job); err != nil {
                        select {
                        case errs &amp;lt;- err:
                        default:
                        }
                    }
                }
            }
        }()
    }

    wg.Wait()
    close(errs)

    // Return first error if any
    return &amp;lt;-errs
}

func processJob(ctx context.Context, job Job) error {
    result, err := fetchFromDatabase(ctx, job.ID)
    if err != nil {
        return err
    }
    return sendToDownstream(ctx, result)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when the context is cancelled — whether by timeout, operator signal, or parent cancellation — each worker’s &lt;code&gt;select&lt;/code&gt; unblocks on &lt;code&gt;ctx.Done()&lt;/code&gt; and returns cleanly. &lt;code&gt;fetchFromDatabase&lt;/code&gt; and &lt;code&gt;sendToDownstream&lt;/code&gt; propagate the context to their underlying I/O calls (database drivers, HTTP clients), which respect it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wiring-context-to-os-signals-for-clean-shutdown&quot;&gt;Wiring context to OS signals for clean shutdown&lt;/h3&gt;
&lt;p&gt;The context that flows into your worker pool should ultimately be rooted at &lt;code&gt;main&lt;/code&gt;, wired to OS signals:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func main() {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    jobs := make(chan Job, 100)

    // Producer
    go func() {
        defer close(jobs)
        if err := loadJobs(ctx, jobs); err != nil {
            log.Printf(&quot;producer stopped: %v&quot;, err)
        }
    }()

    // Workers
    if err := runWorkers(ctx, jobs); err != nil {
        log.Printf(&quot;workers stopped with error: %v&quot;, err)
        os.Exit(1)
    }

    log.Println(&quot;shutdown complete&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;signal.NotifyContext&lt;/code&gt; (added in Go 1.16) returns a context that’s cancelled when &lt;code&gt;SIGINT&lt;/code&gt; or &lt;code&gt;SIGTERM&lt;/code&gt; arrives. That cancellation propagates through &lt;code&gt;loadJobs&lt;/code&gt; and &lt;code&gt;runWorkers&lt;/code&gt; automatically. When the signal arrives, the producer stops generating new jobs, in-flight I/O observes cancellation through the propagated context, workers exit, and the process shuts down cleanly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;timeouts-per-job-vs-pool-wide&quot;&gt;Timeouts: per-job vs. pool-wide&lt;/h3&gt;
&lt;p&gt;There are two distinct timeout requirements in a worker pool, and they need separate contexts:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pool-wide timeout:&lt;/strong&gt; how long the entire batch is allowed to run. Use &lt;code&gt;context.WithTimeout&lt;/code&gt; or &lt;code&gt;context.WithDeadline&lt;/code&gt; on the root context.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-job timeout:&lt;/strong&gt; how long a single job is allowed to take. Use a derived context inside the worker loop.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func runWorkers(ctx context.Context, jobs &amp;lt;-chan Job) error {
    // ctx already carries the pool-wide deadline from the caller

    var wg sync.WaitGroup
    for i := 0; i &amp;lt; 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case &amp;lt;-ctx.Done():
                    return
                case job, ok := &amp;lt;-jobs:
                    if !ok {
                        return
                    }

                    // Per-job timeout: 30 seconds max per job
                    jobCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
                    err := processJob(jobCtx, job)
                    cancel() // always call cancel to release resources

                    if err != nil {
                        log.Printf(&quot;job %s failed: %v&quot;, job.ID, err)
                    }
                }
            }
        }()
    }

    wg.Wait()
    return ctx.Err()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things to note. First, &lt;code&gt;jobCtx&lt;/code&gt; is derived from &lt;code&gt;ctx&lt;/code&gt; — if the pool-wide context is cancelled, the per-job context is also cancelled automatically. The hierarchy works in your favor. Second, &lt;code&gt;cancel()&lt;/code&gt; is called immediately after &lt;code&gt;processJob&lt;/code&gt; returns, not deferred. Deferring a context cancel inside a loop means you accumulate uncancelled contexts until the goroutine exits. Call cancel immediately after the work is done.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;checking-context-in-cpu-bound-work&quot;&gt;Checking context in CPU-bound work&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;ctx.Done()&lt;/code&gt; pattern works for I/O that blocks, because the underlying library propagates the context. But what about CPU-bound work — a job that does heavy computation without any I/O?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func processJob(ctx context.Context, job Job) error {
    chunks := split(job.Data)

    for i, chunk := range chunks {
        // Check context periodically in long-running CPU work
        if i%100 == 0 {
            select {
            case &amp;lt;-ctx.Done():
                return ctx.Err()
            default:
            }
        }

        if err := processChunk(chunk); err != nil {
            return err
        }
    }

    return nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;select&lt;/code&gt; with a &lt;code&gt;default&lt;/code&gt; branch is non-blocking — it checks whether the context is done and moves on immediately if not. Checking every 100 iterations (or every N milliseconds using a ticker) is a reasonable balance between responsiveness and overhead.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-three-rules&quot;&gt;The three rules&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Context is always the first parameter.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func doWork(ctx context.Context, ...) error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not a struct field, not a global, not injected via closure. First parameter, every time. This is a Go convention strong enough to be in the standard library style guide.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Never store context in a struct.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// ❌
type Worker struct {
    ctx context.Context
}

// ✅
func (w *Worker) Process(ctx context.Context, job Job) error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Contexts are request-scoped. A struct outlives any single request. Storing context in a struct means you’re using the wrong context for future requests, or holding a cancelled context longer than you should.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Always call cancel.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // even if the timeout fires first
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the timeout fires before you call cancel, calling cancel is a no-op. But if the work finishes before the timeout, calling cancel releases the timer resources immediately rather than waiting for the timeout to expire. It costs nothing and prevents a resource leak.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;summary-2&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Clean shutdown on SIGTERM&lt;/strong&gt; — &lt;code&gt;signal.NotifyContext&lt;/code&gt; at &lt;code&gt;main&lt;/code&gt;, propagate down.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stop workers on cancellation&lt;/strong&gt; — &lt;code&gt;select { case &amp;lt;-ctx.Done(): return }&lt;/code&gt; in the worker loop.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-job timeout&lt;/strong&gt; — &lt;code&gt;context.WithTimeout(ctx, duration)&lt;/code&gt; inside the loop, cancel immediately after.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CPU-bound work&lt;/strong&gt; — periodic &lt;code&gt;select { case &amp;lt;-ctx.Done(): return ctx.Err(); default: }&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Function signatures&lt;/strong&gt; — &lt;code&gt;ctx context.Context&lt;/code&gt; is always the first parameter.&lt;/p&gt;
&lt;p&gt;Context propagation is not boilerplate. It is the mechanism that lets your service behave correctly under pressure — when deadlines pass, when clients disconnect, when operators need to restart the service without waiting for goroutines to unblock naturally. The cost of adding it from the start is a few extra parameters. The cost of retrofitting it later is much higher.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Previous in this series: &lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Three Go Concurrency Mistakes I See in Almost Every Worker Pool&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Next: Profiling a Go service in production with pprof.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23087/context-context-is-not-optional-a-practical-guide-to-cancellation-in-go-services</guid>
<pubDate>Mon, 20 Jul 2026 16:58:42 +0000</pubDate>
</item>
<item>
<title>Why Modern Engineering Teams Must Prioritize System Observability Over Raw Performance</title>
<link>https://coderlegion.com/22051/why-modern-engineering-teams-must-prioritize-system-observability-over-raw-performance</link>
<description>&lt;p&gt;Engineering teams often chase performance: faster APIs, lower latency, optimized queries, reduced CPU usage. But in 2026, performance alone is no longer the defining factor of a reliable system. Modern architectures — distributed, event‑driven, containerized, multi‑service — demand something far more fundamental: [system observability][1].&lt;/p&gt;
&lt;p&gt;Performance tells you how fast your system is.&lt;br&gt;
Observability tells you why it behaves the way it does.&lt;/p&gt;
&lt;p&gt;And for engineering teams, especially small ones, this difference determines whether a system scales or collapses under pressure.&lt;/p&gt;
&lt;p&gt;Performance Without Observability Is a Blindfold&lt;br&gt;
A fast system can still be fragile.&lt;br&gt;
A highly optimized service can still fail silently.&lt;br&gt;
A low‑latency API can still degrade unpredictably under load.&lt;/p&gt;
&lt;p&gt;When teams focus exclusively on performance metrics, they optimize symptoms instead of causes. They see spikes, drops, delays — but they don’t see the internal relationships that create them.&lt;/p&gt;
&lt;p&gt;Observability exposes:&lt;/p&gt;
&lt;p&gt;hidden dependencies&lt;/p&gt;
&lt;p&gt;cascading failures&lt;/p&gt;
&lt;p&gt;slow internal paths&lt;/p&gt;
&lt;p&gt;unexpected retry storms&lt;/p&gt;
&lt;p&gt;misconfigured backoff logic&lt;/p&gt;
&lt;p&gt;silent timeouts&lt;/p&gt;
&lt;p&gt;resource starvation&lt;/p&gt;
&lt;p&gt;cross‑service bottlenecks&lt;/p&gt;
&lt;p&gt;Without this visibility, engineers operate reactively. They fix what they see, not what actually breaks the system.&lt;/p&gt;
&lt;p&gt;The Three Pillars of Observability&lt;br&gt;
Modern observability is not just logs. It is a structured, multi‑layered system that allows engineers to understand why something happened.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Metrics — the heartbeat of the system&lt;br&gt;
Metrics show trends, anomalies, and thresholds.&lt;br&gt;
They reveal when something is off — but not why.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Key engineering metrics include:&lt;/p&gt;
&lt;p&gt;request latency distribution&lt;/p&gt;
&lt;p&gt;error rate spikes&lt;/p&gt;
&lt;p&gt;queue depth growth&lt;/p&gt;
&lt;p&gt;retry counts&lt;/p&gt;
&lt;p&gt;CPU throttling events&lt;/p&gt;
&lt;p&gt;memory pressure&lt;/p&gt;
&lt;p&gt;cache hit ratios&lt;/p&gt;
&lt;p&gt;Metrics are the first signal, not the full story.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Logs — the narrative&lt;br&gt;
Logs provide context.&lt;br&gt;
They explain the events behind the metrics.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;But logs alone are insufficient.&lt;br&gt;
Without structure, correlation, and context, logs become noise.&lt;/p&gt;
&lt;p&gt;Modern teams rely on:&lt;/p&gt;
&lt;p&gt;structured JSON logs&lt;/p&gt;
&lt;p&gt;correlation IDs&lt;/p&gt;
&lt;p&gt;distributed trace IDs&lt;/p&gt;
&lt;p&gt;log sampling&lt;/p&gt;
&lt;p&gt;log enrichment with metadata&lt;/p&gt;
&lt;p&gt;Logs transform raw events into actionable insights.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Traces — the map of the system&lt;br&gt;
Distributed tracing is the most powerful observability tool of 2026.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Traces show:&lt;/p&gt;
&lt;p&gt;how a request travels through the system&lt;/p&gt;
&lt;p&gt;where it slows down&lt;/p&gt;
&lt;p&gt;where it fails&lt;/p&gt;
&lt;p&gt;where retries accumulate&lt;/p&gt;
&lt;p&gt;where bottlenecks form&lt;/p&gt;
&lt;p&gt;where unexpected dependencies appear&lt;/p&gt;
&lt;p&gt;Traces reveal the architecture as it actually behaves — not as it was designed.&lt;/p&gt;
&lt;p&gt;Why Observability Matters More for Small Teams&lt;br&gt;
Large companies have SRE departments, dedicated monitoring teams, and 24/7 on‑call rotations.&lt;br&gt;
Small engineering teams do not.&lt;/p&gt;
&lt;p&gt;For small teams, observability is not a luxury — it is a multiplier.&lt;/p&gt;
&lt;p&gt;It allows them to:&lt;/p&gt;
&lt;p&gt;detect issues before users notice&lt;/p&gt;
&lt;p&gt;reduce on‑call stress&lt;/p&gt;
&lt;p&gt;shorten incident resolution time&lt;/p&gt;
&lt;p&gt;prevent cascading failures&lt;/p&gt;
&lt;p&gt;scale without rewriting the system&lt;/p&gt;
&lt;p&gt;understand real‑world behavior instead of assumptions&lt;/p&gt;
&lt;p&gt;Observability replaces guesswork with clarity.&lt;/p&gt;
&lt;p&gt;Observability Enables Predictable Engineering&lt;br&gt;
Predictability is the most valuable trait of a modern system.&lt;/p&gt;
&lt;p&gt;A predictable system:&lt;/p&gt;
&lt;p&gt;fails gracefully&lt;/p&gt;
&lt;p&gt;recovers automatically&lt;/p&gt;
&lt;p&gt;scales linearly&lt;/p&gt;
&lt;p&gt;behaves consistently under load&lt;/p&gt;
&lt;p&gt;provides clear signals when something is wrong&lt;/p&gt;
&lt;p&gt;Observability is the foundation of this predictability.&lt;br&gt;
It transforms engineering from reactive firefighting into proactive system design.&lt;/p&gt;
&lt;p&gt;The Future: Observability as a Core Engineering Skill&lt;br&gt;
In 2026, observability is no longer a DevOps specialty.&lt;br&gt;
It is a core engineering skill.&lt;/p&gt;
&lt;p&gt;Teams that master observability:&lt;/p&gt;
&lt;p&gt;ship faster&lt;/p&gt;
&lt;p&gt;debug faster&lt;/p&gt;
&lt;p&gt;scale safer&lt;/p&gt;
&lt;p&gt;maintain stability&lt;/p&gt;
&lt;p&gt;reduce operational cost&lt;/p&gt;
&lt;p&gt;build systems that survive real‑world conditions&lt;/p&gt;
&lt;p&gt;Performance matters.&lt;br&gt;
But system observability determines whether performance is sustainable.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22051/why-modern-engineering-teams-must-prioritize-system-observability-over-raw-performance</guid>
<pubDate>Wed, 15 Jul 2026 14:45:00 +0000</pubDate>
</item>
<item>
<title>Building a Concurrency-Safe Movie Reservation System</title>
<link>https://coderlegion.com/22204/building-a-concurrency-safe-movie-reservation-system</link>
<description>&lt;h2 id=&quot;building-a-concurrency-safe-movie-reservation-system-with-fastapi-redis-and-postgresql&quot;&gt;Building a Concurrency-Safe Movie Reservation System with FastAPI, Redis, and PostgreSQL&lt;/h2&gt;
&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;One of the most interesting challenges in backend engineering isn't building CRUD APIs—it's designing systems that continue to behave correctly when hundreds of users interact with them simultaneously.&lt;/p&gt;
&lt;p&gt;Consider this scenario:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A blockbuster movie opens for booking.&lt;/p&gt;
&lt;p&gt;100 users click &lt;strong&gt;Reserve&lt;/strong&gt; for the exact same seat at the exact same millisecond.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Without proper concurrency control, multiple users could end up paying for the same seat. Preventing this is one of the core responsibilities of a reservation system.&lt;/p&gt;
&lt;p&gt;To better understand how production booking systems solve this problem, I built a &lt;strong&gt;Concurrency-Safe Movie Reservation Backend&lt;/strong&gt; using &lt;strong&gt;FastAPI&lt;/strong&gt;, &lt;strong&gt;PostgreSQL&lt;/strong&gt;, &lt;strong&gt;Redis&lt;/strong&gt;, and &lt;strong&gt;SQLAlchemy&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The goal wasn't to recreate BookMyShow or Ticketmaster. Instead, it was to explore the backend engineering concepts that make these systems reliable under concurrent traffic.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;
&lt;p&gt;At first glance, reserving a seat seems straightforward:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check if the seat is available.&lt;/li&gt;
&lt;li&gt;Create a reservation.&lt;/li&gt;
&lt;li&gt;Mark the seat as booked.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The problem appears when multiple requests arrive simultaneously.&lt;/p&gt;
&lt;p&gt;Imagine two requests reaching the server at nearly the same time.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Request A
Check seat availability
Seat is free

Request B
Check seat availability
Seat is free

Request A
Reserve seat

Request B
Reserve seat
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both requests observed the seat as available before either completed the reservation.&lt;/p&gt;
&lt;p&gt;This classic &lt;strong&gt;race condition&lt;/strong&gt; can result in duplicate bookings if the system isn't designed carefully.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;designing-the-reservation-workflow&quot;&gt;Designing the Reservation Workflow&lt;/h2&gt;
&lt;p&gt;To prevent this, I designed the reservation flow with multiple layers of protection rather than relying on a single mechanism.&lt;/p&gt;
&lt;p&gt;The workflow looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User requests available seats
        ↓
Requested seats are temporarily locked in Redis
        ↓
Lock ownership is assigned
        ↓
Reservation request is submitted
        ↓
Lock ownership is verified
        ↓
Reservation is created inside a PostgreSQL transaction
        ↓
Reserved seats are persisted
        ↓
Redis locks are released
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each layer addresses a different failure scenario.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;layer-1-distributed-seat-locking-with-redis&quot;&gt;Layer 1: Distributed Seat Locking with Redis&lt;/h2&gt;
&lt;p&gt;The first challenge is preventing multiple users from selecting the same seat simultaneously.&lt;/p&gt;
&lt;p&gt;Redis is ideal for this because it provides extremely fast in-memory operations.&lt;/p&gt;
&lt;p&gt;Each seat lock is stored using a key similar to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lock:showtime:{showtime_id}:{seat_label}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every lock contains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User ID&lt;/li&gt;
&lt;li&gt;Expiration timestamp&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These locks are temporary.&lt;/p&gt;
&lt;p&gt;If a user abandons the booking process, the lock eventually expires, allowing someone else to reserve the seat.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;preventing-partial-seat-locking&quot;&gt;Preventing Partial Seat Locking&lt;/h2&gt;
&lt;p&gt;Suppose a user wants three seats.&lt;/p&gt;
&lt;p&gt;If the application locks seats individually, this situation could occur:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Seat A ✓
Seat B ✓
Seat C ✗
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the user owns only part of the requested seats.&lt;/p&gt;
&lt;p&gt;This creates unnecessary complexity.&lt;/p&gt;
&lt;p&gt;Instead, I used &lt;strong&gt;Redis Lua scripts&lt;/strong&gt; so the locking operation becomes atomic.&lt;/p&gt;
&lt;p&gt;Either:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;every requested seat is locked&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;or&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;none of them are.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This eliminates inconsistent intermediate states.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;lock-ownership-verification&quot;&gt;Lock Ownership Verification&lt;/h2&gt;
&lt;p&gt;A Redis lock alone isn't enough.&lt;/p&gt;
&lt;p&gt;Imagine this sequence:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User A acquires a seat lock.&lt;/li&gt;
&lt;li&gt;The lock expires.&lt;/li&gt;
&lt;li&gt;User B acquires the same seat.&lt;/li&gt;
&lt;li&gt;User A submits an old reservation request.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Without ownership validation, the outdated request could incorrectly succeed.&lt;/p&gt;
&lt;p&gt;Before creating a reservation, the application verifies that every requested seat is still locked by the same user.&lt;/p&gt;
&lt;p&gt;If ownership has changed, the reservation is rejected.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;database-constraints-as-the-final-safety-net&quot;&gt;Database Constraints as the Final Safety Net&lt;/h2&gt;
&lt;p&gt;Redis helps coordinate requests, but PostgreSQL remains the source of truth.&lt;/p&gt;
&lt;p&gt;Even if every application-level protection somehow failed, the database still guarantees consistency.&lt;/p&gt;
&lt;p&gt;A unique constraint ensures that a seat cannot be reserved twice for the same showtime.&lt;/p&gt;
&lt;p&gt;This means duplicate reservations are impossible at the persistence layer.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;transactional-reservation-processing&quot;&gt;Transactional Reservation Processing&lt;/h2&gt;
&lt;p&gt;Creating a reservation isn't a single database operation.&lt;/p&gt;
&lt;p&gt;The application needs to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create the reservation&lt;/li&gt;
&lt;li&gt;Insert reserved seats&lt;/li&gt;
&lt;li&gt;Update related records&lt;/li&gt;
&lt;li&gt;Persist everything consistently&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If one operation fails midway, the database should not be left in a partially updated state.&lt;/p&gt;
&lt;p&gt;For that reason, reservation creation happens inside a single PostgreSQL transaction.&lt;/p&gt;
&lt;p&gt;Either:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;every operation succeeds&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;or&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;everything rolls back.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This guarantees atomicity and consistency.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;idempotent-reservation-requests&quot;&gt;Idempotent Reservation Requests&lt;/h2&gt;
&lt;p&gt;Real users retry requests.&lt;/p&gt;
&lt;p&gt;Browsers retry requests.&lt;/p&gt;
&lt;p&gt;Mobile networks retry requests.&lt;/p&gt;
&lt;p&gt;Without idempotency, duplicate retries may accidentally create multiple reservations.&lt;/p&gt;
&lt;p&gt;To solve this, reservation requests include an &lt;strong&gt;Idempotency Key&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;If the same request is received multiple times, the server returns the original result instead of creating another reservation.&lt;/p&gt;
&lt;p&gt;This makes retries safe.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;protecting-the-api-with-rate-limiting&quot;&gt;Protecting the API with Rate Limiting&lt;/h2&gt;
&lt;p&gt;Reservation endpoints are attractive targets for abuse.&lt;/p&gt;
&lt;p&gt;To prevent excessive requests, I implemented Redis-based rate limiting using simple atomic operations:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INCR
EXPIRE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This throttles repeated requests while keeping the implementation lightweight.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;background-workers&quot;&gt;Background Workers&lt;/h2&gt;
&lt;p&gt;Some operations shouldn't happen during the request-response cycle.&lt;/p&gt;
&lt;p&gt;Background workers periodically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Release expired Redis locks&lt;/li&gt;
&lt;li&gt;Expire abandoned reservations&lt;/li&gt;
&lt;li&gt;Restore seat availability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This keeps reservation state consistent without blocking user requests.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;stress-testing-the-system&quot;&gt;Stress Testing the System&lt;/h2&gt;
&lt;p&gt;After implementing the concurrency protections, I wanted to verify that they actually worked.&lt;/p&gt;
&lt;p&gt;Using &lt;strong&gt;Locust&lt;/strong&gt;, I simulated:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;100 concurrent users&lt;/li&gt;
&lt;li&gt;All attempting to reserve the exact same seat&lt;/li&gt;
&lt;li&gt;At nearly the same instant&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The results were exactly what I hoped for.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Reservation Attempts : 100

Successful Reservations : 1

Failed Reservations : 99

Duplicate Bookings : 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exactly one reservation succeeded.&lt;/p&gt;
&lt;p&gt;Every competing request failed safely.&lt;/p&gt;
&lt;p&gt;I also tested:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Multiple users reserving different seats simultaneously&lt;/li&gt;
&lt;li&gt;Redis lock expiration&lt;/li&gt;
&lt;li&gt;Database transaction failures&lt;/li&gt;
&lt;li&gt;Redis failure scenarios&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests increased my confidence that the reservation workflow behaves correctly under concurrent load.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;/h2&gt;
&lt;p&gt;Building this project taught me much more than another CRUD application ever could.&lt;/p&gt;
&lt;p&gt;I gained practical experience with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Race conditions&lt;/li&gt;
&lt;li&gt;Distributed locking&lt;/li&gt;
&lt;li&gt;Database transactions&lt;/li&gt;
&lt;li&gt;Concurrency control&lt;/li&gt;
&lt;li&gt;Idempotent APIs&lt;/li&gt;
&lt;li&gt;Background processing&lt;/li&gt;
&lt;li&gt;Redis coordination&lt;/li&gt;
&lt;li&gt;Transactional consistency&lt;/li&gt;
&lt;li&gt;Backend architecture&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;More importantly, it changed the way I think about backend development.&lt;/p&gt;
&lt;p&gt;Correctness under concurrency is often more important than adding new features.&lt;/p&gt;
&lt;p&gt;A system that behaves predictably under load is significantly harder—and more rewarding—to build.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;This project was an opportunity to move beyond simple REST APIs and explore problems that real booking platforms face every day.&lt;/p&gt;
&lt;p&gt;There are still many areas I'd like to improve, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Optimistic locking strategies&lt;/li&gt;
&lt;li&gt;Distributed tracing&lt;/li&gt;
&lt;li&gt;Event-driven reservation workflows&lt;/li&gt;
&lt;li&gt;Horizontal scaling&lt;/li&gt;
&lt;li&gt;Kubernetes deployment&lt;/li&gt;
&lt;li&gt;Observability with Prometheus and Grafana&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you're interested in backend engineering or distributed systems, I'd love to hear how you would approach these challenges differently.&lt;/p&gt;
&lt;p&gt;Constructive feedback and architectural discussions are always welcome.&lt;/p&gt;
&lt;p&gt;Enjoyed this article? I also shared a shorter version on LinkedIn where I'm collecting architectural feedback from backend engineers. I'd love to hear your thoughts there as well.&lt;/p&gt;
&lt;p&gt;LinkedIn Discussion: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/posts/rahul-ch-434b1a250_backend-python-fastapi-ugcPost-7480908963971166208-KY2M/&quot;&gt;https://www.linkedin.com/posts/rahul-ch-434b1a250_backend-python-fastapi-ugcPost-7480908963971166208-KY2M/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Happy building! 🚀&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22204/building-a-concurrency-safe-movie-reservation-system</guid>
<pubDate>Fri, 10 Jul 2026 07:50:58 +0000</pubDate>
</item>
<item>
<title>Rust Concurrency: Threads, Channels, Mutex &amp; Sync (Part 4)</title>
<link>https://coderlegion.com/19037/rust-concurrency-threads-channels-mutex-sync-part-4</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Rust Concurrency: Threads, Channels, Mutex &amp;amp; Sync (Part 4)&lt;br&gt;
published: true&lt;br&gt;
tags: rust, concurrency, intermediate, systems&lt;br&gt;
cover_image: &lt;a rel=&quot;nofollow&quot; href=&quot;https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1000&quot;&gt;https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1000&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;series-core-rust-concepts&quot;&gt;series: Core Rust Concepts&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;This is &lt;strong&gt;Part 4&lt;/strong&gt; of the Core Rust Concepts series.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Part 1&lt;/a&gt; — Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Part 2&lt;/a&gt; — Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Part 3&lt;/a&gt; — Macros, Modules, Testing, Unsafe Rust, FFI&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;table-of-contents&quot;&gt;Table of Contents&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#19-threads&quot;&gt;Threads&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#20-message-passing-with-channels&quot;&gt;Message Passing with Channels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#21-send-and-sync-traits&quot;&gt;Send and Sync Traits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#22-mutex-and-rwlock&quot;&gt;Mutex and RwLock&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#23-atomics&quot;&gt;Atomics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#24-building-a-cli-tool-with-clap&quot;&gt;Building a CLI Tool with clap&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;19-threads&quot;&gt;19. Threads&lt;/h3&gt;
&lt;p&gt;Rust's standard library provides OS threads via &lt;code&gt;std::thread&lt;/code&gt;. The ownership and type system prevents data races at compile time — if your code compiles, it's free of data races. That's a guarantee no other systems language makes.&lt;/p&gt;
&lt;h4 id=&quot;spawning-a-thread&quot;&gt;Spawning a thread&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!(&quot;thread: {i}&quot;);
            thread::sleep(Duration::from_millis(50));
        }
    });

    for i in 1..=3 {
        println!(&quot;main:   {i}&quot;);
        thread::sleep(Duration::from_millis(80));
    }

    handle.join().unwrap(); // wait for the thread to finish
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;moving-data-into-a-thread&quot;&gt;Moving data into a thread&lt;/h4&gt;
&lt;p&gt;Use &lt;code&gt;move&lt;/code&gt; to transfer ownership of data into the thread closure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5];

    let handle = thread::spawn(move || {
        // data is moved into this thread
        let sum: i32 = data.iter().sum();
        println!(&quot;sum: {sum}&quot;);
    });

    // println!(&quot;{:?}&quot;, data); ← compile error: data was moved

    handle.join().unwrap();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;spawning-many-threads&quot;&gt;Spawning many threads&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::thread;

fn main() {
    let handles: Vec&amp;lt;_&amp;gt; = (0..8)
        .map(|i| {
            thread::spawn(move || {
                println!(&quot;worker {i} done&quot;);
                i * i
            })
        })
        .collect();

    let results: Vec&amp;lt;_&amp;gt; = handles
        .into_iter()
        .map(|h| h.join().unwrap())
        .collect();

    println!(&quot;squares: {:?}&quot;, results);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt; &lt;code&gt;thread::spawn&lt;/code&gt; returns a &lt;code&gt;JoinHandle&amp;lt;T&amp;gt;&lt;/code&gt; where &lt;code&gt;T&lt;/code&gt; is the return type of the closure. Always &lt;code&gt;.join()&lt;/code&gt; your handles — a panicking thread silently fails otherwise.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;20-message-passing-with-channels&quot;&gt;20. Message Passing with Channels&lt;/h3&gt;
&lt;p&gt;Rust's standard library includes &lt;strong&gt;multiple-producer, single-consumer (mpsc)&lt;/strong&gt; channels. The philosophy: &lt;em&gt;&quot;do not communicate by sharing memory; share memory by communicating.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;basic-channel&quot;&gt;Basic channel&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let msgs = vec![&quot;hello&quot;, &quot;from&quot;, &quot;the&quot;, &quot;thread&quot;];
        for msg in msgs {
            tx.send(msg).unwrap();
        }
    });

    for received in rx {
        println!(&quot;got: {received}&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;multiple-producers&quot;&gt;Multiple producers&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel::&amp;lt;String&amp;gt;();

    for id in 0..4 {
        let tx_clone = tx.clone(); // each thread gets its own sender
        thread::spawn(move || {
            tx_clone.send(format!(&quot;msg from worker {id}&quot;)).unwrap();
        });
    }

    drop(tx); // drop the original so rx knows when all senders are gone

    for msg in rx {
        println!(&quot;{msg}&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;synchronous-channel-bounded-&quot;&gt;Synchronous channel (bounded)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::mpsc;
use std::thread;

fn main() {
    // Buffer of 2 — sender blocks when full
    let (tx, rx) = mpsc::sync_channel::&amp;lt;i32&amp;gt;(2);

    thread::spawn(move || {
        for i in 0..5 {
            println!(&quot;sending {i}&quot;);
            tx.send(i).unwrap(); // blocks at i=2 until receiver reads
        }
    });

    thread::sleep(std::time::Duration::from_millis(200));

    for val in rx {
        println!(&quot;received {val}&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt; For more advanced channel patterns (broadcast, watch, oneshot), use the &lt;code&gt;tokio::sync&lt;/code&gt; or &lt;code&gt;crossbeam-channel&lt;/code&gt; crates.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;21-send-and-sync-traits&quot;&gt;21. Send and Sync Traits&lt;/h3&gt;
&lt;p&gt;These two marker traits are the foundation of Rust's fearless concurrency. You rarely implement them manually — the compiler derives them automatically.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Trait &lt;/th&gt;&lt;th&gt;Meaning&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Send&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Safe to &lt;strong&gt;transfer ownership&lt;/strong&gt; to another thread&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Sync&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Safe to &lt;strong&gt;share a reference&lt;/strong&gt; across threads (&lt;code&gt;&amp;amp;T&lt;/code&gt; is &lt;code&gt;Send&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::thread;
use std::rc::Rc;
use std::sync::Arc;

fn main() {
    // Rc&amp;lt;T&amp;gt; is NOT Send — can't move across threads
    // let rc = Rc::new(42);
    // thread::spawn(move || println!(&quot;{}&quot;, rc)); ← compile error!

    // Arc&amp;lt;T&amp;gt; IS Send — atomic reference counting
    let arc = Arc::new(42);
    let arc_clone = Arc::clone(&amp;amp;arc);

    thread::spawn(move || {
        println!(&quot;thread sees: {}&quot;, arc_clone);
    }).join().unwrap();

    println!(&quot;main sees: {}&quot;, arc);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Types and their Send/Sync status:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Type &lt;/th&gt;&lt;th&gt;Send &lt;/th&gt;&lt;th&gt;Sync&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;i32&lt;/code&gt;, &lt;code&gt;f64&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;String&lt;/code&gt;, &lt;code&gt;Vec&amp;lt;T&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Rc&amp;lt;T&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Arc&amp;lt;T&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;RefCell&amp;lt;T&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Mutex&amp;lt;T&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;*mut T&lt;/code&gt; (raw pointer) &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt; If you try to share a non-&lt;code&gt;Sync&lt;/code&gt; type across threads, the compiler refuses to compile. No runtime surprises.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;22-mutex-and-rwlock&quot;&gt;22. Mutex and RwLock&lt;/h3&gt;
&lt;p&gt;When multiple threads need to &lt;strong&gt;mutate shared data&lt;/strong&gt;, use a &lt;code&gt;Mutex&lt;/code&gt; (mutual exclusion lock). Wrap it in &lt;code&gt;Arc&lt;/code&gt; to share ownership across threads.&lt;/p&gt;
&lt;h4 id=&quot;mutex&quot;&gt;Mutex&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&amp;amp;counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // blocks until lock acquired
            *num += 1;
        }); // lock is released here when `num` drops
        handles.push(handle);
    }

    for h in handles { h.join().unwrap(); }

    println!(&quot;result: {}&quot;, *counter.lock().unwrap()); // 10
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;rwlock-many-readers-one-writer&quot;&gt;RwLock — many readers, one writer&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));

    // Spawn 4 reader threads
    let readers: Vec&amp;lt;_&amp;gt; = (0..4).map(|i| {
        let data = Arc::clone(&amp;amp;data);
        thread::spawn(move || {
            let r = data.read().unwrap(); // multiple readers allowed
            println!(&quot;reader {i}: {:?}&quot;, *r);
        })
    }).collect();

    // One writer thread
    let data_w = Arc::clone(&amp;amp;data);
    let writer = thread::spawn(move || {
        let mut w = data_w.write().unwrap(); // exclusive access
        w.push(4);
        println!(&quot;writer pushed 4&quot;);
    });

    for r in readers { r.join().unwrap(); }
    writer.join().unwrap();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Mutex vs RwLock:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;Mutex&amp;lt;T&amp;gt;&lt;/code&gt; &lt;/th&gt;&lt;th&gt;&lt;code&gt;RwLock&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Readers &lt;/td&gt;&lt;td&gt;One at a time &lt;/td&gt;&lt;td&gt;Many simultaneously&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Writers &lt;/td&gt;&lt;td&gt;One at a time &lt;/td&gt;&lt;td&gt;One at a time (exclusive)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best for &lt;/td&gt;&lt;td&gt;Write-heavy workloads &lt;/td&gt;&lt;td&gt;Read-heavy workloads&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Overhead &lt;/td&gt;&lt;td&gt;Lower &lt;/td&gt;&lt;td&gt;Higher&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;avoiding-deadlocks&quot;&gt;Avoiding deadlocks&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::{Arc, Mutex};

fn main() {
    let a = Arc::new(Mutex::new(1));
    let b = Arc::new(Mutex::new(2));

    // ✅ Always acquire locks in the same order
    {
        let _lock_a = a.lock().unwrap();
        let _lock_b = b.lock().unwrap();
        println!(&quot;safe&quot;);
    }

    // ❌ Acquiring in different orders across threads = deadlock risk
    // thread 1: lock a → lock b
    // thread 2: lock b → lock a  ← deadlock!
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;23-atomics&quot;&gt;23. Atomics&lt;/h3&gt;
&lt;p&gt;For simple counters and flags shared between threads, &lt;code&gt;std::sync::atomic&lt;/code&gt; types are faster than a &lt;code&gt;Mutex&lt;/code&gt; — they use hardware atomic instructions with no locking.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    let mut handles = vec![];

    for _ in 0..8 {
        let counter = Arc::clone(&amp;amp;counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                counter.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!(&quot;total: {}&quot;, counter.load(Ordering::SeqCst)); // 8000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;memory-ordering-cheat-sheet&quot;&gt;Memory ordering cheat sheet&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Ordering &lt;/th&gt;&lt;th&gt;Use case&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Relaxed&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Counters — no ordering guarantees needed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Acquire&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Load that synchronizes with a &lt;code&gt;Release&lt;/code&gt; store&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Release&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Store that publishes data to other threads&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AcqRel&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Read-modify-write (e.g. &lt;code&gt;fetch_add&lt;/code&gt; with sync)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SeqCst&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Strongest — total order across all threads&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    let ready = Arc::new(AtomicBool::new(false));
    let ready_clone = Arc::clone(&amp;amp;ready);

    let worker = thread::spawn(move || {
        thread::sleep(std::time::Duration::from_millis(100));
        ready_clone.store(true, Ordering::Release);
        println!(&quot;worker: done&quot;);
    });

    // Spin-wait (not ideal in prod — use a Condvar or channel instead)
    while !ready.load(Ordering::Acquire) {
        thread::yield_now();
    }
    println!(&quot;main: worker finished&quot;);
    worker.join().unwrap();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt; Available atomic types: &lt;code&gt;AtomicBool&lt;/code&gt;, &lt;code&gt;AtomicI8/16/32/64&lt;/code&gt;, &lt;code&gt;AtomicU8/16/32/64&lt;/code&gt;, &lt;code&gt;AtomicUsize&lt;/code&gt;, &lt;code&gt;AtomicIsize&lt;/code&gt;, &lt;code&gt;AtomicPtr&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;24-building-a-cli-tool-with-clap&quot;&gt;24. Building a CLI Tool with clap&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;clap&lt;/code&gt; is the standard Rust library for building command-line interfaces. It gives you argument parsing, help text, and subcommands with minimal boilerplate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;# Cargo.toml
[dependencies]
clap = { version = &quot;4&quot;, features = [&quot;derive&quot;] }
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;basic-cli&quot;&gt;Basic CLI&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use clap::Parser;

/// A simple file word counter
#[derive(Parser, Debug)]
#[command(name = &quot;wordcount&quot;)]
#[command(about = &quot;Count words in a string or file&quot;, long_about = None)]
struct Cli {
    /// The text to count words in
    #[arg(short, long)]
    text: Option&amp;lt;String&amp;gt;,

    /// Show character count too
    #[arg(short, long, default_value_t = false)]
    chars: bool,

    /// Verbosity level
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,
}

fn main() {
    let cli = Cli::parse();

    let input = cli.text.unwrap_or_else(|| String::from(&quot;hello world&quot;));
    let words = input.split_whitespace().count();

    if cli.verbose &amp;gt; 0 {
        println!(&quot;Input: {:?}&quot;, input);
    }

    println!(&quot;Words: {words}&quot;);

    if cli.chars {
        println!(&quot;Chars: {}&quot;, input.chars().count());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Running it:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ cargo run -- --text &quot;the quick brown fox&quot; --chars -v
Input: &quot;the quick brown fox&quot;
Words: 4
Chars: 19

$ cargo run -- --help
Count words in a string or file

Usage: wordcount [OPTIONS]

Options:
  -t, --text &amp;lt;TEXT&amp;gt;   The text to count words in
  -c, --chars         Show character count too
  -v, --verbose...    Verbosity level
  -h, --help          Print help
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;subcommands&quot;&gt;Subcommands&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = &quot;mytool&quot;, about = &quot;A multi-command CLI&quot;)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add two numbers
    Add { a: f64, b: f64 },
    /// Multiply two numbers
    Mul { a: f64, b: f64 },
    /// Greet someone
    Greet {
        name: String,
        #[arg(short, long, default_value = &quot;Hello&quot;)]
        greeting: String,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Add { a, b } =&amp;gt; println!(&quot;{} + {} = {}&quot;, a, b, a + b),
        Commands::Mul { a, b } =&amp;gt; println!(&quot;{} × {} = {}&quot;, a, b, a * b),
        Commands::Greet { name, greeting } =&amp;gt; println!(&quot;{greeting}, {name}!&quot;),
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Running subcommands:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ cargo run -- add 3.5 2.5
3.5 + 2.5 = 6

$ cargo run -- greet Alice --greeting &quot;Hey&quot;
Hey, Alice!

$ cargo run -- mul 6 7
6 × 7 = 42
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt; For a full production CLI, combine &lt;code&gt;clap&lt;/code&gt; with &lt;code&gt;anyhow&lt;/code&gt; for error handling, &lt;code&gt;indicatif&lt;/code&gt; for progress bars, and &lt;code&gt;colored&lt;/code&gt; or &lt;code&gt;owo-colors&lt;/code&gt; for terminal color output.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Concept &lt;/th&gt;&lt;th&gt;What it gives you&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Threads&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;OS-level parallelism, compile-time data race prevention&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Channels&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Safe message passing between threads (mpsc)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Send &amp;amp; Sync&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Compiler-enforced thread-safety contracts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Mutex &amp;amp; RwLock&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Shared mutable state without data races&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Atomics&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Lock-free counters and flags&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;clap&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Production-grade CLI argument parsing&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-full-series-so-far&quot;&gt;The full series so far&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Part &lt;/th&gt;&lt;th&gt;Topics&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Part 1&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Part 2&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#&quot;&gt;Part 3&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Macros, Modules, Cargo, Testing, Unsafe, FFI&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Part 4&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Threads, Channels, Send/Sync, Mutex, Atomics, clap&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h4 id=&quot;what-s-in-part-5-&quot;&gt;What's in Part 5?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Trait objects and dynamic dispatch (&lt;code&gt;dyn Trait&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Deref&lt;/code&gt; and &lt;code&gt;Drop&lt;/code&gt; traits&lt;/li&gt;
&lt;li&gt;Custom iterators&lt;/li&gt;
&lt;li&gt;Building a real REST API with &lt;code&gt;axum&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Found this helpful? Drop a ❤️ and follow for Part 5!&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19037/rust-concurrency-threads-channels-mutex-sync-part-4</guid>
<pubDate>Wed, 27 May 2026 07:36:28 +0000</pubDate>
</item>
<item>
<title>Node Turns Waiting Into Events. Go Moves Context Switching Into User Space.</title>
<link>https://coderlegion.com/16780/node-turns-waiting-into-events-go-moves-context-switching-into-user-space</link>
<description>&lt;p&gt;Most discussions of TypeScript/Node vs Go concurrency stop at the surface: &lt;em&gt;Node is async, Go is threaded.&lt;/em&gt; That framing isn't wrong — it just isn't deep enough to be useful when you're picking a runtime, debugging a tail-latency problem, or explaining to your team why one of the services keeps falling over under CPU load.&lt;/p&gt;
&lt;p&gt;The real difference is not async vs threaded. It's a question about where, in the system, suspended work lives — and what shape it takes when it's resumed.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;tl;dr&lt;/strong&gt; — Both Node and Go refuse to let the CPU sit idle while a request waits on I/O. They disagree on the unit of scheduling. Node's unit is the &lt;em&gt;continuation&lt;/em&gt; — the tail of an async function captured as a heap closure. Go's unit is the &lt;em&gt;goroutine&lt;/em&gt; — a full call stack the runtime can suspend and resume in user space. That single decision cascades into every other property of each runtime.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-wrong-question&quot;&gt;The Wrong Question&lt;/h3&gt;
&lt;p&gt;&quot;Async vs threaded&quot; is the wrong frame because it makes you think the choice is between paradigms. It isn't. Both runtimes have already made the &lt;em&gt;same&lt;/em&gt; fundamental decision: do not block an OS thread waiting for slow external work. The interesting choice is &lt;em&gt;how&lt;/em&gt; they implement that.&lt;/p&gt;
&lt;p&gt;The actually useful question is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;When a request is waiting for I/O — for a database, an HTTP call, a Redis round-trip, a file read — &lt;strong&gt;what does the CPU do, and where does the suspended state of that request live?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Once you frame it that way, Node and Go aren't opposites. They're two answers to the same question — and each answer cascades into a different language shape, a different library style, and a different failure mode under load.&lt;/p&gt;
&lt;p&gt;The naive blocking model answers the question with &quot;an OS thread waits for the syscall to return.&quot; That model collapses around a few thousand concurrent connections — memory per thread, scheduler overhead, kernel context-switch cost. By 40,000 connections you're out of RAM, not CPU. Node and Go both refuse to do this. They diverge on &lt;em&gt;which resource gets freed up&lt;/em&gt; and &lt;em&gt;how the suspended work is captured for later resumption.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;node-s-answer-turn-waiting-into-an-event&quot;&gt;Node's Answer: Turn Waiting Into an Event&lt;/h3&gt;
&lt;p&gt;Node's model can be summarized in one line: &lt;strong&gt;the JS main thread only executes code that's already ready to run.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Look at this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const user = await db.getUser(id);
return user;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It reads as if the function is paused, blocking on the database. It isn't. Here's what V8 actually does at the bytecode level when it compiles an &lt;code&gt;async&lt;/code&gt; function: it rewrites the body into a state machine, with each &lt;code&gt;await&lt;/code&gt; becoming a state transition.&lt;/p&gt;
&lt;p&gt;The function above gets transformed into something equivalent to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;function asyncFn() {
  const promise = new Promise((resolve) =&amp;gt; {
    let state = 0;
    const closure = {};                  // heap object holding locals

    function step(value) {
      switch (state) {
        case 0:
          state = 1;
          db.getUser(id).then(step);     // await → register continuation
          return;                         // ← function POPS here
        case 1:
          closure.user = value;           // resume: locals live in closure
          resolve(closure.user);
          return;
      }
    }
    step();
  });
  return promise;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;await&lt;/code&gt; is not a pause.&lt;/strong&gt; It's the point at which V8 returns from the function and pops the JS stack frame. The &quot;rest of the function&quot; is captured as a continuation registered on the awaited Promise via &lt;code&gt;.then&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local variables move to the heap.&lt;/strong&gt; Because the stack frame is gone, locals (&lt;code&gt;user&lt;/code&gt; here) live in a heap closure, accessible only when the state machine resumes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Each &lt;code&gt;await&lt;/code&gt; slices the function into another state.&lt;/strong&gt; A function with two &lt;code&gt;await&lt;/code&gt;s runs in three event-loop turns, with three independently-pushed JS frames, with all live state stored in heap closures between them.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That third point is the most non-obvious. A single &lt;code&gt;async&lt;/code&gt; function is &lt;strong&gt;not&lt;/strong&gt; one unit of execution — it's a sequence of fresh frames separated by event-loop turns:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://mermaid.ink/img/c2VxdWVuY2VEaWFncmFtCiAgICBhdXRvbnVtYmVyCiAgICBwYXJ0aWNpcGFudCBFTCBhcyBFdmVudCBMb29wIChsaWJ1dikKICAgIHBhcnRpY2lwYW50IEpTIGFzIEpTIE1haW4gVGhyZWFkIChWOCkKICAgIHBhcnRpY2lwYW50IEggYXMgSGVhcCAoY2xvc3VyZXMpCiAgICBwYXJ0aWNpcGFudCBLIGFzIEtlcm5lbCAvIEkvTwoKICAgIHJlY3QgcmdiKDI1NCwgMjQzLCAxOTkpCiAgICBOb3RlIG92ZXIgRUwsSzogVHVybiAxCiAgICBFTC0-PkpTOiBkaXNwYXRjaCBoYW5kbGVyKCkKICAgIGFjdGl2YXRlIEpTCiAgICBOb3RlIG92ZXIgSlM6IGNvbnN0IGEgPSAxCiAgICBKUy0-PkpTOiBjYWxsIGNvbXB1dGUxKCkg4oaSIHJldHVybnMgUHJvbWlzZQogICAgSlMtPj5IOiBWOCBzdG9yZXMgY2xvc3VyZSB7c3RhdGU6MSwgYX0KICAgIEpTLT4-SDogcmVnaXN0ZXIgc3RlcCBhcyAudGhlbiBoYW5kbGVyCiAgICBKUy0tPj5FTDogaGFuZGxlciBmcmFtZSBQT1BQRUQsIHJldHVybnMgUHJvbWlzZQogICAgZGVhY3RpdmF0ZSBKUwogICAgZW5kCgogICAgRUwtPj5LOiBlcG9sbF93YWl0IChubyBtaWNyb3Rhc2tzKQogICAgTm90ZSBvdmVyIEVMLEs6IC4uLiB0aW1lIHBhc3NlcywgT1MgdGhyZWFkIHBhcmtlZCAuLi4KICAgIEstLT4-RUw6IEkvTyByZWFkeSAoY29tcHV0ZTEgcmVzb2x2ZWQpCiAgICBFTC0-PkVMOiBlbnF1ZXVlIHN0ZXAgaW4gVjggbWljcm90YXNrIHF1ZXVlCgogICAgcmVjdCByZ2IoMjE5LCAyMzQsIDI1NCkKICAgIE5vdGUgb3ZlciBFTCxLOiBUdXJuIDIKICAgIEVMLT4-SlM6IGludm9rZSBzdGVwKHZhbHVlKSDigJQgTkVXIGZyYW1lCiAgICBhY3RpdmF0ZSBKUwogICAgSlMtPj5IOiBsb2FkIGNsb3N1cmUge3N0YXRlOjEsIGF9CiAgICBOb3RlIG92ZXIgSlM6IHggPSB2YWx1ZSwgc3RhdGUg4oaSIDIKICAgIEpTLT4-SlM6IGNhbGwgY29tcHV0ZTIoKSDihpIgcmV0dXJucyBQcm9taXNlCiAgICBKUy0-Pkg6IHJlZ2lzdGVyIHN0ZXAgKG5leHQgc3RhdGUpCiAgICBKUy0tPj5FTDogZnJhbWUgUE9QUEVEIGFnYWluCiAgICBkZWFjdGl2YXRlIEpTCiAgICBlbmQKCiAgICBLLS0-PkVMOiBjb21wdXRlMiByZXNvbHZlZAogICAgRUwtPj5FTDogZW5xdWV1ZSBzdGVwCgogICAgcmVjdCByZ2IoMjIwLCAyNTIsIDIzMSkKICAgIE5vdGUgb3ZlciBFTCxLOiBUdXJuIDMKICAgIEVMLT4-SlM6IGludm9rZSBzdGVwKHZhbHVlKSDigJQgeWV0IGFub3RoZXIgbmV3IGZyYW1lCiAgICBhY3RpdmF0ZSBKUwogICAgSlMtPj5IOiBsb2FkIGNsb3N1cmUge3N0YXRlOjIsIGEsIHh9CiAgICBOb3RlIG92ZXIgSlM6IHkgPSB2YWx1ZSwgc3RhdGUg4oaSIGRvbmUKICAgIEpTLT4-SlM6IHJlcy5qc29uKGEgKyB4ICsgeSkKICAgIEpTLS0-PkVMOiBoYW5kbGVyJ3MgUHJvbWlzZSByZXNvbHZlZAogICAgZGVhY3RpdmF0ZSBKUwogICAgZW5k&quot; alt=&quot;sequenceDiagram&quot;&gt;&lt;/p&gt;
&lt;p&gt;There is no &quot;paused&quot; function. There are only &lt;em&gt;captured continuations&lt;/em&gt; and &lt;em&gt;fresh frames that resume them&lt;/em&gt;. The event loop is the dispatcher: it watches for I/O readiness via libuv, for resolved Promises (via V8's microtask queue), for timers — and pulls the corresponding continuation onto the JS thread when it's ready to run. One thread can manage tens of thousands of concurrent connections, because at any moment only a handful of them have work that's actually ready.&lt;/p&gt;
&lt;p&gt;This is event-driven concurrency in its precise sense — the runtime turns &quot;waiting&quot; into a registered event, and only resumes the captured continuation when the event fires.&lt;/p&gt;
&lt;h4 id=&quot;the-visible-side-effect-function-color&quot;&gt;The Visible Side Effect: Function Color&lt;/h4&gt;
&lt;p&gt;Because the suspension point has to be marked at compile time, async-ness becomes part of the function's &lt;em&gt;type&lt;/em&gt;. A function that does I/O returns &lt;code&gt;Promise&amp;lt;T&amp;gt;&lt;/code&gt;. Its callers must &lt;code&gt;await&lt;/code&gt; it. Once they &lt;code&gt;await&lt;/code&gt;, they themselves return &lt;code&gt;Promise&amp;lt;T&amp;gt;&lt;/code&gt;. The &quot;color&quot; propagates up the call stack until you hit an async-aware entry point — typically the top of an HTTP handler or the event loop itself.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;http://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/&quot;&gt;Bob Nystrom named this the function color problem&lt;/a&gt; in 2015. It's not a notation choice — it's a &lt;strong&gt;logical consequence of the stackless coroutine model&lt;/strong&gt;. V8 cannot save and restore arbitrary JS call stacks. The only way to express suspension is &quot;return a Promise and be marked &lt;code&gt;async&lt;/code&gt;,&quot; and once one function does that, every function on the way up has to do the same.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBOb2RlWyI8Yj5Ob2RlIOKAlCBDb2xvciBDYXNjYWRlcyBVcCB0aGUgQ2FsbCBTdGFjazwvYj4iXQogICAgICAgIGRpcmVjdGlvbiBUQgogICAgICAgIG4xWyI8Yj5yZWFkRnJvbURCKCk8L2I-IPCfn6U8YnIvPuKGkiBQcm9taXNlJmx0O0RhdGEmZ3Q7PGJyLz48Yj5kb2VzIEkvTzwvYj4iXQogICAgICAgIG4yWyI8Yj5mZXRjaFVzZXIoKTwvYj4g8J-fpTxici8-4oaSIFByb21pc2UmbHQ7VXNlciZndDs8YnIvPjxiPm11c3QgYXdhaXQgcmVhZEZyb21EQjwvYj4iXQogICAgICAgIG4zWyI8Yj5oYW5kbGVSZXF1ZXN0KCk8L2I-IPCfn6U8YnIvPuKGkiBQcm9taXNlJmx0O1Jlc3BvbnNlJmd0Ozxici8-PGI-bXVzdCBhd2FpdCBmZXRjaFVzZXI8L2I-Il0KICAgICAgICBuNFsiPGI-cm91dGUoJy91c2VyJywgaGFuZGxlcik8L2I-IPCfn6U8YnIvPjxiPm11c3QgYWNjZXB0IFByb21pc2UgcmV0dXJuPC9iPiJdCiAgICAgICAgbjVbIjxiPm1haW4oKTwvYj4g8J-fpTxici8-4oaSIFByb21pc2UmbHQ7dm9pZCZndDs8YnIvPjxiPnRvcC1sZXZlbCBuZWVkcyBhd2FpdDwvYj4iXQogICAgICAgIG4xIC0uY29sb3IgaW5mZWN0cy4tPiBuMgogICAgICAgIG4yIC0uY29sb3IgaW5mZWN0cy4tPiBuMwogICAgICAgIG4zIC0uY29sb3IgaW5mZWN0cy4tPiBuNAogICAgICAgIG40IC0uY29sb3IgaW5mZWN0cy4tPiBuNQogICAgZW5kCgogICAgc3ViZ3JhcGggR29bIjxiPkdvIOKAlCBObyBDb2xvciwgTm8gQ2FzY2FkZTwvYj4iXQogICAgICAgIGRpcmVjdGlvbiBUQgogICAgICAgIGcxWyI8Yj5yZWFkRnJvbURCKCk8L2I-IOKsnDxici8-4oaSIERhdGE8YnIvPjxiPmJsb2NrcyBvbiBJL08gaW50ZXJuYWxseTwvYj4iXQogICAgICAgIGcyWyI8Yj5mZXRjaFVzZXIoKTwvYj4g4qycPGJyLz7ihpIgVXNlcjxici8-PGI-cGxhaW4gY2FsbDwvYj4iXQogICAgICAgIGczWyI8Yj5oYW5kbGVSZXF1ZXN0KCk8L2I-IOKsnDxici8-4oaSIFJlc3BvbnNlPGJyLz48Yj5wbGFpbiBjYWxsPC9iPiJdCiAgICAgICAgZzRbIjxiPnJvdXRlKCcvdXNlcicsIGhhbmRsZXIpPC9iPiDirJw8YnIvPjxiPmhhbmRsZXIgaXMgYSBwbGFpbiBmdW5jPC9iPiJdCiAgICAgICAgZzVbIjxiPm1haW4oKTwvYj4g4qycPGJyLz48Yj5wbGFpbiBmdW5jPC9iPiJdCiAgICAgICAgZzEgLS0-IGcyCiAgICAgICAgZzIgLS0-IGczCiAgICAgICAgZzMgLS0-IGc0CiAgICAgICAgZzQgLS0-IGc1CiAgICBlbmQKCiAgICBOb2RlIH5-fiBHbwoKICAgIGNsYXNzRGVmIHJlZENsYXNzIGZpbGw6I2ZlZTJlMixzdHJva2U6I2RjMjYyNixzdHJva2Utd2lkdGg6MnB4LGNvbG9yOiM3ZjFkMWQKICAgIGNsYXNzRGVmIHBsYWluQ2xhc3MgZmlsbDojZjNmNGY2LHN0cm9rZTojNmI3MjgwLHN0cm9rZS13aWR0aDoycHgsY29sb3I6IzExMTgyNwoKICAgIGNsYXNzIG4xLG4yLG4zLG40LG41IHJlZENsYXNzCiAgICBjbGFzcyBnMSxnMixnMyxnNCxnNSBwbGFpbkNsYXNz&quot; alt=&quot;flowchart LR&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-hard-limit&quot;&gt;The Hard Limit&lt;/h4&gt;
&lt;p&gt;The model fails the moment your code stops waiting. A single CPU-bound operation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;while (true) { /* heavy work */ }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…holds the JS main thread, and &lt;em&gt;every other request on this process is dead&lt;/em&gt; until it returns. The event loop has nowhere else to go. Worker threads, child processes, or splitting CPU work into a separate service are real fixes, but they're escape hatches — they exist because the core model has only one main thread executing JS, and there is exactly one of it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;go-s-answer-move-context-switching-into-user-space&quot;&gt;Go's Answer: Move Context Switching Into User Space&lt;/h3&gt;
&lt;p&gt;Go writes synchronous code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;user := db.GetUser(id)
sendResponse(user)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no &lt;code&gt;await&lt;/code&gt;. There is no callback. The function looks like it blocks on the database. And yet the program scales to hundreds of thousands of concurrent operations on modest hardware.&lt;/p&gt;
&lt;p&gt;The trick is that the &lt;em&gt;scheduling boundary has been moved.&lt;/em&gt; Where Node has the programmer mark the suspension point with &lt;code&gt;await&lt;/code&gt; and the runtime captures a continuation, Go lets the programmer write straight-line code and has the &lt;em&gt;runtime&lt;/em&gt; suspend the entire goroutine when it hits a blocking I/O call.&lt;/p&gt;
&lt;p&gt;This is the central insight, and the cleanest one-line statement of Go's concurrency model:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Go's essence is the user-space-ification of context switching.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A goroutine isn't an OS thread. It's a small (initially 2 KB) growable stack and a register snapshot, managed by the Go runtime. The runtime maps a large number of goroutines (G) onto a small number of OS threads (M) using scheduling contexts (P). This is the GMP model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;G&lt;/strong&gt; — a goroutine. The unit of scheduling. Cheap to create, cheap to suspend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;M&lt;/strong&gt; — an OS thread. Usually only &lt;code&gt;GOMAXPROCS&lt;/code&gt; of them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;P&lt;/strong&gt; — a scheduling context. Decides which G runs on which M.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;many G  →  Go scheduler  →  few M  →  CPU cores
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a goroutine hits a blocking syscall or a channel wait, the Go runtime suspends the goroutine — saves its stack and registers — detaches it from the current M, and schedules another runnable goroutine onto that M. When the original goroutine's wait completes, it's marked runnable again, and some M eventually picks it up and resumes execution from the suspension point. &lt;strong&gt;None of this enters the kernel.&lt;/strong&gt; No &lt;code&gt;clone(2)&lt;/code&gt;, no kernel-mediated thread switch, no kernel scheduler queue. The bookkeeping is all in user space.&lt;/p&gt;
&lt;p&gt;That's the user-space-ification. The CPU still has to switch contexts when work shifts between goroutines, but the cost is roughly a function call plus a stack swap — not a kernel-mediated thread switch.&lt;/p&gt;
&lt;p&gt;The key contrast with Node's model is in &lt;em&gt;where the suspended state lives:&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://mermaid.ink/img/Zmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBOb2RlWyI8Yj5Ob2RlIOKAlCBTdGFja2xlc3MgQ29yb3V0aW5lPC9iPiJdCiAgICAgICAgZGlyZWN0aW9uIFRCCiAgICAgICAgblN0YWNrWyI8Yj5KUyBDYWxsIFN0YWNrPC9iPjxici8-KG9uZSBmcmFtZSBhdCBhIHRpbWUpPGJyLz7ilIHilIHilIHilIHilIHilIHilIHilIHilIHilIHilIHilIE8YnIvPuKaoCA8Yj5jdXJyZW50bHkgZW1wdHk8L2I-PGJyLz4oYWxsIGFzeW5jIGZucyBwb3BwZWQsPGJyLz53YWl0aW5nIGluIGV2ZW50IGxvb3ApIl0KICAgICAgICBuSGVhcFsiPGI-SGVhcDwvYj4iXQogICAgICAgIG5DMVsiPGI-Y29udGludWF0aW9uICMxPC9iPjxici8-eyBzdGF0ZTogMSw8YnIvPiZuYnNwOyZuYnNwO2xvY2Fsczoge3JlcSwgcmVzLCBhfSw8YnIvPiZuYnNwOyZuYnNwO3N0ZXA6IGZuIHB0ciB9Il0KICAgICAgICBuQzJbIjxiPmNvbnRpbnVhdGlvbiAjMjwvYj48YnIvPnsgc3RhdGU6IDAsIC4uLiB9Il0KICAgICAgICBuQzNbIjxiPmNvbnRpbnVhdGlvbiAjMzwvYj48YnIvPnsgc3RhdGU6IDIsIC4uLiB9Il0KICAgICAgICBuSGVhcCAtLT4gbkMxCiAgICAgICAgbkhlYXAgLS0-IG5DMgogICAgICAgIG5IZWFwIC0tPiBuQzMKICAgICAgICBuTm90ZVsiPGI-RWFjaCA8Y29kZT5hd2FpdDwvY29kZT4gcG9wcyB0aGUgZnJhbWUuPC9iPjxici8-U3RhdGUgbGl2ZXMgb25seSBpbiBoZWFwIGNsb3N1cmVzLjxici8-U3RhY2sgaXMgcmV1c2VkIGFjcm9zcyBhbGwgdHVybnMuIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEdvWyI8Yj5HbyDigJQgU3RhY2tmdWwgQ29yb3V0aW5lPC9iPiJdCiAgICAgICAgZGlyZWN0aW9uIFRCCiAgICAgICAgZ01bIjxiPk9TIFRocmVhZCAoTSk8L2I-PGJyLz5jdXJyZW50bHkgcnVubmluZyBHMyDilrYiXQogICAgICAgIGdIZWFwWyI8Yj5IZWFwPC9iPiJdCiAgICAgICAgZ0cxWyI8Yj5nb3JvdXRpbmUgRzE8L2I-ICgyIEtCIHN0YWNrKTxici8-4pSB4pSB4pSB4pSB4pSB4pSB4pSB4pSB4pSB4pSB4pSB4pSBPGJyLz5wcm9jZXNzKCk8YnIvPiZuYnNwOyZuYnNwO-KGsyBzbG93RG91YmxlKCk8YnIvPiZuYnNwOyZuYnNwOyZuYnNwOyZuYnNwO-KGsyB0aW1lLlNsZWVwKCkg4piFcGFya2VkIl0KICAgICAgICBnRzJbIjxiPmdvcm91dGluZSBHMjwvYj4gKDIgS0Igc3RhY2spPGJyLz7ilIHilIHilIHilIHilIHilIHilIHilIHilIHilIHilIHilIE8YnIvPmhhbmRsZXIoKTxici8-Jm5ic3A7Jm5ic3A74oazIGRiLlF1ZXJ5KCkg4piFcGFya2VkIl0KICAgICAgICBnRzNbIjxiPmdvcm91dGluZSBHMzwvYj4gKDIgS0Igc3RhY2spPGJyLz7ilIHilIHilIHilIHilIHilIHilIHilIHilIHilIHilIHilIE8YnIvPmN1cnJlbnRseSBvbiBNIOKWtiJdCiAgICAgICAgZ0hlYXAgLS0-IGdHMQogICAgICAgIGdIZWFwIC0tPiBnRzIKICAgICAgICBnSGVhcCAtLT4gZ0czCiAgICAgICAgZ05vdGVbIjxiPkVhY2ggZ29yb3V0aW5lIG93bnMgaXRzIGZ1bGwgc3RhY2suPC9iPjxici8-UnVudGltZSBzYXZlcy9yZXN0b3JlcyBlbnRpcmUgc3RhY2s8YnIvPm9uIHN1c3BlbmQuIE5vIGZyYW1lIHBvcCBuZWVkZWQuIl0KICAgIGVuZAoKICAgIE5vZGUgfn5-IEdvCgogICAgY2xhc3NEZWYgbm9kZUFsZXJ0IGZpbGw6I2ZlZTJlMixzdHJva2U6I2RjMjYyNixzdHJva2Utd2lkdGg6M3B4LGNvbG9yOiM3ZjFkMWQKICAgIGNsYXNzRGVmIG5vZGVDbGFzcyBmaWxsOiNmZWYzYzcsc3Ryb2tlOiNkOTc3MDYsY29sb3I6IzExMTgyNwogICAgY2xhc3NEZWYgZ29DbGFzcyBmaWxsOiNkYmVhZmUsc3Ryb2tlOiMyNTYzZWIsY29sb3I6IzExMTgyNwogICAgY2xhc3NEZWYgbm90ZUNsYXNzIGZpbGw6I2ZmZmZmZixzdHJva2U6IzM3NDE1MSxzdHJva2Utd2lkdGg6MS41cHgsY29sb3I6IzExMTgyNwoKICAgIGNsYXNzIG5TdGFjayBub2RlQWxlcnQKICAgIGNsYXNzIG5IZWFwLG5DMSxuQzIsbkMzIG5vZGVDbGFzcwogICAgY2xhc3MgZ00sZ0hlYXAsZ0cxLGdHMixnRzMgZ29DbGFzcwogICAgY2xhc3Mgbk5vdGUsZ05vdGUgbm90ZUNsYXNz&quot; alt=&quot;flowchart LR&quot;&gt;&lt;/p&gt;
&lt;p&gt;In Node, the JS call stack is shared and almost always near-empty — every async function in flight has already popped, with its state sitting in a heap closure. In Go, every goroutine owns its full call chain on its own heap-allocated stack; suspended goroutines look like frozen frames waiting for the runtime to resume them on some OS thread.&lt;/p&gt;
&lt;p&gt;This is also why neither language can simply borrow the other's model. &lt;strong&gt;Node runs on V8&lt;/strong&gt;, which was designed in 2008 for browser JS — single call stack, synchronous semantics, no concept of saving stacks across yields. Adding stackful coroutines would mean rewriting the engine, which is roughly what Java's Project Loom did to the JVM at huge cost. &lt;strong&gt;Go was designed from scratch&lt;/strong&gt; with a runtime that owns stacks, can grow them, and can save them. The choice is locked in by runtime architecture, not language taste.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-user-space-actually-buys-you&quot;&gt;What &quot;User-Space&quot; Actually Buys You&lt;/h3&gt;
&lt;p&gt;The slogan only matters if user-space context switching is meaningfully cheaper than the kernel-mediated kind. It is — by more than an order of magnitude.&lt;/p&gt;
&lt;p&gt;Two goroutines pinned to one OS thread (&lt;code&gt;GOMAXPROCS=1&lt;/code&gt;), ping-ponging via &lt;code&gt;runtime.Gosched()&lt;/code&gt; and via an unbuffered channel. Two pthreads pinned to one core (&lt;code&gt;taskset -c 0&lt;/code&gt;), ping-ponging via &lt;code&gt;pthread_mutex&lt;/code&gt; + &lt;code&gt;pthread_cond&lt;/code&gt;. (Reproduction code at the end of the post.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Measured on Intel N100, Ubuntu 24.04 (kernel 6.8.0), Go 1.23.4, gcc 13.3:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Operation &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;ns / switch&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Goroutine yield (&lt;code&gt;runtime.Gosched&lt;/code&gt;, GOMAXPROCS=1) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;~102 ns&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Goroutine round-trip via unbuffered channel &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;~436 ns&lt;/strong&gt; (≈218 ns per G-switch + channel coordination)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;pthread switch (mutex+cond ping-pong, single core) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;~2,900 ns&lt;/strong&gt; (range 2,818–3,611 across 5 runs of 2M iterations)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Ratio: roughly &lt;strong&gt;28× cheaper&lt;/strong&gt; for the bare scheduler yield, &lt;strong&gt;~13× cheaper&lt;/strong&gt; for the apples-to-apples synchronized round-trip.&lt;/p&gt;
&lt;p&gt;Where the gap comes from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mode switch.&lt;/strong&gt; The user → kernel → user round-trip alone is ~100 ns of entry/exit and ABI-mandated register save/restore. A goroutine switch never crosses that line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scheduler work in kernel space.&lt;/strong&gt; Linux CFS maintains a red-black tree of runnable threads with locked, cross-CPU runqueues. The Go scheduler does the same job in user space with per-P local runqueues and lock-free fast paths — and skips the kernel locks entirely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache and TLB effects.&lt;/strong&gt; A kernel scheduler may migrate a thread to a different core, costing you cold L1/L2 and an instruction-cache reload. Goroutines normally stay on the same M, so the cache stays warm.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What the model does &lt;em&gt;not&lt;/em&gt; buy you: a goroutine that makes a real blocking syscall still pays for a real OS thread switch — the runtime detaches the G from its M and may spin up another M so the rest of the goroutines keep running. Async preemption (Go 1.14+, signal-based) is the runtime's answer to tight loops that never yield, and it has its own cost. Once you saturate &lt;code&gt;GOMAXPROCS&lt;/code&gt;, the user-space runqueue itself starts to show up in profiles.&lt;/p&gt;
&lt;p&gt;The &quot;user-space-ification&quot; buys you &lt;strong&gt;cheap G-to-G switching on a hot M.&lt;/strong&gt; That's where the order-of-magnitude lives. The syscalls, the M-to-M handoffs, the actual kernel work — those are still as expensive as they always were. The model wins by making the &lt;em&gt;common case&lt;/em&gt; — many concurrent goroutines, mostly waiting, occasionally running — almost free.&lt;/p&gt;
&lt;p&gt;(N100 is a low-power Alder Lake-N E-core; absolute numbers will be smaller on a server-class Xeon or EPYC, but the ratio is expected to hold.)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-unit-of-scheduling&quot;&gt;The Unit of Scheduling&lt;/h3&gt;
&lt;p&gt;The cleanest comparison is to ask what each runtime actually schedules:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;&lt;/th&gt;&lt;th align=&quot;left&quot;&gt;&lt;strong&gt;Node / TypeScript&lt;/strong&gt; &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;&lt;strong&gt;Go&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Unit of scheduling &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;callback / Promise continuation &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;goroutine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;What's captured at suspension &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;tail of an async function as a heap closure &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;full call stack + registers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;How code looks &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;explicit &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;straight-line synchronous&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Suspension marked by &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;the programmer (&lt;code&gt;await&lt;/code&gt;) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;the runtime (any blocking op)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Suspended state lives in &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;V8 microtask queue + heap closure &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;goroutine stack on the user-space heap&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Kernel involvement &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;epoll/kqueue/IOCP via libuv &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;epoll/kqueue/IOCP via netpoller&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;CPU parallelism &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;one main JS thread; needs workers/cluster for cores &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;M:N scheduler runs goroutines across cores natively&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Function color &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;yes (Promise infects up the call stack) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;no (any function may block)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;What breaks under CPU load &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;the entire event loop &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;nothing — scheduler runs another G on another M&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The two columns describe deeply different mental models, but they belong to the same family. They are both &lt;em&gt;user-space concurrency runtimes that avoid kernel thread-per-request.&lt;/em&gt; They differ in where the suspension is captured (the language vs. the call stack) and how broad the scheduler's mandate is.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-the-boundaries-diverge-cpu-bound-work&quot;&gt;Where the Boundaries Diverge: CPU-Bound Work&lt;/h3&gt;
&lt;p&gt;Node and Go look interchangeable on I/O-bound workloads. They diverge sharply the moment CP&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16780/node-turns-waiting-into-events-go-moves-context-switching-into-user-space</guid>
<pubDate>Tue, 05 May 2026 19:04:54 +0000</pubDate>
</item>
<item>
<title>The Concurrency Choreographer: Making BLE’s Async Data Sync with OBS</title>
<link>https://coderlegion.com/7761/the-concurrency-choreographer-making-bles-async-data-sync-with-obs</link>
<description>&lt;p&gt;“What can we do with asynchronous programming?”, This was one of the questions I received in my talk on asynchronous programming that I delivered in &lt;a rel=&quot;nofollow&quot; href=&quot;https://kitfucoda.medium.com/from-blog-post-to-pycon-my-one-week-crash-course-in-public-speaking-ab4987b75702&quot;&gt;Pycon MY&lt;/a&gt;. While I briefly mentioned building a chatbot that day. I believe the project we are discussing today should resonate more. During my spare time (however rare it is these days), I stream my gameplay session to document my journey. One of the widgets in my streaming session is a heart rate monitor, and it has been broken for some time.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*XhPp68JeBibP6di42WaXHQ.jpeg&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Cute cartoon on the topic generated by Gemini&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;seeking-the-open-standard-from-fitbit-s-lock-in-to-garmin-s-ble&quot;&gt;Seeking the Open Standard: From Fitbit’s Lock-in to Garmin’s BLE&lt;/h4&gt;
&lt;p&gt;I was doing some spring-cleaning for my OBS setup, and one component caught my eye — the heart rate monitor. For a time, it was a novel way to convey a streamer’s psychological state in a numerical form. Coincidentally, I was already wearing a fitness tracker: Fitbit Versa 3 and I decided to give it a go. Fortunately, there was a way to capture heart rate through the somewhat limited proprietary API, and have it streamed to my computer. Problem is, the fitness (not so) smartwatch died.&lt;/p&gt;
&lt;p&gt;Funnily enough, a family member bought an almost identical Fitbit Sense a few months earlier and her watch is still functioning as of now. In a way the watch died rather prematurely. On the other hand, &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/beyond-the-code-a-lunar-new-year-reflection-on-career-recovery-and-new-beginnings-l77&quot;&gt;I was out of a job&lt;/a&gt;, and yet that didn’t translate to more gaming time as I was juggling with multiple things at the time. Consequently, the replacement only came much later, not long after I started my current job. Instead of getting another Fitbit, I moved on to a Garmin Instinct II, a tracker my peers highly recommended.&lt;/p&gt;
&lt;p&gt;Unlike my past Fitbit devices, the Garmin watch does broadcast my heart rate via a standard Bluetooth interface. This makes replacing the workflow a much easier job. For that to work, I just needed to figure out how to get the data, and then supply that data to OBS via the WebSocket interface. The library in use is bleak to communicate with the watch via Bluetooth LE, and obsws-python to publish heart rate to OBS.&lt;/p&gt;
&lt;p&gt;Previously, our chatbot project only dealt with asynchronous libraries. Yet, this time the library that communicates with OBS is synchronous. Therefore, we are taking this opportunity to discuss how to coordinate them to work with each other. Specifically, we are solving the problem where the producer (the heart rate monitor data) is asynchronous, and the consumer (the sending of heart rate to OBS) is synchronous.&lt;/p&gt;
&lt;h4 id=&quot;building-the-async-producer-connecting-via-bluetooth-low-energy&quot;&gt;Building the Async Producer: Connecting via Bluetooth Low Energy&lt;/h4&gt;
&lt;p&gt;My journey on tracking my fitness data started with reading about running. One of the books I read was titled &lt;em&gt;Body, Mind and Sport&lt;/em&gt; by John Douillard. Some of the practices advocated may seem controversial, though the idea of being mindful about my physical state stuck in my mind. As a result, I started running with deep breaths, and soon noticed running could be made bearable or even enjoyable with some effort. At the same time, I went looking for a chest belt to pair with my fitness tracker installed on my phone.&lt;/p&gt;
&lt;p&gt;That was before wearable tech went mainstream, so my choices were rather limited. Chest straps to measure heart rate were common among runners, but they weren’t built to work with Bluetooth. I had to search high and low to finally find one that would work with my smartphone application. It was slightly more expensive than an ordinary tracker, but it was worth it. I ran with it for a few years, and benefited from having my current heart rate displayed on my tracker in real time.&lt;/p&gt;
&lt;p&gt;No more estimating by poking my fingers to my neck and doing multiplication while I was busy catching my breath.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*hKnfzx0ERmDh3wY1&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Luiz Rogério Nunes on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;When wearable technology started to take off, Sony later released their smart band. The companion app also provided some limited insight on how my body reacts to activities in my day-to-day life. Conveniently, it also broadcasts my heart rate through Bluetooth and switching to it in my sports tracker took almost no effort. No more having to prepare my chest strap by wetting it before a run — that was a definite win.&lt;/p&gt;
&lt;p&gt;Enough storytelling for now, let’s shift our focus back to the project. Like my old Sony smart band I bought second-hand, my Garmin Instinct 2 also broadcasts my heart rate on Bluetooth on demand. A quick search yielded &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kieranabrennan/blehrm&quot;&gt;blehrm&lt;/a&gt;, and it provides an abstraction to &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hbldh/bleak&quot;&gt;bleak&lt;/a&gt;. Unfortunately, it wasn’t the right library for the purpose, and I soon moved on to bleak directly after some failed attempts.&lt;/p&gt;
&lt;p&gt;Firstly, we start by finding our device. Call the BleakScanner.discover method with a reasonable timeout, as shown below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from bleak import BleakScanner

ble_devices = await BleakScanner.discover(timeout=10.0)
print(f&quot;--- Discovered {len(ble_devices)} BLE Devices ---&quot;)
for i, device in enumerate(ble_devices):
    print(
        f&quot;#{i}: Name: {device.name}, Address: {device.address}, Details: {device}&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running that will return (device addresses are replaced with the placeholder &lt;/p&gt;&lt;address&gt;):
&lt;code&gt;--- Discovered 8 BLE Devices ---
#0: Name: Instinct 2, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: Instinct 2
#1: Name: None, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: None
#2: Name: None, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: None
#3: Name: U-WTD838C, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: U-WTD838C
#4: Name: None, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: None
#5: Name: None, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: None
#6: Name: None, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: None
#7: Name: None, Address: &amp;lt;address&amp;gt;, Details: &amp;lt;address&amp;gt;: None
&lt;/code&gt;
&lt;p&gt;Jot down the address, as we will need it for the next step: establishing a connection to the device.&lt;/p&gt;
&lt;code&gt;from bleak import BleakClient

HR_MEASUREMENT_CHARACTERISTIC_UUID = &quot;00002a37-0000-1000-8000-00805f9b34fb&quot;
async with BleakClient(
    &amp;lt;address&amp;gt;,
    timeout=15.0
) as client:
    await client.start_notify(
        HR_MEASUREMENT_CHARACTERISTIC_UUID,
        heart_rate_handler,
    )
&lt;/code&gt;
&lt;p&gt;In the snippet, we are sending the address we collected earlier to BleakClient with a reasonable timeout. Then we subscribe to the heart rate measurement event, and delegate the handling to a function.&lt;/p&gt;
&lt;p&gt;With the help of Gemini, we saved time digging through the documentation, as it returned a ready-made snippet for the BLE connection and data parsing, which is shown below.&lt;/p&gt;
&lt;code&gt;def heart_rate_handler(sender, data):
    &quot;&quot;&quot;Callback to handle heart rate measurement data.&quot;&quot;&quot;
    # The heart rate data structure is defined in the BLE spec:
    # Byte 0: Flags (contains format of HR value and if other fields are present)
    # Byte 1: Heart Rate Value (8-bit or 16-bit)

    # Check the first bit of the flags to see if HR is 16-bit (0x01) or 8-bit (0x00)
    flags = data[0]
    is_16_bit = flags &amp;amp; 0x01
    if is_16_bit:
        # 16-bit HR value is at index 1 and 2
        hr_value = unpack(&quot;&amp;lt;H&quot;, data[1:3])[0]
    else:
        # 8-bit HR value is at index 1
        hr_value = data[1]
    logger.info(f&quot;[{flags:04b}] Heart Rate: {hr_value} BPM&quot;)
&lt;/code&gt;
&lt;p&gt;There we have it, we have half the program done, and it is able to fetch heart rate data periodically from the device.&lt;/p&gt;
Bridging the Gap: The Producer-Consumer Pattern
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*iPoTtRyufsJY7PGx&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Lenzil Gonsalves on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Continuing the story after I bought my second-hand Sony smart band. Electronics products die inevitably, as Sony wasn’t making a viable successor for the product I had to look elsewhere. That was when I first looked at Fitbit, particularly a Fitbit Charge 3. It was more than doubled the price of my smart band, though it came with more features. One feature I particularly liked was the sleep tracker, as I have really bad sleep pattern. It was working fine, though with a major drawback — I lost the ability to read my heart rate through the sports tracker app.&lt;/p&gt;
&lt;p&gt;Since the device came with a screen itself, it did mean there was no need to whip out my phone constantly to check my heart rate. Not being able to log my run with heart rate data attached created a minor annoyance. On the other hand, I moved on to other sports already, namely boxing and weightlifting. The shift in activity did change the need as I didn’t need the data as often. For instance, the short-burst nature of weightlifting did not require constant monitoring of my heart rate; the main focus was just on whether I could lift the dumbbell or otherwise.&lt;/p&gt;
&lt;p&gt;Again, the tracker died after serving for several years. I moved on to a Versa 3 on a good deal, despite the mild annoyance. Other brands were considered, but I got confused with the numerous lines of products and decided to stay with Fitbit for another time. Being a smart watch, Fitbit did release an SDK for their Versa watches, but they discontinued application development support when they released the successor, Versa 4. Regrettably, I didn’t put in the time and effort to develop anything as I was adequately happy with it.&lt;/p&gt;
&lt;p&gt;As mentioned earlier, I found a way to broadcast my heart rate with an app offered in the Fitbit app store, and that led to the setup of a heart rate widget on my OBS setup. Coincidentally, I acquired Tetris Effect on my workstation, so I was curious to see if my heart rate changed throughout the game play. Surprisingly, it really did, and the recording clearly showed the heart rate fluctuations in action.&lt;/p&gt;
&lt;p&gt;&lt;iframe src=&quot;https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FXtSY_DvsM-E%3Ffeature%3Doembed&amp;amp;display_name=YouTube&amp;amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DXtSY_DvsM-E&amp;amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FXtSY_DvsM-E%2Fhqdefault.jpg&amp;amp;type=text%2Fhtml&amp;amp;schema=youtube&quot; width=&quot;854&quot; height=&quot;480&quot; frameborder=&quot;0&quot; scrolling=&quot;no&quot;&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/media/018de8b0f30246afd93165f500a77b62/href&quot;&gt;&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/media/018de8b0f30246afd93165f500a77b62/href&amp;lt;/a&amp;gt;&amp;lt;/iframe&amp;gt;&quot;&gt;https://medium.com/media/018de8b0f30246afd93165f500a77b62/href&amp;lt;/a&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/a&gt;&lt;/iframe&gt;&lt;/p&gt;
&lt;p&gt;Granted, as the tracker was worn on the wrist, the response was somewhat delayed. Despite that, the stream showed as the tetrimino dropped faster, my heart rate also increased correspondingly. More often than not, the height of dropped blocks waiting to be eliminated also played a role in influencing the heart rate.&lt;/p&gt;
&lt;p&gt;Now that we have the stream of heart rate data, how do we recreate the widget in OBS through our script?&lt;/p&gt;
&lt;p&gt;Open Broadcasting Studio, or OBS in short, offers a way to interact with the sources on screen through the websocket interface. In our case, we are interested in sending our heart rate data to a text source. By using the obsws-python library, we can send the data as soon as we capture it from the device. We can establish this connection to OBS using the following simple code:&lt;/p&gt;
&lt;code&gt;from obsws_python import ReqClient

obs = ReqClient(
 host=&amp;lt;host&amp;gt;,
 port=&amp;lt;port&amp;gt;,
 password=&amp;lt;password&amp;gt;,
)
&lt;/code&gt;
&lt;p&gt;All of these settings can be found in the OBS websocket setup page, as shown in the screenshot below:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*smsVfNpsIrpHQrGgkfu8hg.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Sending heart rate to the source is simple, through the obs.set_input_settings method&lt;/p&gt;
&lt;code&gt;obs.set_input_settings(
 &amp;lt;identifier of the target source&amp;gt;,
 {“text”: f”{item:3d}”},
 True,
)
&lt;/code&gt;
&lt;p&gt;By assembling all these together we would have a complete program for the job. The only problem with putting everything together is that we are mixing asynchronous code calling bleak with a strictly synchronous and likely non thread-safe code by obsws-python. Fortunately, the data only flows one-way; otherwise it complicates the whole setup significantly more. Ideally the library that dealt with OBS websocket could be written in a more asynchronous manner but obsws-python was the only reliable library that I could find in a weekend.&lt;/p&gt;
&lt;p&gt;The project started as a weekend project, and I stopped right after achieving a running proof-of-concept. Only later, when I finally had the time, did I start refactoring this into a more reliable program that properly handles errors and graceful shutdown. Considering we discussed rather extensively in the past on those topics, let’s skip to how we turn this into a producer-consumer design.&lt;/p&gt;
&lt;code&gt;with ThreadPoolExecutor(max_workers=5) as executor:
   executor.submit(producer)
   executor.submit(consumer)
&lt;/code&gt;
&lt;p&gt;We want both the consumer and producer to remain isolated from each other, so we separate them into individual threads. Usually it is better to split them into different processes, but since we are only running one event loop for the producer part it is okay to stick with threads. In order to synchronize both of them, we need to pass a queue and an exit event to them as well. This can be achieved with functools.partial, or by writing a class with the dunder __call__ method, a topic we &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/the-versatility-of-call-a-python-developers-secret-weapon-4akd&quot;&gt;discussed previously&lt;/a&gt;. I will leave the implementation detail out to focus on the core concept itself.&lt;/p&gt;
&lt;p&gt;Now, back to our producer, where we collect the heart rate, instead of printing the heart rate, we dump it to our queue:&lt;/p&gt;
&lt;code&gt;logger.info(f”[{flags:04b}] Heart Rate: {hr_value} BPM”)
asyncio.create_task(asyncio.to_thread(queue.put, hr_value))
&lt;/code&gt;
&lt;p&gt;In case you missed our series of &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/concurrency-vs-parallelism-achieving-scalability-with-processpoolexecutor-1n7n&quot;&gt;articles&lt;/a&gt; on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/concurrency-vs-parallelism-achieving-scalability-with-processpoolexecutor-1n7n&quot;&gt;AsyncIO&lt;/a&gt; (go read them, they are awesome!), queue.put is a blocking operation, so we are delegating it to asyncio.to_thread. For our purpose, we don’t exactly care about the outcome, but we want to ensure it is scheduled safely. Therefore, we pass the returned coroutine object to asyncio.create_task.&lt;/p&gt;
&lt;p&gt;On the other hand, as the code dealing with OBS is synchronous, we need to plan carefully.&lt;/p&gt;
&lt;code&gt;def consumer(queue: Queue, exit_event: Event) -&amp;gt; None:
    obs = ReqClient(
        host=&amp;lt;host&amp;gt;,
        port=&amp;lt;port&amp;gt;,
        password=&amp;lt;password&amp;gt;,
    )

    logger.info(&quot;Starting consumption loop&quot;)
    while True:
        # task 1: handle the exit event
        if exit_event.is_set():
            logger.info(&quot;Exiting consumption loop&quot;)
            break

        # task 2: publish to obs
        with suppress(Empty):
            if item := queue.get(timeout=5.0):
                obs.set_input_settings(
                    &amp;lt;identifier of the target source&amp;gt;,
                    {&quot;text&quot;: f&quot;{item:3d}&quot;},
                    True,
                )
&lt;/code&gt;
&lt;p&gt;This simplified snippet sets up an infinite loop that does two things, firstly it checks if the exit_event is triggered, and secondly, it consumes the heart rate data from the queue and sends it to OBS. We would need to set a timeout and suppress the potential Empty exception when getting a value from the queue. The reason for this is otherwise the code will block until it receives a data, and we would never be able to capture exit_event that was emitted elsewhere.&lt;/p&gt;
Asyncio’s Role in Real-World, Non-Web I/O
&lt;p&gt;Essentially, this is a fairly common Consumer-Producer application between two different components. While the consumer is depending on the producer to publish to OBS, the producer should not have to wait for it to complete. By separating them into different threads and using a queue for synchronization, we allowed both components to work independently. Unlike the telegram chatbot we built previously, this shows how we can adapt synchronous code into an asynchronous environment.&lt;/p&gt;
&lt;p&gt;I appreciate the fact that Garmin allows the heart rate data to be made available through an open interface. If I were to get a new Versa watch, this project would not have been possible as the data is locked away from a proprietary API. As mentioned earlier, Fitbit also removed support for application development for the newer models.&lt;/p&gt;
&lt;p&gt;That would mean no more Tetris Effect streams with heart rate readings for me.&lt;/p&gt;
&lt;p&gt;&lt;iframe src=&quot;https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FXSC61Y4UzwY%3Ffeature%3Doembed&amp;amp;display_name=YouTube&amp;amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DXSC61Y4UzwY&amp;amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FXSC61Y4UzwY%2Fhqdefault.jpg&amp;amp;type=text%2Fhtml&amp;amp;schema=youtube&quot; width=&quot;854&quot; height=&quot;480&quot; frameborder=&quot;0&quot; scrolling=&quot;no&quot;&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/media/6fd1166a20e17dcd450a99014354e282/href&quot;&gt;&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/media/6fd1166a20e17dcd450a99014354e282/href&amp;lt;/a&amp;gt;&amp;lt;/iframe&amp;gt;&quot;&gt;https://medium.com/media/6fd1166a20e17dcd450a99014354e282/href&amp;lt;/a&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/a&gt;&lt;/iframe&gt;&lt;/p&gt;
&lt;p&gt;Not like anyone cared anyway.&lt;/p&gt;
&lt;p&gt;So, is this project a good example as a response to the question posed early in the article? Is this a good demonstration of what asynchronous programming is capable of? I would argue yes, as this shows even though not all components are asynchronous, it is still possible to workaround it. When we have multiple components that need to talk to each other, asyncio is definitely a way to go, albeit requiring more thought and work into it.&lt;/p&gt;
&lt;p&gt;That’s all I have for today! The code snippets are purposefully simplified for brevity, but if you are interested in the project, feel free to check it out on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Jeffrey04/obs-garmin/&quot;&gt;GitHub&lt;/a&gt;. Thanks for reading, I shall see you in writing again soon.&lt;/p&gt;
&lt;p&gt;For the sake of transparency, this article was a collaborative effort with my editor, Gemini, who assisted me throughout the writing process. Beyond providing editorial suggestions to tighten the narrative and structure, the model was instrumental in accelerating the technical side of the project, specifically by generating and verifying the initial code snippet required to correctly extract and parse the raw heart rate data from the BLE characteristic — a small but critical step that saved me hours of digging through specification sheets. The final code and the story are mine.&lt;/p&gt;

&lt;/address&gt;</description>
<category>Synchronization</category>
<guid isPermaLink="true">https://coderlegion.com/7761/the-concurrency-choreographer-making-bles-async-data-sync-with-obs</guid>
<pubDate>Tue, 25 Nov 2025 03:14:25 +0000</pubDate>
</item>
<item>
<title>Concurrency vs. Parallelism: Achieving Scalability with ProcessPoolExecutor</title>
<link>https://coderlegion.com/1822/concurrency-vs-parallelism-achieving-scalability-with-processpoolexecutor</link>
<description>&lt;p&gt;In our previous exploration, we dove deep into AsyncIO by &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/asyncio-task-management-a-hands-on-scheduler-project-2e54&quot;&gt;writing a task scheduler&lt;/a&gt;. We discovered how IO-bound operations were scheduled to the event loop concurrently while building our task scheduler project. Building on what we’ve done, we will revisit the project and scale it to run CPU-bound operations.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*sGUALoy-YdOG_beDjVYm0A.jpeg&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;More chefs, more dishes, illustration generated by Microsoft Copilot&lt;/em&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/how-to-write-an-asyncio-telegram-bot-in-python-4hig&quot;&gt;How to write an AsyncIO Telegram bot in Python&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/understanding-awaitables-coroutines-tasks-and-futures-in-python-gk7&quot;&gt;Understanding Awaitables: Coroutines, Tasks, and Futures in Python&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/asyncio-task-management-a-hands-on-scheduler-project-2e54&quot;&gt;AsyncIO Task Management: A Hands-On Scheduler Project&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Concurrency vs. Parallelism: Achieving Scalability with ProcessPoolExecutor&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;asyncio-a-foundation-for-concurrency&quot;&gt;AsyncIO: A Foundation for Concurrency&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*mKYnSI3lcyvVJ3xd&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by John Barkiple on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It is hard to avoid talking about concurrency in discussions about asynchronous programming. However, when it gets mentioned, often times it gets mixed up with parallelism. When Golang came out, people raved about how goroutines are efficient, and that was the first time I hear people discussing concurrency after my undergrad days. Rob Pike eventually &lt;a rel=&quot;nofollow&quot; href=&quot;https://go.dev/blog/waza-talk&quot;&gt;delivered a talk&lt;/a&gt; aiming to distinguish the two concepts and it became quite popular among my peers.&lt;/p&gt;
&lt;p&gt;While building our project last week, I mentioned event loops functions like a timetable where we slot in &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/understanding-awaitables-coroutines-tasks-and-futures-in-python-gk7&quot;&gt;awaitables&lt;/a&gt; for execution. They would be scheduled to run one at a time, switching to another whenever one is done, or is busy doing IO-operations. For example, when we send multiple dex tasks, it would attempt to (order is not necessarily exact, just to illustrate the idea)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start an HTTP request to the PokeAPI&lt;/li&gt;
&lt;li&gt;Wait for the response for (1)&lt;/li&gt;
&lt;li&gt;Start another HTTP request to the PokeAPI&lt;/li&gt;
&lt;li&gt;Wait for the response for (3)&lt;/li&gt;
&lt;li&gt;Continue the task started in (1), and compute the result&lt;/li&gt;
&lt;li&gt;Start another HTTP request to the PokeAPI&lt;/li&gt;
&lt;li&gt;Wait for the response for (6)&lt;/li&gt;
&lt;li&gt;Continue the task started in (3), and compute the result&lt;/li&gt;
&lt;li&gt;Continue the task started in (6), and compute the result&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We can see the event loop improves the efficiency by keeping the CPU busy, while IO is happening in the background. This is an example of concurrency, in Pike’s word: dealing with a lot of things at once. In a way, it is like planning our time to minimize time waste on waiting in the kitchen. For example, we can proceed to cut ingredients for other dishes, while waiting for our pot of soup to boil. This allows us to concurrently prepare or cook multiple dishes at once.&lt;/p&gt;
&lt;p&gt;So we now know we can achieve concurrency with AsyncIO for IO-bound operations. How about CPU-bound operations?&lt;/p&gt;
&lt;p&gt;We are fortunate to live in an era where computing devices sports multicore CPU by default. Theoretically this should improve efficiency, but spreading work to multiple cores is a challenge. Continuing our cooking analogy, having an extra core or CPU is like having another kitchen and chef. Both chefs can now work together and get more dishes prepared at once. They can both choose to schedule their time wisely, to work concurrently as well.&lt;/p&gt;
&lt;p&gt;Go’s goroutines can be scheduled to both run concurrently, or to be distributed to multiple CPU cores to run at once. In Python, we can achieve concurrency with AsyncIO. However, for parallelism, we would have to use tools like ProcessPoolExecutor in addition to AsyncIO. Let’s get started by continuing our example project last week by making fib run in parallel.&lt;/p&gt;
&lt;h4 id=&quot;harnessing-parallelism-processpoolexecutor-in-action&quot;&gt;Harnessing Parallelism: ProcessPoolExecutor in Action&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*y2IwwK-0f0okYcLN&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Becca Tapert on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;We usually opt to spawn a new thread, when we want to distribute work across the available cores. With threads, data can be shared among the main process and the spawn threads. However, it also means a race condition can occur when multiple threads are writing to the same variable. Hence, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://wiki.python.org/moin/GlobalInterpreterLock&quot;&gt;global interpreter lock (GIL)&lt;/a&gt; was introduced, to limit the behaviour. While it protects the shared data, it makes threads inefficient as it depends on the lock.&lt;/p&gt;
&lt;p&gt;Ensuring data is properly shared across processes and threads is therefore one of the biggest challenge in parallelism. There are ongoing efforts to deal with the limitations, or even &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.python.org/3/howto/free-threading-python.html&quot;&gt;removing GIL entirely in recent releases&lt;/a&gt;. But for now, most of the time we would just start new processes to distribute works to be done at once. In our case, let’s set up ProcessPoolExecutor in our repl function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from concurrent.futures import ProcessPoolExecutor

async def repl() -&amp;gt; None:
    with (
        ProcessPoolExecutor() as executor,
        suppress(asyncio.CancelledError),
    ):
        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Previously, in our evaluator, we would spawn a new thread whenever we work on fib, through the use of asyncio.to_thread. We also know it is just an abstraction of loop.run_in_executor. Now that we are using our own process executor, let’s revert to use run_in_executor, with our own ProcessPoolExecutor&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def evaluate(…):
    match expression:
        ...

        case Expression(command=Command.FIB):
            return asyncio.get_running_loop().run_in_executor(
                executor, fib, expression.args[0]
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, whenever we issue a fib command, a new process is spawn instead of a thread to execute the (potentially) super slow CPU-bound operation. Starting a new process is considered more expensive, as we are pending resource allocation from the operating system. Sharing data and synchronization between processes also now require more effort, and we will discuss further about this later.&lt;/p&gt;
&lt;p&gt;The loop.run_in_executor is essentially an abstraction of executor.submit call, where it takes function and the corresponding parameters. Therefore, in general, we would write it as&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with ProcessPoolExecutor() as executor:
    future = executor.submit(fib, 100)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The returned future object, has a nearly identical design compared to the asyncio counterpart, sans the asynchronous magic.&lt;/p&gt;
&lt;p&gt;While we are at it, let’s add a new command to send multiple fib operations, where the format is fib-multi  [ …]. For instance, when we do fib-multi 100 200 300, it would schedule fib 100, fib 200 and fib 300 to run in parallel. For that, we add a new case in our evaluator&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; async def evaluate(...):
    match expression:
        ...

        case Expression(command=Command.FIB_MULTI):
            return asyncio.gather(
                *[
                    asyncio.get_running_loop().run_in_executor(
                        executor, fib2, nth
                    )
                    for nth in expression.args
                ]
            ) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When we use asyncio.gather, it takes all the awaitables passed as parameters and return a Future object. The result of the awaitables will be returned as a list when .result() is called. In our case, since we are setting the executor explicitly to a ProcessPoolExecutor, the futures are then scheduled to run in parallel.&lt;/p&gt;
&lt;p&gt;And for that, we need to revise our job and dash to properly display the result, namely to handle cases where the result returned is a list.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def job(
    task: tuple[
        str,
        Event | None,
        asyncio.Task[str] | asyncio.Future[str] | asyncio.Future[list[str]],
    ],
) -&amp;gt; str:
    try:
        match task[-1]:
            case asyncio.Future():
                return (
                    &quot;; &quot;.join(task[-1].result())
                    if isinstance(task[-1].result(), list)
                    else task[-1].result()
                ) # type: ignore

            case _:
                return &quot;Still waiting&quot;

    except asyncio.InvalidStateError:
        return &quot;Still waiting&quot;

def dash(
    tasks: Sequence[
        tuple[
            str,
            Event | None,
            asyncio.Task[str] | asyncio.Future[str] | asyncio.Future[list[str]],
        ]
    ],
) -&amp;gt; str:
    result = [
        &quot;\t&quot;.join((&quot;id&quot;, f&quot;{'command':16s}&quot;, &quot;done?&quot;, &quot;result&quot;)),
        &quot;\t&quot;.join((&quot;==&quot;, f&quot;{'=' * 16}&quot;, &quot;=====&quot;, &quot;======&quot;)),
    ]

    for num, (line, _, task) in enumerate(tasks, 1):
        task_done, task_result = False, None

        if task_done := task.done():
            task_result = (
                &quot;; &quot;.join(task.result())
                if isinstance(task.result(), list)
                else task.result()
            )

        result.append(
            &quot;\t&quot;.join(
                (
                    str(num),
                    f&quot;{line:16s}&quot;,
                    str(task_done),
                    task_done and str(task_result)[:16] or &quot;&quot;,
                )
            )
        )

    return &quot;\n&quot;.join(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now issue an actual fib-multi command, and check our system monitor to see the operating system does indeed allocate multiple processes to do the job.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*CFW4N52aCixLjxXe6fDjjg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;A screen-capture from my system monitor&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;On the other hand, dex is still unaffected, and would continue to work concurrently despite the changes introduced to fib. This exercise show how it is possible to introduce parallelism into a concurrent program.&lt;/p&gt;
&lt;h4 id=&quot;managing-processes-cancellation-and-communication&quot;&gt;Managing Processes: Cancellation and Communication&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*82eJBcJ8GOU9bg7R&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Mark Boss on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A big drawback of starting a new process is that communication takes a lot of effort, and we lose some control over it. For instance, if we create a task in asyncio, we can cancel the task whenever needed with .cancel(). However, despite the ProcessPoolExecutor provides a .cancel() method as well, it does not always work for scheduled futures that are in progress.&lt;/p&gt;
&lt;p&gt;One way to workaround the problem is through event objects. In our example, we will need 2 types of events, one for task cancellation, and another for application exit. Let’s get fib to subscribe to both of them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def fib(nth, exit_event=None, cancel_event=None):
    ...

    for i in range(1, nth + 1):
        if (exit_event and exit_event.is_set()) or (
            cancel_event and cancel_event.is_set()
        ):
            return &quot;Cancelled&quot;

        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key is to stop the loop, whenever either of the events is set. In our case, would be either user is exiting the program with quit, or cancelling the job with kill . Now we move on to the evaluator to ensure both these events are sent to fib.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from multiprocessing.managers import SyncManager

async def evaluate(
    …
    executor: ProcessPoolExecutor,
    exit_event: Event,
    manager: SyncManager,
    expression: Expression,
):
    match expression:
        ...

        case Expression(command=Command.FIB):
            cancel_event = manager.Event()
            return cancel_event, asyncio.get_running_loop().run_in_executor(
                executor, fib, expression.args[0], exit_event, cancel_event
        )

        case Expression(command=Command.FIB_MULTI):
            cancel_event = manager.Event()
            return cancel_event, asyncio.gather(
                *[
                    asyncio.get_running_loop().run_in_executor(
                        executor, fib, nth, exit_event, cancel_event
                    )
                    for nth in expression.args
                ]
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application exit_event is going to be applicable for all fib operations, so we are requiring it in the parameters. On the other hand, a cancel_event is only relevant for a particular job, hence it is created just for it. Then we set up the global exit_event in repl().&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import multiprocessing

async def repl() -&amp;gt; None:
    with (
        ProcessPoolExecutor(max_workers=5) as executor,
        suppress(asyncio.CancelledError),
    ):
        manager = multiprocessing.Manager()
        exit_event = manager.Event()
        …
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We create Event() object through the multiprocessing.Manager() object because it is generally more reliable, especially we are passing the object between processes.&lt;/p&gt;
&lt;p&gt;The ShutdownHandler would require a little bit of revision so it is aware of the exit_event.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class ShutdownHandler:
    exit_event: Event
    signal: int | None = None

    async def __call__ (self) -&amp;gt; None:
        logger.info(&quot;Shutting down tasks. signal:%s&quot;, self.signal or &quot;&quot;)

        self.exit_event.set()

        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we can prompt fib, which is our CPU-bound task to return when the application exits. The changes in the handler, also require init() function to take exit_event as parameter&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def init(exit_event: Event) -&amp;gt; None:
    loop = asyncio.get_running_loop()

    for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
        handler = ShutdownHandler(exit_event, sig)
        loop.add_signal_handler(sig, lambda: asyncio.create_task(handler()))

    loop.set_exception_handler(ExceptionHandler(ShutdownHandler(exit_event)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the application exit part is done, let’s move on to task cancellation. In the evaluator, we see the CPU-bound jobs also return the cancel_event, so we need to properly store them in our list of tasks in repl()&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def repl() -&amp;gt; None:
    ...

                match result := await thread_last(
                    line,
                    (tokenize, t),
                    (parse, g),
                    (
                        evaluate,
                        client,
                        executor,
                        exit_event,
                        manager,
                        tasks,
                        ShutdownHandler(exit_event),
                    ),
                ):
                    case (_, asyncio.Future()):
                        tasks.append((line, *result))
                        print(f&quot;Task {len(tasks)} is submitted&quot;)

                    case asyncio.Task():
                        tasks.append((line, None, result))
                        print(f&quot;Task {len(tasks)} is submitted&quot;)

                    case _:
                        print(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the corresponding kill function is now&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def kill(task):
    match task[-1]:
        case asyncio.Task():
            task[-1].cancel()

        case asyncio.Future():
            assert task[1]

            task[1].set()

    return f'Task &quot;{task[0]}&quot; is killed'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that asyncio.Task is a subclass of asyncio.Future, so the case order matters.&lt;/p&gt;
&lt;h4 id=&quot;the-power-of-the-hybrid-approach&quot;&gt;The Power of the Hybrid Approach&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*sr-mylPImpHPq_Tx&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Elena Mozhvilo on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In our example, we can use ProcessPoolExecutor to introduce parallelism to our application. This allows us to scale CPU-bound operations so that they can be distributed to other cores of the CPU, allowing them to be executed simultaneously. On the other hand, IO-bound operations are still scheduled to run concurrently.&lt;/p&gt;
&lt;p&gt;Remember the cooking analogy we mentioned earlier? Doing things concurrently is very similar to cooking. It is possible to prepare multiple dishes at a time with clever time management. While we wait for our chicken in the oven, we can go stir-fry whatever that is in the pot.&lt;/p&gt;
&lt;p&gt;On the other hand, doing it in parallel means adding a new chef (and maybe even another kitchen). Adding a new chef, would definitely increase throughput, allowing more dishes to be done at once. On the other hand, either or both of them can still be working concurrently, increasing efficiency. In our case, it would be starting separate processes, and each manages their own event loop to take on tasks.&lt;/p&gt;
&lt;p&gt;I hope I am doing a good enough job in this tour of AsyncIO and asynchronous programming. I certainly learned a lot throughout these few weeks, revisiting the documentation, and through building the example project. The updated project can still be &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Jeffrey04/asyncio-post&quot;&gt;accessed on GitHub&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Jeffrey04/asyncio-post&quot;&gt;,&lt;/a&gt;,) with week1 and week2 code separated, allowing comparison. Thank you again for your time, and I shall write again, next week.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This article was enhanced by the editorial assistance of a large language model, which provided valuable feedback on clarity, flow, and adherence to writing principles. While the core ideas and code remain my own, the LLM’s suggestions significantly contributed to the overall quality and readability of the piece. I am always open to collaboration on interesting projects and exploring job opportunities. Feel free to connect with me &lt;a rel=&quot;nofollow&quot; href=&quot;https://kitfucoda.medium.com/&quot;&gt;here on Medium&lt;/a&gt; or via &lt;a rel=&quot;nofollow&quot; href=&quot;https://linkedin.com/in/jeffrey04&quot;&gt;my LinkedIn profile&lt;/a&gt; to discuss further.&lt;/p&gt;
</description>
<category>Adv Concepts</category>
<guid isPermaLink="true">https://coderlegion.com/1822/concurrency-vs-parallelism-achieving-scalability-with-processpoolexecutor</guid>
<pubDate>Tue, 08 Apr 2025 14:57:43 +0000</pubDate>
</item>
</channel>
</rss>