19 ноября 2012 в 03:12

NODE.JS + Windows: заглянем внутрь из песочницы

Статья для node.js программистов, понимающих принципы асинхронно-событийного программирования, но не знающих как это устроено изнутри. Если для вас уже недостаточно стандартной картинки с «зацикленными» кружочками и хочется хотя бы взглянуть, что же у цикла событий под капотом, то вам под кат.


Ограничение


Для простоты описания, я решил построить свою статью вокруг простой операции открытия файла, т.е. функции open из модуля fs. Естественно, из-за этого ОЧЕНЬ многое останется за пределами статьи, но придется идти на компромисс между простотой восприятия и техническими подробностями.

require('fs').open("c:\\1.txt", 'r', function onOpen(err, result){
	console.log("Result: ", result);
});


IOCP


Для понимания работы Node.js под Windows необходимо понимать технологию IOCP. Input/output completion port – технология, предназначенная для выполнения асинхронных операции ввода/вывода, используемая в Windows. Основным объектом в данной технологии является IOCP порт, создаваемый при помощи функции CreateIoCompletionPort().Нас в первую очередь интересует, что IOCP порт инкапсулирует очередь событий, созданную в операционной системе. Функция PostQueuedCompletionStatus() помещает событие в очередь, а функция GetQueuedCompletionStatus() извлекает. Притом, если очередь пуста, то поток вызвавший GetQueuedCompletionStatus приостанавливается, до появления первого события.



Инициализация


Теперь, прежде чем приступить непосредственно к нашему примеру, рассмотрим некоторые моменты инициализации node.js. При запуске создается специальная структура описывающая цикл событий, назовем ее loop. В числе прочих полей, структура содержит ссылку на порт IOCP, и счетчик асинхронных запросов. Для простоты обозначим его как целочисленную переменную req_count. При инициализации цикла происходит создание порта IOCP:

iocp _handle= CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 1);



Далее происходит запуск цикла событий, о котором мы поговорим позже.

Функция open.


Ну и наконец алгоритм работы самой функции open.
Во-первых, создается и инициализируется структура, описывающая асинхронный запрос, назовем ее fs_open_req. В ней сохраняется ссылка на callback onOpen, путь и модификаторы доступа к файлу и другая информация описывающая запрос. Кроме того структура содержит поле для хранения результата запроса или ссылки на него.
Во-вторых, увеличивается счетчик асинхронных запросов в структуре loop.
В-третьих, создается отдельный поток, в котором будет производиться открытие файла. При этом основной поток node.js, возвращается из функции open и затем уходит на следующую итерацию цикла событий. В порожденном потоке средствами операционной системы открывается файл 1.txt. Его дескриптор записывается в структуру fs_open_req.



В-четвертых, после открытия файла и завершения всех необходимых операций, порожденный поток вызывает PostQueuedCompletionStatus(), тем самым помещая в очередь IOCP событие об открытии файла. Притом через один из параметров PostQueuedCompletionStatus к сгенерированному событию прикрепляется ссылка на структуру fs_open_req.



Цикл событий.


На входе в цикл событий проверяется счетчик асинхронных запросов. Если зарегистрированных запросов нет, то программа завершается. Если есть, то вызывается функция GetQueuedCompletionStatus(), которая либо возвращает очередное событие, либо, если событий нет, приостанавливает работу потока до их появления.
На одной из итерации функция GetQueuedCompletionStatus вернет событие об открытии файла и вместе с ним ссылку на структуру fs_open_req. Далее node.js уменьшит счетчик асинхронных запросов и запустит callback onOpen, передав ему в качестве параметра результат открытия файла.



Заключение


Вот, собственно, и все. Хотел показать только основные принципы, так что очень многое осталось не описано. К примеру, сетевые операции ввода/вывода организованы несколько по-другому и полнее используют возможности IOCP. Но оставим это на следующий раз.
Богушевич Максим @bogushevich
карма
5,0
рейтинг 0,0
Похожие публикации
Самое читаемое Разработка

Комментарии (34)

  • +2
    А какой профит использовать Node.js под WInodws в продакшене?
    • +1
      Ответить в общем не могу. Но конкретно в моем случае необходимо было написать модуль для взаимодействия с программным комплексом жестко привязанным к Windows (COM объекты, Windows сообщения и т.п.). То есть понимание работы node.js под Windows очень пригодилось.
  • +5
    Просто на будущее, что бы исправиться. Что именно не понравилось в статье? Плохое изложение, привязка к Windows, описание только файловой операции при громком заголовке? Ответьте, пожалуйста.
    • +3
      Я думаю, что народу не нравится запуск node.js и windows. А уж их сочетание так и подавно :)
      • 0
        Чем Node.js то не угодил? Под Windows и правда профита немного запускать, только если есть объективные причины.
    • +6
      Малый размер при громком заголовке.
    • 0
      1. Желательно больше примеров. В качестве первого вводного оно вполне нормально (кто забыл/не понял/задумался), но нужно развитие идеи.
      2. Нужны более качественные картинки) Хабраюзеры вообще очень часто визуалы и за «няшную» картинку готовы плюсануть какой-нибудь легкий пост в пятницу.
  • –1
    На входе в цикл событий проверяется счетчик асинхронных запросов. Если зарегистрированных запросов нет, то программа завершается. Если есть, то вызывается функция GetQueuedCompletionStatus(), которая либо возвращает очередное событие, либо, если событий нет, приостанавливает работу потока до их появления.

    Вся магия вроде бы здесь, но понять её у меня так и не получилось, кажется. Особенно белым пятном кажется «приостанавливает работу потока до их появления». Как после их появления поток «оживет» совершенно не понятно.
    По статье: тяжеловато читать. Вместо во-первых… четвертых лучше бы использлвать список нумерованный список. Про IOCP хотелось бы чуть более развернуто, а то вроде и краеугольный камень тут, а понимание его так и не доведено.
    • +1
      Спасибо за комментарий. Учту.
      Я не знаю, как реализована приостановка и запуск потока в Windows, это внутренний механизм операционной системы, который используется во многих других Win API функциях. Вот здесь вы можете найти их список.
      Из msdn:
      «Wait functions allow a thread to block its own execution. The wait functions do not return until the specified criteria have been met. The type of wait function determines the set of criteria used. When a wait function is called, it checks whether the wait criteria have been met. If the criteria have not been met, the calling thread enters the wait state until the conditions of the wait criteria have been met or the specified time-out interval elapses.»

      «Блокирующие функции позволяют потоку приостановить свое выполнение.… При вызове блокирующая функция проверяет переданное ей условие. Если условие не выполнено, то поток получает ожидающий статус и функция не возвращает своего значения до тех пор пока условие не выполнится »

      Под условием имеется ввиду не логическое выражение, а, допустим, некоторый объект, через который можно получить событие. В нашем случае условием является наличие или отсутствие событий в очереди IOCP.
      Просто представьте, что функция GetQueuedCompletionStatus() не будет ничего возвращать до того, как в очереди не появятся события.
    • +3
      Очень грубо — IOCP это нечто среднее между Linux AIO и epoll. Вы запускаете функцию и сразу получаете управление обратно в свой код. Когда функция будет выполнена — ядро системы пошлёт событие, которое разблокирует GetQueuedCompletionStatus(), в этом событии будет структурка, по которой можно сразу определить, какая именно функция выполнилась. В отличие от Linux epoll IOCP именно дожидается окончания работы функции чтения, как это делает AIO. В отличие от AIO IOCP умеет хорошо работать с сетью.

      Да, в линусе любая дисковая операция, даже после epoll, вернувшего, что дескриптор готов на чтение, даже неблокирующая, может увести поток в D state. Поэтому асинхронные операции с использованием только обычных сисколов + epoll невозможны, надо обязательно использовать AIO.
      • 0
        >Да, в линусе любая дисковая операция, даже после epoll, вернувшего, что дескриптор готов на чтение, даже неблокирующая, может увести поток в D state.

        эээ… вы получите EPERM при попытке добавить обычный файл в epoll.
    • +1
      >> Как после их появления поток «оживет» совершенно не понятно
      Вкратце — так же, как он оживает при выходе из WaitForSingleObject. Ожидание объекта синхронизации уровня ядра.
      Чуть подробнее:
      — поток, вызвавший GetQueuedCompletionStatus или WaitForSingleObject помечается как waiting,
      — добавляется в список ожидающих потоков объекта синхронизации,
      — выкидывается из планирования на указанный timeout или до активации объекта синхронизации.
      В нашем случае объект синхронизации активируется вызовом PostQueuedCompletionStatus, например.

      Читать:
      blogs.msdn.com/b/ntdebugging/archive/2008/05/07/work-queues-and-dispatcher-headers.aspx и оттуда по ссылкам
      computer.forensikblog.de/en/2006/02/dispatcher-header.html (типы объектов синхронизации уровня ядра)
      исходники ReactOS.
    • 0
      Я что-то увлекся и забыл две ссылки, с которых нужно начинать, чтобы понять, как вообще дело доходит до KeInsertQueue/KeRemoveQueue и KQUEUE/DISPATCHER_OBJECT:
      web.archive.org/web/20101101112358/http://doc.sch130.nsc.ru/www.sysinternals.com/ntw2k/info/comport.shtml
      habrahabr.ru/post/59282/ раздел «Внутреннее устройство» — перевод предыдущей, довольно вялый.

  • +3
    >> компромисс между простотой восприятия и техническими подробностями.
    Этот компромисс не нужен. Простота восприятия не важна, если вы хотите написать действительно крутую техническую статью.
    • 0
      Но прочитают ее полтора гика
      • 0
        Ок, можно просто сгруппировать материал по сложности, если хочется расширить аудиторию.

        В процессе написания и обсуждения крутой технической статьи автор узнает гораздо больше нового, проясняя для себя(или для других) какие-то аспекты темы — несомненный профит. А если найдутся таки эти самые «полтора гика» — это вообще отлично.
      • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    Это выходит, что «асинхронные» запросы нифига не асинхронные, а просто выполняются с блокировкой в другой нити?
    • 0
      В линуксе AIO так же сделан, я выше написал, почему.
    • –1
      Не выходит. Фишка в том, что все асинхронные запросы выполняются в одной нити. Эта нить заблокирована только тогда, когда больше нечему выполняться (не осталось событий в очереди).
      • 0
        Есть основная нить и вторая, в которой без очередности ожидаются ответы от IOCP.
        Выходит, что асинхронность достигается вторым блокирующим потоком.

        Если бы на каждый такой запрос еще бы и нить создавалась, было бы вообще «круто» :)

        Но тут видимо есть некие ограничения, раз и в AIO подобный подход. Сам я не пробовал пока AIO, хоть и давно собирался, так что основываюсь на комментарии ToSHiC.
        • 0
          Вы не так поняли меня, я не так понял ваш вопрос.

          Вы, видимо, спрашивали, как работает event loop. Он блокируется только на те моменты времени, когда все коллбэки отработали, а новых событий из GetQueuedCompletionStatus()/epoll() ещё нету. Как только появляется событие — основной поток разблокируется и вызывает нужный коллбэк. Это самая что ни на есть асинхронная работа, по-честному.

          Про блокировку с IO — это немного другое, когда вы зовёте aio_read(), который Native AIO — в ядре действительно вызывается настоящий read() из другого потока. Сейчас в ядре для этого используется workqueue. В POSIX AIO потоки прямо в userspace создаются.
          • –1
            С event loop все ясно, epoll «мучал» в свое время :)

            Меня как раз заинтересовала работа disk IO в IOCP в контексте node.js.

            1. В node.js создается нить, которая вызывает CreateFileW(), потом PostQueuedCompletionStatus() и завершает работу.
            2. Непосредственное открытие файла выполняется в ядре каким угодно образом асинхронно для node.js.
            3. Основной поток в порядке очереди в какой-то момент получает сведения по результату работы и вызывает onOpen.
            Все так?

            Зачем создавать нить в node.js для вызова CreateFileW(), если эта операция потом будет выполняться самой ОС?
            • –1
              Может там файл открывается с FILE_FLAG_OVERLAPPED, может еще что…

              Возможно все было бы понятнее на примере чтения/записи в файл, а не просто его открытия :)
              Да и вообще было бы не плохо, если бы в статье все менее «на пальцах» объяснялось.
            • –1
              В node.js создается нить, которая вызывает CreateFileW(), потом PostQueuedCompletionStatus() и завершает работу.


              Может так и написано в статье, но я не верю, что подобным маразмом node.js действительно занимается.
              • –1
                Ну там это прямо в блоксхеме нарисовано. И, если это так, то ЗАЧЕМ?
                • –1
                  Может быть я в чем-то и ошибаюсь. Давайте разберемся.
                  1. Вот цитата из документации:
                  The libuv filesystem operations are different from socket operations. Socket operations use the non-blocking operations provided by the operating system. Filesystem operations use blocking functions internally, but invoke these functions in a thread pool and notify watchers registered with the event loop when application interaction is required.

                  2. Вот ссылки на исходники:
                  создание потока
                  QueueUserWorkItem(&uv_fs_thread_proc, 
                  req, 
                  WT_EXECUTEDEFAULT)
                  


                  — вот функция uv_fs_thread_proc, выполняющаяся уже в порожденном потоке. Она вызывает функцию fs_open, и выполняет макрос POST_COMPLETION_FOR_REQ

                  открытие файла внутри функции fs_open
                  file = CreateFileW(req->pathw,
                                       access,
                                       share,
                                       NULL,
                                       disposition,
                                       attributes,
                                       NULL);
                  

                  Флаги открытия файла зависят от параметров, с которыми вызвана функция open еще в JS и FILE_FLAG_OVERLAPPED я в функции fs_open не вижу.

                  Если вы меня ткнете носом в то место, где я ошибся, я буду только рад. Правда :)
                  • –1
                    Действительно, CreateFileW вызывается без флага FILE_FLAG_OVERLAPPED.
                    При этом в fs__read используется OVERLAPPED
                    ReadFile(handle, req->buf, req->length, &bytes, overlapped_ptr)

                    Но по MSDN:
                    pOverlapped [in, out, optional]

                    A pointer to an OVERLAPPED structure is required if the hFile parameter was opened with FILE_FLAG_OVERLAPPED, otherwise it can be NULL.
                    If hFile is opened with FILE_FLAG_OVERLAPPED, the lpOverlapped parameter must point to a valid and unique OVERLAPPED structure, otherwise the function can incorrectly report that the read operation is complete.

                    Это недочет в libuv или я слишком давно не кодил под win?
                    • –1
                      ReadFile в данном случае выполняется, как синхронная операция, блокирующая поток, в котором происходит чтение.
                      1. В цитате из msdn, которую вы привели написано:
                      Если файл открыт с FILE_FLAG_OVERLAPPED, то необходима структура OVERLAPPED, иначе она может быть NULL. А может и не быть. Поэтому никакого нарушения здесь нет.
                      2. Обратите внимание на инициализацию переменной overlapped_ptr:
                      if (offset != -1) {
                          memset(&overlapped, 0, sizeof overlapped);
                      
                          offset_.QuadPart = offset;
                          overlapped.Offset = offset_.LowPart;
                          overlapped.OffsetHigh = offset_.HighPart;
                      
                          overlapped_ptr = &overlapped;
                        } else {
                          overlapped_ptr = NULL;
                        }
                      

                      offset здесь — это смещение, с которого начнется чтение файла

                      Из инициализации видно, что
                      — overlapped_ptr здесь используется для указания смещения (есть у структуры OVERLAPPED и такая функция).
                      — overlapped_ptr может быть равен NULL, что в принципе недопустимо при асинхронной работе с файлами
            • 0
              На сколько я понимаю, функцию PostQueuedCompletionStatus() дёргает поток, который обслуживает IOCP. Количество потоков, которые будут обслуживать собственно работу с IO задаются при создании completion port, параметр NumberOfConcurrentThreads, и они сразу создаются где-то, прозрачно для программиста, использующего IOCP. Т.е. в своём коде просто ассоциируем хэндл с CP, дальше с ним работаем.

              Тут есть такая тонкость: надо уже иметь HANDLE на файл. Т.к. в случае с CreateFile никакого хэндла ещё нету, то приходится создавать свой поток-воркер, который открывает файл и сообщает в PostQueuedCompletionStatus, что удалось файл открыть. Для остальных операций такая штука не нужна.
              • 0
                Да, все логично. Но почему CreateFile вызывается без FILE_FLAG_OVERLAPPED, если потом этот дескриптор используется для неблокирующих операций?

                Или это из серии вызова CloseHandle сразу после CreateFileMapping, после чего все продолжает корректно работать? :)
                • 0
                  Это уже вопрос к разработчикам libuv :)
              • 0
                Количество потоков, которые будут обслуживать собственно работу с IO задаются при создании completion port, параметр NumberOfConcurrentThreads

                Вот создание IOCP порта (выполняется это однократно при запуске node.js):

                loop->iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 1);
                

                Параметр NumberOfConcurrentThreads равен 1. Насколько я понимаю — этим единственным потоком является основной поток node.js
                • 0
                  Не, это тред в тредпуле IOCP должен создаться.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.