We still managed to reach the third part and got to the most interesting part - the organization of asynchronous calculations.
In the last two articles, we looked at the abstraction of concurrently running code and the cooperative execution of task handlers.
Now let's see how you can control the flow of execution (control flow) in the case of processing asynchronous tasks.
Synchronous operations are operations in which we get the result as a result of blocking the thread of execution. For simple computational operations (addition / multiplication of numbers) is the only way to perform them, for I / O operations, one of them, while we say, for example, “try to read something from a file in 100ms” , and if for reading there is nothing - the execution thread will be blocked for these 100ms.
In some cases, this is permissible (for example, if we make a simple console application, or some utility whose purpose is to work out everything), but in some cases it is not. For example, if we get stuck in the stream in which the UI is processed, our application will hang. There is no need to go far for examples - if the javascript on the site makes while(true);
, then any other event handlers for the page will stop being called and have to close it. The same thing, if you start calculating something under Android in UI event handlers (whose code is called in the UI thread), this will cause the “application does not respond, close?” Window (similar windows are called by watchdog timer). which is reset when execution returns to the UI system).
Asynchronous operations are operations in which we ask to perform a certain operation and can in some way monitor the process / result of its execution. When it will be completed is unknown, but we can continue to do other things.
An event loop is an infinite loop that takes events from a queue and somehow processes them. And in some intervals - it looks, if there have been any IO-events , or if any timers have expired - then it adds an event about it to the queue in order to process it later.
Let's go back to the browser example. The whole page works in one event loop , loaded with a javascript page is added to the queue to be executed. If any UI events occur on the page (click on the button, move the mouse, etc.), the code of their handlers is added to the queue . Handlers are executed sequentially, there is no parallelism, while any code is running - everyone else is waiting. If any code calls any special function, like setTimeout(function() { alert(42) }, 5000)
, it will create a timer somewhere outside the loop, after which the function code with alert(42)
will be added to the queue alert(42)
.
Chip: if someone in the queue before the execution of the handler will calculate something for a long time, then the timer handler will obviously execute later than in five seconds.
The second trick: even if we ask, for example, 1 millisecond of waiting, it can go much more, because the implementation of the event loop can look: “yeah, the queue is empty, the nearest timer after 1ms, we will wait for IO events 1ms”, and when we call select, the implementation of the operating system can look: “yeah, there are no events like that, for your time all the same, I am making a context switch, while there is an opportunity ”, and there all the other streams have used all the time available to them and we have flown by.
Low-level asynchronous IO events are implemented using select variations. We have some file descriptors (which can be either files, or network sockets, or something else (in fact, in Linux, anything can be a file (or vice versa, a file can be anything))).
And we can call some synchronous function, passing it a lot of descriptors, from which we expect input, or we want to write something, which will block the stream until:
As a result of this procedure, we get a set of files ready for reading / writing.
The easiest way to get the results of an asynchronous operation is to do this — when you create it, pass references to the functions that will be called during any progress of the execution / readiness of the result.
This is a rather low-level approach, and often the inability to write functions “in a column” along with the abuse of anonymous functions leads to a “callback hell” (a situation where we have four to ten levels of nesting of functions to handle successive operations):
// function someAsync(a, callback) { anotherAsync(a, function(b) { asyncAgain(b, function(c) { andAgain(b, c, function(d) { lastAsync(d, callback); }); }); }); } // function someAsync2(a, callback) { var b; anotherAsync(a, handleAnother); function handleAnother(_b) { b = _b; asyncAgain(b, handleAgain); } function handleAgain(c) { andAgain(b, c, handleAnd); } function handleAnd(d) { lastAsync(d, callback); } }
We, programmers, like to abstract and generalize to hide various complexities / routines. Therefore, there is, among other things, an abstraction over asynchronous computing.
What is “calculation” ? This is the process of converting A to B. We will write synchronous calculations as A → B.
What is an "asynchronous value" ? This promise will provide us with some future T value (which may be a successful outcome, or an error). We will designate it as Async [T] .
Then the “asynchronous operation” will look like A → Async[T]
, where A is any arguments necessary to start the operation (for example, this may be the URL to which we want to make a GET request).
How to work with Async [T] ? Let him have a run method that accepts a callback that will be called when the data becomes available: Async[T].run : (T → ()) → ()
(accepts a function that accepts T , does not return anything).
Well, now add the most important thing - the ability to continue an asynchronous operation. If we have Async [A] , then obviously, when A becomes available, we can create Async [B] and wait for its result. The function for such a continuation will look like this:
Async[A].then : (A → Async[B]) → Async[B]
Those. if we can create Async [B] from a certain A , as well as we have Async [A] , who will ever provide us with A , there is no problem to provide Async [B] right away, because B we can still get through some that time and in the end everything will converge.
function Async(starter) { this.run = function(callback) { starter(callback); }; var runParent = this.run; this.then = function(f) { return new Async(function(callback) { runParent(function(x) { f(x).run(callback); }); }); }; }
And then that our synthetic example becomes higher:
function someAsync(a) { return anotherAsync(a).then(function(b) { return asyncAgain(b).then(function(c) { return andAgain(b, c); }).then(function(d) { return lastAsync(d); }); }); }
But more interesting. Explicitly distinguish between the type of asynchronous value and error / result. Now we always have Async [E + R] (plus this is a type-sum, one of two). And then we can, for example, introduce the Async[E + R].success : (R → Async[E + N]) → Async[E + N]
. Note that E is left untouched.
We can only implement this method so that it performs the function passed to it only if it receives a successful result (that is, receives R , not E ) and runs the next asynchronous operation, otherwise the result of the asynchronous operation continues to be "erroneous."
this.success = function(f) { return new Async(function(callback) { runParent(function(x) { if (x.isError()) callback(x); else f(x).run(callback); }); }); };
Now, if we chain asynchronous operations using the success method, we will process only the successful development branch of events, and any error will slip through all the subsequent handlers and go straight to the callback passed to run .
We have just abstracted the flow of execution and introduced exceptions into our abstraction. If you play a little more, you can think of the failure method, which can convert the error to another error, or return a successful result .
There is a standard that describes the Thenable interface. It works almost identically to what was described above, but in Promises / A + there is no concept of starting an asynchronous operation. If we have at the hands of Thenable , then already somewhere something is being done and all we can do is to subscribe to the result of the execution. And there is one then method that accepts two optional functions for processing a successful / failed branch, and not different methods.
Here, too, the taste and color, both approaches have pros and cons.
To use promises, we need to use lambda functions in unbelievable quantities. Which can be quite visually noisy and uncomfortable. Is it possible to do this somehow better?
There is.
We have Korutin who can have a lot of entry points. And this is what we need. Let us have a korutina, which gives out Async [E + R] , and the resulting R is fed inside it, or the exception E is raised. And then Zen begins:
function someAsync*(a) { var b = yield anotherAsync(a), c = yield asyncAgain(b), d = yield andAgain(b, c); yield lastAsync(d); }
Then we need an “executor” of such kindness, which will accept this quorutine, to get exits from it, if they are Asyncs — to execute them, if by other Qorutines — to recursively execute them, considering the result of the last yield .
And async / await is when we yield renaming to await , and before declaring the function, we write async . Well, and sometimes (in the case of Python, for example), you can see asynchronous generators , in which both yield and await are available . Then they behave like the same Korutin, but operations with it become asynchronous, because between the return / acceptance she is waiting for the results of her internal asynchronous operations.
Well, on this my series of articles ends, I hope they were useful to someone and did not confuse even more.
Source: https://habr.com/ru/post/319350/
All Articles