Абсолютно некоторые вещи!

Опубликовать 3 из серии на ходу

В предыдущем сообщении блога мы рассмотрели, как создать неограниченный канал, при котором записи в канал никогда не будут блокироваться. Он был построен на основе двух небуферизованных каналов, горутины, среза и пары замыканий.

Заметно отсутствовал буферизованный канал. Буферизованные каналы никогда не имеют неограниченного количества буферов. Правильное использование буферизованного канала означает, что вы должны обрабатывать случай, когда буфер заполнен и ваши записывающие блоки горутины ждут чтения горутины. Итак, как правильно использовать буферизованный канал? Почему они были включены в дизайн Go?

Случай для буферизованных каналов тонкий. Подводя итог в одном предложении:

Буферизованные каналы полезны, когда вы знаете, сколько горутин вы запустили, хотите ограничить количество запускаемых горутин или хотите ограничить объем работы, стоящей в очереди.

Буферизованные каналы отлично работают, когда вы хотите либо собрать данные из набора запущенных вами горутин, либо когда вы хотите ограничить одновременное использование. Они также полезны для управления объемом работы, поставленной системой в очередь, предотвращая отставание ваших служб и их перегрузку. Вот несколько примеров, показывающих, как их можно использовать.

Параллельная обработка

Один из распространенных паттернов горутин - это разветвление. Если вы хотите применить одни и те же данные к нескольким алгоритмам, вы можете запустить горутину для каждой подзадачи, а затем снова собрать данные, когда они будут выполнены. Например, вы можете обработать одни и те же данные с помощью нескольких алгоритмов оценки и вернуть обратно все оценки или извлечь данные из нескольких микросервисов для создания одной страницы. Буферизованный канал - идеальный способ собрать данные из ваших подзадач.

Как отмечалось в предыдущем сообщении блога, Go не имеет универсальных шаблонов, поэтому мы собираемся использовать interface {} в качестве типа заполнителя.

Код прост. Сначала мы определяем новый тип, Evaluator, который представляет собой функцию, которая принимает значение и возвращает значение и ошибку. В этом нет необходимости, но это упрощает понимание кода.

Наша функция DivideAndConquer принимает часть оценщиков и данные, которые будут отправлены каждому из них. Мы создаем два буферизованных канала, один для хранения действительных ответов, а другой для хранения ошибок. Затем мы запускаем новую горутину для каждого оценщика.

(обратите внимание, что мы передаем в v горутину, чтобы каждая горутина ссылалась на другой Evaluator; обычная проблема при запуске горутины в afor заключается в использовании ключа или значения из объявления for внутри горутин; это приведет к тому, что одно и то же значение будет передано всем горутинам!)

В горутине мы запускаем Evaluator и записываем результат или ошибку в соответствующий канал.

После запуска всех горутин мы настраиваем срезы out и errs для хранения результатов, цикл len (оценщики) раз, чтобы извлечь результат или ошибку из каждой горутины, а затем вернуть out и ошибки, когда мы закончим.

Используя буферизованные каналы для возврата значений и ошибок, мы гарантируем, что ни одна из горутин не будет приостановлена ​​в ожидании, пока основная задача прочитает их вывод. Буфер позволяет горутинам писать и затем выходить. Также интересно то, что использование буферизованных каналов устраняет необходимость в каких-либо дополнительных инструментах синхронизации. Мы знаем, что получим точно len (оценщики) по обоим каналам, поэтому все, что нам нужно сделать, это дождаться, пока значение будет записано на любом из каналов. и положите на соответствующий ломтик. Когда все оценщики выполнены, функция возвращается.

Этот простой тестовый пример демонстрирует, как можно использовать DivideAndConquer:

Результаты возвращаются в случайном порядке, чего и следовало ожидать, если запустить их параллельно:

Куда уходит время?

Наш параллельный процессор делает то, что должен делать, но у него есть одно очевидное ограничение. Что, если вы не хотите бесконечно ждать завершения всех подзадач? Давайте рассмотрим случай, когда мы собираем данные из нескольких микросервисов. Если один микросервис работает медленно или (что еще хуже) зависает, мы не хотим останавливать интерфейс в ожидании ответа. Даже если мы просто запускаем алгоритмы подсчета очков, в одном из них может быть ошибка, которая вызывает медленное поведение или бесконечный цикл. Лучше ограничить каждую подзадачу определенным временем и возвращать ошибку для тех, которые занимают слишком много времени.

Чтобы реализовать эту новую функцию, мы собираемся немного усложнить наш тип Evaluator. Вместо того, чтобы быть просто названием для afunc (interface {}) (interface {}, error), теперь это интерфейс с двумя методами, Оцените и имя:

