Skip to content

Why asyncio Locks Are Not Thread-Safe

asyncio.Lock, Semaphore, Event, Condition and Queue coordinate tasks on one event loop. They are not thread-safe, and they are not usable from a second event loop in another thread. The trap is that they often appear to work across threads: in a test, a lock acquired from a second loop on another thread succeeded when nothing else held it. The same lock, held by the first loop when the second tried to take it, produced a deadlock that only a timeout broke — and once the lock had been used under contention on the first loop, the second loop's attempt failed immediately with RuntimeError: … is bound to a different event loop. This guide explains each behaviour and maps every asyncio primitive to the tool that works across threads.

Prerequisites

1. See why it seems to work uncontended

An uncontended asyncio.Lock acquisition is a flag check and a flag set — no future, no loop interaction:

import asyncio


async def grab(lock: asyncio.Lock) -> str:
    async with asyncio.timeout(1):
        async with lock:
            return "acquired"


async def main() -> None:
    lock = asyncio.Lock()
    # another thread, another loop, nothing holding the lock:
    print(await asyncio.to_thread(asyncio.run, grab(lock)))    # 'acquired'


asyncio.run(main())

It works because the fast path never touches the event loop. That is exactly why cross-thread misuse survives tests: test traffic is light, so the lock is rarely contended, and the code only fails under production load — when the slow path that creates a future on a specific loop finally runs.

Worse, "works uncontended" does not mean "is safe uncontended". The check-and-set is two Python operations; two threads can both see the lock free and both take it. On a single loop that interleaving is impossible because nothing runs between them; across threads it is a race.

Verify: you can reproduce the success above — and that success is the warning sign.

One asyncio.Lock, two event loops in two threads A grid of 3 rows by 3 columns. One asyncio.Lock, two event loops in two threads situation what happened why lock free acquired fast path never touches a loop lock held, not yet bound deadlock until timeout waiter future on loop B, release on loop A lock held, bound to loop A RuntimeError at once bound to a different event loop All three were reproduced on Python 3.14; only the last fails loudly.

2. Understand the deadlock and the binding error

When the lock is held, an acquirer creates a future and waits on it. Since Python 3.10, primitives bind to an event loop lazily — the first time they need one — and from then on check that every caller is on that loop:

async def main() -> None:
    lock = asyncio.Lock()
    async with lock:
        waiter = asyncio.create_task(grab(lock))     # contended on loop A: lock binds to A
        await asyncio.sleep(0)
    await waiter

    async with lock:
        print(await asyncio.to_thread(asyncio.run, grab(lock)))
        # RuntimeError: <asyncio.locks.Lock object at 0x… [locked]> is bound to a different event loop

If the lock has not yet been bound — it was only ever acquired uncontended on loop A — then loop B's contended acquire binds it to B and waits on a future owned by B. Loop A's release() then resolves that future from A's thread with plain call_soon semantics, which does not wake B's selector; and meanwhile loop A is blocked awaiting the thread. Measured result: a hang, ended only by the timeout. Which of the two failures you see depends on the lock's history, which is why these bugs look intermittent.

Verify: the binding error is the good outcome; if you see hangs around shared primitives, check which loop first used them.

3. Use threading primitives for thread coordination

If the things being coordinated are threads, use threading primitives — and never block the event loop on them. From async code, acquire a threading.Lock in a thread, or keep critical sections so short that blocking briefly is acceptable:

import threading

state_lock = threading.Lock()
counter = 0


def bump_from_thread() -> None:              # in a worker thread
    global counter
    with state_lock:
        counter += 1


async def bump_from_loop() -> None:          # on the event loop
    global counter
    # a few microseconds under a lock that is only held for microseconds: acceptable
    with state_lock:
        counter += 1

Blocking the loop on a threading.Lock is only acceptable when every holder releases it within microseconds. If a thread can hold it for milliseconds — around I/O, around a large computation — acquire it off the loop with await asyncio.to_thread(state_lock.acquire) and release it in a finally. The full set of shared-state patterns is in how to safely share state between async tasks and threads.

Verify: measure the maximum hold time of any threading.Lock touched from the loop; it must be far below your loop lag budget.

4. Hand work to the loop instead of sharing primitives

The cleanest cross-thread design shares no primitives at all: threads send messages to the loop, and only the loop touches asyncio objects. loop.call_soon_threadsafe and asyncio.run_coroutine_threadsafe are the two doors:

