DEV Community

Taylor Wang
Taylor Wang

Posted on

The Linux Pool Minted Four Identical Tokens. My Laptop Used Spawn.

I had a worker pool that stamped every job with a unique token, and four workers printed one string. Have you ever treated uniqueness as a vibe instead of something you can assert in a test? I did that for two days, because my laptop kept lying with a polite spawn start method. The Linux box used fork, copied my parent RNG, and minted four identical random tails.

This write-up is a 48-hour lab reconstruction you can replay, not a dashboard memoir with invented metrics. The only evidence I trust here is a script that collides on fork and stays quiet on spawn. If your laptop hides the start method your server uses, you will debug the token format forever.

Hours 0–8: I blamed the clock, then the pid

I stared at the token format like it had insulted me personally, which it kind of had. The original stamp was f"{int(time.time())}-{random.randint(0, 10_000)}", and collisions looked like a coarse clock. I switched to time.time_ns(), then I stuffed os.getpid() into the middle, and I still saw matching random tails. Why would four different pids share one integer from random?

Because they were not four fresh interpreters. They were four copies of one interpreter that had already warmed random.

Dead ends I actually tried

  • Replacing time.time() with time.time_ns() while leaving random inherited from the parent process.
  • Adding os.getpid() and assuming process identity would make the whole token unique enough.
  • Seeding workers with random.seed(time.time()) and colliding again inside the same thin time window.
  • Reading Pool docs for maxtasksperchild when the bug was start method, not worker reuse.
  • Pretty-printing tokens in a notebook kernel that never forked, so every sample looked unique.

That last bullet still makes me wince. Have you noticed how a notebook will happily answer a question you should not have asked?

Hours 8–20: I printed the start method and felt silly

The turning point was one boring print, not a clever insight from a flame graph. I logged multiprocessing.get_start_method() and multiprocessing.get_all_start_methods() on both machines. My laptop reported spawn. The Linux environment reported fork, which copies the address space, including random's internal state.

After a fork, every worker begins with the same Mersenne Twister state unless you reseed from OS entropy. The first random.randint in each worker is therefore the same number, which looks supernatural until you remember os.fork(). Spawn starts a fresh interpreter, reimports your module, and reseeds random during normal startup, so the bug hides.

Does uuid.uuid4() save you here? Usually yes, because it draws from os.urandom rather than the random module. Does secrets.token_hex() save you? Also yes, for the same reason. I had used random because it looked harmless in a tutorial-sized function.

I am not going to quote a default start method for every 2026 CPython build, because that default is a moving target across platforms. Print it. Do not memorize a blog comment from a different OS and a different patch level.

python -c "import multiprocessing as mp, sys; print(sys.version.split()[0]); print(mp.get_start_method()); print(mp.get_all_start_methods())"
Enter fullscreen mode Exit fullscreen mode

If that printout already disagrees with production, stop redesigning the token. You are not debugging uniqueness yet. You are debugging process creation.

Artifact: one script, two start methods

Save this as fork_rng_tokens.py. It warms the parent RNG on purpose, then launches a tiny pool so the inheritance is visible. This is a labeled lab demo, not a captured production incident.

# fork_rng_tokens.py
# Reproducible demo: inherited random state after fork vs a fresh spawn interpreter.
from __future__ import annotations

import argparse
import multiprocessing as mp
import os
import random
import sys


def stamp(idx: int) -> str:
    token = f"{idx}:{os.getpid()}:{random.randint(0, 10_000)}"
    print(token, flush=True)
    return token


def run(method: str, n: int = 4) -> list[str]:
    ctx = mp.get_context(method)
    # Warm the parent so fork copies a live RNG, not a pristine import.
    _ = random.randint(0, 10_000)
    with ctx.Pool(processes=n) as pool:
        return pool.map(stamp, range(n))


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--method",
        required=True,
        choices=tuple(mp.get_all_start_methods()),
    )
    args = parser.parse_args()
    print(
        f"python={sys.version.split()[0]} method={args.method}",
        flush=True,
    )
    tokens = run(args.method)
    tails = [t.split(":")[-1] for t in tokens]
    unique = set(tails)
    print(f"tails={tails}", flush=True)
    print(f"duplicate_tails={len(tails) != len(unique)}", flush=True)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run both sides when the platform offers them. Windows will refuse fork, which is already a clue.

python fork_rng_tokens.py --method spawn
python fork_rng_tokens.py --method fork
Enter fullscreen mode Exit fullscreen mode

On a spawn-only laptop, the fork line errors and your CI still surprises you later. That is the 48-hour trap: the machine that feels fastest to iterate may not implement the start method your server uses.

Reading the output

  • Spawn: four pids, four different tails, duplicate_tails=False in the usual case.
  • Fork: four pids, identical tails, duplicate_tails=True after the parent warmed random.
  • forkserver: closer to spawn for this bug, because workers do not inherit your warmed parent state the same way.

I am labeling those bullets as expected results from this demo, not as a benchmark of your fleet. If a library reseeds workers for you, the collision can vanish and hide the next inherited thing, like a logging lock.

A tiny test plan I would actually keep

  1. Record get_start_method() and get_all_start_methods() in the same log as the first worker token.
  2. Skip fork assertions on platforms that do not expose fork; do not fake the start method in unit tests.
  3. Assert uniqueness on the entropy field, not on the whole string that already contains idx.
  4. Repeat once with random and once with secrets.token_hex(8) so the contrast stays honest.
  5. Fail the build if Linux CI uses fork and the token still comes from random.
