Перейти к параллельному анализу планировщика для реализации пула сопрограмм

Go
Параллелизм (параллелизм) всегда был одной из основных тем в языке программирования, и это также тема, которой разработчики уделяют наибольшее внимание; язык Go, как богатый язык программирования второго поколения с ореолом «высокого параллелизма» с тех пор его дебют, это Параллельное (параллельное) программирование Golang, безусловно, стоит изучить разработчикам, а параллельное (параллельное) программирование на языке Go реализуется с помощью горутин.Горутины являются одной из наиболее важных функций golang с низкой стоимостью использования, низкое потребление ресурсов. С характеристиками высокой энергоэффективности официально утверждается, что десятки тысяч собственных горутин не являются проблемой, поэтому она также стала функцией, часто используемой Гоферами.

Горутины превосходны, но не идеальны В чрезвычайно крупномасштабных сценариях с высокой степенью параллелизма также могут быть обнаружены проблемы. В чем проблема? Какие альтернативные решения существуют? Эта статья поможет вам понять его механизм и обнаружить некоторые принципы и проблемы памяти и планирования с помощью анализа планирования горутин во время выполнения, а также предложить личное решение, основанное на этом.
кейс — Высокопроизводительный пул горутин (coroutine pool).
Углубленный анализ модели параллельного планирования goroutine и передача пула coroutine

Goroutine & Scheduler

Goroutine, собственное решение языка Go, основанное на конкурентном (параллельном) программировании. что такое горутин? Обычно горутина будет реализована как golang реализация сопрограммы (сопрограммы).С относительно поверхностного уровня это познание разумно, но на самом деле горутина не является сопрограммой в традиционном смысле.Сейчас основная модель потока делится на три типа : модель потоков на уровне ядра, модель потоков на уровне пользователя и двухуровневая модель потоков (также известная как гибридная модель потоков), традиционная библиотека сопрограмм принадлежитМодель потоков на уровне пользователя, а горутина и ееGo SchedulerВ базовой реализации он фактически принадлежитДвухуровневая модель потоков, Поэтому иногда для удобства понимания можно просто сравнивать горутины с корутинами, но в сердце должно быть четкое понимание — горутины не эквивалентны сопрограммам.

нить вещь

Со времен Интернета из-за резкого роста числа онлайн-пользователей количество соединений, обрабатываемых одним сервером, также увеличилось, что вынудило обновить модель программирования с предыдущей последовательной модели на параллельную модель. Параллельная модель также обновлялась от поколения к поколению, добавляя больше операций ввода-вывода.Мультиплексирование, многопроцессорность и многопоточность — эти модели имеют свои сильные и слабые стороны.Большинство современных и сложных архитектур с высокой степенью параллелизма используются в сочетании с несколькими модели.Разные модели используются в разных сценариях, чтобы максимизировать сильные стороны и избежать слабых сторон, чтобы максимизировать производительность сервера.Многопоточность из-за ее легкости и простоты использования стала наиболее часто используемой моделью параллелизма в параллельном программировании и других подпродуктах. такие как производная сопрограмма, также основаны на ней, и горутина, которую мы собираемся сегодня проанализировать, также основана на потоках, поэтому сначала мы поговорим о трех моделях потоков:

Существует три основных типа моделей реализации потоков: модель потоков на уровне ядра, модель потоков на уровне пользователя и двухуровневая модель потоков (также называемая моделью гибридных потоков).Самая большая разница между ними заключается в пользовательском потоке и планировании ядра. объект (KSE, Kernel Scheduling Entity). Так называемая сущность планирования ядра KSE относится к сущности объекта, которая может быть запланирована планировщиком ядра операционной системы (что это за штука, осмелитесь ли вы понять это немного проще?). Проще говоря, KSEпотоки уровня ядра, это наименьшая единица планирования ядра операционной системы, то есть поток, который мы обычно понимаем, когда пишем код (так что вы его не понимаете! Что устанавливать 13).

Модель потоков на уровне пользователя

Пользовательский поток и поток ядра KSE представляет собой модель отображения "многие к одному" (N: 1). Несколько пользовательских потоков обычно подчиняются одному процессу, а планирование многопотокового выполнения выполняется собственной библиотекой потоков пользователя. потоки и операции, такие как координация между несколькими потоками, обрабатываются собственной библиотекой потоков пользователя без необходимости системных вызовов. Все потоки, созданные в процессе, только динамически связаны с одним и тем же KSE во время выполнения, то есть операционная система знает только о пользовательском процессе и не знает о потоках в нем, и все планирование ядра основано на процесс пользователя. реализовано на многих языкахБиблиотека сопрограммВ основном это относится к этому пути (например, gevent python). Поскольку планирование потоков выполняется на уровне пользователя, то есть по сравнению с планированием ядра, ЦП не нужно переключаться между состоянием пользователя и состоянием ядра.Потребление ресурсов будет намного меньше, поэтому количество потоков, которые можно создать и стоимость переключения контекста также будет намного меньше. Но у этой модели есть первородный грех: она не может обеспечить параллелизм в истинном смысле, предполагая, что пользовательский поток в пользовательском процессе прерывается ЦП из-за блокирующего вызова (например, блокировки ввода-вывода) (упреждающее планирование), тогда все потоки в процессе блокируются (поскольку самопланирование потока в одном пользовательском процессе не прерывается часами ЦП, поэтому циклическое планирование отсутствует), и весь процесс приостанавливается. Даже многопроцессорная машина бесполезна, потому что в модели потоков на уровне пользователя ЦП связан со всем пользовательским процессом, а подпотоки в процессе связаны с ЦП и выполняются пользовательским процессом, а внутренний поток — ЦП.Невидимый, можно понять, что единицей планирования ЦП является пользовательский процесс. так многоБиблиотека сопрограммОн переупаковывает некоторые из своих блокирующих операций в полную неблокирующую форму, а затем активно прекращает работу в точке, которая была ранее заблокирована, и уведомляет или пробуждает другие пользовательские потоки для выполнения каким-либо образом для запуска на KSE. избегает переключения контекста планировщика ядра из-за блокировки KSE, так что весь процесс не будет заблокирован.

