3. CPU bound vs IO bound задачи
Классификация задач по типу узкого места
В предыдущей статье 2. Latency vs throughput мы развели понятия задержки и пропускной способности. Теперь пришло время ответить на фундаментальный диагностический вопрос: чем именно занято время процессора, когда наш сервис выполняет полезную работу? Тратит ли он драгоценные такты на математические расчёты или томится в ожидании ответа от сетевой карты и дискового контроллера?
Классификация нагрузок на «вычислительно-ограниченные» (CPU-bound) и «ограниченные вводом-выводом» (IO-bound) — это инженерный фундамент. От неё зависит всё: от настройки пулов горутин и калибровки GOMEMLIMIT до выбора профилировочных инструментов и оптимизации многопоточности в Go.
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 WorkloadTypes["Спектр вычислительных нагрузок"]
CPU["<b>CPU-bound (Вычисления)</b><br/>Узкое горлышко: АЛУ, L1-L3 кэши, память<br/>Ядра загружены на 100%<br/>Криптография, парсинг JSON, сжатие"]
IO["<b>IO-bound (Ввод-вывод)</b><br/>Узкое горлышко: сокеты, диски, сеть<br/>Ядра простаивают в ожидании syscall/netpoller<br/>Запросы к БД, HTTP-клиенты, брокеры"]
end
Определения
CPU-bound (вычислительно-ограниченная) задача — задача, чья предельная скорость выполнения лимитирована исключительно производительностью процессора: тактовой частотой ядер, шириной конвейера инструкций, эффективностью предсказателя переходов и пропускной способностью подсистемы памяти. Арифметико-логическое устройство (АЛУ) работает на пределе возможностей, а время ожидания периферийных устройств стремится к нулю.
IO-bound (ограниченная вводом-выводом) задача — задача, чья скорость упирается в задержку (latency) или пропускную способность (bandwidth) внешних устройств ввода-вывода: сети, дискового массива, терминала или межпроцессного взаимодействия (IPC). Вычислительные ядра большую часть времени простаивают в ожидании поступления очередного пакета или прерывания от контроллера.
В реальной жизни абсолютно «чистые» нагрузки встречаются редко. Типичный веб-хэндлер представляет собой смешанный конвейер:
- Распарсить JSON-запрос из байтового среза (CPU-bound);
- Сделать запрос по сети к реляционной СУБД или Redis (IO-bound);
- Вычислить скидку или проверить права доступа (CPU-bound);
- Сериализовать ответ и отправить его клиенту по сокету (CPU + IO).
Ключевое мастерство инженера — безошибочно выявить доминирующее ограничение системы в конкретный момент времени под реальной нагрузкой.
Почему Go-разработчику это важно
Лёгкость горутин и удобство их создания через ключевое слово go создают у начинающих разработчиков опасную иллюзию: кажется, будто можно просто запустить 50 000 горутин и рантайм сам решит все проблемы производительности. Это фатальное заблуждение.
- Для CPU-bound нагрузки: Создание горутин сверх числа логических процессоров
GOMAXPROCSконтрпродуктивно. Оно порождает бесполезную борьбу за кванты планировщика, сбросы конвейера CPU, вымывание L1-кэша и деградацию throughput (см. 1. Scheduler Go. G-M-P модель, 4. Контекстные переключения). - Для IO-bound нагрузки: Напротив, искусственное занижение числа горутин приведёт к простою сетевых интерфейсов и падению общей пропускной способности. Однако их бесконтрольное разрастание исчерпает память под стеки, переполнит очереди ожидания и разрушит хвостовую задержку (7. Tail latency и почему она важна).
Точная классификация определяет стратегию оптимизации: настраивать ли алгоритмику и локальность кэша или оптимизировать мультиплексирование сокетов и пулы соединений.
Как определить тип границы: признаки и инструменты
Признаки CPU-bound
- Метрика
%userв системных утилитахtopилиhtopстабильно держится около 100% на всех выделенных ядрах. - Высокая суммарная утилизация CPU при минимальном значении ожидания ввода-вывода (
%iowaitблизко к 0). - Пропускная способность (Throughput) практически не растёт при увеличении пула горутин сверх числа доступных физических ядер.
- В топе CPU-профайлера (
pprof -top) доминируют вычислительные функции: математические расчёты, сериализация, хэширование (runtime.memhash,crypto/sha256) или работа сборщика мусора. - В трассировщике
go tool traceфазы исполненияProc startдлятся долго и не прерываются вызовамиProc stop.
Признаки IO-bound
- Процессорные ядра загружены слабо (утилизация CPU ниже 60–70%), либо наблюдается заметный процент
%iowait(при блокирующем дисковом вводе-выводе). - Пропускная способность сервиса линейно масштабируется при увеличении числа параллельных соединений/горутин до достижения физического предела пропускной способности сети или базы данных.
- CPU-профайлер показывает, что значительная часть времени уходит в ожидание сетевого поллера:
runtime.netpollblock,syscall.Read,epollwait. - Профайлеры задержек блокировок (
blockprofile, 5. block profile) и мьютексов (mutexprofile, 6. mutex profile) фиксируют длительные задержки на каналах и сокетах.
Профайлеры и трассировка
В инструментарии Go заложены точные средства объективной классификации:
- CPU profiling (
runtime/pprof, 2. CPU profiling в Go): собирает сэмплы исключительно в те моменты, когда горутина активно исполняется на ядре процессора. Если горутина спит в ожидании сетевого ответа, она физически не попадёт в сэмплы CPU-профайлера! - Block profile (5. block profile): фиксирует события блокировок горутин — ожидание каналов, захват мьютексов, паузы ввода-вывода. Это идеальный инструмент для препарирования IO-bound задержек.
- Execution tracer (
go tool trace, 3. execution tracer): визуализирует жизненный цикл горутин в трёх канонических состояниях рантайма:Executing(горутина активно сжигает такты CPU);Runnable(горутина готова исполняться, но ожидает свободного логического процессора $P$);Waiting(горутина заблокирована на канале, мьютексе или системном вызове ввода-вывода).
Если трассировка показывает доминирование состояния Waiting — вы имеете дело с классическим IO-bound поведением.
Механика ОС и Go при разной нагрузке
Поведение рантайма Go при вычислительной и сетевой нагрузке кардинально различается на системном уровне.
CPU-bound: монопольное владение ядром
Когда горутина выполняет непрерывные вычисления (например, факторизацию матриц или перебор хэшей), она не совершает добровольных системных вызовов. Планировщик Go организует вытеснение (Preemption) с помощью проверок флагов стека при вызовах функций или через асинхронные сигналы операционной системы (SIGURG в современных версиях Go).
Внутри модели планировщика G-M-P:
- Логический процессор $P$ может исполнять в единицу времени строго одну горутину $G$ на одном потоке ОС $M$.
- Если число активных вычислительных горутин превышает количество доступных процессоров
GOMAXPROCS, лишние горутины выстраиваются в локальные очередиrunqueueи ждут своего кванта времени. - Постоянные переключения между ними вызывают сбросы регистров, промахи мимо кэша инструкций L1i и накладные расходы планировщика (4. Контекстные переключения). Кроме того, если горутины работают с общими данными, неизбежно возникает паразитный сброс кэш-линий — False Sharing (8. False sharing).
Инженерный закон: Для чистых CPU-bound задач оптимальное число одновременно исполняющихся горутин строго равно числу доступных ядер (GOMAXPROCS). Дальнейшее умножение горутин лишь увеличивает накладные расходы.
IO-bound: передача управления без блокировки
Когда горутина обращается к сетевому соединению (например, вызывает метод conn.Read()), в работу вступает компонент рантайма — сетевой поллер (Netpoller) (см. 4. epoll, kqueue и netpoller). В Linux он базируется на механизме системного вызова epoll, в macOS и BSD — на kqueue, а в Windows — на I/O Completion Ports (IOCP).
sequenceDiagram
autonumber
participant G as Горутина G
participant P as Логический процессор P
participant M as Поток ОС M
participant Netpoll as Сетевой поллер (epoll)
G->>P: Вызов conn.Read()
P->>Netpoll: Регистрация неблокирующего fd сокета
P->>G: Вызов gopark (парковка горутины G)
M->>M: Переключение на другую готовую G из очереди P
Note over Netpoll: Ядро ожидает событий по сети (epoll_wait)
Netpoll-->>P: Данные в сокете готовы!
P->>G: Вызов goready (возврат G в статус Runnable)
G->>M: Возобновление выполнения с полученными данными
В отличие от традиционных потоков C/C++, горутина в Go не блокирует поток операционной системы $M$ на время ожидания сетевого пакета! Она переводится рантаймом в спящее состояние парковки (gopark), а освободившийся поток $M$ мгновенно берет следующую горутину из очереди $P$ или крадет задачу у соседнего процессора через алгоритм Work Stealing (3. Work stealing).
Благодаря этому Go-сервер легко удерживает 100 000 открытых сетевых сокетов, задействуя всего десяток потоков ядра ОС.
Исключение: Блокирующий файловый ввод-вывод
Сетевые сокеты в Linux можно опрашивать асинхронно через epoll. Но со стандартными файлами на диске это не работает: традиционные вызовы os.ReadFile, read() и write() к файловой системе являются блокирующими (блокируют поток ОС целиком).
Когда горутина совершает блокирующий файловый вызов, рантайм Go вынужден выполнить системные процедуры:
entersyscallblock: логический процессор $P$ открепляется от заблокированного потока $M$.- Поток $M$ остаётся ждать завершения дисковой операции, а процессор $P$ захватывает другой свободный поток из пула рантайма (или порождает новый системный поток ОС).
exitsyscall: после завершения чтения горутина пытается вновь привязать поток к свободному процессору $P$.
Именно поэтому интенсивный файловый ввод-вывод создаёт гораздо более тяжёлую нагрузку на планировщик ОС, чем работа с сетью.
Mechanical Sympathy: что происходит на уровне CPU и памяти
Для CPU-bound задач критичны:
- Предсказание ветвлений (Branch Prediction): современный процессор пытается предугадать путь исполнения условий
if/else(см. 6. Branch prediction и код). Ошибка предсказания приводит к очистке глубокого конвейера инструкций и потере 15–20 тактов. - Промахи кэша (Cache Misses): промах мимо кэша L1 и поход в основную память DRAM обходится в 60–100 наносекунд (200 тактов простоя АЛУ). Поэтому компактные последовательные срезы данных колоссально выигрывают у разрозненных указателей связных списков.
- Векторизация SIMD: компилятор Go может использовать векторные инструкции регистров AVX/NEON (7. SIMD и Go) для параллельной обработки байтовых массивов за один такт.
Для IO-bound задач ключевую роль играют аппаратные контроллеры:
- Прямой доступ к памяти (DMA — Direct Memory Access): сетевая карта сама сбрасывает принятые кадры Ethernet в оперативную память сервера, не отвлекая CPU.
- Аппаратные прерывания (Hardware Interrupts): завершив передачу пакета, контроллер прерывает процессор, ядро Linux переводит сокет в статус готового к чтению, а Netpoller разблокирует спящую горутину.
- Zero-Copy архитектура: минимизация копирований между пространством ядра и буферами приложения (5. Zero copy подходы).
Связь с масштабированием и законом Амдала
Характер нагрузки определяет предел масштабируемости сервиса:
- CPU-bound задачи подчиняются 4. Amdahl law и масштабирование: ускорение сервиса строго ограничено долей последовательного кода. Если 10% алгоритма не поддаются параллелизму (например, запись под общим мьютексом), то даже на компьютере с 1 000 ядер максимальное теоретическое ускорение составит не более 10 раз.
- IO-bound задачи подчиняются Закону Густавсона (Gustafson's Law): с ростом числа ядер мы можем пропорционально увеличивать объём одновременно обслуживаемых независимых соединений, упираясь лишь в физический канал сети и пропускную способность шины PCIe.
Стратегии оптимизации в зависимости от типа границы
Если приложение CPU-bound:
- Ограничьте число горутин: зафиксируйте размер пула вычислительных воркеров на уровне
GOMAXPROCS, исключив избыточный шедулинг. - Оптимизируйте алгоритмы: снижайте вычислительную сложность $O(N^2) \rightarrow O(N \log N)$, применяйте предрасчитанные таблицы поиска (Lookup Tables).
- Обеспечьте Cache Friendliness (8. Cache friendliness): выравнивайте поля структур в памяти, упаковывайте данные в непрерывные срезы, избавляйтесь от лишних указателей.
- Помогайте компилятору инлайнить функции (5. Inline и влияние на performance): делайте горячие методы лаконичными, чтобы компилятор встраивал их тело прямо в место вызова без накладных расходов на вызов функции.
- Профилируйте CPU: находите топ функций по числу сэмплов в
pprofи точечно оптимизируйте горячие ветви.
Если приложение IO-bound:
- Увеличивайте конкурентность: масштабируйте количество горутин до тех пор, пока система не упрётся в пропускную способность сетевой карты, базы данных или лимиты соединений.
- Буферизируйте ввод-вывод: используйте
bufio.Reader/Writer(bufio.Readerиbufio.Writer), пулы соединений (sql.DB,net.Conn), чтобы амортизировать накладные расходы на системные вызовы ядра (1. Системные вызовы и их стоимость). - Используйте пулы постоянных соединений: переиспользуйте открытые TCP-сокеты (
sql.DB,net.Conn,http.Client), исключая избыточный тройной handshake при каждом запросе. - Применяйте асинхронные шаблоны: распределяйте независимые сетевые запросы через паттерн fan-out/fan-in с контролем хвостовых задержек (7. Tail latency и почему она важна).
- Оптимизируйте дисковый ввод-вывод: используйте асинхронный системный интерфейс
io_uringв ядре Linux либо изолируйте дисковые операции в выделенный ограниченный пул горутин.
Пример: определение типа и адаптация
Рассмотрим практический сервис, совмещающий оба типа нагрузок:
package main
import (
"crypto/sha256"
"fmt"
"net/http"
_ "net/http/pprof"
"time"
)
// cpuBound симулирует интенсивные вычисления
func cpuBound() [32]byte {
data := []byte("some data to hash")
// Выполняем активную работу на регистрах и АЛУ
for i := 0; i < 100000; i++ {
data = append(data, byte(i%256))
}
return sha256.Sum256(data)
}
// ioBound симулирует блокирующее сетевое взаимодействие
func ioBound() string {
resp, err := http.Get("https://httpbin.org/get")
if err != nil {
return err.Error()
}
defer resp.Body.Close()
return resp.Status
}
func handler(w http.ResponseWriter, r *http.Request) {
// Смешанный сценарий: вычисления + сетевой запрос
_ = cpuBound()
status := ioBound()
fmt.Fprintf(w, "Status: %s\n", status)
}
func main() {
// Запускаем диагностический эндпоинт pprof на порту 6060
go func() {
_ = http.ListenAndServe("localhost:6060", nil)
}()
http.HandleFunc("/", handler)
_ = http.ListenAndServe(":8080", nil)
}
Снимем 30-секундный профиль процессора:
go tool pprof -http=:8081 localhost:6060/debug/pprof/profile?seconds=30
Если во вкладке Top доминируют вызовы crypto/sha256 и runtime.memhash — узким местом является алгоритмический блок cpuBound. Если же в профиле блокировок (debug/pprof/block) видно долгое ожидание в сокете net.(*netFD).Read — оптимизировать нужно сетевой вызов http.Get.
Частые ошибки и ловушки
- Ложная диагностика первопричины: Видя высокую утилизацию CPU, разработчик слепо увеличивает число горутин, усугубляя ситуацию конкуренцией за логические процессоры.
- Иллюзия неблокирующего диска: Традиционные файловые операции в стандартной библиотеке блокируют поток ОС $M$. Неконтролируемый запуск файловых операций может породить сотни потоков ядра. Для экстремальной производительности применяют
io_uring. - Игнорирование хвостовых задержек в IO-bound: Большое количество одновременных запросов к СУБД способно удерживать средний Throughput, но хвостовой перцентиль p99 улетит в бесконечность из-за очереди на соединение.
- Скрытая конкуренция за CPU со стороны Garbage Collector: Интенсивные аллокации в теле цикла приводят к тому, что фаза разметки GC отбирает до четверти тактов процессора, заставляя чистый алгоритм выглядеть медленным (см. 9. Когда GC становится bottleneck).
Итог
- CPU-bound и IO-bound — полярные типы нагрузок, требующие диаметрально противоположных инженерных решений.
- Для CPU-bound задач идеальный пул горутин строго ограничен
GOMAXPROCS. В фокусе внимания — $O$-сложность, эффективность кэшей CPU и снижение аллокаций. - Для IO-bound задач число горутин масштабируется в тысячи раз благодаря Netpoller. В фокусе внимания — пулы постоянных соединений, неблокирующий опрос сокетов и буферизация.
- Используйте профайлеры CPU, Block-профилирование и трассировщик
go tool trace, чтобы принимать решения на основе объективных измерений.
В следующей статье мы применим полученное понимание к математическому расчёту пределов масштабирования: 4. Amdahl law и масштабирование.