Despite the fact that most of those who study Node.js know JavaScript to some extent and have experience using it in the context of browsers, when discussing practical issues, many find it difficult to understand the work of the standard library and the mechanisms for ensuring asynchronous execution of code containing many callbacks. Also often there is a misunderstanding. I will try to briefly describe the order of work of the event loop in Node.js and tell you what moments you should pay attention to when writing high-quality asynchronous code. I think that the article will be useful to those who are engaged in writing productive frameworks for browsers.
Lyrical digression: The cycle of events underlying Node.js
')
As it has already been written many times, Node.js is based on the event loop implemented by the
libev library. At each turn of the cycle, the following happens: first of all, the functions installed on the previous turn of the loop are executed using process.nextTick (). Next comes the processing of libev events, in particular, timer events. At last,
libeio is polled to complete I / O operations and execute callbacks set for them. If during the passage of the loop it turned out that no function was set using process.nextTick (), there is not a single timer and request queue in libev and libeio are empty, then the node terminates. If you want to know more about how the event loop works, please skip over the presentation
www.slideshare.net/jacekbecela/introduction-to-nodejs .
Thus, if the server contains little logic and is mainly engaged in low-cost processing of incoming data, the cycle is performed very often. However, if data processing takes a long time, then it is advisable to divide this process into separate parts, between which to return control to the event loop in order to be able to start servicing a new connection or processing asynchronously read from disk data. Consider an example of an HTTP server: when accessing it, it reads from the current folder a file with the name corresponding to the query string and returns a certain hash sum from its contents.
Synchronous version of the test server
The example server contains a synchronous reading of a file from disk, which blocks the execution until completion of reading, and then calculating the value of two functions that can be long performed with a large file size. Moreover, if the reading takes T
read seconds, and calculating the sum of T
calc seconds, then such a blocking server can serve less than 1 / (T
read + T
calc ) requests per second.
How can we improve our server by allowing it to handle more connections? First of all, use non-blocking file reading.Asynchronous file reading and attempt to use callbacks
> Due to the use of asynchronous reading, we can speed up the processing of each request due to the fact that during calculations in the background mode, the file will be read for another request. <strike Thus, the processing time will be min (T
read , T
calc ), and not (T
read + T
calc ), as in the case of a synchronous server. Thus, if two requests are received in the synchronous case, the time of their maintenance will be T
read1 + T
calc1 + T
read2 + T
calc2 , and with asynchronous reading it can reach T
read1 + T
read2 + min (T
calc1 , T
calc2 ).
This is already good. But what to do if the file processing time is much longer than the file reading time and besides it fluctuates strongly? In this case, during the calculation of the amount for one file, several other smaller files may have time to read, which will then be quickly processed. In addition, in this example, clients will receive the result in almost the same order in which requests were sent to the server. However, it is logical to want to return the result earlier to those customers who request smaller files or files that require less time for processing. To do this, when using a long chain of nested processing functions, you must somehow after calculating func1 () return control to the main thread, and on the next loop of the loop, calculate func2 () and return the result to the client. Due to this, in the interval between the calculation of func1 () and func2 () for one request, a new connection can be accepted and a task for reading another file can be created, or a smaller file already read can be processed.
What do newbies get to Node.js in this case (in fact, they should be called newbies to javascript because it concerns the use of a language in any of the common javascript VMs)?
Since the asynchronous I / O functions from the standard library return execution to the main thread immediately after the call, many consider that it is enough to write a function that accepts callback, and it will provide a break in the main execution thread at the place of its call.
What will happen in reality? No magic, of course, no. The only difference will be that we replaced the purely imperative sum calculation code with a code with two nested callback functions that will consistently call each other and slightly increase the calculation time of the sums due to unnecessary function calls, which ultimately worsens our server’s performance. .
Asynchronous file reading and proper asynchronous processing
In order to transfer control to the main thread of execution and at the same time set the task of further processing the sum after the calculation of func1 (), you can use the old proven tool available in JavaScript: setTimeout (fn, 0). This function would be worth using if we programmed for browsers. But, as I already wrote above, in Node.js there is a function process.nextTick (fn), which is more effective and the function transferred to it will be executed guaranteed earlier than the functions installed with the help of timers or being event handlers from sockets or the file system. Thus, the server code readFile-and-sync-chain.js can be rewritten as follows:
This result is the final version of the server, which performs all operations asynchronously and contains the minimum number of code blocks that block the event loop.
Performance comparison of options considered
All that is said above, for the most part reasoning about the correct architecture. In fact, the performance of one or another variant may depend on the size of the file being read and the time it is processed, on the nonlinearity of the dependence of the processing on the file size and on how many requests the server processes. Nevertheless, tests have shown that in any case, using solutions that are more correct from the point of view of the architecture even in the worst case does not slow down the server by more than 10%.
For comparison, files ranging in size from 128 bytes to 1 MB were used and the server was loaded using Apache Bench:
ab2 -n 1000 -c 100 http://127.0.0.1:8124/filename
The results are shown in the graphs:

As you can see, the use of asynchronous read actually improves server performance with large file sizes. This is due to the fact that when reading small files, the overhead of creating various structures for asynchronous reading exceeds the time to read files, especially since they are read from the file system cache. As expected, using normal nested functions does not improve performance. But the use of asynchronous callbacks in the step-by-step processing of the file brought its results, which are clearly visible for small file sizes.
However, it should be noted that the test results strongly depend on the size of the requested file, both for better and for worse, as well as for the variety of requests received by the server. I hope I have time for extensive testing with several files and a different number of requests for them. Also, I intend not to consider the problems associated with the incomplete implementation of asynchronous I / O in some operating systems and the restriction on the number of threads used by libeio to emulate asynchronous operations for such systems.
PS Thanks nodejs-newbies from
forum.nodejs.ru for raising this issue.