diff --git a/mypy/typeshed/stubs/librt/librt/threading.pyi b/mypy/typeshed/stubs/librt/librt/threading.pyi new file mode 100644 index 0000000000000..a3571ac7f4745 --- /dev/null +++ b/mypy/typeshed/stubs/librt/librt/threading.pyi @@ -0,0 +1,16 @@ +from types import TracebackType +from typing import final + +@final +class Lock: + def acquire(self, blocking: bool = True) -> bool: ... + def release(self) -> None: ... + def locked(self) -> bool: ... + def __enter__(self) -> bool: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + /, + ) -> None: ... diff --git a/mypyc/build.py b/mypyc/build.py index b46365d263e6a..8c6eabead17c9 100644 --- a/mypyc/build.py +++ b/mypyc/build.py @@ -127,6 +127,12 @@ class ModDesc(NamedTuple): ), ModDesc("librt.time", ["time/librt_time.c"], ["time/librt_time.h"], []), ModDesc("librt.random", ["random/librt_random.c"], ["random/librt_random.h"], ["random"]), + ModDesc( + "librt.threading", + ["threading/librt_threading.c"], + ["threading/librt_threading.h"], + ["threading"], + ), ] try: diff --git a/mypyc/codegen/emitmodule.py b/mypyc/codegen/emitmodule.py index 6e3c122aa13f6..3b320b5321232 100644 --- a/mypyc/codegen/emitmodule.py +++ b/mypyc/codegen/emitmodule.py @@ -62,6 +62,7 @@ LIBRT_BASE64, LIBRT_RANDOM, LIBRT_STRINGS, + LIBRT_THREADING, LIBRT_TIME, LIBRT_VECS, Capsule, @@ -1261,6 +1262,10 @@ def emit_module_exec_func( emitter.emit_line("if (import_librt_time() < 0) {") emitter.emit_line("return -1;") emitter.emit_line("}") + if LIBRT_THREADING in module.dependencies: + emitter.emit_line("if (import_librt_threading() < 0) {") + emitter.emit_line("return -1;") + emitter.emit_line("}") if LIBRT_VECS in module.dependencies: emitter.emit_line("if (import_librt_vecs() < 0) {") emitter.emit_line("return -1;") diff --git a/mypyc/ir/deps.py b/mypyc/ir/deps.py index 751845d3a324c..b1a27f85fcfe6 100644 --- a/mypyc/ir/deps.py +++ b/mypyc/ir/deps.py @@ -110,6 +110,7 @@ def get_header(self) -> str: LIBRT_VECS: Final = Capsule("librt.vecs") LIBRT_TIME: Final = Capsule("librt.time") LIBRT_RANDOM: Final = Capsule("librt.random") +LIBRT_THREADING: Final = Capsule("librt.threading") BYTES_EXTRA_OPS: Final = SourceDep("bytes_extra_ops.c") BYTES_WRITER_EXTRA_OPS: Final = SourceDep("byteswriter_extra_ops.c") diff --git a/mypyc/ir/rtypes.py b/mypyc/ir/rtypes.py index 9d13ecc83175d..1a5515d5621a8 100644 --- a/mypyc/ir/rtypes.py +++ b/mypyc/ir/rtypes.py @@ -41,7 +41,7 @@ class to enable the new behavior. In rare cases, adding a new from typing import TYPE_CHECKING, ClassVar, Final, Generic, TypeGuard, TypeVar, Union, final from mypyc.common import HAVE_IMMORTAL, IS_32_BIT_PLATFORM, PLATFORM_SIZE, JsonDict, short_name -from mypyc.ir.deps import LIBRT_RANDOM, LIBRT_STRINGS, LIBRT_VECS, Dependency +from mypyc.ir.deps import LIBRT_RANDOM, LIBRT_STRINGS, LIBRT_THREADING, LIBRT_VECS, Dependency from mypyc.namegen import NameGenerator if TYPE_CHECKING: @@ -550,12 +550,19 @@ def __hash__(self) -> int: } | { "librt.random.Random": RPrimitive( "librt.random.Random", is_unboxed=False, is_refcounted=True, dependencies=(LIBRT_RANDOM,) - ) + ), + "librt.threading.Lock": RPrimitive( + "librt.threading.Lock", + is_unboxed=False, + is_refcounted=True, + dependencies=(LIBRT_THREADING,), + ), } bytes_writer_rprimitive: Final = KNOWN_NATIVE_TYPES["librt.strings.BytesWriter"] string_writer_rprimitive: Final = KNOWN_NATIVE_TYPES["librt.strings.StringWriter"] random_rprimitive: Final = KNOWN_NATIVE_TYPES["librt.random.Random"] +lock_rprimitive: Final = KNOWN_NATIVE_TYPES["librt.threading.Lock"] def is_native_rprimitive(rtype: RType) -> bool: diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py index 428650a810dca..e90aa089305f2 100644 --- a/mypyc/irbuild/statement.py +++ b/mypyc/irbuild/statement.py @@ -80,6 +80,7 @@ c_pyssize_t_rprimitive, exc_rtuple, is_tagged, + lock_rprimitive, none_rprimitive, object_pointer_rprimitive, object_rprimitive, @@ -115,6 +116,7 @@ restore_exc_info_op, ) from mypyc.primitives.generic_ops import iter_op, next_raw_op, py_delattr_op +from mypyc.primitives.librt_threading_ops import lock_acquire_op, lock_release_op from mypyc.primitives.misc_ops import ( check_stop_op, coro_op, @@ -1108,6 +1110,10 @@ def transform_with( al = "a" if is_async else "" mgr_v = builder.accept(expr) + + if not is_async and mgr_v.type == lock_rprimitive: + transform_with_lock(builder, mgr_v, target, body, line) + return is_native = isinstance(mgr_v.type, RInstance) if is_native: value = builder.add(MethodCall(mgr_v, f"__{al}enter__", args=[], line=line)) @@ -1179,6 +1185,32 @@ def finally_body() -> None: ) +def transform_with_lock( + builder: IRBuilder, mgr_v: Value, target: Lvalue | None, body: GenFunc, line: int +) -> None: + """Optimized 'with' for librt.threading.Lock. + + Generate a simple try/finally with direct acquire/release calls. + Lock.__exit__ never suppresses exceptions, so we don't need the + full PEP 343 try/except/finally machinery. + """ + # __enter__: acquire the lock + value = builder.primitive_op(lock_acquire_op, [mgr_v], line) + + mgr = builder.maybe_spill(mgr_v) + + def try_body() -> None: + if target: + builder.assign(builder.get_assignment_target(target), value, line) + body() + + def finally_body() -> None: + # __exit__: release the lock (ignoring exception info) + builder.primitive_op(lock_release_op, [builder.read(mgr, line)], line) + + transform_try_finally_stmt(builder, try_body, finally_body, line) + + def transform_with_stmt(builder: IRBuilder, o: WithStmt) -> None: # Generate separate logic for each expr in it, left to right def generate(i: int) -> None: diff --git a/mypyc/lib-rt/setup.py b/mypyc/lib-rt/setup.py index 371b322ca18b2..15d31a443ff86 100644 --- a/mypyc/lib-rt/setup.py +++ b/mypyc/lib-rt/setup.py @@ -164,5 +164,11 @@ def run(self) -> None: include_dirs=["."], extra_compile_args=cflags, ), + Extension( + "librt.threading", + ["threading/librt_threading.c"], + include_dirs=[".", "threading"], + extra_compile_args=cflags, + ), ] ) diff --git a/mypyc/lib-rt/threading/librt_threading.c b/mypyc/lib-rt/threading/librt_threading.c new file mode 100644 index 0000000000000..84d7346a75872 --- /dev/null +++ b/mypyc/lib-rt/threading/librt_threading.c @@ -0,0 +1,714 @@ +#include "pythoncapi_compat.h" + +#define PY_SSIZE_T_CLEAN +#include +#include "librt_threading.h" +#include "mypyc_util.h" + +#if !defined(_WIN32) +#include +#include +#endif + +#if CPY_3_14_FEATURES + +// Python 3.14+ (all platforms, with or without the GIL): Use PyMutex (1-byte +// atomic lock with parking lot). PyMutex gives better interruptibility than the +// pthread fallback, and its fast release path is competitive with sem_t. +// PyMutex_LockFast, _PyMutex_LockTimed, and _PY_LOCK_DETACH are internal +// CPython APIs that might change across minor releases. +#define LOCK_BACKEND_PYMUTEX +#ifndef Py_BUILD_CORE +#define Py_BUILD_CORE +#endif +#include "internal/pycore_lock.h" + +#elif defined(_WIN32) + +// Python <3.14 on Windows: Use Slim Reader/Writer Lock +#define LOCK_BACKEND_SRWLOCK +#include + +#else + +// Python <3.14 on POSIX. +// +// Prefer a POSIX unnamed semaphore when the platform supports it well and the +// GIL is enabled, and fall back to a pthread mutex + condition variable +// otherwise. We use the same test CPython uses to pick its semaphore-based lock +// (see Python/thread_pthread.h): an unnamed semaphore is only usable when +// sem_init() actually works AND a timed wait is available. Notably this is true +// on Linux but false on macOS (whose sem_init() is a non-functional stub), so +// macOS uses the mutex+condvar fallback. Free-threaded builds also use the +// mutex+condvar fallback because the semaphore backend's `locked` bookkeeping +// relies on GIL serialization. +#if !defined(Py_GIL_DISABLED) && \ + defined(_POSIX_SEMAPHORES) && (_POSIX_SEMAPHORES + 0) != -1 && \ + (defined(HAVE_SEM_TIMEDWAIT) || defined(HAVE_SEM_CLOCKWAIT)) +#define LOCK_BACKEND_SEM +#include +#else +#define LOCK_BACKEND_PTHREAD +#include +#endif + +#endif + +// +// Lock +// +// A fast mutex lock for use from mypyc-compiled code. +// +// On Python 3.14+ (all platforms), this uses CPython's PyMutex, a 1-byte atomic +// lock backed by a parking lot for contended waits. PyMutex automatically +// releases the GIL when blocking. +// +// On Python 3.13 and earlier with Windows, this uses an SRWLOCK (Slim +// Reader/Writer Lock) plus a CONDITION_VARIABLE guarding a `locked` flag. The SRWLOCK only +// protects the flag and is never held across the user's critical section, so +// release() may be called from a thread other than the acquirer (matching +// threading.Lock semantics). This mirrors CPython's Windows lock (NRMUTEX in +// Python/thread_nt.h) and is the Windows twin of the POSIX pthread+condvar +// backend below. +// +// On Python 3.13 and earlier with POSIX systems, there are two backends, both +// of which allow release() from a thread other than the one that acquired the +// lock (matching threading.Lock semantics): +// +// - Where unnamed POSIX semaphores work well (e.g. Linux) and the GIL is +// enabled, this uses a sem_t initialized to 1: acquire is sem_wait, release +// is sem_post. Semaphores have no ownership concept, so cross-thread release +// is directly well-defined. +// +// - Otherwise (e.g. macOS, whose sem_init() is a non-functional stub, and +// free-threaded builds), this uses a pthread mutex + condition variable +// guarding a `locked` flag. The mutex only protects the flag and is never +// held across the user's critical section, so the OS mutex is always +// unlocked on the same thread that locked it. +// + +// ---------- Platform-specific lock state ---------- + +#if defined(LOCK_BACKEND_PYMUTEX) + +typedef struct { + PyObject_HEAD + PyMutex mutex; +} LockObject; + +#elif defined(LOCK_BACKEND_SRWLOCK) + +typedef struct { + PyObject_HEAD + // The SRWLOCK below does NOT represent the Python lock; like the pthread + // fallback's `mut`, it only guards `locked` and the condition variable, + // and is held just long enough to inspect/flip the flag -- never across + // the user's critical section. That is what allows release() from a + // thread other than the acquirer: SRWLOCK's same-thread-release rule is + // never violated, and clearing `locked` is just a guarded store. This + // mirrors CPython's Windows lock (NRMUTEX in Python/thread_nt.h). + SRWLOCK srw; + CONDITION_VARIABLE lock_released; + int locked; // 0=unlocked, 1=locked; protected by `srw` +} LockObject; + +#elif defined(LOCK_BACKEND_SEM) + +typedef struct { + PyObject_HEAD + sem_t sem; // counting semaphore, initialized to 1 + // Tracks the locked state for locked() and release-unlocked detection. + // The semaphore itself is the source of truth for mutual exclusion; this + // flag is advisory bookkeeping. It is set after a successful acquire and + // cleared before sem_post in release. + // + // This backend is only selected when the GIL is enabled, so this flag is a + // plain int relying on the GIL for serialization: it is only ever touched + // with the GIL held (the blocking sem_wait drops the GIL, but the flag + // store happens after the GIL is reacquired). This mirrors the old + // PyThread_type_lock wrapper bookkeeping used by CPython 3.12 and earlier, + // where _thread lock kept a plain `char locked` for sanity checks and + // locked(). + int locked; +} LockObject; + +#else // pthread mutex + condvar fallback + +typedef struct { + PyObject_HEAD + // The pthread mutex below does NOT represent the Python lock; it only + // guards the `locked` flag and the condition variable. The Python lock + // state is `locked` itself. This indirection (matching CPython's POSIX + // lock) is what allows release() from a thread other than the acquirer: + // `mut` is always locked and unlocked within a single call on a single + // thread, so pthread's same-thread-unlock rule is never violated. + pthread_mutex_t mut; + pthread_cond_t lock_released; + // Always accessed while holding `mut` (including the locked() reader), + // so a plain int is sufficient -- the mutex provides the ordering. + // Matches CPython's pthread_lock.locked (a plain char). + int locked; // 0=unlocked, 1=locked; protected by `mut` +} LockObject; + +#endif + +// ---------- Platform-specific init/acquire/release ---------- + +static inline int +Lock_init_internal(LockObject *self) +{ +#if defined(LOCK_BACKEND_PYMUTEX) + self->mutex = (PyMutex){0}; +#elif defined(LOCK_BACKEND_SRWLOCK) + InitializeSRWLock(&self->srw); + InitializeConditionVariable(&self->lock_released); + self->locked = 0; +#elif defined(LOCK_BACKEND_SEM) + if (sem_init(&self->sem, 0, 1) != 0) { + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + self->locked = 0; +#else + int status = pthread_mutex_init(&self->mut, NULL); + if (status != 0) { + errno = status; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + + status = pthread_cond_init(&self->lock_released, NULL); + if (status != 0) { + pthread_mutex_destroy(&self->mut); + errno = status; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + + self->locked = 0; +#endif + return 0; +} + +// Try to acquire the lock. Returns 1 (true) on success, 0 (false) if +// non-blocking and the lock is held, or -1 if interrupted by an error-raising +// signal handler. +static int +Lock_acquire_impl(LockObject *self, int blocking) +{ +#if defined(LOCK_BACKEND_PYMUTEX) + if (!blocking) { + PyLockStatus r = _PyMutex_LockTimed(&self->mutex, 0, _Py_LOCK_DONT_DETACH); + return r == PY_LOCK_ACQUIRED; + } + if (PyMutex_LockFast(&self->mutex)) { + return 1; + } + PyLockStatus r = _PyMutex_LockTimed(&self->mutex, -1, + _PY_LOCK_DETACH | _PY_LOCK_HANDLE_SIGNALS); + if (r == PY_LOCK_INTR) { + return -1; + } + return 1; + +#elif defined(LOCK_BACKEND_SRWLOCK) + // `srw` only guards `locked` and the condition variable; it is held just + // long enough to inspect/flip the flag, never across the user's critical + // section. This is what lets a different thread call release(). + // + // Fast path: grab the lock without releasing the GIL if it is free. This is + // also the whole story in the non-blocking case. + AcquireSRWLockExclusive(&self->srw); + if (!self->locked) { + self->locked = 1; + ReleaseSRWLockExclusive(&self->srw); + return 1; + } + ReleaseSRWLockExclusive(&self->srw); + if (!blocking) { + return 0; + } + + // Slow path: wait for the lock to be released, with the GIL dropped so + // other Python threads can run (and release the lock). + // SleepConditionVariableSRW atomically releases the SRWLOCK while sleeping + // and reacquires it on wake, exactly like pthread_cond_wait. + Py_BEGIN_ALLOW_THREADS + AcquireSRWLockExclusive(&self->srw); + while (self->locked) { + SleepConditionVariableSRW(&self->lock_released, &self->srw, INFINITE, 0); + } + self->locked = 1; + ReleaseSRWLockExclusive(&self->srw); + Py_END_ALLOW_THREADS + return 1; + +#elif defined(LOCK_BACKEND_SEM) + // A semaphore has no ownership: any thread may sem_post a token that + // another thread consumed via sem_wait, so cross-thread release is + // directly well-defined. `locked` is advisory bookkeeping for locked() + // and for guarding against releasing an unheld lock. + // + // Fast path: try a non-blocking acquire first to avoid GIL release/reacquire + // overhead in the common uncontended case. This is also the whole story in + // the non-blocking case. + { + int status; + do { + status = sem_trywait(&self->sem); + } while (status == -1 && errno == EINTR); + if (status == 0) { + self->locked = 1; + return 1; + } + } + if (!blocking) { + return 0; // EAGAIN: already held + } + + // Slow path: block with the GIL dropped so other Python threads can run + // (and release the lock). If a signal interrupts sem_wait(), run pending + // Python signal handlers with the GIL held; retry unless a handler raises. + for (;;) { + int status; + int err = 0; + + Py_BEGIN_ALLOW_THREADS + status = sem_wait(&self->sem); + if (status == -1) { + err = errno; + } + Py_END_ALLOW_THREADS + + if (status == 0) { + self->locked = 1; + return 1; + } + + if (err != EINTR) { + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + + if (Py_MakePendingCalls() < 0) { + return -1; + } + } + +#else // pthread mutex + condvar fallback + // `mut` only guards `locked` and the condition variable; it is held just + // long enough to inspect/flip the flag, never across the user's critical + // section. This is what lets a different thread call release(). + // + // Fast path: grab the lock without releasing the GIL if it is free. This is + // also the whole story in the non-blocking case. + pthread_mutex_lock(&self->mut); + if (!self->locked) { + self->locked = 1; + pthread_mutex_unlock(&self->mut); + return 1; + } + pthread_mutex_unlock(&self->mut); + if (!blocking) { + return 0; + } + + // Slow path: wait for the lock to be released, with the GIL dropped so + // other Python threads can run (and release the lock). If we wake but do + // not get the lock, give pending Python signal handlers a chance to run, + // matching CPython's pthread fallback. + for (;;) { + int acquired = 0; + int interrupted = 0; + int status; + + Py_BEGIN_ALLOW_THREADS + status = pthread_mutex_lock(&self->mut); + if (status == 0) { + while (self->locked) { + status = pthread_cond_wait(&self->lock_released, &self->mut); + if (status != 0) { + break; + } + if (self->locked) { + interrupted = 1; + break; + } + } + if (status == 0 && !interrupted) { + self->locked = 1; + acquired = 1; + } + int unlock_status = pthread_mutex_unlock(&self->mut); + if (status == 0) { + status = unlock_status; + } + } + Py_END_ALLOW_THREADS + + if (status != 0) { + errno = status; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + if (acquired) { + return 1; + } + if (interrupted && Py_MakePendingCalls() < 0) { + return -1; + } + } +#endif +} + +// Release the lock. Returns 0 on success, -1 if the lock was not held. +static int +Lock_release_impl(LockObject *self) +{ +#if defined(LOCK_BACKEND_PYMUTEX) + // threading.Lock is unowned, so release() may be called from a different + // thread than acquire(). CPython's atomic _PyMutex_TryUnlock() is not part + // of the public API and is not exported by all CPython builds. This fast + // partial-replacement path deliberately avoids a second guard mutex: + // ordinary release() of an unlocked lock raises RuntimeError, but racy + // erroneous release() calls are undefined behavior and may make + // PyMutex_Unlock() abort. + if (!PyMutex_IsLocked(&self->mutex)) { + return -1; + } + PyMutex_Unlock(&self->mutex); + return 0; + +#elif defined(LOCK_BACKEND_SRWLOCK) + AcquireSRWLockExclusive(&self->srw); + if (!self->locked) { + ReleaseSRWLockExclusive(&self->srw); + return -1; + } + self->locked = 0; + // Wake one waiter (if any). Signalling under `srw` is fine and avoids a + // lost-wakeup race. + WakeConditionVariable(&self->lock_released); + ReleaseSRWLockExclusive(&self->srw); + return 0; + +#elif defined(LOCK_BACKEND_SEM) + // Check-then-clear the flag, then post. This backend is only selected when + // the GIL is enabled, so the check and clear are serialized without an + // atomic. + if (!self->locked) { + return -1; + } + self->locked = 0; + sem_post(&self->sem); + return 0; + +#else // pthread mutex + condvar fallback + pthread_mutex_lock(&self->mut); + if (!self->locked) { + pthread_mutex_unlock(&self->mut); + return -1; + } + self->locked = 0; + // Wake one waiter (if any). Signalling under `mut` is fine and avoids a + // lost-wakeup race. + pthread_cond_signal(&self->lock_released); + pthread_mutex_unlock(&self->mut); + return 0; +#endif +} + +static inline int +Lock_is_locked(LockObject *self) +{ +#if defined(LOCK_BACKEND_PYMUTEX) + return PyMutex_IsLocked(&self->mutex); +#elif defined(LOCK_BACKEND_SRWLOCK) + // locked() is not expected to be on a perf-critical path, so take the + // SRWLOCK (shared) for a clean read of the guarded flag rather than + // relying on an atomic/volatile field. + AcquireSRWLockShared(&self->srw); + int result = self->locked; + ReleaseSRWLockShared(&self->srw); + return result; +#elif defined(LOCK_BACKEND_SEM) + // The flag is GIL-serialized (see the struct comment); locked() is a + // plain read, matching the old CPython _thread lock bookkeeping described + // above. + return self->locked != 0; +#else // pthread mutex + condvar fallback + // `locked` is only ever accessed under `mut`; take it for a clean read. + // locked() is not expected to be on a perf-critical path. + pthread_mutex_lock(&self->mut); + int result = self->locked; + pthread_mutex_unlock(&self->mut); + return result; +#endif +} + +// ---------- Python type methods (shared across platforms) ---------- + +static PyTypeObject LockType; + +static PyObject * +Lock_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + if (type != &LockType) { + PyErr_SetString(PyExc_TypeError, "Lock cannot be subclassed"); + return NULL; + } + + LockObject *self = (LockObject *)type->tp_alloc(type, 0); + if (self != NULL && Lock_init_internal(self) < 0) { + type->tp_free((PyObject *)self); + return NULL; + } + return (PyObject *)self; +} + +static int +Lock_init(LockObject *self, PyObject *args, PyObject *kwds) +{ + if (!PyArg_ParseTuple(args, "")) { + return -1; + } + + if (kwds != NULL && PyDict_Size(kwds) > 0) { + PyErr_SetString(PyExc_TypeError, + "Lock() takes no keyword arguments"); + return -1; + } + + return 0; +} + +static void +Lock_dealloc(LockObject *self) +{ +#if defined(LOCK_BACKEND_SEM) + sem_destroy(&self->sem); +#elif defined(LOCK_BACKEND_PTHREAD) + // `mut` is only ever held transiently within a single call, so it is + // always unlocked here even if the Python lock is still "locked". + // Some pthread implementations require the cond to be destroyed first. + pthread_cond_destroy(&self->lock_released); + pthread_mutex_destroy(&self->mut); +#endif + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyObject * +Lock_acquire(LockObject *self, PyObject *const *args, Py_ssize_t nargs, + PyObject *kwnames) +{ + int blocking = 1; + + Py_ssize_t nkw = kwnames ? PyTuple_GET_SIZE(kwnames) : 0; + if (nargs + nkw > 1) { + PyErr_SetString(PyExc_TypeError, "acquire() takes at most 1 argument"); + return NULL; + } + + if (nargs == 1) { + blocking = PyObject_IsTrue(args[0]); + if (blocking < 0) + return NULL; + } else if (nkw == 1) { + PyObject *key = PyTuple_GET_ITEM(kwnames, 0); + if (PyUnicode_CompareWithASCIIString(key, "blocking") != 0) { + PyErr_Format(PyExc_TypeError, + "acquire() got an unexpected keyword argument '%U'", + key); + return NULL; + } + blocking = PyObject_IsTrue(args[0]); + if (blocking < 0) + return NULL; + } + + int result = Lock_acquire_impl(self, blocking); + if (result < 0) { + return NULL; + } + return PyBool_FromLong(result); +} + +static PyObject * +Lock_release(LockObject *self, PyObject *Py_UNUSED(ignored)) +{ + if (Lock_release_impl(self) < 0) { + PyErr_SetString(PyExc_RuntimeError, "cannot release an unlocked lock"); + return NULL; + } + Py_RETURN_NONE; +} + +static PyObject * +Lock_locked(LockObject *self, PyObject *Py_UNUSED(ignored)) +{ + return PyBool_FromLong(Lock_is_locked(self)); +} + +static PyObject * +Lock_enter(LockObject *self, PyObject *Py_UNUSED(ignored)) +{ + int result = Lock_acquire_impl(self, 1); + if (result < 0) + return NULL; + return PyBool_FromLong(result); +} + +static PyObject * +Lock_exit(LockObject *self, PyObject *const *args, Py_ssize_t nargs) +{ + return Lock_release(self, NULL); +} + +static PyMethodDef Lock_methods[] = { + {"acquire", (PyCFunction)(void(*)(void))Lock_acquire, METH_FASTCALL | METH_KEYWORDS, + PyDoc_STR("Acquire the lock, blocking or non-blocking.\n" + "Returns True if the lock was acquired, False otherwise.")}, + {"release", (PyCFunction)Lock_release, METH_NOARGS, + PyDoc_STR("Release the lock.")}, + {"locked", (PyCFunction)Lock_locked, METH_NOARGS, + PyDoc_STR("Return True if the lock is currently held.")}, + {"__enter__", (PyCFunction)Lock_enter, METH_NOARGS, + PyDoc_STR("Acquire the lock.")}, + {"__exit__", (PyCFunction)Lock_exit, METH_FASTCALL, + PyDoc_STR("Release the lock.")}, + {NULL} +}; + +static PyTypeObject LockType = { + .ob_base = PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "Lock", + .tp_doc = PyDoc_STR("A fast mutual exclusion lock"), + .tp_basicsize = sizeof(LockObject), + .tp_itemsize = 0, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_new = Lock_new, + .tp_init = (initproc)Lock_init, + .tp_dealloc = (destructor)Lock_dealloc, + .tp_methods = Lock_methods, +}; + +static PyTypeObject * +Lock_type_internal(void) { + return &LockType; +} + +// Create a new Lock object (for use from compiled code) +static PyObject * +Lock_new_internal(void) { + LockObject *self = (LockObject *)LockType.tp_alloc(&LockType, 0); + if (self != NULL && Lock_init_internal(self) < 0) { + LockType.tp_free((PyObject *)self); + return NULL; + } + return (PyObject *)self; +} + +// Acquire the lock (blocking), for use from compiled code. +// Returns true on success, sets error and returns 2 (ERR_MAGIC) on failure. +static char +Lock_acquire_internal(PyObject *self) { + int result = Lock_acquire_impl((LockObject *)self, 1); + if (result < 0) { + return 2; + } + return (char)result; +} + +// Acquire the lock with explicit blocking arg, for use from compiled code. +// Returns true if acquired, false otherwise. Sets error and returns 2 +// (ERR_MAGIC) on failure. +static char +Lock_acquire_blocking_internal(PyObject *self, char blocking) { + int result = Lock_acquire_impl((LockObject *)self, blocking); + if (result < 0) { + return 2; + } + return (char)result; +} + +// Release the lock, for use from compiled code. +// Returns 0 (None) on success, sets error and returns 2 (ERR_MAGIC) on failure. +static char +Lock_release_internal(PyObject *self) { + if (Lock_release_impl((LockObject *)self) < 0) { + PyErr_SetString(PyExc_RuntimeError, "cannot release an unlocked lock"); + return 2; + } + return 0; +} + +// Check if the lock is held, for use from compiled code. +static char +Lock_locked_internal(PyObject *self) { + return (char)Lock_is_locked((LockObject *)self); +} + +static PyMethodDef librt_threading_module_methods[] = { + {NULL, NULL, 0, NULL} +}; + +static int +threading_abi_version(void) { + return LIBRT_THREADING_ABI_VERSION; +} + +static int +threading_api_version(void) { + return LIBRT_THREADING_API_VERSION; +} + +static int +librt_threading_module_exec(PyObject *m) +{ + if (PyType_Ready(&LockType) < 0) { + return -1; + } + if (PyModule_AddObjectRef(m, "Lock", (PyObject *)&LockType) < 0) { + return -1; + } + + // Export mypyc internal C API via capsule + static void *threading_api[LIBRT_THREADING_API_LEN] = { + (void *)threading_abi_version, + (void *)threading_api_version, + (void *)Lock_type_internal, + (void *)Lock_new_internal, + (void *)Lock_acquire_internal, + (void *)Lock_release_internal, + (void *)Lock_locked_internal, + (void *)Lock_acquire_blocking_internal, + }; + PyObject *c_api_object = PyCapsule_New((void *)threading_api, "librt.threading._C_API", NULL); + if (PyModule_Add(m, "_C_API", c_api_object) < 0) { + return -1; + } + return 0; +} + +static PyModuleDef_Slot librt_threading_module_slots[] = { + {Py_mod_exec, librt_threading_module_exec}, +#ifdef Py_MOD_GIL_NOT_USED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} +}; + +static PyModuleDef librt_threading_module = { + .m_base = PyModuleDef_HEAD_INIT, + .m_name = "threading", + .m_doc = "Fast threading primitives optimized for mypyc", + .m_size = 0, + .m_methods = librt_threading_module_methods, + .m_slots = librt_threading_module_slots, +}; + +PyMODINIT_FUNC +PyInit_threading(void) +{ + return PyModuleDef_Init(&librt_threading_module); +} diff --git a/mypyc/lib-rt/threading/librt_threading.h b/mypyc/lib-rt/threading/librt_threading.h new file mode 100644 index 0000000000000..a22284f3fdbcd --- /dev/null +++ b/mypyc/lib-rt/threading/librt_threading.h @@ -0,0 +1,10 @@ +#ifndef LIBRT_THREADING_H +#define LIBRT_THREADING_H + +#include + +#define LIBRT_THREADING_ABI_VERSION 1 +#define LIBRT_THREADING_API_VERSION 1 +#define LIBRT_THREADING_API_LEN 8 + +#endif // LIBRT_THREADING_H diff --git a/mypyc/lib-rt/threading/librt_threading_api.c b/mypyc/lib-rt/threading/librt_threading_api.c new file mode 100644 index 0000000000000..156975d243638 --- /dev/null +++ b/mypyc/lib-rt/threading/librt_threading_api.c @@ -0,0 +1,43 @@ +#include "librt_threading_api.h" + +void *LibRTThreading_API[LIBRT_THREADING_API_LEN] = {0}; + +int +import_librt_threading(void) +{ + PyObject *mod = PyImport_ImportModule("librt.threading"); + if (mod == NULL) + return -1; + Py_DECREF(mod); // we import just for the side effect of making the below work. + void **capsule = (void **)PyCapsule_Import("librt.threading._C_API", 0); + if (capsule == NULL) + return -1; + + // Only after version validation succeeds can we safely copy the full table. + int (*abi_version)(void) = (int (*)(void))capsule[0]; + int (*api_version)(void) = (int (*)(void))capsule[1]; + if (abi_version() != LIBRT_THREADING_ABI_VERSION) { + char err[128]; + snprintf(err, sizeof(err), "ABI version conflict for librt.threading, expected %d, found %d", + LIBRT_THREADING_ABI_VERSION, + abi_version() + ); + PyErr_SetString(PyExc_ValueError, err); + return -1; + } + if (api_version() < LIBRT_THREADING_API_VERSION) { + char err[128]; + snprintf(err, sizeof(err), + "API version conflict for librt.threading, expected %d or newer, found %d (hint: upgrade librt)", + LIBRT_THREADING_API_VERSION, + api_version() + ); + PyErr_SetString(PyExc_ValueError, err); + return -1; + } + // Provider API version is >= our expected version, which (by the API + // compatibility contract) means it has at least LIBRT_THREADING_API_LEN + // entries, so this copy is safe. + memcpy(LibRTThreading_API, capsule, sizeof(LibRTThreading_API)); + return 0; +} diff --git a/mypyc/lib-rt/threading/librt_threading_api.h b/mypyc/lib-rt/threading/librt_threading_api.h new file mode 100644 index 0000000000000..acb3414d79561 --- /dev/null +++ b/mypyc/lib-rt/threading/librt_threading_api.h @@ -0,0 +1,20 @@ +#ifndef LIBRT_THREADING_API_H +#define LIBRT_THREADING_API_H + +#include "librt_threading.h" + +int +import_librt_threading(void); + +extern void *LibRTThreading_API[LIBRT_THREADING_API_LEN]; + +#define LibRTThreading_ABIVersion (*(int (*)(void)) LibRTThreading_API[0]) +#define LibRTThreading_APIVersion (*(int (*)(void)) LibRTThreading_API[1]) +#define LibRTThreading_Lock_type_internal (*(PyTypeObject* (*)(void)) LibRTThreading_API[2]) +#define LibRTThreading_Lock_new_internal (*(PyObject* (*)(void)) LibRTThreading_API[3]) +#define LibRTThreading_Lock_acquire_internal (*(char (*)(PyObject *self)) LibRTThreading_API[4]) +#define LibRTThreading_Lock_release_internal (*(char (*)(PyObject *self)) LibRTThreading_API[5]) +#define LibRTThreading_Lock_locked_internal (*(char (*)(PyObject *self)) LibRTThreading_API[6]) +#define LibRTThreading_Lock_acquire_blocking_internal (*(char (*)(PyObject *self, char blocking)) LibRTThreading_API[7]) + +#endif // LIBRT_THREADING_API_H diff --git a/mypyc/primitives/librt_threading_ops.py b/mypyc/primitives/librt_threading_ops.py new file mode 100644 index 0000000000000..c060742dc2050 --- /dev/null +++ b/mypyc/primitives/librt_threading_ops.py @@ -0,0 +1,54 @@ +from mypyc.ir.deps import LIBRT_THREADING +from mypyc.ir.ops import ERR_MAGIC, ERR_NEVER +from mypyc.ir.rtypes import bool_rprimitive, lock_rprimitive, none_rprimitive +from mypyc.primitives.registry import function_op, method_op + +# Lock() +function_op( + name="librt.threading.Lock", + arg_types=[], + return_type=lock_rprimitive, + c_function_name="LibRTThreading_Lock_new_internal", + error_kind=ERR_MAGIC, + dependencies=[LIBRT_THREADING], +) + +# Lock.acquire() -- blocking acquire, returns True unless it raises +lock_acquire_op = method_op( + name="acquire", + arg_types=[lock_rprimitive], + return_type=bool_rprimitive, + c_function_name="LibRTThreading_Lock_acquire_internal", + error_kind=ERR_MAGIC, + dependencies=[LIBRT_THREADING], +) + +# Lock.acquire(blocking) -- acquire with explicit blocking argument +method_op( + name="acquire", + arg_types=[lock_rprimitive, bool_rprimitive], + return_type=bool_rprimitive, + c_function_name="LibRTThreading_Lock_acquire_blocking_internal", + error_kind=ERR_MAGIC, + dependencies=[LIBRT_THREADING], +) + +# Lock.release() +lock_release_op = method_op( + name="release", + arg_types=[lock_rprimitive], + return_type=none_rprimitive, + c_function_name="LibRTThreading_Lock_release_internal", + error_kind=ERR_MAGIC, + dependencies=[LIBRT_THREADING], +) + +# Lock.locked() +method_op( + name="locked", + arg_types=[lock_rprimitive], + return_type=bool_rprimitive, + c_function_name="LibRTThreading_Lock_locked_internal", + error_kind=ERR_NEVER, + dependencies=[LIBRT_THREADING], +) diff --git a/mypyc/primitives/registry.py b/mypyc/primitives/registry.py index 22422987b4277..060af0f88020c 100644 --- a/mypyc/primitives/registry.py +++ b/mypyc/primitives/registry.py @@ -411,6 +411,7 @@ def load_global_op(name: str, type: RType, src: str) -> LoadAddressDescription: import mypyc.primitives.int_ops import mypyc.primitives.librt_random_ops import mypyc.primitives.librt_strings_ops +import mypyc.primitives.librt_threading_ops import mypyc.primitives.librt_time_ops import mypyc.primitives.librt_vecs_ops import mypyc.primitives.list_ops diff --git a/mypyc/test-data/irbuild-threading.test b/mypyc/test-data/irbuild-threading.test new file mode 100644 index 0000000000000..652ccedcb9d75 --- /dev/null +++ b/mypyc/test-data/irbuild-threading.test @@ -0,0 +1,115 @@ +[case testLockBasics] +from librt.threading import Lock + +def lock_create() -> Lock: + return Lock() + +def lock_acquire(lk: Lock) -> bool: + return lk.acquire() + +def lock_release(lk: Lock) -> None: + lk.release() + +def lock_locked(lk: Lock) -> bool: + return lk.locked() +[out] +def lock_create(): + r0 :: librt.threading.Lock +L0: + r0 = LibRTThreading_Lock_new_internal() + return r0 +def lock_acquire(lk): + lk :: librt.threading.Lock + r0 :: bool +L0: + r0 = LibRTThreading_Lock_acquire_internal(lk) + return r0 +def lock_release(lk): + lk :: librt.threading.Lock + r0 :: None +L0: + r0 = LibRTThreading_Lock_release_internal(lk) + return 1 +def lock_locked(lk): + lk :: librt.threading.Lock + r0 :: bool +L0: + r0 = LibRTThreading_Lock_locked_internal(lk) + return r0 + +[case testLockAcquireRelease] +from librt.threading import Lock + +def acquire_release() -> None: + lk = Lock() + lk.acquire() + lk.release() +[out] +def acquire_release(): + r0, lk :: librt.threading.Lock + r1 :: bool + r2 :: None +L0: + r0 = LibRTThreading_Lock_new_internal() + lk = r0 + r1 = LibRTThreading_Lock_acquire_internal(lk) + r2 = LibRTThreading_Lock_release_internal(lk) + return 1 + +[case testLockAcquireBlocking] +from librt.threading import Lock + +def lock_acquire_blocking(lk: Lock, b: bool) -> bool: + return lk.acquire(b) +[out] +def lock_acquire_blocking(lk, b): + lk :: librt.threading.Lock + b, r0 :: bool +L0: + r0 = LibRTThreading_Lock_acquire_blocking_internal(lk, b) + return r0 + +[case testLockWith] +from librt.threading import Lock + +def with_lock(lk: Lock) -> None: + with lk: + a: list[int] = [] +[out] +def with_lock(lk): + lk :: librt.threading.Lock + r0 :: bool + r1, a :: list + r2, r3, r4 :: tuple[object, object, object] + r5 :: None + r6 :: bit +L0: + r0 = LibRTThreading_Lock_acquire_internal(lk) +L1: + r1 = PyList_New(0) + a = r1 +L2: +L3: + r2 = :: tuple[object, object, object] + r3 = r2 + goto L5 +L4: (handler for L1) + r4 = CPy_CatchError() + r3 = r4 +L5: + r5 = LibRTThreading_Lock_release_internal(lk) + if is_error(r3) goto L7 else goto L6 +L6: + CPy_Reraise() + unreachable +L7: + goto L11 +L8: (handler for L5, L6) + if is_error(r3) goto L10 else goto L9 +L9: + CPy_RestoreExcInfo(r3) +L10: + r6 = CPy_KeepPropagating() + unreachable +L11: + return 1 diff --git a/mypyc/test-data/run-threading.test b/mypyc/test-data/run-threading.test new file mode 100644 index 0000000000000..8af8755b76c44 --- /dev/null +++ b/mypyc/test-data/run-threading.test @@ -0,0 +1,156 @@ +# Test cases for librt.threading (compile and run) + +[case testLockBasics_librt] +from typing import Any +from testutil import assertRaises +from librt.threading import Lock + +class BadBool: + def __bool__(self) -> bool: + raise RuntimeError("bad bool") + +def test_lock_basic() -> None: + lock = Lock() + assert not lock.locked() + assert lock.acquire() + assert lock.locked() + lock.release() + assert not lock.locked() + +def test_lock_context_manager() -> None: + lock = Lock() + with lock as acquired: + assert acquired is True + assert lock.locked() + assert not lock.locked() + +def test_lock_non_blocking() -> None: + lock = Lock() + assert lock.acquire() + assert not lock.acquire(False) + lock.release() + assert lock.acquire(False) + lock.release() + +def test_contention() -> None: + import threading + lock = Lock() + counter = [0] + n_threads = 4 + n_increments = 10000 + + def worker() -> None: + for _ in range(n_increments): + lock.acquire() + counter[0] += 1 + lock.release() + + threads = [threading.Thread(target=worker) for _ in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + assert counter[0] == n_threads * n_increments + +def test_cross_thread_release() -> None: + # threading.Lock is unowned: a lock acquired on one thread may be + # released from another, after which another thread can acquire it. + import threading + lock = Lock() + assert lock.acquire() + + started = threading.Event() + released = threading.Event() + + def releaser() -> None: + started.set() + # Hand the lock off from a different thread than the acquirer. + lock.release() + released.set() + + t = threading.Thread(target=releaser) + t.start() + started.wait() + # This may block until releaser releases the lock on the other thread. + assert lock.acquire() + t.join() + assert released.is_set() + lock.release() + assert not lock.locked() + +def test_context_manager_exception() -> None: + lock = Lock() + try: + with lock: + assert lock.locked() + raise ValueError("test") + except ValueError: + pass + assert not lock.locked() + +def test_acquire_blocking_true() -> None: + lock = Lock() + assert lock.acquire(True) + assert lock.locked() + lock.release() + +def test_lock_constructor_errors() -> None: + lock_type: Any = Lock + make_type: Any = type + with assertRaises(TypeError): + lock_type(1) + with assertRaises(TypeError): + lock_type(foo=1) + with assertRaises(TypeError): + make_type("LockSubclass", (lock_type,), {}) + +def test_lock_acquire_argument_errors() -> None: + lock: Any = Lock() + with assertRaises(TypeError): + lock.acquire(True, False) + with assertRaises(TypeError): + lock.acquire(foo=True) + +def test_lock_acquire_blocking_truthiness() -> None: + lock: Any = Lock() + assert lock.acquire(blocking=True) + assert lock.locked() + assert not lock.acquire(blocking=False) + lock.release() + + assert lock.acquire(None) + assert lock.locked() + assert not lock.acquire(None) + lock.release() + + assert lock.acquire(1) + lock.release() + assert lock.acquire(0) + lock.release() + +def test_lock_acquire_blocking_bool_error() -> None: + lock: Any = Lock() + with assertRaises(RuntimeError, "bad bool"): + lock.acquire(BadBool()) + with assertRaises(RuntimeError, "bad bool"): + lock.acquire(blocking=BadBool()) + +def test_lock_exit_manual_call() -> None: + lock: Any = Lock() + lock.acquire() + assert lock.__exit__(None, None, None) is None + assert not lock.locked() + + lock.acquire() + assert lock.__exit__() is None + assert not lock.locked() + +def test_release_unlocked() -> None: + lock = Lock() + with assertRaises(RuntimeError): + lock.release() + # Also after acquire + release + lock.acquire() + lock.release() + with assertRaises(RuntimeError): + lock.release() diff --git a/mypyc/test/test_irbuild.py b/mypyc/test/test_irbuild.py index 7e3993e267e74..c0d3027e2a81c 100644 --- a/mypyc/test/test_irbuild.py +++ b/mypyc/test/test_irbuild.py @@ -61,6 +61,7 @@ "irbuild-librt-strings.test", "irbuild-librt-random.test", "irbuild-base64.test", + "irbuild-threading.test", "irbuild-time.test", "irbuild-match.test", ] diff --git a/mypyc/test/test_run.py b/mypyc/test/test_run.py index 9004a28ebf598..868f83654b47c 100644 --- a/mypyc/test/test_run.py +++ b/mypyc/test/test_run.py @@ -82,6 +82,7 @@ "run-base64.test", "run-librt-time.test", "run-librt-random.test", + "run-threading.test", "run-match.test", "run-vecs-i64-interp.test", "run-vecs-misc-interp.test",