Cheatsheet
Since the documentation is currently still under development, here is a concise set of notes on the main features of aiologic. It is intended to be transformed into separate sections in the future.
Synchronization primitives
Synchronization primitives are used to organize controlled interactions between tasks. It can be simple notification, mutual exclusion, or any other effect the programmer wants.
Events
aiologic.Event is one of the simplest signaling mechanisms. You can
use it to notify other tasks that a particular condition has become true, such
as a shutdown. When the event is set, each wait call returns immediately.
event = aiologic.Event()
async def work(i):
print(f"worker #{i} started")
await event # waits one second
print(f"worker #{i} notified")
await event # returns immediately
print(f"worker #{i} stopped")
async with anyio.create_task_group() as tg:
tg.start_soon(work, 1)
tg.start_soon(work, 2)
await anyio.sleep(1)
assert not event.is_set()
event.set() # notify all
assert event.is_set()
event = aiologic.Event()
def work(i):
print(f"worker #{i} started")
event.wait() # waits one second
print(f"worker #{i} notified")
event.wait() # returns immediately
print(f"worker #{i} stopped")
with ThreadPoolExecutor(2) as executor:
executor.submit(work, 1)
executor.submit(work, 2)
time.sleep(1)
assert not event.is_set()
event.set() # notify all
assert event.is_set()
Unlike standard events (threading.Event and asyncio.Event),
aiologic.Event cannot be reset to its initial state. In this way it is
similar to AnyIO / Trio events, and this is why it is called a one-time event.
aiologic.REvent, in contrast, is a reusable event. It achieves this at
the cost of some performance degradation, so it is recommended for use only
when really needed.
event = aiologic.REvent()
assert not event.is_set()
event.set() # notify all
assert event.is_set()
event.clear() # reset
assert not event.is_set()
Note
Repeated calls to event.set() / event.clear() when the event is
already set / unset have no effect. This corresponds to the behavior of the
standard events, but may be unexpected if you have not worked with them
before.
aiologic.CountdownEvent represents a completely different class of
events that is inspired by CountdownEvent from .NET Framework 4.0. Unlike regular events, it
counts the number of “sets” and “resets” (to wake up tasks, you need to “set” a
countdown event as many times as it has been “reset”), and it is “set” by
default.
event = aiologic.CountdownEvent()
async def work(i):
print(f"worker #{i} started")
try:
await anyio.sleep(i / 9)
finally:
event.down() # one set
print(f"worker #{i} stopped")
async with anyio.create_task_group() as tg:
for i in range(1, 10):
event.up() # one reset
tg.start_soon(work, i)
assert event.value == 9
await event # waits one second
assert event.value == 0
event = aiologic.CountdownEvent()
def work(i):
print(f"worker #{i} started")
try:
time.sleep(i / 9)
finally:
event.down() # one set
print(f"worker #{i} stopped")
with ThreadPoolExecutor(9) as executor:
for i in range(1, 10):
event.up() # one reset
executor.submit(work, i)
assert event.value == 9
event.wait() # waits one second
assert event.value == 0
It is useful for signaling when some group of events has occurred, such as all
threads, tasks, or whatever else has finished. And compared to standard
functions such as concurrent.futures.wait() or asyncio.gather(), it
has four key advantages:
It supports adding new tasks to wait dynamically, just by calling
event.up(), which works even from another thread. The standard functions work only with a fixed set.It can be used with any library, with any number of waiting tasks, and with any worker tasks of any nature, which only requires calling
event.down()when a single unit completes. The standard functions do not have such versatility.It can be reset to its initial (set) state at any time externally by calling
event.clear(). The standard functions require more sophisticated techniques.With \(m\) waiting tasks for the same group of \(n\) worker tasks, the time complexity of the entire “join” operation will be only \(O(m+n)\). The standard functions, in contrast, give \(O(mn)\) time complexity because they require adding a callback to each unit (and this is the same time complexity as if each waiting task were to loop through each worker task to wait one by one).
Thus, countdown events are a convenient way to implement joining. But their disadvantage is that they require \(O(n)\) memory, where \(n\) is their current counter.
Special behavior
The use of atomic operations as well as the lock-free implementation style
gives aiologic primitives a special behavior. And first of all it concerns
atomicity of primitives’ methods, such as event.set().
When you call threading.Event.set(), it works in mutual exclusion mode —
in fact, threading.Event is built on top of
threading.Condition (see source code). But aiologic.Event.set()
has a different situation — it allows its parallel execution in different
threads, which affects the wakeup order and when the method completes its
execution. So all aiologic primitives have to use some tricks to provide
predictable behavior and emulate atomicity (within some limits).
The events implement the following special behavior:
The wakeup order is exactly FIFO for all events except
aiologic.Eventwithout GIL (free threading, perfect fairness disabled). The latter allows racing between threads, which makes the order non-deterministic — your async tasks may wake up in a different order than when they calledawait event. If you need determinism in free threading, you can enable perfect fairness via theAIOLOGIC_PERFECT_FAIRNESSenvironment variable, but this will cost you some (noticeable) performance degradation with a huge number of threads.All tasks wake up at the same time (or in several scheduler passes, usually one or two, if perfect fairness is disabled), which gives \(O(n)\) time complexity of a full wakeup. That is, both when returning from the
event.set()method and when returning from theevent.wait()method, you can expect that all tasks are already scheduled for execution, which is especially useful for benchmarks. This is different from the threading events, which due to mutual exclusion give \(O(n^2)\) time complexity of a full wakeup.The
event.set()call wakes up only those tasks that were waiting until the nearest reset and until the first task wakes up, for which markers and timestamps are used. At the same time, the woken task inherits the deadline (current timestamp) of the one that woke it up to wake up its neighbors. This ensures that theevent.clear()+event.wait()combination is processed correctly after wakeup (otherwiseevent.wait()could return immediately), and that the wakeup is done in a finite amount of time, which eliminates possible resource starvation.When no wakeup/waiting is required, the event methods work as truly non-blocking, which gives good scalability. In particular, the
event.up()method always runs for \(O(1)\), and theevent.down()method runs for \(O(1)\) until the counter goes to zero. This is different from the threading events, which, for example, may take \(O(n)\) time for repeatedevent.set()calls due to mutual exclusion.
You can read about the origins of time complexity in mutual exclusion in the Performance section.
Barriers
aiologic.Latch is an auto-signaling mechanism. It notifies all tasks
when they are all waiting, that is, call barrier.wait() /
await barrier. When the barrier is used, each wait call returns
immediately.
barrier = aiologic.Latch(3) # for three workers
async def work(i):
print(f"worker #{i} started")
await barrier # waits for all
print(f"worker #{i} notified")
await barrier # returns immediately
print(f"worker #{i} stopped")
async with anyio.create_task_group() as tg:
tg.start_soon(work, 1)
tg.start_soon(work, 2)
tg.start_soon(work, 3)
barrier = aiologic.Latch(3) # for three workers
def work(i):
print(f"worker #{i} started")
barrier.wait() # waits for all
print(f"worker #{i} notified")
barrier.wait() # returns immediately
print(f"worker #{i} stopped")
with ThreadPoolExecutor(3) as executor:
executor.submit(work, 1)
executor.submit(work, 2)
executor.submit(work, 3)
Unlike standard barriers (threading.Barrier and
asyncio.Barrier), aiologic.Latch is a single-phase barrier
that cannot be reused. In this way it is similar to std::latch from C++20, and this is
why it is called a single-use barrier.
aiologic.Barrier, in contrast, is a cyclic (or multi-phase) barrier.
It is convenient when your application logic contains sequential phases (as is
usually the case with parallel computing).
barrier = aiologic.Barrier(2) # for two workers
async def work(i):
print(f"worker #{i} started")
await barrier # waits for all
print(f"worker #{i} notified")
await barrier # waits for all
print(f"worker #{i} stopped")
async with anyio.create_task_group() as tg:
tg.start_soon(work, 1)
tg.start_soon(work, 2)
barrier = aiologic.Barrier(2) # for two workers
def work(i):
print(f"worker #{i} started")
barrier.wait() # waits for all
print(f"worker #{i} notified")
barrier.wait() # waits for all
print(f"worker #{i} stopped")
with ThreadPoolExecutor(2) as executor:
executor.submit(work, 1)
executor.submit(work, 2)
Nevertheless, aiologic.Barrier is still not reusable. You cannot
return either of these two barrier types to the default, empty state, except
via barrier.wait() / await barrier (only for the cyclic barrier). If
you need the barrier.reset() method, there is a third type for that,
aiologic.RBarrier.
Error handling
Barriers require a special approach to error handling because of their auto-signaling nature. If even one worker fails to wait, all others will wait forever. To solve this problem, they have a special, “broken” state.
There are two ways to put a barrier into the broken state. The first is
automatic, on cancellation or timeouts. When barrier.wait() fails, each
current or future call raises aiologic.BrokenBarrierError. It is not
raised for the failed call if the failure is due to some other exception, but
it is raised on internal timeouts.
barrier = aiologic.Latch(3) # for three workers
async def work(i):
print(f"worker #{i} started")
try:
with anyio.fail_after(i):
await barrier # waits one second + fails
except (aiologic.BrokenBarrierError, TimeoutError):
print(f"worker #{i} failed")
async with anyio.create_task_group() as tg:
tg.start_soon(work, 1)
tg.start_soon(work, 2)
barrier = aiologic.Latch(3) # for three workers
def work(i):
print(f"worker #{i} started")
try:
# with internal timeout
barrier.wait(i) # waits one second + fails
except aiologic.BrokenBarrierError:
print(f"worker #{i} failed")
with ThreadPoolExecutor(2) as executor:
executor.submit(work, 1)
executor.submit(work, 2)
The second is manual, by calling barrier.abort(). It is useful at startup
(when at least one worker fails to start), it is useful at shutdown, and it is
especially useful during phases.
barrier = aiologic.Latch(1) # for one worker
try:
pass # do some work
except:
barrier.abort() # something went wrong
raise
await barrier # waits or fails
barrier = aiologic.Latch(1) # for one worker
try:
pass # do some work
except:
barrier.abort() # something went wrong
raise
barrier.wait() # waits or fails
The case of sequential phases is particularly complex. Unless it is ensured that no one raises an exception at a wait call on a successful wakeup, the failed call must also abort the next phase. The need for correct handling creates inconvenient patterns when using different barriers for different phases:
phase1 = aiologic.Latch(1) # for one worker
phase2 = aiologic.Latch(1) # for one worker
phase3 = aiologic.Latch(1) # for one worker
try:
await phase1 # waits or fails
pass # do some work, phase #1
except:
phase2.abort() # something went wrong
raise
try:
await phase2 # waits or fails
pass # do some work, phase #2
except:
phase3.abort() # something went wrong
raise
phase1 = aiologic.Latch(1) # for one worker
phase2 = aiologic.Latch(1) # for one worker
phase3 = aiologic.Latch(1) # for one worker
try:
phase1.wait() # waits or fails
pass # do some work, phase #1
except:
phase2.abort() # something went wrong
raise
try:
phase2.wait() # waits or fails
pass # do some work, phase #2
except:
phase3.abort() # something went wrong
raise
This problem is solved by aiologic.Barrier (and its relative
aiologic.RBarrier). Besides the fact that using a single instance for
all phases simplifies the pattern to a single-phase case, it also supports use
as a context manager:
barrier = aiologic.Barrier(1) # for one worker
async with barrier: # waits or fails at enter
pass # do some work, phase #1
async with barrier: # waits or fails at enter
pass # do some work, phase #2
barrier = aiologic.Barrier(1) # for one worker
with barrier: # waits or fails at enter
pass # do some work, phase #1
with barrier: # waits or fails at enter
pass # do some work, phase #2
Note
Using aiologic barriers as context managers ensures that
barrier.abort() is called if an exception has been raised. However,
asyncio.Barrier instances do not do this, and do not even put a
barrier into the broken state on exceptions raised at await
barrier.wait(). In fact, the only way to put an asyncio barrier into the
broken state is to explicitly do await barrier.abort().
The possible reason for such behavior in asyncio is quite simple. Like any
other modern asynchronous framework, asyncio has developed cancellation
semantics. Instead of doing await barrier.abort(), you can simply
cancel tasks directly. Or even use asyncio.TaskGroup. This
eliminates the need to mess with asyncio.BrokenBarrierError at all.
What makes aiologic different is that its barriers can work with different
libraries at the same time, and each may have different cancellation
semantics (or even no cancellation semantics). So you need to work with
aiologic.BrokenBarrierError on all interfaces.
Finalizing
aiologic.Barrier (and its relative aiologic.RBarrier) gives
each worker its own integer, in wakeup order. It is returned both when waiting
and when using a barrier as a context manager.
barrier = aiologic.Barrier(3) # for three workers
async def work(i):
print(f"worker #{i} started")
async with barrier as j: # int in range(0, 3)
print(f"worker #{i} notified as #{j + 1}")
print(f"worker #{i} stopped")
async with anyio.create_task_group() as tg:
tg.start_soon(work, 1)
tg.start_soon(work, 2)
tg.start_soon(work, 3)
barrier = aiologic.Barrier(3) # for three workers
def work(i):
print(f"worker #{i} started")
with barrier as j: # int in range(0, 3)
print(f"worker #{i} notified as #{j + 1}")
print(f"worker #{i} stopped")
with ThreadPoolExecutor(3) as executor:
executor.submit(work, 1)
executor.submit(work, 2)
executor.submit(work, 3)
This can be used to finalize a resource in a single thread when the previous phase is complete.
Special behavior
The barriers implement the same special behavior as aiologic.Event,
but with the following specifics:
Successful and unsuccessful wakeups (due to explicit or implicit
barrier.abort()) can race. When threads wake up in a natural way (due to sufficientbarrier.wait()calls), they wake each other up with the information that the wakeup was successful. When threads wake up in an unnatural way (due to a timeout, an exception, or abarrier.abort()call), they do the same thing, but with the information that the wakeup was unsuccessful. In a multithreaded scenario where both types of wakeup coexist, the success of a thread’s wakeup is determined by the race condition.The parallelism of successful wakeup is limited for
aiologic.Barrierandaiologic.RBarrier. When tasks are more than expected, they are divided into phases. Tasks wake up each other in their phase, but the wakeup of phases is sequential — a task from the next phase will be woken up only when the wakeup initiator wakes up all tasks in its phase. In particular, the case where the expected number is \(1\) and the actual number is \(n\) gives \(O(n^2)\) complexity of a full wakeup (instead of \(O(n)\) if the expected number was \(n\)).The wakeup order of phases may not be exactly FIFO for
aiologic.Barrierandaiologic.RBarrierwithout GIL (free threading, perfect fairness disabled). When perfect fairness is disabled, a separate list is used to parallelize wakeups. As a result, an unsuccessful wakeup may wake up new tasks before the phase wakeup is complete.
The ability of the barriers to wake up all tasks at once opens the way for one non-trivial application of them: solving the squares problem. By using a barrier to synchronize the start of threads, you can ensure that none of them run until they all start, and thus eliminate unnecessary context switching (and wasted CPU cycles) during the wakeup process. As a result, you will lower the time complexity of a full start from \(O(n^2)\) to \(O(n)\). This extends the already known use of barriers for similar purposes, such as reducing the impact of startup overhead for timeouts (improving test reproducibility).
import asyncio
import threading
# import time
import aiologic
N = 300
started = aiologic.Latch(N)
stopped = False
async def work(i):
global stopped
# without: 12.72 seconds
# with: 0.43 seconds
await started
if i == N - 1: # the last thread
stopped = True # stop the work
while not stopped:
await asyncio.sleep(0) # do some work
for i in range(N):
threading.Thread(target=asyncio.run, args=[work(i)]).start()
# import asyncio
import threading
import time
import aiologic
N = 300
started = aiologic.Latch(N)
stopped = False
def work(i):
global stopped
# without: 1.17 seconds
# with: 0.13 seconds
started.wait()
if i == N - 1: # the last thread
stopped = True # stop the work
while not stopped:
time.sleep(0) # do some work
for i in range(N):
threading.Thread(target=work, args=[i]).start()
Like all other aiologic primitives, the barriers implement the FIFO wakeup
order. This is achieved by forcing a checkpoint for the task that came last in
the current phase. Besides giving more expected and predictable behavior, this
also distinguishes them from asyncio.Barrier.
import asyncio
from itertools import count
import aiologic
async def work(barrier, c, i):
print(f"worker #{i} started")
async with barrier:
j = next(c)
print(f"worker #{i} notified as #{j}")
assert j == i # passes for all tasks
print(f"worker #{i} stopped")
async def main():
barrier = aiologic.Barrier(3) # for three workers
c = count(1) # for wakeup enumerating
async with asyncio.TaskGroup() as tg:
tg.create_task(work(barrier, c, 1))
tg.create_task(work(barrier, c, 2))
tg.create_task(work(barrier, c, 3))
asyncio.run(main())
import asyncio
from itertools import count
# import aiologic
async def work(barrier, c, i):
print(f"worker #{i} started")
async with barrier:
j = next(c)
print(f"worker #{i} notified as #{j}")
assert j == i # fails for all tasks
print(f"worker #{i} stopped")
async def main():
barrier = asyncio.Barrier(3) # for three workers
c = count(1) # for wakeup enumerating
async with asyncio.TaskGroup() as tg:
tg.create_task(work(barrier, c, 1))
tg.create_task(work(barrier, c, 2))
tg.create_task(work(barrier, c, 3))
asyncio.run(main())
Advanced topics
There is a whole layer of topics that aiologic covers or in some way takes into account. Since these are related to features that are commonly used in some special cases, such topics are called advanced topics. Nevertheless, they are recommended reading for anyone who wants to use aiologic effectively.
Cancellation and timeouts
“When in Rome, do as the Romans do” is a proverb attributed to St. Ambrose, a fourth-century bishop of Milan, and it also reflects well aiologic’s vision of cancellation and timeouts. You can pass timeouts when using “green” libraries, but when using “async” libraries you have to use the mechanisms they provide.
async with aiologic.Condition() as cv:
await asyncio.wait_for(cv, timeout=5)
with aiologic.Condition() as cv:
cv.wait(timeout=5)
One reason why aiologic does not provide its own timeouts for async libraries is the difference between cancellation semantics combined with the fact that AnyIO with the asyncio backend cannot be distinguished from pure asyncio on the aiologic side.
Note
Since 0.17.0, await cv.with_(timeout=5) is valid (which applies to all
awaitable primitives), and passing a timeout to blocking async functions is
supported. These have the same semantics as when using the green libraries
and are intended for writing library-independent code.
There are still no (and will be no) timeouts as cancellation scopes, so the last paragraph remains in effect.
Shielding
Sometimes you need to ensure that something will not be cancelled. For example,
some piece of asynchronous code in aclose() or __aexit__() method that
finalizes your resource. Or a task that you are waiting for. In general, you
may want to shield from being cancelled one of the following things:
An awaitable object
A call (async or green)
A code block
aiologic provides aiologic.lowlevel.shield() universal decorator that
works on the first two levels. It creates a wrapt-powered object proxy that adds shielding
from cancellation to any awaitable or callable object, and can be used like
this:
# for an awaitable object (coroutine)
result = await aiologic.lowlevel.shield(corofunc(...))
or like this:
# for a callable object (coroutine function)
result = await aiologic.lowlevel.shield(corofunc)(...)
# for a callable object (regular function)
result = aiologic.lowlevel.shield(func)(...)
or even like this:
# for a callable object (coroutine function) as a decorator
@aiologic.lowlevel.shield
async def corofunc(...):
...
result = await corofunc(...)
# for a callable object (regular function) as a decorator
@aiologic.lowlevel.shield
def func(...):
...
result = func()
But what is its particularity? Let’s take asyncio as an example.
Using asyncio, you can directly work with future objects, a special case of which are tasks. And when you wait for a future object, a quite logical “chaining” takes place, which delegates cancelling of the current task to the object:
task = asyncio.create_task(asyncio.sleep(5))
await asyncio.sleep(0) # start the task
asyncio.current_task().cancel() # cancel the current task
try:
await task # cancels immediately
finally:
print(task.cancelled()) # True
future = asyncio.get_running_loop().create_future()
# future is pending
asyncio.current_task().cancel() # cancel the current task
try:
await future # cancels immediately
finally:
print(future.cancelled()) # True
This is the same as if, instead of cancelling the current task (via
asyncio.current_task().cancel()), you would cancel the waiting object
directly (via task.cancel() / future.cancel()). In particular, the
current task would wait until the asynchronous call actually completes:
async def work():
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
await asyncio.sleep(1) # wait for one second
raise
task = asyncio.create_task(work())
await asyncio.sleep(0) # start the task
asyncio.current_task().cancel() # cancel the current task
try:
await task # cancels after one second
finally:
print(task.cancelled()) # True
Such behavior mimics that which would be the case if await were applied to
a coroutine (which are interacted with in yield from style, and thus work
in the context of the current task) rather than a future object, and is the
expected behavior. But what if you want the cancelling of the current task to
have no effect on the waiting object? For example, if it can be used later by
someone else to get a result? In that case you can use asyncio.shield(),
which works at the first level (shielding an awaitable object):
task = asyncio.create_task(asyncio.sleep(5))
await asyncio.sleep(0) # start the task
asyncio.current_task().cancel() # cancel the current task
try:
await asyncio.shield(task) # cancels immediately
finally:
print(task.cancelled()) # False
future = asyncio.get_running_loop().create_future()
# future is pending
asyncio.current_task().cancel() # cancel the current task
try:
await asyncio.shield(future) # cancels immediately
finally:
print(future.cancelled()) # False
As you can see, using asyncio.shield() only “undoes the chaining”. The
asynchronous call will still be cancelled. The difference is that the future
object will neither be cancelled nor waiting to be done when the current task
is cancelled. Sadly, this difference in the levels is often misunderstood,
resulting in incorrect use of asyncio.shield() for finalization in the
wild.
Okay, but how can we actually shield asynchronous calls (and thus safely
perform finalization)? Well, you can see an example of such shielding in
the asyncio.Condition implementation, which uses shielding from cancellation to ensure
that the lock is acquired anyway. It catches and suppresses each
asyncio.CancelledError in the loop, and raises the last one when
finished. It does not use asyncio.shield(), since cancelling the
acquire() method does not affect operability in its case (it does, however,
affect performance).
So as you can guess, aiologic.lowlevel.shield() uses both of these
techniques. By using asyncio.shield(), it shields the waiting object
(such as the coroutine resulting from an asynchronous function call), allowing
it to never be “chained”. By using the loop with the try-except block, it
suppresses cancellation and raises it upon completion. This is consistent with
the cancellation semantics of pure asyncio.
As for AnyIO, it has a different cancellation semantics and its own
cancellation shielding mechanism. anyio.CancelScope with
shield=True works at the third level and, unlike the second technique of
pure asyncio, does not raise asyncio.CancelledError upon completion — it
will be raised on the next asynchronous call within the cancelled scope (since
in AnyIO the cancel is raised at each unshielded call, even if you have
previously suppressed the cancel). But along with this, it does not handle pure
asyncio level cancels in any way, which makes its cancel scopes incompatible
with any code capable of calling task.cancel():
async def work():
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
await asyncio.sleep(1) # wait for one second
raise
task = asyncio.create_task(work())
await asyncio.sleep(0) # start the task
with anyio.CancelScope(shield=True):
asyncio.current_task().cancel() # cancel the current task
try:
await task # still cancels after one second
finally:
print(task.cancelled()) # True
Because of the difference in cancellation semantics, aiologic also has to
explicitly support AnyIO. When the library is imported, it adds a shielded
cancel scope from AnyIO to the two techniques described above. As a result, you
have a solution that fills two holes at once: it implements proper shielding
for asynchronous calls on pure asyncio, and also provides accompanying
shielding from task.cancel() on AnyIO.
with anyio.CancelScope() as scope: # anyio level
scope.cancel() # cancel the current scope
coro = anyio.sleep(5)
await aiologic.lowlevel.shield(coro) # returns after five seconds
with anyio.CancelScope() as scope: # anyio level
asyncio.current_task().cancel() # cancel the current task
coro = anyio.sleep(5)
await aiologic.lowlevel.shield(coro) # cancels after five seconds
# asyncio level
asyncio.current_task().cancel() # cancel the current task
coro = asyncio.sleep(5)
await aiologic.lowlevel.shield(coro) # cancels after five seconds
Other async libraries also provide their own ways of shielding from
cancellation, and these are used directly. A more interesting situation is in
the case of green libraries. For them, aiologic uses an implementation similar
to the techniques of pure asyncio, but for greenlet.GreenletExit and
timeouts. And adds to that rescheduling of all caught timeouts, which ensures
that they are raised after completion, and in the correct order:
with gevent.Timeout(2): # second
with gevent.Timeout(1, False): # first
# raises the first timeout (1) after three seconds
aiologic.lowlevel.shield(gevent.sleep)(3)
assert "never be reached"
# raises the second timeout (2)
gevent.sleep(0)
with eventlet.Timeout(2): # second
with eventlet.Timeout(1, False): # first
# raises the first timeout (1) after three seconds
aiologic.lowlevel.shield(eventlet.sleep)(3)
assert "never be reached"
# raises the second timeout (2)
eventlet.sleep(0)
Note
Using asyncio.shield() effectively gives that each run of the
shielded coroutine function will create a new task. The same is true for
green libraries — they create a new greenlet via gevent.spawn() /
eventlet.spawn(). If you rely on the number of context switches in
your applications, you should take this into account.
Caution
aiologic.lowlevel.shield() performs shielding only for known
cancellation types. If you kill a greenlet with an exception other than
greenlet.GreenletExit, it will be successfully raised as if you did
not perform shielding from cancellation. The same is true for
KeyboardInterrupt — if you want safe cancelling, make sure you have
proper signal handlers (such as those that asyncio installs by default in
asyncio.run()).
Know more
We are currently preparing students for jobs that don’t yet exist, using technologies that haven’t been invented, in order to solve problems we don’t even know are problems yet.
—Richard Riley; Gunderson et al., 2004
This document describes not all the secrets of aiologic. The scope of the documentation is gradually increasing, describing more and more details. But if you want to get the information right now, there are several ways:
Open the rest of the documentation if you have not already done so.
Read the author’s answers on Stack Overflow. Many of them reveal the use of aiologic in specific use cases and even contain unique information that you may not find within the documentation.
Check out the changelog. It has information about some non-trivial things.
Look at the source code. Stub files (
.pyifiles) can serve as a good guide to the interfaces, and you can also learn a lot just by reading the implementation code. Note, however, that understanding some behaviors requires more than just being able to read source code.Ask your question on GitHub Discussions or write to the author. Response times vary, but you can expect a quality answer. Do not be afraid to give feedback.
Search on DeepWiki or ask Devin there as well. The information there is generated from both the source code and the documentation you are reading right now. Although Devin may lie sometimes (because it is an AI; and lies a lot) or not know about some things (especially complex concurrent things)… it can tell you what to do in your particular case. But be extremely skeptical!
Perhaps, even with all the answers, you still will not know the spirit of aiologic. Well, that is okay, because few people actually do. But the more you know, the closer you are to the truth.