I'm trying to understand what are the problems with using a synchronous lock in an async context. In my code, I've reached a point where multiple coroutines might access the same shared resource. The API to get this resource is synchronous and I don't want to turn it asynchronous right now.
Here's the API, I've designed.
class OAuthManager(abc.ABC):
"""
Represents an OAuth token manager.
The manager main responsibility is to return an oauth access token
and manage it's lifecycle (i.e. refreshing and storage).
"""
@abc.abstractmethod
def get_access_token(self) -> OAuthToken:
"""
Should return the [OAuthToken] used to authorize the client
to access OAuth protected resources.
The implementation should handle refreshing and saving the token,
if necessary.
"""
pass
Essentially, the OAuth manager should be able to always return a token, either from memory, an storage or by refreshing it from the third party API.
I'm making the asynchronous calls by offloading synchronous tasks into background threads, I imagine this won't be a huge problem because the methods are all IO bound.
class AsyncEndpoint:
"""
Transforms an sync endpoint into an async one.
The asynchronous tasks are executed by offloading a synchronous task into
a executor thread. Due to the GIL, this method is only useful with IO
bound tasks.
"""
def __init__(self, endpoint):
self.endpoint = endpoint
def __getattr__(self, name: str) -> Union[Any, Callable]:
attr = getattr(self.endpoint, name)
if not callable(attr):
return attr
async def _wrapper(*args, **kwargs):
return await utils.run_async(attr, *args, **kwargs)
return _wrapper
The methods that are run asynchronously will use the synchronous get_access_token
method and this access token is a shared resource among the coroutines.
I'm trying to use the decorator pattern to add a lock to this method.
class LockOAuthManager(OAuthManager):
"""
Most of the times tokens will be retrieved from memory
which means the lock won't be locked for a long time,
In the worst case, the lock will remain locked for
an full http request period.
But if one thread needed to retrieve the token from the
API, the same would have to happen to the other threads,
so I believe the waiting penalty would happen anyway.
"""
def __init__(self, oauth_manager: OAuthManager):
self.lock = threading.Lock()
self.oauth_manager = oauth_manager
def get_access_token(self) -> OAuthToken:
with self.lock:
return self.oauth_manager.get_access_token()
Surprisingly, I haven't found much information on this topic, which probably points out that I'm doing something wrong, but even if I am, I still wan't to know what are the things that could go wrong.
From what I understand, a blocking lock (i.e. threading.Lock
) holds the thread that is being used to run the coroutine, which means that other coroutines which could use this same thread won't be able to run. An async lock (i.e. asyncio.Lock
) would allow the thread to be given to other tasks.
So my question is:
Will any other concurrency problems occur due to the use of the blocking lock inside the coroutine?
Will other coroutines that don't access the shared resource be affected by the lock (i.e. can they be scheduled on other threads)?
Will any other concurrency problems occur due to the use of the blocking lock inside the coroutine?
That depends on what run_async
does. As I understand your question, the blocking lock is not "inside the coroutine", it's inside an ordinary function, which is itself invoked via the run_async
utility. Since the function is run in a separate thread, a threading.Lock
is the natural way to protect a shared resource.
You don't find much information about this because what you're doing is not how asyncio was designed to be used. If your code consists of blocking calls, you should probably use threads instead of asyncio. The whole point of asyncio is to use async calls under the hood, and achieve concurrency within a single thread using await
to yield execution to others. When used correctly, asyncio improves scalability and eliminates race conditions that otherwise arise from uncontrolled thread switches. In your code you don't get those benefits because you just end up using a thread pool, paying the price for synchronization.