📘 TypeScript

Многопоточность и асинхронность в TypeScript: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты — шеф-повар на кухне. Тебе нужно приготовить три блюда: суп, салат и десерт. Если ты будешь делать всё последовательно (сначала полностью сваришь суп, потом нарежешь салат, потом испечёшь пирожное), то гости будут ждать очень долго. Это называется синхронное выполнение — одна задача за раз.

Но умный повар делает иначе: он ставит суп на плиту (запускает процесс), пока он варится — режет салат, а когда суп почти готов — ставит в духовку десерт. Повар не сидит сложа руки, ожидая закипания воды, а переключается между задачами. Это асинхронность.

Асинхронность — это умение не ждать, а заниматься другими делами, пока долгая операция выполняется «в фоне».

Теперь представь, что у тебя есть три помощника-стажёра. Ты можешь дать каждому отдельное задание: один варит суп, второй режет салат, третий печёт десерт. Они работают одновременно (параллельно). Это многопоточность.

В мире программирования:

  • Многопоточность — это когда несколько «рабочих» (потоков) выполняют код одновременно. Каждый поток — как отдельный помощник.
  • Асинхронность — это когда один «повар» (поток) переключается между задачами, не блокируя себя ожиданием.

В JavaScript и TypeScript (в браузере или Node.js) есть одна важная особенность: у нас один главный поток (один повар). Но мы можем делать вид, что у нас много поваров, используя асинхронность. Настоящая многопоточность в JS появилась недавно (Web Workers, Worker Threads), но чаще всего мы работаем именно с асинхронностью.

Теория: как это работает изнутри

Событийный цикл (Event Loop)

Чтобы понять асинхронность в TypeScript, нужно разобраться с событийным циклом. Представь, что у программы есть:

  • Стек вызовов (Call Stack) — список того, что нужно сделать прямо сейчас. Как список задач на столе повара.
  • Web API / Node API — «волшебные коробки», которые выполняют долгие операции (таймеры, запросы в интернет, чтение файлов).
  • Очередь задач (Task Queue / Callback Queue) — список того, что нужно сделать после завершения долгой операции.
  • Микро-очередь (Microtask Queue) — очередь для самых срочных задач (промисы).

Как это работает:

  1. Программа выполняет код сверху вниз, помещая функции в стек вызовов.
  2. Когда встречается долгая операция (например, setTimeout или fetch), она отправляется в Web API, а стек продолжает работать дальше.
  3. Когда долгая операция завершается, её колбэк (функция обратного вызова) попадает в очередь задач.
  4. Событийный цикл постоянно проверяет: если стек пуст, взять задачу из очереди и выполнить её.
  5. Микро-задачи (промисы) имеют приоритет — они выполняются перед обычными задачами.
Важно: JavaScript — однопоточный язык. Асинхронность не делает код параллельным, она делает его неблокирующим.

Многопоточность через Workers

Настоящая многопоточность в TypeScript достигается через Web Workers (в браузере) или Worker Threads (в Node.js). Каждый воркер — это отдельный поток со своим стеком и памятью. Они общаются с главным потоком через сообщения.

Воркеры полезны для тяжёлых вычислений (обработка изображений, шифрование, сложные расчёты), которые не должны блокировать интерфейс пользователя.

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

1. Колбэки (Callbacks) — устаревший способ

Функция, которая вызывается после завершения операции. Раньше использовался везде, сейчас считается плохой практикой из-за «ада колбэков».

// Функция, которая имитирует загрузку данных
function loadData(callback: (data: string) => void) {
    // setTimeout имитирует долгую операцию (например, запрос к серверу)
    setTimeout(() => {
        callback("Данные загружены");
    }, 1000); // 1 секунда ожидания
}

// Использование
console.log("Начало загрузки...");
loadData((data) => {
    console.log(data); // Выведется через 1 секунду
});
console.log("Конец программы"); // Выведется сразу, не дожидаясь загрузки

