تعدد المهام والتزامن غير المتزامن في 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) — очередь для самых срочных задач (промисы).
Как это работает:
- Программа выполняет код сверху вниз, помещая функции в стек вызовов.
- Когда встречается долгая операция (например, setTimeout или fetch), она отправляется в Web API, а стек продолжает работать дальше.
- Когда долгая операция завершается, её колбэк (функция обратного вызова) попадает в очередь задач.
- Событийный цикл постоянно проверяет: если стек пуст, взять задачу из очереди и выполнить её.
- Микро-задачи (промисы) имеют приоритет — они выполняются перед обычными задачами.
Важно: 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
Лучшие практики и советы профессионалов
- Всегда используй async/await вместо .then() — код становится читаемее и легче отлаживается.
- Не смешивай синхронный и асинхронный код — если функция асинхронная, пусть весь код внутри неё будет асинхронным.
- Используй Promise.all для независимых задач — это ускоряет выполнение в несколько раз.
- Обрабатывай ошибки в каждом асинхронном блоке — необработанные ошибки могут «упасть» и сломать всю программу.
- Избегай «ада колбэков» — если видишь вложенные колбэки, перепиши их на async/await.
- Для тяжёлых вычислений используй Web Workers — не блокируй главный поток.
- Используй TypeScript строго — типизация помогает избежать ошибок с промисами (указывай тип возвращаемого значения).
- Тестируй асинхронный код — используй jest или mocha с поддержкой async/await.
- Не забывай про finally — если нужно выполнить код независимо от результата (закрыть файл, очистить ресурсы).
- Используй AbortController для отмены запросов — если пользователь ушёл со страницы, не нужно продолжать загрузку.
Золотое правило: асинхронность — это про ожидание, а не про параллельность. Если тебе нужно реальное параллельное выполнение — используй Workers.
Вопросы для самопроверки
-
Вопрос: В чём разница между многопоточностью и асинхронностью?
Ответ: Многопоточность — это несколько потоков, работающих одновременно (параллельно). Асинхронность — это один поток, который не блокируется при ожидании, а переключается между задачами.
-
Вопрос: Что такое Event Loop и как он работает?
Ответ: Event Loop — это механизм, который постоянно проверяет, пуст ли стек вызовов. Если стек пуст, он берёт задачу из очереди и выполняет её. Микро-задачи (промисы) имеют приоритет перед обычными задачами.
-
Вопрос: Какие три состояния может иметь Promise?
Ответ: Pending (ожидание), Fulfilled (выполнено успешно), Rejected (выполнено с ошибкой).
-
Вопрос: В чём разница между Promise.all и Promise.allSettled?
Ответ: Promise.all завершается ошибкой, если хотя бы один промис упал. Promise.allSettled ждёт завершения всех промисов и возвращает массив с результатами (успех/неудача) каждого.
-
Вопрос: Что произойдёт, если забыть ключевое слово await перед асинхронной функцией?
Ответ: Вместо результата мы получим объект Promise. Код продолжит выполняться, не дожидаясь завершения асинхронной операции.
-
Вопрос: Как обработать ошибку в async/await функции?
Ответ: Использовать блок try/catch. Если ошибка не будет обработана, она приведёт к необработанному отклонению промиса.
-
Вопрос: Когда нужно использовать Web Workers вместо async/await?
Ответ: Когда есть тяжёлые вычислительные задачи (обработка изображений, шифрование, сложные расчёты), которые могут заблокировать главный поток на длительное время.
-
Вопрос: Что такое «ад колбэков» и как его избежать?
Ответ: Это ситуация, когда колбэки вложены друг в друга, создавая глубокую вложенность. Избежать можно с помощью промисов и async/await.
-
Вопрос: Как выполнить несколько асинхронных операций последовательно?
Ответ: Использовать await для каждой операции по очереди. Если нужно выполнить строго одну за другой, не используйте Promise.all.
-
Вопрос: В чём отличие асинхронности в 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)
Помни: асинхронность — это суперсила, но с большой силой приходит большая ответственность. Всегда думай о том, что может пойти не так, и обрабатывай ошибки. Удачи в изучении!