Threading models in Coroutines and Android SQLite API
Implementing suspending transactions in Room
Room 2.1 now lets you use Kotlin Coroutines by defining suspending DAO functions. Coroutines are great for performing asynchronous operations. They allow you to write natural, sequential code that operates on expensive resources like the database, without having to explicitly pass tasks, results, and errors between threads. Room’s Coroutine support gives you the benefits of concurrency scoping, lifecycle, and nesting in your database operations.
While developing Coroutines support in Room we ended up encountering some unforeseen problems between the threading models in Coroutines and Android’s SQL API. Read on to find out more about these problems, our solutions, and implementation.
Take a look at the following snippet of code, which might seem safe, but is actually broken 😮:
Android’s SQLite transactions are thread confined
The issue is that Android’s SQLite transactions are thread confined. When a query is executed within an ongoing transaction on the current thread, then it is considered part of that transaction and can proceed. If the query was instead executed on a different thread, then it is not considered part of that transaction and will block until the transaction on the other thread ends. That is how the
endTransaction API allows atomicity. It’s a reasonable API when database tasks run entirely on one thread; however, this a problem for coroutines because they are not bound to any particular thread. There is no guarantee that the thread continuing a coroutine after it was suspended is the same as the thread that executed before the suspension point.
A simple implementation
To work around Android’s SQLite limitation we needed an API similar to
runInTransaction that accepts a suspending block. A naive implementation of this API can be as simple as using a single thread dispatcher:
The above implementation is a start, but it quickly falls apart when a different dispatcher is used within the suspending block:
By accepting a suspend block there is a possibility that a child coroutine will get launched using a different dispatcher, which may execute a database operation in an unexpected, different thread. Therefore, an appropriate implementation should allow the usage of the standard coroutine builders, such as
withContext. In practice, it is only the database operations that need to be dispatched to a single transaction thread.
To accomplish this, we’ve built the
withTransaction API, which mimics the
withContext API but provides a coroutine context specifically built for safe Room transactions. This allows you to write code such as:
As we dive into the implementation of Room’s
withTransaction API, let’s review some of the coroutine concepts that have been mentioned. A
CoroutineContext holds information a Coroutine needs to dispatch work. It carries the current
Job, and maybe some additional data; but it can also be extended to contain more elements. One important feature of a
CoroutineContext is that they are inherited by child coroutines within the same coroutine scope, such as the scope in the
withContext block. This mechanism allows for child coroutines to continue using the same dispatcher, or for them to get cancelled when the parent coroutine Job is cancelled. In essence, Room’s suspending transaction API creates a specialized coroutine context for performing database operations in a transaction scope.
There are three key elements in the context created by the
- A single threaded dispatcher used to perform database operations.
- A context element that helps DAO functions identify that they are in a transaction.
ThreadContextElementthat marks dispatched threads used during the transaction coroutine.
CoroutineDispatcher dictates in which thread a coroutine will execute. For example,
Dispatchers.IO uses a shared pool of threads recommended for off-loading blocking operations, while
Dispatchers.Main will execute coroutines in Android’s main thread. The transaction dispatcher created by Room is able to dispatch to a single thread taken from Room’s Executor — it is not using some arbitrary new thread. This is important since the executor is configurable by the user and is instrumentable for tests. At the start of a transaction, Room will take ownership of one of the threads in the executor until the transaction is completed. Database operations performed during the transaction will be dispatched into the transaction thread, even if the dispatcher was changed for a child coroutine.
Acquiring a transaction thread is not a blocking operation — it shouldn’t be, since if no threads are available, we should suspend and yield the caller so that other coroutines can proceed. It also involves enqueuing a runnable and waiting for it to actually execute, which is an indicator that a thread has become available. The function
suspendCancellableCoroutine helps us bridge between a callback-based API and coroutines. In this case, our callback for when a thread is available is the execution of the enqueued runnable. Once our runnable executes we use
runBlocking to start an event loop that takes ownership of the thread. The dispatcher created by
runBlocking is then extracted out and used for dispatching blocks into the acquired thread. Additionally, a
Job is used to suspend and hold the thread until the transaction is done. Note that precautions are taken for when the coroutine is cancelled or is unable to acquire a thread. The snippet of code that acquires a transaction thread is as follows:
Transaction Context Element
With the dispatcher in hand, we can then create a transaction element that we can add into our context and that has a reference to the dispatcher. This enables us to re-route DAO functions into the right thread if they are called from within the transaction scope. Our transaction element is as follows:
release are used to keep track of nested transactions. Since the counterpart methods
endTransaction allowed for nested invocation, we ideally want to allow the same behaviour; but we only need to release the transaction thread when the outermost transaction finishes. Usages of these functions are shown later in the implementation of
Transaction Thread Marker
The last key element mentioned earlier needed to create the transaction context was a
ThreadContextElement. This element within a
CoroutineContext is analogous to a
ThreadLocal that tracks if there is an ongoing transaction in the thread. The element is backed by an actual
ThreadLocal; it works by setting a value to the
ThreadLocal for each thread used by the dispatcher to execute the coroutines block. Once the thread is done executing the block, the value is reset. For our use-case the value is meaningless; in Room it’s just a matter of whether a value is present or not. If the coroutine context could access the
ThreadLocal<SQLiteSession> that exists in the platform, we could just dispatch begin/ends to it from any thread the coroutine is on. We can’t, so we have to block the thread until the transaction completes; but we still need to keep track of which transaction — and therefore which thread that owns the platform transaction — each blocking database method should run on.
ThreadContextElement used by Room’s
withTransaction API identifies blocking database functions. Blocking functions in Room, including those in generated DAOs, now have special handling when they are invoked within a transaction coroutine to ensure they aren’t being run on a different dispatcher. This makes it possible to mix and match blocking functions with suspending functions within a
withTransaction block for cases where your DAO still has a mix of both types of functions. By adding the
ThreadContextElement to the coroutine context and having access to it from all DAO functions, we can then verify that blocking functions are in the correct scope. If they are not, then we throw an error instead of causing a deadlock. In the future we plan to reroute blocking functions into the transaction thread as well.
The combination of the three elements is what creates our transaction context:
Transaction API Implementation
With the transaction context created, we can now finally provide a safe API for performing database transactions in coroutines. What is left to do is combine this context with the usual begin / end transaction pattern:
Android’s SQLite thread restrictions are reasonable and were designed in a time where Kotlin didn’t exist. Meanwhile, coroutines introduce a new paradigm that shifts some of the thought process of traditional Java concurrency. Lifting the thread restrictions from Android is not a viable solution since we want to provide a backwards compatible solution. The combinations of the things mentioned above ultimately lead us into being creative in a solution that goes along with coroutines-fluent API.