Write barriers: цена инкрементального и поколенческого GC
Каждая запись указателя в объект кучи запускает write barrier. Он служит двум господам: поколенческий барьер записывает указатели old→young в remembered set, чтобы minor GC не сканировал old space, а барьер в стиле Дейкстры держит трёхцветный инвариант
obj.next = node выглядит как одна запись — записать указатель, готово. Но у GC есть два открытых вопроса, которые эта запись может обесценить. Если obj живёт в old space, а node молодой, то minor GC, сканирующий только young space, пропустит эту совсем новую ссылку и освободит живой объект. Если в полёте конкурентная разметка и obj уже black, то эта запись может протащить белый объект мимо маркера. Поэтому это одно присваивание — не одна инструкция: V8 тихо оборачивает его в проверку под названием write barrier, и она выполняется почти на каждой записи указателя в вашей программе.
Почему запись не бесплатна
Прошлые два урока оставили два долга по корректности, оба порождённые одним и тем же — указателем, изменённым между сборками или во время них:
- Поколенческий долг. Minor GC сканирует только young space (плюс корни), потому что сканировать гигабайты old space при каждом Scavenge уничтожило бы весь смысл поколенческого GC. Но старые объекты могут держать ссылки на молодые (
oldArray.push(youngObj)). Если бы V8 их игнорировал, он освободил бы молодой объект, на который old space всё ещё ссылается. - Долг разметки. Конкурентная/инкрементальная разметка даёт JS изменять граф посреди трассировки. Запись может заставить чёрный (просканированный) объект указать на белый (недостигнутый), нарушив трёхцветный инвариант, так что маркер завершится, не посетив живой объект, и sweep его соберёт.
Write barrier — это небольшой кусок кода, который компилятор выпускает вокруг почти каждой записи указателя в объект кучи. Он выполняется в момент obj.field = ptr и делает любой учёт, нужный, чтобы оба вопроса оставались закрыты. Один барьер — две работы.
Работа 1: поколенческий барьер и remembered set
Чтобы не сканировать old space на minor GC, V8 поддерживает remembered set (реализованный через store buffer, позже обрабатываемый в наборы слотов по страницам): запись каждого слота в old space, держащего указатель в young space. Когда вы пишете oldObj.field = youngObj, поколенческий write barrier замечает, что запись пересекает old→young, и записывает этот слот.
При следующем Scavenge V8 трактует remembered set как дополнительные корни: он сканирует эти записанные слоты, чтобы найти молодые объекты, удерживаемые живыми ссылками old space, без обхода old space вообще. После того как Scavenge обновляет адреса перемещённых молодых объектов, именно remembered set позволяет починить указатели old space.
const cache = []; // promoted to old space over time
function handle(req) {
const entry = { req }; // young
cache.push(entry); // OLD-массив теперь ссылается на YOUNG-запись
// ^ барьер записи поколений фиксирует этот слот
}Барьер срабатывает только для направления old→young. Записи young→young и young→old неинтересны minor GC (young всё равно полностью сканируется; old→? — забота major GC), поэтому V8 убирает барьер в этих случаях.
Работа 2: барьер инкрементальной разметки (стиль Дейкстры)
Во время конкурентной разметки тот же хук записи защищает трёхцветный инвариант. V8 использует insertion-барьер в стиле Дейкстры: когда мутатор записывает указатель на white-объект в любой объект во время разметки, барьер серит белую цель (кладёт её в marking worklist). Это гарантирует, что маркер в итоге её просканирует, поэтому её нельзя оставить белой и ошибочно собрать. (Классическая альтернатива — Yuasa snapshot-at-the-beginning барьер, серящий перезаписываемого старого референта; дизайн V8 использует insertion в стиле Дейкстры для конкурентного маркера. В любом случае цель идентична: ни один живой объект не ускользает от трассировки.)
Поскольку обе работы запускаются одним событием — записью указателя в слот кучи — V8 сливает их в единый путь барьера, проверяющий нужные условия (активна ли разметка? пересекает ли запись old→young?) и делающий минимум работы.
Почему записи Smi пропускают барьер
Барьер существует только для отслеживания указателей между объектами кучи. Smi (small integer — 31-битное целое в 64-битном V8) — это не указатель: он закодирован прямо внутри tagged-значения с нулевым младшим tag-битом, поэтому он вообще не может ссылаться на объект кучи. Запись Smi в поле — obj.count = 42 — не создаёт межобъектной ссылки, поэтому V8 не выпускает write barrier для неё. То же верно для записи других immediate. Это ещё одна причина, почему различие Smi/HeapNumber из юнита 02 важно для производительности: записи полей с большим числом целых барьерны-свободны, тогда как записи, кладущие указатели на объекты или упакованные double, платят за проверку барьера.
Стоимость и где она кусает
Каждый барьер дёшев по отдельности — несколько инструкций: загрузить метаданные страницы цели или проверить флаг разметки, ветвление, а в медленном случае положить в worklist или store buffer. Назовём это горсткой циклов на запись указателя. Это невидимо в обычном коде. Это становится измеримым на горячих путях с большим числом аллокаций и мутаций: плотные циклы, строящие большие графы объектов, многократно переуказывающие поля или кладущие миллионы ссылок на объекты в долгоживущие контейнеры. Там накладные расходы барьера реальны, и это одна из скрытых стоимостей «просто положить объекты в массив» против работы с типизированными массивами или Smi-кодированными данными.
- Выполняется на
- ~каждой записи указателя в кучу
- Поколенческая работа
- записать слот old→young
- Работа разметки (V8)
- Дейкстра insertion: серить цель
- Remembered set через
- store buffer → наборы слотов
- Запись Smi / immediate
- без барьера
- Типичная стоимость
- несколько циклов / запись
Зачем minor GC (Scavenge) вообще нужен remembered set?
`obj.count = 42` против `obj.child = someObject`. Какая запись(и) выпускает write barrier и почему?
Расставьте по порядку, что происходит, когда JS исполняет `oldObj.field = youngWhiteObj` во время активной конкурентной разметки.
- 1 Запись попадает в инлайновый write barrier вместо прямой записи
- 2 Барьер проверяет: разметка активна, значит серить белую цель в marking worklist
- 3 Барьер проверяет: запись пересекает old→young, значит записать слот в remembered set
- 4 Фактический указатель записывается в поле
- 5 Позже маркер сканирует ставшую серой цель, чтобы её не собрали ошибочно
▸Почему это работает
Почему вставлять барьер на записи, а не на чтения? Потому что записи в типичном коде гораздо реже чтений и являются единственными операциями, которые могут изменить достижимость или нарушить трёхцветный инвариант, — чтение указателя не может создать новое ребро в графе объектов. Read barrier (используемый некоторыми сборщиками, например для перемещения в ZGC/Shenandoah) дороже именно потому, что чтения доминируют. V8 выбирает write barriers как более дешёвое место платить, поэтому стоимость концентрируется на коде с большим числом мутаций.
- 01Какие две проблемы корректности решает write barrier и как?
- 02Почему записи Smi пропускают write barrier и почему это важно для производительности?
- 03Где накладные расходы write barrier реально становятся измеримыми и почему барьеры на записях, а не на чтениях?
Write barrier — это код, который V8 выпускает вокруг почти каждой записи указателя в объект кучи, и он гасит два долга по корректности из прошлых уроков. Поколенчески: поскольку minor GC сканирует только young space, указатели old→young были бы ему невидимы; барьер записывает слот каждой такой записи в remembered set (опирающийся на store buffer), и Scavenger использует эти слоты как дополнительные корни, чтобы находить молодых выживших без сканирования old space. Для разметки: поскольку конкурентная/инкрементальная разметка даёт JS менять граф посреди трассировки, запись может заставить чёрный объект сослаться на белый и нарушить трёхцветный инвариант; insertion-барьер V8 в стиле Дейкстры серит белую цель в worklist, чтобы маркер её всё же посетил и никогда не собрал живой объект. Обе работы делят один триггер — запись указателя, — поэтому V8 их сливает. Запись Smi или другого immediate не создаёт межобъектного указателя, поэтому пропускает барьер, отчего накладные расходы барьера концентрируются на горячих путях с большим числом аллокаций и мутаций и являются реальной, пусть и малой, скрытой стоимостью текучки указателей на объекты. Теперь, когда в flamegraph вы замечаете неожиданную стоимость барьера — плотный цикл, кладущий объекты в долгоживущий массив, — вы знаете, что делать: переключитесь на типизированные массивы или Smi-кодированные данные, и барьер исчезнет.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в208
- Путь запроса: семь остановок от сокета до ответаjunior
- Accept и парсинг: от очереди ядра до типизированного запросаmiddle
- Маршрутизация и middleware: что выполняется и в каком порядкеmiddle
- Обработчик и ответ: от бизнес-логики до байтов на проводеmiddle
- Стриминг и backpressure: когда клиент читает медленнее, чем вы пишетеsenior
- Таймауты и хвостовая задержка: бюджеты, дедлайны и ловушка fan-outsenior
- Middleware и DI: два паттерна, формирующие любой backendjunior
- Пишем middleware: сигнатуры, next() и три модели фреймворковmiddle
- Инверсия управления: как зависимости добираются до классаmiddle
- Скоупы и время жизни DI: singleton, request, transientmiddle
- DI как шов для тестов: фейки, моки и граница, которая важнаsenior
- DI-контейнеры в продакшене: графы разрешения, циклы и когда не стоитsenior
- Блокирующий vs неблокирующий I/O: два способа ждатьjunior
- Event loop: один поток, упорядоченные фазыmiddle
- Что блокирует цикл: CPU-работа и синхронные вызовыmiddle
- Вынос CPU-работы: worker threads и пул libuvmiddle
- Backpressure и ограниченная конкурентностьsenior
- Пропускная способность под нагрузкой: хвостовая задержка и насыщениеsenior
- Зачем пул: цена создания соединенияjunior
- Размер пула: почему больше не значит быстрееmiddle
- Взятие и таймауты: очередь ожидания — настоящий дроссель задержкиmiddle
- Стратегии retry: backoff, jitter и thundering herdmiddle
- Наблюдаемость, production-инциденты и дизайн для глобального масштабаsenior
- Задачи, микрозадачи и scheduler.yield()middle
- Точность таймеров, троттлинг и фоновая работаmiddle
- Event loop Node.js: фазы, nextTick и задержка циклаsenior
- Стратегии рендеринга: SSG, SSR, ISR, streaming и гидратацияjunior
- SSG, SSR, ISR, streaming и RSC — как работает каждая стратегияmiddle
- Цена гидратации: selective, progressive, острова, resumabilitymiddle
- Core Web Vitals: что измеряют LCP, INP и CLSjunior
- LCP: четыре фазы, одна доминирующая стоимостьmiddle
- INP: input delay, processing, presentationmiddle
- Lab vs field: почему они расходятся и как использовать каждыйmiddle
- Трейдоффы метрик, RUM-атрибуция и цикл CI+полеsenior
- Общая картина: от URL до LCP до INP как эстафетаjunior
- Восемь слоёв трассировки: от service worker до второй навигацииmiddle
- Пять канонических поломок: где производство стабильно ломаетсяsenior
- Метод трёх треков: чтение трасс и построение системы мониторингаsenior
- Что такое индекс и как он ускоряет запросыjunior
- Leading-column rule: почему порядок столбцов в composite-индексе важенmiddle
- Partial, expression и covering-индексыmiddle
- Типы индексов: GIN, GiST, BRIN, Hash, Bloom и HOT-обновленияmiddle
- Index-only scan, Visibility Map и INCLUDEsenior
- Типичные сбои в продакшне и аудит индексовsenior
- Упражнение по проектированию индексов: стратегия полнотекстового поискаsenior
- EXPLAIN и планы выполнения: что решает планировщик и почемуjunior
- Типы сканирования: Seq, Index, Bitmap, Index-Onlymiddle
- Алгоритмы соединения и каскад ошибок оценки строкmiddle
- pg_statistic, ANALYZE и производственная наблюдаемостьmiddle
- Расширенная статистика: исправление ошибок оценки для коррелированных колонокsenior
- Кеш планов, настройка константных стоимостей и внутренности планировщикаsenior
- Производственные режимы отказа и стабильность плановsenior
- Connection pool: зачем амортизировать стоимость backend Postgresjunior
- Режимы PgBouncer: session, transaction и statementmiddle
- Размер пула: формула (ядра × 2) + шпинделей и двухуровневый стекmiddle
- Исчерпание пула и idle-in-transaction: сценарий отказа в 3 ночиmiddle
- Миграция на transaction mode: план развёртывания и prepared statements в PgBouncer 1.21middle
- Процессная модель Postgres и почему увеличение max_connections снижает производительностьsenior
- Ландшафт пулеров 2026, serverless connection storms и полная таксономия отказовsenior
- ADD COLUMN: мгновенно в PG 11+ против перезаписи в старом Postgresjunior
- Режим отказа очереди блокировок: почему мгновенный DDL может заморозить базуmiddle
- Безопасные DDL-паттерны: NOT VALID, CONCURRENTLY и исправления небезопасных операцийmiddle
- Таксономия сбоев миграций и дисциплина продакшнаsenior
- Выбор ключа шарда: стратегии hash, range, list и directorymiddle
- Ко-локация и Citus: инвариант, делающий шардирование пригодным к использованиюmiddle
- Режим отказа hot shard: обнаружение, изоляция и долгосрочная политикаmiddle
- Онлайн-решардинг, 2PC и операционная стоимость шардированияsenior
- Семь актов: от CREATE TABLE до Citusjunior
- Акты 1–3 в глубину: схема, индексы и статистика планировщикаmiddle
- Акты 4–6 в глубину: MVCC bloat, connection pooling и безопасные миграцииmiddle
- Акт 7 в глубину: шардинг, co-location и семиуровневый каскад трейдоффовmiddle
- Наблюдаемость, антипаттерны и производственный триажsenior
- Биты в проводеjunior
- Математика задержкиmiddle
- Bufferbloat и перегрузкаsenior
- Граница физического уровняsenior
- Номера последовательности и состояние соединенияmiddle
- Управление потоком и перегрузкойmiddle
- BBR, производственная наблюдаемость и за пределами TCPsenior
- CDN: контент по соседствуjunior
- Anycast и GeoDNS: маршрутизация к ближайшему edgemiddle
- Многоуровневый кеш и Cache-Controlmiddle
- Заголовок Vary и cache keysmiddle
- Stale-while-revalidate и cache stampedesenior
- Edge workers и edge-side compositionsenior
- CDN: операции и observabilitysenior
- WebSocket: HTTP-апгрейд до постоянного соединенияjunior
- WebSocket vs SSE vs long-polling: выбор правильного транспортаmiddle
- Backpressure в WebSocket: когда клиенты не успеваютmiddle
- Реконнект: jittered backoff, thundering herd, восстановление сообщенийsenior
- WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10Msenior
- WebSocket в production: прокси, безопасность и распределённая архитектураsenior
- Что делают обратные проксиjunior
- Алгоритмы балансировки: от round-robin до power-of-two-choicesmiddle
- L4 vs L7 балансировка и сохранение IP клиентаmiddle
- Health checks, connection draining и slow startmiddle
- Retry-бури, circuit breakers и load sheddingsenior
- Устойчивая архитектура LB: anycast, zone-aware маршрутизация и observabilitysenior
- Почему QUIC, а не TCP+TLSjunior
- QUIC-потоки и head-of-line blockingjunior
- Объединённое рукопожатие и 1-RTTmiddle
- Connection ID и миграция сетиmiddle
- Обнаружение потерь и управление перегрузкойmiddle
- Возобновление 0-RTT и шифрование пакетовsenior
- Развёртывание и стоимость CPUsenior
- DDoS: что это и почему работаетjunior
- Атаки усиления и истощение состоянияmiddle
- Ограничение скорости: алгоритмы и архитектураmiddle
- WAF, межсетевые экраны, mTLS и HSTSmiddle
- Отравление DNS-кэша и BGP-перехватsenior
- Эшелонированная защита и экономика атакsenior
- Двенадцать слоёв: один URL, семь действующих лицjunior
- DNS, TCP, TLS по очереди: куда уходят миллисекундыmiddle
- Критический путь рендеринга и Core Web Vitalsmiddle
- Перехват прокси и шлюзы безопасности: rate limiter, WAF, mTLSmiddle
- Альтернативные пути: QUIC 0-RTT, WebSocket upgrade, миграция соединенияmiddle
- Наблюдаемость: распределённые трейсы, USE/RED и семплированиеsenior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое три сигнала: метрики, логи, трейсыjunior
- Метрики и cardinality: cost-модель time-series databasemiddle
- Логи и объём: cost-модель структурного логированияmiddle
- Трейсы и сэмплирование: cost-модель distributed tracingmiddle
- Join-ключи и exemplar''''ы: как три сигнала становятся компонуемымиmiddle
- Observability 2.0: широкие события и сдвиг стоимостиsenior
- Режимы сбоя и инженерная практика: cardinality budget''''ы, PII и сэмплированиеsenior
- Зачем нужны структурные логи: дневник против таблицыjunior
- Схема продакшн-лога: поля, которые несёт каждая строкаmiddle
- Log levels и маршрутизация алертовmiddle
- Стратегии sampling и стоимость логовmiddle
- PII-редакция и log injectionsenior
- Propagation trace-контекста в логахsenior
- OTel Logs Data Model и audit-логи как подсистемаsenior
- Сигналы OTel, Semantic Conventions и проводной формат OTLPmiddle
- Авто-инструментирование и ручные спаны: правило 80/20 в OTelmiddle
- Collector OTel: receivers, processors, exporters и паттерны развёртыванияmiddle
- Стратегии сэмплирования: head, tail и parent-basedmiddle
- Vendor-нейтральность, eBPF-инструментирование, Operator и OTel в браузере и serverlesssenior
- Эксплуатация OTel Collector: надёжность, version skew, режимы отказа и управлениеsenior
- RED и USE: два чек-листа, одна дисциплина триажаjunior
- Инструментация RED в Prometheus: счётчики, гистограммы и дисциплина cardinalitymiddle
- USE на Linux: CPU, память, диск, сеть и PSImiddle
- Golden signals, структура дашборда и auto-RED в service meshmiddle
- Cardinality как драйвер затрат: label, PII, exemplars и семплированиеmiddle
- Native histograms, SLO и паттерны production-сбоевmiddle
- Выбор SLI и SLO-целей: отношения, не ощущенияmiddle
- Multi-window multi-burn-rate-алертинг: почему AND лучше ORmiddle
- Error budget policy, latency SLO и составные journeysmiddle
- Iceberg SLI, математика составного SLO и SLA vs SLOsenior
- Flame graph: читаем картинку, которая показывает, куда ушло времяjunior
- Sampling vs instrumentation profiling: почему 99 Гц побеждает в productionmiddle
- Типы профилей: CPU, память, off-CPU, mutex — какой когда братьmiddle
- Continuous profiling: always-on flame graphs с eBPF и корреляцией trace-idmiddle
- Как flame graph строится из сэмплов и как использовать его в productionmiddle
- Linux perf, внутренности eBPF, PGO и ограничения sampling''''аsenior
- Profiling в production: безопасность, war stories, OTel profiles и дизайн инфраструктурыsenior
- Debugging-воронка: SLO → RED → trace → profilejunior
- Архитектура OTel: один SDK, четыре сигнала, один wire-форматmiddle
- Экономия на observability: удерживаем затраты в пределах 5% inframiddle
- Масштаб, безопасность и ROI наблюдаемых системsenior
- Сначала профиль: измерь куда реально уходит времяjunior
- Закон Амдала и self-time: потолок любого ускорения, которое ты можешь выпуститьmiddle
- Измерительный цикл: микробенч, макробенч, prod-профиль, эффект наблюдателяmiddle
- Чтение флейм-графов: формы, профайлеры по языкам и 60-секундный сканmiddle
- Статистические baseline''''ы: почему один запуск — не измерениеmiddle
- История профайлеров и ловушки микробенчей: от Кнута до GWPsenior
- Hardware counters, профили холодного старта и безопасность профилейsenior
- Непрерывное профилирование в масштабе: затраты, CI-гейты, корреляция с трейсами и антипаттерныsenior
- Что делает путь горячим: симптом против причиныjunior
- Пять форм hotspot''''а: CPU, аллокации, кэш, лок, syscallmiddle
- Чтение parent и child chains: где применять правкуmiddle
- JIT deopt, цикл fix-and-verify и PR-time профилированиеmiddle
- Аппаратные счётчики и Intel TMA: диагностика подкатегорийsenior
- False sharing и горячие пути нативных мостовsenior
- Горячие пути в production: безопасность, хвостовая латентность и происхождение инструментовsenior
- Иерархия памяти: почему расстояние важнее числа операцийjunior
- Row-major vs column-major: порядок доступа и разрыв в 9xjunior
- Branch prediction: 10–30 циклов штрафа за неожиданный ifmiddle
- Hardware prefetcher, TLB и memory-level parallelismsenior
- Основы GC: за что рантайм берёт налогjunior
- Алгоритмы GC: поколенческая гипотеза, concurrent marking и write barriermiddle
- GC tradeoffs: пауза, throughput, память и давление аллокацийmiddle
- Настройка GC: пейсинг, форма кучи и наблюдаемость аллокацийmiddle
- Внутреннее устройство GC: tri-color инвариант, write barriers и глубокое погружение в рантаймыsenior
- GC в production: наблюдаемость, безопасность, edge cases и управление флотомsenior
- N+1: одна логическая операция, много round-trip''''овjunior
- Семейства фиксов: JOIN, IN, preload и DataLoadermiddle
- Обнаружение N+1: query logs, APM traces и CI gatesmiddle
- DataLoader: батчинг по дереву резолверовmiddle
- Кросс-протокольный N+1: HTTP fan-out и Redis MGETmiddle
- N+1 в масштабе: исчерпание пула, изменения планов и денормализацияsenior
- Batching: амортизируй фиксированную цену каждой операцииjunior
- Окно батчинга: размер и время ожиданияmiddle
- Batching в Kafka и Postgresmiddle
- io_uring и наблюдаемость пакетированияmiddle
- От Nagle до io_uring: эволюция пакетированияmiddle
- Backpressure, изоляция сбоев и безопасность батчей в продакшенеsenior
- Что на самом деле стоит bundle: download, parse, compile, executejunior
- Core Web Vitals: LCP, INP и CLSmiddle
- Code splitting: route-level, component-level, vendor splittingmiddle
- Tree shaking и compression: удаляем то, что не используемmiddle
- Third-party scripts: тихий убийца бюджетаmiddle
- CI enforcement и RUM: делаем бюджеты рабочимиmiddle
- V8 JIT-пайплайн, HTTP-приоритеты и безопасность bundlesenior
- Цикл performance: дисциплина, а не проектjunior
- Классификация и исправление: сопоставление family bottleneck с методамиmiddle
- Observability-стек и CI gates: ловить регрессии до выпускаmiddle
- От инцидента к enforcement: SLO burn до верифицированного исправления за 35 минутmiddle
- Культура, экономика и масштаб performancesenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.