Search code examples
pythonlockingpython-asynciopython-multithreading

Can I use an blocking lock on an asynchronous coroutine?


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)?


Solution

  • 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.