Модель потоков на уровне ядра

Пользовательский поток и поток ядра KSE представляет собой модель отображения один к одному (1:1), то есть каждый пользовательский поток привязан к реальному потоку ядра, а планирование потоков полностью передается ядру операционной системы. создание, завершение и синхронизация потоков выполняются на основе системных вызовов, предоставляемых ядром.Библиотеки потоков большинства языков программирования (например, java.lang.Thread в Java, std::thread в C++11 и т. д.) все операции на уровне инкапсуляции системных потоков (потоки уровня ядра), каждый созданный поток статически привязан к независимому KSE, поэтому его планирование полностью выполняется планировщиком ядра операционной системы. Преимущества и недостатки этой модели также очевидны: преимущество в том, что ее просто реализовать, напрямую используя потоки и планировщики ядра операционной системы, поэтому ЦП может быстро переключать потоки планирования, поэтому несколько потоков могут выполняться одновременно. времени, поэтому по сравнению с моделью потоков на уровне пользователя она действительно обеспечивает параллельную обработку, но ее недостатком является то, что из-за прямого использования ядра операционной системы для создания, уничтожения, переключения контекста и планирования между несколькими потоками стоимость ресурсов значительно увеличивается, и производительность сильно снижается.

Двухуровневая модель потоков

Двухуровневая модель потоков является продуктом изучения сильных сторон других, полностью вбирая в себя преимущества первых двух моделей потоков и максимально избегая их недостатков. В рамках этой модели пользовательские потоки и KSE ядра представляют собой модель отображения «многие ко многим» (N : M): во-первых, в отличие от модели потоков пользовательского уровня процесс в двухуровневой модели потоков может быть связан с несколькими ядрами потоки KSE, поэтому несколько потоков в процессе могут быть привязаны к разным KSE, что похоже на модель потоков на уровне ядра; во-вторых, она отличается от модели потоков на уровне ядра.Все потоки в этом процессе не привязаны к KSE один за другим, но один и тот же KSE может быть динамически связан.Когда KSE запланировано ядром вне ЦП из-за операции блокировки его связанного потока, оставшиеся пользовательские потоки в связанном процессе могут быть связаны и запущены снова с помощью другие КСЭ. Таким образом, двухуровневая модель потоков не является ни моделью потоков уровня пользователя, которая полностью планируется сама по себе, ни моделью потоков уровня ядра, которая полностью планируется операционной системой, а является промежуточным состоянием (самопланирование и системное планирование работы). вместе), то есть — «модель Шредингера» (ошибочно), из-за высокой сложности этой модели разработчики ядра операционной системы ее вообще не используют, поэтому чаще используется как сторонняя библиотека, а планировщик времени выполнения на языке Go — это схема реализации принята для реализации динамической связи между Goroutine и KSE, но реализация языка Go более продвинутая и элегантная, почему эта модель называется двухуровневой?То есть пользовательский планировщик реализует «планирование» от пользовательских потоков к KSE, а планировщик ядра реализует «планирование» от KSE к ЦП..

Обзор модели G-P-M

Каждый поток ОС имеет блок памяти фиксированного размера (обычно 2 МБ) в виде стека, который используется для хранения внутренних переменных функций, которые в данный момент вызываются или приостанавливаются (имеется в виду при вызове других функций). Этот стек фиксированного размера большой и маленький одновременно. Потому что стек размером 2 МБ — это большая трата памяти для маленькой горутины, и он слишком мал для некоторых сложных задач (таких как глубоко вложенная рекурсия). Поэтому язык Go делает свои собственные «потоки».

В языке Go каждая горутина является независимой исполнительной единицей.По сравнению с фиксированным выделением 2 МБ памяти для каждого потока ОС стек горутины использует метод динамического расширения, который изначально составляет всего 2 КБ, и по мере выполнения задачи по требованию. , до 1 ГБ (64-разрядная машина до 1 ГБ, 32-разрядная машина до 256 МБ), и она полностью контролируется собственным планировщиком golang.Go Schedulerпланировать. Кроме того, GC будет периодически освобождать память, которая больше не используется, и уменьшать пространство стека. Таким образом, программы Go могут одновременно запускать тысячи горутин благодаря мощному планировщику и эффективной модели памяти. Создатели Go, вероятно, позиционировали горутину как смертельный нож, потому что они не только сделали горутину основным компонентом параллельного программирования в golang (программы разработчиков запускаются на основе горутины), но и повсеместно внедряют множество стандартных библиотек в golang. Например, пакет net/http и даже исполняемые компоненты самого языка и сборщик мусора GC работают на горутинах.Очевидны большие ожидания автора в отношении горутин.

Любой пользовательский поток должен в конечном итоге выполняться потоком ОС, и горутина (называемая G) не является исключением, но G не привязана напрямую к потоку ОС для запуска, а запускается P в планировщике горутины.Logical Processor(логический процессор) как «посредник» между ними, P можно рассматривать как абстрактный ресурс или контекст, P связан с потоком ОС, а поток ОС абстрагируется в структуру данных в реализации golang: M , G на самом деле запланировано и выполняется от M до P, но на уровне G P предоставляет все ресурсы и среду, необходимые для запуска G, поэтому, с точки зрения G, P должен его запускать. «ЦП», три реализации, абстрагированные от Go с помощью G, P и M, наконец, формируют базовую структуру планировщика Go:

  • G: представляет горутину, каждая горутина соответствует структуре G. G хранит рабочий стек горутины, статус и функции задач, которые можно использовать повторно. G не является исполнителем, и каждый G должен быть привязан к P, чтобы его выполнение было запланировано.
  • P: Процессор, который представляет собой логический процессор.Для G P эквивалентен ядру ЦП, и G может быть запланирован только в том случае, если он привязан к P (в локальном runq P). Для M P предоставляет соответствующую среду выполнения (Context), такую ​​как состояние выделения памяти (mcache), очередь задач (G) и т. д. Число P определяет максимальное количество параллельных G в системе (посылка: физический ЦП). количество ядер >= количество P), количество P определяется GOMAXPROCS, установленным пользователем, но независимо от того, насколько велика установка GOMAXPROCS, максимальное количество P равно 256.
  • М: машина, абстракция потока ОС, представляет собой ресурс, который фактически выполняет вычисления. После связывания действительного P он входит в цикл планирования, и механизм цикла планирования примерно состоит в том, чтобы получить G из глобальной очереди, локальной очереди P и ждать Переключитесь на стек выполнения G и выполните функцию G, вызовите goexit для очистки и возврата к M и так далее. M не сохраняет состояние G, которое является основой для планирования G через M. Количество M неопределенно и регулируется Go Runtime, чтобы предотвратить создание слишком большого количества потоков ОС, и система не может быть запланирована , текущее максимальное ограничение по умолчанию составляет 10 000.

