2. Latency vs throughput
Что такое Latency и Throughput
В инженерной практике термины «быстродействие» и «производительность» часто используют как синонимы, что порождает грубейшие архитектурные ошибки. Система может отвечать на отдельный запрос молниеносно, но захлёбываться уже при сотне одновременных пользователей. И наоборот: сервис может перемалывать колоссальные объёмы данных в секунду, заставляя каждого отдельного клиента томиться в мучительном ожидании.
Чтобы мыслить о производительности строго, мы обязаны развести два фундаментальных понятия:
- Latency (задержка / время отклика): время, необходимое системе для выполнения одной неделимой операции от момента её инициации клиентом до получения готового результата. В веб-разработке это время ожидания ответа браузером или мобильным приложением (миллисекунды или микросекунды).
- Throughput (пропускная способность): количество полезных операций или объём данных, который система способна успешно обработать за единицу времени. В бэкенде это классическая метрика «запросов в секунду» (RPS — Requests Per Second) или мегабайт в секунду.
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
subgraph Analogies["Физические модели задержки и пропускной способности"]
subgraph Car["Спортивный болид"]
C1["Скорость: 300 км/ч<br/>Пассажиров: 1 человек<br/><b>Минимальная Latency, скромный Throughput</b>"]:::process
end
subgraph Train["Товарный поезд"]
T1["Скорость: 60 км/ч<br/>Груз: 5 000 тонн<br/><b>Высокая Latency, колоссальный Throughput</b>"]:::process
end
end
Бытовая аналогия: спортивный двухместный автомобиль преодолевает дистанцию между городами за 1 час (минимальная задержка — latency), но перевозит всего одного пассажира. Тяжёлый товарный поезд ползёт ту же дистанцию 5 часов (высокая задержка), но доставляет 5 000 тонн груза за один рейс (огромная пропускная способность — throughput).
Другая физическая модель — водопроводная труба. Latency — это время, за которое конкретная капля воды проходит путь от вентиля до крана. Throughput — это объём воды (литры в секунду), вытекающий из трубы, определяемый её диаметром и напором насоса.
Фундаментальный вывод: latency и throughput не являются строго обратно пропорциональными величинами. Быстрое исполнение одной изолированной функции в бенчмарке не гарантирует высокую пропускную способность под реальной нагрузкой, а рекордный throughput распределённой системы часто достигается ценой сознательного увеличения задержки отдельных запросов (например, за счёт пакетной обработки — батчинга).
Закон Литтла — мост между Latency и Throughput
Каким математическим законом связаны задержка, пропускная способность и конкурентность? Этот мост описывается классической теоремой теории массового обслуживания — Законом Литтла (Little’s Law):
$$L = \lambda \times W$$
где:
- $L$ (Queue Length / In-Flight Requests) — среднее число запросов, одновременно находящихся внутри системы (уровень конкурентности — concurrency);
- $\lambda$ (Arrival Rate / Throughput) — средняя интенсивность входящего потока (количество обработанных запросов в секунду);
- $W$ (Wait Time / Latency) — среднее время пребывания одного запроса в системе (полная задержка).
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
In["Входящий поток (λ = 1000 RPS)"]:::network --> System["Сервер Go: L = λ × W<br/>(L = 50 активных горутин в памяти)"]
System --> Out["Ответы клиентам (W = 50 мс)"]
В Go-сервисе переменная $L$ имеет совершенно осязаемое физическое воплощение: это количество горутин, одновременно исполняющих обработку запросов в данный момент времени.
Почему различие критично в Go
Программисты, переходящие в Go из экосистем с тяжёлыми системными потоками операционной системы (Java, C++, C#), часто поддаются эйфории от дешевизны горутин. Начальный стек горутины составляет всего около 2 КБ, что позволяет легко запустить хоть 100 000 горутин на скромном сервере.
Однако легковесность создания горутины не делает бесплатным её исполнение процессором. Физические ядра CPU, каналы оперативной памяти и сетевые интерфейсы остаются строго ограниченными ресурсами.
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
Req["10 000 параллельных запросов"]:::process --> G["10 000 горутин в Go"]
G --> Bottleneck["Узкое горлышко: NVMe SSD / СУБД"]
Bottleneck --> Contention["Борьба за диск и переключения контекста"]
Contention --> Spike["Взрывной рост Latency p99 и деградация Throughput"]
Инженер обязан понимать, какой ценой покупается пропускная способность. Например, внедрение буферизированных каналов и агрегация мелких записей в буфер (bufio.Writer) резко увеличивает throughput ценой небольшой дополнительной задержки на накопление батча.
Измерение Latency и Throughput
Latency
Оценивать задержку по среднему арифметическому значению (Average Latency) категорически запрещено — это самообман. Серверные задержки всегда исследуют через перцентили:
- p50 (медиана): типичное время ответа для половины пользователей.
- p95: 95% запросов завершились быстрее этого порога; показывает границу стабильности.
- p99 (хвостовая задержка — Tail Latency): наихудший опыт 1% пользователей. Именно этот хвост определяет стабильность сложных микросервисных цепочек.
Подробнее эта математика раскрывается в статьях 6. Метрики. p50, p95, p99 и 7. Tail latency и почему она важна.
В Go-коде задержка одиночной операции замеряется стандартным time.Since(start), а распределение задержек внутри конвейера анализируется через бенчмарки пакета testing.B и инструмент трассировки go tool trace.
Throughput
Пропускная способность измеряется в операциях в секунду:
- RPS (Requests Per Second) для веб-серверов и API Gateway;
- IOPS (I/O Operations Per Second) для баз данных и дисковых подсистем;
- сообщений в секунду для брокеров Apache Kafka и NATS.
Throughput калибруется инструментами генерации открытой нагрузки (1. Load testing), микробенчмарками (2. Benchmarking в Go) и телеметрией Prometheus (8. Observability и performance).
Mechanical Sympathy: что под капотом влияет на Latency и Throughput
- Системные вызовы и смена колец защиты (Syscalls):
Каждый переход процессора из пространства пользователя (Ring 3) в пространство ядра (Ring 0) через инструкциюSYSCALLобходится в сотни наносекунд аппаратного оверхеда. Множество мелких вызововwrite()раздувают latency каждого запроса и сжигают такты CPU. Буферизация и сброс данных крупными блоками увеличивают суммарный throughput ценой незначительного роста задержки на сборку пакета. - Планировщик Go и модель G-M-P:
Когда тысячи горутин жадно делят вычислительные мощности (см. 1. Scheduler Go. G-M-P модель), планировщик тратит такты на переключение контекстов и распределение работы (Work Stealing). Если искусственно задатьGOMAXPROCSвыше реального числа физических ядер CPU, планировщик ОС начнёт вытеснять системные потокиM, порождая промахи кэша L1i/L1d и взрывной рост задержки. - Сборщик мусора (Garbage Collector):
Конкурентный трёхцветный GC в Go минимизирует паузы полной остановки мира (см. 6. GC pause и latency). Однако во время фазы активной маркировки рантайм выделяет до 25% ресурсов CPU на работу маркёров (Mark Assist), отбирая их у горутин приложения. Это напрямую срезает пиковый throughput и увеличивает задержку запросов, интенсивно аллоцирующих память в куче. - Когерентность кэшей и False Sharing:
Когда горутины на соседних физических ядрах одновременно модифицируют независимые переменные, расположенные в пределах одной 64-байтной кэш-линии процессора (8. False sharing), протокол когерентности MESI непрерывно инвалидирует кэши L1/L2. Задержка операций возрастает в десятки раз, а суммарный throughput деградирует.
Горутины, конвейеры и соотношение Latency/Throughput
Представим конвейер обработки данных (Pipeline), разбитый на три последовательные стадии, соединённые каналами. Каждая стадия выполняется в отдельной горутине:
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
Stage1["Стадия 1 (Парсинг)<br/>Latency: 5 мс"]:::accent -->|"Канал ch1"| Stage2["Стадия 2 (Бизнес-логика)<br/>Latency: 20 мс"]
Stage2 -->|"Канал ch2"| Stage3["Стадия 3 (Запись в БД)<br/>Latency: 10 мс"]
- Узкое горлышко (Bottleneck): Максимальный throughput конвейера всегда определяется самой медленной стадией (в данном примере — Стадией 2, способной выдать не более $1 / 0.02 = 50\text{ оп/с}$).
- Влияние буферизации каналов: Если каналы не буферизированы, Стадия 1 будет простаивать в ожидании освобождения Стадии 2. Добавление буфера в канал сглаживает пики входящего трафика и повышает Throughput. Однако, если Стадия 2 устойчиво отстаёт, буфер переполняется, и каждый элемент проводит в очереди дополнительное время. Throughput стабилизируется, но Latency каждого элемента возрастает на время ожидания в буфере!
Выбор приоритета: Latency vs Throughput
В реальных продуктовых системах инженер всегда выбирает компромисс, исходя из SLA бизнеса:
- HFT и торговые шлюзы (High-Frequency Trading): Абсолютный приоритет минимальной и предсказуемой задержки (Ultra-Low Latency). Ради выигрыша 5 микросекунд разработчики жертвуют пропускной способностью, выделяют под горутины изолированные ядра CPU (Core Pinning) и избегают очередей.
- Пакетная обработка и ETL (Batch Processing): Абсолютный приоритет Throughput. Обработать 100 миллионов записей за ночь — главная задача; завершится ли конкретная запись за 10 миллисекунд или за 5 секунд — не имеет значения.
- Интерактивные веб-сервисы: Прагматичный баланс. Требуется удерживать хвостовую задержку p99 в рамках строгих SLO (например, <150 мс), не раздувая кластер серверов сверх меры.
Паттерн: Защита Latency через ограничение конкурентности
Чтобы перегрузка не разрушила задержку веб-сервера, применяется паттерн ограничения конкурентности с помощью семафора на базе буферизированного канала:
// Ограничиваем число одновременно выполняемых запросов
sem := make(chan struct{}, maxConcurrency)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Пытаемся занять слот в семафоре
sem <- struct{}{}
defer func() { <-sem }()
// Выполняем полезную работу в предсказуемых условиях
processRequest(w, r)
})
В этом сценарии мы сознательно ограничиваем пиковый throughput сервиса ради того, чтобы задержка допущенных в обработку запросов оставалась стабильной и не деградировала до таймаутов.
Связь с законом Амдала
4. Amdahl law и масштабирование строго доказывает: предельное ускорение системы при распараллеливании ограничено долей её последовательного кода.
Если 20% логики алгоритма принципиально последовательны (например, запись в общую структуру под мьютексом), то даже на 1000-ядерном процессоре вы не сможете ускорить систему более чем в 5 раз. При этом попытка запустить 1000 горутин лишь увеличит задержку из-за конкуренции за мьютекс и очередей планировщика.
Измерение в Go: пример бенчмарка
Для раздельной оценки задержки одиночного вызова и пропускной способности под параллельной нагрузкой в пакете testing применяются различные режимы:
// Замер изолированной задержки последовательного исполнения
func BenchmarkLatency(b *testing.B) {
for i := 0; i < b.N; i++ {
// Эмулируем целевую операцию
time.Sleep(10 * time.Microsecond)
}
}
// Замер пропускной способности при конкурентном исполнении
func BenchmarkThroughput(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// Конкурентная работа без взаимных блокировок
computeHash()
}
})
}
Глубокие техники анализа и калибровки тестов рассматриваются в статьях 2. Benchmarking в Go и 3. go test -bench под капотом.
Итог
- Latency (задержка одной операции) и Throughput (число операций в единицу времени) — различные, но математически связанные через Закон Литтла метрики.
- В языке Go горутины позволяют легко достичь огромного Throughput, но удержание стабильной Latency требует понимания физики: блокировок ввода-вывода, работы сборщика мусора и структуры кэшей процессора.
- Оптимизация — это искусство осознанных компромиссов. Пакетная обработка увеличивает пропускную способность ценой задержки; ограничение параллелизма спасает задержку ценой пропускной способности.
- Никогда не полагайтесь на интуицию: калибруйте обе метрики под реальной нагрузкой.
В следующей статье 3. CPU bound vs IO bound задачи мы научимся безошибочно классифицировать характер нагрузки и точно определять, в какой именно аппаратный ресурс упирается наше Go-приложение.