что такое пул интов
Как это работает в мире java. Пул потоков
Основной принцип программирования гласит: не изобретать велосипед. Но иногда, чтобы понять, что происходит и как использовать инструмент неправильно, нам нужно это сделать. Сегодня изобретаем паттерн многопоточного выполнения задач.
Представим, что у вас которая вызывает большую загрузку процессора:
Мы хотим как можно быстрее обработать ряд таких задач, попробуем*:
Время выполнения 104 сек.
Как вы заметили, загрузка одного процессора на один java-процесс с одним выполняемым потоком составляет 100%, но общая загрузка процессора в пользовательском пространстве составляет всего 2,5%, и у нас есть много неиспользуемых системных ресурсов.
Давайте попробуем использовать больше, добавив больше рабочих потоков:
ThreadPoolExecutor
Для ускорения мы использовали ThreadPool — в java его роль играет ThreadPoolExecutor, который может быть реализован непосредственно или из одного из методов в классе Utilities. Если мы заглянем внутрь ThreadPoolExecutor, мы можем найти очередь:
в которой задачи собираются, если запущено больше потоков чем размер начального пула. Если запущено меньше потоков начального размера пула, пул попробует стартовать новый поток:
Каждый addWorker запускает новый поток с задачей Runnable, которая опрашивает workQueue на наличие новых задач и выполняет их.
ThreadPoolExecutor имеет очень понятный javadoc, поэтому нет смысла его перефразировать. Вместо этого, давайте попробуем сделать наш собственный:
Теперь давайте выполним ту же задачу, что и выше, с нашим пулом.
Меняем строку в MultithreadClient:
Время выполнения практически одинаковое — 15 секунд.
Размер пула потоков
Попробуем еще больше увеличить количество запущенных потоков в пуле — до 100.
Мы можем видеть, что время выполнения увеличилось до 28 секунд — почему это произошло?
Существует несколько независимых причин, по которым производительность могла упасть, например, из-за постоянных переключений контекста процессора, когда он приостанавливает работу над одной задачей и должен переключаться на другую, переключение включает сохранение состояния и восстановление состояния. Пока процессор занято переключением состояний, оно не делает никакой полезной работы над какой-либо задачей.
Количество переключений контекста процесса можно увидеть, посмотрев на csw параметр при выводе команды top.
На 8 потоках:
На 100 потоках:
Как выбрать размер пула?
Размер зависит от типа выполняемых задач. Разумеется, размер пула потоков редко должен быть захардокожен, скорее он должен быть настраиваемый а оптимальный размер выводится из мониторинга пропускной способности исполняемых задач.
Предполагая, что потоки не блокируют друг друга, нет циклов ожидания I/O, и время обработки задач одинаково, оптимальный пул потоков = Runtime.getRuntime().availableProcessors() + 1.
Если потоки в основном ожидают I/O, то оптимальный размер пула должен быть увеличен на отношение между временем ожидания процесса и временем вычисления. Например. У нас есть процесс, который тратит 50% времени в iowait, тогда размер пула может быть 2 * Runtime.getRuntime().availableProcessors() + 1.
Другие виды пулов
Пул потоков с ограничением по памяти, который блокирует отправку задачи, когда в очереди слишком много задач MemoryAwareThreadPoolExecutor
Пул объектов для Java-оболочек и строк
Как вы все очень хорошо знаете, иногда Java использует пулы объектов для оболочек и строковых типов, иногда это не так.
Как вы видите, бокс примитивов берет объекты из пула, создание строк через строковый литерал также берет объекты из пула. Такие объекты фактически являются одним и тем же объектом (оператор == возвращает true на них).
другие механизмы создания оболочек и строк не принимают предметы из бассейна. Объекты, созданные этими способами, на самом деле являются разными объектами (оператор == возвращает false на них).
меня смущает тот факт, что пул используется частично.
Если это проблема с памятью, почему бы не использовать пул все время? Если это не проблема памяти-зачем использовать его вообще?
вопрос в том-каковы причины реализации такого поведения (=частичное использование пула)?
5 ответов
его скорость озабоченность, выделяя новый Integer каждый раз будет стоить как времени, так и памяти. Но по той же причине выделение при запуске слишком много при запуске использует тонны памяти и времени.
это приводит к некоторым контр-интуитивное поведение, как вы нашли.
результатом является этот странный компромисс, который у нас есть. Причины такого поведения обсуждаются в стандарте Java. (5.7)
для других значений эта формулировка запрещает любые предположения о тождестве упакованных значений со стороны программиста. Это позволит (но не потребует) совместного использования некоторых или всех этих ссылок.
tl; dr
невозможно заставить его работать идеально, и это слишком странно, чтобы не работать вообще. Таким образом, у нас это работает «большую часть» времени.
Если это проблема с памятью, почему бы не использовать пул все время?
объединение всего все время дороже, чем простой кэш, который иногда терпит неудачу: вам нужны более сложные структуры данных (объединение небольших целых чисел можно сделать с помощью простого и небольшого массива) и алгоритмы, и вы всегда должны идти, хотя эта проверка, даже когда пул не поможет вам. Кроме того, вы объедините много объектов, которые никогда не понадобятся снова, что является пустой тратой памяти: Вам нужно больше (бесполезных) записей кэша, и вам нужно управлять этим кэшем (или страдать от сохранения бесполезных объектов).
Если это не проблема с памятью-зачем использовать его вообще?
Это is проблемы с памятью. Такая оптимизация экономит много памяти. Объединение объект не обязательно уменьшает использование памяти, потому что не каждый объект используется много. Это обмен. Подход, который был принят, экономит значительную сумму памяти для некоторых распространенных случаев использования, без замедления других операций чрезмерно или тратить много памяти.
существуют различные соображения. Например, использование памяти, но и семантика языка.
является явным созданием объекта. Замена этого оператора оператором «get from pool» изменит семантику языка.
если вы проводите некоторое время, работая над численными вычислениями на Java, вы do почувствуйте негативное влияние автобоксинга. Для чисел высокой эффективности, первое эмпирическое правило во избежание любой вид autoboxing. Например, GNU Trove предлагает хэш-карты и аналогичные структуры для примитивных типов, а преимущества среды выполнения и памяти необъятный.
что касается строк, компилятор должен хранить их соответствующим образом в файле класса. Итак, как вы храните строку в файле класса? Самый простой способ-переписать код что-то вроде это:
который не полагается на любую магическую обработку String тип. Это просто завернутый набор примитивов. И, конечно, вы хотите хранить каждую уникальную строку только один раз в классе, чтобы уменьшить размер класса и, следовательно, время загрузки.
Это случай памяти и скорости. Вы действительно не хотите создавать 4 миллиарда целочисленных объектов при запуске JVM, потому что большую часть времени они никогда не будут использоваться.
для строк также существует проблема поиска соответствующих строк в интернированном пуле.
строковые литералы в исходном коде просты, поскольку компилятор может найти и организовать их интернирование, но если я создаю строку динамически во время выполнения, JVM придется пройти через пул и искать каждый строка, чтобы увидеть, соответствует ли она и может быть повторно использована, а строки могут
пока есть структуры данных, которые могут помочь ускорить, это вообще просто проще и быстрее создать новый объект.
см., следующий сегмент кода:
, тогда как в следующем коде:
Пулы потоков в Java
Серверные программы, такие как базы данных и веб-серверы, многократно выполняют запросы от нескольких клиентов, и они ориентированы на обработку большого количества коротких задач. Подход к созданию серверного приложения заключается в создании нового потока каждый раз, когда поступает запрос, и обслуживании этого нового запроса во вновь созданном потоке. Хотя этот подход кажется простым для реализации, он имеет существенные недостатки. Сервер, который создает новый поток для каждого запроса, будет тратить больше времени и потреблять больше системных ресурсов при создании и уничтожении потоков, чем при обработке фактических запросов.
Поскольку активные потоки потребляют системные ресурсы, JVM, создающая слишком много потоков одновременно, может привести к нехватке памяти в системе. Это обуславливает необходимость ограничения количества создаваемых потоков.
Что такое ThreadPool в Java?
Пул потоков повторно использует ранее созданные потоки для выполнения текущих задач и предлагает решение проблемы издержек цикла потоков и перерасхода ресурсов. Поскольку поток уже существует, когда приходит запрос, задержка, вызванная созданием потока, устраняется, что делает приложение более отзывчивым.
Инициализация пула потоков с размером = 3 потока. Очередь задач = 5 запускаемых объектов
Методы пула потоков исполнителя
В случае фиксированного пула потоков, если все потоки в настоящий момент выполняются исполнителем, отложенные задачи помещаются в очередь и выполняются, когда поток становится свободным.
Пример пула потоков
В следующем уроке мы рассмотрим базовый пример исполнителя пула потоков — FixedThreadPool.
Шаги, которым нужно следовать
// Java-программа для иллюстрации
// ThreadPool
// Задача класса для выполнения (Шаг 1)
class Task implements Runnable
private String name;
public Task(String s)
// Печатает имя задачи и спит в течение 1 с
// Весь этот процесс повторяется 5 раз
for ( int i = 0 ; i 5 ; i++)
SimpleDateFormat ft = new SimpleDateFormat( «hh:mm:ss» );
System.out.println( «Initialization Time for»
// печатает время инициализации для каждой задачи
SimpleDateFormat ft = new SimpleDateFormat( «hh:mm:ss» );
// печатает время выполнения для каждой задачи
catch (InterruptedException e)
// Максимальное количество потоков в пуле потоков
static final int MAX_T = 3 ;
public static void main(String[] args)
// создает пять задач
Runnable r1 = new Task( «task 1» );
Runnable r2 = new Task( «task 2» );
Runnable r3 = new Task( «task 3» );
Runnable r4 = new Task( «task 4» );
Runnable r5 = new Task( «task 5» );
// создаем пул потоков с номером MAX_T из
// потоки как фиксированный размер пула (шаг 2)
ExecutorService pool = Executors.newFixedThreadPool(MAX_T);
// передает объекты Task в пул для выполнения (шаг 3)
// закрытие пула (шаг 4)
Образец исполнения
Как видно из выполнения программы, задача 4 или задача 5 выполняются только тогда, когда поток в пуле становится свободным. До этого дополнительные задачи помещаются в очередь.
Пул потоков, выполняющий первые три задачи
Пул потоков, выполняющий задачи 4 и 5
Риски при использовании потоковых пулов
Важные моменты
Тюнинг-пул
Пул потоков — полезный инструмент для организации серверных приложений. Он довольно прост по своей концепции, но есть несколько моментов, на которые нужно обратить внимание при его реализации и использовании, таких как тупик, перегрузка ресурсов. Использование службы executor облегчает реализацию.
Пожалуйста, пишите комментарии, если вы обнаружите что-то неправильное, или вы хотите поделиться дополнительной информацией по обсуждаемой выше теме.
Руководство по пулу строк Java
Узнайте, как JVM оптимизирует объем памяти, выделенный для хранения строк в пуле строк Java.
1. Обзор
Объект String является наиболее часто используемым классом в языке Java.
2. Интернирование строк
Благодаря неизменяемости Строк в Java JVM может оптимизировать объем выделенной для них памяти, храня только одну копию каждого литерала Строки в пуле . Этот процесс называется интернированием .
Когда мы создаем переменную String и присваиваем ей значение, JVM ищет в пуле String равного значения.
Если он будет найден, компилятор Java просто вернет ссылку на свой адрес памяти, не выделяя дополнительной памяти.
Если он не найден, он будет добавлен в пул (интернет), и его ссылка будет возвращена.
Давайте напишем небольшой тест, чтобы проверить это:
3. Строки, выделенные с помощью конструктора
Каждая строка |, созданная подобным образом, будет указывать на другую область памяти со своим собственным адресом.
Давайте посмотрим, чем это отличается от предыдущего случая:
4. Строковый литерал против строкового объекта
Когда мы создаем объект String с помощью оператора new () , он всегда создает новый объект в памяти кучи. С другой стороны, если мы создадим объект с использованием синтаксиса String literal, например “Baeldung”, он может вернуть существующий объект из пула строк, если он уже существует. В противном случае он создаст новый строковый объект и поместит его в пул строк для последующего повторного использования.
В этом примере объекты String будут иметь одну и ту же ссылку.
Затем давайте создадим два разных объекта с помощью new и проверим, что у них разные ссылки:
5. Ручная Стажировка
Мы можем вручную ввести String в пул строк Java, вызвав метод intern() для объекта, который мы хотим интернировать.
Ручное интернирование строки | сохранит ее ссылку в пуле, и JVM вернет эту ссылку при необходимости.
Давайте создадим тестовый случай для этого:
6. Сбор Мусора
7. Производительность и оптимизация
В Java 6 единственная оптимизация, которую мы можем выполнить, – это увеличение пространства PermGen во время вызова программы с помощью параметра MaxPermSize JVM:
В Java 7 у нас есть более подробные параметры для изучения и расширения/уменьшения размера пула. Давайте рассмотрим два варианта просмотра размера пула:
Если мы хотим увеличить размер пула с точки зрения ведер, мы можем использовать параметр StringTableSize JVM:
До Java 7u40 размер пула по умолчанию составлял 1009 ведер, но это значение было подвержено нескольким изменениям в более поздних версиях Java. Если быть точным, размер пула по умолчанию с Java 7u40 до Java 11 составлял 60013, а теперь он увеличился до 65536.
Обратите внимание, что увеличение размера пула будет потреблять больше памяти, но имеет преимущество в сокращении времени, необходимого для вставки Строки в стол.
8. Примечание О Java 9
В Java 9 предоставляется новое представление, называемое Compact Strings. Этот новый формат будет выбирать соответствующую кодировку между char[] и byte[] в зависимости от сохраненного содержимого.
Поскольку новое представление String будет использовать кодировку UTF-16 только в случае необходимости, объем кучи памяти будет значительно ниже, что, в свою очередь, приведет к меньшим затратам Сборщика мусора на JVM.
9. Заключение
В этом руководстве мы показали, как JVM и компилятор Java оптимизируют выделение памяти для объектов String через пул строк Java.
Понимаем соединения и пулы
Прим. перев.: автор этой статьи — технический архитектор Sudhir Jonathan — рассказывает об одном из тех базовых механизмов, с которым сталкивается каждый пользователь, разработчик и системный администратор. Однако до возникновения определённых (и иногда довольно специфичных) проблем многие не задумываются о том, как всё работает «под капотом». Автор устраняет этот пробел, используя популярные фреймворки, серверы БД и приложений в качестве понятных примеров.
Соединения — это скрытый механизм, который компьютерные системы используют для общения друг с другом. Они стали настолько неотъемлемой частью нашей жизни, что мы часто забываем, насколько они важны, не замечаем, как они работают и терпят неудачу. Часто мы забываем о них до тех пор, пока не возникает проблема. При этом обычно она проявляется массовым отказом именно в то время, когда системы загружены сильнее всего. Поскольку соединения встречаются повсюду и они важны практически для каждой системы, стоит потратить немного времени на их изучение.
Соединения — что это?
Соединение — это связующее звено между двумя системами, позволяющее им обмениваться информацией в виде последовательности нулей и единиц: посылать и принимать байты.
В зависимости от того, как расположены системы по отношению друг к другу, комбинация нижележащего программного и аппаратного обеспечения активно работает, чтобы обеспечить физическое перемещение информации, абстрагируя ее. Например, при взаимодействии двух Unix-процессов за выделение памяти для обмена данными и приём/доставку байтов с обеих сторон отвечает система межпроцессного взаимодействия (IPC). Если системы расположены на разных компьютерах, они скорее всего будут взаимодействовать по протоколу TCP, который и обеспечит перемещение данных по проводной или беспроводной системе связи между компьютерами. Детали совместной работы компьютеров для надёжной обработки, передачи и приёма данных скорее относятся к проблеме стандартизации, и большинство систем используют базовые блоки, предоставляемые протоколами UDP и TCP. То, как эти соединения обрабатываются на каждом из концов, является более актуальной проблемой для разработки приложений. О ней мы и поговорим сейчас.
Где используются соединения?
Соединения используются прямо сейчас. Ваш браузер установил соединение с веб-сервером, на котором размещен этот блог, и по нему получил байты, составляющие HTML, CSS, JavaScript и изображения, на которые вы сейчас смотрите. При работе по протоколу HTTP/1.1 браузер устанавливал множество соединений с сервером — по одному для каждого файла. Протокол HTTP/2 позволил получить все файлы по одному соединению (с помощью мультиплексирования). Во всех этих случаях браузер выступал клиентом, а сервер блога, собственно, был сервером.
Но сервер, в свою очередь, также устанавливал соединения, чтобы передать эту страницу. Так, он подключился к базе данных и отправил ей запрос, содержащий URL страницы. В ответ он получил её содержимое. В данном сценарии сервер приложений выступал клиентом, а база данных — сервером. Кроме того, сервер приложений мог устанавливать соединения с различными сторонними сервисами, такими как сервис подписки или оплаты, а также сервис определения местоположения.
Организовать «отгрузку» статических файлов, таких как JS, CSS и изображения, помогает CDN-система, расположенная между браузером и сервером блога. Браузер (клиент) установил соединение с ближайшим сервером CDN, и, если нужных файлов не оказалось в кэше CDN-сервера, тот (выступая как клиент) связался с сервером блога (сервер).
Если внимательно посмотреть на системы, которыми мы пользуемся или которые создаём, можно увидеть множество всевозможных соединений. Часто они скрыты от глаз, и, забывая об их невидимом существовании и ограничениях, можно столкнуться с проблемами в моменты, когда меньше всего этого ожидаешь.
Почему важна обработка соединений?
Понимание того, как именно ведется работа с соединениями, важна, поскольку их стоимость асимметрична: издержки, связанные с созданием соединения, отличаются на стороне клиента и сервера. В одноранговой (P2P) системе это не так, и соединения имеют одинаковую «стоимость» на обоих концах, но такое бывает редко. Типичное использование соединений всегда предполагает наличие клиента и сервера, при этом издержки, связанные с созданием соединения, различны на стороне клиента и сервера.
Прежде чем перейти к различным механизмам обработки соединений, необходимо освежить знания о способах запуска программ на компьютерах и об их параллельной работе.
При старте программы операционная система запускает код как один экземпляр процесса. Во время работы процесс занимает одно ядро CPU и некоторый объём памяти, и не делится своей памятью ни с какими другими процессами.
Процесс может запускать так называемые threads (потоки выполнения) — дочерние элементы процесса, способные работать параллельно. Потоки используют память совместно с процессом, который их породил (тот может выделять больше памяти для их использования).
Также процесс может использовать event loop (цикл событий), который выглядит как система с одним процессом, который отслеживает имеющиеся задания, непрерывно и бесконечно перебирая их. При этом он выполняет активные задания и пропускает заблокированные.
Другой способ предполагает использование внутренних конструкций, таких как fibers (файберы), green-threads (зелёные потоки), coroutines (сопрограммы) или actors (акторы). Каждая из этих конструкций чуть отличается от остальных (в том числе, в смысле издержек), но все они внутренне управляются процессом и его потоками.
Возвращаясь к обработке соединений, давайте сначала рассмотрим подключения к базе данных. Для сервера приложений (клиента в данном случае) установка TCP-соединения связана с выделением небольшого объёма памяти для буфера и одного порта.
Если используется PostgreSQL, на стороне сервера каждое соединение обрабатывается путём создания нового процесса, который занимается всеми запросами, поступающими по данному соединению. Такой процесс занимает ядро процессора и около 10 Мб памяти (или больше).
MySQL для обработки каждого подключения создаёт поток внутри процесса. Требования к памяти гораздо ниже в потоковой модели, но платить за это приходится постоянным переключением контекстов.
Redis обрабатывает каждое соединение как итерацию в цикле событий, что снижает требования к ресурсам, однако платить за это приходится тем, что каждое соединение дожидается своей очереди, при этом Redis обрабатывает запросы строго по одному.
Представьте себе запрос к серверу приложений. Браузер инициирует TCP-соединение в качестве клиента (это ему обходится дёшево — небольшой объём памяти для буфера и один порт). На стороне сервера ситуация совершенно иная:
Если сервер использует Ruby on Rails, каждое соединение обрабатывается одним потоком, порождённым внутри фиксированного числа запущенных процессов (в случае веб-сервера Puma), или одним процессом (Unicorn).
Если используется PHP, система CGI запускает новый PHP-процесс для каждого соединения, а более популярная реализация FastCGI поддерживает несколько активных процессов, чтобы ускорить обработку новых соединений.
В случае Go для обработки каждого соединения создается goroutine (дешёвая и легковесная потокоподобная структура, управляемая & планируемая исполняемой средой Go).
В Node.js/Deno входящие соединения обрабатываются в цикле событий путём последовательного перебора и ответа на запросы по одному за раз.
В системах вроде Erlang/Elixir каждое соединение обрабатывается актором — ещё одной легковесной и внутренне-планируемой потокоподобной конструкцией.
Архитектуры обработки соединений
Из примеров выше видно, что существуют несколько типичных стратегий обработки соединений:
Процессы. Каждое соединение обрабатывается отдельным процессом, который либо создается специально для этого соединения (CGI, PostgreSQL), либо входит в некую группу доступных процессов (Unicorn, FastCGI).
Потоки. Каждое соединение обрабатывается отдельным потоком, который либо специально создаётся, либо берётся из специального резерва. Потоки могут быть распределены по нескольким процессам, при этом все потоки эквивалентны между собой (Puma/Ruby, Tomcat/Java, MySQL).
Цикл событий. Каждое соединение включается в цикл событий в виде задачи, и соединения с данными для чтения обрабатываются последовательно (Node, Redis). Обычно подобные системы — однопроцессные и однопотоковые, но в некоторых случаях бывают многопроцессными, когда каждый процесс действует как полунезависимая система с отдельными циклами событий.
Coroutines / Green-Threads / Fibers / Actors. Каждое соединение обрабатывается легковесной конструкцией с внутренним управлением (Go, Erlang, Scala/Akka).
Представление о том, как сервер обрабатывает соединения, имеет решающее значение для понимания его ограничений и моделей масштабирования. Даже базовое использование или настройка требуют понимания того, как обрабатываются соединения: Redis и PostgreSQL, например, предлагают различную семантику транзакций и блокировок, на которую влияют их соответствующие механизмы обработки соединений. Серверы, основанные на процессах и потоках, могут «упасть» из-за исчерпания ресурсов, если их максимальное количество не задано в разумных пределах. С другой стороны, установка пределов может привести к масштабному недоиспользованию серверов из-за слишком низких лимитов. Системы, основанные на циклах событий, ничего не выигрывают от работы на 64-ядерных CPU (если, конечно, их 64 копии не настроены на совместную работу — что отлично работает в случае веб-серверов, но плохо подходит для баз данных).
Каждый из этих способов обработки соединений по-разному проявляет себя при использовании в серверах приложений и базах данных из-за распределённой или централизованной природы каждой системы. Например, серверы приложений, как правило, хорошо подходят для горизонтального масштабирования — они работают нормально и одинаково независимо от того, один ли у вас сервер, 10 или 10000. В этих случаях отказ от модели процессов/потоков обычно приводит к росту производительности, поскольку мы хотим выполнить как можно больше работы с минимальным использованием памяти и переключением контекста процессором.
Подходы на циклах событий вроде Node отлично зарекомендовали себя на одноядерных серверах, и для использования на многоядерных серверах их необходимо кластеризовать правильным образом. Системы, основанные на сопрограммах/акторах, такие как Go или Erlang, гораздо легче задействуют ядра процессора, так как разработаны специально для этого: на одной машине параллельно могут работать многие тысячи горутин и акторов.
С другой стороны, централизованные базы данных выигрывают от обработки на основе процессов/потоков/циклов событий, поскольку из-за транзакционных гарантий системы нежелательно, чтобы множество соединений работало с одними и теми же данными в одно и то же время. В случае операций, происходящих на множестве соединений, придётся использовать блокировки во время чувствительных к транзакциям этапов их работы, или использовать стратегии вроде MVCC, поэтому чем меньше число возможных обработчиков соединений, тем лучше. Эти системы поддерживают малое число соединений на одной машине.
На крупном сервере PostgreSQL может управлять несколькими сотнями соединений, в то время как MySQL способен обрабатывать пару тысяч. Redis способен обрабатывать наибольшее количество соединений (возможно, десятки тысяч), поскольку цикл событий помогает ему поддерживать согласованность данных, но платить за это приходится тем, что одновременно выполняется только одна операция.
Распределённые базы данных могут и будут пытаться отойти от модели, основанной на процессах и потоках. Поскольку данные распределяются по нескольким машинам, от блокировок обычно отказываются в пользу секционирования (partitioning). Такие базы данных способны поддерживать множество соединений между большим числом серверов. Например, AWS DynamoDB или Google Datastore, а также распределенные БД, написанные на Go, с готовностью примут миллионы или даже миллиарды одновременных подключений.
Однако все эти решения имеют последствия — они жертвуют многими операциями (join’ы, ad-hoc-запросы) и гарантиями согласованности, предоставляемыми централизованными/односерверными базами данных. Но, идя на эту жертву, они получают возможность обрабатывать соединения секционированным, горизонтально масштабированным, практически неограниченным способом, позволяя выбирать конструкцию, которая поддерживает множество соединений на множестве машин. Проблема соединений в данном случае снимается: каждый отдельный сервер должен сам заботиться об их обработке, но в совокупности, с тысячами и миллионами машин с умной маршрутизацией подключений, данные системы часто ведут себя так, словно они бесконечно масштабируемы.
Что такое пул и зачем он нужен?
«Дороговизна» соединений требует их эффективного и экономного использования. Часто бывает сложно понять, насколько дорого они обходятся. Связано это с асимметрией: с точки зрения клиента соединение стоит дёшево, и обычно от их чрезмерного числа страдает именно сервер.
В процессе работы клиент не может позволить себе роскошь использовать отдельное соединение для каждой операции. Например, сервер приложений как клиент подключается к базе данных. Устанавливая для каждого запроса новое соединение, он искусственно ограничил бы себя возможностями БД (к которой подключается) по обработке соединений. В некоторых случаях подобный способ работы абсолютно эффективен — например, если сервер приложений является прокси-сервером для базы данных. В реальности серверы приложений выполняют кучу другой работы: ожидают поступления запрашиваемых данных на сервер, анализируют его, формулируют запрос, пересылают его в БД по соответствующему соединению, ждут результатов, считывают и обрабатывают их, преобразуют выходные данные в формат HTML/JSON/RPC, посылают сетевые запросы другим сервисам, и т.д. Основную часть времени соединение простаивает — другими словами, дорогостоящий ресурс используется неэффективно. И это мы ещё не учли издержки, связанные с созданием соединения (запуск процесса, аутентификация) и завершением его работы на стороне сервера.
Чтобы повысить эффективность использования соединений, многие клиенты баз данных используют так называемые connection pools (пулы соединений). Пул — это объект, самостоятельно обслуживающий некоторый набор соединений, не предусматривающий прямого доступа или использования. Пул выдает соединения, когда необходимо связаться с базой данных. Соединения возвращаются в пул после завершения работы. Пул может быть инициализирован с заданным числом соединений, или может наполняться по необходимости. Идеальное применение пула соединений выглядит следующим образом: код запрашивает соединение у пула (checkout), когда оно ему необходимо, использует его и сразу возвращает в пул (release). Таким образом, код не удерживает соединение, пока выполняется работа, никак с ним не связанная, что существенно повышает эффективность. Это позволяет выполнять множество различных задач с использованием одного или нескольких соединений. Если все соединения в пуле заняты, когда происходит очередной запрос (checkout), запрашивающей стороне обычно приходится ждать (block), пока соединение не освободится.
Механизм пулов по-разному реализован в различных языках и фреймворках:
Ruby on Rails, например, автоматически выделяет и возвращает соединения в пул, при этом непонимание нюансов данного процесса приводит к неэффективному коду. Представьте, что совершается запрос к базе данных, за которым следует длинный сетевой запрос к другому сервису, и еще один запрос к БД. В этом случае соединение простаивает во время выполнения сетевого запроса (автоматическое управление Rails должно быть консервативным и осторожным, а потому — неэффективным).
В Go есть драйвер для работы с БД, входящий в стандартную библиотеку, который поддерживает автоматическую работу с пулом соединений. Однако неучет того, что соединения возвращаются в пул между обращениями к БД, приводит к неожиданным и трудно воспроизводимым багам. Иногда разработчики исходят из предположения, что последовательные операции в одном и том же запросе идут по тому же соединению, однако автоматический менеджер случайным образом выбирает соединения из пула (гейзенбаг в Go, связанный с Postgres advisory locks).
Транзакции усугубляют данную проблему: базы данных часто привязывают функционал транзакции к соединению (иногда это называют session — сессией). Начав транзакцию, вы можете её зафиксировать (commit) или откатить (roll back) только по тому же соединению, на котором она изначально запускалась. Автоматическое управление пулом должно учитывать этот фактор с тем, чтобы не вернуть соединение в пул во время выполнения транзакции. В зависимости от базы данных другие функции, такие как блокировки и подготовленные выражения (prepared statements), также могут иметь привязку к соединениям.
Так что, если мы хотим писать эффективный код, нам необходимо знать, как работает пулинг соединений в используемом фреймворке, какие действия выполняются автоматически, и когда автоуправление не работает или его применение контрпродуктивно. Pooling-прокси (вроде pgBouncer, Odyssey или AWS RDS Proxy) — один из инструментов, помогающий забыть об этих нюансах. Эти системы позволяют создавать столько соединений с базой данных, сколько нужно, не заботясь об управлении ими, поскольку выделяемые соединения не настоящие, а их «дешёвая» имитация, не требующая значительных ресурсов. Когда клиент пытается воспользоваться одной из имитаций, pooling-прокси извлекает настоящее соединение из внутреннего пула и сопоставляет имитацию с реальным соединением. Когда прокси замечает, что соединение больше не используется, он оставляет открытым соединение с клиентом, но агрессивно освобождает и повторно использует соединение с базой данных. Число соединений и уровень «агрессивности» можно настроить, в том числе с учётом таких нюансов, как транзакции, подготовленные выражения и блокировки.
Решите ли вы оптимизировать свой код и включить в него эффективное управление соединениями, или выберете инструмент вроде pgBouncer, в конечном счёте будет определяться требуемым компромиссом между производительностью и сложностью развертывания. Любой вариант подойдёт в зависимости от того, сколько кода вы готовы написать, насколько удобно реализовано управление соединениями в используемом языке программирования и насколько эффективным должен быть проект.
Pooling не ограничивается клиентами баз данных. Мы называли соединения на стороне клиента «дешёвыми», но — увы — они не бесплатны. Они тоже используют память, порты и файловые дескрипторы на стороне клиента — ресурсы, которые ограничены. По этой причине многие языки/библиотеки имеют пулы для HTTP-соединений с одним и тем же сервером, а также используют пулы для других ограниченных ресурсов. Как правило, эти пулы скрыты от глаз до тех пор, пока система не исчерпает ограниченный ресурс, и в этот момент она обычно падает. Знание о подобной специфике сильно помогает при отладке — помимо соединений, виновниками проблем часто выступают файловые дескрипторы.
Настройка общих пулов
Теперь, когда мы увидели, как обычно обрабатываются соединения, можно поговорить о различных комбинациях сервер приложений + база данных и подумать о том, как максимизировать количество запросов, обрабатываемых на сервере приложений, минимизируя количество подключений к базе данных. Хотя эта статья покрывает не все возможные комбинации, большинство из них похожи по крайней мере на один из приведенных ниже примеров, так что понимание их принципов работы поможет разобраться с другими возможными комбинациями. Дайте мне знать, если хотите, чтобы я добавил больше комбинаций/систем.
1. Обработка на основе процессов и потоков
Puma, популярный сервер для запуска Ruby-приложений, предлагает пару механизмов для управления обработчиками входящих HTTP-запросов. Первый механизм — это число запускаемых процессов, представленное в конфигурации директивой workers. Каждый процесс сервера независим и загружает полный стек приложения в память. Таким образом, если приложение занимает N Мб памяти, необходимо убедиться, что на машине по крайней мере доступно workers×N Мб памяти для запуска всех копий. Есть способ смягчить эту проблему: Ruby 2+ поддерживает механизм копирования при записи (copy-on-write), позволяющий запускать множество процессов как один и потом разветвлять его на нужное число без необходимости копировать всю память — общие области памяти будут использоваться совместно до тех пор, пока в них не внесут изменения. Активация функции copy-on-write с помощью директивы preload_app! может помочь снизить потребление памяти по сравнению с приведенной выше формулой (workers×N). Однако не стоит возлагать на неё слишком большие надежды, не протестировав предварительно поведение при длительных нагрузках.
Серверы, основанные исключительно на процессах, такие как Unicorn, останавливаются на этом уровне конфигурации — как и популярные серверы для Python, PHP и других языков, использующие глобальную блокировку или построенные по схеме один поток/один процесс. Каждый процесс может обрабатывать один запрос за раз, но это не гарантирует полную загрузку: пока процесс дожидается выполнения запроса к БД или сетевого запроса к другому сервису, он не будет обрабатывать другие запросы, и соответствующее ядро процессора будет простаивать. Для устранения этой проблемы можно запустить больше процессов, чем имеется ядер CPU (что приведёт к издержкам, связанным с переключением контекста), или задействовать потоки.
Что приводит нас ко второму механизму, который предлагает Puma — числу потоков для запуска в каждом процессе/worker’e. Директива threads позволяет настроить минимальное и максимальное число потоков в пуле потоков каждого worker’а. Использование двух этих директив позволяет контролировать общее число потоков, которые будут действовать как параллельные обработчики запросов для приложения. Очевидно, оно равно произведению числа worker’ов на число потоков.
Эмпирическое правило состоит в том, что обычно на каждое доступное ядро CPU приходится по одному worker’у — при условии, конечно, что для этого достаточно памяти. Это позволяет эффективно использовать память, так что можно прикинуть, сколько RAM нужно, проведя несколько тестов с этим номером. Теперь желательно полностью задействовать CPU — сделать это можно, увеличивая максимальное количество потоков. Как помните, потоки используют память совместно с процессом, так что увеличение их числа не слишком отражается на потреблении памяти. Вместо этого большее число потоков будет всё сильнее загружать процессор, попутно позволяя обрабатывать больше запросов одновременно. Это разумно, поскольку, пока один поток спит в ожидании результатов запроса к БД или сетевого запроса, ядро CPU может переключиться на другой поток из того же процесса. Но помните, что большое число потоков приведёт к конкуренции за блокировки процессов, так что вам придется опытным путем установить, сколько потоков можно добавить для значимого увеличения производительности.
Но как все эта конфигурация влияет на количество соединений с базой данных? Rails использует автоматическое управление подключениями к базе данных, так что каждому запущенному потоку потребуется собственное подключение к базе данных для эффективной работы без ожидания, когда работу завершат другие потоки. Он поддерживает пул соединений (его настройки хранятся в файле database.yml и применяются на уровне процесса/worker’а). Таким образом, если оставить значение по умолчанию, равное 5, Rails будет поддерживать максимум 5 соединений для каждого worker’а. Такой лимит не слишком хорошо сработает, если изменить максимальное число потоков — все они будут бороться за эти пять соединений в пуле. Эмпирическое правило состоит в том, чтобы сделать число соединений в пуле равным максимальному количеству процессов, как рекомендовано в руководстве по развертыванию Heroku Puma.
Но тут возникает другая проблема: количество соединений, равное произведению числа worker’ов на число потоков (workers × threads), благотворно влияет на производительность сервера приложений, но совершенно не подходит для базы данных вроде PostgreSQL и, в некоторых случаях, для MySQL. В зависимости от того, сколько у вас оперативной памяти (в случае Postgres) и сколько CPU (в случае MySQL), данная конфигурация может не сработать. Можно уменьшить объём пула (pool) или значение threads, чтобы сократить число соединений, или число worker’ов, или оба этих параметра. В зависимости от конкретного приложения, все эти решения, вероятно, будут иметь один и тот же эффект: если каждый запрос требует обращений к базе данных, количество подключений к БД всегда будет выступать узким местом, ограничивая число запросов, которые реально обработать. Но если некоторые запросы могут обходиться без обращений к БД, то число worker’ов и потоков можно оставить высоким, а объём пула сделать относительно низким — в результате множество потоков будет обрабатывать запросы, но только небольшая их часть станет конкурировать за подключения к БД в пуле по мере необходимости.
Эту идею можно реализовать другим способом: перевести управление соединениями в код и убедиться, что они эффективно резервируются (checkout) и освобождаются (release). Это особенно важно, если между обращениями к БД делаются сетевые запросы.
Если станет понятно, что управлять соединениями должным образом или настраивать эти цифры затруднительно, и сервер приложений искусственно ограничен пределом на количество подключений к БД, можно будет воспользоваться инструментом вроде pgBouncer, Odyssey, AWS RDS Proxy. Запуск pooling-прокси позволит сделать размер пула равным максимальному числу потоков и попутно обеспечит уверенность в том, что прокси-сервер сделает все максимально эффективно.
Что касается баз данных, PostgreSQL использует обработчики на основе процессов, поэтому приходится проявлять сдержанность в отношении числа соединений при работе с этой БД. MySQL использует потоки, так что количество соединений можно увеличить — хотя это и способно привести к снижению производительности из-за переключения контекста и блокировок.
2. Обработка на основе цикла событий
Node / Deno — первый сервер на основе цикла событий (event loop), который мы рассмотрим. Подобный сервер с конфигурациями по умолчанию способен весьма эффективно использовать ядро процессора, на котором работает, но будет практически полностью игнорировать другие. Да, его внутренние подсистемы и библиотеки вполне могут задействовать и другие ядра, но сейчас мы больше заинтересованы в полезной нагрузке, и поможет в этом кластеризация (clustering). Она достигается путем запуска одного процесса, который принимает все входящие соединения, а затем действует как прокси-сервер и распределяет соединения по другим процессам, работающим на той же машине. У Node имеется модуль кластеризации, входящий в стандартную библиотеку, и популярные серверы вроде PM2 его активно используют. Основное правило здесь состоит в том, чтобы запускать столько же процессов, сколько ядер CPU доступно (конечно, при условии, что памяти достаточно).
Stripe выпустил интересный проект Einhorn, который представляет собой менеджер соединений, существующий вне стека, в котором пишется код. Он запускает собственный процесс, которые принимает соединения, и распределяет их по экземплярам приложения, которые запускает и которыми управляет как дочерними процессами. Инструмент вроде этого очень полезен в системах, основанных на циклах событий, поскольку при возможности всегда будет максимально полно задействовать ядро процессора. Однако сам по себе он не так полезен с Ruby/Python, поскольку, хотя и позволяет запускать несколько процессов, отсутствие потоков означает, что каждый из процессов может обслуживать только один запрос за раз.
Подход на основе кластеризации цикла событий используется системами, которые меняют поведение по умолчанию для языка, основанного на процессах. Сервер Tornado для Python, например, преобразовывает обработчик запросов Python в систему на основе цикла событий, также известную как non-blocking I/O. Также можно настроить его кластеризацию на все доступные ядра CPU (при условии достаточного количества памяти).
Аналогичный подход используется в веб-сервере Falcon для Ruby. В новых версиях Ruby имеется аналог зеленых потоков (green-thread), называемый файбером (fiber), и каждый входящий запрос Falcon обрабатывает с помощью одного такого Ruby-примитива. Файберы не могут автоматически использовать все ядра CPU, поэтому Falcon запускает копию приложения в каждом доступном ядре процессора (опять же, при условии, что памяти достаточно).
Во всех этих случаях нужно настроить пулы соединений в адаптерах БД с целью ограничить число подключений, доступное для каждого процесса. Также можно использовать pooling-прокси, если серверы приложений ограничены лимитом базы данных на соединения, или если управление выделением и высвобождением соединений становится затруднительным.
Redis обрабатывает соединения с помощью цикла событий. То есть он может поддерживать столько соединений, сколько имеется доступных портов, файловых дескрипторов и памяти, при этом каждая операция от каждого соединения обрабатывается по очереди.
3. Внутреннее управление / Кастомная обработка
Образцом в данном случае выступает Go, который, в отличие от всех других примеров, полностью свободен от каких-либо ограничений при обработке запросов — он может по-максимуму использовать доступные ресурсы CPU и памяти. Каждый входящий запрос обрабатывается новой горутиной (goroutine) — легковесной потокоподобной конструкцией. Исполняемая среда Go управляет ей, при этом планирование значительно опережает по эффективности аналогичные механизмы для потоков или процессов. Go автоматически «раскидывает» горутины по всем доступным ядрам CPU, хотя его аппетиты можно несколько обуздать, задав параметр runtime.GOMAXPROCS. Поскольку все происходит в рамках исполняемой среды, память не копируется. Go работает на всех ядрах CPU и не требует запуска отдельной копии приложения на каждом ядре.
Поскольку Go оптимизирован для работы с десятками тысяч параллельных запросов даже на совсем маленьких серверах, его сопряжение с базой данных на основе процессов, такой как PosgreSQL, можно сравнить с картинкой, на которой гоночный автомобиль на полной скорости врезается в кирпичную стену. Если каждая горутина использует SQL-пакет из стандартной библиотеки, будет создано столько соединений, сколько насчитывается горутин — ведь по умолчанию размер пула не ограничен.
Первое, что необходимо сделать с любым приложением на Go, работающем с SQL-базой, — задать предел соединений с помощью SetMaxOpenConns и связанных опций. Go использует внутренний пул с пакетом sql, поэтому каждый запрос будет получать соединение из пула, и сразу после завершения запроса оно будет возвращаться в пул. Другими словами, чтобы провести транзакцию, придется воспользоваться специальными методами, предоставляющими объект, который «обёртывает» открытое соединение, в котором транзакция была запущена — это единственный способ, позволяющий в дальнейшем зафиксировать данные или откатить транзакцию. Это имеет большое значение и при использовании других завязанных на соединение функций, таких как подготовленные выражения или рекомендательные блокировки (advisory locks).
Подобный автоматический подход даёт неожиданное преимущество: он устраняет необходимость в pooling-прокси, поскольку управление соединениями уже осуществляется крайне агрессивно. В вызовы БД по умолчанию встроено эффективное управление соединениями, и соединение используется так же эффективно, как и в pooling-прокси. Системы, устроенные подобным образом, по умолчанию работают эффективно, однако пользователю приходится разбираться с пограничными случаями, когда ручное управление действительно необходимо.
Помимо привычных нюансов, связанных с подготовленными выражениями и рекомендательными блокировками (advisory locks), подобное ручное управление соединениями также потенциально может приводить к проблемам, связанным со взаимной блокировкой. Если задано максимальное число соединений, и некоторым запросам для работы требуется более одного соединения, существует вероятность попадания в порочный круг, в котором запросы бесконечно дожидаются, пока другие запросы освободят соединения. В заметках об определении размера пула для HikariCP приводятся формулы, которые помогают справится с проблемами вроде этой.
Другие языки на основе VM, такие как Java, Scala/Akka, Clojure, Kotlin (все на JVM) и Elixir/Erlang (на BEAM VM) работают аналогичным образом: задействование всех доступных ядер CPU возможно без запуска отдельной копии приложения для каждого ядра. Реализация каждой конкретной системы или библиотеки подключений к БД обычно имеет свои тонкости, однако с ними легко разобраться, используя одну или несколько концепций, приведённых выше.
Свяжитесь со мной в Twitter, если хотите добавить примеры работы других систем, у вас есть какие-либо вопросы/замечания/пожелания, или вы обнаружили ошибку.