О P нужно сказать еще несколько слов.Когда Go 1.0 был выпущен, его планировщик был фактически моделью GM, то есть не было никакого P. Процесс планирования был полностью завершен G и M. Эта модель выставила некоторые проблемы:

  • Существование единого глобального мьютекса (Sched.Lock) и централизованного хранилища состояний приводит к блокировке всех операций, связанных с горутинами, таких как: создание, изменение расписания и т. д.;
  • Проблема переноса горутин: M часто передает «работоспособные» горутины между M, что приводит к увеличению задержки планирования и дополнительной потере производительности;
  • Каждый M используется в качестве кэша памяти, что приводит к высокому использованию памяти и плохой локализации данных;
  • Энергичная блокировка и разблокировка рабочего потока из-за вызовов системных вызовов приводит к дополнительному снижению производительности.

Эти проблемы настолько поразительны, что, хотя Go1.0 утверждает, что изначально поддерживает параллелизм, его раскритиковали с точки зрения производительности параллелизма.Затем основной разработчик в языковом комитете Go не выдержал и лично переработал и внедрил Go. планировщик (представленный P в исходной модели GM) и реализуетwork-stealingАлгоритм планирования:

  • Каждый P поддерживает локальную очередь G;
  • Когда G создается или становится исполняемым, он помещается в очередь исполняемых файлов P;
  • Когда G выполняется в M, P берет G из очереди; если очередь P в это время пуста, то есть никакая другая G не может быть выполнена, M случайным образом выбирает другой P и выбирает другой P из его исполняемый файл G. Возьмите половину очереди.

Этот алгоритм позволяет избежать использования глобальных блокировок при планировании горутин.

На данный момент установлена ​​базовая модель планировщика Go:

Планирование модели G-P-M

Когда планировщик Go работает, он поддерживает две очереди задач для сохранения G: одна — глобальная очередь задач, а другая — локальная очередь задач, поддерживаемая каждым P.

когда прошлоgoКогда ключевое слово создает новую горутину, она сначала помещается в локальную очередь P. Чтобы запустить горутину, M нужно удерживать (связать) P, а затем M запускает поток ОС, который зацикливает горутину из локальной очереди P и выполняет ее. Конечно, вышеперечисленноеwork-stealingАлгоритм планирования: когда M заканчивает выполнение всех G в текущей локальной очереди P, P не будет просто лежать там и ничего не делать, он сначала попытается найти G из глобальной очереди для выполнения, если глобальная очередь пуста, он будет случайным образом выбрать другой P, взять половину G из его очереди и выполнить его в своей собственной очереди.

Если все нормально, то планировщик будет работать как указано выше, но мир не так прекрасен, и всегда случаются аварии.Далее анализируется поведение горутины в двух исключительных случаях.

Среда выполнения Go запустит другую горутину, в то время как следующая горутина заблокирована:

  • blocking syscall (for example opening a file)
  • network input
  • channel operations
  • primitives in the sync package

Эти четыре сценария можно разделить на два типа:

Блокировка/пробуждение пользовательского режима

Когда горутина блокируется из-за работы канала или сетевого ввода-вывода (на самом деле, golang использовал netpoller, чтобы понять, что блокировка сетевого ввода-вывода горутиной не приведет к блокировке M, а только заблокирует G, здесь просто каштан), соответствующий G будет помещен в очередь ожидания (например, waitq канала), состояние G определяется_Gruningстать_Gwaitting, и M пропустит G и попытается получить и выполнить следующий G. Если в это время нет исполняемого G для запуска M, то M отвяжет P и войдет в состояние сна, когда заблокированный G пробуждается G2 на другом конце (например, уведомление о чтении/записи канала) G помечается как работоспособный, попробуйте присоединиться к runnext P, где находится G2, а затем к локальной очереди и глобальной очереди P.

блокировка системных вызовов

Когда G заблокирован на системном вызове, G заблокируется на_Gsyscallсостояние, M также находится в состоянии блокировки в состоянии системного вызова.В это время M может быть вытеснен и запланирован: M, который выполняет этот G, будет несвязан с P, в то время как P попытается связать с M других бездействующих и продолжить выполнение других Гс. Если нет другого незанятого M, но в локальной очереди P все еще есть G для выполнения, создается новый M; когда системный вызов завершен, G повторит попытку получить незанятый P и войдет в свою локальную очередь для выполнения. возобновить выполнение. Без простоя P, G будет помечен как работоспособный и добавлен в глобальную очередь.

Вышеизложенное является кратким введением в Goroutine и его планировщик с точки зрения макросов.Конечно, для получения более подробной информации о более сложном упреждающем планировании и планировании блокировки в планировании Go вы можете найти соответствующую информацию для более глубокого понимания., эта статья рассказывается только об основном процессе планирования планировщика Go, который обеспечивает теоретическую основу для реализации пула горутин позже.Я не буду продолжать здесь вдаваться в вышеупомянутые расписания.На самом деле, если вы хотите полностью объяснить планировщик Go , Длина статьи действительно растянута, поэтому студенты, которые хотят узнать больше деталей, могут перейти к дизайн-документу модели, написанному Дмитрием Вьюковым, дизайнером модели GPM планировщика Go.Go Preemptive Scheduler Design"И переходим непосредственно к исходникам, определение модели G-P-M помещено вsrc/runtime/runtime2.goвнутри, а процесс планирования помещается вsrc/runtime/proc.goвнутри.

Узкое место крупномасштабных горутин

