Всем привет! Меня зовут Паша, я занимаюсь коммерческой разработкой уже 10 лет, 8 из них — на Go. Мне довелось разрабатывать приложение, активно использующее как сеть, так и диск.
Регулярно приходилось решать вопросы производительности. В ходе исследований я использовал все, что было было под рукой: логи, метрики, трейсы, профилировщики и runtime-трейсы. А еще изучал, как по доступным данным расследовать причины проблем производительности постфактум. Тогда мне стало интересно, почему метрика количества потоков сильно отличается от значения GOMAXPROCS и можно ли по этой метрике диагностировать какие-то конкретные проблемы.
Эксперимент с Hello world!
Зафиксируем окружение, в котором проведем эксперименты:
Версия языка — go1.22.7.
Операционная система — macOS.
Количество логических ядер — 10.
Сначала я решил посмотреть, сколько потоков создаст программа Hello world!
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello world!")
}
Такая программа сразу завершится и не даст проанализировать состояние процесса, поэтому нужно предотвратить выход из нее. Для этого я пользуюсь отладчиком, так как это имеет минимальное влияние на эксперимент. Если настроенного отладчика под рукой нет, можно использовать time.Sleep, в текущей версии языка это никак не влияет на эксперимент.
Я воспользовался утилитой ps, указав ID процесса, чтобы получить список всех созданных процессом потоков.
% ps M 36861
USER PID TT %CPU STAT PRI STIME UTIME COMMAND
user 36861 s018 0.0 S 31T 0:00.00 0:00.00 ./main
36861 0.0 S 31T 0:00.00 0:00.00
36861 0.0 S 31T 0:00.00 0:00.00
36861 0.0 S 31T 0:00.00 0:00.00
36861 0.0 S 31T 0:00.00 0:00.00
Я увидел, что наша программа создала целых пять потоков. Нужно понять, для чего они были созданы и можно ли уменьшить их количество.
Влияние GOMAXPROCS на количество потоков. Я запускал программу, не задавая явно GOMAXPROCS, а значит, по умолчанию он был равен десяти, что позволяет golang запускать до десяти потоков для выполнения горутин.
С помощью отладчика я увидел пять горутин:
Для чего нужны эти горутины, нам не важно, но если коротко, то одна из них — входная точка в нашу программу, три связаны с GC, а последняя выполняет Finalizer перед очисткой объектов из памяти.
Наличие нескольких горутин подразумевает, что для их выполнения необходимо несколько потоков. Так как одной из целей было сократить количество создаваемых потоков, я решил запустить эту же программу, установив GOMAXPROCS равным единице, и посмотреть, изменится ли количество потоков.
% ps M 39418
USER PID TT %CPU STAT PRI STIME UTIME COMMAND
user 39418 s018 0.0 S 31T 0:00.01 0:00.00 ./main
39418 0.0 S 31T 0:00.00 0:00.00
39418 0.0 S 31T 0:00.00 0:00.00
Количество потоков снизилось, но не до одного. Важно понимать, что, уменьшая GOMAXPROCS, мы ограничиваем количество потоков, которые может одновременно использовать планировщик для выполнения горутин, а на количество создаваемых потоков ОС влияем только косвенно.
Вывод 1. Минимальное создаваемое количество потоков на старте golang-приложения равно трем.
Сократив количество потоков до трех, рассмотрим каждый из них подробнее.
Первый поток — родительский. Он был создан вместе с процессом и не требует дополнительного внимания.
Второй поток будет создан при запуске приложения на любой архитектуре кроме wasm. В этом потоке работает компонент sysmon, который:
насильно прерывает горутины, которые слишком долго не разрешают себя переключить;
создает новый поток для планировщика, если один из существующих был заблокирован системным вызовом более чем на 20 микросекунд или любым вызовом cgo;
возвращает в очередь на выполнение горутины, для которых готово событие, полученное от нетполлера;
проверяет время ближайшего срабатывания таймера;
пишет runtime trace;
будит горутину, ответственную за принудительный запуск GC.
Этот поток не попадает в пул потоков, которые может использовать планировщик для выполнения горутин.
Вывод 2. Golang имеет служебный поток, помогающий управлять ресурсами, необходимыми для работы планировщика.
Вывод 3. Поток sysmon’а находится вне зоны ответственности планировщика, поэтому при расчете максимального потребления ЦПУ процессом его потребление необходимо прибавлять к максимальному потреблению ЦПУ планировщиком.
Если интересно и хочется узнать о нем больше, можно прочитать его:
Нехватка потоков для планировщика. На старте приложения у нас есть пять горутин, каждая из которых хотела бы выполняться. К этому моменту у процесса уже есть два потока, осталось понять, почему родительский поток не мог этим заняться.
Не все созданные потоки могут быть использованы планировщиком для выполнения горутин из очереди. Если подходящих потоков меньше, чем GOMAXPROCS, то при необходимости будет создан новый и в процессе количество созданных потоков может значительно превышать GOMAXPROCS. Причин, по которым потоку не будут назначаться новые горутины, несколько:
поток явно закрепили за горутиной с помощью runtime.LockOSThread;
поток был заблокирован на системном вызове;
поток был использован для cgo-вызова.
При анализе листинга кода инициализации приложения можно найти вызов функции `lockOSThread` с комментарием, что некоторые вызовы должны быть сделаны только из главного (родительского) потока.
Для большинства приложений нет необходимости фиксировать поток за горутиной. Но если в приложении есть системные вызовы или cgo-вызовы, изменяющие или полагающиеся на состояние потока, то они должны фиксировать поток перед началом работ.
// Lock the main goroutine onto this, the main OS thread,
// during initialization. Most programs won't care, but a few
// do require certain calls to be made by the main thread.
// Those can arrange for main.main to run in the main thread
// by calling runtime.LockOSThread during initialization
// to preserve the lock.
lockOSThread()
Фиксация потока за какой-либо горутиной убирает его из пула планировщика для выполнения горутин. Поэтому, когда планировщик решит, что ему необходимо изменить выполняющиеся в данный момент горутины, ему придется создать новый поток для этого.
Когда именно планировщик создаст новый поток, сказать сложно, это может произойти в любой preemption point. Изучение принципов переключения горутин планировщиком не является нашей целью. Пример того, когда это может быть сделано:
при блокировке работы текущей горутины;
если текущая горутина сама проверила, не нужно ли ей передать управление;
планировщиком принудительно с помощью сигналов ОС.
// LockOSThread wires the calling goroutine to its current operating system thread.
// The calling goroutine will always execute in that thread,
// and no other goroutine will execute in it,
// until the calling goroutine has made as many calls to
// [UnlockOSThread] as to LockOSThread.
// If the calling goroutine exits without unlocking the thread,
// the thread will be terminated.
//
// All init functions are run on the startup thread. Calling LockOSThread
// from an init function will cause the main function to be invoked on
// that thread.
//
// A goroutine should call LockOSThread before calling OS services or
// non-Go library functions that depend on per-thread state.
//
//go:nosplit
func LockOSThread() {
Если посмотреть на два предыдущих запуска программы, то разные значения GOMAXPROCS влияли на количество создаваемых потоков именно на этом шаге.
Вывод 4. Если есть горутина, желающая выполняться, нет свободного потока и количество потоков, способных выполнять горутины, меньше GOMAXPROCS, то будет создан новый поток.
Вывод 5. Фиксация потока за какой-либо горутиной делает невозможным для планировщика использовать его для выполнения горутин из очереди.
Влияние фиксации потока на горутине
Для чего нужна фиксации потока на горутине, мы уже определили, осталось понять, как это будет влиять на нашу программу. Проведем эксперимент, могут ли такие потоки обойти ограничение GOMAXPROCS:
func busyLoop() { for { } }
func main() {
for i := 0; i < 5; i++ {
go func() {
runtime.LockOSThread()
busyLoop()
}()
}
busyLoop()
}
Если запустить его с GOMAXPROCS=1, то он создаст 8 ОС потоков. Но, так как все эти потоки все еще находятся под полным управлением планировщика, он может гарантировать, что одновременно не будет выполняться более одной горутины, а значит, не более одного потока.
Казалось бы, если мы создали поток и закрепили за ним какую-то горутину, он будет исполняться, пока горутина не завершит свою работу, ведь никто не снимет эту горутину с потока. И так мы превысим ограничение на количество одновременно исполняющихся горутин.
Но превышения не произойдет из-за того, что планировщик хоть и не снимает горутину с потока, но все равно паркует ее. Как результат — поток переходит в состояние сна и ОС просто перестает выделять ему процессорное время.
Поток участвует в двойной конкуренции: сначала он ждет, когда процесс разбудит его, а потом ждет, когда ОС выделит ему ресурсы. При большом количестве потоков это может приводить к неожиданной деградации системы.
Вывод 6. Мы не можем обойти ограничение GOMAXPROCS с помощью механизма фиксации потока на горутине.
Вывод 7. Потоки, зафиксированные на горутинах, все еще находятся под управлением планировщика, то они не превысят ожидаемого максимального потребления ЦПУ.
Влияние системных вызовов на создание потоков
Мы разобрались, почему приложение Hello world! создало три потока, и в ходе исследования обнаружили подсказки, когда еще будут созданы потоки. В исходном коде sysmon мы видели проверки состояния потоков, выполняющих системные вызовы, — это будет отправной точкой для новых экспериментов. Нам известно четыре вида системных вызовов, рассмотрим каждый из них.
Быстрые системные вызовы с точки зрения sysmon — это вызовы, которые завершились быстрее 20 микросекунд. Такие вызовы не будут приводить к созданию новых потоков, а будут выполняться потоками из пула планировщика по мере выполнения горутин из очереди:
package main
import (
"sync"
"syscall"
"time"
)
func getPID() {
_, _, errNo := syscall.Syscall(syscall.SYS_GETPID, 0, 0, 0)
if errNo != 0 {
panic(errNo)
}
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
getPID()
}()
}
wg.Wait()
}
На моей машине выполнение этого системного вызова занимает около 0,2 микросекунды, что меньше порога срабатывания sysmon, поэтому дополнительные потоки не будут созданы. И мы увидим только три потока, как и в примере с Hello world!
Вывод 8. Системные вызовы продолжительностью менее 20 микросекунд не приводят к созданию потока.
Медленными системными вызовами с точки зрения sysmon считаются вызовы продолжительностью более 20 микросекунд. Sysmon выполняет проверки каждые 20 микросекунд, так что на практике время обнаружения медленного системного вызова будет от 20 до 40 микросекунд. В тот момент, когда sysmon заметит медленный системный вызов, он уберет поток из пула планировщика и очистит его очередь задач. Чтобы понять, какие потоки можно считать заблокированными на системном вызове, каждый системный вызов оборачивается вызовами entersyscall и exitsyscall. Для всех платформ и архитектур подход одинаков.
package main
import (
"os"
"sync"
"syscall"
"time"
"unsafe"
)
func openDevNull() {
p, err := syscall.BytePtrFromString(os.DevNull)
if err != nil {
panic(err)
}
fd, _, errNo := syscall.Syscall(syscall.SYS_OPEN, uintptr(unsafe.Pointer(p)), uintptr(syscall.O_RDONLY), 0)
if errNo != 0 {
panic(errNo)
}
_, _, errNo = syscall.Syscall(syscall.SYS_CLOSE, fd, uintptr(syscall.O_RDONLY), 0)
if errNo != 0 {
panic(errNo)
}
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
openDevNull()
}()
}
wg.Wait()
}
В этом примере мы используем системный вызов, который превысит порог sysmon и заставит его создать дополнительные потоки ОС. На моей машине этот системный вызов выполняется примерно за 68 микросекунд. В коде использовался тот же механизм системных вызовов, что и в предыдущем примере, но поведение стандартной библиотеки отличается и наша программа создала уже восемь потоков.
Вывод 9. При увеличении количества медленных системных вызовов растет и число потоков, простаивающих вне периодов нагрузки.
Вывод 10. Высокое значение метрики созданных потоков может значить большое количество параллельных медленных системных вызовов.
Вывод 11. Фактически системные вызовы не потребляют ЦПУ во время своего ожидания, поэтому эти потоки не будут влиять на общее потребление ЦПУ процесса.
«Сырые» медленные системные вызовы. Стандартная библиотека предоставляет еще один интерфейс для системных вызовов. В отличие от предыдущего, он не регистрируется и поэтому не попадает в поле зрения sysmon, а значит, в случае блокировки на системном вызове новый поток не будет создан.
package main
import (
"os"
"sync"
"syscall"
"time"
"unsafe"
)
func rawOpenDevNull() {
p, err := syscall.BytePtrFromString(os.DevNull)
if err != nil {
panic(err)
}
fd, _, errNo := syscall.RawSyscall(syscall.SYS_OPEN, uintptr(unsafe.Pointer(p)), uintptr(syscall.O_RDONLY), 0)
if errNo != 0 {
panic(errNo)
}
_, _, errNo = syscall.RawSyscall(syscall.SYS_CLOSE, fd, uintptr(syscall.O_RDONLY), 0)
if errNo != 0 {
panic(errNo)
}
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rawOpenDevNull()
}()
}
wg.Wait()
}
В этом примере мы применяем другой механизм использования системных вызовов, который не учитывается в sysmon. Благодаря этому, хоть системные вызовы и выполняются дольше 20 микросекунд, дополнительные потоки ОС запущены не будут.
Вывод 12. Если вы знаете что делаете, можете избежать создания ненужных потоков, используя «сырые» системные вызовы.
Сетевые системные вызовы, происходящие в пакетах языка, связанных с сетью, рассмотрим отдельно. Хоть здесь и не используются «сырые» системные вызовы, наличие неблокирующего I/O позволяет организовать работу так, чтобы выполнение системных вызовов не занимало более 20 микросекунд.
Не все операционные системы поддерживают неблокирующее I/O. Для тех, что поддерживают, реализован netpoller. Если операционная система не готова выполнить I/O-операции прямо сейчас, она не будет блокироваться, а сразу вернет EAGAIN или EWOULDBLOCK ошибку. При этом поток не блокируется, а горутина, инициирующая чтение, уходит в состояние сна.
Когда сокет будет готов для I/O, планировщик разбудит горутину и наша программа выполнит запланированный системный вызов. И, так как все было заранее подготовлено для его выполнения, он должен попасть в категорию быстрых системных вызовов.
Вывод 13. Сетевая подсистема имеет собственные способы снизить продолжительность системных вызовов, что позволяет не создавать дополнительные потоки.
Влияние cgo-вызовов на количество потоков
Cgo-вызовы позволяют обратиться к библиотекам, написанным не на go. По своей сути они похожи на системные вызовы, так как находятся вне зоны ответственности планировщика. Поэтому даже обработка cgo-вызовов и системных вызовов похожа.
До проведения эксперимента можно сказать, что медленные cgo-вызовы приведут к созданию новых потоков, но нужно проверить, смогут ли эти потоки обойти ограничение GOMAXPROCS.
Пример с использованием cgo:
//void cBusyLoop() {
// for (;;) {}
//}
import "C"
func main() {
for i := 0; i < 5; i++ {
go func() { C.cBusyLoop() }()
}
for {}
}
Если сравнить с примером фиксации потока на горутине, то будет ли этот пример работать так же хорошо? На первый взгляд, они очень похожи: оба примера создадут восемь потоков и попытаются максимально их утилизировать. Но, в отличие от примера фиксации потока на горутине, пример с cgo будет потреблять полные шесть ядер, а значит, у нас выполняется шесть одновременных задач и мы обошли ограничение GOMAXPROCS. Такое отличие обусловлено тем, что планировщик не может приостановить работу внешнего вызова, соответственно, поток не может уйти в сон. Аналогичная ситуация будет с тяжелыми системными вызовами. Такую особенность нужно учитывать в приложениях, которые активно используют системные и cgo-вызовы.
Вывод 14. Как и системные вызовы, медленные cgo-вызовы способны привести к созданию новых потоков.
Вывод 15. Использование cgo может позволить обойти ограничение GOMAXPROCS на количество одновременно выполняющихся задач.
Вывод 16. Как и при выполнении системных вызовов, потоки, выполняющие cgo-вызовы, выходят из-под управления планировщиком. Но, в отличие от системных вызовов, cgo-вызовы активно выполняют какую-то работу, а значит, способны привести к неожиданному потреблению ЦПУ.
Завершение созданных потоков
Рано или поздно поток избавится от ограничивающих его обстоятельств, блокирующий вызов завершится, или горутина будет откреплена от потока. Какое-то время поток будет ожидать новую работу, не переходя в состояние сна. Если же работы нет или достигнуто максимальное количество работающих потоков, он будет переведен в состояние сна.
Неиспользуемые потоки, которые не были остановлены, могут быть использованы повторно вместо создания новых. Поэтому, если загрузка приложения примерно стабильная, то такие потоки не являются мертвым грузом для приложения, а регулярно используются для выполнения задач.
А если не открепить горутину от потока перед ее завершением, поток будет остановлен. Будет считаться, что он находится в неизвестном состоянии и рантайм не может гарантировать, что на этом потоке можно безопасно выполнять горутины.
Подробнее о том, почему это происходит и как разработчики языка пришли к этому решению, можно прочитать на GitHub. Что интересно, там не говорится, когда поток будет остановлен.
Если поискать в трекере задач языка, можно найти issue, где пользователи просят добавить в язык функцию остановки неиспользуемых потоков. Задача была одобрена в 2017 году, но ее до сих пор так и не взяли в работу.
В похожем обсуждении было предложено использовать хак, основанный на логике функции `runtime.LockOSThread()`, чтобы сократить количество потоков, которые держит процесс.
Заключение
Разработчик не может контролировать, когда планировщик создаст дополнительные потоки ОС и сколько потоков будет создано во время работы его приложения. Поэтому, если это важно, то, скорее всего, golang не подходит для выполнения ваших задач.
Если ваше приложение работает с файловой системой или в своем приложении вы используете cgo, то метрика количества созданных потоков может значительно отличаться от GOMAXPROCS. Но не стоит воспринимать это как проблему: эти потоки нужны планировщику для нормальной работы. Однако резкий рост количества потоков может означать какую-то проблему в соответствующих подсистемах.
Так как созданные потоки никогда не завершаются, а остаются в пуле планировщика для будущих задач, в сценариях, когда приложение имеет эпизодическую нагрузку, эти потоки большую часть времени простаивают. Хоть они и не потребляют ЦПУ, для них зарезервирован некоторый объем оперативной памяти. В условиях ограниченных ресурсов это может быть важно.
Кроме того, если вы используете cgo, вы не можете быть уверенными, сколько ресурсов будет потреблять ваше приложение. Это может быть важно для выделения квот и лимитов при контейнеризации, например, в kubernetes.
Что мы узнали в этой статье:
Приложения, написанные на go, будут иметь как минимум на один поток больше, чем GOMAXPROCS.
Системные вызовы потенциально могут привести к созданию потока ОС, если они выполняются дольше 20 мкс.
Вызовы cgo потенциально могут привести к созданию потока.
Вызов `runtime.LockOSThread()` потенциально может привести к созданию потока.
Отсутствие парного для `runtime.LockOSThread()` вызова функции `runtime.UnlockOSThread()` приведет к остановке этого потока.
Рантайм не имеет механизма автоматической остановки неиспользуемых потоков.
Полезные ссылки:
Принципы переключения горутин планировщиком