Галоўная / Артыкулы / Паспрацаванне канкурантнасці ў Node.js: libuv, цыкл здарэнняў і пул адбегаў

Паспрацаванне канкурантнасці ў Node.js: libuv, цыкл здарэнняў і пул адбегаў

Дазвольце даклэ научыцца, як Node.js выкарыстоўвае прымітывы операцыйной системы з libuv і пул рабочых адэнакоў для обработкі асінхронных операцый I/O, а таксама распашчыце падазры з пуламі адэнакоў і практычныя патрыбуты для ўталення.

2284 слоў

Практычна кожны развіццелер запоўнюе сваё першае выучэнне платформы фразай "Node.js ўяўляе сабою аднонітковы працэс". Аднак на практыцы адзін працэс Node.js можа одночасна чытаць сотні файлоў, шукаць тыячы записаў DNS і кераваць дзесяткамі тысяч відкрытых з’ўязкаў з базамі дадзенаў, продовжаючы выканаваць код без паўзок.

Якщо сам JavaScript выканавляецца толькі на аднам нітку, як сервер Node.js можа продаваляць на запиты, калі ён завантажае файл размерам у калькі гігабайта з обертальнага диска?

Механізам, які стоіць за гэтым, — це libuv, біблія на мове C, створаная спецыяльна для Node.js, якая керуе асінхронным, неблокуючым вводам і выходам.

Працэзная разуменне таго, як libuv перакладае выкарыстання ресурсаў за межы аддзінай ниткі JavaScript, — гэта не проста тэорыя. Гэта тое, што адпаведзае на пытанні: чаму запыт да базы дадзенаў працюе інакш, чым операцыя хэшавання, з точкі зору выдатков; чаму змяна адной-едзінай зменны ў сераўысе можа значна парадзіцься на швальнасці (або повалка) адпаведзення вашага API; і як можна выявіць та ухіліцца ад скрытых вузкоў у вашых сервісах.

Што такое libuv?

Node.js — гэта не адзін монолітны двыжак; гэта сукупнасць калькі сумесна працуючых слоёў:

