GraphLMS

ОС
Начать

ОС · Конкурентность · 15 мин

Конкурентные структуры, барьеры памяти, false sharing

Зачем вообще «потокобезопасные» структуры

Представьте обычную хеш-таблицу (map) или счётчик. Пока к ним лезет один поток — всё хорошо. Но как только два потока (или две горутины на разных ядрах) начинают одновременно читать и писать, начинается хаос: один читает значение, второй в этот момент его меняет, третий видит наполовину обновлённое состояние. Получаем race condition (гонку) — баг, который воспроизводится раз в неделю в проде и никогда на твоей машине.

Потокобезопасная (thread-safe) структура — это структура данных, которой можно безопасно пользоваться из нескольких потоков сразу, не получая порчи данных.

Самый прямой способ сделать структуру потокобезопасной — обернуть её мьютексом (mutex, «замок»: кто захватил — тот один внутри, остальные ждут). Про мьютекс как устройство мы говорили в главе locks-hardware, а как API в Go — в курсе sync-primitives. Здесь вопрос глубже: мьютекс — это правильно, но часто медленно, и важно понимать почему.

   Наивная потокобезопасная map:
 
   ┌──────────────────────────────┐
   │  type SafeMap struct {       │
   │      mu  sync.Mutex          │   ← один замок на ВСЮ структуру
   │      m   map[K]V             │
   │  }                           │
   └──────────────────────────────┘
 
   Get/Set:  mu.Lock() → работаем → mu.Unlock()

«Что нарисовано»: один общий замок защищает всю таблицу. Любая операция, даже чтение одного ключа, должна сначала захватить этот замок целиком.

Проблема: contention (борьба за замок)

Contention (контеншн, «состязание») — это ситуация, когда много потоков хотят один и тот же замок одновременно, и большинство просто стоит в очереди, ничего не делая. Чем больше ядер у сервера, тем хуже: 32 ядра дерутся за один мьютекс — и 31 из них простаивает. Параллельности нет, хотя процессоров полно.

   ОДИН БОЛЬШОЙ ЛОК (global lock)
 
   ядро0 ──┐
   ядро1 ──┤
   ядро2 ──┼──►  [ MUTEX ]  ──►  данные
   ядро3 ──┘        ▲

            только ОДИН проходит,
            остальные ждут (spin/sleep)

«Что нарисовано»: бутылочное горлышко. Сколько бы ядер ни было, в каждый момент работает одно. Это называют сериализацией — параллельный код выродился в последовательный.

Гранулярность блокировок: striped / sharded locks

Решение — не один замок на всё, а много замков на части. Это называется гранулярность блокировки (lock granularity): крупная гранулярность = один лок на всё (просто, но contention); мелкая = много мелких локов (сложнее, но потоки реже сталкиваются).

Приём striped locks (полосатые/секционные локи), он же sharding: делим структуру на N независимых кусков (шардов), у каждого свой замок. Ключ попадает в шард по хешу. Два потока, работающие с разными ключами, скорее всего попадут в разные шарды — и не столкнутся вообще.

   STRIPED LOCKS (16 шардов)
 
   ключ "user:42"  ─hash%16─►  shard[5]   [lock5][bucket5]
   ключ "order:7"  ─hash%16─►  shard[12]  [lock12][bucket12]
 
   ┌───────┬───────┬───────┬─────┬────────┐
   │shard0 │shard1 │shard2 │ ... │shard15 │
   │[lock] │[lock] │[lock] │     │[lock]  │
   └───────┴───────┴───────┴─────┴────────┘
        ▲       ▲
     ядро0   ядро1   ← работают ПАРАЛЛЕЛЬНО, локи разные

«Что нарисовано»: вместо одного замка — 16 независимых. Потоки, попавшие в разные шарды, не мешают друг другу. Это даёт почти линейный рост с числом ядер, пока ключи распределены равномерно.

Подход Плюс Минус
Один глобальный лок прост, легко рассуждать contention, сериализация
Striped / sharded locks масштабируется по ядрам сложнее; операции «через все шарды» (len, итерация) дороги
Lock-free (CAS) нет блокировок, нет deadlock очень сложно правильно; ABA-проблема

Lock-free: CAS-цикл вместо замка

Можно вообще без замка? Иногда да — через атомарную операцию CAS.

CAS (Compare-And-Swap, «сравни-и-поменяй») — это одна машинная инструкция, которую процессор выполняет неделимо (атомарно). Смысл: «сравни значение в памяти с тем, что я ожидаю; если совпало — запиши новое и верни успех; если кто-то успел поменять — верни неудачу, ничего не трогая». Подробно про атомики — в atomic.

Бытовая аналогия: ты редактируешь Google-документ. CAS — это «сохрани, только если с момента, когда я открыл, никто другой не сохранял; иначе скажи мне, и я перечитаю и попробую снова».