Поскольку планировщик Go уже настолько хорош, зачем нам самим реализовывать пул Goroutine в golang? На самом деле превосходство не означает совершенство, любая модель программирования, не учитывающая конкретных сценариев применения, — хулиган! С одобрением планировщика Go на основе GPM при параллельном программировании программ go можно произвольно запускать крупномасштабные горутины для выполнения задач.

Тем не менее, у вас нет проблем с 1000 горутин, 10000 не проблема, и 10w может не быть проблемой; тогда как насчет 100w? А 1000w? (Это просто крайний пример, примеров реального программирования такой масштабной горутины очень мало) Здесь будет проблема, в чем проблема?

  1. Прежде всего, даже если каждая горутина выделяет только 2 КБ памяти, если это такой ужасающий объем, если объем слишком мал, память резко возрастет, что создаст большую нагрузку на сборщик мусора. знаю что jvm GC это зло.Механизм STW(Stop The World),то есть GC будет приостанавливать работу программы пользователя до завершения сборки мусора.Хотя GC после Go1.8 удалил STW и оптимизировал его в параллельный сборщик мусора, производительность была значительно улучшена, однако, если сборщик мусора выполняется слишком часто, все равно будут возникать узкие места в производительности;
  2. Во-вторых, помните, что среда выполнения и сборщик мусора, о которых мы упоминали ранее, также являются горутинами? Да, если размер горутины слишком велик, а памяти мало, планирование времени выполнения и сборка мусора также будут иметь проблемы.Хотя модель GPM достаточно хороша, Хань Синь приказал солдатам, чем больше, тем лучше, но вы не можете помогите отправить паек (память) солдатам, да? Умной женщине трудно готовить без риса.Если нет памяти, планировщик Go заблокирует горутину.В результате локальная очередь P будет завалена, что приведет к переполнению памяти.Это бесконечный loop... Очень даже вероятно, что программа сразу выйдет из строя.Наслаждайтесь преимуществами параллелизма golang, но результаты перевешивают выгоды.

Кровавый случай, вызванный стандартной библиотекой http

Я думаю, что Gophers, которые являются поклонниками golang, должно быть, использовали его стандартную библиотеку net/http.Многие люди говорят, что использование golang для написания веб-сервера может полностью устранить необходимость в сторонней веб-инфраструктуре и использовать только стандартную сеть/http. библиотека. Напишите высокопроизводительный веб-сервер. Действительно, я также использовал его для написания веб-сервера. Он прост и эффективен, и его производительность довольно хороша. Если нет особых потребностей, как правило, нет необходимости использовать сторонний веб-фреймворк, но мир не свободен.На обед, почему net/http такой быстрый? Чтобы разобраться в этой проблеме, лучше всего начать с исходного кода. Конфуций однажды сказал: Перед исходным кодом это все равно, что бегать голышом. Таким образом, высокое разрешение без кода является большим камнем преткновения для развития программистов.Исходный код - это лестница нашего прогресса, помните!

Далее, давайте взглянем на внутреннюю реализацию net/http.

Исходный код, где net/http получает запрос и начинает обработку, находится вsrc/net/http/server.go, начнем с функции входаListenAndServeИди в:

