Что такое пул потоков

от admin

Заметка о многопоточности и пулах потоков

Не так давно готовил небольшой доклад и решил поделиться заметками, которые образовались в процессе.

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

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

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

1:1 (потоки исполнения на уровне ядра)
В этом случае потоки исполнения соответствуют соответствующим потокам операционной системы и могут исполняться параллельно на различных ядрах.

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

M:N (смешанная потоковость)
Самая сложная модель, в ней некоторое количество потоков на уровне пользователя отображаются на некоторое количество потоков ядра.
Плюс — быстрые переключения контекста
Минус — возможна неоптимальная работа из за дорогой координации между планировщиком виртуальной машины и планировщиком ядра

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

Итак, подведем небольшой итог:

Поток — управляется операционной системой, множество потоков может исполняться параллельно на множестве ядер процессора.
Легковесный поток (green thread) — то же что и поток но в пользовательском пространстве, управляется виртуальной машиной, работающей поверх операционной системы. В зависимости от реализации могут или не могут выполняться параллельно.
Кооперативные потоки — легковесные потоки, которые должны явно уступить выполнение друг другу. В этом случае роль планировщика сводится к созданию и уничтожению потоков.

Итак, что делать если в приложении нужна параллельная обработка вычислений?
Первая мысль — создавать новый поток на каждую задачу.
В Java модель исполнения 1:1, соответственно потоки могут исполняться на разных ядрах процессора параллельно, что является большим плюсом, из минусов — создание, удаление, переключения контекста и блокировки стоят сравнительно дорого. В итоге приложение будет тратить больше времени на создание и удаление потоков, чем на обработку непосредственного запроса.
Потоки занимают значительное количество памяти, соответственно при создании большого количества потоков JVM может начать пробуксовывать.
Это наводит нас на мысли о том, что потоки нужно каким-то образом переиспользовать и ограничивать их количество.

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

В Java есть несколько реализаций пулов потоков:

Single Thread Pool
— один поток
— неограниченная очередь задач
— последовательное исполнение задач

Fixed Thread Pool
— фиксированное количество потоков
— неограниченная очередь задач

Cached Thread Pool
— создает новые потоки если все потоки заняты, но будет переиспользовать созданные ранее если они свободны
— динамически сдувается и раздувается

Scheduled Thread Pool
— исполняет задачу периодически

Work Stealing Pool (Fork Join Pool)
— имеет две очереди, одну общую и одну специфичную для каждого потока
— рекурсивно порождаемые задачи добавляются в очередь потока и могут быть “украдены” другим бездействующим потоком
— динамически увеличивает и уменьшает количество потоков

Как реализовать пул потоков в Java

Поток – это путь выполнения независимой программы. В Java каждый поток расширяет класс java.lang.Thread или реализует java.lang.Runnable.

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

1. Что такое пул потоков

При работе с очередью важно использовать блок синхронизации, чтобы контролировать доступ потоков к очереди.

В приведенном выше примере мы использовали notifyAll() вместо notifyAll() . Потому что notify() имеет более желательные характеристики производительности, чем notifyAll() ; в частности, notify() вызывает гораздо меньше переключений контекста, что важно в серверном приложении. Но важно убедиться, что при использовании notify() в других ситуациях существуют тонкие риски, связанные с использованием notify() , и его целесообразно использовать только при определенных конкретных условиях.

На следующем рисунке показана структура пула потоков в приведенном выше примере.

Фигура 1. Дизайн пула потоков

2. Эффективное использование потоковых пулов

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

Вот несколько моментов:

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

3. Вывод

Пул потоков полезен для организации серверных приложений, и очень важно правильно его реализовать, чтобы предотвратить любые проблемы, такие как взаимоблокировка и сложность использования для wait() или notify() . Поэтому рекомендуется рассмотреть возможность использования одного из классов Executor из util.concurrent , такого как ThreadPoolExecutor , а не писать пул потоков с нуля. Если требуется создать потоки для обработки краткосрочных задач, вы можете вместо этого использовать пул потоков.

4. Загрузите исходный код

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

Что такое пул потоков

content

тут рассказать про проблему запуска 100500 потоков, что каждый поток затрачивает ресурсы ОС, есть НЕбесплатное переключение контекста потоков и пр

Переключение контекста

Большое количество потоков требует больших вычислительных ресурсов на их планирование и выполнение.

Чем больше потоков, тем больше переключений и тем больше памяти требуется для хранения контекстов.

Переключение контекста

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

Очередь перед кассой

Представим, что вы в магазине. Вместе с вами там еще сотни людей. А теперь представим, что, чтобы расчитать вашу покупку, к вам подходит новый нанятый кассир, который считает ваш товар, принимает у вас деньги, отдает сдачу. А после того, как этот кассир обслужил вас, его увольняют и нанимают нового кассира для другого покупателя. Это НЕВЫГОДНО. Так примерно и происходит, если под каждую задачу запускать новый поток. ОС — магазин, покупатели — задачи, кассиры — потоки.

Пул потоков

Проблему огромного количества потоков в приложении призван решить пул потоков.

Пул потоков

В стандартной библиотеке C++ нет пула потоков.

Но его несложно реализовать с помощью стандартных средств C++ для своих целей.

Пример ThreadPool

Пример ThreadPool

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

В примере выше, это метод enqueue .

Метод enqueue

  • метод возвращает future на результат выполнения функции
  • принимает функцию func , которую необходимо выполнить
  • и принимает аргументы args , которые необходимо передать в функцию func во время выполнения

Метод enqueue

Аргументы повторяют аргументы функции std::async (за исключением первого параметра std::launch policy ).

По факту, std::async может не создавать новый поток для исполнения задачи, а использовать поток из встроенного пула, но это зависит от конкретной реализации компилятора.

Пример

Как устроен пул потоков

объяснить как работают пулы потоков.

Как устроен пул потоков

По сути, это применение шаблона producer-consumer.

В качестве consumer’ов выступают потоки, которые выполняют поставленные в очередь задачи.

В качестве producer’ов – пользовательский код.

Модификации пула потоков

Иногда, чтобы решить задачу в “виртуальном” мире достаточно посмотреть как подобная задача решается в “реальном” мире.

Динамическое добавление и снятие потоков

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

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

Локальные очереди для каждого потока

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

Но иногда у одной кассы собирается большая очередь, а другие пустуют.

Резюме

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

Асинхронное программирование

Как приготовить ужин

есть два способа приготовить ужин. Рассказать, как готовить стейк: надо не отходя от плиты и скороводы ждать пока стейк две минуты прожарится на одной стороне, потом надо перевернуть его и опять НЕ отходя от плиты опять ждать. Приходится ждать, а не погамать в AoE 2… это Синхронный метод готовки ужина.

Как приготовить ужин

Но есть альтернативный способ приготовить ужин — асинхронный. Подготавливется мясо, солится, перчится, ставится в духовку, заводится будильник на полтора часа и все. Через полтора часа звонит будильник, вы идете к плите и ужин уже готов. А в то время, пока запекается мясо, можно заниматься другими делами.

Cинхронная модель

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

Пример

Cинхронная модель

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

Cинхронная модель

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

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

Асинхронная модель

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

Асинхронная модель

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

Пример

Замечание

Представленный выше код является псевдокодом. Реальный код не представлен на слайдах для упрощения понимания примера.

Чтобы увидеть работающий код, смотрите пример к лекции.

Псюсы асинхронности

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

Функции обратного вызова

Функции обратного вызова (callback) – функции, которые будут вызываны после того, как завершится задача, запущенная в асинхронном режиме.

Callback

С помощью callback’ов можно обрабатывать результат асинхронных операций.

Callback

В примере лямбда on_read_callback является callback-функцией.

Лямбда on_read_callback будет вызвана после того как будут получены данные по сети и завершится системный вызов.

Проблемы callback-функций

Код с использованием callback-функций становится запутанным, нарушается последовательность кода.

Легко “заблудиться”, в какой момент и в каком порядке будут вызываться callback-функции.

Проблемы callback-функций

Всегда необходимо заботиться о времени жизни объектов, которые используются в callback-функциях.

Совет

Всегда, когда имеете дело с callback-функциями, думайте о времени жизни объектов, с которыми работаете в callback’ах.

Сопрограммы

Чтобы решить проблемы, связанные с использованием callback-функций, можно воспользоваться сопрограммами (coroutine).

Сопрограммы

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

Пример

Точки входа

Функция Echo является сопрограммой. У нее три точки входа:

  1. Как у обычных функций в начале тела функции;
  2. После асинхронного чтения данных из сокета;
  3. После асинхронной записи данных в сокет.

co_await

Оператор co_await приостанавливает сопрограмму и возвращает управление вызывающему коду.

co_await

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

Преимущества сопрограмм

Использование сопрограмм позволяет писать асинхронный код, который выглядит как синхронный. Такой код проще реализовывать, отлаживать и использовать.

Резюме

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

асинхронность необходимо использовать при наличии потенциально блокирующих работу действий

асинхронность полезна при обращении к потоку пользовательского интерфейса

Резюме

  • при разработке callback-функций продумывайте, как объекты “доживут” до вызова callback’a

Резюме