На CAS строят lock-free счётчик: читаем текущее значение, считаем новое, пытаемся записать через CAS. Если не вышло (кто-то опередил) — повторяем цикл.

   CAS-RETRY ЦИКЛ (инкремент счётчика)
 
   ┌─────────────────────────────────────────┐
   │ loop:                                    │
   │   old := atomic.Load(&cnt)   // прочитали│
   │   new := old + 1             // посчитали│
   │   if CAS(&cnt, old, new) {   // записали? │
   │        return    ──────────────► успех   │
   │   }                                      │
   │   // кто-то опередил → goto loop ◄──┐     │
   └────────────────────────────────────┘─────┘
 
   Поток A: old=5 new=6  CAS ok ✓
   Поток B: old=5 new=6  CAS fail ✗ (стало 6) → retry: old=6 new=7 ✓

«Что нарисовано»: цикл «прочитал → посчитал → попробовал записать». Если между чтением и записью кто-то вклинился, CAS проваливается и мы крутим цикл заново. Под капотом так работает atomic.AddInt64.

Чем это отличается от мьютекса:

  • Мьютекс при провале усыпляет поток (планировщик переключает на другого).
  • CAS-цикл не спит, он крутится и пробует снова (busy retry). При малой конкуренции это быстрее (нет похода в ядро), при большой — может жечь CPU впустую.

Важная ловушка lock-free — ABA-проблема: значение было A, кто-то поменял на B и обратно на A. Твой CAS видит A и думает «ничего не менялось», хотя на деле менялось дважды. Лечат счётчиками версий/тегами указателей. Для простого счётчика ABA не страшна, для стека на указателях — очень.

Барьеры памяти: почему «записал → другой увидел» не гарантировано

Вот коварная часть. Ты пишешь:

   Поток 1                 Поток 2
   data = 42               while ready == 0 {}   // ждём
   ready = 1               use(data)             // читаем

Кажется очевидным: раз ready==1, значит data уже 42. Нет. И процессор, и компилятор имеют право переупорядочить операции, если для одного потока результат не меняется. Поток 2 может увидеть ready==1, но data ещё старое.

Почему так происходит — две причины:

   ПЕРЕУПОРЯДОЧЕНИЕ (reordering)
 
   что написал программист:   data=42 ; ready=1
   ─────────────────────────────────────────────
   компилятор может выдать:    ready=1 ; data=42   (оптимизация)
   процессор может выполнить:  ready=1 ; data=42   (store buffer, OoO)
 
   Другое ядро видит порядок НЕ такой, как ты писал.

«Что нарисовано»: твой исходный порядок — не закон. Компилятор переставляет инструкции ради скорости; процессор откладывает записи в store buffer (буфер записи) и выполняет инструкции вне очереди (out-of-order). Для одного потока всё консистентно, но другое ядро видит «протёкший» порядок.

Барьер памяти (memory barrier / fence) — специальная инструкция, которая запрещает переупорядочивание через себя: «всё, что до барьера, завершится и станет видно раньше всего, что после». Это забор, через который операции не перепрыгивают.

   С БАРЬЕРОМ
 
   data = 42
   ─── BARRIER (store fence) ───   ← перелезать запрещено
   ready = 1
 
   Гарантия: увидел ready==1 ⇒ data уже 42

«Что нарисовано»: барьер фиксирует порядок. Запись data гарантированно видна до записи ready.

Связь с happens-before («происходит-до») — формальным правилом «если событие A happens-before B, то эффекты A видны в B». Барьеры — это механизм, которым happens-before реализуется на железе. В Go тебе обычно не нужно ставить барьеры руками: их вставляют атомики, мьютексы и каналы. mu.Unlock() в одном потоке happens-before mu.Lock() в другом — поэтому всё, что ты записал под локом, второй поток увидит. Подробно — в memory-model. Тут ключевая мысль: синхронизация нужна не только чтобы «не толкаться», но и чтобы записи стали видны другим ядрам в правильном порядке.

Аппаратная подоплёка: MESI и кэш-когерентность

Откуда вообще берётся видимость? У каждого ядра свой кэш (L1/L2) — быстрая локальная копия кусочков памяти. Память делится на кэш-линии (cache line), обычно 64 байта: это минимальная единица, которой ядра обмениваются.

Чтобы ядра не видели разные значения одной ячейки, они гоняют протокол когерентности MESI. Каждая кэш-линия в каждом ядре имеет одно из 4 состояний:

   MESI — состояния кэш-линии
 
   M (Modified)  — линия изменена ТОЛЬКО у меня, в памяти старьё.
   E (Exclusive) — линия только у меня, совпадает с памятью.
   S (Shared)    — линия у нескольких ядер, все читают, совпадает с памятью.
   I (Invalid)   — линия протухла, нельзя использовать.
 
   Ядро хочет ПИСАТЬ → должно получить линию в M:
     рассылает «Invalidate» всем → у других линия становится I
     ┌────────┐  invalidate   ┌────────┐
     │ ядро0  │ ────────────► │ ядро1  │
     │ line:M │               │ line:I │  (выкинул копию)
     └────────┘               └────────┘

