Pochopení smyčky událostí Node.js

Tento článek vám pomůže pochopit, jak je Node.jsNode.js asynchronní událostmi řízený běhový modul JavaScriptu a je nejúčinnější při vytváření škálovatelných síťových aplikací. Node.js je bez zámků, takže neexistuje žádná šance na zablokování jakéhokoli procesu. smyčka událostí funguje a jak ji můžete využít k vytváření rychlých aplikací. Probereme také nejčastější problémy, se kterými se můžete setkat, a jejich řešení.

S Node.js v měřítku vytváříme kolekci článků zaměřených na potřeby společností s většími instalacemi Node.js a vývojářů, kteří se již naučili základy Node.

Zobrazit všechny kapitoly Node.js v měřítku:

  • Používání npmnpm je softwarový registr, který obsluhuje více než 1,3 milionu balíčků. npm používají vývojáři open source z celého světa ke sdílení a vypůjčování kódu, stejně jako mnoho firem. npm má tři součásti:web rozhraní příkazového řádku (CLI) registr Pomocí webu můžete objevovat a stahovat balíčky, vytvářet uživatelské profily a...
    • npm triky a doporučené postupy
    • Publikování SemVer a modulů
    • Pochopení modulového systému, CommonJS a požadavků
  • Node.js Internals Deep Dive
    • Smyčka událostí Node.js [ tento článek ]
    • Vysvětlení Node.js Garbage Collection
    • Psaní nativních modulů Node.js
  • Vytváření pomocí Node.js
    • Pokročilá struktura projektu Node.js
    • Doporučené postupy pro čisté kódování JavaScript
    • Doporučené postupy pro asynchronizaci Node.js
    • Zdrojování událostí s příklady
    • Vysvětlení CQRS (oddělení odpovědnosti za příkazový dotaz)
  • Testování + uzel
    • Testování Node.js a správné nastavení TDD
    • Úplné testování Node.js s Nightwatch.js
  • Node.js v produkci
    • Konečný průvodce pro monitorování aplikací Node.js
    • Jak ladit Node.js pomocí nejlepších dostupných nástrojů
    • Post-mortem Diagnostika a ladění Node.js
  • Node.js + MicroservicesMicroservices nejsou nástrojem, spíše způsobem myšlení při vytváření softwarových aplikací. Začněme vysvětlení opačným směrem:pokud vyvíjíte jedinou samostatnou aplikaci a neustále ji vylepšujete jako celek, obvykle se nazývá monolit. Postupem času je stále obtížnější jej udržovat a aktualizovat, aniž by se něco porušilo, takže vývojový cyklus může...
    • Distribuované sledování
    • Brány API

Problém

Většina backendů za webovými stránkami nemusí provádět složité výpočty. Naše programy tráví většinu času čekáním, než disk přečte a zapíše, nebo čekáním, až drát přenese naši zprávu a odešle odpověď.

IO operace mohou být řádově pomalejší než zpracování dat. Vezměme si například toto:SSD mohou mít rychlost čtení 200-730 MB/s – alespoň high-end. Načtení pouhého jednoho kilobajtu dat by zabralo 1,4 mikrosekundy, ale během této doby mohl CPU taktovaný na 2 GHz provést 28 000 cyklů zpracování instrukcí.

Pro síťovou komunikaci to může být ještě horší, zkuste pingnout na google.com

$ ping google.com
64 bytes from 172.217.16.174: icmp_seq=0 ttl=52 time=33.017 ms
64 bytes from 172.217.16.174: icmp_seq=1 ttl=52 time=83.376 ms
64 bytes from 172.217.16.174: icmp_seq=2 ttl=52 time=26.552 ms
64 bytes from 172.217.16.174: icmp_seq=3 ttl=52 time=40.153 ms
64 bytes from 172.217.16.174: icmp_seq=4 ttl=52 time=37.291 ms
64 bytes from 172.217.16.174: icmp_seq=5 ttl=52 time=58.692 ms
64 bytes from 172.217.16.174: icmp_seq=6 ttl=52 time=45.245 ms
64 bytes from 172.217.16.174: icmp_seq=7 ttl=52 time=27.846 ms

Průměrná latence je asi 44 milisekund. Jen při čekání na oběh paketu po drátu dokáže výše zmíněný procesor vykonat 88 milionů cyklů.

