Галоўная / Артыкулы

Як function process.nextTick() тыха прычына голаду ў цікле запуску супэрвізора Node.js

Функцыі recursive process.nextTick() ніколі не дазваюць libuv дася да фазы паллавання, таму цикл задач Node.js застойваецца. Как яго выявіць і вылечыць за дапамою setImmediate().

Выдавец: REACTAPP.TOP7 хв чытання1723 слоў

Крыніцы: Why `process.nextTick()` Can Starve Your Node.js Event Loop

Большасць інжынераў, які працуюць з адзюльтайнам, разумеюць, што Node.js працуе на адно-нітковым цыкле здачы справ, які керуецца libuv, і што самэ ўсуненне блаканняў у асінхронных патэрантах дапамагае заставіць API быстрым і рэакtywnым пад навантажэнням.

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

У сервісах, які обробляюць дзесяткі тысяч запитоў за секунду, выкарыстоўванне process.nextTick() як безнебяспечнага спосабу для „выконання гэтага пасля завершэння чынной функцыі“ можа зупініць працэс всей аплікацыі.

Гэты спосаб абякання называецца I/O starvation. Чырабаваць, чаму гэта выклікаецца, трэба дакладна розглядзець, як V8 перадае керуванне libuv, і навпакі.

1. Разбор фаз цыклу здачы справ Node.js

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

┌───────────────────────────┐
┌─>│          timers           │  setTimeout(), setInterval()
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  System-level I/O callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │        idle, prepare      │  Internal node operations
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  Retrieve new I/O events, execute I/O callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  setImmediate() callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │  socket.on('close', ...)
   └───────────────────────────┘

Цыкл праходзіць праз гэтыя фазы строгай архітектурой. У спецыяльна фазе паллування сэрвісны механізм блакуе, чакаючы на прыходзячыя з’ўязкі, пераглядае ядро ОС на наявнасць дадзеных у ачыненых сокэтах і запускае калебачанняя JavaScript, прыўязаныя да гэтай дзеяння.

2. Дзе на самай працы знаходзіцца process.nextTick()?

Ёсць деталі, якія большасць разработчыкаў застаюць без адпаведзення: process.nextTick() ўжо не ёсць частью цыклу здарэнняў libuv.

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

Тое, што робіць гэта ўсё болей складным, — такое прапорцыйнае распаўненне, якое існуе ў самай складзе мікрозадач:

[ Active V8 Call Stack Finishes ]
               │
               ▼
┌──────────────────────────────────────┐
│       Microtask Queue 1              │
│       process.nextTick Queue         │ ◄── Emptied COMPLETELY first
└──────────────┬───────────────────────┘
               │
               ▼
┌──────────────────────────────────────┐
│       Microtask Queue 2              │
│       Promise / async-await Queue    │ ◄── Emptied second
└──────────────┬───────────────────────┘
               │
               ▼
[ Advance to Next libuv Event Loop Phase ]

Кожны калбэк, зарэгістраваны за дапамою process.nextTick(), выконваецца раней, чым V8 разв’язвае якія-небудзь стандартныя Promises, і ўжо заўсёды раней, чым libuv мае можлівасць перейсці да таймераў, пераглядаць калбэкі або стежыць за прыходзячым сетавым трафікам.

3. Структура проблемы голаду ціклу змагання

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

Як ілюстрацыю можна взяць гэты прыклад пакетной обработкі:

import http from "node:http";

// Mock worker performing continuous state reconciliation
function scheduleWork() {
  // Heavy in-memory computation
  performSanitization();
  // Recursively scheduling next iteration
  process.nextTick(scheduleWork);
}
// Kick off the background worker
scheduleWork();
// Spin up a standard HTTP API
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ status: "healthy" }));
});
server.listen(3000, () => {
  console.log("Server listening on port 3000");
});