2. Промисы (Promises) — современный стандарт

Промис — это объект, который представляет будущее значение. Он может быть в трёх состояниях:

  • Pending — ожидание (операция ещё не завершена)
  • Fulfilled — выполнено успешно
  • Rejected — выполнено с ошибкой
// Создание промиса
const myPromise = new Promise<string>((resolve, reject) => {
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve("Успех! Данные получены");
        } else {
            reject("Ошибка загрузки");
        }
    }, 1000);
});

// Использование промиса
myPromise
    .then((result) => {
        console.log(result); // "Успех! Данные получены"
    })
    .catch((error) => {
        console.error(error); // Если ошибка
    })
    .finally(() => {
        console.log("Операция завершена"); // Выполнится в любом случае
    });

3. Async/Await — сахар для промисов

Позволяет писать асинхронный код так, будто он синхронный. Ключевое слово async перед функцией делает её асинхронной, а await ждёт выполнения промиса.

// Функция, возвращающая промис
function fetchUserData(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => resolve("Иван Иванов"), 1000);
    });
}

// Асинхронная функция
async function displayUser() {
    console.log("Загружаем пользователя...");
    const user = await fetchUserData(); // Ждём 1 секунду
    console.log(`Привет, ${user}!`);
    console.log("Загрузка завершена");
}

displayUser();
// Вывод:
// "Загружаем пользователя..."
// (пауза 1 секунда)
// "Привет, Иван Иванов!"
// "Загрузка завершена"

4. Обработка ошибок в async/await

Используем try/catch, как в обычном синхронном коде.

async function loadWithErrorHandling() {
    try {
        const data = await fetchUserData();
        console.log(data);
    } catch (error) {
        console.error("Произошла ошибка:", error);
    } finally {
        console.log("Попытка загрузки завершена");
    }
}

5. Параллельное выполнение промисов

Если нужно запустить несколько независимых операций одновременно, используем Promise.all.

async function loadMultipleUsers() {
    const user1Promise = fetchUserData();
    const user2Promise = fetchUserData();
    const user3Promise = fetchUserData();

    // Все три запроса выполняются параллельно
    const users = await Promise.all([user1Promise, user2Promise, user3Promise]);
    console.log(users); // ["Иван Иванов", "Иван Иванов", "Иван Иванов"]
}

6. Promise.race — кто быстрее

Возвращает результат первого завершённого промиса (успешного или с ошибкой).

async function raceExample() {
    const fast = new Promise<string>((resolve) => setTimeout(() => resolve("Быстрый"), 500));
    const slow = new Promise<string>((resolve) => setTimeout(() => resolve("Медленный"), 2000));

    const winner = await Promise.race([fast, slow]);
    console.log(winner); // "Быстрый" — через 0.5 секунды
}

7. Web Workers — настоящая многопоточность

Создаём отдельный файл для воркера и общаемся с ним через сообщения.

// Главный файл (main.ts)
const worker = new Worker('worker.js');

worker.postMessage('start'); // Отправляем сообщение воркеру

worker.onmessage = (event) => {
    console.log('Результат от воркера:', event.data);
};

// worker.js (отдельный файл)
self.onmessage = (event) => {
    if (event.data === 'start') {
        // Тяжёлые вычисления
        let result = 0;
        for (let i = 0; i < 1000000000; i++) {
            result += i;
        }
        self.postMessage(result); // Отправляем результат обратно
    }
};

Примеры кода — от простого к сложному

Пример 1: Простой таймер

// Самый простой пример асинхронности
console.log("Старт");

setTimeout(() => {
    console.log("Прошло 2 секунды");
}, 2000);

console.log("Финиш");

// Вывод:
// "Старт"
// "Финиш"
// (через 2 секунды) "Прошло 2 секунды"

Пример 2: Имитация загрузки данных с сервера