class LoopOwnedCounter:
    """All mutation happens on the loop; threads only submit requests."""

    def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
        self._loop = loop
        self._lock = asyncio.Lock()            # used on its own loop only
        self.value = 0

    async def _add(self, n: int) -> int:
        async with self._lock:
            await persist(self.value + n)
            self.value += n
            return self.value

    def add_from_thread(self, n: int, timeout: float = 5.0) -> int:
        fut = asyncio.run_coroutine_threadsafe(self._add(n), self._loop)
        return fut.result(timeout)              # blocks the calling thread, not the loop

The asyncio.Lock is fine here because only coroutines running on its own loop ever touch it. Threads interact through a concurrent.futures.Future, which is thread-safe. This is the same shape as the bridge in running an event loop in a background thread.

Verify: grep the thread-side code for any asyncio. primitive method calls; there should be none except the two threadsafe functions.

Threads submit; only the loop touches asyncio primitives A sequence of 5 messages between 3 participants. Threads submit; only the loop touches asyncio primitives worker thread event loop asyncio.Lock run_coroutine_threadsafe(_add(1)) acquire, on its own loop persist, update value release concurrent future resolved The asyncio primitive never leaves its loop; the only thing crossing threads is a thread-safe future.

5. Map each asyncio primitive to its cross-thread equivalent

asyncio primitive Same loop only Across threads use Notes
asyncio.Lock yes threading.Lock, or submit to the loop never block the loop for long
asyncio.Event yes loop.call_soon_threadsafe(event.set) setting from a thread via the loop
asyncio.Queue yes call_soon_threadsafe(q.put_nowait, x) or janus or a queue.Queue read with to_thread
asyncio.Semaphore yes threading.Semaphore separate limits per side
asyncio.Future yes concurrent.futures.Future + wrap_future wrap_future bridges the two

The pattern in every row is the same: either the thread hands the operation to the loop, or the two sides use different primitives designed for their own world. Queues are the most common case and are covered in sending results from threads to an asyncio queue.

Verify: every shared object in your code has a documented owner — one loop, or "threads" — and the table above tells you how the other side reaches it.

Who needs to coordinate with whom? A decision on Who is coordinating with 3 outcomes. Who needs to coordinate with whom? Who is coordinating? tasks on one loop asyncio primitives never touched elsewhere threads with threads threading primitives not on the loop for long threads with a loop call_soon_threadsafe or run_coroutine_threadsafe asyncio primitives are only correct when every user runs on the loop that owns them.

Verification

Primitive usage is thread-correct when:

  • No asyncio primitive is created at import time and shared across loops or threads.
  • No thread calls methods on an asyncio primitive except through call_soon_threadsafe or run_coroutine_threadsafe.
  • Debug mode is clean: PYTHONASYNCIODEBUG=1 reports no non-thread-safe operations.
  • threading locks touched from the loop are held for microseconds only.

Diagnostic Hook: in debug and staging builds, wrap your asyncio primitives in a thin subclass that records threading.get_ident() on first use and asserts it on every later call. The assertion fires on the first cross-thread use — under light test load, long before the contended path would expose the bug in production.

Pitfalls & edge cases

  • Module-level asyncio.Lock() shared by code that runs under several asyncio.run() calls, such as tests.
  • Assuming the GIL makes asyncio primitives safe. The GIL protects interpreter internals, not multi-step logic in acquire().
  • Blocking the loop on threading.Lock held by a thread doing I/O.
  • Free-threaded Python. Without the GIL the race in section 1 becomes far more likely; the rules here matter more, not less.

Frequently Asked Questions

Is asyncio.Lock thread-safe?

No. asyncio.Lock coordinates tasks running on one event loop. It must not be used from other threads or from another event loop; under contention it either deadlocks or raises RuntimeError because it is bound to a different event loop.

Why does my asyncio.Lock say it is bound to a different event loop?

asyncio primitives bind to the loop that first needs to wait on them. Using the same lock later from another loop — another thread, or another asyncio.run call — raises that error. Create primitives inside the loop that uses them.

How do I share a lock between threads and asyncio tasks?

Prefer not to: let threads submit work to the loop with run_coroutine_threadsafe and keep the asyncio lock on its own loop. If threads must share state directly, use a threading.Lock held only briefly, or acquire it from async code via asyncio.to_thread.

Can I set an asyncio.Event from a thread?

Use loop.call_soon_threadsafe(event.set). Calling event.set() directly from a thread is not safe and may not wake waiting tasks promptly.