В современных высоконагруженных фронтенд-системах и Node.js микросервисах управление асинхронными операциями давно вышло за рамки простых цепочек Promise.all(). В реальных распределенных архитектурах инженерам необходимо решать задачи:
Стандарт AbortController / AbortSignal стал универсальным протоколом кооперативной отмены задач в веб-платформе. В комбинации с интерфейсами AsyncIterable<T> и паттерном Communicating Sequential Processes (CSP / Go-like Channels) TypeScript позволяет строить математически строгие конвейеры конкурентной обработки данных с нулевым риском возникновения необработанных исключений и гонок состояний.
+-------------------------------------------------------------------------+
| АРХИТЕКТУРА КОНКУРЕНТНОГО КОНВЕЙЕРА |
+-------------------------------------------------------------------------+
| [Producer (SSE / WebSocket / LLM Stream)] |
| |--> Порождает поток сообщений: AsyncIterable<ServerPacket> |
+-------------------------------------------------------------------------+
|
v (Backpressure Queue / Channel<T>)
+-------------------------------------------------------------------------+
| [Типобезопасный буферизированный канал: Channel<ServerPacket>] |
| |-- push(packet: T): Promise<void> (Блокируется при переполнении) |
| |-- pop(signal?: AbortSignal): Promise<T> (Блокируется при пустоте) |
| `-- close(): void |
+-------------------------------------------------------------------------+
|
v (Worker Pool: Concurrency Limit = 3)
+-------------------------------------------------------------------------+
| [Параллельный пул потребителей (Consumers / Workers)] |
| |-- Worker 1: Обработка пакета A (AbortSignal.timeout(5000)) |
| |-- Worker 2: Обработка пакета B (Прерывание по abort()) |
| `-- Worker 3: Обработка пакета C (Успешная финализация) |
+-------------------------------------------------------------------------+Конкурентная обработка в TypeScript опирается на строгие абстракции отмены и передачи сообщений:
Композиция сигналов отмены:
[Сигнал тайм-аута: AbortSignal.timeout(5000)] -----\
+--> [AbortSignal.any([...])] --> fetch(url, { signal })
[Сигнал кнопки UI: userCancelController.signal] ---/
Запрос прерывается, если прошло 5 секунд ИЛИ если пользователь нажал «Отмена»!Определите пропускную способность сетевого канала или базы данных и задайте concurrencyLimit пула (обычно 4-8).
Используйте AbortSignal.timeout() для защиты от зависаний и связывайте его с UI-сигналом через AbortSignal.any().
Настройте канал AsyncChannel<T> с политикой вытеснения при переполнении (Backpressure Strategy).
Запустите параллельные обработчики через ConcurrencyPool, передавая сигнал отмены в каждую задачу.
Убедитесь в вызове reader.releaseLock(), channel.close() и удалении слушателей событий в блоках finally.
В чем критическое архитектурное преимущество использования AbortSignal.any() перед классическим Promise.race() при отмене асинхронных операций?
AsyncIterable<string> с возможностью мгновенного сброса контекста кнопкой «Stop generating».AbortSignal.timeout() и переключением на резервные узлы (Failover).AbortSignal.any() и .timeout()#В современном TypeScript (Node.js 20+ и современные браузеры) создание таймаутов упростилось благодаря статическим методам AbortSignal:
export interface FetchWithTimeoutOptions extends RequestInit {
readonly timeoutMs?: number;
readonly externalSignal?: AbortSignal;
}
/**
* Выполняет сетевой запрос с композитной отменой: по таймауту ИЛИ по внешнему сигналу.
*/
export async function fetchWithResilience<TResponse>(
url: string,
options: FetchWithTimeoutOptions = {}
): Promise<TResponse> {
const { timeoutMs = 8000, externalSignal, ...fetchOptions } = options;
// 1. Создаем сигнал таймаута
const timeoutSignal = AbortSignal.timeout(timeoutMs);
// 2. Объединяем таймаут и внешний сигнал через AbortSignal.any
const combinedSignal = externalSignal
? AbortSignal.any([timeoutSignal, externalSignal])
: timeoutSignal;
try {
const response = await fetch(url, {
...fetchOptions,
signal: combinedSignal,
});
if (!response.ok) {
throw new Error(`[HttpError] HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as TResponse;
} catch (error: unknown) {
if (combinedSignal.aborted) {
// Различаем причину отмены: таймаут или действие пользователя
if (timeoutSignal.aborted) {
throw new Error(`[TimeoutError] Запрос к ${url} превысил лимит в ${timeoutMs}ms`);
}
throw new Error(`[AbortError] Запрос к ${url} был отменен вызывающей стороной`);
}
throw error instanceof Error ? error : new Error(String(error));
}
}Реализуем канал передачи данных между независимыми параллельными процессами:
interface Resolver<T> {
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
/**
* Типобезопасный асинхронный канал передачи данных (Communicating Sequential Processes).
*/
export class AsyncChannel<T> implements AsyncIterable<T> {
private readonly buffer: T[] = [];
private readonly receivers: Resolver<T>[] = [];
private isClosed = false;
constructor(private readonly maxCapacity: number = Infinity) {}
/**
* Запись элемента в канал.
*/
public push(item: T): boolean {
if (this.isClosed) {
throw new Error('[ChannelError] Попытка записи в закрытый канал');
}
// Если есть ждущие потребители — отдаем первому в очереди
if (this.receivers.length > 0) {
const receiver = this.receivers.shift()!;
receiver.resolve(item);
return true;
}
// Проверка емкости буфера
if (this.buffer.length >= this.maxCapacity) {
// Буфер переполнен: вытесняем старейший элемент (Drop-oldest backpressure)
this.buffer.shift();
}
this.buffer.push(item);
return true;
}
/**
* Асинхронное чтение элемента из канала с поддержкой отмены.
*/
public async pop(signal?: AbortSignal): Promise<T | null> {
if (this.buffer.length > 0) {
return this.buffer.shift()!;
}
if (this.isClosed) {
return null;
}
return new Promise<T | null>((resolve, reject) => {
const resolver: Resolver<T> = {
resolve: (val) => {
cleanUp();
resolve(val);
},
reject: (err) => {
cleanUp();
reject(err);
},
};
const onAbort = () => {
const index = this.receivers.indexOf(resolver);
if (index > -1) {
this.receivers.splice(index, 1);
}
cleanUp();
reject(new Error('[AbortError] Ожидание канала прервано по сигналу'));
};
const cleanUp = () => {
signal?.removeEventListener('abort', onAbort);
};
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort);
this.receivers.push(resolver);
});
}
public close(): void {
this.isClosed = true;
// Оповещаем всех ждущих читателей о закрытии
while (this.receivers.length > 0) {
const receiver = this.receivers.shift()!;
receiver.resolve(null as unknown as T);
}
}
/**
* Реализация протокола AsyncIterable для использования в цикле for await.
*/
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
const value = await this.pop();
if (value === null && this.isClosed) {
break;
}
if (value !== null) {
yield value;
}
}
}
}export type AsyncTask<T> = (signal: AbortSignal) => Promise<T>;
/**
* Ограничивает количество параллельно выполняемых асинхронных задач.
*/
export class ConcurrencyPool {
private activeCount = 0;
private readonly queue: Array<() => void> = [];
constructor(public readonly concurrencyLimit: number) {
if (concurrencyLimit < 1) {
throw new RangeError('Лимит параллелизма обязан быть >= 1');
}
}
/**
* Запускает задачу с соблюдением пула параллелизма и контролем отмены.
*/
public run<T>(task: AsyncTask<T>, signal?: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const execute = async () => {
if (signal?.aborted) {
this.next();
reject(new Error('[AbortError] Задача отменена до начала исполнения'));
return;
}
this.activeCount++;
try {
// Создаем локальный контроллер с поддержкой внешнего сигнала
const ctrl = new AbortController();
const onAbort = () => ctrl.abort();
signal?.addEventListener('abort', onAbort);
const result = await task(ctrl.signal);
signal?.removeEventListener('abort', onAbort);
resolve(result);
} catch (err: unknown) {
reject(err);
} finally {
this.activeCount--;
this.next();
}
};
if (this.activeCount < this.concurrencyLimit) {
void execute();
} else {
this.queue.push(execute);
}
});
}
private next(): void {
if (this.queue.length > 0 && this.activeCount < this.concurrencyLimit) {
const nextTask = this.queue.shift();
nextTask?.();
}
}
}Задачи поступают в пул. Если активный счетчик activeCount < concurrencyLimit, задача запускается немедленно.
Сигнал таймаута и внешний сигнал пользователя связываются через AbortSignal.any, образуя непрерывную цепочку отмены.
Сетевая операция выполняется с передачей signal. При возникновении аборта сетевой стек сокета закрывается на уровне ядра ОС.
В блоке finally активный счетчик уменьшается (activeCount--), и диспетчер выталкивает следующую задачу из массива ожидания.
export interface SseEvent<TData> {
readonly id?: string;
readonly event: string;
readonly data: TData;
readonly timestamp: number;
}
/**
* Читает поток Server-Sent Events в виде типобезопасного асинхронного генератора.
*/
export async function* streamSseEvents<TData>(
url: string,
signal?: AbortSignal
): AsyncIterable<SseEvent<TData>> {
const response = await fetch(url, {
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
signal,
});
if (!response.ok || !response.body) {
throw new Error(`[SseError] Не удалось установить соединение: HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
if (signal?.aborted) {
break;
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() ?? '';
for (const block of lines) {
if (!block.trim()) continue;
let eventType = 'message';
let eventDataRaw = '';
let eventId: string | undefined;
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
eventType = line.replace('event:', '').trim();
} else if (line.startsWith('data:')) {
eventDataRaw += line.replace('data:', '').trim();
} else if (line.startsWith('id:')) {
eventId = line.replace('id:', '').trim();
}
}
if (eventDataRaw) {
const parsedData = JSON.parse(eventDataRaw) as TData;
yield {
id: eventId,
event: eventType,
data: parsedData,
timestamp: Date.now(),
};
}
}
}
} finally {
reader.releaseLock();
}
}Какую угрозу несет прикрепление signal.addEventListener('abort', ...) к долгоживущему сигналу отмены без опции { once: true } или без ручного removeEventListener?
Создание функции с аргументом signal: AbortSignal без прикрепления слушателя addEventListener('abort', ...) делает сигнал бесполезным: функция продолжит выполняться до своего естественного завершения.
// ❌ АНТИПАТТЕРН: Аргумент signal принимается, но не слушается!
async function sleepBad(ms: number, signal?: AbortSignal) {
return new Promise((resolve) => setTimeout(resolve, ms)); // Сигнал игнорируется!
}
// ✅ ПРАВИЛЬНО: Прикрепление слушателя и очистка таймера
async function sleepGood(ms: number, signal?: AbortSignal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error('Aborted'));
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(new Error('Aborted'));
}, { once: true });
});
}Если прикреплять слушатели к долгоживущему сигналу без удаления через removeEventListener или без флага { once: true }, в памяти накапливаются тысячи висящих замыканий.
// ❌ АНТИПАТТЕРН: Накопление слушателей на глобальном сигнале
signal.addEventListener('abort', onAbort); // УТЕЧКА ПАМЯТИ!
// ✅ ПРАВИЛЬНО: Использование опции once: true
signal.addEventListener('abort', onAbort, { once: true });При отмене нативного fetch выбрасывается исключение с error.name === 'AbortError'. Если приложение не фильтрует это исключение, в логах появляются ложные аварийные алерты.
// ✅ ПРАВИЛЬНО: Фильтрация штатной отмены от реальных аварий сети
try {
await fetch(url, { signal });
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
// Штатное прерывание запроса — игнорируем или логируем на уровне debug
return;
}
console.error('Реальный сетевой сбой:', err);
}Если асинхронный генератор не проверяет статус завершения или канал не вызывает close(), цикл for await останется заблокированным навсегда в ожидании следующего элемента.
// ✅ ПРАВИЛЬНО: Обязательный вызов channel.close() в блоке finally продюсера