// Функция, имитирующая HTTP-запрос
function fetchData(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
        console.log(`Запрос к ${url}...`);
        setTimeout(() => {
            if (url === "https://api.example.com/data") {
                resolve('{"name": "Алиса", "age": 25}');
            } else {
                reject(new Error("404 Not Found"));
            }
        }, 1500);
    });
}

// Использование с async/await
async function getData() {
    try {
        const data = await fetchData("https://api.example.com/data");
        const parsed = JSON.parse(data);
        console.log(`Имя: ${parsed.name}, Возраст: ${parsed.age}`);
    } catch (error) {
        console.error("Ошибка:", error.message);
    }
}

getData();

Пример 3: Цепочка промисов

// Последовательное выполнение: сначала логин, потом загрузка профиля
function login(username: string): Promise<number> {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Пользователь ${username} вошёл в систему`);
            resolve(123); // ID пользователя
        }, 1000);
    });
}

function getProfile(userId: number): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Загружен профиль пользователя ${userId}`);
            resolve("Профиль: Администратор");
        }, 1000);
    });
}

// Цепочка
login("admin")
    .then((userId) => getProfile(userId))
    .then((profile) => console.log(profile))
    .catch((error) => console.error(error));

Пример 4: Параллельная загрузка нескольких файлов

// Загружаем несколько файлов одновременно
function downloadFile(filename: string, duration: number): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(`Файл ${filename} загружен`);
        }, duration);
    });
}

async function downloadAll() {
    console.time("Загрузка");
    
    const files = [
        downloadFile("photo.jpg", 2000),
        downloadFile("video.mp4", 3000),
        downloadFile("music.mp3", 1000)
    ];
    
    const results = await Promise.all(files);
    results.forEach(result => console.log(result));
    
    console.timeEnd("Загрузка"); // ~3 секунды, не 6!
}

downloadAll();

Пример 5: Обработка ошибок в параллельных задачах

// Promise.allSettled — ждёт все промисы, даже если некоторые упали
function riskyTask(id: number): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (id % 2 === 0) {
                resolve(`Задача ${id} выполнена`);
            } else {
                reject(new Error(`Задача ${id} провалена`));
            }
        }, 1000);
    });
}

async function runTasks() {
    const tasks = [riskyTask(1), riskyTask(2), riskyTask(3), riskyTask(4)];
    const results = await Promise.allSettled(tasks);
    
    results.forEach((result, index) => {
        if (result.status === 'fulfilled') {
            console.log(`Успех: ${result.value}`);
        } else {
            console.log(`Неудача: ${result.reason.message}`);
        }
    });
}

runTasks();

Пример 6: Асинхронный итератор

// Обработка данных по мере их поступления
async function* asyncGenerator() {
    let i = 0;
    while (i < 5) {
        yield new Promise<number>((resolve) => {
            setTimeout(() => resolve(i++), 500);
        });
    }
}

async function processAsyncData() {
    for await (const value of asyncGenerator()) {
        console.log(`Получено: ${value}`);
    }
    console.log("Все данные обработаны");
}

processAsyncData();

Пример 7: Создание и использование Web Worker

// main.ts — главный поток
const worker = new Worker('heavy-worker.js');

// Отправляем данные воркеру
worker.postMessage({ numbers: [1, 2, 3, 4, 5] });

// Получаем результат
worker.onmessage = (event) => {
    console.log('Результат вычислений:', event.data);
    worker.terminate(); // Завершаем воркер
};

worker.onerror = (error) => {
    console.error('Ошибка в воркере:', error);
};

// heavy-worker.js — отдельный файл
self.onmessage = (event) => {
    const { numbers } = event.data;
    // Тяжёлое вычисление: сумма квадратов
    const result = numbers.reduce((sum, num) => sum + num * num, 0);
    self.postMessage(result);
};

Сравнение с Python: чем похоже и чем отличается

