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

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

Понимание схемы

Мы начали этот пост с демонстрации высокоуровневой схемы реализации пула рабочих в Go. Он возобновляет основные операции, которые должны произойти в любой реализации пула рабочих:

  • «Выделить» ресурсы для обработки
  • «Работать» (или обрабатывать) эти ресурсы
  • и «Собрать» результаты для дальнейшей постобработки.

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

Давайте посмотрим на каждый компонент более подробно в увеличенном масштабе:

Основная GoRoutine - это нормальное выполнение программы, в этом случае наша программа мало что делает конкретно, так как мы фокусируемся на рабочем пуле, единственная конкретная задача, которую нужно выполнить, - это создание и управление другими GoRoutines, связанными с правильным выполнением. рабочего пула

Канал Готово (done chan bool) используется в качестве основного элемента управления программой, который указывает, что все задачи были выполнены и все goRoutines завершены. В отличие от конструкции основного цикла, основной GoRoutine ожидает отправки сигнала по этому каналу для остановки и завершения программы. Почему мы используем канал для управления выполнением программы и ждем, пока все goRoutines не ответят: простота

В предыдущем примере наша программа ожидает продолжения выполнения, пока переменная канала «done» (<-m.done) не получит значение. Это делает код меньше и легче читается.

Распределение ресурсов

«Выделить» goRoutine - это подпрограмма, которая порождается из основной goRoutine, ее цель - выделить ресурсы, которые будут использоваться пулом рабочих. У него есть основной цикл с ограниченным временем жизни: до тех пор, пока не будут выделены все ресурсы. Чтобы лучше понять это, давайте взглянем на следующую диаграмму:

«Пока есть ресурсы для выделения, goRoutine будет продолжать свою работу» - это выражение, которое лучше описывает предыдущую диаграмму. Выполнение выделения получает массив ресурсов неизвестного размера и выполняет итерацию по массиву, преобразуя его в структуру «Job», которая может быть позже обработана другим goRountine. Ресурсы, преобразованные в «Задания», отправляются в буферизованный канал «Задания», который имеет ограниченный размер / размер. Вы могли заметить, что в этом примере массив ресурсов больше, чем элементы, которые могут быть буферизованы / отправлены в канале, и это намеренно задумано как он контролирует количество параллельных заданий, которые могут выполняться одновременно. . Вот и все, если канал «Задания» заполнен, итерация по ресурсам остановится до тех пор, пока не сможет снова распределить задания в канале заданий.

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

Обработка канала заданий (задания, которые необходимо выполнить)

Рабочий пул goRoutine существование определяется каналом Задания: пока есть задания, над которыми нужно работать и обрабатывать, продолжайте это делать. Продолжая разделение проблем, эта цель goRoutine - это фактическая обработка заданий, и, как правило, она работает одновременно и параллельно. Это самый важный раздел нашего обсуждения, поскольку он охватывает создание и назначение рабочих групп.

В этом случае goRoutine рабочего пула порождает новые рабочие (или рабочие) goRoutines, количество которых необходимо создать, определяется конфигурацией или вводится в наше решение, а пока давайте определим 3 как количество рабочих в бассейн. Один ключевой фактор любого рабочего пула (или пула потоков) - это контролировать размер пула, если вы этого не сделаете, есть вероятность, что аппаратные ресурсы будут исчерпаны или даже ваша программа зашел в тупик.

Мы собираемся упростить наше решение, используя Sync.WaitGroup, которая по сути является серверами в качестве структуры, которую мы можем запросить в любое время, чтобы увидеть, все ли goRoutines завершены. Подобно нашему Done каналу, пакет WaitGroup предоставляет метод Wait, который останавливает выполнение до тех пор, пока не будет получено значение. Код выглядит примерно так:

Как вы могли заметить, синхронизация - это просто атомарный счетчик, который мы увеличиваем (Add), а затем уменьшаем (Done) внутри каждой подпрограммы. Затем workerPool останавливает выполнение до тех пор, пока не будет выполнено условие wg.Wait().

Теперь давайте посмотрим на фактическое выполнение каждой «работы» (или работника), чтобы лучше понять всю синхронизацию и фактическую обработку заданий.

В goRoutine WokerPool мы видели, как мы порождаем новые рабочие goRoutine внутри цикла for, вызывая go m.work, который создаст новые goRoutine (ы).

Внутри этого нового goRoutine находится фактическая обработка заданий и назначение результатов, для этого каждая новая «работа» goRoutine будет выполнять for range jobs цикл по каналу «Задания». Результатом этого является то, что каждая рабочая процедура будет захватывать уникальное задание из-за реализации каналов в Go, которые используют блокировки и гарантируют, что только один элемент в канале может быть захвачен goRoutine, это также определяет время жизни «рабочей» goRoutine: пока есть работа в канале Jobs, продолжайте итерацию. Этот метод обеспечивает лучшее чтение и понимание кода, который скрывает весь механизм блокировки и разблокировки общей структуры между процессами.

Выбрав задание из канала Задания, мы обрабатываем его и создаем объект Результат, который затем отправляется в канал Результаты. Это продолжение нашего принятого дизайна Разделение проблем, время жизни рабочей goRoutine определяется количеством времени, которое требуется для обработки Задания, и результата, отправленного в Канал Результаты, он будет повторяться снова по каналу Задания, если он еще не закрыт.

Предыдущий код представляет эту главу, как вы можете видеть, переменная wg (sync.WaitGroup) передается как ссылка на метод, поэтому, когда мы закончим обработку всех «заданий», мы можем вызвать wg.Done(), давая родительскому goRoutine (workerPool) знать, что этот конкретный goRoutine завершен.

Дополнительный контент: фактическая обработка задания не происходит нигде в опубликованном нами коде, потому что мы используем Внедрение зависимостей специально с помощью подпись функции ProcessorFunc в качестве аргумента для рабочего пула, а затем для работы goRoutine, это изолирует фактическую реализацию рабочего пула, а обработка работы выполняется на другом уровне, что упрощает повторное использование.

Получение результатов

Итак, что мы должны делать, когда результаты отправляются в каналы «результатов»? Ответ прост: соберите, обработайте и делегируйте результат.

В нашем буферизованном канале «Результаты» хранятся все результаты от всех рабочих процессов, поэтому у нас есть другой подход к обработке этой «очереди» (в данном случае мы просто перебираем их). Важной частью этой реализации является понимание того, что обработка задания имеет другие задачи, чем обработка его результатов. В зависимости от результата можно предпринимать разные действия.

Из предыдущего кода важно понять и соотнести с диаграммой несколько факторов:

  • Внедрение зависимостей снова используется для внедрения результата в постпроцессор, это создает делегирование результата вне контекста пула, где внедренная функция может выполнять различные действия.
    Подпись для этого типа похожа на:
    type ResultProcessorFunc func(result Result) error
  • Мы просто перебираем канал, это определяет время жизни процедуры сбора. Мы можем поменять местами этот подход и создать еще один пул рабочих, чтобы ускорить постобработку результатов, но по замыслу в этом не должно быть необходимости, поскольку обработка «Задания» в «рабочих» подпрограммах теоретически должна занимать больше времени, чем публикация. -обработка результата. Если последнее не удовлетворяет ваш проект, это означает, что в результате у вас может быть много правил бизнес-логики, которые, возможно, стоит изучить и перенести в обработку «Задание».
  • m.done <- true в конце сигнализирует каналу «Готово», позволяя основной goRoutine знать, что рабочий пул завершен.

Что дальше?

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

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