Concurrency¶
darkob runs on one event loop and owns its state without locks — which is why it is safe to share a client between tasks, and unsafe to share it between loops or threads.
One loop owns everything¶
Every mutable structure inside the library belongs to the event loop that is running it:
| State | Where it lives |
|---|---|
| Handler dispatch index | Api._dispatch_index |
| Endpoint latency averages | Api._latency |
| Circuit breakers | Api._breakers |
| Rate-limit buckets | RateLimiter._global, RateLimiter._chats |
| Conversation registry | ConversationManager._waiters |
| Metric registry | darkob.metrics.metrics |
None of these needs a lock, because asyncio is cooperative: a section of
code that never awaits cannot be interleaved with another task. Every
mutation above is such a section — a dict read followed by a dict write, a
counter increment, a deque append. Where an operation genuinely has to span
an await, the code uses an asyncio.Lock instead of a thread lock:
TokenBucketlocks around refill-plus-take, and sleeps outside the lock;SendQueuelocks around the whole flush so two flushes cannot interleave.
These locks are LazyLock wrappers: the underlying asyncio.Lock is
created on first async with, not at construction. That lets a
RateLimiter, a SendQueue or a storage backend be built at import time,
where Python 3.9 has no running loop to bind a lock to. The lock binds to
the first loop that uses it, which is another way of saying the same thing
as the rule below.
The one thread lock in the library is SQLiteStorage, which guards
blocking sqlite3 calls; it is safe cross-thread precisely because it does
not touch the event-loop state above.
One client per loop, one loop per client
darkob is not safe to share across threads or across event loops.
Awaiting one Bot from two asyncio.run() calls, or from a second
thread's loop, can corrupt the dispatch index, the breakers and the
buckets — the exact failures the lock-free design avoids by assuming a
single loop. Create the client inside the loop that will use it and
keep it there.
Sharing a client between tasks on that loop is fine and is the intended model: a handler, a background task and a metrics scrape can all use the same object.
The sync bridge does not change the rule¶
Calling an async method without await runs it on a dedicated worker
thread's loop (darkob/hybrid.py). That is still exactly one loop, so it
is safe — as long as you do not also drive the same client from your own
loop. The wrapper detects a running loop and returns the coroutine
unchanged, so mixed style within one process means "pick one per client".
darkob.utils.concurrency¶
asyncio.TaskGroup and asyncio.timeout are Python 3.11 additions, and
darkob supports 3.9. This module makes one style available everywhere:
from darkob.utils.concurrency import TaskGroup, timeout_after
async with TaskGroup() as group:
group.create_task(fetch(a))
group.create_task(fetch(b))
# both finished, or the first failure cancelled the other one
async with timeout_after(5.0):
await slow_call()
| Name | Meaning |
|---|---|
native_task_group / native_timeout |
True on 3.11+, False below |
TaskGroup |
asyncio.TaskGroup on 3.11+, else a compatible reimplementation |
timeout_after(seconds) |
asyncio.timeout on 3.11+, else a compatible reimplementation; None means no timeout |
TaskGroupError |
Raised on 3.9/3.10 when one or more grouped tasks fail |
LazyLock |
An async with-compatible lock created on first use, so objects can be built outside a loop |
The failure type therefore differs by interpreter, and callers should not depend on it:
- on 3.9/3.10 a
TaskGroupErroris raised, a plainExceptionsubclass carrying.exceptions(and.errors) as a tuple; - on 3.11+ the interpreter's native
ExceptionGroupis raised.
A failing task cancels its siblings in both cases, which is the whole point of structured concurrency — and the reason the library does not use it everywhere.
Why handler dispatch does not use TaskGroup¶
Handlers are independent. One failing handler must not cancel the others, and one slow handler must not take its siblings down with it. Structured concurrency gives the opposite guarantee: the first failure cancels the group. So:
- Bot dispatch awaits each handler in turn, inside a
try/exceptthat logs the failure and keeps going (HandlerRegistry._invoke). - Messenger/Shad dispatch spawns each matching handler as a task and
keeps a strong reference to it, with a done-callback that logs the
exception (
Api._spawn). One handler's exception is a log line, not a cancellation of the rest.
Wrapping either in a TaskGroup would be a regression: a broken command in
one handler would silently cancel the sibling that was still replying.
The same reasoning applies to health checks. HealthRegistry.report()
runs every probe with asyncio.gather, not TaskGroup, so that one failing
probe still lets the others report — a liveness endpoint that hides six
green checks because the seventh raised is worse than useless.
Free-threaded builds (PEP 703)¶
Not validated
darkob has never been run on a free-threaded interpreter. Nothing in
the codebase special-cases a --disable-gil build.
The GIL is load-bearing for the lock-free updates described above:
dict.get() followed by dict[key] = value, or counter += 1, is atomic
only while a single thread holds the GIL. On a free-threaded build those
sequences can interleave, so the "no locks needed" argument stops holding.
The supported configuration on such a build is unchanged: one client, one
event loop, one thread driving it. Do not treat the absence of locks as a
promise of thread safety — it is a consequence of the single-loop rule.
(On CPython 3.13 and later, sys._is_gil_enabled() reports whether the GIL
is active in the running interpreter.)
Practical rules¶
- Construct clients inside the loop that will use them.
- Share a client across tasks; never across loops or threads.
- Use
asyncio.create_taskfor fire-and-forget work and keep a reference to the task, so it is not garbage-collected mid-flight. - Bound concurrency:
max_concurrent_updates(default 32) caps updates,RateLimitercaps outgoing calls. - Prefer
timeout_after()over hand-rolledwait_forwhen the code has to run on 3.9 as well as 3.11+.