Мы переместили функцию в другой тип, EvaluatorFunc, который реализует интерфейс Evaluator. Нам нужен метод Name, чтобы мы могли сообщить, какая подзадача не выполняется. EvaluatorFunc использует уловку в Go, чтобы получить имя функции с учетом переменной, которая на нее ссылается. Если имя, возвращаемое с помощью r untime.Func.Name (), вам не по вкусу, вы можете встроить EvaluatorFunc в структуре, чтобы настроить ее:

Вот наша обновленная функция DivideAndConquer:

Хотя код для сбора результатов остается прежним, логика горутины становится немного сложнее. Каждая горутина теперь создает два буферизованных канала размера 1, ch и ech, и запускает вторую горутину. Вторая горутина запускает Evaluator.Evaluate и записывает в ch (в случае успеха) или в ech (в случае возврата ошибки).

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

Чтобы воспользоваться преимуществом time.After, нам нужно запустить вторую горутину и общаться с ней по каналам. Но не совсем очевидно, почему каналы ch и ech буферизуются. Почему бы просто не использовать небуферизованный канал? Ответ в том, что мы не хотим утечки каких-либо горутин. Хотя среда выполнения Go способна обрабатывать тысячи или сотни тысяч горутин за раз, каждая горутина использует некоторые ресурсы, поэтому вы не хотите оставлять их без дела, когда в этом нет необходимости. Если вы это сделаете, долгоиграющая программа Go начнет плохо работать.

Помните, что небуферизованный канал приостанавливает выполнение горутины до тех пор, пока не будет прочитана другая горутина. Если тайм-аут срабатывает до завершения выполнения Evaluator, чтение никогда не произойдет, потому что единственное место, где эти каналы считываются, - это оператор select внешней горутины, а внешняя горутина завершилась. после срабатывания таймаута. Это означает, что использование небуферизованного канала приведет к тому, что внутренняя горутина будет ждать вечно всякий раз, когда наступит тайм-аут, что приведет к утечке горутины. Опять же, буферизованный канал оказывается полезным, потому что мы точно знаем, сколько записей мы можем ожидать.

Вот простой тест, демонстрирующий тайм-ауты:

Это возвращает результат:

Создание пула

Другая ситуация, когда буферизованные каналы полезны, - это создание пула объектов. Если у вас есть объекты, создание которых требует больших затрат (например, большие буферы памяти) или где вы хотите ограничить одновременное выполнение (например, не перегружать службу слишком большим количеством запросов), следует использовать пул.

Идея здесь состоит в том, чтобы использовать буферизованный канал в качестве пула. Функция NewPool принимает Factory, который заполняет пул идентичными по количеству элементов (если элементы не t функционально идентичны, то поведение клиентов вашего пула будет зависеть от того, какой элемент они получат, что является плохой идеей). Когда вызывается метод Borrow, значение считывается из канала и возвращается. Если все элементы в пуле были использованы, чтение канала будет заблокировано до тех пор, пока значение не будет возвращено в пул с помощью метода Return.

Если вы не хотите ждать вечно (а в производственном коде вы, вероятно, никогда не захотите бесконечно ждать, чтобы что-то произошло), BorrowWithTimeout позволяет указать время. Продолжительность ожидания возврата элемента в пул. Не ожидая вечно, вы предоставляете своим клиентским процессам ценную информацию о том, что ваш текущий процесс выполняет слишком много работы. Это называется противодавлением, и это очень важный инструмент при построении масштабируемых систем. Противодавление позволяет узнать, что одна часть вашей системы находится под слишком большой нагрузкой или вызывается слишком часто. Когда вы видите увеличение нагрузки, один из лучших способов предотвратить полный сбой - просто отказаться от постановки в очередь работы, которую вы не сможете обработать в разумные сроки.

Бассейн работает так, как вы ожидаете:

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

Устранение утечки в бассейне

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

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

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

Переводя это на Go, мы не собираемся возвращать значения из пула через Borrow и надеемся, что они вернутся в Return ; мы собираемся взять закрытие и запустить его.

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

Как видите, буферизованные каналы действительно имеют свое место в системах, которые предназначены для разумной обработки нагрузки. Они могут помочь вам управлять нагрузкой в ​​ваших системах, что, в свою очередь, поможет вам поддерживать работу ваших сервисов.

ЗАЯВЛЕНИЕ О РАСКРЫТИИ ИНФОРМАЦИИ: это мнение автора. Если в этом посте не указано иное, Capital One не связан и не одобрен ни одной из упомянутых компаний. Все используемые или отображаемые товарные знаки и другая интеллектуальная собственность являются собственностью соответствующих владельцев. Эта статья принадлежит © Capital One, 2017 г.

Дополнительные ссылки