Skip to content

BUG: SeedSequence.spawn and np.random.set_bit_generator are not thread-safe #32063

Open

Description

@ngoldbaum

Describe the issue:

Two independent thread-safety problems in numpy.random, found while reviewing the fix for gh-32059.

1. SeedSequence.spawn hands out duplicate spawn keys

SeedSequence.spawn reads self.n_children_spawned, constructs the children (Python-level work, so the GIL can be released between bytecodes), and only then increments the counter. Two threads spawning from the same SeedSequence can both observe the same starting index and return children with identical spawn keys, i.e. bit generators that produce identical streams. That silently breaks the central guarantee of the spawning API for parallel work.

The BitGenerator lock can't help here: a SeedSequence is an independent object that can be shared by multiple bit generators (or none), so it needs its own synchronization. Note that a per-instance lock interacts with pickling (the auto-generated cdef pickling would try to serialize it).

BitGenerator.spawn and Generator.spawn delegate to SeedSequence.spawn, so they inherit the bug.

Reproduce:

import sys
import threading

import numpy as np

# not required, just makes it reproduce quickly on the GIL-enabled build
sys.setswitchinterval(1e-5)  

ss = np.random.SeedSequence(0)
barrier = threading.Barrier(4)


def spawn(out):
    barrier.wait()
    out.extend(ss.spawn(500))


results = [[] for _ in range(4)]
threads = [threading.Thread(target=spawn, args=(r,)) for r in results]
for t in threads:
    t.start()
for t in threads:
    t.join()

keys = [c.spawn_key for r in results for c in r]
print(f"spawned {len(keys)} children, {len(set(keys))} unique spawn keys, "
      f"n_children_spawned={ss.n_children_spawned}")

Observed (3 out of 3 runs on a GIL build):

spawned 2000 children, 500 unique spawn keys, n_children_spawned=2000

i.e. all four threads returned the same 500 children.

2. np.random.set_bit_generator races with concurrent draws from the singleton

set_bit_generator calls RandomState._initialize_bit_generator with no synchronization. That method rewrites, non-atomically with respect to running draws:

  • self._bit_generator (dropping the reference to the old one),
  • self._bitgen, the copied bitgen_t struct of function pointers plus state pointer,
  • self._aug_state.bit_generator, and
  • self.lock itself.

Distribution fills run with self.lock, nogil: — without the GIL — reading those function/state pointers, so a concurrent swap lets a fill observe a mixed struct (e.g. MT19937's next_double called on a pcg64_state*, which reads/writes far past the end of the smaller struct) or a state pointer into an already-deallocated bit generator.

Taking the lock inside set_bit_generator would not be sufficient: the lock attribute is replaced by the swap, so a thread still blocked on the old lock and a thread acquiring the new lock can be inside the critical section simultaneously. A real fix needs a stable guard owned by RandomState that is never swapped or the API has to explicitly require that replacement happen only while no other thread uses the singleton.

IMO the simplest fix is to detect and raise an error when we detect that set_bit_generator is used on a shared RandomState.

Reproduce:

import threading

import numpy as np

barrier = threading.Barrier(2)
stop = threading.Event()


def draw():
    barrier.wait()
    while not stop.is_set():
        np.random.random(100_000)


def swap():
    barrier.wait()
    for i in range(500):
        bg = np.random.PCG64(i) if i % 2 else np.random.MT19937(i)
        np.random.set_bit_generator(bg)
    stop.set()


threads = [threading.Thread(target=draw), threading.Thread(target=swap)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print("survived")

This script segfaults most times I run it on the GIL-enabled build in my testing.

Python and NumPy Versions:

NumPy main, reproduced using Python 3.14.5.

cc @rkern since fixing these issues requires answering some design questions for the RNG module

Activity

  1. added
    39 - free-threadingPRs and issues related to support for free-threading CPython (a.k.a. no-GIL, PEP 703)
    on Jul 22, 2026
  2. rkern commented on Jul 22, 2026

    @rkern
    Member

    What do you mean GIL-enabled (I have not really kept up with the free-threading developments)? When I run that last script on 3.11 (which is definitely GIL-enabled to my understanding), I get no problem.

    (Python-level work, so the GIL can be released between bytecodes)

    Did Cython change how it offers up GIL switches? I thought it was still held at the method call boundary without with nogil: even if the statement was nominally equivalent to pure Python.

  3. ngoldbaum commented on Jul 22, 2026

    @ngoldbaum
    MemberAuthor

    What do you mean GIL-enabled

    I mean Python classic. So, no "t" in the version.

    When I run that last script on 3.11 (which is definitely GIL-enabled to my understanding), I get no problem.

    I think it's that Python changed the eval-breaker semantics even on the GIL-enabled build for 3.13 and that makes this race more likely to trigger crashes on 3.13 and newer, at least on a Mac. I can trigger a crash on 3.11 on Linux FWIW.

    Did Cython change how it offers up GIL switches? I thought it was still held at the method call boundary without with nogil: even if the statement was nominally equivalent to pure Python.

    You're right about Cython but there is Python code firing as well. For example, SeedSequence.__init__ calls get_assembled_entropy which calls np.concatenate. That makes ordinary Python-dispatched calls via e.g. __array_function__ so the GIL can be released there mid-spawn, between reading and incrementing n_children_spawned.

  4. kumaraditya303 commented on Jul 23, 2026

    @kumaraditya303
    Contributor

    I think for 2) the update can happen under the old lock so that no other thread could be using the old generator while it is being replaced.

  5. rkern commented on Jul 23, 2026

    @rkern
    Member
    1. Yeah, I think SeedSequence needs its own lock. I think implementing __reduce_ex__ appropriately will solve the pickling issue.

    2. I think it's correct that holding the old BitGenerator's lock before doing the swap would work, but it's worth thinking through both the cases where the RandomState method body has the with lock and when it uses one of the _common.pyx helpers like disc() or cont() that are passed the self._bitgen and self.lock separately and it's those helper functions that acquire the lock.

  6. ngoldbaum commented on Jul 24, 2026

    @ngoldbaum
    MemberAuthor

    OK, that sounds like a plan. I'm not planning to take on fixes for these issues because there's other higher-priority stuff on my plate. I'd happily review someone else's PR though.

  7. added
    sustain-2026Issues reserved for NumFOCUS Sustaining Open Source Series 2026
    on Sep 30, 2026
  8. shbhmbnsl1 commented on Oct 1, 2026

    @shbhmbnsl1
    Contributor

    Hi @ngoldbaum @seberg
    I would be happy to work on this as part of the sustain-2026 series. I am planning to first make a code change for the first issue here and then build from there. For that I think adding a lock of its own on SeedSequence makes sense to me and to handle pickling I am planning to add an implementation of __reduce_ex__ inside SeedSequence as mentioned above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    00 - Bug39 - free-threadingPRs and issues related to support for free-threading CPython (a.k.a. no-GIL, PEP 703)component: numpy.randomsustain-2026Issues reserved for NumFOCUS Sustaining Open Source Series 2026

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions