Привет, Хабр! Я Артём Чаадаев из команды ассортимента размещения в Туту и занимаюсь разработкой на языке Go. Большое количество статей посвящено простоте конкурентной разработки на Go, однако без практических примеров для начинающих разработчиков бывает трудно понять, как можно это применить. Более того, задачи на конкурентную разработку постоянно фигурируют на собеседованиях. Поэтому в рамках данной статьи решим задачу, приближенную к реальным условиям, например, как деактивировать большое количество пользователей в стороннем API (обычно это бывает после применения бизнес-логики). Для решения используем два паттерна конкурентной разработки на Go: семафор (Semaphore) и пул обработчиков (Worker pool). Также выясним плюсы и минусы обоих подходов с помощью небольших бенчмарк-тестов с точки зрения времени выполнения и затрат по памяти. Есть некое количество пользователей, например от 1 до 100 000, по каждому надо выполнить функцию Deactivate. Как правило, это происходит путём отправки одного запроса в сторонний API (который не позволяет обновлять пачками) по данному пользователю с его ID и какими-нибудь данными. Каждый запрос даёт временные затраты. При последовательной отправке это будет приблизительно время тайм-аута запроса * количество пользователей. Это даст серьёзный рост временных затрат. Для того чтобы ускорить время обработки пользователей, можем воспользоваться преимуществами языка Go, а именно возможностью обработки данных в конкурентном режиме. Однако при этом нужно иметь возможность ограничивать конкурентное выполнение. В рамках данной статьи мы не будем рассматривать основы конкурентности, поэтому рекомендую к этому моменту ознакомиться с примитивами конкурентности: каналами, горутинами, WaitGroup, мьютексами. Цель данной статьи — познакомить вас с тем, как пользоваться примитивами конкурентности в рамках аналогичных задач. Для решения данного кейса можем воспользоваться двумя подходами: семафор (Semaphore); пул обработчиков (Worker pool). Для простоты не учтено завершение процессов по контексту, хотя это нужно сделать в реальных условиях. Но при этом для наглядности (в своё время, когда я разбирался с подходами в аналогичных задачах, это меня интересовало) я оставил завершение процесса при первом же получении ошибки. В рамках данного подхода создаётся, по сути, неограниченное количество горутин. Их выполнение блокируется с помощью объекта Semaphore, который ограничивает количество одновременно выполняемых горутин с помощью буферизированного канала. В данном подходе на каждую задачу создаётся горутина. Однако если буфер канала семафора переполнен, то при операции Acquire горутина с задачей блокируется до тех пор, пока буфер не освободится с помощью операции Release. Для сбора результатов используется канал, из которого происходит чтение в основной горутине (здесь, конечно, речь идёт о той, в которой выполняется функция, это совсем не обязательно может быть горутина, в которой выполняется функция main). Реализация в рамках примера показана ниже: Здесь же мы создаём настраиваемое заранее фиксированное количество горутин, которое не зависит от того, сколько задач на входе. Таким образом, не нужно создавать сотни тысяч горутин при соответствующем наборе пользователей. Каждая из горутин читает общий канал с входными данными, куда отправляются задачи, пока он не закроется. Аналогично предыдущему способу в основной горутине (здесь, конечно, речь идёт о той, в которой выполняется функция) собираются результаты. Реализация в рамках примера показана ниже: В рамках работы над статьёй мне было интересно сравнить оба этих подхода с помощью бенчмарков.  Условия для бенчмаркинга были следующими: количество пользователей — от 1 до 100 000; количество горутин — от 10 до 1000; количество запусков каждых кейсов —10. Для этого использовалась утилита benchstat. Она есть тут: https://cs.opensource.google/go/x/perf. В рамках бенчмаркинга рассмотрены следующие параметры: затраты по времени (sec/op — секунд на операцию); затраты по памяти (B/op — байт на операцию); количество аллокаций (allocs/op — количество операций выделения памяти на операцию). Видим: огда максимальное количество одновременно выполняемых горутин больше, чем задач, то временные затраты у семафора меньше на 1–3 порядка; когда максимальное количество одновременно выполняемых горутин меньше, чем задач, то временные затраты в принципе держатся в тех же порядках, хотя и Worker pool быстрее; при количестве горутин от 100 и больше незначительно растут временные затраты у Worker pool, однако не особо меняются у семафора. Видим: когда максимальное количество одновременно выполняемых горутин больше, чем задач, то затраты по памяти у семафора меньше, разница вышла на 1–2 порядка; когда максимальное количество одновременно выполняемых горутин меньше, чем задач, то затраты по памяти у Worker pool меньше, но разница примерно на 1 порядок. Видим: при изменении нагрузки количество аллокаций растёт пропорционально количеству задач (и это логично, на каждую задачу создаём горутину) в семафоре; при изменении нагрузки количество аллокаций константно для Worker pool и может поменяться, только если поменять заданное количество горутин. По ходу статьи мы познакомились с двумя паттернами конкурентности: семафор (Semaphore) и пул обработчиков (Worker pool). При решении примера, указанного выше, можно использовать оба этих паттерна на выбор. Исходя из результатов бенчмаркинга, расхождения в затратах по времени и памяти не такие большие (в районе одного порядка). Разница ощутима только для ситуации, когда задач меньше чем горутин и, соответственно, наоборот. Возможно у кого-то уже были практические примеры, где можно было бы чётко проследить, что конкретно лучше использовать, буду рад, если поделитесь в комментариях. Подробнее просмотреть репозиторий вместе с тестами и бенчмарками можно здесь.