func (srv *Server) ListenAndServe() error {
    addr := srv.Addr
    if addr == "" {
        addr = ":http"
    }
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

Видишь последний звонок srv.Serve? Правильно, этоServeВнутри метода находится логика, которая фактически обрабатывает http-запрос, а затем мы входим внутрь этого метода:

func (srv *Server) Serve(l net.Listener) error {
    defer l.Close()
    ...
    // 不断循环取出TCP连接
    for {
        // 看我看我!!!
        rw, e := l.Accept()
        ...
        // 再看我再看我!!!
        go c.serve(ctx)
    }
}

Во-первых, параметры этого метода(l net.Listener), это пакет мониторинга TCP, отвечающий за мониторинг сетевых портов,rw, e := l.Accept()Это блокирующая операция, берущая новое TCP-соединение с сетевого порта для обработки и, наконец,go c.serve(ctx)Это логика фактической обработки этого HTTP-запроса в конце. Видите ключевое слово go перед ним? Правильно, здесь запускается новая горутина для выполнения логики обработки, и это в теле бесконечного цикла, значит, каждый раз, когда приходит запрос, он будет открывать горутину для его обработки, что довольно своевольно и грубо. .. А вот Go планировщик индоссаментов вообще не сильно напрягает, однако если, то бишь если ха, вдруг хлынет волна запросов (допустим хакер сделал тысячи бройлерных DDOS тебе, да! Так не повезло!), на в этот раз это очень проблематично.Если он запрашивает 10w, вы ему открываете горутины 10w, а если он запрашивает 100w, то вы должны честно открыть ему 100w.Нагрузка на планирование потоков резко возрастает.Память заполнена, а потом, ты встань на колени...

Зарплата со дна горшка

Если есть проблема, должно быть и решение Итак, как можно уменьшить нагрузку на планирование и память крупномасштабных горутин в системе? Чтобы решить проблему, самое главное - найти первопричину проблемы. В чем первопричина проблемы? Чрезмерное количество горутин приводит к занятию ресурсов.Чтобы решить эту проблему, необходимо ограничить количество запущенных горутин, разумно использовать их повторно и экономить ресурсы.В частности, объединение горутин в пул.

В сценариях сверхбольшого параллелизма неограниченные крупномасштабные горутины могут вызвать резкий скачок памяти, оказывая сильное давление на машину.Падение пропускной способности и скорости обработки второстепенны, и более опасным является то, что программа может аварийно завершить работу. Таким образом, объединение горутин имеет практическое значение.

Во-первых, действительно ли для обработки 100-ваттных задач нужны 100-ваттные горутины? не обязательно! Его также можно обработать с помощью 1w горутин, просто позвольте одной горутине обрабатывать несколько задач.Основное преимущество объединения заключается в повторном использовании горутин. Это, во-первых, значительно снижает нагрузку на среду выполнения для планирования горутин, а во-вторых, снижает потребление памяти.

Есть торговый центр, и 1000 покупателей приходят за покупками, так как же организовать шопинг-гидов для обслуживания этой 1000 человек? Есть два варианта:

Во-первых, я нанял 1000 гидов по магазинам, чтобы оказывать индивидуальные услуги. Это, конечно, наиболее эффективно, но слишком расточительно для ресурсов. Стоимость найма 1000 человек чрезвычайно высока, а управление сложно. Их можно перечислить. сначала, но каждый раз, когда покупатель идет в торговый центр, чтобы купить что-то, он не покупает это сразу, как правило, он должен ходить по магазинам и выбирать, то есть он должен тратить время на выбор, и 1000 гидов по магазинам наблюдение один на один, что крайне неэффективно, это приводит к первым двум вариантам: я нанимаю только 10 гидов по магазинам, и я дежурю в торговом центре Когда есть покупатель, которому нужна консультация, я звоню в магазин руководство, чтобы справиться с этим. Руководство по покупкам вернется после обработки и подождет, пока следующему покупателю не потребуется консультация. Так что туда и обратно...

Второй вариант звучит знакомо? Да, основная идея состоит в том, чтобы имитировать мультиплексирование ввода/вывода.С помощью механизма можно отслеживать несколько дескрипторов.Как только дескриптор готов (обычно готов к чтению или записи), программа может быть уведомлена о соответствующем ответе.чтение и запись операции. Что касается мультиплексирования, то оно выходит за рамки данной статьи, поэтому повторяться не будем.Мультиплексирование ввода/вывода.

Первое решение принято стандартной библиотекой net/http: открыть горутину для запроса; второе решение — пул горутины (мультиплексирование ввода/вывода).

Реализовать пул горутин

Из-за некоторых из перечисленных выше проблем, которые могут возникнуть из-за чрезмерного размера горутин, необходимы решения для решения этих проблем.Как было проанализировано выше, объединение горутин является эффективным решением.На основе этого может быть реализована горутина. Создание пула, повторное использование горутины, снижение нагрузки на планирование времени выполнения и снижение нагрузки на память.Опираясь на эти оптимизации, производительность параллелизма может быть значительно улучшена в сценарии крупномасштабного параллелизма горутины.

Мой Бог! После стольких разговоров я, наконец, перешел к сути.Далее я объясню, как реализовать высокопроизводительный пул горутин, убить нативные параллельные горутины за секунды и повысить производительность параллельных программ с точки зрения скорости выполнения и использования памяти. Что ж, без дальнейших церемоний, давайте начнем~~притворный~~анализ.

Идеи дизайна

Идея реализации Goroutine Pool примерно такова:

При запуске службы сначала инициализируйте пул Goroutine.Этот пул поддерживает подобную стеку очередь FILO, в которой хранится рабочий процесс, ответственный за обработку задач, а затем отправляет задачу в пул на стороне клиента.Внутри пула ядро ​​после получение задачи получено Операция такова:
  1. Проверяем, есть ли простаивающие воркеры в текущей очереди воркеров, если есть, вынимаем и выполняем текущую задачу;
  2. Если нет простаивающего воркера, определить, не превысил ли текущий работающий воркер емкость пула, да - заблокировать и дождаться, пока воркер будет снова помещен в пул, нет - открыть новый воркер (горутин) для обработки;
  3. После того, как каждый рабочий процесс завершает свою задачу, он возвращается в очередь пула для ожидания.

Процесс планирования выглядит следующим образом:

В соответствии с этой дизайнерской идеей я реализовал высокопроизводительный пул горутин, который может лучше решить вышеупомянутые проблемы крупномасштабного планирования и использования ресурсов.По сравнению с параллелизмом нативных горутин с точки зрения скорости выполнения и использования памяти, он имеет очевидные преимущества, особенно это использование памяти.Из-за мультиплексирования он позволяет избежать недостатков запуска крупномасштабных горутин без мозга и может сэкономить много памяти.

Полный код проекта доступен на моем github:портал, комментарии и обмен также приветствуются.

детали реализации

Принцип проектирования Goroutine Pool обсуждался ранее. Я считаю, что каждый может понять весь процесс планирования. Однако есть старая поговорка о том, что пустые разговоры навредят стране, а тяжелая работа принесет процветание стране. определенно будет много конкретных реализаций.Подробности и трудности.Далее мы проведем вас через принцип пула горутин, проанализировав несколько основных реализаций этого пула горутин и их связь.

прежде всегоPool struct:

type sig struct{}

type f func() error

// Pool accept the tasks from client,it limits the total
// of goroutines to a given number by recycling goroutines.
type Pool struct {
    // capacity of the pool.
    capacity int32

    // running is the number of the currently running goroutines.
    running int32

    // freeSignal is used to notice pool there are available
    // workers which can be sent to work.
    freeSignal chan sig

    // workers is a slice that store the available workers.
    workers []*Worker

    // release is used to notice the pool to closed itself.
    release chan sig

    // lock for synchronous operation
    lock sync.Mutex

    once sync.Once
}

PoolЭто общий пул сопрограмм, который поддерживает разные типы задач, то есть каждая задача привязывается к функции и отправляется в пул, а разные типы задач выполняются пакетами. другой Пул сопрограмм — пул сопрограмм, который выполняет аналогичные задачи в пакетном режиме.PoolWithFunc,КаждыйPoolWithFuncБудет привязана только одна функция задачиpf, этот пул подходит для сценариев с большими пакетами одних и тех же задач, поскольку каждый пул привязан только к одной функции задачи, поэтомуPoolWithFuncПо сравнению сPoolЭто сэкономит больше памяти, но универсальность не так хороша, как первая.Чтобы все могли лучше понять принцип работы пула сопрограмм, здесь мы используем общийPoolанализировать.

capacity— вместимость пула, то есть верхний предел количества открытых воркеров, каждый воркер привязан к горутине;runningколичество рабочих, выполняющих задачи в данный момент;freeSignalЭто сигнал, потому что существует верхний предел количества воркеров, открытых Пулом, поэтому, когда все воркеры выполняют задачи, новые входящие запросы нужно блокировать и ждать, когда воркеры, выполнившие задачи, ставятся обратно в Пул, как уведомить о блокировке Запрос привязан к простоящему воркеру для запуска?freeSignalсделать это;workersЭто слайс, используемый для хранения простаивающих воркеров, после того как запрос попадет в пул, он будет проверен в первую очередь.workersЕсть ли простаивающие воркеры, если есть, вынести связанную задачу на выполнение, иначе судить, достиг ли текущий работающий воркер верхнего предела мощности, да - заблокировать ожидание, нет - открыть новый воркер для выполнения задачи;releaseЭто когда поддержка пула закрывается, чтобы уведомить всех рабочих о прекращении работы, чтобы предотвратить утечку горутины;lockБлокировка для поддержки операции синхронизации пула;onceИспользуется для обеспечения того, чтобы операция закрытия пула выполнялась только один раз.

Отправляйте задачи в пул

p.Submit(task f)следующее:

// Submit submit a task to pool
func (p *Pool) Submit(task f) error {
    if len(p.release) > 0 {
        return ErrPoolClosed
    }
    w := p.getWorker()
    w.sendTask(task)
    return nil
}

Первый if оценивает, закрыт ли текущий пул, если да, то он больше не будет принимать новые задачи, в противном случае получить воркер, доступный в пуле, и привязатьtaskвоплощать в жизнь.

Получить доступных рабочих (ядра)

p.getWorker()Исходный код:

// getWorker returns a available worker to run the tasks.
func (p *Pool) getWorker() *Worker {
    var w *Worker
    // 标志,表示当前运行的worker数量是否已达容量上限
    waiting := false

    // 涉及从workers队列取可用worker,需要加锁
    p.lock.Lock()
    workers := p.workers
    n := len(workers) - 1
    // 当前worker队列为空(无空闲worker)
    if n < 0 {
        // 运行worker数目已达到该Pool的容量上限,置等待标志
        if p.running >= p.capacity {
            waiting = true
        // 否则,运行数目加1
        } else {
            p.running++
        }
    // 有空闲worker,从队列尾部取出一个使用
    } else {
        w = workers[n]
        workers[n] = nil
        p.workers = workers[:n]
    }
    p.lock.Unlock()

    // 阻塞等待直到有空闲worker
    if waiting {
        // 队列有空闲worker通知信号
        <-p.freeSignal
        for {
            p.lock.Lock()
            workers = p.workers
            l := len(workers) - 1
            if l < 0 {
                p.lock.Unlock()
                continue
            }
            w = workers[l]
            workers[l] = nil
            p.workers = workers[:l]
            p.lock.Unlock()
            break
        }
    // 当前无空闲worker但是Pool还没有满,
    // 则可以直接新开一个worker执行任务
    } else if w == nil {
        w = &Worker{
            pool: p,
            task: make(chan f),
        }
        w.run()
    }
    return w
}

К приведенному выше исходному коду добавлены более подробные комментарии. В сочетании с предыдущими идеями дизайна я считаю, что каждый должен быть в состоянии понять основную операцию получения доступных задач связывания рабочих для выполнения этого пула сопрограмм. Вот основное внимание: после достижение предела емкости пула, доп.Запрос задачи нужно заблокировать и дождаться простаивающего воркера.Это для предотвращения неконтролируемого создания горутин.На самом деле планировщик Go имеет механизм мультиплексирования.goКогда ключевое слово используется, оно проверяет, есть ли доступная структура G в P в текущей структуре M. Если есть, берем прямо из него, иначе нужно выделить новую структуру G. Если выделяется новый G, его необходимо связать с соответствующей очередью времени выполнения, но планировщик не ограничивает количество горутин.В сценарии мгновенного взрыва горутины может быть слишком поздно повторно использовать G и все же создавать большое количество горутин, поэтомуantsПомимо мультиплексирования, он также ограничивает количество горутин.

Другие части можно понять по примечаниям, и здесь они повторяться не будут.

выполнение задачи

// Worker is the actual executor who runs the tasks,
// it starts a goroutine that accepts tasks and
// performs function calls.
type Worker struct {
    // pool who owns this worker.
    pool *Pool

    // task is a job should be done.
    task chan f
}

// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *Worker) run() {
    //atomic.AddInt32(&w.pool.running, 1)
    go func() {
        // 监听任务列表,一旦有任务立马取出运行
        for f := range w.task {
            if f == nil {
                atomic.AddInt32(&w.pool.running, -1)
                return
            }
            f()
            
            // 回收复用
            w.pool.putWorker(w)
        }
    }()
}

