Урок 05

Асинхронность и Event Loop

Почему асинхронность

Node.js — однопоточный. Один поток на JavaScript, но операции ввода-вывода выполняются в фоне (через libuv).

Проблема: если блокировать поток — приложение встанет.

Решение: асинхронные операции.

Event Loop

Сердце Node.js. Крутится постоянно, обрабатывая очереди задач.

Фазы:

  1. Timers — setTimeout, setInterval.
  2. Pending callbacks — I/O callbacks.
  3. Idle, prepare — внутренние.
  4. Poll — новые I/O события.
  5. Check — setImmediate.
  6. Close callbacks — закрытие.

Microtasks (промисы) — между фазами.

Синхронный код

console.log('1');
console.log('2');
console.log('3');
// 1, 2, 3

Выполняется по порядку.

setTimeout

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 1, 3, 2

setTimeout(fn, 0) — не «сразу», а после текущего кода.

Очереди

Порядок:

  1. Синхронный код — первым.
  2. Microtasks — промисы, queueMicrotask.
  3. Macrotasks — setTimeout, setInterval, I/O.

Пример:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

// 1, 4, 3, 2

Microtask (3) раньше macrotask (2).

Callback-стиль

Старый способ:

import fs from 'node:fs';

fs.readFile('file.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

Проблема — callback hell:

fs.readFile('a.txt', (err, a) => {
    fs.readFile('b.txt', (err, b) => {
        fs.readFile('c.txt', (err, c) => {
            // пирамида
        });
    });
});

Промисы

Promise — объект с будущим результатом.

const promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve('готово'), 1000);
});

promise.then(result => console.log(result));

Три состояния:

  • pending — ждёт.
  • fulfilled — успех.
  • rejected — ошибка.

then / catch / finally

fetch('/api/users')
    .then(res => res.json())
    .then(data => console.log(data))
    .catch(err => console.error(err))
    .finally(() => console.log('Готово'));

async / await

Современный синтаксис:

async function load() {
    try {
        const res = await fetch('/api/users');
        const data = await res.json();
        console.log(data);
    } catch (err) {
        console.error(err);
    } finally {
        console.log('Готово');
    }
}

await — ждёт промис, возвращает результат.

async — функция всегда возвращает промис.

Параллельно

Promise.all — ждёт все:

const [users, posts, comments] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/comments').then(r => r.json()),
]);

Быстрее, чем последовательно.

Проблема: если один упадёт — всё упадёт.

Promise.allSettled — не падает:

const results = await Promise.allSettled([
    fetchA(),
    fetchB(),
    fetchC(),
]);

results.forEach(r => {
    if (r.status === 'fulfilled') console.log(r.value);
    else console.error(r.reason);
});

Promise.race — первый завершённый:

const result = await Promise.race([
    fetchA(),
    timeout(3000),
]);

Promise.any — первый успешный:

const result = await Promise.any([
    fetchFromServerA(),
    fetchFromServerB(),
    fetchFromServerC(),
]);

Последовательно

Когда параллельность не подходит:

for (const id of ids) {
    const user = await fetchUser(id);
    await saveUser(user);
}

Или:

const users = [];
for (const id of ids) {
    users.push(await fetchUser(id));
}

setTimeout / setInterval

setTimeout(() => console.log('через 1 сек'), 1000);

const id = setInterval(() => console.log('каждую сек'), 1000);
clearInterval(id);   // остановить

С промисами:

const delay = (ms) => new Promise(r => setTimeout(r, ms));
await delay(1000);

setImmediate

После текущей фазы, до I/O:

setImmediate(() => console.log('immediate'));

Отличие от setTimeout(fn, 0): в главном модуле порядок не определён, внутри I/O — setImmediate первым.

process.nextTick

Перед микротасками:

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));

nextTick раньше promise.

⚠️ Не злоупотребляй — может заблокировать Event Loop.

Порядок в примере

console.log('start');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

console.log('end');

// start, end, nextTick, promise, timeout/immediate (порядок не гарантирован)

Блокирующий код

⚠️ Синхронный код блокирует Event Loop:

// Плохо — блокирует всё на 5 секунд
const data = fs.readFileSync('huge.txt', 'utf8');

// Хорошо
const data = await fs.readFile('huge.txt', 'utf8');

Тяжёлые вычисления — тоже блокируют:

// Плохо
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

Решение: worker threads, child processes, или другой язык.

Worker Threads

Параллельно, отдельный поток:

import { Worker, isMainThread, parentPort } from 'node:worker_threads';

if (isMainThread) {
    const worker = new Worker(new URL(import.meta.url));
    worker.on('message', result => console.log(result));
} else {
    const result = heavyComputation();
    parentPort.postMessage(result);
}

Обработка ошибок

В async:

async function load() {
    try {
        const data = await fetchData();
        return data;
    } catch (err) {
        console.error('Ошибка:', err.message);
        throw err;
    }
}

Unhandled rejection:

process.on('unhandledRejection', (reason) => {
    console.error('Unhandled:', reason);
});

Uncaught exception:

process.on('uncaughtException', (err) => {
    console.error('Uncaught:', err);
    process.exit(1);
});

⚠️ После uncaughtException — процесс в неопределённом состоянии. Лучше exit.

Практика: загрузка с retry

async function fetchWithRetry(url, maxAttempts = 3) {
    for (let i = 1; i <= maxAttempts; i++) {
        try {
            const res = await fetch(url);
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            return await res.json();
        } catch (err) {
            console.error(`Попытка ${i} провалилась:`, err.message);
            if (i === maxAttempts) throw err;
            await new Promise(r => setTimeout(r, 1000 * i));
        }
    }
}

const data = await fetchWithRetry('https://api.example.com/users');

Практика: параллельная загрузка

async function loadAll(urls) {
    const results = await Promise.allSettled(
        urls.map(url => fetch(url).then(r => r.json()))
    );

    return results.map((r, i) => ({
        url: urls[i],
        ok: r.status === 'fulfilled',
        data: r.status === 'fulfilled' ? r.value : null,
        error: r.status === 'rejected' ? r.reason.message : null,
    }));
}

const results = await loadAll([
    'https://api.example.com/users',
    'https://api.example.com/posts',
]);

console.log(results);

Итог

  • Event Loop — сердце Node.js.
  • Microtasks раньше macrotasks.
  • Callback → Promise → async/await — эволюция.
  • Promise.all — параллельно.
  • Promise.allSettled — не падает.
  • Promise.race / any — первый.
  • Не блокируй Event Loop.
  • Worker threads — для CPU-heavy.
  • Обрабатывай ошибки — unhandledRejection.

Дальше — HTTP-сервер.