Řešení

Většina operačních systémů poskytuje nějaký druh asynchronního IO rozhraní, které umožňuje začít zpracovávat data, která nevyžadují výsledek komunikace, přičemž komunikace stále pokračuje..

Toho lze dosáhnout několika způsoby. V dnešní době se to většinou provádí využitím možností multithreadingu za cenu dodatečné softwarové složitosti. Například čtení souboru v Javě nebo Pythonu je operace blokování. Váš program nemůže dělat nic jiného, ​​když čeká na dokončení komunikace mezi sítí a diskem. Jediné, co můžete udělat – alespoň v Javě – je zapnout jiné vlákno a po dokončení operace upozornit hlavní vlákno.

Je to zdlouhavé, složité, ale svou práci zvládne. Ale co Node? No, určitě čelíme nějakým problémům, protože Node.js – nebo spíše V8 – je jednovláknový. Náš kód může běžet pouze v jednom vlákně.

EDIT:Není to tak úplně pravda. Java i Python mají asyncAsynchrony, v softwarovém programování označuje události, které se vyskytují mimo primární tok programu, a metody, jak se s nimi vypořádat. Externí události, jako jsou signály nebo aktivity vyvolané programem, které se vyskytují současně s prováděním programu, aniž by způsobily zablokování programu a čekání na výsledky, jsou příklady této kategorie. Asynchronní vstup/výstup je... rozhraní, ale jejich použití je rozhodně obtížnější než v Node.js. Děkujeme Shahar a Dirku Harringtonovi za upozornění.

Možná jste to slyšeli v prohlížeči nastavení setTimeout(someFunction, 0) někdy dokáže věci magicky opravit. Proč ale nastavení časového limitu na 0, odložení spuštění o 0 milisekund, něco vyřeší? Není to totéž, jako jednoduše zavolat someFunction? ihned? Vlastně ne.

Nejprve se podívejme na zásobník volání, nebo jednoduše „zásobník“. Udělám věci jednoduše, protože potřebujeme pouze porozumět samotným základům zásobníku volání. Pokud víte, jak to funguje, neváhejte přejít na další sekci.

Zásobník

Kdykoli zavoláte návratovou adresu funkce, parametry a lokální proměnné se přesunou do zásobníku. Pokud zavoláte jinou funkci z aktuálně spuštěné funkce, její obsah se přesune nahoru stejným způsobem jako předchozí – s její návratovou adresou.

Pro zjednodušení řeknu, že „funkce je od nynějška posunuta“ na vrchol zásobníku, i když to není úplně správné.

Pojďme se na to podívat!

 1 function main () {
 2   const hypotenuse = getLengthOfHypotenuse(3, 4)
 3   console.log(hypotenuse)
 4 }
 5
 6 function getLengthOfHypotenuse(a, b) {
 7   const squareA = square(a)
 8   const squareB = square(b)
 9   const sumOfSquares = squareA + squareB
10   return Math.sqrt(sumOfSquares)
11 }
12
13 function square(number) {
14   return number * number
15 }
16 
17 main()

main se nazývá první:

pak main zavolá getLengthOfHypotenuse s 3 a 4 jako argumenty

poté má čtverec hodnotu a

když se čtverec vrátí, je odstraněn ze zásobníku a jeho návratová hodnota je přiřazena squareA . squareA je přidán do zásobníku getLengthOfHypotenuse

totéž platí pro další volání do čtverce

na dalším řádku výraz squareA + squareB je hodnocen

pak se Math.sqrt zavolá pomocí sumOfSquares

nyní vše zbývá pro getLengthOfHypotenuse je vrátit konečnou hodnotu jeho výpočtu

vrácená hodnota bude přiřazena hypotenuse v main

hodnotu hypotenuse je přihlášen do konzole

konečně main vrátí bez jakékoli hodnoty, vyskočí ze zásobníku a zůstane prázdný

