Когда HTTP-запрос обрабатывается медленно, причина может быть совсем не в Go. Возможно, процессор занят вычислениями, значения заполнили память, база медленно читает диск или удалённый сервис не отвечает. Чтобы отличить эти случаи, нужна простая модель компьютера: без неё горутины, сборка мусора и профили выглядят как отдельные заклинания.
Что делает железо
Процессор исполняет машинные инструкции: складывает числа, сравнивает значения, переходит к другой инструкции. Компилятор Go превращает исходный код именно в такие инструкции. Несколько ядер позволяют процессору действительно выполнять несколько последовательностей инструкций одновременно.
Оперативная память, RAM, хранит код и данные, к которым процессор обращается прямо сейчас: аргументы функций, срезы, map, сетевые буферы. Её содержимое исчезает после выключения питания. У процессора есть ещё более быстрые кэши, но программист на Go обычно не управляет ими напрямую.
Диск сохраняет файлы и данные базы после перезапуска. Обращение к нему требует работы устройства и операционной системы. Часть файлов ОС кэширует в RAM, поэтому повторное чтение может обойтись без физического обращения к диску.
Сеть передаёт байты другому процессу, часто на другой компьютер. Запрос в PostgreSQL или HTTP-вызов не являются обычным чтением из памяти: после отправки данных программа ждёт работу внешней системы и обратную передачу ответа.
Практический вывод: нагрузка на процессор и ожидание ввода-вывода - разные проблемы. Добавление горутин не ускоряет вычисления сверх возможностей ядер, но позволяет использовать процессор, пока часть задач ждёт диск или сеть.
Зачем нужна операционная система
Программа не работает с железом совершенно самостоятельно. Операционная система распределяет процессор и память, управляет файлами и сетевыми соединениями, а также изолирует программы друг от друга.
Запущенная программа называется процессом. ОС даёт ему виртуальное адресное пространство: адреса в Go-указателях не являются напрямую номерами ячеек физической RAM. ОС и процессор отображают виртуальные страницы на физические и проверяют права доступа.
Поэтому Go-сервис и PostgreSQL могут использовать одинаковое число как виртуальный адрес, но попадут в разные участки памяти. Ошибка в одном процессе обычно не позволяет ему просто прочитать или перезаписать память другого. Для обмена нужны механизмы ОС: сокеты, каналы, файлы или явно настроенная разделяемая память.
Процесс - это единица владения ресурсами и изоляции. Но внутри него должна существовать хотя бы одна последовательность исполняемых инструкций. Ею занимается поток.
Что такое поток
Поток ОС - последовательность выполнения, которую планирует ядро операционной системы. У потока есть текущая инструкция, состояние регистров и собственный стек вызовов. Потоки одного процесса используют общую кучу, код и открытые файловые дескрипторы.
Если два потока одновременно меняют одну структуру данных, без синхронизации результат может быть повреждён. Общая память не становится безопасной от того, что оба потока принадлежат одному процессу. В Go для координации применяют каналы, примитивы sync и атомарные операции.
Ядер меньше, чем готовых работать потоков, поэтому ОС переключает их. На двух ядрах два потока могут выполняться параллельно, а остальные в этот момент ждут своей очереди, завершения ввода-вывода или снятия блокировки.
Создание и переключение потоков ОС требует участия ядра, а каждый системный стек резервирует заметный объём памяти. Создавать отдельный поток на каждый из десятков тысяч преимущественно ожидающих HTTP-запросов расточительно. Go решает это на уровне своего рантайма.
Как горутины связаны с потоками
Горутина - легковесная единица выполнения Go. Она имеет состояние и стек, но не закреплена за одним потоком ОС навсегда. Рантайм распределяет горутины по системным потокам и ставит на выполнение те, которые готовы продолжаться.
Когда горутина ждёт канал, таймер или сетевой ответ, она приостанавливается. Другая готовая горутина получает возможность выполняться. Поэтому большое число ожидающих запросов само по себе не означает такое же число потоков ОС.
Важны две оговорки:
- Долгие вычисления не превращаются в ожидание. Если всем горутинам нужен процессор, их пропускная способность ограничена доступными ядрами и
GOMAXPROCS. - Обычный файловый ввод-вывод и вызовы внешнего C-кода могут блокировать поток ОС. Рантайм может отделить право выполнять другие горутины от такого потока, но не отменяет стоимость самого блокирующего вызова.
В следующей статье модель разбирается через G, M и P. Здесь достаточно запомнить: горутины - задачи, потоки ОС - исполнители, ядра - физический ресурс.
Стек и куча
При вызове функции ей нужны аргументы, локальные значения и адрес возврата. Эти данные образуют кадр вызова в стеке. При возврате кадр больше не нужен. У каждой горутины свой стек: Go начинает с небольшого размера и увеличивает его по мере необходимости.
Куча хранит значения, время жизни которых не ограничено одним вызовом. Например, функция может вернуть указатель на созданный внутри заказ. В Go это безопасно: компилятор определяет, что значение должно пережить функцию, и размещает его так, чтобы указатель оставался действительным.
Не нужно считать, что new всегда означает кучу, а обычная локальная переменная всегда стек. Решение принимает компилятор после анализа времени жизни значения. Размещение в куче само по себе не является ошибкой; важна стоимость аллокаций на действительно горячем пути.
Куча общая для горутин процесса. Сборщик мусора начинает с корней - стеков и глобальных переменных - и идёт по ссылкам. Недостижимые значения можно освободить. Если забытая горутина хранит ссылку на большой объект, этот объект остаётся живым: сборщик не угадывает, что он больше не нужен по смыслу.
Почему сервис ждёт
Предположим, обработчик отправил SQL-запрос. Пока база читает данные и сеть переносит ответ, процессору нечего вычислять именно для этого запроса. Если поток просто спит, а других исполнителей нет, ядро простаивает.
Go старается не тратить это время: ожидающая горутина снимается с выполнения, а другая обрабатывает следующий запрос. Сетевой поллер сообщает рантайму, когда соединение снова готово. Это делает конкурентную модель особенно полезной для backend-систем, где много времени уходит на базы, очереди и удалённые API.
Ожидание всё равно нужно ограничивать. context.WithTimeout и настройки клиентов задают границу, после которой запрос отменяется. Иначе внешний сбой накапливает горутины, соединения и память, а затем выглядит уже как проблема самого сервиса.
Отмена не заменяет устранение медленной зависимости. Она защищает ресурсы и возвращает управление вызывающему, а профили и метрики помогают найти источник задержки.
Как это увидеть в работающем сервисе
Начинайте с вопроса, какой ресурс исчерпан или чего ждёт программа:
| Наблюдение | Что проверить | Инструмент |
|---|---|---|
| Высокая загрузка CPU | Какие функции выполняют вычисления | Метрики CPU, pprof/profile |
| Растёт память процесса | Какие объекты достижимы и кто их создаёт | pprof/heap, gctrace |
| Растёт число горутин | На каком канале, блокировке или вызове они стоят | pprof/goroutine |
| Медленные ответы при свободном CPU | Базу, диск, сеть, очереди и таймауты | Трассировка и метрики зависимостей |
Системные утилиты показывают процесс целиком, а pprof - внутреннее состояние Go. Эти уровни дополняют друг друга: высокий RSS процесса не обязан означать большую живую кучу Go, а тысяча горутин не обязана означать тысячу потоков ОС.
Коротко
- CPU исполняет инструкции, RAM хранит данные работающей программы, диск обеспечивает постоянное хранение, сеть связывает процессы.
- Процесс - изолированное адресное пространство и набор ресурсов; ОС отображает виртуальную память процессов на физическую RAM.
- Поток ОС - путь выполнения с собственным стеком; потоки одного процесса делят кучу и должны синхронизировать доступ к общим данным.
- Горутина - задача рантайма Go. Множество горутин выполняется на меньшем числе потоков ОС, а ожидание позволяет переключиться на готовую работу.
- Стек хранит вызовы и локальные значения, куча - значения, которые должны жить дольше. В Go размещение выбирает компилятор, а недостижимую память освобождает сборщик.
- Ожидание диска или сети и загрузка CPU - разные причины медленной работы. Таймауты ограничивают ожидание, профили показывают его источник.
Что почитать дальше
- Рантайм Go: горутины на потоках, стек и куча - модель G, M, P, escape analysis, сборщик мусора и pprof.
- Горутины и каналы - запуск, остановка и координация конкурентной работы.
- Указатели - ссылки на значения и передача данных между вызовами.