Што відбываецца на самай справе:

  • scheduleWork() адклёввае свою сінхронную логіку.
  • Ён дадае ў чергу process.nextTick ўсё той жа выклік самога сябе.
  • Стак вызваў V8 апошні, і Node.js пераглядае чергу мікрозадач.
  • Ён знаходзіць няўжоўчыну даданы выклік scheduleWork і адразу ж яго адклёввае.
  • Гэты цыкл павтараецца без канца.
  • Рэзультат: час адканалення ніколі не дайжыць да фазы паллування libuv. Хоць ядро ОС вялікі ўжо порт 3000 і прымае пакеты TCP SYN, Node.js ніколі насправдзе не пераканалвае сокет. Прыходзячыя HTTP-запросы накапліваюцца ў черзе ядра, пакуль кліенты, якія прабуюць з’яўіцца, не выйдуць за час з кодам 504 Gateway Timeout.
  • 4. process.nextTick() проты setImmediate(): практычная разліка

    Хочацца супадзяць process.nextTick() і setImmediate() з прыбліжна аднаковымі інструментамі, але яны знаходзяцца на працоўных канцах порядку выконання часу адканалення:

    Функцыя | process.nextTick() | setImmediate()

    Дзе я выканаўваецца — калека мікразадач V8 проты фазы перагляду libuv (макразадача) Калі я выканаўваецца — адразу пасля таго, як потычная стака вызваў стане порожней, проты пад час наступнага циклу адбывання запуска Чы добра работае з I/O? — Ня, рекурэнтныя ланцюгі можаць цалкам заблокаваць I/O, проты Так, ён вяртае кантроль назад у цыкл Тыповая аплікацыя — нормалізацыя працы API і внутраніх налашчэнняў, проты разбіванне дорогіх задач на пакеты

    5. Калека проблемы: Саўместнае мнагазадачаванне за дапамой setImmediate()

    Для асінхронных задач, якім трэба час на завершэнне, патрэбны моделі, якія ніколі не блакуюць нитку і не ігнаруюць прыходзячыя з’ўязкі. Такія моделі часта называюцца саўместным мнагазадачаваннем.

    У зяместо таго, каб перайсці через усё адразу, кожны застаўшыся фрагмент работы плануецца за дапамогою setImmediate(). Гэта чыткаа прыказвае Node.js вярнуць кантроль назад да ціклу змаганняў пасля кожнага фрагмента, чым даецца можлівасць фазе палювання прачытаць чакаючыя пакеты сеті, прыняць адпаведзі з драйвераў базы дадзеных і обслужыць іншыя чакаючыя запиты пры продажу.

    /**
     * Processes an array in chunks while yielding the thread
     * back to libuv to prevent event loop starvation.
     */
    function processLargeDataset(records, chunkSize = 100) {
      let index = 0;
    
    function processChunk() {
        const end = Math.min(index + chunkSize, records.length);
    
        // Process current slice
        for (; index < end; index++) {
          transformRecord(records[index]);
        }
        if (index < records.length) {
          // Yield control: libuv can now poll I/O, timers, and accept HTTP traffic
          setImmediate(processChunk);
        } else {
          console.log("Batch processing completed safely.");
        }
      }
      // Initial trigger
      setImmediate(processChunk);
    }
    

    6. Праўаматываныя сцэнарыі выкарыстоўвання process.nextTick()

    Нічыга з гэтага не значыць, што process.nextTick() — гэта бяспраўны API; гэта проста прымітіва низкага рангу, яку трэба аднойчына вялікай адрозумеласцю выкарыстоўваць. Фактычна, внутршняя структура Node завісіць ад яе для двух важлівых задач.

    A. Адварот ад проблемы „Zalgo“

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

    import { readFile } from "node:fs";
    
    const cache = new Map();
    function getCachedConfig(filePath, callback) {
      if (cache.has(filePath)) {
        // Bad: callback(null, cache.get(filePath)); -> Sync execution!
    
        // Good: Ensures the caller's following lines run first, maintaining async contracts
        process.nextTick(() => {
          callback(null, cache.get(filePath));
        });
        return;
      }
      readFile(filePath, "utf-8", (err, data) => {
        if (err) return callback(err);
        cache.set(filePath, data);
        callback(null, data);
      });
    }
    

    B. Безпечнае выдача падзей з канстрактара

    Якщо клас, який расшыроўвае EventEmitter, выдае падзею зсередзіні свайго канстрактара, выхадзіць проблема: код, які інстанцыюе гэты об’ект, ўсё яшчэ не маў можлівасці зарэгістраваць жадных слухачоў, таму падзея проста згубіцца.

    import { EventEmitter } from "node:events";
    
    class DatabaseConnection extends EventEmitter {
      constructor(connectionString) {
        super();
        this.connect(connectionString);
        // Defer the event until the consumer finishes binding .on('connected', ...)
        process.nextTick(() => {
          this.emit("connected", { ready: true });
        });
      }
      connect(str) {
        // Socket setup logic...
      }
    }
    // Consumer code:
    const db = new DatabaseConnection("postgres://localhost:5432");
    db.on("connected", (status) => {
      // Listener is bound BEFORE the nextTick callback fires!
      console.log("Database initialized safely:", status);
    });
    

    Рэкамендаціі для прымесных систем

    • Не дазволяйце мікрозадачы самай сабе перепланавацца. Функцыя, якая продовжвае выклікваць process.nextTick() для сябе, ніколи не дазволі цыклу працаваць даўжэй.
  • Ўжыце setImmediate(), калі падзеляеце работу. Якщо перагляд калекцыі займае больш часу, чым кальколька мілісэкунд, разбейце яе на часткі і выкарыстоўваеце setImmediate(), каб пераходзіць межы ямі.
  • Заказваеце process.nextTick() для задаўанняў, якія выкарыстоўваюць адносна стабільную роботу. Сконцэнтруйцеся на ўзначытых ситуаціях — напрыклад, на безпечным выдачы аўтаматызаваных паведамленняў пад час стварэння аб’ектаў, або на перакананні, што калебачэнне з вялікім кэшаваным значэнням выкарыстоўваецца за той жа час, што і без кэшавання.
  • Сталоўна стежыце за затрымкамі ў цыкле аўтаматызаваных паведамленняў. Незалежна ад таго, які APM вы выкарыстоўвайце — Datadog, New Relic, OpenTelemetry — стежыце за паметрай nodejs.eventloop.delay. Пастойнае адныяўшчанне яе ёсць сильным сігналам пра тое, што якаясь частка коду блакуе сінхронна або што цыкл мікразадаўаннях заважае нормальнай роботе системы.