BOČNÍ POZNÁMKA:Viděli jste, že místní proměnné jsou vyskakovány ze zásobníku po dokončení provádění funkcí. Stává se to pouze tehdy, když pracujete s jednoduchými hodnotami, jako jsou čísla, řetězce a booleany. Hodnoty objektů, polí a podobně jsou uloženy v haldě a vaše proměnná je pouze ukazatelem na ně. Pokud předáte tuto proměnnou, předáte pouze zmíněný ukazatel, takže tyto hodnoty budou měnitelné v různých rámcích zásobníku. Když je funkce vytažena ze zásobníku, vyskočí pouze ukazatel na objekt, přičemž aktuální hodnota zůstane v haldě. Popelář je člověk, který se stará o uvolnění místa, jakmile předměty přežijí svou užitečnost.

Zadejte smyčku události Node.js

Ne, tahle smyčka ne. 🙂

Co se tedy stane, když zavoláme něco jako setTimeout , http.get , process.nextTick nebo fs.readFile ? Ani jednu z těchto věcí nelze najít v kódu V8, ale jsou k dispozici v Chrome WebApi a C++ API v případě Node.js. Abychom to pochopili, budeme muset trochu lépe porozumět pořadí provádění.

Podívejme se na běžnější aplikaci Node.js – server naslouchající na localhost:3000/ . Po obdržení požadavku server zavolá wttr.in/<city> Chcete-li zjistit počasí, vytiskněte na konzolu nějaké milé zprávy a ta přepošle odpovědi volajícímu poté, co je obdrží.

'use strict'
const express = require('express')
const superagent = require('superagent')
const app = express()

app.get('/', sendWeatherOfRandomCity)

function sendWeatherOfRandomCity (request, response) {
  getWeatherOfRandomCity(request, response)
  sayHi()
}

const CITIES = [
  'london',
  'newyork',
  'paris',
  'budapest',
  'warsaw',
  'rome',
  'madrid',
  'moscow',
  'beijing',
  'capetown',
]

function getWeatherOfRandomCity (request, response) {
  const city = CITIES[Math.floor(Math.random() * CITIES.length)]
  superagent.get(`wttr.in/${city}`)
    .end((err, res) => {
      if (err) {
        console.log('O snap')
        return response.status(500).send('There was an error getting the weather, try looking out the window')
      }
      const responseText = res.text
      response.send(responseText)
      console.log('Got the weather')
    })

  console.log('Fetching the weather, please be patient')
}

function sayHi () {
  console.log('Hi')
}

app.listen(3000)

Co bude vytištěno kromě zjištění počasí, když je požadavek odeslán na localhost:3000 ?

Pokud máte nějaké zkušenosti s Node, neměli byste být překvapeni, že i když console.log('Fetching the weather, please be patient') je voláno po console.log('Got the weather') v kódu se první vytiskne jako první a výsledkem bude:

Fetching the weather, please be patient
Hi
Got the weather

Co se stalo? I když je V8 jednovláknové, základní C++ API Node není. Znamená to, že kdykoli zavoláme něco, co je neblokující operace, Node zavolá nějaký kód, který poběží souběžně s naším javascriptovým kódem pod kapotou. Jakmile toto skryté vlákno obdrží hodnotu, na kterou čeká, nebo vyvolá chybu, bude zavoláno zpětné volání s potřebnými parametry.

BOČNÍ POZNÁMKA:„Nějaký kód“, který jsme zmínili, je ve skutečnosti součástí libuv. libuv je knihovna s otevřeným zdrojovým kódem, která zpracovává fond vláken, provádí signalizaci a všechna další kouzla, která jsou potřebná k tomu, aby asynchronní úlohy fungovaly. Původně byl vyvinut pro Node.js, ale již jej využívá mnoho dalších projektů.

Potřebujete pomoc s vývojem Node.js na podnikové úrovni?

Najměte si odborníky na Node.js z RisingStack!

Abychom mohli nahlédnout pod pokličku, musíme představit dva nové koncepty:smyčku událostí a frontu úkolů.

Fronta úkolů

Javascript je jednovláknový jazyk řízený událostmi. To znamená, že můžeme k událostem připojit posluchače, a když se událost spustí, posluchač provede námi poskytnuté zpětné volání.

Kdykoli zavoláte na číslo setTimeout , http.get nebo fs.readFile , Node.js odesílá tyto operace do jiného vlákna, což umožňuje V8 pokračovat ve spouštění našeho kódu. Node také volá zpětné volání, když čítač došel nebo skončila operace IO/http.