Характеристика TypeScript (JavaScript) Python
Основная модель Асинхронность на одном потоке (Event Loop) Многопоточность (GIL ограничивает)
Ключевые слова async, await, Promise async, await, asyncio
Параллелизм Web Workers, Worker Threads (настоящие потоки) threading (настоящие потоки), multiprocessing (процессы)
Промисы Встроенные (Promise) Нет встроенных, используются Future/Task
Event Loop Встроен в runtime (браузер/Node.js) asyncio.run() создаёт цикл
Блокирующие операции Любая синхронная операция блокирует поток То же самое, но GIL может освобождаться
Сложность Проще для начинающих (меньше терминов) Сложнее из-за выбора между threading/asyncio/multiprocessing
Главное отличие: в TypeScript асинхронность — это основной способ работы с I/O (вводом-выводом), а в Python нужно выбирать между асинхронностью, потоками и процессами в зависимости от задачи.

Типичные ошибки и подводные камни

1. Забыли await

// Ошибка: забыли await
async function wrong() {
    const data = fetchData(); // Получаем Promise, а не данные!
    console.log(data); // Выведет: Promise { <pending> }
}

// Правильно:
async function correct() {
    const data = await fetchData();
    console.log(data); // Выведет: "Данные загружены"
}

2. Блокировка event loop

// Плохо: синхронный цикл блокирует поток
function blockEventLoop() {
    console.log("Начало блокировки");
    for (let i = 0; i < 1000000000; i++) {
        // Тяжёлые вычисления
    }
    console.log("Конец блокировки");
}

// Хорошо: разбиваем на части или используем воркер
async function nonBlocking() {
    console.log("Начало");
    await new Promise(resolve => setTimeout(resolve, 0)); // Даём event loop'у вздохнуть
    console.log("Продолжение");
}

3. Ошибка в Promise.all

// Если один промис упадёт, упадут все
async function danger() {
    try {
        const results = await Promise.all([riskyTask(1), riskyTask(2)]);
    } catch (error) {
        console.log("Один из промисов упал, все отменены");
    }
}

// Решение: Promise.allSettled
async function safe() {
    const results = await Promise.allSettled([riskyTask(1), riskyTask(2)]);
    // Обрабатываем каждый результат отдельно
}

4. Гонка состояний (Race Condition)

// Проблема: два запроса к одному ресурсу
let cache: string | null = null;

async function getDataWithRace() {
    if (!cache) {
        // Оба вызова увидят, что cache пуст, и сделают запрос
        cache = await fetchData();
    }
    return cache;
}

// Решение: блокировка
let pendingPromise: Promise<string> | null = null;

async function getDataSafe() {
    if (!cache) {
        if (!pendingPromise) {
            pendingPromise = fetchData().then(data => {
                cache = data;
                pendingPromise = null;
                return data;
            });
        }
        return pendingPromise;
    }
    return cache;
}

5. Забыли обработать ошибки в промисах

// Плохо: необработанная ошибка
fetchData().then(data => console.log(data));
// Если fetchData упадёт, ошибка будет проглочена

// Хорошо: всегда добавляем catch
fetchData()
    .then(data => console.log(data))
    .catch(error => console.error(error));

// Или используем try/catch с async/await

Лучшие практики и советы профессионалов

  1. Всегда используй async/await вместо .then() — код становится читаемее и легче отлаживается.
  2. Не смешивай синхронный и асинхронный код — если функция асинхронная, пусть весь код внутри неё будет асинхронным.
  3. Используй Promise.all для независимых задач — это ускоряет выполнение в несколько раз.
  4. Обрабатывай ошибки в каждом асинхронном блоке — необработанные ошибки могут «упасть» и сломать всю программу.
  5. Избегай «ада колбэков» — если видишь вложенные колбэки, перепиши их на async/await.
  6. Для тяжёлых вычислений используй Web Workers — не блокируй главный поток.
  7. Используй TypeScript строго — типизация помогает избежать ошибок с промисами (указывай тип возвращаемого значения).
  8. Тестируй асинхронный код — используй jest или mocha с поддержкой async/await.
  9. Не забывай про finally — если нужно выполнить код независимо от результата (закрыть файл, очистить ресурсы).
  10. Используй AbortController для отмены запросов — если пользователь ушёл со страницы, не нужно продолжать загрузку.
