Decouple Time: The Reactive Principles, Explained
Process asynchronously to avoid coordination and waiting
Among the four tenets of the Reactive Manifesto, the starting point for "going Reactive" is rooted in taking a message-driven approach to system communication–specifically, an asynchronous and non-blocking form of messaging. In the fifth of the Reactive Principles: Decouple Time, we look at how we can use the laws of physics to our advantage in computing, with our guest author Mark Paluch (@mp911de).
In this explanatory series, we look at The Reactive Principles in detail and go deeper into the meaning of things. You can refer to the original document for full context as well as these excerpts and additional discussion.
Strong Coupling vs. Loose Coupling
There are still times when we have to communicate and coordinate our actions. The problem with blocking on resources—for example I/O, but also when calling a different service—is that the caller, including the thread it is executing on, is held hostage waiting for the resource to become available. During this time the calling component (or a part thereof) is unavailable for other requests.
Whenever a computer runs a program, it typically employs multiple steps to finish it. Programs expect a sequence of things that happen in a particular order to proceed only with the next step if the previous one is finished.
Let’s draw an interaction example to understand time coupling of an activity:
For example, we have an application that accepts a request, stores data in a database generating an identifier, and returns a response. Each subsequent step requires that the previous one is completed. We can’t store data before we have processed the incoming request. Likewise, we can’t return a response before we have saved data to the database because we have to wait until the database interaction is complete, as the database should generate an identifier.
A typical implementation using blocking I/O and program controls imposes a strong coupling between system resources and the actual program execution. This is because it is typical for the server component to invoke program code using a thread. Our program holds on to the thread until either the program sequence fails or until it completes.
The blocking nature of the used API imposes a tight time coupling. We might not realize that at first sight. Using blocking APIs naturally leads to the fact that we need to wait until the previous call has been completed. Also, we continue as soon as the program is able to resume.
Blocking API usage is a significant driver for scalability constraints as it exclusively utilizes system resources by holding on to Threads. Kernel threads are expensive as they require memory. In addition, each thread gets assigned CPU time and having more threads than CPU cores adds switching overhead. Therefore, you can have only so many Threads.
To loosen coupling between each activity step, it’s necessary to have utilities to express activities and their dependencies. These can be either explicit or implicit.
Activities as Series of Events
If we revisit our activity model, we recognize that each activity can be represented as a series of events. Request – Database interaction – Response. Each event has a happens-after relationship with its predecessor. We could say,
the database interaction happens after the request
the response happens after the database interaction.
With this model in mind, we can decouple the activity flow from an imperative, strongly coupled execution model towards an event-driven, loosely coupled execution model.
Each activity is triggered by an event and triggers another event upon completion:
Trigger: Incoming request
Activity: Process request
Completion: Request processed
Trigger: Request processed
Activity: Database interaction
Completion: Database identifier generated
Trigger: Database identifier generated
Activity: Send response
Completion: Response completed
We have separated the composite program flow into three blocks. While we retain a happens-after relationship, we have broken up the strong coupling that mandates co-execution of each step.
The complexity shifts now towards expressing our sequence of activities in code.
Methods for Temporal Decoupling
We already learned that we can represent our activity as a series of independent building blocks. Depending on the programming model and its capabilities we can express dependencies between each step using specific patterns.
Event-oriented programming makes use of events and observers. The initiator fires an event and an event-handler reacts to the event. An event captures what has happened. It represents a fact from the past thus it is immutable and its payload always reflects past state.
For a chain of events, the activity requires handlers for each step. Also, after the event, the handler needs to emit another event to signal completion or signal error an error state.
While this programming model is lightweight and comes often as part of a language or framework, this model is prone to event loss. In many cases, error handling is not a built-in feature so it must be done typically as part of the event handlers.
Queues and Callbacks
Storing requests on an in-memory queue and completion/error callback allows an event loop to select a task, process it, and continue with the next task. The strength of this model lies in the encapsulation of the actual processing.
With event processing in a central place, this model prevents event loss and allows for centralized error handling. Event loops are typically single-threaded which comes with a very efficient resource utilization. According to the computation resources available in a system, spinning up multiple event loops correlates to scalability and resource utilization.
The flip side is clearly that any blocking activity immediately blocks the EventLoop thread and no further progress can happen. Even worse, threads can starve because the underlying activity tries to await a result that isn’t processed by the EventLoop handler yet.
Chaining multiple callbacks typically leads to hard-to-understand code combined with deep nesting. It also comes with a very primitive failure model. Since callbacks are anonymous, they are not addressable–which means that you have to rely on a dedicated error channel for the failure events for others to subscribe to, but the non-addressability gives you no way to actually manage the failed component/callback.
Next, we'll discuss how switching to a higher abstraction model like Futures/Promises allows us to address this shortcoming.
Futures and Promises
Futures/Promises are an improved variant of Queues and Callbacks. A typical Future allows external synchronization of an activity that will complete eventually. Therefore, we’re going to focus on promises that allow chaining of multiple activities.
A Promise allows registering either a callback or a function that is called once the Promise completes. Representing a chain of activities requires merely registering a promise callback that invokes the next step returning ideally another Promise. This pattern is also known as Promise composition.
Futures/Promises initiate their work directly. Their result typically remains at the same multiplicity – a single result or a collection of results. If something goes wrong, the Promise cannot be easily retried. Therefore, we can use Reactive Sequences.
Reactive Sequences, provided by Reactive composition libraries such as those in the Reactive Streams initiative, take Promise composition to the next level. While a Promise is directly related to a result handle, a reactive sequence is a recipe for a piece of work.
Reactive sequences treat data as streams by introducing the notion of a data flow. Input data flows through a series of operators until its result is emitted. Think of input as the incoming request from the example above and the output as response. Operators differentiate the individual work steps into stages of filtering, mapping, collecting results or error-handling operators.
All of the methods that we’ve heard so far require a significant change in the programming model. Either code needs to use a different method to process a step or to chain multiple steps together. These are quite invasive and the actual code looks entirely different from how it would look like in its imperative form.
Coroutines are an approach to retain the imperative form of code while the actual execution follows a continuation model. The strength is clearly that code retains an imperative form. Coroutines typically require additional keywords to demarcate which parts can be asynchronous/suspendable. Then the execution layer can orchestrate the actual execution and decouple the program flow.
Temporal decoupling is all about splitting activities into multiple steps. The main takeaway is that a composite sequence of activities requires decomposition into its individual activities that comes ideally with a categorization into blocking and non-blocking activities.
The actual programming models are just tools that allow the composition of individual steps depending on the programming language and requirements.
Allowing an external scheduler to react to the availability and processing capabilities of resources is key to unlock scalability.