Tato zpětná volání mohou zařadit do fronty další úkoly a tyto funkce mohou zařadit další a tak dále. Tímto způsobem můžete číst soubor při zpracování požadavku na vašem serveru a poté provést volání http na základě přečteného obsahu, aniž byste zablokovali zpracování jiných požadavků.

Máme však pouze jedno hlavní vlákno a jeden zásobník volání, takže v případě, že je při čtení uvedeného souboru obsluhován další požadavek, jeho zpětné volání bude muset počkat, až se zásobník vyprázdní. Limbo, kde zpětná volání čekají, až na ně přijde řada, se nazývá fronta úloh (nebo fronta událostí nebo fronta zpráv). Zpětná volání jsou volána v nekonečné smyčce, kdykoli hlavní vlákno dokončí svůj předchozí úkol, odtud název „smyčka událostí“.

V našem předchozím příkladu by to vypadalo nějak takto:

  1. expres zaregistruje obsluhu pro událost „request“, která bude volána, když požadavek dorazí na „/“
  2. přeskočí funkce a začne naslouchat na portu 3000
  3. zásobník je prázdný a čeká na spuštění události ‚request‘
  4. při příchozím požadavku se spustí dlouho očekávaná událost a expresní volání zavolá poskytnutou obsluhu sendWeatherOfRandomCity
  5. sendWeatherOfRandomCity je odsunut do zásobníku
  6. getWeatherOfRandomCity je zavolán a odsunut do zásobníku
  7. Math.floor a Math.random jsou volány, vloženy do zásobníku a vyskakovány, a z cities je přiřazeno city
  8. superagent.get je voláno s 'wttr.in/${city}' , handler je nastaven na end událost.
  9. požadavek http na http://wttr.in/${city} je odeslána do vlákna na pozadí a provádění pokračuje
  10. 'Fetching the weather, please be patient' je přihlášen do konzole, getWeatherOfRandomCity vrací
  11. sayHi se nazývá 'Hi' se vytiskne na konzoli
  12. sendWeatherOfRandomCity vrátí se, vyskočí ze zásobníku a zůstane prázdný
  13. čekání na http://wttr.in/${city} odeslat odpověď
  14. po obdržení odpovědi end událost je spuštěna.
  15. anonymous handler jsme předali .end() je zavolán, je odeslán do zásobníku se všemi proměnnými v jeho uzávěru, což znamená, že může vidět a upravovat hodnoty express, superagent, app, CITIES, request, response, city a všechny funkce, které jsme definovali
  16. response.send() se volá buď s 200 nebo 500 statusCode, ale opět je odeslán do vlákna na pozadí, takže stream odpovědí neblokuje naše provedení, anonymous handler vyskočí ze zásobníku.

Nyní tedy můžeme pochopit, proč dříve zmíněný setTimeout hack funguje. I když jsme nastavili počítadlo na nulu, odloží provedení, dokud aktuální zásobník a fronta úloh nebudou prázdné, což prohlížeči umožní překreslit uživatelské rozhraní nebo uzlu obsloužit jiné požadavky.

Mikroúkoly a makroúkoly

Pokud by to nestačilo, máme ve skutečnosti více než jednu frontu úkolů. Jeden pro mikroúlohy a druhý pro makroúlohy.

příklady mikroúloh:

  • process.nextTick
  • promises
  • Object.observe

příklady makroúloh:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O

Podívejme se na následující kód:

console.log('script start')

const interval = setInterval(() => {
  console.log('setInterval')
}, 0)

setTimeout(() => {
  console.log('setTimeout 1')
  Promise.resolve().then(() => {
    console.log('promise 3')
  }).then(() => {
    console.log('promise 4')
  }).then(() => {
    setTimeout(() => {
      console.log('setTimeout 2')
      Promise.resolve().then(() => {
        console.log('promise 5')
      }).then(() => {
        console.log('promise 6')
      }).then(() => {
        clearInterval(interval)
      })
    }, 0)
  })
}, 0)

Promise.resolve().then(() => {
  console.log('promise 1')
}).then(() => {
  console.log('promise 2')
})

toto se přihlásí do konzole:

script start
promise1
promise2
setInterval
setTimeout1
promise3
promise4
setInterval
setTimeout2
setInterval
promise5
promise6