+-------------------------------------------------------------+
|                      Your Application                       |
+-------------------------------------------------------------+
|                    Node.js Core (JS / C++)                  |
+------------------------------+------------------------------+
|     V8 Engine (Google)       |            libuv             |
|   (Executes JavaScript)      |   (Event Loop & Async I/O)   |
+------------------------------+------------------------------+
|                Operating System Kernel                      |
+-------------------------------------------------------------+
  • V8 (Google): Гэты двыжак компілюе та запускае ваш JavaScript. У яго є роўна адна стак-стрыя, і ён выконвае код последоўна, толькі на адной нітцы.
  • libuv: Крос-платформовая біблія, напісана на C, яка адмініструецькія задачы такія як цыкл змагання, пул робочых нейтрансаў, доступ да файлавой системы, таймеры, стварэнне дзецячых процэсаў і стежылля за сетавымі сокетамі.
  • Калі людзі называюць Node.js однанейтрансавым, на самай працоўцы ў значэнні контекст выканання JavaScript работае на аднам галоўным нейтрансавы. Сама ж libuv напісана на C і ў сваёй сутнасі ёсць багатонейтрансавая. Яна выкарыстоўвае будь-якія низкоуровневыя можлівасці, якія аператывная система надае, каб выканаваць задачы паралельна, пры чым не блакуе выканання JavaScript.

    Два спосабы, якими libuv адмахоўваеся асінхронных задач

    Часта прыймаюць, што libuv направляе кожную асінхронную операцыю на фонавы нейтранс. Гэта не зусім правда — на самай працоўцы libuv фактычна дзеліць работу между двума рознымі стратэгіямі, залежна ад таго, якога роду ёсць задача:

    • Натыўныя, неблакучыя механізмы операцыйнай системы (для вводу/выводу па сеті)
    • Внутршній пул задаў libuv (для доступу да файлавой системы, запытанняў DNS і крыптаграфіі)

    Розумеўце гэтага раздзелення можна сказаць, ёсць найболей корыстным ментальным модэлем для аналізу выканаўсці бэкенду Node.js.

    Incoming Async Task
            │
            ├── Is it Network I/O? (TCP/UDP, HTTP sockets)
            │     └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
            │          (Zero worker threads used)
            │
            └── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
                  └──> Handled by libuv Thread Pool (4 threads by default)
    

    1. Ввод і вывод па сеті: прымітывы операцыйнай системы

    Савременныя операцыйныя системы выклікаюцца з спецыяльнымі, неблакучымі API для керування сетавымі сокетамі:

    • epoll у Linux
    • kqueue у macOS і сям’і BSD
    • IOCP (порты завершэння для вводу і выводу) у Windows

    Калі прыкладнасць Node.js ачывае слухальнік TCP або выканалівае запыт HTTPS, libuv не перадае яго на рабочую ніт. У замен яна рэгіструеяе опис файлу сокета безпасоваўсіма з кэрамерай ОС, фактычна прасягаючы паведаміць яе, калі на гэты сокет прыйду новыя данні або калі ён будзе гатовы прымаць даўэйшыя запісы.

    З таго моменту libuv проста чакае. Саме кэрамера оператыўной системы стежыць за сетавым апаратам.

    Калі пакеты нарэшце прыбуду да сетавага інтерфейса, кэрамера стварыць запаведзенне. libuv перагледвае яго пад час этапу апытавання сваёй цыклі запаведзень, а потым ставяе відпаведны JavaScript-колбэк у чергу на выкананне. V8 зрэшты выкарыстоўвае яго і запускае на галоўнай ніцы.

    Паколькі няма рабочага адэнту, які чакаў бы на падачу байтаў праз сеть, адзін процэс Node.js можа з лёгкасцю кераваць дзесяткамі тысяч спакойных або нерабочых з’яўленняў, вядучы пры тым мінімальную колькасць памяці.

    2. Ввод/выход дакументаў і системныя операцыі: Пул рабочых адэнтав

    Усведамляючы, што сетевыя з’єднання можна обрабляць без блакування на рэверсі ядра, можа з’яўіцца пытанне: чаму чытанне і запіс дакументаў не можа працаваць так сама?

    Прычына ў тым, што большасць операцыйных систем не маюць справжней неблакуванай API для доступу да файлавой системы. У системах на базе POSIX, такіх як Linux і macOS, звычныя файловыя операцыі блакуюць той адэнт, які іх вызывае, пакуль прыстрой зберагання фактычна не вярнуе запрашаваныя даны.

    Якщо Node.js прагнуў бы безпасэродна выконаць чытанне файла на галоўнай ниті JavaScript, весь час выконання програмы зупініўся бы, пакуль диск не запрацаваў, не з’явіліся бы неабходныя блокі і не былі б вернуты байты. За весь гэты час ніякі іншы запрошання HTTP не могла бы буць обслужана.

    Ёнколі з гэтай проблемы, libuv падтрымвае внутранній пул рабочых нітей.

    Ось што адбываецца, калі ваш код вызвае fs.readFile():

    • Вызов JavaScript праходзіць через внутранія з’ўязкі Node ў libuv.
    • libuv упаковвае запрошання на чытанне файла як елемент роботы і додае його у внутранюя чергу задач.
    • Адна з фонавых нітей у пуле выкарабквае гэта запрошання з чергі.
    • Эта ніт потым безпечна, паўстаючы з галоўнай ніты, выконвае сама блокуючая системная вызов — read() або write().
  • Калі чытанне завершыцца, рабочая нить сігналізуе цыклу здарэнняў через механізм абавесцювання межы нітямаў.
  • Цыкл здарэнняў пасля таго запланавае адпаведны JavaScript-колбэк, які будзе выкананы на галоўнай нить, перадаючы атрыбуты буфера, які быў створаны.
  • Што на самай працоўнай пулі выканана?

    Чатыры большыя категоріі задач выкарыстоўваюць працоўную пул libuv:

    • Выклікі файловай системы: кожны асінхронны метод у fs, такі як fs.readFile, fs.stat і fs.writeFile.
    • Пошук у DNS: конкрэтна dns.lookup(), який выкарыстоўвае блакуючую C-функцыю getaddrinfo(). На адміну, dns.resolve() зовсім не выкарыстоўвае працоўную пул і ведае роботу з сеткай безпосередна через асінхронныя выклікі.
  • Дорогі крыптаграфічныя операцыі: функцыі на кшталт crypto.pbkdf2(), crypto.scrypt() і рутыны для стварэння ключоў.
  • Рутыны для стыснення: асінхронныя методы zlib, такія як zlib.gzip().
  • Спагляданне работы пула задач

    Вы можете пераканацца, што libuv выкарыстоўвае фонавы пул, і пазнакоміцца з його стандартным размерам, адмах вялікага эксперыменту:

    // thread-pool-test.js
    const crypto = require('crypto');
    
    const start = Date.now();
    const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
      crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
        const elapsed = Date.now() - start;
        console.log(`Task ${i} completed in ${elapsed}ms`);
      });
    }
    

    crypto.pbkdf2() ўзьміцца за хорашы прыклад тэста, каліхоў ён навмысна выканае важку роботу на CPU для стварэння хеша пароля, і гэтае заведамства перадаецца пулу задач.

    Запускайте скрыпт з вашага тэрміналу:

    node thread-pool-test.js
    

    Расклад будзе выглядаць працоўна так:

    Task 2 completed in 218ms
    Task 1 completed in 220ms
    Task 4 completed in 224ms
    Task 3 completed in 226ms
    Task 5 completed in 435ms
    Task 6 completed in 437ms
    

    Чаму пасляпэльныя два заведамства выканаюцца удвое дольшы час?

    Аккуратнае разглядзенне часоў выканання. Першыя чатыры задачы завершаюцца прыблізна ў той ж самы момент — адпаведна 220 мс. Але пятая і шостая задачы выкананы прыблізна за 435 мс — гэта майже удвое больш.

    Прычына ў тым, што бібліятэка libuv мае пул нитак з стандартным размерам 4 нітак.

    Як толькі пачынаецца цикл, першыя чатыры задачы захопляюць усе чатыры доступныя ніткі-рабоў. Пятая і шостая задачы тады застаюцца ў внутранім кяле libuv і чакаюць. Лишэнь калі адна з першых чатырох задач завершыцца і звалічыць нітку, задачы з кялу можаць пачаць выкананне.

    Кантролюванне размеру пулу за дапамою UV_THREADPOOL_SIZE

    Вы можете змяніць колькість нітак-рабоў, якія запускае libuv, задаючы значэнне зменной аптаварынты UV_THREADPOOL_SIZE пры запуске процэса Node.js. Яна приймае значэння з 1 да 128.

    Спробуйце той самы скрыпт знову, але цяраз прабуйце выкорыстаць пул з 8 нітак:

    # On Linux / macOS:
    UV_THREADPOOL_SIZE=8 node thread-pool-test.js
    
    # On Windows (PowerShell):
    $env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
    

    Рэзультаты тепер выглядаюць інакш:

    Task 1 completed in 240ms
    Task 3 completed in 242ms
    Task 2 completed in 245ms
    Task 6 completed in 249ms
    Task 4 completed in 250ms
    Task 5 completed in 252ms
    

    Калі є достатнька колькасць рабочых потак, усе шысць заданняў выконваюцца паралельна, а не у рядку.

    Важлівая абмежэнне: вы не можете зменіць размах пула з усероўні вашага скрыпта, запісавшы process.env.UV_THREADPOOL_SIZE = 8. libuv чытае і блакуе гэтыя значэння ўсё раней, чым ваш код на JavaScript запускаецца, таму зменнай сераўніснае месца павінна быць заданая на рэвеню шэлу або прыладом керавання процесамі, які запускае Node, а не з усероўні самай прыемлівання.

    Паўзучая проблема ў прыемліванні: потакі, якія дзелюцца межы некаляжнымі заданнямі

    Паколькі операцыі з файламі, запыты DNS і крыптаграфічныя задання за замовчаннем выкарыстоўваюць той самы пул з чатырох потак, інтэнсіўнае выкарыстоўвання ў аднай категорыі можа таямна спамячваць ўсё, што зусім не стосуецца яе.

    Уявіце сабе гэтую последовасць падзей:

    • Хвіля запуску прыходзяў спрычынае калькольванне кількох паралельных вызоваў функцыі crypto.pbkdf2 для пераканання ў правільнасці пароў.
    • Усе чатыры потокі libuv зараз абоўсюдна зайняты вычысленнем хэшаў пароў.
    • У той жа момант іншая частка прыкладнага програму запускае функцыю fs.readFile() для завантажэння шаблона пісьма або функцыю dns.lookup() для вычыслення імені хоста вашай базы дадзеных.
    • Обе гэтыя операцыі вынужданы чакаць своей разы ў черге.

    Чытанне файла практычна не викорыстоўвае CPU, але ён такі ж затрымляецца, таму што кожны рабочы поток зайняты вычысленнем хэшаў. Ззовні це выглядае так, нібы доступ да файла або з’єднанне з базай дадзеных сповільніліся, хоча асалоджыцелем проблемы є борьба за потокі libuv.

    Як зменшыць конкурэнцію ў пуле потакоў:

    • Дадзіце басэне большае лічба адгульнікаў: якщо вашы сервіс выканае многа задач з чытанням/запісам на дыскі або крыптаграфічных вычыслаў, падвышэнне значэння UV_THREADPOOL_SIZE да 16 або 32 можа зменшыць супернагружанне, за ўмовы што апаратная частка мае достатню колькасць ЦП-ресурсоў для ўтрымання такога режыма.
    • Вынесіце спецыяльныя задачы, залежныя ад ЦП, за межы басэна: для задач, якімі вы керуеце, напрыклад, стварэнняя звітоў аб працэсаванне зображэнняў, не намагайцеся перадаваць іх через libuv. У замен выкорыстоўваеце модуль worker_threads, які стварае адзеленыя экземпляры V8 на сасобных адгульніках ОС.
    • Ўтримваецеся ад dns.lookup() там, дзе гэта можліва: вярніце прыоритет dns.resolve4(), або стварыце басэны з’яўленняў, якія выкарыстоўваюць чыста IP-адрэсы, так каб распакаванне меркавых імен не спрацьваліла на лімітованыя кантыненты працоўнікаў libuv.

    Дзе развіццялеры часта робяць помылкі

    Парадокс першы: Падзеяць, што async/await аўтаматычна пераказвае роботу

    Дадзенне прыменшэння функцыі з async не стварае для яе фонавага аддзінку. async/await — это проста болей чыстая сінтаксіс, якая практыкуецца на базе Promises. Якщо тэла функцыі містяць сінхронны цикл або выкарыстана важкая обработка, такі код все равно запускаецца безпосередня на галоўным аддзінку JavaScript і будзе блакаваць ваш сервер пад час свайго выканання.

    Парадокс другі: Плутанне пула аддзінкаў libuv з worker_threads

    • Пул аддзінкаў libuv керуецца внутршняя часткаю настаўнага коду C. Ён адпавядае за вбудованыя операцыі, такія як fs, crypto і zlib. У вас няма можлівасці дадаць у гэты пул своія прызначальныя функцыі JavaScript.
  • Модуль worker_threads, які ўвядзены з Node.js 10.5, — это API на рэвэрсе JavaScript. Ён дазваляе запускаць ваш код паралельна, пры чым кожны рабочы процес атрымлея свой незалежны двыжок V8 і цыкл змаганняў.
  • Трэція памылка: занадта великі пул нитак

    Існуе спадчына проста задаць UV_THREADPOOL_SIZE=128 у всіх месцах і думаць, што чым больш, тым лепяй. Але ніты маюць рэальныя витраты. Кожная з іх патрэбуе памяці для свайго стака выконання, і калі у вас сотні нітак, якія конкуруюць за толькі два чы трохе CPU-ядра, аператывная система пачынае витрачаць значны час на переключэння межы ямі.

    Разумны пачатковы пункт — задаць розмер пулу так, каб ён адпавядаў колькасці вашых логічных CPU-ядраў, калі навантажэнне залежыць ад CPU, напрыклад у кріптаграфіі чы стисненні, або ж вжываць два-чатыры разы большую колькасць, калі робота пераважна чакае на I/O з дыска.

    Разытнак

    Настоям секрэт Node.js не ў тым, што ён цалкам ухілляецца ад канкурантнасці, а ў тым, што ён паказвае деталі низкага рэвяра ў простой модэлі програмавання, заснованым на запуску задач.

    • Выконанне JavaScript застаецца ў однам рэвяре: логіка прыкладнення выконваецца па шаго, чым ухіляюцца ситуацыі канкурантнасці і патрэба ў блакуваннях.
    • Сетавыя операцыі адбываюцца через механізмы на рэвяре ОС у libuv: сокеты керуецца системамі паллавання ядра, такімі як epoll, kqueue або IOCP, і ўзагалі не викорыстоўваець рабочыя рэвяры.
    • Даступ да файлаў, запыты DNS і крыптаграфічныя задачы выконваюцца за дапамогою пула рэвяраў: чатыры фонавыя рэвяры на мове C прымоцьваюць гэтыя блакуючыя запыты, тады як галоўны рэвяр застаецца вольным для прыймання новых запросаў.

    Калі вы знаеце, які з эўтах двух шляхоў выбірае даная операцыя, вы знаходзіцеся ў набліжна лепшай пазыцыі, каб выявіць проблемы з продуктывасцю, правільна падбараваць размер сервероў і стварыць сервісы на заднем фоне, якія будуць стойкія пад вялікай навантажэнням.

    Спадні матэрыялы

    • Як process.nextTick() таямніча блакуе цыкл задач Node.js — Пяшчотнаячае, чаму рекурзіўныя вызовы process.nextTick() цэлкам блакуюць фазу паллавання libuv і як выправіць ситуацыю з блакаванням цыклу задач за дапамою setImmediate().
    • Выбір между Promise.all, Promise.race і Sequential Awaits — Дазвольце дазнацца, калі Promise.all() прышвартавае API Node.js, чаму ён шыбкая падваляецца при будзь-ям адхіленні і якія критэрыя следуець выкарыстоўваць для выбору правильнага асінхроннага патэрану.
  • Node.js Streams: Адказанне працы з потокамі та способы выправлення крахоў сервера з-за нехваткі памяці — Дазвольце вам дазнацца, чырваё навантажэнне цэлых файлоў у памяць спрычынае крахі сервера Node.js, і як потокі чытання, запісу, двунаправленага абмену дадзеннямі та трансформацыі выправляюць гэтую проблему за дапамогою механізма backpressure.
  • Контроль канкурантнасці ў Node.js: Як ужыць p-map і Bottleneck, каб утримаць API ад крахоў — Дазвольце вам дазнацца, як саюз p-map і Bottleneck у Node.js запобегае памылкам, вызваным лімітамі шчыльнасці запытаў, і перанасоўванню системы, што дасягаецца за дапамогою кантролю канкурантнасці та часу адпаведзення на запыты.
  • Node.js 26: Адказанне пра Temporal API, Map Upserts і Undici 8 — Адказанае пра галоўныя змены ў Node.js 26, якія стосуюцца бэкенду: стабільны Temporal API, вбудованыя методы Map upsert, павышэнне працоўнае спроможнасці Undici 8, а таксама змены, якія можу спанаваць проблемы праз адчыненне перад апгрэйдам.
  • Розумеўце закрыцця ў JavaScript і цыкл здарэнняў — Дазнаецеся, як закрыцця зберагаюць зменныя званічнага калектара, і як цыкл здарэнняў аранжуе стек вызваў, мікрозадачы і макрозадачы за дапамогою практычных прыкладаў коду.