Галоўная > Цыкл падзей Node.js, таймеры і функцыя process.nextTick()

Цыкл падзей Node.js, таймеры і функцыя process.nextTick()

Nodejs
цыклпадзей
пераклад

Гэта пераклад артыкула "The Node.js Event Loop, Timers, and process.nextTick()" з афіцыйнага сайта NodeJs

Што такое Цыкл падзей?

Цыкл падзей (Event Loop) — гэта тое, што дазваляе Node.js выконваць аперацыі ўводу-вываду без блакіроўкі (нягледзячы на тое, што JavaScript з'яўляецца аднапаточным), шляхам выгрузкі аперацый у ядро сістэмы, калі гэта магчыма.

Большасць сучасных ядраў з'яўляюцца шматпаточнымі. Гэта значыць, што яны могуць апрацоўваць некалькі аперацый у фонавым рэжыме. Калі адна з гэтых аперацый завяршаецца, ядро паведамляе Node.js, што адпаведная callback-функцыя можа быць дададзена ў чаргу апытання (poll) для выканання. Мы разгледзім гэта больш падрабязна далей у артыкуле.

Падрабязней пра цыкл падзей

Пры старце Node.js ініцыялізуе цыкл падзей (event loop). Пасля гэтага апрацоўваецца ўводны скрыпт (input script) (або працэс трапляе ў REPL, але гэта не разглядаецца ў гэтым артыкуле). Уводны скрыпт можа рабіць асінхронныя выклікі API, планаваць таймеры або выклікаць функцыю process.nextTick(). Пасля апрацоўкі ўводнага скрыпта пачынаецца апрацоўка цыкла падзей.

На дыяграме ніжэй паказаны спрошчаны агляд паслядоўнасці аперацый цыкла падзей. Дыяграма аперацый цыкла падзей

Кожны блок дыяграмы далей будзе называецца "фазай" цыкла падзей.

Кожная фаза мае FIFO чаргу callback-функцый для выканання. Хоць кожная фаза па-свойму асаблівая, але звычайна кожная фаза працуе па наступным алгарытме. Калі цыкл падзей пераходзіць на пэўную фазу, то спачатку будуць выкананы ўсе аперацыі, характэрныя для гэтай фазы, а затым будуць выкананы callback-функцыі з чаргі гэтай фазы. Callback-функцыі фазы будуць выконвацца пакуль чарга не будзе вычарпана або пакуль не будзе выканана максімальна дазволеная колькасць callback-функцый. Калі чарга вычарпана або дасягнуты ліміт на колькасць выкананых callback-функцый, цыкл падзей пяройдзе да наступнай фазы і гэтак далей.

Паколькі любая з гэтых аперацый можа запланаваць больш аперацый і новыя падзеі, якія апрацоўваюцца на фазе poll, дадаюцца ў чаргу ядром, падзеі для фазы poll могуць дадавацца ў чаргу падчас апрацоўкі падзей фазы poll. Такім чынам callback-функцыі, якія патрабуюць шмат часу на выкананне, могуць дазволіць фазе poll працаваць значна даўжэй, чым ліміт чакання дадзенага таймера. Больш падрабязную інфармацыю глядзіце ў раздзелах timers і poll.

Існуе невялікае разыходжанне паміж рэалізацыяй Windows і Unix/Linux, але гэта не важна для гэтай дэманстрацыі. Тут будуць разгледжаны самыя важныя часткі. Фактычна існуе сем або восем этапаў, але тыя, якія нас цікавяць (тыя, якія насамрэч выкарыстоўвае Node.js) — прыведзены вышэй.

Агляд фаз

  • timers: на гэтай фазе выконваюцца callback-функцыі, якія былі запланаваны з дапамогай функцый setTimeout() і setInterval().
  • pending callbacks: выконваюцца callback-функцыі ўводу/вываду, якія былі адкладзены да наступнай ітэрацыі цыкла.
  • idle, prepare: толькі для ўнутранага выкарыстання.
  • poll: атрымліваюцца новыя падзеі ўводу-вываду; выконваюцца callback-функцыі звязаныя з уводам-вывадам (амаль усе, за выключэннем callback-функцый закрыцця і callback-функцый запланаваных таймерамі і функцыяй setImmediate()); Node будзе блакіраваць выкананне тут, калі гэта неабходна.
  • check: выклікаюцца callback-функцыі, зарэгістраваныя функцыяй setImmediate().
  • close callbacks: выконваюцца некаторыя callback-функцыі закрыцця, напрыклад, socket.on('close', ...).

Паміж кожным запускам цыкла падзей Node.js правярае, ці чакаюцца якія-небудзь асінхронныя аперацыі ўводу/вываду або таймеры (timers). Калі нічога няма, то працэс Node.js завяршае сваю працу.

Падрабязны агляд фаз

timers (таймеры)

Таймер вызначае парогавае значэнне, пасля якога зададзеная callback-функцыя можа быць выканана, а не дакладны час, калі яе трэба выканаць. Callback-функцыі таймера будуць выкананы як толькі Node.js зможа запланаваць іх пасля заканчэння вызначанага часу чакання. Аднак планаванне аперацыйнай сістэмы або выкананне іншых callback-функцый можа затрымаць іх выкананне.

Тэхнічна, фаза poll кантралюе час, калі будуць выкананы таймеры.

Напрыклад, вы запланавалі callback-функцыю, якая павінна быць выканана пасля 100мс чакання, пасля гэтага ваш скрыпт пачынае асінхроннае чытанне файла, якое займае 95мс:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Дапусцім, што гэта займе 95мс
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}мс прайшло з таго часу, як я была запланавана`);
}, 100);

// выканаем функцыю someAsyncOperation, якая займае 95 мс
someAsyncOperation(() => {
  const startCallback = Date.now();

  // зробім што-небудзь, што зойме 10 мс…
  while (Date.now() - startCallback < 10) {
    // нічога не рабіць
  }
});

Калі цыкл падзей пераходзіць у фазу poll, то яна мае пустую чаргу (функцыя fs.readFile() яшчэ не завершана), таму цыкл падзей будзе чакаць столькі мілісекунд, колькі засталося, да парогу чакання найбліжэйшага таймера. Пакуль ён чакае 95мс, функцыя fs.readFile() завяршае чытанне файла і callback-функцыя, выкананне якой займае 10мс, дадаецца ў чаргу фазы poll, а затым выконваецца. Калі callback-функцыя завершыцца, у чарзе больш не застанецца іншых callback-функцый, таму цыкл падзей убачыць, што парог чакання найбліжэйшага таймера дасягнуты, пасля чаго вернецца да фазы timers, каб выканаць callback-функцыі таймера. У гэтым прыкладзе вы ўбачыце, што агульная затрымка паміж запланаваным таймерам і выкананнем яго callback-функцыі складзе 105мс.

Каб прадухіліць фазу poll ад вычарпання цыкла падзей, libuv (бібліятэка напісаная на мове C, якая рэалізуе цыкл падзей Node.js і ўсе асінхронныя паводзіны платформы) таксама мае жорсткі максімум (які залежыць ад сістэмы), пасля якога яна спыніць апытанне новых падзей.

pending callbacks (callback-функцыі ў чаканні)

На гэтым этапе выконваюцца callback-функцыі для некаторых сістэмных аперацый, напрыклад, для памылак TCP. Некаторыя *nix сістэмы чакаюць перад тым як паведаміць, што TCP-сокет атрымаў памылку ECONNREFUSED падчас спробы злучэння. Гэта падзея будзе дададзена ў чаргу для выканання ў фазе pending callbacks.

poll (апытанне)

Фаза poll мае дзве асноўныя функцыі:

  1. Разлік таго, як доўга яна павінна блакіраваць і апытваць увод-вывад, а затым
  2. Апрацоўка падзей у чарзе фазы poll.

Калі цыкл падзей пераходзіць у фазу poll і запланаваныя таймеры адсутнічаюць, адбудзецца адно з двух:

  • Калі чарга фазы pollне пустая, цыкл падзей будзе перабіраць чаргу callback-функцый, выконваючы іх сінхронна, пакуль чарга не будзе вычарпана або не будзе дасягнуты сістэмны жорсткі ліміт.
  • Калі чарга фазы pollпустая, адбудзецца адно з двух:
    • Калі скрыпты былі запланаваны функцыяй setImmediate(), цыкл падзей скончыць фазу poll і пяройдзе да фазы check, каб выканаць гэтыя запланаваныя скрыпты.
    • Калі скрыпты не былі запланаваны з дапамогай функцыі setImmediate(), цыкл падзей будзе чакаць, пакуль callback-функцыі будуць дададзены ў чаргу, а затым неадкладна выканае іх.

Пасля таго, як чарга для фазы poll апусцее, цыкл пачне правяраць таймеры ў якіх быў дасягнуты парогавы час чакання. Калі адзін або некалькі таймераў дасягнулі гэты парог, цыкл падзей вернецца да фазы timers, каб выканаць запланаваныя callback-функцыі гэтых таймераў.

check (этап агляду)

Гэтая фаза дазваляе карыстальніку выконваць callback-функцыі адразу пасля завяршэння фазы poll. Калі фаза poll становіцца бяздзейнай і callback-функцыі былі пастаўлены ў чаргу з дапамогай функцыі setImmediate(), цыкл падзей можа перайсці да фазы check замест чакання.

Функцыя setImmediate() на самай справе з'яўляецца спецыяльным таймерам, які працуе ў асобнай фазе цыкла падзей. Яна выкарыстоўвае API бібліятэкі libuv, якое плануе выкананне callback-функцый пасля завяршэння фазы poll.

close callbacks (callback-функцыі закрыцця)

Калі сокет або дэскрыптар раптоўна закрываюцца (напрыклад, socket.destroy()), на гэтай фазе будзе сгенерыравана падзея 'close'. Інакш яна будзе сгенерыравана праз функцыю process.nextTick().

setImmediate() vs setTimeout()

Функцыі setImmediate() і setTimeout() падобныя, але паводзяць сябе па-рознаму ў залежнасці ад таго, калі яны былі выкліканы.

  • Функцыя setImmediate() прызначана для выканання скрыпта пасля завяршэння бягучай фазы poll.
  • Функцыя setTimeout() плануе запуск скрыпта пасля таго, як скончыцца мінімальны парог чакання ў мілісекундах.

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

Напрыклад, калі мы запусцім наступны скрыпт, які не знаходзіцца ўнутры цыкла ўводу-вываду (то-бок галоўнага модуля), парадак выканання двух таймераў не з'яўляецца дэтэрмінаваным, паколькі ён залежыць ад прадукцыйнасці працэсу:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

Аднак, калі вы перамесціце два выклікі ў цыкл уводу-вываду, неадкладная (immediate) callback-функцыя заўсёды будзе выконвацца першай:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

Асноўная перавага выкарыстання функцыі setImmediate() над функцыяй setTimeout() заключаецца ў тым, што функцыя setImmediate() заўсёды будзе выконвацца перад таймерамі, калі яна запланавана ўнутры цыкла ўводу-вывыду, незалежна ад колькасці таймераў.

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

Разуменне функцыі process.nextTick()

Магчыма, вы заўважылі, што функцыя process.nextTick() не была адлюстравана на дыяграме, хаця яна і з'яўляецца часткай асінхроннага API. Гэта адбылося таму, што функцыя process.nextTick() тэхнічна не з'яўляецца часткай цыкла падзей. Замест гэтага, чарга функцыі (nextTickQueue) будзе апрацавана пасля завяршэння бягучай аперацыі, незалежна ад бягучай фазы цыкла падзей. Аперацыя тут — гэта пераход кантролю ад асноўнага апрацоўшчыка C/C++, а таксама апрацоўка JavaScript, які неабходна выканаць.

Гледзячы на нашу схему можна ўбачыць, што кожны раз, калі вы выклікаеце функцыю process.nextTick() у зададзенай фазе, усе callback-функцыі перададзеныя ў функцыю process.nextTick() будуць выкананы перад тым, як цыкл падзей працягне сваю працу. Гэта можа прывесці да непрыемных вынікаў, таму што гэта дазваляе "вычарпаць" чаргу ўводу/вываду, зрабіўшы рэкурсіўныя выклікі функцыі process.nextTick(), што не дасць цыклу падзей дасягнуць фазы poll.

Чаму гэта дазволена?

Навошта дадаваць нешта падобнае ў Node.js? Часткова таму, што гэта філасофія дызайну, згодна з якой API заўсёды павінен быць асінхронным, нават калі гэта не патрэбна. Возьмем, напрыклад, гэты фрагмент кода:

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(
      callback,
      new TypeError('аргумент павінен мець тып string')
    );
}

У гэтым фрагменце кода адбываецца праверка аргумента, калі аргумент памылковы — у callback-функцыю будзе перададзена памылка. Даволі нядаўна API было абноўлена, каб дазволіць перадаваць аргументы ў функцыю process.nextTick(). Цяпер пасля callback-функцыі можна перадаць любую колькасць аргументаў і яны будуць перададзены ў callback-функцыю ў якасці яе аргументаў, такім чынам вам не трэбы ствараць укладзеныя функцыі.

Мы перадаём памылку назад карыстальніку, але толькі пасля таго, як астатняя частка карыстальніцкага кода будзе выканана. З дапамогай функцыі process.nextTick() мы гарантуем, што функцыя apiCall() заўсёды будзе запускаць свае callback-функцыі пасля астатняй часткі карыстальніцкага кода і да таго, як цыклу падзей будзе дазволена працягваць сваё выкананне. Для гэтага стэк выклікаў JS дазволена раскруціць, а потым неадкладна выканаць дадзеную callback-функцыю. Гэта дазваляе карыстальніку рабіць рэкурсіўныя выклікі функцыі process.nextTick() не атрымліваючы RangeError: Перавышаны максімальны памер стэка выкліку ад v8.

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

let bar;

// гэта функцыя мае асінхронную сігнатуру, але выклікае callback-функцыю сінхронна
function someAsyncApiCall(callback) {
  callback();
}

// callback-функцыя выклікаецца да завяршэння `someAsyncApiCall`.
someAsyncApiCall(() => {
  // паколькі функцыя someAsyncApiCall не была завершана, пераменнай bar 
  // не было прысвоена ніякае значэнне
  console.log('bar', bar); // undefined
});

bar = 1;

Карыстальнік вызначае функцыю someAsyncApiCall() як функцыю з асінхроннай сігнатурай, але яна на самай справе працуе сінхронна. Калі яе выклікаюць, то callback-функцыя, перададзеная ў someAsyncApiCall(), выклікаецца ў той жа фазе цыкла падзей, таму што someAsyncApiCall() насамрэч не робіць нічога асінхроннага. У выніку callback-функцыя спрабуе спасылацца на пераменную bar, хоць такой пераменнай яшчэ можа не быць у вобласці бачнасці гэтай функцыі, таму што скрыпт магчыма яшчэ не дайшоў да канца.

Калі мы памесцім callback-функцыю ў process.nextTick(), то скрыпт будзе выкананы да канца, і толькі пасля гэтага будзе выканана callback-функцыя, а значыць, што ў момант выканання callback-функцыі ўсе пераменныя, функцыі і г.д. ужо будуць ініцыялізаваныя. Перавага гэтага спосабу яшчэ і ў тым, што ён не дазваляе цыклу падзей працягваць сваё выкананне. Гэта можа быць карысна калі мы хочам папярэдзіць карыстальніка аб памылцы да таго, як цыкл падзей працягне сваю працу. Вось папярэдні прыклад з выкарыстаннем функцыі process.nextTick():

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

Вось яшчэ адзін рэальны прыклад:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

Калі перадаецца толькі порт, то порт прывязваецца адразу ж. Такім чынам, callback-функцыю для падзеі 'listening' можна неадкладна выклікаць. Праблема заключаецца ў тым, што callback-функцыя .on('listening') у гэты момант яшчэ не будзе зададзена.

Каб абысці гэта, падзея 'listening' ставіцца ў чаргу праз функцыю nextTick(), каб дазволіць скрыпту дайсці да канца. Гэта дазваляе карыстальніку дадаць любыя апрацоўшчыкі падзей.

process.nextTick() супраць setImmediate()

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

  • Функцыя process.nextTick() запускаецца адразу ў той жа самай фазе
  • Функцыя setImmediate() спрацоўвае на наступнай ітэрацыі або 'ціку' цыкла падзей

Па сутнасці, імёны трэба памяняць месцамі. Функцыя process.nextTick() спрацоўвае хутчэй, чым функцыя setImmediate(), але гэта артэфакт мінулага, і вельмі малаверагодна, што гэта ўжо зменіцца. Гэта змена прывядзе да паломкі вялікага працэнта існуючых пакетаў у npm. Новыя модулі дадаюцца кожны дзень, таму з кожным днём чакання, колькасць патэнцыяльных праблем павялічваецца. І хоць назвы функцый прыводзяць да блытаніны, але яны не зменяцца.

Мы рэкамендуем распрацоўшчыкам выкарыстоўваць функцыю setImmediate(), таму што яе прасцей зразумець.

Навошта выкарыстоўваць функцыю process.nextTick()?

На гэта ёсць дзве асноўныя прычыны:

  1. Дазволіць карыстальнікам апрацоўваць памылкі, ачышчаць усе непатрэбныя рэсурсы або, напрыклад, паўтарыць запыт, перш чым цыкл падзей працягне сваю працу.
  2. Часам неабходна дазволіць выкананне callback-функцыі пасля раскручвання стэка выклікаў, але перад тым, як цыкл падзей працягне сваю працу.

Адзін з прыкладаў — адпавядаць чаканням карыстальніка. Просты прыклад:

const server = net.createServer();
server.on('connection', (conn) => {});

server.listen(8080);
server.on('listening', () => {});

Уявім, што функцыя listen() запускаецца ў пачатку цыкла падзей, але callback-функцыя, для падзеі 'listening' змяшчаецца ў функцыі setImmediate(). Акрамя выпадку калі імя хоста перададзена, прывязка да порта адбудзецца імгненна. Для працягу сваёй працы цыкл падзей павінен перайсці ў фазу poll, што азначае, што існуе шанец таго, што злучэнне магло быць атрымана. Гэта значыць, што падзея 'connection' была створана да падзеі 'listening'.

Іншы прыклад — пашырэнне EventEmitter і стварэнне падзеі ў канструктары:

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    this.emit('event');
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('адбылася падзея!');
});

Вы не можаце стварыць падзею наўпрост з канструктара, таму што скрыпт яшчэ не дойдзе да таго месца, дзе карыстальнік прызначае callback-функцыю для апрацоўкі гэтай падзеі. Таму ў самім канструктары вы можаце выкарыстоўваць функцыю process.nextTick(), і задаць ёй callback-функцыю, якая створыць падзею ўжо пасля завяршэння працы канструктара, што забяспечыць чаканыя вынікі:

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();

    // выкарыстоўвайце nextTick, каб стварыць падзею, як толькі прызначаны апрацоўшчык
    process.nextTick(() => {
      this.emit('event');
    });
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('адбылася падзея!');
});
Дзмітрый Зубялевіч, 2023-05-09
Каментары

    (Каб даслаць каментар залагуйцеся ў свой уліковы запіс)

    ;