Skip to content

gh-128942: Make array.array thread-safe in the free-threaded build - #161787

Open
nascheme wants to merge 1 commit into
python:mainfrom
nascheme:gh-128942-array-qsbr
Open

nascheme wants to merge 1 commit into
python:mainfrom
nascheme:gh-128942-array-qsbr

Conversation

@nascheme

@nascheme nascheme commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

This makes array.array safe to share between threads in the free-threaded build, without the slowdown that caused the earlier gh-128943 change to be reverted (75f38af, scimark regressions).

It is based on gh-130771 by Tomasz Pytel (tom-pytel), who did the bulk of the design, and supersedes it. He is listed as co-author.

Approach

  • Same design as list. The layout of the array object is unchanged. In the free-threaded build the capacity is also stored in a header in front of the items. Once the array is shared between threads, old buffers are freed with QSBR (_PyMem_FreeDelayed, now exported for the module).
  • All mutating and multi-item methods run in the object's critical section; methods on two arrays lock both.
  • len(), a[i], a[i] = x and iteration stay lock-free. A lock-free access loads ob_item, then ob_size, and then checks that ob_item is unchanged, so the size it uses belongs to the buffer it uses. It falls back to the locked path otherwise.

Memory ordering

  • The length uses release stores and acquire loads, and items use acquire loads and release stores. A reader that observes a larger length also observes the new items, because growing operations write the items before publishing the length.
  • 1- and 2-byte items use a relaxed access plus an acquire or release fence, since pyatomic.h has no 8/16-bit acquire/release operations. 16-byte 'Zd' items are accessed as two 8-byte halves and can tear.
  • Bulk copies use per-item atomic accesses unless the array is owned by the current thread and not shared, so there are no data races with lock-free readers. TSan is clean.

Changes relative to gh-130771

  • The object layout stays as it is on main (ob_item, allocated), instead of a pointer to a header. Cython's cpython.array support mirrors this struct and manages ob_item itself, so the default build stays compatible. Item buffers also keep their 16-byte alignment.
  • The lock-free paths re-check ob_item after loading the size. With a stale size a reader could index a new buffer before its items were written and return uninitialized memory.
  • In the lock-free a[i] = x path, the value is converted before the buffer is loaded. Before, __index__/__float__ ran after the load and could let QSBR free the buffer before the store. The per-type setitem functions became pack functions that convert into a local buffer, so append()/insert() also convert only once.
  • Release/acquire on the length and items instead of relaxed and plain accesses.
  • Items are written before a larger length is published (append, extend, frombytes, fromlist, fromunicode, *=, slice assignment).
  • a[i] no longer reports a failed item conversion as IndexError, and PySequence_DelItem() raises IndexError for a bad index again.
  • tofile() rechecks the size after each write() call; it could read out of bounds if write() shrank the array (also with the GIL).
  • __reduce_ex__ initializes its cached reconstructor with a compare-and-swap.
  • The free-threading tests are in test_free_threading/test_array.py and run under TSan.

Known limitation

A lock-free a[i] = x that races with an operation that resizes or rearranges the same array can be lost; for a resize the store can land in the old buffer after it was copied. This is accepted to keep item stores lock-free; there is no crash and no corruption. It is documented in the new "Thread safety" section of the array docs, together with 'Zd' tearing.

Performance

pyperformance scimark (x86-64): no slowdown; geomean 1.01x faster in the default build and 1.03x faster in the free-threaded build.

Microbenchmarks (x86-64, 1M items):

  • Default build: a[i], a[i] = x and iteration are within 3% of main; append() is about 30% faster because the value is converted once.
  • Free-threaded build: a[i] and a[i] = x are 3-7% faster than main, append() is about 20% faster, iteration is about 30% slower because of the lock-free checks, and array('d', list) is about 9% slower. Bulk copies of a 'B' array that is shared between threads use per-item atomics and are 1.5-4x slower than memcpy; arrays used by one thread are not affected.

Note: creation of this PR was assisted by an LLM

Based on pythongh-130771. Keep single-item reads and writes lock-free,
serialize mutating and multi-item operations, and reclaim shared
buffers with QSBR. Preserve the object layout and default-build
performance.

Validate the buffer and length together for lock-free access, and
convert values before loading the buffer when assignment can run user
code. Concurrent assignments racing with a resize may still be lost,
and 16-byte complex items can tear.

Add free-threading tests and document thread-safety guarantees.

Co-authored-by: Tomasz Pytel <tompytel@gmail.com>
@nascheme nascheme added type-bug An unexpected behavior, bug, or error topic-free-threading labels Nov 10, 2026
@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #35799340 | 📁 Comparing f8eaede against main (acab532)

  🔍 Preview build  

2 files changed
± library/array.html
± whatsnew/changelog.html

@nascheme
nascheme marked this pull request as ready for review November 11, 2026 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review topic-free-threading type-bug An unexpected behavior, bug, or error

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant