Знай свій інструмент: Event Loop в libuv

Юдель Пен. Каплиця. 1924

"Комп'ютер - це кінцевий автомат. Потокове програмування потрібно тим, хто не вміє програмувати кінцеві автомати "

Алан Кокс, прим. Вікіпедія

«Знай свій інструмент» - твердять всі навколо і все одно довіряють. Довіряють модулю, довіряють фреймворку, довіряють чужому прикладу.

Улюблене питання на співбесідах по Node.js - це пристрій Event Loop. І при всьому тому, очевидному факті, що прикладному розробнику ці знання будуть корисні, мало хто намагається самостійно зануритися в пристрій подієвого циклу. В основному, всіх влаштовує картинка зверху. Хоч це і схоже на переказ фільму, який ти не дивився, а про який тобі розповів друг.

Найскладніше, напевно для мене, це визнавати свої помилки і погоджуватися з тим, що я чогось не знаю. Про помилки не люблять говорити і писати. В основному, всі люблять писати і говорити про свої успіхи і хороші історії, людина намагається вибудувати образ непереможного героя.

Адже як правило, помилки допускаються саме по незнанню, саме через поверхневі судження, через те, що хтось витратив менше часу, ніж потрібно, на вивчення поставленого питання. Очевидність. Я знаю.

Нижче, я спробую описати моє розуміння подієвого циклу на прикладі вихідного коду libuv (в тандемі з V8, це основа Node.js), а так само я примкну до когорти людей, які твердять: «Треба знати свій інструмент».

До речі останнє, в сучасних реаліях, стає нелегким заняттям. Один тільки npm налічує, на поточний момент, майже півмільйона модулів, я вже і не кажу про армію репозиторіїв на github. Але так все влаштовано, щоб залишатися на місці, потрібно бігти, щоб зрушити з місця, потрібно бігти в два рази швидше.

Ця нотатка насамперед нагадування мені, нагадування бути уважнішим. Читачеві ж, я рекомендую зануритися у вихідний код самостійно, зробити якісь висновки, а потім повернутися до цього тексту.

Також, описане нижче - це величезна апроксимація того, що насправді відбувається під капотом Node.js. Серед багатьох інших, нотатка базується саме на вихідному коді libuv. Я буду розглядати кодову базу бібліотеки в частині unix. Код для win буде іншим.

Ну і спочатку трохи фундаментальної термінології:

Подієво-орієнтоване програмування (^, Event-Driven Programming/EDP) - парадигма програмування, в якій виконання програми визначається подіями.

Парадигма ^ активно застосовується при розробці GUI, однак, застосування їй знайшлося і на боці сервера. У 1999 році, обслуговуючи популярний у той час публічний FTP-сервер Simtel, його адміністратор Ден Кегель зауважив, що вузол на гігабітному каналі за апаратними показниками мав би справлятися з навантаженням у 10 000 сполук, але програмне забезпечення цього не дозволяло. Проблема була пов'язана з великою кількістю програмних потоків, кожен з яких створювався на окреме з'єднання.

Ідея подієвого циклу, що працює в одному потоці, вирішувала цю проблему. Подібні реалізації є не тільки в світі JavaScript (Node.js). Наприклад, Asyncio і Twisted в Python, EventMachine і Celluloid в Ruby, Vert.x в Java. Ще один яскравий представник подібної реалізації - проксі-сервер Nginx.

В основі ^ лежить Подієвий Цикл (Event Loop) - програмна конструкція, яка займається диспетчеризацією подій і повідомлень у програмі.

Цикл, у свою чергу, працює з асинхронним вводом/висновком, або неблокуючим вводом/висновком, що є формою обробки даних, який дозволяє іншим процесам продовжити виконання до того, як передача буде завершена.

Функція зворотного виклику (Callback) - можливість передачі виконуваного коду як одного з параметрів іншого коду. Подібна техніка дозволяє нам зручно працювати з асинхронним введенням/висновком.

“Hello World!”

А тепер почнемо з офіційного прикладу «Hello World!» сайту http://docs.libuv.org:

Приклад простий, резервується необхідна оперативна пам'ять і ініціалізується структура подієвого циклу, далі він запускається в режимі за замовчуванням (це до речі саме той режим, який використовується в Node.js).

Потім відбувається закриття циклу (зупинка всіх спостерігачів за подіями, спостерігачів сигналів, звільнення пам'яті виділеної під спостерігачі) і звільнення пам'яті зарезервованої самим циклом. Нас же буде цікавити пристрій функції-запуску циклу (uv_run), подивимося її вихідний код (він не зовсім оригінальний, я видалив рядки не пов'язані з режимом за замовчуванням, тому в прикладі змінна «mode» ніде не бере участі):

Тіло функції-запуску, як ми бачимо, починається не з циклу «while», а з виклику uv __ loop _ alive. У свою чергу, дана функція перевіряє наявність активних обробників або запитів:

Від результату виконання цієї функції буде залежати запуститься цикл «while» чи ні. У разі відсутності запитів або обробників, функція-запуску просто оновить час виконання подієвого циклу і тут же завершиться.

Якщо ж є що обробляти (r! = 0) і прапор зупинки не встановлений (stop_flag = = 0), то цикл запуститься. І першою дією в ітерації циклу буде теж оновлення часу виконання (uv __ update _ time).

Наступний крок в ітерації - це запуск таймерів.

Структура подієвого циклу містить, так звану, купу таймерів. Функція-запуску таймерів витягує з купи обробник таймера з найменшим часом і порівнює це значення з часом виконання подієвого циклу. Якщо час таймера менший, цей таймер зупиняється (видаляється з купи, його обробник теж видаляється з купи). Далі йде перевірка, чи потрібно його перезапустити.

У Node.js (JavaScript) у нас є функції setInterval і setTimeout, в термінах libuv це одне і те ж - таймер (uv_timer_t), з тією лише різницею, що в інтервального таймера виставлений прапор повтору (repeat = 1).

Цікаве спостереження: у разі виставленого прапора повтору, функція uv_timer_stop спрацює двічі для обробника таймера.

Перейдемо до наступної дії в ітерації подієвого циклу, а саме функції-запуску очікуючих зворотних викликів (pending callbacks). Виклики зберігаються в черзі. Це можуть бути обробники читання або записи файла, TCP або UDP з'єднань, загалом будь-яких I/O операцій, бо тип не особливо має значення, так як, ви пам'ятаєте, в unix все є файл.

Далі в ітерації йдуть два містичні рядки:

Насправді це теж функції-запуску зворотних викликів, але вони не мають ніякого відношення до I/O. Фактично, це якісь внутрішні підготовчі дії, які було б непогано зробити перед тим, як починати виконання зовнішніх операцій (мається на увазі I/O). У випадку з «Hello World», таких обробників немає, але на сайті є приклади, де такі зворотні виклики реєструються.

В даному прикладі, idle-обробник нічого не робить, він буде виконуватися поки лічильник не досягне певного значення. Таким же способом реєструються і підготовчі обробники (prepare).

У Node.js (JavaScript) немає еквівалента цим обробникам, тобто ми не можемо зареєструвати якийсь зворотний виклик, який би виконувався саме на одному з цих кроків. Однак, треба зробити одну обмовку, використовуючи process.nextTick, ми можемо ненавмисно виконати код на одному з цих кроків, так як ця функція спрацьовує безпосередньо на поточному етапі подієвого циклу, а це, в тому числі, може бути і uv __ run _ idle або uv __ run _ prepare. Сама ж, функція process.nextTick, ніякого відношення до бібліотеки libuv не має.

На цю тему (робота process.nextTick) у мене збереглася стара, але поки ще актуальна, діаграма зі stackoverflow:

Наступний етап ітерації найцікавіший - це зовнішні операції I/O (poll (2)).

Тут я об'єднав два кроки: обчислення часу для виконання зовнішньої операції і, безпосередньо, зовнішня операція.

Обчислення часу виконання зовнішньої операції I/O з реалізації подібне до функції запуску таймерів, оскільки значення цього часу обчислюється на основі найближчого таймера. Цим, до речі, і досягається неблокуюча модель (non-blocking poll).

Початковий код функції uv __ io _ poll досить складний і не маленький. Там ведеться багатопоточна робота, реєструються спостерігачі подій, зворотні виклики і ведеться робота з файловими дескрипторами.

Я не буду тут наводити код цієї функції, картинка цілком відображає суть цієї операції:

Наступна операція в черзі команд ітерації подієвого циклу - uv __ run _ check. Вона за своєю суттю ідентична функціям uv __ run _ idle і uv __ run _ prepare, тобто це запуск зворотних викликів, що реєструються за тим же принципом, і викликають після зовнішніх операцій. Однак, в цьому випадку, у нас є можливість реєстрації подібних обробників з Node.js. Це функція setImmediate (тобто негайне виконання після зовнішньої операції I/O).

Передостанній крок - це запуск обробників, що закриваються.

Ця функція обходить зв'язаний список обробників, що закриваються, і намагається завершити закриття для кожного. Якщо у обробника є спеціальний зворотний виклик на закриття, то, по закінченню, запускається цей зворотний виклик.

І останній крок ітерації, це вже знайома функція uv __ loop _ alive. Якщо ця функція поверне результат відмінний від нуля, то подієвий цикл запустить нову ітерацію.

***

Якщо у вас є якісь зауваження або доповнення, я буду радий їх побачити в коментарях або пишіть на Ця електронна адреса захищена від спам-ботів. Вам необхідно увімкнути JavaScript, щоб побачити її.

Корисні посилання

LibUV: Design Overview

Nodejs.org: The Node.js Event Loop, Timers, and process.nextTick()

Переклад документації Nodejs.org на тему

Philip Roberts: What the heck is the event loop anyway?

RisingStack.com: Understanding Node.js Event Loop

Nodesource.com: Understanding Node.js Event Loop

Mozilla.org: EventLoop

COM_SPPAGEBUILDER_NO_ITEMS_FOUND