использование сопрограмм в асинхронном программировании помогает упростить код

в зависимости от реализации сопрограммы иногда бывают эффективнее callback’ов

сопрограммы полезны не только при асинхронности, например, еще и при реализации генераторов

Атомарные операции

Атомарные vs неатомарные

Операция называется атомарной, если она выполняется как единое целое, либо не выполняется вовсе. Т.е. она не может быть частично выполнена или частично не выполнена.

Если один поток выполняет атомарную операцию, то другие потоки не могут “вмешаться” в выполнение этой операции (например, получить её промежуточное значение).

Атомарные vs неатомарные

Неатомарные операции такой гарантией не обладают.

Неатомарная переменная

Атомарная переменная

atomic

  • load() — получить текущее значение
  • store() — присвоить новое значение
  • is_lock_free() — возвращает true, если операции на данном типе неблокирующие
  • operator++ — инкремент
  • exchange() — установить новое значение и вернуть предыдущее
  • compare_exchange_strong() — аналог CAS
  • compare_exchange_weak() — аналог CAS

CAS (Compare and Swap)

Операция атомарно сравнивает значение одного объекта с другим и при равенстве измениет значение объекта.

compare_exchange

compare_exchange

Разница между compare_exchange_weak и compare_exchange_strong заключается в том, что compare_exchange_weak на некоторых платформах может вернуть false и НЕ выполнить обмен даже в случае равных значений.

Lock-free программирование

Неблокирующая синхронизация – подход в параллельном программировании, в котором принят отказ от традиционных примитивов блокировки.

Недостатки блокировок

  • блокировки — довольно медленные операции
  • при блокировках поток с низким приоритетом может заблокировать поток, с более высоким приоритетом

Определение

Процедура считается lock-free, если для нее гарантируется прогресс как минимум одного потока, выполняющего эту процедуру. Другие потоки могут ждать, но минимум один поток должен прогрессировать.

Операция называется wait-free, если она завершается за определенное количество шагов, не зависящих от состояние и действий других потоков.

Реализация lock-free

Неблокирующие алгоритмы строятся на атомарных операциях.

Одна из самых значимых операций при lock-free программировании – это сравнение с обменом (CAS).

Lock-free stack

Чтобы лучше разобраться с lock-free алгоритмами, необходимо рассмотреть пример.

Для этого реализуем потокобезопасной стек с использованием lock-free программирования.

Как это работает в мире java. Пул потоков

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

Представим, что у вас которая вызывает большую загрузку процессора:

Мы хотим как можно быстрее обработать ряд таких задач, попробуем*:

На моей тачке с 4 физическими ядрами использование ресурсов процессора top -pid :

image

Время выполнения 104 сек.

Как вы заметили, загрузка одного процессора на один java-процесс с одним выполняемым потоком составляет 100%, но общая загрузка процессора в пользовательском пространстве составляет всего 2,5%, и у нас есть много неиспользуемых системных ресурсов.

Давайте попробуем использовать больше, добавив больше рабочих потоков:

image

ThreadPoolExecutor

Для ускорения мы использовали ThreadPool — в java его роль играет ThreadPoolExecutor, который может быть реализован непосредственно или из одного из методов в классе Utilities. Если мы заглянем внутрь ThreadPoolExecutor, мы можем найти очередь:

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

Каждый addWorker запускает новый поток с задачей Runnable, которая опрашивает workQueue на наличие новых задач и выполняет их.

ThreadPoolExecutor имеет очень понятный javadoc, поэтому нет смысла его перефразировать. Вместо этого, давайте попробуем сделать наш собственный:

Теперь давайте выполним ту же задачу, что и выше, с нашим пулом.
Меняем строку в MultithreadClient:

Время выполнения практически одинаковое — 15 секунд.

Размер пула потоков

Попробуем еще больше увеличить количество запущенных потоков в пуле — до 100.

Мы можем видеть, что время выполнения увеличилось до 28 секунд — почему это произошло?

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

Количество переключений контекста процесса можно увидеть, посмотрев на csw параметр при выводе команды top.

На 8 потоках:
image

На 100 потоках:
image

Как выбрать размер пула?

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

Предполагая, что потоки не блокируют друг друга, нет циклов ожидания I/O, и время обработки задач одинаково, оптимальный пул потоков = Runtime.getRuntime().availableProcessors() + 1.

Если потоки в основном ожидают I/O, то оптимальный размер пула должен быть увеличен на отношение между временем ожидания процесса и временем вычисления. Например. У нас есть процесс, который тратит 50% времени в iowait, тогда размер пула может быть 2 * Runtime.getRuntime().availableProcessors() + 1.

Другие виды пулов

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

Похожие статьи