// stop this worker.
func (w *Worker) stop() {
    w.sendTask(nil)
}

// sendTask sends a task to this worker.
func (w *Worker) sendTask(task f) {
    w.task <- task
}

Повторное использование рабочего процесса (повторное использование горутины)

// putWorker puts a worker back into free pool, recycling the goroutines.
func (p *Pool) putWorker(worker *Worker) {
    p.lock.Lock()
    p.workers = append(p.workers, worker)
    p.lock.Unlock()
    p.freeSignal <- sig{}
}

в сочетании с предыдущимp.Submit(task f)иp.getWorker(), после отправки задачи в Пул получить доступного воркера, который нужно вызывать каждый раз при создании нового экземпляра воркераw.run()Запустите горутину для прослушивания списка задач работникаtask, она будет выполнена сразу после отправки задачи, поэтому при вызове рабочегоsendTask(task f)После того, как метод отправит задачу в очередь задач воркера, она может быть получена и выполнена немедленно.Когда задача будет выполнена, она вызоветw.pool.putWorker(w *Worker)Метод отвязывает работника, выполнившего задачу, от текущей задачи и помещает его обратно в Пул для использования следующей задачей.На этом этапе процесс задачи от отправки до завершения заканчивается, и планирование Пула переходит следующий цикл.

В итоге,antsПроцесс планирования пула горутин показан следующим образом:

пасхальные яйца

Помните, прежде чем я сказал, что в дополнение к общемуPool structКроме того, этот проект также реализуетPoolWithFunc struct— пул сопрограмм, который выполняет пакеты похожих задач,PoolWithFuncПо сравнению сPool, так как пул привязан только к одной функции задачи, что устраняет необходимость передачи функции задачи для каждой задачи, поэтому его преимущество в производительности по сравнению сPoolБолее очевидно, здесь мы немного поговорим о деталях пула сопрограмм, привязанного только к одной функции задачи:

