Недавно я попробовал pthreads и был приятно удивлен — это расширение, которое добавляет в PHP возможность работать с несколькими самыми настоящими потоками. Никакой эмуляции, никакой магии, никаких фейков — все по-настоящему.
Я рассматриваю такую задачу. Есть пул заданий, которые надо побыстрее выполнить. В PHP есть и другие инструменты для решения этой задачи, тут они не упоминаются, статья именно про pthreads.
Стоит отметить, что автор расширения, Joe Watkins, в своих статьях предупреждает, что многопоточность — это всегда не просто и надо быть к этому готовым.
Кто не испугался, идем далее.
Что такое pthreads
Pthreads — это объектно-ориентированное API, которое дает удобный способ для организации многопоточных вычислений в PHP. API включает в себя все инструменты, необходимые для создания многопоточных приложений. PHP-приложения могут создавать, читать, писать, исполнять и синхронизировать потоки с помощью объектов классов Threads, Workers и Threaded.
Что внутри pthreads
Иерархия основных классов, которые мы только что упомянули, представлена на диаграмме.
Threaded — основа pthreads, дает возможность параллельного запуска кода. Предоставляет методы для синхронизации и другие полезные методы.
Thread. Можно создать поток, отнаследовавшись от Thread и реализовав метод run(). Метод run() начинает выполняться, причем в отдельном потоке, в момент, когда вызывается метод start(). Это можно инициировать только из контекста, который создает поток. Объединить потоки можно тоже только в этом-же контексте.
Worker. Персистентное состояние, которое в большинстве случаев используется разными потоками. Доступно, пока объект находится в области видимости или до принудительного вызова shutdown().
Помимо этих классов есть еще класс Pool. Pool — пул (контейнер) Worker-ов можно использовать для распределения Threaded объектов по Worker-ам. Pool — наиболее простой и эффективный способ организовать несколько потоков.
Не будем сильно грустить над теорией, а сразу попробуем все это на примере.
Пример
Можно решать разные задачи в несколько потоков. Мне было интересно решить одну конкретную и как мне кажется весьма типовую задачу. Напомню ее еще раз. Есть пул заданий, их надо побыстрее выполнить.
Так давайте приступим. Для этого создадим провайдер данных MyDataProvider
(Threaded), он будет один и общий для всех потоков.
/**
* Провайдер данных для потоков
*/
class MyDataProvider extends Threaded
{
/**
* @var int Сколько элементов в нашей воображаемой БД
*/
private $total = 2000000;
/**
* @var int Сколько элементов было обработано
*/
private $processed = 0;
/**
* Переходим к следующему элементу и возвращаем его
*
* @return mixed
*/
public function getNext()
{
if ($this->processed === $this->total) {
return null;
}
$this->processed++;
return $this->processed;
}
}
Для каждого потока у нас будет MyWorker
(Worker), где будет храниться ссылка на провайдер.
/**
* MyWorker тут используется, чтобы расшарить провайдер между экземплярами MyWork.
*/
class MyWorker extends Worker
{
/**
* @var MyDataProvider
*/
private $provider;
/**
* @param MyDataProvider $provider
*/
public function __construct(MyDataProvider $provider)
{
$this->provider = $provider;
}
/**
* Вызывается при отправке в Pool.
*/
public function run()
{
// В этом примере нам тут делать ничего не надо
}
/**
* Возвращает провайдера
*
* @return MyDataProvider
*/
public function getProvider()
{
return $this->provider;
}
}
Сама обработка каждой задачи пула, (пусть это будет некая ресурсоемкая операция), наше узкое горлышко, ради которого мы и затеяли многопоточность, будет в MyWork
(Threaded).
/**
* MyWork это задача, которая может выполняться параллельно
*/
class MyWork extends Threaded
{
public function run()
{
do {
$value = null;
$provider = $this->worker->getProvider();
// Синхронизируем получение данных
$provider->synchronized(function($provider) use (&$value) {
$value = $provider->getNext();
}, $provider);
if ($value === null) {
continue;
}
// Некая ресурсоемкая операция
$count = 100;
for ($j = 1; $j <= $count; $j++) {
sqrt($j+$value) + sin($value/$j) + cos($value);
}
}
while ($value !== null);
}
}
Обратите внимание, что данные из провайдера забираем в synchronized()
. Иначе есть вероятность часть данных обработать более 1 раза, или пропустить часть данных.
Теперь заставим все это работать с помощью Pool
.
require_once 'MyWorker.php';
require_once 'MyWork.php';
require_once 'MyDataProvider.php';
$threads = 8;
// Создадим провайдер. Этот сервис может например читать некие данные
// из файла или из БД
$provider = new MyDataProvider();
// Создадим пул воркеров
$pool = new Pool($threads, 'MyWorker', [$provider]);
$start = microtime(true);
// В нашем случае потоки сбалансированы.
// Поэтому тут хорошо создать столько потоков, сколько процессов в нашем пуле.
$workers = $threads;
for ($i = 0; $i < $workers; $i++) {
$pool->submit(new MyWork());
}
$pool->shutdown();
printf("Done for %.2f seconds" . PHP_EOL, microtime(true) - $start);
Получается довольно элегантно на мой взгляд. Этот пример я выложил на гитхаб.
Вот и все! Ну почти все. На самом деле есть то, что может огорчить пытливого читателя. Все это не работает на стандартном PHP, скомпилированным с опциями по умолчанию. Чтобы насладиться многопоточностью, надо, чтобы в вашем PHP был включен ZTS (Zend Thread Safety).
Настройка PHP
В документации сказано, что PHP должен быть скомпилирован с опцией --enable-maintainer-zts. Я не пробовал сам компилировать, вместо этого нашел пакет для Debian, который и установил себе.
sudo add-apt-repository ppa:ondrej/php-zts
sudo apt update
sudo apt-get install php7.0-zts php7.0-zts-dev
Таким образом у меня остался прежний PHP, который запускается из консоли обычным образом, с помощью команды php
. Соответственно, веб сервер использует его-же. И появился еще один PHP, который можно запускать из консоли через php7.0-zts
.
После этого можно ставить расширение pthreads.
git clone https://github.com/krakjoe/pthreads.git
./configure
make -j8
sudo make install
echo "extension=pthreads.so" > /etc/pthreads.ini
sudo cp pthreads.ini /etc/php/7.0-zts/cli/conf.d/pthreads.ini
Вот теперь все. Ну… почти все. Представьте, что вы написали мультипоточный код, а PHP на машине у коллеги не настроен соответствующим образом? Конфуз, не правда ли? Но выход есть.
pthreads-polyfill
Тут снова спасибо Joe Watkins за пакет pthreads-polyfill. Суть решения такова: в этом пакете содержатся те-же классы, что и в расширении pthreads, они позволяют выполниться вашему коду, даже если не установлено расширение pthreads. Просто код будет выполнен в один поток.
Чтобы это заработало, вы просто подключаете через composer этот пакет и больше ни о чем не думаете. Там происходит проверка, установлено ли расширение. Если расширение установлено, то на этом работа polyfill заканчивается. Иначе подключаются классы-”заглушки”, чтобы код работал хотя бы в 1 поток.
Проверим
Давайте теперь посмотрим, действительно ли обработка происходит в несколько потоков и оценим выигрыш от использования этого подхода.
Я буду менять значение $threads
из примера выше и смотреть, что получается.
Информация о процессоре, на котором запускал тесты
$ lscpu
CPU(s): 8
Потоков на ядро: 2
Ядер на сокет: 4
Model name: Intel(R) Core(TM) i7-4700HQ CPU @ 2.40GHz
Посмотрим диаграмму загрузки ядер процессора. Тут все соответствует ожиданиям.
$threads = 1
$threads = 2
$threads = 4
$threads = 8
А теперь самое главное, ради чего все это. Сравним время выполнения.
$threads | Примечание | Время выполнения, секунд |
---|---|---|
PHP без ZTS | ||
1 | без pthreads, без polyfill | 265.05 |
1 | polyfill | 298.26 |
PHP с ZTS | ||
1 | без pthreads, без polyfill | 37.65 |
1 | 68.58 | |
2 | 26.18 | |
3 | 16.87 | |
4 | 12.96 | |
5 | 12.57 | |
6 | 12.07 | |
7 | 11.78 | |
8 | 11.62 |
Из первых двух строк видно, что при использовании polyfill мы потеряли примерно 13% производительности в этом примере, это относительно линейного кода на совсем простом PHP “без всего”.
Далее, PHP с ZTS. Не обращайте внимание на такую большую разницу во времени выполнения в сравнении с PHP без ZTS (37.65 против 265.05 секунд), я не пытался привести к общему знаменателю настройки PHP. В случае без ZTS у меня включен XDebug например.
Как видно, при использовании 2-х потоков скорость выполнения программы примерно в 1.5 раза выше, чем в случае с линейным кодом. При использовании 4-х потоков — в 3 раза.
Можно обратить внимание, что хоть процессор и 8-ядерный, время выполнения программы почти не менялось, если использовалось более 4 потоков. Похоже, это связано с тем, что физических ядра у моего процессора 4. Для наглядности изобразил табличку в виде диаграммы.
Резюме
В PHP возможна вполне элегантная работа с многопоточностью с использованием расширения pthreads. Это дает ощутимый прирост производительности.
Комментарии (45)
parrker
17.05.2016 09:52+2Вопрос от человека, ни разу не работавшего с потоками: есть ли у такого подхода еще какие-то преимущества, помимо производительности?
DeLuxis
17.05.2016 09:57Ресурсы более рационально тратятся. На многоядерных серверах будут загружены все ядра.
Fesor
17.05.2016 12:29+1Это собственно причина того что производительность улучшается. Но опять же нужно учитывать еще локи, переключение контекста… Словом в WEB оно не столь разумно как event loop в подавляющем большинстве случаев.
DeLuxis
17.05.2016 12:37+1Просто в веб да, но многие пишут всякие выгрузки на PHP и суют их уже в крон.
Или например экспорт отчета в админке Excel файла, собирающийся из всяких под отчетов.
Короче есть где применить, нужно просто иметь ввиду, что есть такая классная штука.Fesor
17.05.2016 19:32всякие выгрузки на PHP и суют их уже в крон.
а это не сетевое взаимодействие? Там простоев из-за сетевых запросов обычно больше чем CPU-time на работу самого пыха.
Короче есть где применить, нужно просто иметь ввиду, что есть такая классная штука.
Иметь в виду — конечно стоит. Но нужно так же знать о других вариантах (мультиплексирование потока выполнения, корутины, очереди + процессы) и выбирать из них. А так для тредов есть свои задачи.
mnv
17.05.2016 10:00+1У каждого подхода свои преимущества и недостатки. Подходы:
- Создаем общедоступную очередь, например, на Beanstalk, RabbitMQ или Redis или еще на чем-нибудь. Создаем PHP скрипт, который будем запускать из консоли несколько раз, создавая нужное количество процессов. Это решение наиболее универсальное.
- Плюсы. Хорошая масштабируемость на несколько серверов, отказоустойчивость.
- Минусы. Может быть неудобно или непрозрачно с точки зрения архитектуры. Если в обработке данных несколько “узких горлышек”, то возможно, понадобится предусмотреть несколько очередей.
- Создавать потоки через Curl, для такого решения есть даже проект на гитхабе.
- Плюсы. Мне неизвестны.
- Минусы. Ненадежно.
- Использовать popen().
- Плюсы. Просто с первого взгляда.
- Минусы. Сложно организовать равномерную загрузку ядер. Трудности в создании общей очереди.
- Написать собственное расширение для PHP и пользоваться им.
- Плюсы. Можно сделать полный фен шуй.
- Минусы. Затратно.
- Воспользоваться расширением PCNTL. Насколько это удачное решение, возможно, кто-то расскажет в комментариях.
- Воспользоваться готовым расширением pthreads.
- Плюсы. Надежность. Можно прятать многопоточное поведение внутри модуля, не выносить на уровень архитектуры. Простота в создании общей очереди.
- Минусы. Нельзя масштабировать на несколько серверов.
Fesor
17.05.2016 13:03+1Все кроме последнего никакого отношения к потокам не имеет. Это порождение процессов.
Минусы. Сложно организовать равномерную загрузку ядер. Трудности в создании общей очереди.
нет никаких проблем, просто мастер процесс должен распределять задачи воркерам, а коркеры должны жить всегда.
Написать собственное расширение для PHP и пользоваться им.
Все уже написано. Имеет смысл только написало аналог микротредов (корутины + пул тредов), но в теории это можно и на userland сделать.
Воспользоваться расширением PCNTL
плюсы: это полный контроль за дочерними процессами, возможность управлять их жизненным циклом, организовывать обмен сигналами. Минус — это всеравно процессы, они жирные, их нужно один раз порадить и держать в пуле, желательно из мастер процесса который только монитори процессы, что-то типа супервизора.
Так же есть отдельные экстеншены для того что бы организовать общую память между процессами, так что можно добиться прикольных вещей имея при этом свое адресное пространство для каждого процесса (безопаснее) + немного общей для кэшей. Правда тут уже нужно опять же вводи локи или разбираться с lock-free программированием и тут я не уверен что это можно делать красиво в php.
Воспользоваться готовым расширением pthreads.
На самом деле для WEB в 95% случаев все упирается в эффективность работы с I/O и тут явный лидер корутины/event loop так как нет накладных расходов на переключение контекстов. А что бы эффективнее использовать ресурсы можно просто увеличить количество процессов.
Треды хорошо подходят для каких-то массивных вычислений, хотя тут уже вопрос зачем нам PHP если мы можем написать многопоточную програмку на Си с векторизацией вычислений и получить 100x профита.
Так что использование тредов в PHP я считаю экзотикой нежели чем-то важным и необходимым. Хотя понимать минусы использования тредов — это важно.
zapimir
17.05.2016 14:35+1хотя тут уже вопрос зачем нам PHP если мы можем написать многопоточную програмку на Си с векторизацией вычислений и получить 100x профита
Ну например если весь проект на PHP, то зачем для одной задачи искать программера на Си. На первое время решения на PHP хватит с головой, тем более, если речь о PHP 7.Fesor
17.05.2016 16:45На первое время решения на PHP хватит с головой, тем более, если речь о PHP 7.
Если речь идет про объемные вычисления, они для начала должны легко паралелиться, иначе объем работ на паралелизацию может слихвой покрыть разницу php vs С.
Если алгоритм легко паралелится — то никаких проблем но "первое время" может быстро закончится если мы зависим от количества данных и оно увиличивается. У меня был на проектике скриптик с k-means, написанный на коленке потому что так быстрее. Его "первое время" закончилось через 2 недели, когда обработка данных стала занимать по 5 минут на запуск (100КК итераций). Переход на PHP7 а потом на HHVM снизил время в 2 раза но с объемами данных этот "профит" быстро бы невилировался. Распаралелить его обошлось бы довольно дорого, в итоге просто применили другой алгоритм кластеризации реализованный на java (потому что готовый и потому что реализовывать его на PHP сильно дорого вышло бы).
phantomd
17.05.2016 14:39+1Скажем так, у меня была такая задача, реализована на PCNTL, суть заключалась в том, что регулярно поступает достаточно большой набор ссылок на картинки для каталога товаров из импорта и требуется произвести конвертацию в формат сайта. Деление на задачи производится по домену и у каждого запускается свой пул потоков в соответствии с настройками. В рамках нагрузки на систему, каждый поток незначительный, но требуется обработать максимум в кратчайшие сроки. На мой взгляд дешевле и быстрее такое сделать на PHP, чем на Си.
Если использовать расширение pthreads + PCNTL, то можно сократить количество процессов и выиграть в производительностиFesor
17.05.2016 16:41На мой взгляд дешевле и быстрее такое сделать на PHP, чем на Си.
На мой взгляд вам в вашей задаче потоки не нужны. Берем очередь, берем парочку процессов-воркеров обрабатывающих очередь, в каждом воркере будут крутиться корутины/event loop (amphp, reactphp, recoil). Итого имеет малое количество процессов, отсутствие оверхэда на создание потоков/переключение контекста, отсутствие блокировок, максимальную утилизацию CPU, максимальный перформанс. Ну и делать это даже проще чем на тредах.
- Создаем общедоступную очередь, например, на Beanstalk, RabbitMQ или Redis или еще на чем-нибудь. Создаем PHP скрипт, который будем запускать из консоли несколько раз, создавая нужное количество процессов. Это решение наиболее универсальное.
mib
17.05.2016 10:31+1А если например нужно использовать mysqli?
На пример: задача в том, чтобы в несколько потоков брать записи из одной таблицы, проводить манипуляции над данными, а результат записывать в другую таблицу в произвольном порядке?
При этом потоки не должны читать одну и ту-же запись, но должны гарантированно прочитать все записи.
Объект mysqli нельзя «расшаривать» между потоками.ollisso
17.05.2016 10:441. создаёте mysqli объект после разделения на потоки — у каждого потока будет своё подключение.
2. делаете очередь в базе, там дополнительные столбцы: «бот который взял на обработку».
в момент взятия задачи на обработку, делаете лок:update table set bot=BOT_ID where bot=0;
3. В добавок, нужно сделать какой нибуть механизм, который будет разлочивать строки ботов которые зависли.mib
17.05.2016 11:00Я вместо этого делал так: каждый поток читает все строки таблицы, но обрабатывает только те, которые должен.
Ну там несложные вычисления: каждый поток знает свой номер и общее количество потоков, ну и отсчитывает каждую X строчку со сдвигом Y
GennPen
17.05.2016 11:49Делал примерно так же, но присваивал значение timestamp. И отдельным потоком просматривал все записи с значением меньше, чем «timestamp — время на таймаут», если такие появлялись — давал им еще несколько попыток обнуляя значение, затем блокировка записи с присвоением значения заведомо бОльшим.
phantomd
17.05.2016 15:32У меня было реализовано так, по некому группирующему параметру из источника данных создавалась задача, в отдельной таблице. Далее, при чтении из источника, полученная строка сразу же удалялась. Одновременно может работать любое количество потоков без пересечения. Я делал это на Redis, там своя специфика работы на сокетах и как такового persistent connection просто не существует. В MySQL таких проблем нет.
Если не удалять записи, а устанавливать некий параметр, аля bot_id, то всё равно нужно как-то чистить обработанные записи, чтобы не перегружать таблицу.ollisso
17.05.2016 16:44В вашем случае, есть возможность что задача будет удалена, хотя она не была выполнена (Т.е. создаём задачу, бот её сразу же забирает, и потом бот почему то падает.). Лучше удалять задачу после того как она взята и выполнена. Т.е. когда «берём задачу» — её лочим, но не удаляем. Когда сделали — удаляем.
AlexLeonov
17.05.2016 11:57+3Воспользоваться расширением PCNTL. Насколько это удачное решение, возможно, кто-то расскажет в комментариях.
Это вполне себе удачное решение. Только вы должны понимать, что pcntl — это многопроцессность, а не многопоточность.
Плюсы:
- Процессы независимы, каждый из них выполняется изолированно, его время жизни никак не зависит от других процессов
- Расширение pcntl работает везде и из коробки (кроме Windows по понятным причинам)
Минусы:
- Форк — не самая дешевая операция
- N процессов требуют *N памяти
- Межпроцессное взаимодействие вам нужно выстраивать самостоятельно
- После форка дочерний процесс теряет контекст (подключения к файлам, БД, прочим ресурсам), его нужно восстанавливать
В целом мне pcntl нравится и он находит своё применение
akalend
17.05.2016 12:19+3Похоже, это связано с тем, что физических ядра у моего процессора 4
так и есть, все рекомендации сводятся к тому, чтоб запускать по одному потоку на ядроFesor
17.05.2016 13:05Это связано с переключением контекста. Чем больше потоков, тем чаще нам нужно переключаться, а операция эта не дешевая.
m_z
17.05.2016 13:35Всё зависит от того, чем потоки занимаются. У меня сейчас на 8 ядрах запущено 2600+ потоков и всё летает. А если 8 потоков заняты вычислениями, загружая каждое ядро на 100%, то, естественно, что не делай, они не начнут быстрее выполнять свою работу.
akalend
17.05.2016 12:20-1к сожалению это не применимо для работы с БД
GrizliK1988
17.05.2016 12:32+1Почему? Даже если открыть N коннектов и работать с каждыми из них по отдельности?
GrizliK1988
17.05.2016 12:31-2Бесспорно полезное расширение, однако надо всегда держать в уме, что PHP интерпретируемый язык, который итак имеет достаточно накладных расходов на выполнение своих скиптов. Так что распараллеливание может и дать достаточное ускорение работы, но при этом забрать существенно больше ресурсов чем ожидается, так как все обертка на posix threads все же будет добавлять некий оверхед. Так же, если есть нужна проводить тяжелые операции на сервере (которые требуют оптимизации распараллеливанием), то это повод задуматься как это в будущем будет развиваться и, возможно, стоит какую то часть функционала переписать в виде C расширения, например.
Fesor
17.05.2016 13:07так как все обертка на posix threads все же будет добавлять некий оверхед
Незначительный, им можно принебречь. Но в остальном согласен, event loop справляется лучше, а если нужно нагрузить все ядра — просто делаем больше процессов.
coh
17.05.2016 13:43Все бы хорошо, если не большое количчество нюансов, ограничей и отличия работы PHP c pthreads от PHP как такового.
Нельзя быть уверенным, что стандартная языковая конструкция будет работать корректно.
Пример: https://github.com/krakjoe/pthreads/issues/52
И многое другое, типа позднего статического связываня и наследования… Достаточно взглянуть https://github.com/krakjoe/pthreads/issues
Как эксперимент, очень интересная библиотека. В продакшен?… врядли.
Metus
17.05.2016 14:27+3pthreads хорош, но стоит упомянуть и про подводные камни.
Есть возможности "пронаследовать" какую-то часть окружения в поток (причём, по умолчанию это не "ничего"), но в ряде случаев вылезают WTF. Например, если в мастере был подключен автозагрузчик композера через require_once, он не подключится в потоке аналогичном образом.
Ресурсы, файловые дескрипторы и прочее не шарятся. Потому лучше стараться запускать потоки через PTHREADS_INHERIT_NONE, а внутри производить собственные подключения к БД, логи (монолог умеет писать с блокировками) и т.д.
Общение между потоком и мастер-процессом происходит с чем-то вроде сериализации, потому вы не сможете вернуть в мастер Closure, т.к. тот не сериализуем. Есть особенности и с другими типами данных, например с массивами.
Если вы захотите вернуть из потока вложенный массив и сделаете так.
$this->result = ['hello' => ['foo' => 'bar']]
То можете словить ошибку, так как это будет преобразовано в Volatile-объекты и при попытке считать данные в мастере они будут уже уничтожены сборщиком мусора.
Самый простой "способ" в таком случае, это явно приводить к массиву:
$this->result = (array) ['hello' => ['foo' => 'bar']]
Подробнее здесь: http://stackoverflow.com/questions/14796674/a-php-pthreads-thread-class-cant-use-array
В целом, от меня общий совет — каждый поток должен быть максимально независим от мастер процесса. Обмен, по возможности, производить скалярными данными.
Также на некоторых конфигурациях систем (например у меня такое происходит Debian 7 и pthreads 3) могут вылетать ошибки сегментирования. С чем это точно связано я не знаю, но скорее всего с версиями каких-то библиотек.
ArthurKushman
17.05.2016 16:53Молодец Николай, редкую тему поднял (в контексте php/pthreads) и с конкретными примерами — привет от бывшего коллеги.
Acuna
25.05.2016 01:25Невероятно! Надо бы им на досуге интро на php.net перевести в качестве благодарности…
keltanas
25.05.2016 09:01Судя по графикам у меня создалось впечатление, что это все равно не мультипоточность, а мультипроцессовость, которые в PHP почему-то постоянно путают.
Fesor
25.05.2016 15:26На самом деле разница между многопроцессностью и многопоточностью проявляется только в общем потреблении ресурсов (потоки меньше жрут, что как бы логично) и в меньшем времени, необходимом на старт. А графики выглядели бы примерно так же. Просто с потоками не нужно париться о организации IPC
vadimr
Всё-таки, если порядок обработки заданий не важен, то это не очередь с заданиями, а пул с заданиями. Лучше использовать корректную терминологию.
mnv
Да, исправил.