Search code examples
node.jshttphttpconnection

How multiple simultaneous requests are handled in Node.js when response is async?


I can imagine situation where 100 requests come to single Node.js server. Each of them require some DB interactions, which is implemented some natively async code - using task queue or at least microtask queue (e.g. DB driver interface is promisified).

How does Node.js return response when request handler stopped being sync? What happens to connection from api/web client where these 100 requests from description originated?


Solution

  • This feature is available at the OS level and is called (funnily enough) asynchronous I/O or non-blocking I/O (Windows also calls/called it overlapped I/O).

    At the lowest level, in C (C#/Swift), the operating system provides an API to keep track of requests and responses. There are various APIs available depending on the OS you're on and Node.js uses libuv to automatically select the best available API at compile time but for the sake of understanding how asynchronous API works let's look at the API that is available to all platforms: the select() system call.

    The select() function looks something like this:

    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, time *timeout);
    

    The fd_set data structure is a set/list of file descriptors that you are interested in watching for I/O activity. And remember, in POSIX sockets are also file descriptors. The way you use this API is as follows:

    // Pseudocode:
    
    // Say you just sent a request to a mysql database and also sent a http
    // request to google maps. You are waiting for data to come from both.
    // Instead of calling `read()` which would block the thread you add
    // the sockets to the read set:
    
    add mysql_socket to readfds
    add maps_socket to readfds
    
    // Now you have nothing else to do so you are free to wait for network
    // I/O. Great, call select:
    
    select(2, &readfds, NULL, NULL, NULL);
    
    // Select is a blocking call. Yes, non-blocking I/O involves calling a
    // blocking function. Yes it sounds ironic but the main difference is
    // that we are not blocking waiting for each individual I/O activity,
    // we are waiting for ALL of them
    
    // At some point select returns. This is where we check which request
    // matches the response:
    
    check readfds if mysql_socket is set {
        then call mysql_handler_callback()
    }
    
    check readfds if maps_socket is set {
        then call maps_handler_callback()
    }
    
    go to beginning of loop
    

    So basically the answer to your question is we check a data structure what socket/file just triggered an I/O activity and execute the appropriate code.

    You no doubt can easily spot how to generalize this code pattern: instead of manually setting and checking the file descriptors you can keep all pending async requests and callbacks in a list or array and loop through it before and after the select(). This is in fact what Node.js (and javascript in general) does. And it is this list of callbacks/file-descriptors that is sometimes called the event queue - it is not a queue per-se, just a collection of things you are waiting to execute.

    The select() function also has a timeout parameter at the end which can be used to implement setTimeout() and setInterval() and in browsers process GUI events so that we can run code while waiting for I/O. Because remember, select is blocking - we can only run other code if select returns. With careful management of timers we can calculate the appropriate value to pass as the timeout to select.

    The fd_set data structure is not actually a linked list. In older implementations it is a bitfield. More modern implementation can improve on the bitfield as long as it complies with the API. But this partly explains why there is so many competing async API like poll, epoll, kqueue etc. They were created to overcome the limitations of select. Different APIs keep track of the file descriptors differently, some use linked lists, some hash tables, some catering for scalability (being able to listen to tens of thousands of sockets) and some catering for speed and most try to do both better than the others. Whatever they use, in the end what is used to store the request is just a data structure that keeps tracks of file descriptors.