Код вверх!

type pf func(interface{}) error

// PoolWithFunc accept the tasks from client,it limits the total
// of goroutines to a given number by recycling goroutines.
type PoolWithFunc struct {
    // capacity of the pool.
    capacity int32

    // running is the number of the currently running goroutines.
    running int32

    // freeSignal is used to notice pool there are available
    // workers which can be sent to work.
    freeSignal chan sig

    // workers is a slice that store the available workers.
    workers []*WorkerWithFunc

    // release is used to notice the pool to closed itself.
    release chan sig

    // lock for synchronous operation
    lock sync.Mutex

    // pf is the function for processing tasks
    poolFunc pf

    once sync.Once
}

PoolWithFunc structбольшинство полей в иPool structВ основном то же самое, сосредоточьтесь наpoolFunc pf, это тип функции, то есть указанная функция задачи, привязанная к Пулу, и данные, переданные клиентом в этот тип Пула, уже не являются функцией задачиtask f, ноpoolFunc pfЗатем формальные параметры функции задачи передаютсяWorkerWithFuncиметь дело с:

// WorkerWithFunc is the actual executor who runs the tasks,
// it starts a goroutine that accepts tasks and
// performs function calls.
type WorkerWithFunc struct {
	// pool who owns this worker.
	pool *PoolWithFunc

	// args is a job should be done.
	args chan interface{}
}

// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *WorkerWithFunc) run() {
	//atomic.AddInt32(&w.pool.running, 1)
	go func() {
		for args := range w.args {
			if args == nil {
				atomic.AddInt32(&w.pool.running, -1)
				return
			}
			w.pool.poolFunc(args)
			w.pool.putWorker(w)
		}
	}()
}

Исходный код выше можно увидетьWorkerWithFuncаналогичныйWorkerСтруктура функции, но очередь параметров функции отслеживается, каждый раз, когда принимается пакет параметров, он вызывается напрямую.PoolWithFuncФункция задачи привязкиpoolFunc pfФункция задачи выполняет задачу, а следующий процесс такой же, какWorkerЭто последовательно После выполнения задачи поместите работника обратно в пул сопрограмм и дождитесь следующего использования.

Что касается другой логики, такой как submittask,ПолучатьWorkerЗадачи привязки и т. д. в основном повторно используются изPool struct, конкретные детали немного отличаются, но принцип тот же, и изменения те же Заинтересованные студенты могут посмотреть мой исходный код на github: Goroutine Pool Coroutine Poolants.

Benchmarks

Пул горутин, который так долго был взорван, полностью виртуален.Теоретически объединение в пул может повторно использовать горутины, повышать производительность и экономить память.Пока нет данных бенчмарков, кажется, что это не может быть убедительно! Поэтому в этой главе будет проведен реальный тест, чтобы проверить, действительно ли производительность пула горутин лучше, чем у собственного параллелизма горутин в сценарии крупномасштабного параллелизма горутин!

Параметры тестовой машины:

OS : macOS High Sierra
Processor : 2.7 GHz Intel Core i5
Memory : 8 GB 1867 MHz DDR3

Тест пула

Портал тестового кода

Результаты теста:

Здесь, чтобы смоделировать крупномасштабный сценарий горутины, время параллелизма двух тестов составляет 100 Вт и 1000 Вт соответственно.Первые два теста должны выполнять одновременные задачи по 100 Вт без использования пула и с использованиемantsПроизводительность пула Goroutine, последние две — это производительность под задачами 1000 Вт, интуитивно видно, что с точки зрения скорости выполнения и использования памяти,antsБассейны имеют очевидные преимущества. Объем задачи 100 Вт, использованиеants, скорость выполнения сравнима с нативной горутиной или даже немного выше, но для выполнения всех задач реально используется горутина менее 5w, а потребление памяти составляет всего 40% от нативного параллелизма; когда объем задачи достигает 1000w, преимущества еще более очевидны: для выполнения всех задач горутине требуется около 70 Вт, а скорость выполнения на 100% выше, чем у нативной горутины, а потребление памяти остается примерно на 40% от того, что без использования пула.

Тест PoolWithFunc

Портал тестового кода

Результаты теста:

- Формат Benchmarkxxx-4基准测试函数名-GOMAXPROCS, последний -4 представляет соответствующее количество ядер ЦП при выполнении тестовой функции.
- 1 за количество исполнений
- xx ns/op означает каждое время выполнения
- xx B/op представляет собой общее количество байтов, выделенных для выполнения (потребление памяти)
- xx allocs/op указывает, сколько выделений памяти произошло за одно выполнение

так какPoolWithFuncЭтот пул связывает только одну функцию задачи, то есть все задачи выполняют одну и ту же функцию, поэтому по сравнению сPoolПреимущество нативных горутин в скорости выполнения и потреблении памяти еще больше.Из вышеприведенных результатов видно, что скорость выполнения может достигать 300% от нативных горутин, а преимущество в потреблении памяти достигло двузначного разрыва. нативных горутин Достигнутоants35 раз и количество выделений памяти на выполнение нативной горутины также достиглоants45 раз, объем задачи 1000 Вт,antsНачальная мощность распределения составляет 5 Вт, поэтому он выполняет все задачи и по-прежнему использует только горутины 5 Вт! По факту,antsГорутина Емкость пула можно настраивать, что означает, что пользователи могут настраивать этот параметр в соответствии с различными сценариями, пока не будет достигнута максимальная производительность.

Тест пропускной способности

После выхода вышеуказанных бенчмарков мое сердце было таким:

Но это было слишком гладко, чтобы заставить меня задуматься, потому что, учитывая мою тяжелую жизнь в последние 20 лет или около того, все не должно быть так хорошо, и, конечно же, подумайте об этом хорошенько, хотяantsСкорость выполнения и потребление памяти Grooutine Pool при крупномасштабном параллелизме имеют очевидные преимущества по сравнению с нативными горутинами, но я думаю, вы заметили это в предыдущей тестовой демонстрации, в которой используется WaitGroup, инструмент для синхронизации горутин, поэтому приведенные выше тесты Основной процесс будет ждать, пока все подпрограммы завершат задачу, прежде чем завершить тест производительности.Однако сколько существует сценариев, когда одной машине нужно выполнять 100 Вт или даже 1000 Вт задач синхронизации? В принципе никакой! В результате был создан нож для убийства драконов, но драконов в мире нет! тоже безжалостный...

