предисловие
В этой части есть три статьи, в основном объясняющие содержание планировщика go.
Три статьи:
- Поймите одно из планирования Golang: планирование операционной системы
- Понимание планирования Golang, часть 2: Планировщик Go
- Понимание третьего планирования Golang: параллелизм
Введение
Поведение дизайна планировщика Golang может сделать ваши многопоточные программы GO более эффективными и исполнительными, благодаря поддержке Golang планировщика планировщиков операционной системы. Для разработчика Голанга, глубокое понимание планирования операционной системы и принцип работы планировщика Golang может сделать ваш дизайн программы Golang, и разработку программы Goalang идут правильно.
планировщик операционной системы
Планировщик операционной системы очень сложен, он должен учитывать базовую аппаратную структуру, включая, помимо прочего, количество процессоров и ядер, кэш-память процессора и NUMA. Без этих вещей планировщик не сможет работать максимально эффективно.
Программа на самом деле представляет собой серию машинных инструкций, которые выполняются последовательно. Чтобы заставить его работать правильно, операционная система использует концепцию потоков. Поток обрабатывает и выполняет ряд назначенных ему машинных инструкций. Поток будет продолжать выполнять эти машинные инструкции до тех пор, пока не останется инструкций для выполнения. Вот почему потоки называются «путем выполнения».
Каждый бегун создает процесс, и у каждого процесса есть начальный поток. Потоки могут создавать больше потоков. Эти разные потоки выполняются независимо, и поведение планирования определяется на уровне потока, а не на уровне процесса. Потоки могут выполняться параллельно (каждый опрос потока на отдельном ядре занимает определенное количество процессорного времени), а не параллельное выполнение (одновременное выполнение на разных ядрах). Поток также поддерживает свое собственное состояние и может выполнять свои инструкции безопасно и независимо локально. Это также объясняет, почему поток является наименьшей единицей планирования процессора.
Планировщик операционной системы, который отвечает за то, чтобы ядро не простаивало, пока есть потоки, доступные для выполнения. Это создает иллюзию того, что все потоки, которые могут выполняться, выполняются одновременно. Для этого планировщику необходимо установить приоритеты высокоприоритетных потоков, но он также должен гарантировать, что низкоприоритетные потоки не будут голодать. Планировщик также должен максимально минимизировать задержки планирования.
К счастью, применение многих алгоритмов делает планировщик более эффективным. Некоторые важные концепции поясняются ниже.
Выполнение инструкций
Счетчик программ (ПК), иногда называемый указателем команд (IP), позволяет найти следующую команду для выполнения. В большинстве процессоров ПК указывает на следующую инструкцию.
Если вы когда-нибудь обращали внимание на трассировку стека программы go, вы заметите эти шестнадцатеричные числа в конце каждой строки. Например, +0x39 и +0x72 в листинге 1.
Listing 1
goroutine 1 [running]:
main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
stack_trace/example1/example1.go:13 +0x39 <- LOOK HERE
main.main()
stack_trace/example1/example1.go:8 +0x72 <- LOOK HERE
Эти числа представляют собой значение PC, которое является смещением от начала соответствующей функции. Смещение +0x39 PC означает, что когда программа не запаниковала, поток находится в состоянииexample
Следующая инструкция для выполнения метода. +0x72 PC-смещение для ifexample
Функция возвращается к основной функции,main
следующая инструкция в . Предыдущий указатель на инструкцию сообщает вам, какая инструкция выполняется в данный момент.
Взгляните на программу, которая вызывает панику в листинге 1.
Listing 2
07 func main() {
08 example(make([]string, 2, 4), "hello", 10)
09 }
12 func example(slice []string, str string, i int) {
13 panic("Want stack trace")
14 }
Шестнадцатеричное число +0x39 представляет смещение ПК, которое составляет 57 (десятичных) байтов от начала функции в примере функции. В листинге 3 ниже вы можете увидеть пример функции через двоичный файлobjdump
. Найдите 12-ю инструкцию внизу и обратите внимание, что инструкция в строке над ней вызвала ошибку.panic
Listing 3
$ go tool objdump -S -s "main.example" ./example1
TEXT main.example(SB) stack_trace/example1/example1.go
func example(slice []string, str string, i int) {
0x104dfa0 65488b0c2530000000 MOVQ GS:0x30, CX
0x104dfa9 483b6110 CMPQ 0x10(CX), SP
0x104dfad 762c JBE 0x104dfdb
0x104dfaf 4883ec18 SUBQ $0x18, SP
0x104dfb3 48896c2410 MOVQ BP, 0x10(SP)
0x104dfb8 488d6c2410 LEAQ 0x10(SP), BP
panic("Want stack trace")
0x104dfbd 488d059ca20000 LEAQ runtime.types+41504(SB), AX
0x104dfc4 48890424 MOVQ AX, 0(SP)
0x104dfc8 488d05a1870200 LEAQ main.statictmp_0(SB), AX
0x104dfcf 4889442408 MOVQ AX, 0x8(SP)
0x104dfd4 e8c735fdff CALL runtime.gopanic(SB)
0x104dfd9 0f0b UD2 <--- LOOK HERE PC(+0x39)
Примечание: PC всегда является следующей командой, а не текущей командой. Листинг 3 — хорошая иллюстрация того, как поток go выполняет последовательность инструкций в amd64.
состояние потока
Другая важная концепция - «Состояние потока», состояние потока иллюстрирует, как в это время обрабатывает потоки планировщика. Есть три состояния потоков: ждать, беги, выполнение.
Ожидающий:
В этот момент это означает, что поток остановлен и ожидает пробуждения. Возможные причины — ожидание аппаратного обеспечения (жесткий диск, сеть), операционной системы (системные вызовы) или синхронных вызовов (атомарные, мьютексы). Эти условия являются источником проблем с производительностью
Запускаемый:
В этот момент поток хочет занять процессорное время на ядре для выполнения инструкций, назначенных потоку. Если у вас есть много потоков, которым требуется процессорное время, потоки должны ждать некоторое время, чтобы получить процессорное время. Чем больше потоков соревнуется за процессорное время, тем меньше времени выделяется процессорному времени. Задержки планирования в этом случае также могут вызвать проблемы с производительностью.
Выполнение:
В этот момент поток помещен в ядро и выполняет свои машинные инструкции. Контент, связанный с приложением, обрабатывается. Это состояние то, что мы хотим
Тип вакансии
Нити имеют два типа работы. Первый называется процессороемким, а второй называется IO-интенсивным.
Привязка к ЦП (привязка к ЦП):
При такой работе поток никогда не переводится в состояние ожидания. Как правило, это непрерывная вычислительная работа процессора. Например, вычисление Pi требует интенсивной работы процессора.
IO-bound (IO-bound)
Этот вид работы переводит поток в состояние ожидания. В этом случае поток будет продолжать запрашивать ресурсы (например, сетевые ресурсы) или выполнять системные вызовы операционной системы. Ситуация, когда потоку требуется доступ к базе данных, требует интенсивного ввода-вывода. Синхронные события (такие как мьютексы, атомарные), аналогичные ситуации, требующей ожидания потоков, я также отношу к этой категории.
Переключение контекста
Если ваша программа работает на Linux, Mac или Windows, ваш планировщик является упреждающим. Это означает, что, во-первых, планировщик заранее не знает, какой поток будет выполняться в этот момент. Приоритет потока плюс транзакции (такие как прием сетевых данных) не позволяют планировщику определить, какой поток выполнять в какое время.
Во-вторых, вы никогда не сможете смотреть на это с точки зрения исторического опыта, на самом деле код, который был запущен ранее, не может гарантировать, что он будет выполняться так, как вы хотите, каждый раз. Если ваш код выполняется одинаково 1000 раз, вы будете думать, что он гарантированно будет выполняться так же и в следующий раз. Если ваша программа должна быть детерминированной, вы должны контролировать синхронизацию и планирование потоков.
Физический акт переключения потоков в ядре называется переключением контекста. Переключение контекста происходит следующим образом: планировщик выгружает исполняемый поток из ядра и заменяет исполняемый поток. Поток берется из очереди выполнения и устанавливается в состояние Executing. Потоки, удаленные из ядра, переводятся в состояние готовности к выполнению или в состояние ожидания.
Переключение контекста обходится дорого, потому что требуется время, чтобы поменять местами потоки, отключить их от ядра и снова включить. На задержку переключения контекста влияет множество факторов, но обычно она составляет 1000-1500 наносекунд. Учитывая, что каждое ядро на оборудовании выполняет в среднем 12 инструкций в наносекунду, переключение контекста будет стоить вам задержки в 12-18 тысяч инструкций. По сути, ваша программа теряет возможность выполнять большое количество инструкций во время переключения контекста.
Если ваша программа ориентирована на работу с интенсивным вводом-выводом (с привязкой к процессору), переключение контекста будет относительно полезным. Как только поток входит в состояние ожидания. Его место занимает другой поток в состоянии Runnable. Это позволит постоянно поддерживать ядро в рабочем состоянии. Это важный аспект планирования планировщика, не позволяющий ядру бездействовать, если есть какие-то дела (есть потоки в рабочем состоянии).
Если ваша программа ориентирована на работу с привязкой к процессору, переключение контекста может стать кошмаром для производительности. Поскольку поток всегда что-то делает, переключение контекста останавливает выполняемую работу. Эта ситуация резко отличается от работы с интенсивным вводом-выводом.
Меньше - больше
В первые дни процессоры имели только одно ядро, а планировщик не был очень сложным. Поскольку у вас есть один процессор, одно ядро, в любой момент времени может выполняться только один поток. Способ справиться с этим состоит в том, чтобы определитьпериод планирования(период планировщика) Затем попытайтесь выполнить все исполняемые потоки в течение периода планирования. Это не проблема: разделите период планирования на небольшие сегменты в соответствии с количеством потоков, которые необходимо выполнить.
Например, если вы определяете период планирования как 10 мс и у вас есть два потока, каждому потоку будет выделено 5 мс. 5 потоков, каждый поток 2 мс. Но что, если у вас есть 100 потоков? Каждый квант времени потока составляет 10 мкс (микросекунд), что не сработает, потому что вам нужно много времени для переключения контекста.
В другом сценарии, если минимальный квант времени составляет 2 мс и у вас есть 100 потоков, период планирования необходимо увеличить до 2000 мс или 2 с. Если у вас есть 1000 потоков, теперь период планирования занимает 20 секунд, то есть вам потребуется 20 секунд, чтобы запустить все потоки, если каждый поток может выполнить свой квант времени.
Все вышеперечисленные сценарии очевидны. Существует больше факторов, которые планировщик учитывает при принятии решения. Вы контролируете количество потоков в своем приложении, когда потоков больше, а работа связана с вводом-выводом, будет больше путаницы и неопределенного поведения, а планирование и выполнение займут больше времени.
Вот почему действует правило игры: «Меньше значит больше». Меньше исполняемых потоков означает меньше времени планирования и больше времени для потоков. Больше потоков означает, что каждый поток получает меньше времени и выполняет меньше работы за отведенное время.
найти баланс
Вам нужно найти баланс между количеством ядер и количеством потоков, который даст вашей программе наилучшую пропускную способность. Чтобы найти такой баланс, пулы потоков — хороший выбор.
Прежде чем использовать go, первоначальный автор использовал C++ и C# в системах NT. В этой операционной системе использование пула потоков IOCP (порты завершения ввода-вывода) очень важно для написания многопоточного программного обеспечения. Как инженер, вы должны выяснить, сколько пулов потоков вы хотите использовать, и максимальное количество потоков на пул потоков, чтобы максимизировать пропускную способность в системе с определенным количеством ядер.
При написании веб-сервисов вам необходимо взаимодействовать с базой данных. 3 — это магическое число, и установка 3 потоков на ядро обеспечивает наилучшую пропускную способность в NT. Другими словами, 3 потока на ядро могут минимизировать задержку переключения контекста и максимизировать время выполнения на ядре. Когда вы создаете пул потоков IOPC, я знаю, что могу установить количество потоков на ядро от 1 до 3 на хосте.
Если я использую 2 потока на ядро, время для завершения работы увеличивается, потому что ядра, которые в противном случае нуждались бы в работе, имеют время простоя. Если я использую 4 потока на ядро, это также займет больше времени, потому что мне нужно тратить больше времени на переключение контекста. Баланс номер 3 по какой-то причине кажется магическим числом в NT.
Что делать, если ваш сервис должен обрабатывать множество различных типов работы. Это будет иметь разные и непоследовательные задержки. Возможно, он генерирует множество различных событий системного уровня, которые необходимо обрабатывать. В этом случае вы вряд ли найдете волшебное число, которое всегда будет давать вам хорошую производительность во всех различных рабочих ситуациях. При использовании пулов потоков найти подходящую конфигурацию может быть сложно.
Линии кэша
Доступ к данным из основной памяти имеет большую задержку (около 100-300 тактов), поэтому процессоры и ядра имеют кэши, которые позволяют потокам получать доступ к более свежим данным. Задержка доступа к данным из кеша очень мала (около 3~40 тактов) в зависимости от метода доступа к кешу. Одним из показателей производительности является эффективность, с которой процессор извлекает данные за счет уменьшения задержки доступа к данным. При написании многопоточных приложений учитывается система кэширования машины.
Процессор и оперативная память используют кэш-линии для обмена данными. Строка кэша — это 64-байтовый блок памяти, который перемещается между памятью и системой кэширования. Каждое ядро будет выделять свою собственную копию необходимого ему кеша. По этой же причине мутация памяти в многопоточности может вызвать серьезные проблемы с производительностью.
Когда несколько потоков, работающих параллельно, обращаются к одним и тем же данным, даже к соседним блокам данных, они будут обращаться к одной и той же строке кэша. Любой поток, работающий на любом ядре, может получить свою собственную копию из той же строки кэша.
Если поток в ядре изменяет свою копию строки кэша, под действием оборудования все остальные копии той же строки кэша будут помечены как недействительные. Когда поток пытается прочитать или записать недопустимую строку кэша, ему необходимо повторно обратиться к основной памяти, чтобы получить новую копию строки кэша (около 100–300 тактов).
Может быть, на 2-ядерном процессоре это не большая проблема, но что, если 32-ядерный процессор запускает 32 потока параллельно, обращаясь и модифицируя одну и ту же строку кэша одновременно? Ситуация усугубляется из-за увеличения задержки связи между процессорами. Память программы бьется, производительность падает, и, скорее всего, вы не знаете, в чем проблема.
Этопроблема согласованности кеша(проблема когерентности кеша) или сбой совместного использования (ложное совместное использование). При написании многопоточных приложений, изменяющих общее состояние, необходимо учитывать систему кэширования.
Сценарий решения о планировании
Рассмотрим следующий сценарий планирования.
Приложение запускается, и основной поток уже запущен на ядре1. Во время выполнения потока ему необходимо получить строку кэша, чтобы получить доступ к данным. Основной поток теперь создает новый поток для некоторой параллельной обработки. Итак, вот в чем проблема.
Как только поток создан и готов к запуску, планировщик должен:
- Изменить основной поток с core1? Это повышает производительность, поскольку очень высока вероятность того, что те же самые данные, которые нужны этому новому потоку, будут кэшированы. Но основной поток не получает свой полный временной отрезок.
- Должен ли поток ждать, пока ядро 1 станет доступным после того, как основной поток закончит свое время? Поток не запущен, но как только он запустится, задержка в получении данных будет устранена.
- Поток ждет следующего доступного ядра? Это означает, что строка кэша выбранного ядра будет очищаться, извлекаться и копироваться, вызывая задержки. Но поток запустится быстрее, и основной поток завершит свой квант времени.
Все это необходимо учитывать планировщику при принятии решения.
в заключении
Это первая часть, которая даст вам некоторое представление о том, что следует учитывать в отношении потоков и планировщика ОС при программировании с несколькими потоками. Это также необходимо учитывать планировщику golang. В следующей части мы поговорим о некоторых связанных знаниях о планировщике Go.