# labeled pytest sketch; skip when this OS cannot fork
import multiprocessing as mp
import pytest

@pytest.mark.skipif(
    "fork" not in mp.get_all_start_methods(),
    reason="this platform cannot reproduce Linux fork inheritance",
)
def test_fork_pool_random_tails_are_a_trap():
    from fork_rng_tokens import run

    tokens = run("fork", n=4)
    tails = [t.split(":")[-1] for t in tokens]
    assert len(tails) != len(set(tails)), (
        "If this assertion fails, the demo no longer shows inherited RNG; re-read the parent warmup."
    )
Enter fullscreen mode Exit fullscreen mode

Yes, that test asserts a collision. I want the trap to stay visible. A green test that only runs under spawn taught me nothing for two days.

Decision table I wish I had at hour one

What you see Laptop is quiet First check Safer default
Four workers, one random tail macOS/Windows spawn get_start_method() secrets.token_hex() or uuid.uuid4()
Duplicate IDs only in Linux CI Local Pool looks fine Did the parent call random first? Reseed from os.urandom() in worker_init
Logging deadlocks after Pool start Single process is fine Held threading.Lock across fork Do not fork a threaded parent
Numpy samples match across workers Notebook samples differ numpy.random state is separate Use a per-process SeedSequence
Tokens unique but sockets already bound Dev server restarted cleanly Inherited file descriptors Spawn/forkserver, or close fds in worker_init

Would I still use random for a shuffle in a single process? Yes. Would I use it as a uniqueness primitive across a pool? Not after this lab.

Hours 20–32: I wrote an assertion instead of another token format

I wanted a prettier token, because pretty tokens feel like progress when you are tired. Changing the alphabet does not change the generator. The useful patch was smaller than my ego wanted.

import os
import secrets
from multiprocessing import Pool


def worker_init() -> None:
    # If you must keep random, reseed from OS entropy after the process starts.
    import random

    random.seed(int.from_bytes(os.urandom(16), "big"))


def stamp(idx: int) -> str:
    return f"{idx}:{os.getpid()}:{secrets.token_hex(8)}"


def main() -> None:
    with Pool(processes=4, initializer=worker_init) as pool:
        tokens = pool.map(stamp, range(4))
    tails = [t.split(":")[-1] for t in tokens]
    if len(tails) != len(set(tails)):
        raise SystemExit(f"collision: {tails}")
    print("ok", tokens)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The initializer is optional once secrets is the source of the tail. I kept it because leftover random calls still existed elsewhere in the worker. Have you ever grepped a codebase for random. and found a second generator hiding in a helper?

I also printed inherited hazards next to the start method, because RNG was only the loudest copy.

  1. random state, if the parent already drew a number.
  2. numpy.random state, which does not follow random unless you wire it.
  3. Logging locks, if any handler created threads before the pool started.
  4. Open sockets and database clients, which are unsafe to use after fork.
  5. In-memory caches that look like per-worker state and are actually one copied dict.

Fork is convenient until it is not. This article is not permission to keep forking a threaded web process.

Hours 32–48: I reran the pool on a Linux interpreter that was not my laptop

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The spawn laptop could not show me the collision, no matter how many token formats I invented. I reran fork_rng_tokens.py with MonkeyCode's free server option so a Linux start method list could include fork. I pasted only the parent warmup and stamp() into a free model session and asked which objects survive fork unchanged. The model was a reviewer of a snippet I already ran, not a replacement for Pool.map.

If you need that same split — a Linux interpreter plus a model that reads the function — the free model access and free server option are the pieces that actually participated.

What broke, and what I would repeat

What broke was my belief that four pids implied four independent runtimes. What also broke was a helper that mixed time and random and called the result a token. What I would repeat is boring instrumentation before a clever rewrite.

  • Print start methods before rewriting token formats, because format is rarely the process model.
  • Warm the parent RNG on purpose when you want the trap to be visible in a demo.
  • Assert uniqueness on the entropy field, not on an index you already know is unique.
  • Prefer secrets or uuid4 for identifiers, and keep random for shuffles and simulations.
  • Reproduce on an OS that offers fork before you close a Linux-only incident.

Would I repeat the two days of clock chasing? No. Would I repeat the one-line start method print? Every time a pool behaves differently off my laptop.

Limitations, and who should skip this

This approach is a start-method check plus a uniqueness assertion, not a multiprocessing course and not a performance study. I did not collect timings, hardware notes, or model names, because those claims would be invented. I also did not pin a CPython default for every platform in September 2026; your interpreter's own printout beats my memory.

Skip this workflow if you cannot run Python at a prompt, or if your workers are threads rather than processes. Skip it if you need a production replica with your private network, because a free shared server is the wrong place for secrets and customer data. Skip it if you already spawn everywhere and your duplicates come from a retry queue, a non-unique database key, or a load balancer timeout.

Fork remains a sharp tool. If the parent has threads, open SSL connections, or a live logging handler, switching identifiers to secrets will not make fork safe. In that case the fix is the start method, or not using multiprocessing at all.

I closed the lab when spawn and fork stopped disagreeing about uniqueness. The token looks plainer now, and I can live with that. Can you?

Top comments (0)