<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Почему Node.js кластеры жрут 70% CPU на GC: утечки worker_threads и нативный фикс]]></title><description><![CDATA[<p dir="auto">Node.js кластеры на многозадачных серверах часто показывают 70% CPU в GC. Проблема не в V8, а в том, как worker_threads делят память и объекты между процессами. Разберем, почему это происходит и как починить без pm2 - чисто нативно.</p>
<p dir="auto">Это спасет от просадок производительности под нагрузкой. Узнаете скрытые утечки и получите код, который реально работает. Без костылей и лишних зависимостей.</p>
<h2>Как worker_threads ломают GC в кластере</h2>
<p dir="auto">В кластере Node.js основной процесс форкает воркеры по числу ядер. Каждый воркер - отдельный V8 изолят с собственной heap. Но когда добавляешь worker_threads внутри воркера, начинается бардак: потоки шейрят SharedArrayBuffer и Atomics, а GC не знает, что с этим делать.Под нагрузкой объекты мигрируют между потоками, создавая <strong>кросс-трэдовые ссылки</strong>. V8 маркит их дважды - в каждом изоляте, и GC взлетает на 70% CPU.</p>
<p dir="auto">Реальный пример: парсер JSON в воркере с worker_thread для тяжелых вычислений. SharedArrayBuffer заполняется данными, но без правильного cleanup ссылки висят. HTOP покажет, как GC жрет все ядра, пока основной event loop стоит.Кластер думает, что все ок - метрики лгут, а отклик растет до таймаутов. Логично перейти к симптомам.</p>
<ul>
<li><strong>Double-marking объектов</strong>: SharedArrayBuffer делает GC в каждом потоке независимым, но ссылки дублируются - overhead x2.</li>
<li><strong>Утечки в message ports</strong>: PostMessage между threads копирует объекты, не уничтожая оригиналы - heap растет.</li>
<li><strong>Atomics.wait() блокирует GC</strong>: Синхронизация тормозит minor GC, major запускается чаще.</li>
</ul>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Симптом</th>
<th>Норма</th>
<th>Проблема</th>
</tr>
</thead>
<tbody>
<tr>
<td>GC CPU</td>
<td>&lt;20%</td>
<td>70%+</td>
</tr>
<tr>
<td>Heap size</td>
<td>stable</td>
<td>растет</td>
</tr>
<tr>
<td>Throughput</td>
<td>10k req/s</td>
<td>2k req/s</td>
</tr>
</tbody>
</table>
<h2>Скрытые утечки: где именно они прячутся</h2>
<p dir="auto">Основная засада - в SharedArrayBuffer без детектора ownership. Когда thread A пишет в буфер, thread B держит WeakRef, но V8 не видит, что буфер больше не active. Результат: <strong>phantom references</strong> множатся, minor GC не справляется.Добавь кластер - и каждый воркер плодит свои утечки независимо.</p>
<p dir="auto">Пример кода-убийцы: воркер спавнит threads для батч-обработки, шлет данные через parentPort.postMessage({data: hugeArray}). Array не transferable - копируется целиком, heap x10. Под 1k RPS кластер умирает за 5 минут.Переходим к диагностике.</p>
<ol>
<li><code>node --inspect</code> + Chrome DevTools: смотри heap snapshots в worker_threads.</li>
<li><code>clinic.js flame</code> на кластере: увидишь GC пики синхронно по всем воркерам.</li>
<li><code>process.memoryUsage()</code> в setInterval: детект growth в rss и heapTotal.</li>
</ol>
<p dir="auto"><em>Нюанс: worker.terminate() не всегда чистит SharedArrayBuffer - detach вручную.</em></p>
<h2>Нативный фикс без pm2: чистый кластер + threads</h2>
<p dir="auto">Фикс простой: кастомный кластер-менеджер с explicit cleanup. Используй process.fork() вместо cluster, управляй threads lifecycle сам. В воркере - <strong>Transferable objects</strong> для postMessage, WeakMap для shared refs.Код-скелет: в master спавни воркеры, в них - threads только для CPU задач. На exit - Atomics.store(buffer, 0, 0); detachArrayBuffer(). GC падает до 10%.Нет pm2 - нет оверхеда на sticky sessions и respawn.</p>
<pre><code class="language-javascript">const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.postMessage({ task: 'heavy' }, [sharedBuffer]);
  worker.on('exit', () =&gt; {
    sharedBuffer = null; // explicit cleanup
  });
} else {
  // process task, post back transferable
  parentPort.postMessage(result, [resultBuffer]);
}
</code></pre>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>До фикса</th>
<th>После</th>
</tr>
</thead>
<tbody>
<tr>
<td>GC 70%</td>
<td>10%</td>
</tr>
<tr>
<td>Mem leak</td>
<td>stable</td>
</tr>
<tr>
<td>RPS</td>
<td>x5 growth</td>
</tr>
</tbody>
</table>
<h2>Проверка и тюнинг: что мониторить дальше</h2>
<p dir="auto">Запусти под нагрузкой Artillery или autocannon - смотри p95 latency. Тюнь --max-old-space-size=4g на воркер, ParallelGCThreads=ядер/2.Если кластер на 8 cores - 4 threads max, остальное I/O.</p>
<p dir="auto">Кластер стабилен, но подумай о numactl для pinning threads к cores - еще -20% GC. Или мигрируй тяжелые задачи в Rust WASM - но это уже другая история. Меньше threads - чище heap.</p>
]]></description><link>https://forum.exlends.ru/topic/1981/pochemu-node.js-klastery-zhrut-70-cpu-na-gc-utechki-worker_threads-i-nativnyj-fiks</link><generator>RSS for Node</generator><lastBuildDate>Sat, 17 Oct 2026 03:11:35 GMT</lastBuildDate><atom:link href="https://forum.exlends.ru/topic/1981.rss" rel="self" type="application/rss+xml"/><pubDate>Sat, 28 Mar 2026 07:43:49 GMT</pubDate><ttl>60</ttl></channel></rss>