Podle specifikace WHATVG by měla být z fronty makroúloh v jednom cyklu smyčky událostí zpracována právě jedna (makro)úloha. Po dokončení této makroúlohy budou všechny dostupné mikroúlohy zpracovány ve stejném cyklu. Zatímco se tyto mikroúlohy zpracovávají, mohou zařadit do fronty další mikroúlohy, které budou všechny spouštěny jeden po druhém, dokud nebude fronta mikroúloh vyčerpána.

Tento diagram se snaží udělat obrázek trochu jasnějším:

V našem případě:

Cyklus 1:

  1. `setInterval` je naplánováno jako úkol
  2. `setTimeout 1` je naplánován jako úkol
  3. v `Promise.resolve 1` jsou obě `pak` naplánovány jako mikroúlohy
  4. zásobník je prázdný, mikroúlohy jsou spuštěny

Fronta úkolů:setInterval , setTimeout 1

2. cyklus:

  1. fronta mikroúloh je prázdná, lze spustit obsluhu `setInteval`, další `setInterval` je naplánován jako úloha, hned za `setTimeout 1`

Fronta úkolů:setTimeout 1 , setInterval

3. cyklus:

  1. fronta mikroúloh je prázdná, lze spustit obsluhu `setTimeout 1`, `promise 3` a `promise 4` jsou naplánovány jako mikroúlohy,
  2. spouštějí se obslužné rutiny `promise 3` a `promise 4` `setTimeout 2` je naplánován jako úloha

Fronta úkolů:setInterval , setTimeout 2

Cyklus 4:

  1. fronta mikroúloh je prázdná, lze spustit obsluhu `setInteval`, další `setInterval` je naplánován jako úloha, hned za `setTimeout`

Fronta úkolů:setTimeout 2 , setInteval

  1. Spuštění obslužného programu `setTimeout 2`, `promise 5` a `promise 6` jsou naplánovány jako mikroúlohy

Nyní manipulátory promise 5 a promise 6 by měl být spuštěn vymazáním našeho intervalu, ale z nějakého podivného důvodu setInterval je znovu spuštěn. Pokud však tento kód spustíte v prohlížeči Chrome, získáte očekávané chování.

Můžeme to opravit i v Node pomocí process.nextTick a nějakého ohromujícího zpětného volání.

console.log('script start')

const interval = setInterval(() => {
  console.log('setInterval')
}, 0)

setTimeout(() => {
  console.log('setTimeout 1')
  process.nextTick(() => {
    console.log('nextTick 3')
    process.nextTick(() => {
      console.log('nextTick 4')
      setTimeout(() => {
        console.log('setTimeout 2')
        process.nextTick(() => {
          console.log('nextTick 5')
          process.nextTick(() => {
            console.log('nextTick 6')
            clearInterval(interval)
          })
        })
      }, 0)
    })
  })
})

process.nextTick(() => {
  console.log('nextTick 1')
  process.nextTick(() => {
    console.log('nextTick 2')
  })
})

To je přesně stejná logika, jakou používají naše milované sliby, jen o něco ohavnější. Alespoň to dělá práci tak, jak jsme očekávali.

Zkroťte asynchronní zvíře!

Jak jsme viděli, musíme při psaní aplikace v Node.js spravovat a věnovat pozornost jak frontám úloh, tak i smyčce událostí – v případě, že chceme využít všechnu její sílu a pokud chceme udržet náš dlouhodobý provoz úkoly z blokování hlavního vlákna.

Smyčka událostí může být zpočátku kluzký koncept, ale jakmile se do toho pustíte, nebudete si schopni představit, že bez ní existuje život. Styl předávání pokračování, který může vést k peklu zpětného volání, může vypadat ošklivě, ale máme Promises a brzy budeme mít v rukou async-await... a zatímco my (a)čekáme, můžete simulovat async-await pomocí co a /nebo koa.

Ještě jedna rada na rozloučenou:

Když víte, jak Node.js a V8 zvládnou dlouhodobé spouštění, můžete je začít používat pro své vlastní dobro. Možná jste již dříve slyšeli, že byste měli své dlouhé běžící smyčky poslat do fronty úloh. Můžete to udělat ručně nebo použít async.js.

Hodně štěstí při kódování!

Pokud máte nějaké dotazy nebo myšlenky, podělte se o ně v komentářích, budu tam! Další část série Node.js at Scale pojednává o Garbage Collection v Node.js, doporučuji se na to podívat!