32. Hyper Threading и SMT

Иллюзия ядер в htop

Представьте знакомую многим картину: вы заказываете у облачного провайдера или покупаете выделенный сервер с гордой надписью в спецификации: «8 физических ядер». Вы подключаетесь по SSH, запускаете утилиту htop или компилируете небольшую программу на Go с вызовом runtime.NumCPU(), и операционная система радостно рапортует: у вас в распоряжении целых 16 ядер!

Планировщик Linux усердно распределяет процессы по шестнадцати строчкам в окне монитора, а Go-рантайм по умолчанию услужливо поднимает шестнадцать логических процессоров P в своей модели планирования G-M-P.

Но если взять отвертку, снять с сервера массивный медный радиатор и заглянуть в кремниевый кристалл под микроскопом, вы обнаружите ровно 8 физических кремниевых ядер. Никаких тайных фабрик внутри кремния нет. Откуда же взялись еще 8 вычислителей?

Перед нами классическая инженерная иллюзия — технология SMT (Simultaneous Multithreading — Одновременная многопоточность). В маркетинговых материалах компании Intel эта концепция получила звучное имя Hyper-Threading (HT), инженеры AMD именуют ее просто SMT, но физическая суть кремния везде едина.

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


Проблема простаивающего кремния

В главе 12. Суперскалярность, Out Of Order Execution и Register Renaming мы выяснили, что современное ядро CPU — это колоссальный, невероятно широкий завод. На кристалле размещены десятки специализированных исполнительных блоков (Execution Units):

  • Несколько целочисленных ALU (арифметико-логических устройств);
  • Блоки векторных и вещественных вычислений FPU (Floating Point Unit);
  • Множество портов и очередей загрузки/сохранения (Load/Store);
  • Механизмы переименования регистров и буферы переупорядочивания (ROB).

В теории такой суперскалярный процессор способен декодировать и запустить на исполнение 4, 6 или даже 8 инструкций за один-единственный такт генератора!

Однако в реальной жизни классического бэкенд-сервера этот могучий кремниевый конвейер большую часть времени мучительно скучает. Происходят так называемые заторы и пузыри конвейера (Pipeline Stalls).

Почему процессор простаивает?

  1. Cache Miss (Промах мимо кэша): Программа обращается по указателю к данным в оперативной памяти (см. 18. Кэши CPU. L1, L2, L3 и Cache Line). Если нужной строки нет в L1 и L2, ядро отправляет запрос в медленный DRAM и буквально замирает на 150–250 тактов. Для частоты в 4 ГГц 200 тактов — это бесконечность: за это время ядро могло бы выполнить тысячу инструкций!
  2. Branch Misprediction (Ошибочное предсказание перехода): В коде встретилась проверка if err != nil. Если branch predictor ошибся, сотни спекулятивно разогнанных инструкций аннулируются, конвейер принудительно сбрасывается, и работа начинается с нуля.
  3. Data Dependency (Зависимость по данным): Инструкция сложения ждет, пока предыдущая инструкция умножения завершит расчет и запишет результат во временный регистр.

В результате реальный показатель IPC (Instructions Per Cycle) в типовом программном коде редко превышает 1–1.5 инструкции за такт. Огромный массив транзисторов ALU и FPU просто греет воздух, ожидая прибытия байт из шины памяти.

Инженеры Bell Labs и создатели процессорных микроархитектур задались логичным вопросом: если один поток команд физически не способен насытить работой широкий конвейер, почему бы не скормить одному физическому ядру сразу два независимых потока инструкций одновременно?


Как работает SMT под капотом

Чтобы операционная система поверила, что перед ней два полноценных процессора, инженерам не потребовалось удваивать дорогостоящие транзисторы всего ядра. Достаточно было продублировать лишь так называемое Архитектурное состояние (Architectural State) процессора.

Что входит в архитектурное состояние? Только то, что однозначно определяет контекст выполняемой нити:

  • Файлы регистров общего назначения (Register File): регистры RAX, RBX, RCX, RDX, RDI, RSI, RSP, RBP и R8–R15;
  • Счетчик команд (Program Counter / PC/RIP): указатель на адрес следующей инструкции в кодовой секции;
  • Сегментные регистры, регистр флагов (RFLAGS) и контрольные управляющие регистры.

В кремниевой топологии чипа это архитектурное состояние занимает крошечную площадь — по оценке Intel для первых реализаций Hyper-Threading (Pentium 4 / Xeon, 2002 год), менее 5% дополнительной площади кристалла!

При этом все самые тяжелые, «горячие» и энергоемкие узлы процессора остаются едиными и неделимыми:

  • Исполнительный движок (Execution Engine): блоки ALU, блоки FPU, порты Load/Store;
  • Кэши первого и второго уровней (L1, L2): кэш инструкций L1i, кэш данных L1d и быстрый кэш L2 (физически один на ядро и общий для обоих потоков; впрочем, часть структур — очереди, буферы — делится между потоками статически, а записи в TLB и других структурах помечаются идентификатором потока);
  • Блоки предсказания ветвлений и буферы трансляции адресов (TLB).
flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Core["Физическое Ядро CPU (Physical Core)"]
        subgraph L0["Логическое ядро 0 (Hardware Thread 0)"]
            State0["Архитектурное состояние 0<br>Register File (RAX, RBX, RDI), PC/RIP, Флаги"]
        end
        subgraph L1["Логическое ядро 1 (Hardware Thread 1)"]
            State1["Архитектурное состояние 1<br>Register File (RAX, RBX, RDI), PC/RIP, Флаги"]
        end

        Sched["Аппаратный диспетчер ядра<br>(Out-of-Order Instruction Scheduler)"]
        Exec["Общий исполнительный движок (Execution Engine)<br>Целочисленные ALU, Вещественные FPU, Load/Store"]
        Cache["Общие кэши ядра<br>L1i (32 КБ), L1d (32 КБ), L2 (1-2 МБ)"]

        State0 -->|"Поток инструкций А"| Sched
        State1 -->|"Поток инструкций Б"| Sched
        Sched -->|"Чередование без задержек"| Exec
        Exec <-->|"Чтение / Запись данных"| Cache
    end
  

Ресторанная аналогия: один шеф-повар и два официанта

Чтобы живо представить работу SMT, вообразите элитный ресторан.

  • Физическое ядро — это кухня во главе с виртуозным шеф-поваром (Execution Engine).
  • Если у шеф-повара всего один официант (однопоточное ядро без SMT), процесс выглядит так: официант приносит заказ, повар начинает жарить стейк. Вдруг официант вспоминает, что клиенту нужно редкое марочное вино, и убегает за ним в дальний винный погреб (Cache Miss в оперативную память). Шеф-повар вынужден выключить плиту и 15 минут стоять без дела, дожидаясь возвращения официанта. Кухня простаивает!
  • Инженеры внедряют SMT: они нанимают второго официанта (второе логическое ядро). Официанты имеют свои блокноты с заказами (Register File и PC/RIP). Теперь, как только первый официант убежал в погреб за вином, второй официант мгновенно протягивает повару чек на салат. Повар тут же нарезает зелень на ALU.

Когда поток А выполняет операцию чтения a = *ptr и наталкивается на промах кэша, аппаратный планировщик ядра не ждет ни наносекунды. Он может в следующем же такте продолжить исполнять инструкции потока Б. (Это упрощённое описание: именно так работает грубая многопоточность с переключением по событию (coarse-grained / switch-on-event). В настоящем SMT потоки не «переключаются», а инструкции обоих потоков одновременно присутствуют в конвейере и могут выдаваться на исполнение в одном и том же такте — отсюда слово Simultaneous.)

SMT — это не увеличение абсолютной скорости вычислений. Это виртуозное заполнение «пузырей» простоя одного потока полезными инструкциями другого потока.


Mechanical Sympathy: Hyper-Threading и Go

Когда ваше Go-приложение стартует в операционной системе, рантайм Go запрашивает число доступных процессоров через стандартные системные интерфейсы Linux (такие как sched_getaffinity). ОС радостно докладывает обо всех логических процессорах (vCPU).

По умолчанию Go инициализирует конфигурационную переменную GOMAXPROCS числом, равным количеству логических ядер. На нашем 8-ядерном сервере с включенным SMT рантайм Go создаст ровно 16 логических сущностей P в модели G-M-P. (Начиная с Go 1.25 рантайм дополнительно учитывает ограничение CPU в cgroup — например, лимит контейнера в Kubernetes — и уменьшает GOMAXPROCS при необходимости.) Каждая такая структура P получит свой локальный планировщик горутин (runqueue), кэш аллокатора mcache и будет выполняться на потоке ОС (M); привязка P к M не жёсткая: например, при блокирующем системном вызове P передаётся другому потоку, так что потоков M может быть больше, чем P.

С точки зрения неопытного разработчика это выглядит как бесплатное удвоение мощности: «У меня теперь 16 процессоров, значит, код будет работать в два раза быстрее!» Увы, законы физики не обмануть.

1. Когда SMT работает идеально (Web-backend)

Типичный микросервис на Go — это смесь ввода-вывода и вычислений на данных, раскиданных по памяти:

  • Десериализация и парсинг JSON/Protobuf с обильными аллокациями в куче;
  • Чтение и запись в сетевые сокеты HTTP/gRPC;
  • Вызовы базы данных (PostgreSQL, Redis), обход связанных списков и хэш-таблиц (map).

В таком коде переходы по указателям происходят на каждом шагу, а кэш-промахи сыплются сотнями тысяч в секунду. Для web-бэкенда SMT раскрывается во всем блеске: пока горутина №1 на логическом ядре 0 простаивает из-за промаха кэша (подгрузка структуры из RAM) или зависимости по данным, горутина №2 на логическом ядре 1 бодро сериализует байты в буфер. (Важная оговорка: SMT скрывает микроархитектурные простои длиной в десятки–сотни тактов. Ожидание ответа от сети или диска длится микро- и миллисекунды: такая горутина паркуется рантаймом и вообще не занимает логическое ядро — ее место занимает другая готовая горутина.)

В сценариях классического веб-сервиса автоматическая настройка GOMAXPROCS = 16 под число всех доступных логических ядер обеспечивает максимальный RPS (Requests Per Second). Рантайм Go извлекает максимум пользы из GOMAXPROCS = 16.

2. Когда SMT вредит (Math & Cache Contention)

Представьте диаметрально противоположную задачу. Вы пишете на Go высокооптимизированный вычислительный модуль (CPU-bound):

  • Перемножение матриц или расчет графов для рекомендательной системы;
  • Криптографическое хэширование (SHA-256, Argon2);
  • Высокоскоростное сжатие данных (zstd, snappy).

Ваш код написан с глубоким «механическим сочувствием»: структуры выровнены по 64-байтовым границам, циклы развернуты, данные лежат плотно в непрерывных слайсах, процессорный конвейер почти не делает кэш-промахов, а векторные инструкции заполняют все порты FPU на 100%.

Что произойдет, если запустить такой алгоритм на всех логических ядрах?

TEXT
+-------------------------------------------------------------------+
|               Физическое Ядро (Physical Core)                    |
|                                                                   |
|   Логическое Ядро 0 (Thread A)      Логическое Ядро 1 (Thread B)  |
|   [Математический расчет X]        [Математический расчет Y]     |
|              \                              /                     |
|               \                            /                      |
|                v                          v                       |
|           ======================================                  |
|           КОНКУРЕНЦИЯ ЗА ЕДИНСТВЕННЫЙ БЛОК FPU / ALU              |
|           Аппаратная давка за порты исполнения!                   |
|           ======================================                  |
|                             |                                     |
|                             v                                     |
|           ======================================                  |
|           L1d КЭШ (32 КБ) — ОБЩИЙ НА ДВА ПОТОКА                   |
|           Thread A вытесняет строки Thread B                      |
|           -> Взрывной рост Cache Misses (Cache Thrashing)        |
|           ======================================                  |
+-------------------------------------------------------------------+

Для тяжелых математических систем золотое правило — выставлять GOMAXPROCS равным количеству физических, а не логических ядер (либо использовать taskset / numactl, привязывая воркеры строго к отдельным физическим ядрам; какие номера логических ядер являются соседями по SMT, зависит от платформы — смотрите lscpu -e или thread_siblings_list).


"Шумные соседи" и Безопасность в Облаке

В эпоху публичных облаков (AWS, Google Cloud, Azure) концепция SMT обрела остросоциальное звучание.

Когда вы арендуете небольшую виртуальную машину в облаке (например, инстанс t3.medium в AWS EC2 на 2 vCPU), провайдер почти никогда не выделяет вам два изолированных физических кремниевых ядра. Как правило, вам выделяют два логических потока (vCPU) — у многих провайдеров это два SMT-потока одного физического ядра. (Например, в AWS на архитектуре Nitro такие соседние потоки принадлежат одному и тому же инстансу, и «шумные соседи» из других виртуальных машин делят с вами не их, а общие ресурсы кристалла: кэш L3, память, шины.)

Если соседняя виртуалка на том же кристалле решит запустить стресс-тест памяти, ваш Go-сервис почувствует деградацию: его данные будут вытесняться из общего кэша L3, а пропускная способность памяти окажется разделена. А если провайдер всё же отдаёт SMT-потоки одного ядра разным клиентам, то к этому добавятся «вымытый» L1 и занятые чужими инструкциями исполнительные блоки.

Атаки по сторонним каналам (Side-Channel Attacks)

Но потеря производительности — это лишь половина беды. Общие кремниевые структуры логических ядер породили фундаментальный кризис информационной безопасности:

  • PortSmash и TLBleed: атаки через общие порты исполнения и TLB соседнего логического потока (классические SMT-атаки). Заметим: Spectre и Meltdown к SMT как таковому не относятся — это уязвимости спекулятивного исполнения, они работают и без соседнего потока;
  • L1TF (L1 Terminal Fault / Foreshadow) и MDS (Microarchitectural Data Sampling): уязвимости, при которых код на одном логическом ядре может подсмотреть чужие данные на соседнем. У L1TF речь о данных в L1 кэше данных, общем для SMT-соседей; у MDS утечка идёт из внутренних буферов ядра (store/fill buffers, load ports), а не напрямую из L1. Страдают горутины и виртуальные машины, оказавшиеся на соседнем логическом ядре!

Через микроскопические различия во времени доступа к кэшу (тайминги в тактах процессора) злоумышленник способен по крупицам восстановить приватные ключи шифрования TLS, пароли и токены авторизации, находящиеся в памяти вашего Go-сервиса.

Именно поэтому в индустриях с повышенными требованиями к безопасности, а также в мире сверхнизких задержек (HFT — High Frequency Trading):

  1. Hyper-Threading принудительно отключают в настройках BIOS.
  2. Жертвуют 20–30% общей пропускной способности ради железной стабильности задержек (устранения хвостовых p99.9 Latency spikes) и криптографической изоляции.

Итог

Подведем инженерные итоги нашего знакомства с Simultaneous Multithreading:

  1. SMT (Hyper-Threading) — это элегантная кремниевая оптимизация, дублирующая лишь минимальное архитектурное состояние процессора (регистры и счетчик команд, менее 5% площади кристалла) при сохранении общих вычислительных мощностей (ALU, FPU, конвейер) и общих кэшей (L1, L2).
  2. Иллюзия для софта: Операционная система и рантайм Go (через runtime.NumCPU()) воспринимают логические потоки SMT как полноценные физические процессоры, устанавливая GOMAXPROCS в суммарное число vCPU (а в Go 1.25+ — с поправкой на лимит CPU в cgroup, если он задан).
  3. Область максимальной пользы: Наибольший выигрыш (до 20–30% к пропускной способности) SMT дает в задачах с частыми простоями по памяти — классических сетевых микросервисах, где инструкциям есть чем заполнить аппаратные «пузыри» простоя.
  4. Зона риска и деградации: В чистых счетных задачах (CPU-bound, криптография, физика) SMT приводит к аппаратной давке за блоки вычислений и разрушению кэша (Cache Thrashing), увеличивая задержки.
  5. Безопасность: Логические соседи по физическому ядру не изолированы на уровне кэшей L1/L2, что делает технологию уязвимой к аппаратным атакам по сторонним каналам (MDS, L1TF, PortSmash, TLBleed).

Мы разобрались, как одно ядро может эффективно притворяться двумя. Но в современных серверных процессорах масштабирование давно вышло за рамки отдельного монолитного кристалла.

Сегодня процессоры собираются как конструктор Lego из отдельных миниатюрных чипов, объединенных высокоскоростными шинами. Чтобы понять, почему соседние ядра в современных чипах могут общаться с радикально разной скоростью, в следующей лекции мы разберем: 33. Архитектура современных CPU. Chiplet, CCX, CCD, Ring Bus, Mesh.