Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion pcre/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,12 @@ def _cached_compile_thread_local(
except KeyError:
compiled = wrapper(_pcre2.compile(pattern, flags=flags, jit=jit))
active_limit = _bounded_cache_limit(_THREAD_LOCAL.cache_limit)
if _THREAD_LOCAL.epoch != current_epoch or active_limit == 0:
# Re-read the *global* epoch: another thread may have run clear_cache()
# while we compiled, and caching the result stamped with the old epoch
# would let a stale pattern survive a process-wide cache clear.
# (_THREAD_LOCAL.epoch is only ever written by this thread, so
# comparing against it could never detect the race.)
if get_cache_epoch() != current_epoch or active_limit == 0:
return compiled
if len(cache) >= active_limit:
cache.pop(next(iter(cache)))
Expand Down
40 changes: 29 additions & 11 deletions pcre/pcre.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,14 @@ def _should_use_auto_threads(subjects: list[Any]) -> bool:
return False


# Marks threads that are currently executing a parallel_map chunk on the
# shared pool. A nested parallel_map on such a thread must not submit to the
# same pool and block on the results: if every worker does that, all of them
# wait for chunks that can only run on those same workers — a permanent
# deadlock. Nested calls run inline instead.
_PARALLEL_TLS = local()


def parallel_map(
pattern: Any,
subjects: Iterable[Any],
Expand Down Expand Up @@ -1767,6 +1775,12 @@ def parallel_map(
"threading defaults."
)

if getattr(_PARALLEL_TLS, "in_worker", False):
return [
bound_method(subject, pos=pos, endpos=endpos, options=options)
for subject in materials
]

# A one-item map cannot benefit from worker fan-out. Keep the documented
# list-shaped result but avoid executor creation, queueing, and a Future;
# this is also the safest path for explicit ``Flag.THREADS`` on tiny jobs.
Expand Down Expand Up @@ -1846,17 +1860,21 @@ def parallel_map(
chunk_size = (len(materials) + task_count - 1) // task_count

def _run_chunk(start: int, stop: int) -> list[Any]:
if fast_method is not None:
if fast_method_takes_owner:
return [
fast_method(materials[index], pattern_obj)
for index in range(start, stop)
]
return [fast_method(materials[index]) for index in range(start, stop)]
return [
bound_method(materials[index], pos=pos, endpos=endpos, options=options)
for index in range(start, stop)
]
_PARALLEL_TLS.in_worker = True
try:
if fast_method is not None:
if fast_method_takes_owner:
return [
fast_method(materials[index], pattern_obj)
for index in range(start, stop)
]
return [fast_method(materials[index]) for index in range(start, stop)]
return [
bound_method(materials[index], pos=pos, endpos=endpos, options=options)
for index in range(start, stop)
]
finally:
_PARALLEL_TLS.in_worker = False

def _make_task(start: int, stop: int) -> Any:
return lambda: _run_chunk(start, stop)
Expand Down
22 changes: 13 additions & 9 deletions pcre/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,26 @@ def _performance_cpu_total() -> int:
"""Return macOS performance-tier logical CPUs when the kernel exposes it."""

global _PERFORMANCE_CPU_TOTAL
with _THREAD_POOL_LOCK:
if _PERFORMANCE_CPU_TOTAL is not None:
return _PERFORMANCE_CPU_TOTAL
if sys.platform != "darwin":
_PERFORMANCE_CPU_TOTAL = 0
return 0
snapshot = _PERFORMANCE_CPU_TOTAL
if snapshot is not None:
return snapshot
# Probe outside _THREAD_POOL_LOCK: spawning a subprocess under the
# process-wide pool lock stalls every other thread touching the pool.
# A duplicate concurrent probe is harmless; publication is atomic.
if sys.platform != "darwin":
result = 0
else:
try:
value = subprocess.check_output(
["sysctl", "-n", "hw.perflevel0.logicalcpu"],
text=True,
stderr=subprocess.DEVNULL,
)
_PERFORMANCE_CPU_TOTAL = max(0, int(value.strip()))
result = max(0, int(value.strip()))
except (OSError, ValueError, subprocess.CalledProcessError):
_PERFORMANCE_CPU_TOTAL = 0
return _PERFORMANCE_CPU_TOTAL
result = 0
_PERFORMANCE_CPU_TOTAL = result
return result


_THREADS_DEFAULT: bool = threading_supported() and not (
Expand Down
7 changes: 7 additions & 0 deletions pcre_ext/atomic_compat.h
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,13 @@ static __forceinline void atomic_flag_clear_explicit(atomic_flag *obj, memory_or
# define ATOMIC_COMPAT_HAVE_ATOMICS 1
# define ATOMIC_VAR(type) _Atomic(type)

/* C17 deprecated ATOMIC_VAR_INIT and C23 removed it (gcc 15+/clang 19+ no
* longer define it). Plain initialization of an _Atomic object is valid in
* every supported standard revision. */
# ifndef ATOMIC_VAR_INIT
# define ATOMIC_VAR_INIT(value) (value)
# endif

#endif /* _MSC_VER */

#ifdef __cplusplus
Expand Down
56 changes: 49 additions & 7 deletions pcre_ext/cache.c
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,17 @@ global_jit_stack_cache_release(pcre2_jit_stack *jit_stack)
pcre2_jit_stack_free(jit_stack);
}

/* Set once the cache subsystem has fully initialized. A repeated
* module_exec (importlib.reload) must not reset strategies, toggles, or
* cached objects that other threads may be using concurrently. */
static int cache_initialized = 0;

int
cache_initialize(int global_mode)
{
if (cache_initialized) {
return 0;
}
if (global_cache_lock == NULL) {
global_cache_lock = PyThread_allocate_lock();
if (global_cache_lock == NULL) {
Expand Down Expand Up @@ -562,12 +570,14 @@ cache_initialize(int global_mode)
atomic_store_explicit(&global_jit_capacity, 1, memory_order_release);
atomic_store_explicit(&global_jit_start_size, 32 * 1024, memory_order_release);
atomic_store_explicit(&global_jit_max_size, 1024 * 1024, memory_order_release);
cache_initialized = 1;
return 0;
}

void
cache_teardown(void)
{
cache_initialized = 0;
thread_cache_teardown();
global_cache_teardown();
if (global_cache_lock != NULL) {
Expand Down Expand Up @@ -605,6 +615,13 @@ match_data_cache_release(pcre2_match_data *match_data)
}
}

/*
* Match contexts are handed out with exclusive ownership: acquire detaches the
* context from the thread-local slot and release re-stores (or frees) it.
* Returning a pointer that stays resident in the slot would let thread-exit
* cleanup, reentrant acquires (GC finalizers), or a context-cache toggle free
* or mutate a context that is still in use.
*/
pcre2_match_context *
match_context_cache_acquire(int use_offset_limit)
{
Expand All @@ -626,15 +643,18 @@ match_context_cache_acquire(int use_offset_limit)
&state->offset_match_context :
&state->match_context;

if (*slot == NULL) {
*slot = pcre2_match_context_create(NULL);
if (*slot == NULL) {
PyErr_NoMemory();
return NULL;
}
if (*slot != NULL) {
pcre2_match_context *context = *slot;
*slot = NULL;
return context;
}

return *slot;
pcre2_match_context *context = pcre2_match_context_create(NULL);
if (context == NULL) {
PyErr_NoMemory();
return NULL;
}
return context;
}

void
Expand All @@ -656,6 +676,28 @@ match_context_cache_release(pcre2_match_context *context, int had_offset_limit)

if (!atomic_load_explicit(&context_cache_enabled, memory_order_acquire)) {
pcre2_match_context_free(context);
return;
}

ThreadCacheState *state = thread_cache_state_get();
if (state == NULL) {
pcre2_match_context_free(context);
return;
}

pcre2_match_context **preferred = had_offset_limit ?
&state->offset_match_context :
&state->match_context;
pcre2_match_context **fallback = had_offset_limit ?
&state->match_context :
&state->offset_match_context;

if (*preferred == NULL) {
*preferred = context;
} else if (*fallback == NULL) {
*fallback = context;
} else {
pcre2_match_context_free(context);
}
}

Expand Down
21 changes: 21 additions & 0 deletions pcre_ext/error.c
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,11 @@ static PyObject *
get_error_type_for_code(int error_code)
{
if (PcreErrorByCode == NULL) {
if (PcreError == NULL) {
PyErr_SetString(PyExc_RuntimeError,
"pcre error types are not initialized");
return NULL;
}
Py_INCREF(PcreError);
return PcreError;
}
Expand All @@ -1258,6 +1263,21 @@ get_error_type_for_code(int error_code)
return NULL;
}

#if PY_VERSION_HEX >= 0x030D0000
/* Returns an owned reference under the dict's internal lock, so the entry
* cannot be deallocated between lookup and INCREF on free-threaded builds. */
PyObject *exc_type = NULL;
int lookup_rc = PyDict_GetItemRef(PcreErrorByCode, code_obj, &exc_type);
Py_DECREF(code_obj);
if (lookup_rc < 0) {
return NULL;
}
if (exc_type == NULL) {
Py_INCREF(PcreError);
return PcreError;
}
return exc_type;
#else
PyObject *exc_type = PyDict_GetItemWithError(PcreErrorByCode, code_obj);
Py_DECREF(code_obj);
if (exc_type == NULL) {
Expand All @@ -1270,6 +1290,7 @@ get_error_type_for_code(int error_code)

Py_INCREF(exc_type);
return exc_type;
#endif
}

void
Expand Down
34 changes: 14 additions & 20 deletions pcre_ext/memory.c
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,12 @@ pcre_memory_initialize(void)
return 0;
}

/* Spin until we own the flag. Waiters must never clear the flag: only
* the owner releases it, otherwise two threads can run the initializer
* concurrently and tear the current_alloc/current_free pair. */
while (atomic_flag_test_and_set_explicit(&allocator_init_lock, memory_order_acq_rel)) {
if (atomic_load_explicit(&allocator_initialized, memory_order_acquire)) {
atomic_flag_clear_explicit(&allocator_init_lock, memory_order_release);
return 0;
}
/* busy-wait */
}

if (atomic_load_explicit(&allocator_initialized, memory_order_acquire)) {
atomic_flag_clear_explicit(&allocator_init_lock, memory_order_release);
return 0;
Expand Down Expand Up @@ -185,18 +184,12 @@ pcre_memory_initialize(void)
void
pcre_memory_teardown(void)
{
#if !defined(_WIN32)
void *handle_to_close = current_handle;
current_handle = NULL;
if (handle_to_close != NULL) {
dlclose(handle_to_close);
}
#endif
current_alloc = (alloc_fn)PyMem_Malloc;
current_free = (free_fn)PyMem_Free;
current_name = "pymem";
atomic_store_explicit(&allocator_initialized, 0, memory_order_release);
atomic_flag_clear_explicit(&allocator_init_lock, memory_order_release);
/* Allocator selection is irreversible for the lifetime of the process.
* Blocks obtained from pcre_malloc() (Match ovectors, error-name buffers)
* can outlive any teardown point, so resetting current_free or dlclosing
* the backing library would pair a live allocation with the wrong
* deallocator (heap corruption) or call into unmapped code. The dlopen
* handle is intentionally retained. */
}

void *
Expand All @@ -216,9 +209,10 @@ pcre_free(void *ptr)
if (ptr == NULL) {
return;
}
if (!atomic_load_explicit(&allocator_initialized, memory_order_acquire)) {
(void)pcre_memory_initialize();
}
/* Any pointer passed here came from pcre_malloc(), which initialized the
* allocator; the statically-initialized PyMem pair covers the impossible
* uninitialized case. Re-running initialization here could select a
* different allocator than the one that produced `ptr`. */
current_free(ptr);
}

Expand Down
Loading