I’m currently adopting a program to use FT Python and went down a bit of a rabbit hole. The code bounds a shared resource with a semaphore, acquired from inside an asyncio event loop. With free threading, the code moves to a one-loop-per-thread shape. With each loop attempting to access the same resource, I can’t find a way to express “at most N of these process-wide” with what currently exists in the standard library.
From my understanding, there are two existing primitives but neither address this use case:
threading.Semaphore has no __aenter__ and its acquire() blocks the calling thread.
asyncio.Semaphore is awaitable, but each instance binds only to a single event loop.
Notably, this primitive is also already self identified as not thread safe.
Would it make sense for us to either extend one of the existing semaphore implementations to simultaneously handle threads & asyncio? Would it make sense to introduce a new threading primitive that specifically handles this combination? Alternatively, is this a concept best expressed outside of the standard library? I just added an implementation of a “process semaphore” to ft_utils.
I don’t think this fits neatly into asyncio or threading as is, but I’ve found myself needing something similar before and just implemented it., though it’s not quite the same case.
I would think anything designed for multi-eventloop use probably needs a dedicated home (even if in the standard library, it probably shouldn’t be in the top-level asyncio namespace), as the behavior and intended use cases differ significantly from what already exists.
As for implementation, I noticed a couple comments about optimizing attribute access, so it may be worth you taking the time to see if using a different underlying waiting method is better or worse here as well.
I used a concurrent.futures.Future + asyncio.wrap_future[1]. Because of when each of those are used, this never hits the path in wrap_future that needs to use loop.call_threadsafe.
though I haven’t bothered with attribute lookup optimization at the moment, and wrap_future does slightly more than this use case needs, so you could inline only the minimum needed behavior. ↩︎
Sorry, that last bit about not hitting the asyncio loop threadsafe methods isn’t accurate, this can still hit those on waking tasks currently waiting on another thread via the callback wrap_future creates, which slipped my mind at first.
I wonder if this would benefit from using a ThreadPoolExecutor to acquire the global semaphore behind the scenes, just like we do for some system APIs that block the current thread and have no non-blocking variant (getaddrinfo and getnameinfo). It could be as simple as loop.run_in_executor(global_semaphore.acquire).
I don’t think using an executor would be appropriate here. I can think of a way to create a deadlock with otherwise unrelated locking with that approach unless it’s an executor per eventloop per lock, which is a lot of potential extra overhead.
It is still relatively niche for people to be running multiple event loops, so I think leaving this to 3rd party is fine right now, but this is on a list of things for concurrency that I would like to see a “relatively good” implementation in the standard library eventually.
I think this is also rare enough to need with “best practices”, that it’s not worth eking out every drop of performance for. At the point where you have both threading and eventloops per thread, you can put a worker for any specific shared resource on its own thread and post work to it via queues or channels to avoid extra cross-thread synchronization in many cases.
For reference, I have exactly 2 uses of the semaphore I linked above in my own code, it only exists in that library to prevent anyone else with a similar enough use case from needing to figure out an effective option for putting together blocks in the standard library.