Как работает event loop в Node.js

Первый основной тезис Node.js заключается в том, что операции ввода/вывода обходятся дорого:

Вообще, это самое большое расточительство в современном программировании — ждать, пока завершится операция ввода/вывода(I/O). Ниже я приведу список различных подходов, так или иначе влияющих на производительность (на основе статьи Сэма Рашинга (Sam Rushing):

Второй основной тезис заключается в том, что обработка каждого нового подключения в отдельном треде приводит к большим расходам памяти. К примеру, Apache, работая с тредами, потребляет гораздо больше памяти, чем Nginx.

Apache использует многопоточность. В зависимости от настроек, для обработки нового запроса запускается новый тред или поднимется еще один процесс. Вы можете наблюдать за ростом количества потребляемой памяти, увеличивающимся параллельно с ростом количества одновременных подключений. Nginx и Node.js, напротив, не используют многопоточность, потому как потоки и процессы обходятся бóльшими расходами памяти. И Node.js и Nginx построены на основе событий, и используют только один поток. Однопоточность избавляет их от расходов на обработку тысяч потоков или процессов.

Node.js использует один поток для выполнения всего вашего кода

Это действительно так. Node.js работает в одном потоке. Вы ничего не можете выполнить параллельно. Пример ниже заблокирует весь сервер на одну секунду:

while(new Date().getTime() < now + 1000) {  
   // do nothing  
}

Пока этот код выполняется, Node.js не будет реагировать на любые запросы от клиентов, потому что Node.js использует один поток для выполнения всего вашего кода. То же самое произойдет, если вы выполните полный тяжелых вычислений код. Например, изменяющий размер изображения, точно так же заблокирует обработку других запросов.

…тем не менее, все, кроме вашего кода, запускается параллельно

Нет ни одного способа выполнить код параллельно в рамках обработки запроса. Однако, для того, чтобы избежать блокировки сервера, все операции ввода-вывода асинхронны, и используют события для взаимодействия с сервером:

c.query(  
   'SELECT SLEEP(20);',  
   function (err, results, fields) {  
     if (err) {  
       throw err;  
     }  
     res.writeHead(200, {'Content-Type': 'text/html'});  
     res.end('<html><head><title>Hello</title></head><body><h1>Return from
async DB query</h1></body></html
>');  
     c.end();  
    }  
); 

Если выполнить этот код обрабатывая один из поступивших запросов, то сервер приступит к обработке следующих запросов не дожидаясь ответа базы данных.

Почему такой подход хорош? Когда стоит использовать асинхронность вместо синхронности?

Синхронные операции хороши своей простотой. Особенно, по сравнению с тредами, где проблема с параллельным доступом к ресурсам так и хочет свести ваш код к состоянию «WTF?!».

В Node.js вам не нужно волноваться о том, что происходит на бэкэнде: достаточно просто использовать коллбеки при работе с I/O. Это даст вам уверенность в том, что ваш код не заблокирует работу сервера. В отличии от использования дополнительных тредов или процессов, это хорошее решение с точки зрения производительности.

Асинхронный I/O — это замечательно, потому что операции ввода/вывода гораздо затратнее, чем простое исполнение кода. К тому же, программа должна заниматься более полезными делами, чем постоянно ждать I/O.

Событийный цикл — это «сущность которая перехватывает и обрабатывает внешние события и конвертирует их в функции обратного вызова». То есть, вызовы I/O — это определенные точки, в которых Node.js может переключаться от одного запроса к другому. При обращении к I/O, ваш код сохраняет коллбек, и возвращает контроль обратно, в среду выполнения Node.js. Сохраненный коллбек будет вызван позднее, когда все необходимые данные будут получены.

Конечно, на стороне бэкенда, используются процессы и потоки для доступа к базам данных и для выполнения различных процессов. Однако, все это не присутствует явно в вашем коде, так что вам не нужно будет об этом беспокоиться. Единственное, о чем вам стоит помнить, так это то, что операции I/O, например, с базой данных или с другими процессами будут асинхронными с точки зрения каждого запроса, и результаты работы этих процессов будут возвращаться через событийный цикл обратно в код. В сравнении с моделью работы Apache, здесь гораздо меньше тредов, как и потраченных на них ресурсов, потому что здесь нет необходимости создавать новый тред для каждого запроса. Просто, когда вам действительно необходимо что-то получить или выполнить что-то в параллельном потоке, то управлять этим будет Node.js

Node.js предполагает, быстрый ответ на входящие запросы, так что, такой подход стоит применять не только к I/O: сложные расчеты, загружающие процессор должны быть вынесены в отдельные процессы, с которыми вы сможете взаимодействовать с помощью событий. Либо тяжелые операции можно вынести из основного потока, используя такие абстракции, как WebWorkers. Это подразумевает, что вы не сможете распараллелить работу вашего кода, без использования отдельного фонового потока, с которым вы сможете взаимодействовать с помощью событий. В основном, все объекты, которые могут бросать события (к примеру, инстансы EventEmitter) поддерживают асинхронное взаимодействие на основе коллбеков. Вы можете использовать это в работе с блокирующим кодом. Взаимодействие можно организовать с помощью файлов, сокетов, или дочерних процессов, каждый из которых будет представлен в Node.js инстансом EventEmitter. Поддержку многоядерности можно реализовать также. Советую посмотреть node-http-proxy.

Внутренняя реализация

Внутри, для работы событийного цикла, nodejs использует libev. В приложение к нему Node.js использует libeio, который использует очереди потоков для реализации асинхронного ввода/вывода. Узнать об этом больше вы можете в документации к libev.

Так как же мы реализуем асинхронность в Node.js?

Тим Касвел (Tim Caswell) определил следующие паттерны в своей потрясающей презентации:

Во всем этом нет ничего сложного!