Overview
The aiologic library has primitives similar to existing ones:
import anyio
import aiologic
async def work(lock):
async with lock:
await anyio.sleep(1) # do some work
async def main():
lock = aiologic.Lock()
async with anyio.create_task_group() as tg:
for _ in range(3):
tg.start_soon(work, lock)
anyio.run(main)
# program will end in 3 seconds
import anyio
# import aiologic
async def work(lock):
async with lock:
await anyio.sleep(1) # do some work
async def main():
lock = anyio.Lock()
async with anyio.create_task_group() as tg:
for _ in range(3):
tg.start_soon(work, lock)
anyio.run(main)
# program will end in 3 seconds
import trio
# import aiologic
async def work(lock):
async with lock:
await trio.sleep(1) # do some work
async def main():
lock = trio.Lock()
async with trio.open_nursery() as nursery:
for _ in range(3):
nursery.start_soon(work, lock)
trio.run(main)
# program will end in 3 seconds
import asyncio
# import aiologic
async def work(lock):
async with lock:
await asyncio.sleep(1) # do some work
async def main():
lock = asyncio.Lock()
async with asyncio.TaskGroup() as tg:
for _ in range(3):
tg.create_task(work(lock))
asyncio.run(main())
# program will end in 3 seconds
And those that are inspired by some libraries but can be used with other ones:
import anyio
import aiologic
async def work(lock, n):
async with lock:
if n > 0:
await work(lock, n - 1) # re-enter
else:
await anyio.sleep(1) # do some work
async def main():
lock = aiologic.RLock()
async with anyio.create_task_group() as tg:
for _ in range(5):
tg.start_soon(work, lock, 3)
anyio.run(main)
# program will end in 5 seconds
import gevent
import gevent.lock
def work(lock, n):
with lock:
if n > 0:
work(lock, n - 1) # re-enter
else:
gevent.sleep(1) # do some work
def main():
lock = gevent.lock.RLock()
greenlets = [gevent.spawn(work, lock, 3) for _ in range(5)]
gevent.joinall(greenlets)
main()
# program will end in 5 seconds
Meanwhile, one of the unique features of aiologic is that primitives can be used by anyone at the same time. For example, you can limit access to a shared resource using a capacity limiter for both gevent and asyncio within the same process. And it will just work, just like magic!
import asyncio
from threading import Thread
import gevent
import aiologic
limiter = aiologic.CapacityLimiter(2)
def green_work():
with limiter:
gevent.sleep(1) # do some work
async def async_work():
async with limiter:
await asyncio.sleep(1) # do some work
def green_main():
greenlets = [gevent.spawn(green_work) for _ in range(7)]
gevent.joinall(greenlets)
async def async_main():
tasks = [asyncio.create_task(async_work()) for _ in range(7)]
await asyncio.gather(*tasks)
Thread(target=green_main).start()
Thread(target=asyncio.run, args=[async_main()]).start()
# program will end in 7 seconds
However, because of their two-faced nature, primitives do not offer API-level compatibility. Methods are prefixed:
with
green_for “green” libraries (without async/await syntax)with
async_for “async” libraries (with async/await syntax)
And the exception is the wait methods:
primitive.wait()for “green” libraries (without async/await syntax)await primitivefor “async” libraries (with async/await syntax)
Note
Despite their name, methods prefixed with green_ support not only
greenlets but also threads. They are called so because they switch to a hub
when it is detected in the current thread. Unless you use eventlet or gevent, they behave like “sync” methods!
The aiologic library aims to be the best locking library as far as it can be. Harder than just thread-safe asyncio primitives. Better than AnyIO primitives. Faster than Curio’s universal synchronization. Stronger than separate solutions.
Features
There are many features common to the entire library. Below is a brief description of just a few of them. If you want to know more, please read the rest of the documentation.
Versatility
Every primitive can be used as:
Single-library: you can use it with a single library, like a standard primitive or native to a third-party library (but better?).
Multi-library/single-threaded: you can use it with multiple libraries combined in some tricky way in a single thread (such as the asyncio hub in eventlet, asyncio-gevent, or trio-asyncio).
Multi-library/multi-threaded: you can use it with multiple libraries running in different threads (even with multiple event loops!).
And also every primitive is:
Async-aware: it is designed to support asynchronous libraries (there is async/await!).
Thread-aware: it is designed to support threads (regardless of the interface used!).
Greenlet-aware: it is designed to support greenlet-based libraries (both with and without monkey patching!).
But the versatility does not end there.
Safety
Unless explicitly stated otherwise, everything in aiologic is:
Thread-safe: you can freely call the same functions and methods in different threads (even with free-threading!).
Coroutine-safe: you can freely call the same functions and methods in different tasks within the same thread (even with greenlets!).
Cancellation-safe: you can freely cancel any blocking call at any point in time without the risk of data loss or other unpleasant things (but this does not mean that you will not lose your place in the waiting queue — a note for those who came from Tokio).
Neither standard nor native primitives have all three guarantees.
Reentrancy
Let us give the following definitions:
A reentrant primitive is a primitive that can be safely reused (reacquired) by the same execution unit after it has already been used (acquired). For example,
threading.Lockis not reentrant because callingacquire()twice will result in a deadlock, butthreading.RLockis reentrant.A reentrant function, also known as an async-signal-safe function, is a function that can be safely called from inside a signal handler or destructor (which can be called after any bytecode instruction). For example,
queue.SimpleQueue.put()is reentrant, but only when implemented at the C level, and only that one.A signal-safe primitive is a primitive whose functions are all reentrant. For example, no primitive from the
threadingmodule (exceptthreading.Lock) is signal-safe, because attempting to use a primitive while interrupted in any of its methods can lead to a deadlock or broken behavior, even for non-blocking calls, even withthreading.RLock.
Due to its design (lockless, lock-free, thread-safe, etc.), aiologic boasts
both reentrant and signal-safe primitives. You may find that
aiologic.RLock is a reentrant lock, and
aiologic.RCapacityLimiter is, when compared to standard primitives, a
reentrant semaphore. And what about signal-safety…
The following primitives work as expected in conditions requiring signal-safety:
Events: all (including low-level)
Semaphores: unbounded only (both counting and binary)
Queues: simple only (both FIFO and LIFO)
Flags (nothing to say)
Resource guards (but does this make sense?)
All others may behave unexpectedly (for example, you will not be able to put items into a complex queue that is in use, and sometimes you will not even be able to reacquire a reentrant capacity limiter or lock), but they still remain functional inside signal handlers and destructors. In particular, all non-blocking calls remain non-blocking and do not lead to deadlocks or any other undesirable things.
Caution
By default, low-level waiters (via which all of the above-mentioned blocking primitives operate!) are not signal-safe for all libraries except threading, when tasks wait in the same thread (in the case of signal handlers, in the main thread). This is because when using primitives in a single thread, fast local ways of waking up tasks are used, which are not thread-safe and certainly not signal-safe.
If you want to use signal handlers or destructors to wake up, for example,
asyncio tasks, you can apply aiologic.lowlevel.enable_signal_safety()
to your function or code block:
import asyncio
import signal
import aiologic
async def main():
signalled = aiologic.Event()
@aiologic.lowlevel.enable_signal_safety
def handler(signum, frame):
signalled.set()
# set the signal handler and a 1-second alarm
signal.signal(signal.SIGALRM, handler)
signal.alarm(1)
print("before")
await signalled # waits for the alarm
print("after")
asyncio.run(main())
# program will end in 1 second