38. Latency Numbers Every Programmer Should Know
Проблема человеческого восприятия
Человеческий мозг формировался миллионы лет в макроскопическом мире. Мы интуитивно ощущаем разницу между одной секундой (ударом сердца) и одним часом (пешей прогулкой по городу). Но когда инженер сталкивается с миром микроэлектроники, наша биологическая интуиция терпит сокрушительное фиаско.
Для человека и одна наносекунда, и одна микросекунда, и одна миллисекунда звучат одинаково: как нечто «неуловимо быстрое, почти мгновенное».
Именно эта когнитивная слепота порождает чудовищные архитектурные ошибки: разработчик спокойно пишет цикл, выполняющий 100 отдельных SQL-запросов к базе данных, беззаботно оборачивает каждый примитивный тип в интерфейс any с аллокацией в куче или удивляется, почему его сервис на Go не выдерживает нагрузку, хотя «все вызовы в памяти».
Легендарная Грейс Хоппер (Grace Hopper) на своих лекциях часто раздавала студентам отрезки медной проволоки длиной ровно 30 сантиметров. Это — физическое расстояние, которое свет (и электрический сигнал) успевает пройти в вакууме за одну наносекунду. Если процессор работает на частоте 3 ГГц, за один такт свет пролетает всего 10 сантиметров — расстояние от вашей ладони до запястья!
Эта памятка складывалась постепенно: первую подобную таблицу в 2001 году опубликовал Питер Норвиг (Peter Norvig), в 2009 году её популяризировал в докладах Джефф Дин (Jeff Dean), а в 2012 году Йонас Бонер (Jonas Bonér) оформил ее в виде знаменитой памятки: «Latency Numbers Every Programmer Should Know» («Числа задержек, которые обязан знать каждый программист»).
С тех пор микросхемы стали плотнее, появились шины PCIe 5.0 и NVMe-накопители, и многое сдвинулось (SSD и сеть внутри дата-центра стали заметно быстрее), но общая иерархия порядков величин сохраняется: она в значительной мере определяется физикой — расстояниями, ёмкостью носителей, скоростью распространения сигнала (в том числе пределом скорости света).
Масштабирование до «Человеческого времени»
Чтобы эти сухие порядки величин навсегда врезались в инженерную память, применим классический педагогический прием: растянем шкалу времени так, чтобы 1 наносекунда (ns) приравнивалась к 1 секунде человеческой жизни.
При таком мысленном эксперименте наш процессор с тактовой частотой 1 ГГц совершает один неторопливый шаг в секунду, а мы можем наблюдать за компьютером невооруженным глазом:
| Операция (Железо) | Реальное время | Человеческое время | Житейская аналогия для CPU |
|---|---|---|---|
| L1 кэш (чтение) | 1 ns | 1 секунда | Взять ручку с рабочего стола перед собой. |
| Сброс конвейера (Branch Mispredict) | 3–7 ns (15–20 тактов) | 3–7 секунд | Ошибиться в бланке и взять новый из ящика. |
| L2 кэш (чтение) | 4 ns | 4 секунды | Достать папку из тумбочки под столом. |
| L3 кэш (чтение) | ~15 ns | 15 секунд | Сходить к общему шкафу в соседней комнате. |
| Мьютекс (захват/освобождение, CAS) | 17 ns | 17 секунд | Повесить замок на дверь кабинета. |
| DRAM (чтение из RAM) | 100 ns | 1.5 минуты | Сходить в другой конец коридора к архиву. |
| NVMe SSD (случайное чтение) | ~16 000 ns (16 µs) | 4.5 часа | Съездить на машине на склад в другом районе. |
| Сеть (RTT внутри дата-центра) | 500 000 ns (0.5 ms) | ≈ 5.8 суток | Отправить письмо почтой в соседний город. |
| SSD старого поколения (SATA) | ~100 000 ns (~100 µs) | ~28 часов | Ждать посылку из соседнего города. |
| HDD (магнитный диск, seek) | 10 000 000 ns (10 ms) | 4 месяца | Заказать доставку груза кораблем из Китая. |
| Сеть (Интернет, RTT Европа - США) | 150 000 000 ns (150 ms) | 4.7 года | Отправить научную экспедицию на Марс. |
| Таймаут HTTP запроса (1 сек) | 1 000 000 000 ns (1 s) | 31 год | Прожить целую человеческую жизнь в ожидании. |
flowchart LR
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
CPU["CPU<br>(Мгновенно, 1 такт)"]:::data -->|"1 ns"| L1["L1 Кэш<br>(1 сек)"]
L1 -->|"100 ns"| RAM["RAM (Память)<br>(1.5 мин)"]
RAM -->|"16 000 ns"| NVMe["NVMe SSD<br>(4.5 часа)"]
NVMe -->|"500 000 ns"| LAN["Локальная сеть DC<br>(≈5.8 суток)"]
LAN -->|"150 000 000 ns"| WAN["Интернет RTT<br>(4.7 года)"]
Взгляните на эту шкалу еще раз.
Когда процессор промахивается мимо кэша L1 в оперативную память, он ждет не «чуть-чуть»: он ждет полторы минуты! А когда программа делает сетевой запрос в соседний сервер внутри дата-центра, ядро процессора проживает почти неделю томительного ожидания!
Mechanical Sympathy в Go: Практические следствия
Каждое инженерное решение в коде на Go должно приниматься с оглядкой на эти цифры.
1. Цена указателя и аллокаций в Куче
Программисты, пришедшие в Go из миров Java, Python или Ruby, часто страдают привычкой передавать всё через указатели: «Передать указатель — это всего 8 байт, это же быстрее, чем копировать целую структуру!». Это опасное заблуждение.
Взгляните на таблицу. Чтение структуры из кэша L1 занимает ровно 1 наносекунду (1 секунду в человеческом масштабе). Копирование 64-байтовой структуры по значению (Value Semantics) укладывается в 2–3 такта регистровых операций.
А теперь представьте слайс указателей []*User. Сами структуры User создавались оператором new или утекли в кучу (Heap) через Escape Analysis. В физической памяти они раскиданы по произвольным адресам.
При итерации по []*User на каждом шаге цикла процессор вынужден:
- Вычитать адрес указателя;
- Сделать разыменование и обратиться по неизвестному адресу в памяти RAM;
- Поймать кэш-промах (
Cache Miss) и замереть на 100 наносекунд (полторы минуты жизни CPU!).
Вместо непрерывного потокового чтения из L1-кэша вы заставляете ядро процессора 99% времени топтаться в ожидании шины памяти. Разница в производительности между непрерывным слайсом структур []User и слайсом указателей []*User может достигать 10–20 раз!
2. Иллюзия локального Redis (Парадокс кэширования)
Типичный диалог на код-ревью:
— «Зачем вы сохраняете эти справочники в глобальную структуру map? Давайте положим их в Redis! Redis ведь in-memory база данных, он работает прямо в памяти!».
Это катастрофическая ошибка мышления.
- Обращение к локальной оперативной памяти сервера (структура
mapилиsync.Mapв Go): ~100 наносекунд (1.5 минуты человеческого времени). - Обращение к «быстрому» Redis по сети внутри дата-центра: порядка 500 000 наносекунд в нашей учебной шкале (≈ 5.8 суток человеческого времени!). Для ровности расчётов взято осторожное значение: в хорошей сети внутри одной стойки RTT бывает и 10–150 мкс, но и это на 2–3 порядка медленнее локальной памяти.
Сетевой Redis медленнее локальной оперативной памяти на порядки (на нашей шкале — в ~5 000 раз)!
Вызов Redis в цикле для каждой строчки бизнес-логики генерирует колоссальный сетевой оверхед (Overhead). Процессор проводит годы в человеческом эквиваленте, дожидаясь ответа сетевого адаптера.
- Инженерное решение: Использовать многоуровневое кэширование (L1 in-memory кэш на Go через библиотеки вроде
FreeCacheилиRistrettoпрямо в адресном пространстве процесса + сетевой Redis в качестве L2) или группировать команды в конвейеры (Redis Pipelines).
3. Проблема N+1 запросов
Рассмотрим классическую антипаттерн-болячку ORM-фреймворков — проблему N+1.
Программа вычитывает список из 100 пользователей, а затем в цикле запрашивает заказы для каждого:
SELECT * FROM orders WHERE user_id = ?
База данных работает превосходно: она индексирована и отвечает всего за 1 миллисекунду на запрос.
С точки зрения человека $100 \times 1 \text{ ms} = 100 \text{ ms}$ — десятая доля секунды, глазом моргнуть не успеешь.
Но что произошло с точки зрения CPU?
Каждая миллисекунда — это 11.5 дней простоя. Процессор отправил запрос, уснул на 11 дней, проснулся, отправил второй, снова уснул на 11 дней... В итоге на простейшую операцию процессор потратил 3 года ожидания в человеческом эквиваленте!
Один пакетный запрос WHERE user_id IN (...) выполнит ту же задачу за один сетевой раунд-трип, сэкономив 99% времени накладных расходов. Пакетная обработка (Batching) — это ключевой закон архитектуры высоконагруженных систем.
4. Почему Goroutines такие дешевые?
Вспомним механизм переключения контекста из главы 34. Аппаратные прерывания и Системные вызовы.
Когда операционной системе Linux нужно переключить контекст между двумя потоками ядра (Thread Context Switch):
- Происходит переход в Ring 0 (ядро ОС);
- Сохраняются и восстанавливаются десятки системных регистров;
- Сбрасывается конвейер инструкций, инвалидируются части буфера TLB;
- Суммарная стоимость переключения составляет от 1 000 до 2 000 наносекунд.
Когда же рантайм Go переключает контекст между двумя горутинами (Goroutine Context Switch):
- Переключение происходит целиком в пространстве пользователя (User Space, без сисколлов);
- В структуру
gobufсохраняется малое число слов (sp,pc,g,ctxt,lr,bp— шесть слов): в ABI Go нет callee-saved регистров, поэтому остальные регистры при кооперативном переключении сохранять не нужно; - Никаких сбросов Ring 0 и смены таблиц страниц
CR3; - Задержка составляет всего порядка сотен наносекунд (по типичным микробенчмаркам — в основном «пинг-понг» двух горутин через канал, где в стоимость входит и синхронизация; само переключение дешевле)!
Рантайм Go не нарушает законы физики: он просто выполняет переключение контекста на порядок дешевле с точки зрения микроархитектурных тактов процессора.
Итог: Иерархия решений Архитектора
Держа перед глазами эти порядки задержек, инженер автоматически принимает правильные архитектурные решения. При проектировании любой функции или сервиса на Go задавайте себе строгую последовательность вопросов сверху вниз (от самых медленных операций к самым быстрым):
- Могу ли я полностью избежать сетевого вызова? (Сетевой IO — абсолютный чемпион по задержкам: от десятков–сотен µs внутри ЦОД до 150 ms через океан).
- Если сеть неизбежна, могу ли я сгруппировать 100 запросов в один (Batching)? (Экономия сотен сетевых RTT и недель времени процессора).
- Могу ли я читать с диска последовательно, а не случайно? (Случайное чтение фрагментирует кэши, а случайная запись убивает ресурс SSD, см. 37. SSD, NVMe и почему современные диски такие быстрые).
- Могу ли я избежать аллокации в куче? (Остаться на стеке и в кэше
L1/L2, избавив процессор от 100-наносекундных походов в медленный DRAM). - Могу ли я обойтись без разделяемого мьютекса? (Исключить False Sharing и дорогостоящую межядерную инвалидацию кэш-линий, см. 21. False Sharing и Cache Line Contention).
Мы увидели фундаментальную шкалу времени компьютерных задержек. Но если все операции подчиняются столь строгой математике, почему в реальной жизни удвоение ядер процессора почти никогда не удваивает скорость программы? Почему производительность масштабируется нелинейно?
В следующей лекции мы разберем закон Амдала, универсальный закон масштабируемости и математику очередей: 39. Почему производительность бывает нелинейной.