«Что нарисовано»: чтобы записать в линию, ядро должно стать её единоличным владельцем (M) и аннулировать копии у всех остальных. Это сетевой трафик между ядрами. Чтение «протухшей» линии заставит ядро заново тянуть свежую копию.

False sharing: тихий убийца производительности

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

   FALSE SHARING — одна линия (64 байта)
 
   ┌──────────────── cache line 64B ────────────────┐
   │ counterA(8B) │ counterB(8B) │ ...padding 48B... │
   └──────────────────────────────────────────────────┘
        ▲ ядро0 пишет       ▲ ядро1 пишет
 
   ядро0 пишет A → invalidate линии у ядра1
   ядро1 пишет B → invalidate линии у ядра0
   ... линия мечется туда-сюда (ping-pong), хотя A и B не связаны!

«Что нарисовано»: counterA и counterB логически независимы, но физически в одной линии. Каждая запись одного ядра аннулирует линию у другого. Линия «пинг-понгует» между кэшами — производительность падает в разы, а в коде «ничего подозрительного».

Лечение — padding (набивка): раздвигаем переменные так, чтобы каждая лежала в своей кэш-линии. Добавляем пустые байты-заполнители.

   ПОСЛЕ PADDING
 
   ┌──── line 0 (64B) ────┐  ┌──── line 1 (64B) ────┐
   │ counterA │ pad 56B   │  │ counterB │ pad 56B   │
   └──────────────────────┘  └──────────────────────┘
        ▲ ядро0                    ▲ ядро1
   разные линии → никаких взаимных invalidate, полный параллелизм

«Что нарисовано»: каждая переменная одна в своей линии. Записи ядер больше не конфликтуют. В Go это делают вставкой поля-набивки, например _ [56]byte, или выравнивают структуры по 64 байта (см. cpu.CacheLinePad в рантайме). Классика — шардированные счётчики/пулы: каждый шард паддят до целой линии.

Симптом Похоже на На деле Лечение
Код тормозит при росте ядер плохой алгоритм false sharing padding до 64B
Значение «не видно» другому потоку редкий баг нет барьера/синхронизации atomic/mutex/канал
31 ядро простаивает мало работы contention на 1 локе striped locks / lock-free

Как это собрать вместе

  • Начни с простого мьютекса — он корректен и понятен. Не усложняй, пока профайлер не покажет contention.
  • Видишь горячий лок — переходи на striping/sharding: много мелких локов вместо одного.
  • Совсем горячий счётчик/флаг — атомики/CAS (lock-free), помня про ABA.
  • Любая межпоточная видимость держится на барьерах, которые дают atomic/mutex/ каналы. Без них «записал» ≠ «другой увидел».
  • Замеряя многопоточный код, держи в голове кэш-линии и false sharing — иногда одно поле-набивка ускоряет код в разы.
Проверь себя· Конкурентные структуры и память

Почему один глобальный мьютекс на всю структуру плохо масштабируется на многоядерном сервере?

Что делает приём striped (sharded) locks?

Как устроен lock-free инкремент на CAS?

Зачем нужны барьеры памяти, если код уже под мьютексом не толкается?

Что такое false sharing?

Сколько байт обычно занимает одна кэш-линия на x86?

Чем CAS-цикл (lock-free) отличается от мьютекса при провале попытки?

Что спрашивают на собесе

  • Чем плох один глобальный мьютекс на структуру и что такое contention? Ожидают рассказ про сериализацию и переход к striped/sharded locks.
  • Что такое CAS и как на нём сделать lock-free счётчик? Опиши retry-цикл read→compute→CAS→retry и упомяни ABA-проблему.
  • Зачем нужны барьеры памяти, если есть мьютекс? Объясни, что синхронизация даёт не только взаимное исключение, но и видимость/порядок записей (happens-before).
  • Что такое false sharing и как его лечить? Две независимые переменные в одной 64-байтной кэш-линии, ping-pong через MESI, лечение паддингом.
  • Расскажи про MESI на пальцах. Modified/Exclusive/Shared/Invalid и то, что запись требует invalidate чужих копий — отсюда стоимость межъядерной записи.
  • Когда lock-free хуже мьютекса? При высокой конкуренции CAS-цикл жжёт CPU на ретраях, а мьютекс усыпит поток; плюс lock-free тяжело написать корректно.
Как устроен лок: от железа к futexУсловные переменные