Золотое правило: асинхронность — это про ожидание, а не про параллельность. Если тебе нужно реальное параллельное выполнение — используй Workers.

Вопросы для самопроверки

  1. Вопрос: В чём разница между многопоточностью и асинхронностью?

    Ответ: Многопоточность — это несколько потоков, работающих одновременно (параллельно). Асинхронность — это один поток, который не блокируется при ожидании, а переключается между задачами.

  2. Вопрос: Что такое Event Loop и как он работает?

    Ответ: Event Loop — это механизм, который постоянно проверяет, пуст ли стек вызовов. Если стек пуст, он берёт задачу из очереди и выполняет её. Микро-задачи (промисы) имеют приоритет перед обычными задачами.

  3. Вопрос: Какие три состояния может иметь Promise?

    Ответ: Pending (ожидание), Fulfilled (выполнено успешно), Rejected (выполнено с ошибкой).

  4. Вопрос: В чём разница между Promise.all и Promise.allSettled?

    Ответ: Promise.all завершается ошибкой, если хотя бы один промис упал. Promise.allSettled ждёт завершения всех промисов и возвращает массив с результатами (успех/неудача) каждого.

  5. Вопрос: Что произойдёт, если забыть ключевое слово await перед асинхронной функцией?

    Ответ: Вместо результата мы получим объект Promise. Код продолжит выполняться, не дожидаясь завершения асинхронной операции.

  6. Вопрос: Как обработать ошибку в async/await функции?

    Ответ: Использовать блок try/catch. Если ошибка не будет обработана, она приведёт к необработанному отклонению промиса.

  7. Вопрос: Когда нужно использовать Web Workers вместо async/await?

    Ответ: Когда есть тяжёлые вычислительные задачи (обработка изображений, шифрование, сложные расчёты), которые могут заблокировать главный поток на длительное время.

  8. Вопрос: Что такое «ад колбэков» и как его избежать?

    Ответ: Это ситуация, когда колбэки вложены друг в друга, создавая глубокую вложенность. Избежать можно с помощью промисов и async/await.

  9. Вопрос: Как выполнить несколько асинхронных операций последовательно?

    Ответ: Использовать await для каждой операции по очереди. Если нужно выполнить строго одну за другой, не используйте Promise.all.

  10. Вопрос: В чём отличие асинхронности в TypeScript от Python?

    Ответ: В TypeScript асинхронность — основной способ работы с I/O, в Python нужно выбирать между asyncio, threading и multiprocessing. В Python есть GIL, который ограничивает многопоточность.

Итог и что изучить дальше

Мы разобрали две важнейшие концепции современного программирования: многопоточность и асинхронность. В TypeScript (и JavaScript) асинхронность — это основа работы с сетью, файлами, таймерами и пользовательским интерфейсом. Многопоточность через Workers — это мощный инструмент для тяжёлых вычислений.

Теперь ты знаешь:

  • Как работает Event Loop и почему JavaScript не блокируется
  • Как использовать колбэки, промисы и async/await
  • Как обрабатывать ошибки в асинхронном коде
  • Как запускать задачи параллельно и последовательно
  • Когда нужна настоящая многопоточность

Что изучить дальше:

  • RxJS — библиотека для реактивного программирования с потоками данных
  • AbortController — отмена асинхронных операций
  • Service Workers — перехват сетевых запросов и кэширование
  • SharedArrayBuffer — разделяемая память между воркерами
  • Паттерны проектирования для асинхронного кода (Producer-Consumer, Throttling, Debouncing)
Помни: асинхронность — это суперсила, но с большой силой приходит большая ответственность. Всегда думай о том, что может пойти не так, и обрабатывай ошибки. Удачи в изучении!