This is the second post in a series on asynchronous programming. The whole series tries to answer a simple question: "What is asynchrony?". In the beginning, when I first started digging into the question, I thought I knew what it is. It turned out that I didn't know the slightest thing about asynchrony. So let's find out!
- Asynchronous programming. Blocking I/O and non-blocking I/O
- Asynchronous programming. Cooperative multitasking
- Asynchronous programming. Await the Future
- Asynchronous programming. Python3.5+
In the last post we discussed how to provide concurrent processing of several requests and that it can be implemented with the help of threads or processes. But there is one more option — cooperative multitasking(aka non-preemptive multitasking).
Here we are saying that the operating system is, of course, cool — there are schedulers/planners, it can handle processes, threads, switch between them, etc. But it still doesn't know how our application works so it will pause thread/process in a random moment(probably not the best one), save the context and switch to the next thread/process. But we as developers know how our application works. We know that we have short periods when some computing operations are performed on the CPU, but most of the time we wait for network I/O, and we know better when to switch between processing individual requests.
From an OS perspective, cooperative multitasking is just one execution thread, but inside it, the application has the power to decide when to switch between processing individual requests/commands. Once some data arrives, the application reads it, parses the request, sends the data to the database for example, and this is a blocking operation, but instead of waiting for a response from the database, it can start processing another request. This is called "cooperation" because all tasks/commands have to cooperate to make the entire planning scheme work. They intersperse with each other but in a single control thread, known as a cooperative scheduler, whose role is to start processes and give them the opportunity to voluntarily take control back.
This is easier than multithreaded multitasking because the developer always knows that when one task is performed, the other is not. Although in a single-processor system a multithreaded application will be executed in an interleaved manner as well, a programmer using threads must still think about errors so that the application doesn't work incorrectly when switching to a multi-core system. But a single-threaded asynchronous system will always be executed in interleaved fashion even on a multi-core system.
The complexity of writing such programs lies in the fact that this process of switching, maintaining the context as such, organizing each task as a sequence of smaller steps performed with interruptions, it all falls on the shoulders of developers. On the other hand, we win in efficiency because there are no unnecessary context switches, like a processor context when switching between threads and processes.
There are two ways to implement cooperative multitasking — callbacks and cooperative threads.
All cooperative operations cause the action to occur sometime in the future and our execution thread should return the result when it is ready. So in order to get the result, we have to register a callback — if the request/operation is successful, it will call one function, if request/operation is not successful, it will call another. Callbacks is an explicit approach — the developer should write programs so that he really doesn't know when the callback function will be called.
It is the most used option because it is explicit and supported by most modern languages. Also, there are Futures or Promises — it's kind of the same thing internally but with more clear API.
The pros and cons:
- It differs from threaded programs and doesn't have their problems;
- Callbacks swallow exceptions;
- The callback becomes confusing and difficult to debug.
The second way is implicit, when developers write a program in such a way that there seems to be no cooperative multitasking. There are different flavors of this approach: user-threads(aka green threads) or coroutines.
Using green threads, we can perform a blocking operation as we have done before and expect the result right away as if it is non-blocking. But there is black magic "under the hood" — the framework or programming language that makes the blocking operation non-blocking and transfers control to some other execution thread but not in the sense of the operating system thread but in the sense of a logical thread (user-level thread). They are executed by a "normal" user process and not by the OS.
In coroutines, you should write programs that add some "checkpoints" where your function can be paused and resumed. Exit can be done by calling other coroutines, which may later return to the point where they are calling the original coroutine. Coroutines are very similar to threads. However, coroutines are cooperative multitasked, while threads tend to be preemptively multitasked. There is no need for synchronization primitives such as mutexes, semaphores, etc. and no need for support from the operating system.
The pros and cons:
- They are controlled at the user-space level, not the OS;
- They feel like synchronous programming;
- Includes all the problems of normal threading programming except switching the context of the CPU.
Within cooperative multitasking, there is always a processing engine that is responsible for all I/O processing. It is called reactor from design template name. The reactor interface says, "Give me a bunch of your sockets and your callbacks, and when that socket is ready for I/O, I will call your callback functions. A reactor job is to react to I/O events by delegating all the processing to the appropriate handler(worker). The handlers perform processing, so there is no need to block on I/O, as long as handlers or callbacks for events are registered to take care of them.
The purpose of the reactor design template is to avoid the common problem of creating a thread for each message, request, and connection. It then receives events from multiple handlers and sequentially distributes them to the corresponding event handlers.
In principle, the standard Reactor allows the application to be run with simultaneous events while maintaining the simplicity of single-thread processing. It usually would use non-blocking synchronous I/O(check out multiplexing in the I/O models).
Those paradigms are underlain the nginx HTTP server, node.js via libuv, Twisted Python and the new asyncio libraries in Python.
But none of these options are really perfect. The combination works best because cooperative multitasking usually wins, especially if your connections hang up for a long time. For example, a web socket is a long-lasting connection. If you allocate a single process or a single thread to handle a single web socket, you significantly limit the number of connections to one backend server at a time. And because the connection will last a long time, it's important to keep many simultaneous connections, while each connection will have little work to do.
The problem with multitasking is that it can only use one processor core. Of course, you can run multiple instances of an application on the same machine (this is not always convenient and has its drawbacks). But it is a good idea to run multiple processes using a reactor/proactor and use cooperative multitasking within each process.
This combination allows, on the one hand, to use all available processor cores in our system and, on the other hand, it will work efficiently inside each core without allocating a lot of resources to handle each individual connection.
The difficulty in writing applications that use cooperative multitasking is that this switching process while maintaining the context as such, falls on the shoulders of poor developers. On the other hand, by using this approach, we get efficiency because there are no unnecessary switches.
A more interesting solution comes from combining the cooperative multitasking with reactor/proactor patterns.
In the next post, we will talk about asynchronous programming itself and how it differs from synchronous programming, about old concepts but on a new level and with new terms.
Daily dose of