https://github.com/python/cpython/commit/60093096ba62110151d822b072a01061876e9404
commit: 60093096ba62110151d822b072a01061876e9404
branch: main
author: Sam Gross <[email protected]>
committer: colesbury <[email protected]>
date: 2026-03-24T14:29:37-04:00
summary:
gh-146041: Avoid lock in sys.intern() for already interned strings (gh-146072)
Fix free-threading scaling bottleneck in sys.intern and `PyObject_SetAttr` by
avoiding the interpreter-wide lock when the string is already interned and
immortalized.
files:
A
Misc/NEWS.d/next/Core_and_Builtins/2026-03-17-00-00-00.gh-issue-146041.7799bb.rst
M InternalDocs/string_interning.md
M Objects/object.c
M Objects/unicodeobject.c
M Tools/ftscalingbench/ftscalingbench.py
diff --git a/InternalDocs/string_interning.md b/InternalDocs/string_interning.md
index 26a5197c6e70f3..0913b1a3471ef4 100644
--- a/InternalDocs/string_interning.md
+++ b/InternalDocs/string_interning.md
@@ -52,15 +52,9 @@ The key and value of each entry in this dict reference the
same object.
## Immortality and reference counting
-Invariant: Every immortal string is interned.
+In the GIL-enabled build interned strings may be mortal or immortal. In the
+free-threaded build, interned strings are always immortal.
-In practice, this means that you must not use `_Py_SetImmortal` on
-a string. (If you know it's already immortal, don't immortalize it;
-if you know it's not interned you might be immortalizing a redundant copy;
-if it's interned and mortal it needs extra processing in
-`_PyUnicode_InternImmortal`.)
-
-The converse is not true: interned strings can be mortal.
For mortal interned strings:
- the 2 references from the interned dict (key & value) are excluded from
diff --git
a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-17-00-00-00.gh-issue-146041.7799bb.rst
b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-17-00-00-00.gh-issue-146041.7799bb.rst
new file mode 100644
index 00000000000000..812f023266bd76
--- /dev/null
+++
b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-17-00-00-00.gh-issue-146041.7799bb.rst
@@ -0,0 +1,3 @@
+Fix free-threading scaling bottleneck in :func:`sys.intern` and
+:c:func:`PyObject_SetAttr` by avoiding the interpreter-wide lock when the
string
+is already interned and immortalized.
diff --git a/Objects/object.c b/Objects/object.c
index ae6ad558ff6c37..4db22f372ec3f7 100644
--- a/Objects/object.c
+++ b/Objects/object.c
@@ -2032,7 +2032,7 @@ _PyObject_GenericSetAttrWithDict(PyObject *obj, PyObject
*name,
}
Py_INCREF(name);
- Py_INCREF(tp);
+ _Py_INCREF_TYPE(tp);
PyThreadState *tstate = _PyThreadState_GET();
_PyCStackRef cref;
@@ -2107,7 +2107,7 @@ _PyObject_GenericSetAttrWithDict(PyObject *obj, PyObject
*name,
}
done:
_PyThreadState_PopCStackRef(tstate, &cref);
- Py_DECREF(tp);
+ _Py_DECREF_TYPE(tp);
Py_DECREF(name);
return res;
}
@@ -2761,13 +2761,6 @@ _Py_NewReferenceNoTotal(PyObject *op)
void
_Py_SetImmortalUntracked(PyObject *op)
{
-#ifdef Py_DEBUG
- // For strings, use _PyUnicode_InternImmortal instead.
- if (PyUnicode_CheckExact(op)) {
- assert(PyUnicode_CHECK_INTERNED(op) == SSTATE_INTERNED_IMMORTAL
- || PyUnicode_CHECK_INTERNED(op) ==
SSTATE_INTERNED_IMMORTAL_STATIC);
- }
-#endif
// Check if already immortal to avoid degrading from static immortal to
plain immortal
if (_Py_IsImmortal(op)) {
return;
diff --git a/Objects/unicodeobject.c b/Objects/unicodeobject.c
index 4bf4479065e332..daf4651c4313b3 100644
--- a/Objects/unicodeobject.c
+++ b/Objects/unicodeobject.c
@@ -14187,8 +14187,11 @@ immortalize_interned(PyObject *s)
_Py_DecRefTotal(_PyThreadState_GET());
}
#endif
- FT_ATOMIC_STORE_UINT8_RELAXED(_PyUnicode_STATE(s).interned,
SSTATE_INTERNED_IMMORTAL);
_Py_SetImmortal(s);
+ // The switch to SSTATE_INTERNED_IMMORTAL must be the last thing done here
+ // to synchronize with the check in intern_common() that avoids locking if
+ // the string is already immortal.
+ FT_ATOMIC_STORE_UINT8(_PyUnicode_STATE(s).interned,
SSTATE_INTERNED_IMMORTAL);
}
static /* non-null */ PyObject*
@@ -14270,6 +14273,23 @@ intern_common(PyInterpreterState *interp, PyObject *s
/* stolen */,
assert(interned != NULL);
#ifdef Py_GIL_DISABLED
# define INTERN_MUTEX &_Py_INTERP_CACHED_OBJECT(interp, interned_mutex)
+ // Lock-free fast path: check if there's already an interned copy that
+ // is in its final immortal state.
+ PyObject *r;
+ int res = PyDict_GetItemRef(interned, s, &r);
+ if (res < 0) {
+ PyErr_Clear();
+ return s;
+ }
+ if (res > 0) {
+ unsigned int state =
_Py_atomic_load_uint8(&_PyUnicode_STATE(r).interned);
+ if (state == SSTATE_INTERNED_IMMORTAL) {
+ Py_DECREF(s);
+ return r;
+ }
+ // Not yet fully interned; fall through to the locking path.
+ Py_DECREF(r);
+ }
#endif
FT_MUTEX_LOCK(INTERN_MUTEX);
PyObject *t;
@@ -14307,7 +14327,7 @@ intern_common(PyInterpreterState *interp, PyObject *s
/* stolen */,
Py_DECREF(s);
Py_DECREF(s);
}
- FT_ATOMIC_STORE_UINT8_RELAXED(_PyUnicode_STATE(s).interned,
SSTATE_INTERNED_MORTAL);
+ FT_ATOMIC_STORE_UINT8(_PyUnicode_STATE(s).interned,
SSTATE_INTERNED_MORTAL);
/* INTERNED_MORTAL -> INTERNED_IMMORTAL (if needed) */
diff --git a/Tools/ftscalingbench/ftscalingbench.py
b/Tools/ftscalingbench/ftscalingbench.py
index bcbd61f601a7d3..a3d87e1f855dcb 100644
--- a/Tools/ftscalingbench/ftscalingbench.py
+++ b/Tools/ftscalingbench/ftscalingbench.py
@@ -285,6 +285,15 @@ def deepcopy():
for i in range(40 * WORK_SCALE):
copy.deepcopy(x)
+@register_benchmark
+def setattr_non_interned():
+ prefix = "prefix"
+ obj = MyObject()
+ for _ in range(1000 * WORK_SCALE):
+ setattr(obj, f"{prefix}_a", None)
+ setattr(obj, f"{prefix}_b", None)
+ setattr(obj, f"{prefix}_c", None)
+
def bench_one_thread(func):
t0 = time.perf_counter_ns()
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]