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

Як JavaScript выконвае ваш код: стак, хіп, цыкл здачы справ і генераторы

Экскурсія па рэалайзаванні JavaScript: стак дзвянакоў і фрэмы, кансаекты хіпу, цыкл здарэнняў Node.js, DOM, калбэкі, праграмы, async/await і генератары.

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

Крыніцы: JavaScript

Асінхронны JavaScript частаць ведаецца так, што дзіўі разработчыкаў: лінія журналу з’являецца „занадта рана“, await здаецца прыскаквае далей, генератор выдрукоўвае толькі частку сваіх запаведзянняў. Практычна кожная з гэтых неспадзянак зникае, калі у вас ёсць чыстая модэль таго, як среда выканання експлуатуе код. У гэтым артыкуле гэтая модэль ствараецца пашагоўна — ад стака вызваў і хіта через цыкл задач Node.js і DOM да праграм, async/await і генератораў, так што вы можете запрацаваць рэзультат кожнага фрагмента прычынам яго чытання.

Элементы среды выканання

JavaScript выканана на аднай галоўной ніткаі, пры тым програмы чакаюць на таймеры, адпаведзі з сеті і файлы без замарожвання, таму што двайнік не працюе самастоятельна:

  • стак вызваў, які фіксуе функцыі, якія зараз выкананы
  • heap — меса, дзе знаходзяцца об’екты і іншы даны, выдылены дынамічна
  • API хоста (Web APIs у браузерах, Node.js APIs на сервере), якія керуюць таймерамі, файламі і сеціяванням
  • рэяды вызваў (задач) — меса, дзе завершаныя задачы чакаюць на выконанне
  • рэяд мікразадач, які в основным чынніку викорыстоўваецца пры обявленнях promises
  • цыкл адбытка падзеяў — элемент, які выбірае, што будзе выканана далей
  • Ваш код выконваецца на стаке вызваў, тады калі сэрвіс выконання адпрацоўвае повольныя операцыі. Калі якая-небудзь з іх завершыцца, ўсунутая яе вызваў падаецца у рэяд, а цыкл адбытка падзеяў перакладае яе на стак, калі ён стане вольным.

    Цыкл адбытка падзеяў у Node.js

    У дыяграме паказан цыкл Node.js. Спачатку выконваецца сінхронны код, пасля — мікразадачы з кошчыку, а потым фазы цыклу: таймеры (setTimeout, setInterval), запланаваныя калебры, процес польнага (poll, дзе выконваюцца большасць калебраў для файлаў і сеті), перагляд (setImmediate) і калебры закрыцья.

    ┌───────────────────┐
    │    CALL STACK     │
    │    JavaScript     │
    └─────────┬─────────┘
              │
              │ Synchronous code finishes
              ▼
    ┌───────────────────┐
    │  MICROTASK QUEUE  │
    │                   │
    │  Promise.then()   │
    │  Promise.catch()  │
    │  queueMicrotask() │
    └─────────┬─────────┘
              │
              ▼
    ╔═════════════════════════════════════════════╗
    ║                EVENT LOOP                   ║
    ║                                             ║
    ║  ┌───────────────┐                          ║
    ║  │     TIMERS    │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │    PENDING    │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     POLL      │                          ║
    ║  │      I/O      │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CHECK     │                          ║
    ║  │  setImmediate │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CLOSE     │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          └───────────────────────────────┐  ║
    ║                                          │  ║
    ╚══════════════════════════════════════════╪══╝
                                               │
                                               ▼
                                      ┌───────────────────┐
                                      │    CALL STACK     │
                                      │    Execute JS     │
                                      └───────────────────┘
    

    Важны два аспекты. Мікразадачы обрабоцваны не толькі адна раз на кожны цикл: Node спрачоўвае чергу мікразадач (а таксама і чергу process.nextTick да яе) пасля кожнага калебэка, таму працэсар .then абявы прагнення выкананы раней, чым запускаецца наступны калебэк таймера. А браузеры выкарыстоўваюць прынцып задач плюс мікразадач, але не маюць функцый setImmediate чы ўзлосу libuv, і яны перамішваюць процесы атрыбутавання. Па деталям, спецыфічным для Node, адзірніце як работаюць разам libuv, цыкл змагання і пул задач.

    Стак вызваў

    Стак вызваў фіксуе, якая функцыя зараз выканана і якія функцыі чакаюць, пакуль вона не завершыцца. Це структура LIFO — «пасляльгавае выведанне»: функцыя, якая была вызвана апошней, павінна завершыцца, перш чым функцыя, якая знаходзіцца нижэй, зможа продаваць роботу.

    У наступным прыкладзе func1 вызывае func2, які зноў вызывае func3, які фіксуе паведамленне:

    function func1() {
      func2();
    }
    function func2() {
      func3();
    }
    function func3() {
      console.log("Hello");
    }
    func1();
    

    Вызывы выконваюцца ў такой спрабе:

    func1()
       ↓
    func2()
       ↓
    func3()
       ↓
    console.log()
    

    Падобна до стакана з тарэлкамі, console.log выканана ў канцы і першая, пасля чаго адпаведна вяртаюцца func3, func2 і func1. Кале стакан спражоўваецца, толькі тады можна запусціць зачаканыя функцыі-вызывы, і самэ гэта прычына таго, што дзейсненне дужа довгаг сінхроннага цыклу блакуе всё іншае.

    Heap

    Heap — это памяц для дадзеных, чыя трываласць жыцця не прыязана да адного вызыву, такіх як об’екты, масівы і клозуры.

    Стварэнне літералу об’екта выдзеляе яго ў heap:

    const user = {
      name: "Rayane",
      age: 20
    };
    

    Зменная храніць толькі аб’ектны кансэльт до об’екта, які знаходзіцца ў heap:

    CALL STACK                  HEAP
    ┌───────────────┐          ┌────────────────────┐
    │ user variable │─────────▶│ {                  │
    │               │          │   name: "Rayane",  │
    └───────────────┘          │   age: 20          │
                               │ }                  │
                               └────────────────────┘
    

    Калі user прызначаецца іншайяй зменный, копіюецца толькі абсалютна нарадка, а не сам об’ект, таму змяны, якія відбуваюцца ў будзь-кай з іх, стаюць виднымі ў обох. Калі не застаецца жаднойя доступнай нарадкі, якая бы вказвала на об’ект, калекцыя сметлінага мусуе яго звалічыць; длительна закрыцця, якая зафіксавае об’ект, прымушвае яго застацца жывым.

    Што координуе цикл здарэнняў

    Цикл здарэнняў спаявае ваш код з завершэннем асінхронных заданняў. У Node.js ваш код выкананы V8; вызов API Node, такі як чытанне файла, пераходзіць у libuv, якая передае його оператывнай системе або ў яе пул нитак. Калі заданне завершыцца, калбэк стае гатовым, і цикл здарэнняў востанні час выканае яго на стэкі.

    JavaScript code
          │
          ▼
       V8 Engine
          │
          ▼
      Call Stack
          │
          ▼
    Node.js APIs
          │
          ▼
        libuv
          │
          ├───────────────┐
          ▼               ▼
         OS           Thread Pool
          │               │
          └───────┬───────┘
                  ▼
         Asynchronous operation
                  │
                  ▼
         Callback becomes ready
                  │
                  ▼
             Event Loop
                  │
                  ▼
           Callback Queue
                  │
                  ▼
             Call Stack
    

    Маршрут залежыць ад операцыі: сокеты зазвычай обрабоўваюцца оператывной системай через epoll або kqueue, тады як робота з файлавай системай, а таксама дзеяння, якія стосуюцца крыптаграфіі і DNS, выкананы за дапамою пулу нитак.

    Рамкі стака

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

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

    Возьмімо функцыю, якая дадае два числы:

    function add(a, b) {
      const result = a + b;
      return result;
    }
    add(2, 3);
    

    Колі выканана функцыя add(2, 3), яе рамка містіць аргументы, локальны результат і вяліканец да вызываючай функцыі:

         CALL STACK
    ┌─────────────────────┐
    │   stack frame: add  │
    │                     │
    │   a = 2             │
    │   b = 3             │
    │   result = 5        │
    │   return → caller   │
    └─────────────────────┘
    

    Калі функцыя вяртаецца, яе рамка абрактуюцца:

         CALL STACK
    ┌─────────────────────┐
    │   frame removed     │
    └─────────────────────┘
    

    Усі етапы жыццёвага циклу функцыі є стиснутымі:

    Function call
          ↓
    Stack Frame created
          ↓
    Function executes
          ↓
    Function returns
          ↓
    Stack Frame removed
    

    Рекурсія, якая заходзіць занадта глыбока, накаплюе рамкі стака, пакуль двайчыннік не выдае прымэту RangeError за перакрыцча максымальнага размеру стака вызываў.

    DOM

    Модэль об’екта дакумента — это жывая структура вяроўак, якая ствараецца браузерам на адной з HTML-дакументаў. За ўзмоцю яе JavaScript можа:

    • чытаць элементы
    • зменяць ўхідны тэкст
    • дадаць новыя элементы
    • выдаліць існуючыя
    • мэндзіраваць стылі і атрыбуты

    DOM — это API браузера, а не частка самага языка; таму ключ document не існуе ў чыстам Node.js.

    Ось прыклад аднаго заглавняка ў HTML:

    <h1 id="title">Hello</h1>
    

    Скрыпт можа атрымаць канект да гэтага элемента праз яго ідентыфікатор:

    const title = document.getElementById("title");
    

    а пасля — заменіць яго тэкст:

    title.textContent = "Hello Rayane!";
    

    Да змены элементы меў у сабе первісны тэкст:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello"
    

    Пасля выкарыстання гэтага прызначэння:

    title.textContent = "Hello Rayane!";
    

    структура вяроўак прадставляе новы тэкст:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello Rayane!"
    

    Пасульнік пасля таго перазрасавляе экран. У рэальным кодзе трэба пераканаліцца, чы не вяртае null функцыя getElementById, калі нічога не падпадае пад умову; а таксама для стрэлкі, якія не ўзяты з абавесцю, лепш выкарыстоўваць textContent замест innerHTML, адтакуе ён ніколі не парсуе HTML.

    Callback-ы

    Callback-ам ёсць функцыя, якая перадаецца іншай функцыі, каб яе можна было выклікаць пазней. У гэтым прыкладзе greet перадаецца функцыі execute, якая выклікае ёў з іменем:

    function greet(name) {
      console.log(`Hello ${name}`);
    }
    function execute(callback) {
      callback("Ajami");
    }
    execute(greet);
    

    Програма выведае:

    Hello Ajami
    

    Унутры execute параметар пад назвай callback апісваеся на greet, і гэты рядок выклікае яго:

    callback("Ajami");
    

    Callback-ам не ёсць асінхронным па свайбе; гэты выклікаецца немедленна. Ён стае асінхронным, калі яго выклікае той, хто яго прымоўчае, пасля якога-небудзь здарэння, напрыклад пасля запуску таймера.

    Promises

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

    fetch — это типовы API, яны ўтвараюць прамісы:

    const promise = fetch("https://api.example.com/data");
    

    Вы реагуеце на рэзультат, прыкрепіваючы обрабоўчыкі:

    • .then() для успешнага рэзультата
    • .catch() для неудачы
    • .finally() для чысткі, якая выканана ў будзь-яму случае
    fetch("https://api.example.com/data")
      .then((response) => {
        console.log("Request finished");
      })
      .catch((error) => {
        console.log("Something went wrong:", error.message);
      })
      .finally(() => {
        console.log("Request completed");
      });
    

    Запуск запытку не блакуе; обрабоўчыкі выкананы пазней як мікрозадачы. Заўважыце, что fetch адхіляеся толькі у разе сяброўства сеті. Коды 404 або 500 все равно выпалняюць прамісу, таму самі пераканайцеся ў response.ok.

    Станы прамісы

    Праміса завжды знаходзіцца ў аднам з трох станоў:

    PENDING
       │
       ├───────────────┐
       ▼               ▼
    FULFILLED       REJECTED
    
    • Чакаецца: аператыя яшчэ не завершылася.
    • Завершана: яна завершылася успешна і мае значэнне.
    • Адраджана: яна зазнала нявыходу і ўсё ж мае прычыну, зазвычай канфлікт.

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

    Async/await

    Функцыя async завжды вяртае promise, а await дазволяе пісаць асінхронныя крокі последоўна. Што стосуецца гэтай функцыі, яна логавае, чакае на запит і зноў логавае, а пасля вызову — яшчэ раз:

    async function leetfinish() {
      console.log("The function starts");
    const res = await fetch("https://api.example.com/finish");
      console.log("The request finished");
    }
    leetfinish();
    console.log("Let's review our code");
    

    Верагодны выходны результат:

    The function starts
    Let's review our code
    The request finished
    

    Чаму зовнішні лог выкананы другі

    Функцыя выводзіць сваё першае паведамленне і доходзіць да гэтай лініі:

    const res = await fetch("https://api.example.com/finish");
    

    fetch вяртае обявленую праміс, а await призупошвае функцыю і вяртае кантроль вызывачу, не заморажваючы нитку. Вызывач выводзіць свойя паведамленні; толькі пасля прыбыць адпаведзя функцыя продовжвае сваю роботу. Таму:

    The function starts
    Let's review our code
    The request finished
    

    await призупошвае функцыю, а не нітку. І «ймовірна» ситуацыя мае значэнне: якщо запрос не выйшоў, апошняе рэченне ніколі не будзе выведзена, а неспраўжаная памылка стане неконтрольванай адмоваю.

    Контроль памылак за дапамою try, catch і finally

    За дапамою async/await вы вжываеце звычныя try/catch/finally. У гэтым прыкладзе запрашваюцца даны, перакантроліванае адпаведзя і бюджет, пры чым завжды скасоўваецца флаг загрузкі. У коментарах паказаны два моменты: HTTP-памылкі не вызываюць адмову, а response.json() таксама вяртае праміс.

    let isLoading = true;
    async function checkBudget() {
      try {
        // fetch() returns a Promise.
        // await pauses this function until the Promise settles.
        const response = await fetch(
          "https://api.example.com/khouribga/business/budget"
        );
        // fetch() does NOT reject automatically for HTTP errors
        // such as 404 or 500.
        if (!response.ok) {
          throw new Error("Failed to fetch business data");
        }
        // response.json() also returns a Promise.
        const business = await response.json();
        if (business.budget < 10) {
          throw new Error("Budget is too low");
        }
        console.log("Budget is sufficient:", business.budget);
      } catch (error) {
        console.log("Something went wrong:", error.message);
      } finally {
        // This runs whether the request succeeds or fails.
        isLoading = false;
        console.log("Loading finished:", isLoading);
      }
    }
    checkBudget();
    

    Шаг за шагам:

    1. fetch() адрасуе запыт, а await націшчае выкананне функцыі.
    2. Калі прыходзіць адпаведна адказа, функцыя продовжвае сваё выкананне.
    3. Неспяшны статус HTTP ператвараецца на паданыя адказу працоўнікі.
    4. Тэла даных у формате JSON аналізуецца, а бюджет пераканальваецца.
    5. Будзь-якія працоўнікі адказу ў блоке try, уключаючы проблемы з сетью, перадаюцца ў блок catch.
    6. Блок finally завжды выкананы і скасоўвае статус завантажэння.

    Практыка: выкананне асінхронных функцый паралельна

    Хорашым абавязкам яўнаеся задача 2721 на сайце LeetCode, якая вымагае запускання калькольных операцый адразу, замест таго каб чакаць на выкананне кожной з іх по аднай, фактычна ствараючы свой варыянт Promise.all.

    Генераторы

    Функцыя-генератор можа націшчаць сваё выкананне і пракрацаваць далей, выдаваючы значэнні з часам. Яна прызначаецца за дапамою function*, а точкі націшчэння пазначаюцца сілабам yield:

    function* generatorAjami() {
      // Instructions ...
      yield i;
      // More instructions ...
      yield i;
      // More instructions ...
    }
    

    Порашчыце yield з return:

    • return выдае значэнне і завершае функцыю.
    • yield выдае значэнне і призупіняе функцыю, захоўваючы ўсё яе стана.

    Адработка генератора

    Этот генератор выводзіць запаведзю перад кожным з трох запаведзей yield:

    function* generatorAjami() {
      console.log("First born in casa");
      yield 10;
      console.log("Second move to Khouribga");
      yield 20;
      console.log("Third live in Khouribga");
      yield 30;
    }
    

    Калі яго вызваць, ніяка частка тэлу не будзе выканана:

    const generator = generatorAjami();
    

    Вы отрымліе об’ект генератора, ітэратар, з якога можна выцягваць значэнні за дапамою .next():

    console.log(generator.next());
    

    Першы вызов выкананы да першага yield, праз што выводзіцца першая запаведзь:

    First born in casa
    { value: 10, done: false }
    

    Выкананне зупіняецца на гэтай запаведзі:

    yield 10;
    

    і вяртае 10. Кожны рэзультат .next() мае два атрыбуты:

    • value — значэнне, якое было выданае
  • done – чы гэнератар завершыў сваю роботу
  • Тады выходнае рэзультат:

    { value: 10, done: false }
    

    значыць «тут 10, і ўсё яшчэ будзе».

    Выклік .next() во другі раз:

    console.log(generator.next());
    

    дае:

    Second move to Khouribga
    { value: 20, done: false }
    

    Другая паведамленне з’яўляецца толькі зараз, калі гэнератар працюе далей адразу пасля паканальнаго yield.

    Трэці выклік:

    console.log(generator.next());
    

    выводзіць трэцяе паведамленне і выдае 30:

    Third live in Khouribga
    { value: 30, done: false }
    

    і чэтырты:

    console.log(generator.next());
    

    паведамляе, што нічога не засталося:

    { value: undefined, done: true }
    

    Без явнага return фінальная value ёсць undefined.

    Што на самай працы ёсць гэнератар

    Генератор не выкананяецца колькаразо: адна екзекуцыя пачынаецца і заканчваецца адназначы, але можа быць павярнутая і продовжаная, каб выдаты многія значэнні. Першы вызов выкананяецца да yield 10:

    generator.next()
           ↓
    Execute instructions
           ↓
    yield 10
           ↓
    Pause and return { value: 10, done: false }
    

    Другі вызов павярнуўся і зупыніўся на yield 20:

    generator.next()
           ↓
    Resume after yield 10
           ↓
    yield 20
           ↓
    Pause and return { value: 20, done: false }
    

    Трэці вызов павярнуўся і зупыніўся на yield 30:

    generator.next()
           ↓
    Resume after yield 20
           ↓
    yield 30
           ↓
    Pause and return { value: 30, done: false }
    

    Чатырты вызов не знаходзіць больш запаведзяў і абавешчае прымусоваяе завершэнне:

    generator.next()
           ↓
    No more instructions
           ↓
    Return { value: undefined, done: true }
    

    for...of і синтаксі распашчэння вызываюць .next() замест нас і зупыняюцца на done: true. Для большых прыкладаў адзірніце функцыі, якія можна павярнуць, і ледзьве-ітерацыю з генератарамі.

    Практыка: бесканечны генератор Фібоначчі

    Задача LeetCode 2648 ідзе на лягкість. Генератор выдае текущую значэнне, а пасля — прыбліжае оба дзейнуючыя значэння. Цикл while (true) ў безпецы, таму што кожная ітерацыя зупіняецца на yield.

    var fibGenerator = function* () {
      let a = 0;
      let b = 1;
      while (true) {
        yield a;
        const next = a + b;
        a = b;
        b = next;
      }
    };
    

    Стан развіваецца так:

    a = 0, b = 1
         ↓
    yield 0
         ↓
    a = 1, b = 1
         ↓
    yield 1
         ↓
    a = 1, b = 2
         ↓
    yield 1
         ↓
    a = 2, b = 3
         ↓
    yield 2
    

    Значэнні вычысляюцца ледзь, по аднаму за кожны вызов .next(); проста ніколи не распрасцварваеце і не ітеруеце яго без ліміту.

    Складанне модэлю

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

    ┌─────────────────────────────────────────────┐
    │                  NODE.JS                    │
    │                                             │
    │   ┌─────────────┐                           │
    │   │     V8      │                           │
    │   │             │                           │
    │   │ Call Stack  │                           │
    │   │ Heap        │                           │
    │   └──────┬──────┘                           │
    │          │                                  │
    │          ▼                                  │
    │   ┌───────────────┐                         │
    │   │  Node.js APIs │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    │           ▼                                 │
    │   ┌───────────────┐                         │
    │   │     libuv     │                         │
    │   │               │                         │
    │   │  ┌─────────┐  │                         │
    │   │  │ Event   │  │                         │
    │   │  │  Loop   │  │                         │
    │   │  └─────────┘  │                         │
    │   │               │                         │
    │   │  Thread Pool  │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    └───────────┼─────────────────────────────────┘
                │
                ▼
          ┌───────────────┐
          │      OS       │
          │               │
          │ Networking    │
          │ Kernel        │
          │ epoll/kqueue  │
          └───────────────┘
    

    Ключовыя выводы

    • Нічога з чергі не запускаецца пакуль стак вызоў не будзе порожнім, таму довгая сінхронная робота блакуе все.
    • Зменныя храняць канкрэтнасці да об’ектаў у купцы памяці; недоступныя об’екты збіраюцца.
  • Час рэאнтайму выканання виконвае сповольнае заведзенне; працэўнікі прамесаў выкананыя як мікразадачы прытаму да наступнага калбэка.
  • fetch адхоўвае толькі праз канектныя бяды, таму трэба пераканацца ў response.ok.
  • await паставляе на паузу функцыю, а не нитку.
  • Генератары зупіняюцца на yield і продовжуюць роботу праз .next(), чым ствараецца можлівасць для ледзьвыканальных, нават бесконечных, серыйяў.