В то время мое сердце стало таким:


к счастью,antsС точки зрения синхронных пакетных задач это немного высоко и низко, но если это сценарий асинхронного пакетного задания, это полезно, то есть в случае, когда большое количество задач не нужно ждать завершения синхронно, вы можете проверить снова.antsПо сравнению с производительностью параллелизма нативной горутины, сравнение производительности в это время также является сравнением пропускной способности, то есть когда поступает такое же крупномасштабное количество запросов,antsКто может "проглотить" эти запросы с большей скоростью и меньшим объемом памяти с родной горутиной.

Портал тестового кода

Результаты теста:

пропускная способность 10 Вт

пропускная способность 100 Вт

пропускная способность 1000 Вт

Поскольку родная горутина достигла предела при тестировании пропускной способности 1000 Вт на моем компьютере, программа напрямую тормозила компьютер и не могла нормально протестировать, поэтому тестовые данные пропускной способности 1000 Вт толькоantsБассейн.

Из сравнения пропускной способности демонстрационного теста видно, что использованиеantsПо сравнению с нативной горутиной производительность пропускной способности может поддерживаться в 2–6 раз ниже, а потребление памяти может быть снижено в 10–20 раз.

Суммировать

На данный момент разработка высокопроизводительного пула Goroutine завершена. На самом деле, принцип понять несложно. В общем, это «повторное использование». Конкретная реализация деталей кода заключается в использовании блокировки синхронизация, атомарные операции, канальная связь и другие методы.antВесь этот проект не зависит от какой-либо сторонней библиотеки и использует стандартную библиотеку golang для выполнения всех функций, потому что библиотека собственного языка golang сама по себе достаточно хороша, много раз при разработке проектов golang она может поддерживать легкий вес и высокая производительность, необязательно для всего использовать сторонние библиотеки.

оantsЗначение , по сути, также упоминалось ранее,antsЕсть очевидные преимущества в производительности при крупномасштабной асинхронной и синхронной пакетной обработке задач (особенно асинхронных пакетных задач), в то время как обработка десятков миллионов синхронных пакетных задач на одной машине не имеет большого практического значения, но у асинхронной пакетной обработки есть преимущества. обработка задач, Он имеет большую прикладную ценность, поэтому лично я считаю, что реальная ценность Goroutine Pool по-прежнему заключается в:

  1. Ограничьте количество одновременных горутин;
  2. Повторно используйте горутины, чтобы снизить нагрузку на планирование во время выполнения и повысить производительность программы;
  3. Избегайте чрезмерного использования горутинами системных ресурсов (ЦП и памяти).

постскриптум

Среди трех первоначальных создателей языка Go — Роба Пайка, Роберта Гриземера и Кена Томпсона, Роберт Гриземер участвовал в разработке виртуальной машины HotSpot для Java и движка JavaScript V8 для Chrome.Роб Пайк уже много лет работает в знаменитой лаборатории Bell. в разработке и реализации операционной системы Plan9, компилятора C и многоязычных компиляторов.Кен Томпсон — лауреат премии Тьюринга, отец Unix и отец языка C. Эти трое — ветераны компьютерной истории, особенно Кен Томпсон. , является древним богом, создавшим компьютерное поле Unix и языка C, поэтому философия дизайна языка Go имеет глубокую марку Unix: простота, модульность, ортогональность, комбинация, конвейер, короткие и целенаправленные функции и т. д.; Это также причина почему люди предпочитают лаконичную и эффективную модель программирования Go.

Эта статья представляет собой относительно полное представление о прошлом и настоящем всей модели параллелизма языка Go, от трех основных моделей потоков до параллельного планировщика Go и пользовательского пула горутин. Критикуемый режим обработки ошибок, отсутствие универсальной поддержки, неудовлетворительное управление пакетами, чрезмерная абстракция объектно-ориентированного режима и т. д. На самом деле ни один язык программирования не осмеливается сказать, что он совершенен. позиционирование сцены и языка бессмысленно, а позиционирование Go было языком системного программирования и языком программирования облачных вычислений с момента его дебюта (это немного расплывчато), и авторы Go всегда настаивали на использовании простейшей и абстрактной инженерии. Дизайн выполняет самые сложные функции, поэтому, если вы посмотрите на модель параллелизма Go с этого уровня, вы увидите, что помимо внедрения модели GPM P , существует не так много инновационных оригинальных теорий, двухуровневая модель потоков является зрелой теорией, а упреждающее планирование не является новой моделью планирования.Перейти в Google: языковой дизайн для разработки программного обеспеченияИ спроектированный, Go на самом деле сочетает в себе эти классические теории и технологии элегантным и эффективным инженерным способом и открывает его для пользователей с помощью простого и абстрактного API или синтаксического сахара.Go стремится найти высокую производительность и разработку. эффективности, пока что она далека от совершенства, но достаточно хороша. Кроме того, Go представляет канал и горутину для совместной работы, создавая параллельный режим программирования, который отличается от блокировок и атомарных операций. — CSP предлагает язык Go, который сильно вдохновляет разработчиков на размышления о шаблонах параллельного программирования.

Из анализа планировщика Go в этой статье иantsВ процессе проектирования и реализации Goroutine Pool я разобрал и оптимизировал модель параллелизма Go.antsРеализация кода в книге также позволила более подробно изучить использование синхронизации блокировок, атомарных операций и канального взаимодействия.Я надеюсь, что Gophers будет полезно понять модель параллелизма языка Go и параллельное программирование.

Спасибо за чтение.

Ссылаться на

Я демон, жизнь такая длинная, будь интересным человеком и пиши интересные слова. Эта статья была впервые опубликована в моем личном публичном аккаунте - Aolai Sanshao, прошу обратить внимание, код, история, душа, там все есть.

обо мне