Pull to refresh

Comments 47

Мы для синхронизации потоков в критичных местах использовали семафоры Linux, надежнее некуда. В остальных случаях (чаще) используем блокировки через Memcached.
Вы смешиваете две совершенно разные проблемы.

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

Совершенно другое дело — коллизии внесения изменений (ваш пример с гитхабом). Тут ведь дело не в параллелизме как таковом, тут дело в том, что данные, использовавшиеся для создания изменений, устарели на момент их внесения. И тут сериализация не поможет, потому что в большей части случаев никто вам не даст устраивать пессимистические блокировки данных на все время работы пользователя, а, значит, нужно переходить на совершенно другие примитивы, нежели простая синхронизация.

И, кстати, пример с гитхабом наглядно показывает, что коллизии внесения изменений однозначного решения не имеют: благодаря тому, что гитхаб — это версионное хранилище, никто не мешает просто создать два независимых коммита (точнее, две staging area), выбор между которыми (если он вообще нужен) пользователь сделает самостоятельно. В типовых LOB-системах это совсем не так тривиально.
На примере гита я хотел показать другое, не вопрос потери данных. Но пример действительно получился плохим. Надо придумать другой…
С примером под счетчик все вообще тривиально: там нужно отслеживать количество свободных слотов + количество идущих закачек, пока они одно меньше другого, позволять закачивать заново.
Нет, не тривиально. Несколько запросов параллельно могу сравнить и получить разрешение на закачку. И, соответственно, закачать. Решение здесь есть через UPDATE… fact=fact+1… WHERE id = x AND limit < fact, а потом посмотреть affected rows — если оно больше нуля, то принять закачку. Но это не тривиальное решение. И я не думаю, что все повально им пользуются.

Причем, это вообще будет работать только если есть fact. А если нет, то нужно вести подсчет на лету, и это уже не прокатит.
Несколько запросов параллельно могу сравнить и получить разрешение на закачку.

Так каждый запрос должен получать разрешение и уменьшать лимит в рамках одной транзакции.
Ниже уже разобрались, что для MySQL с уровнем изоляции по умолчанию это не так.
Так надо использовать не «по умолчанию», а подходящий для задачи.
Ну то есть либо выбрать другой уровень изоляции (для MySQL в данном случае потребуется SERIALIZABLE со всеми вытекающими), либо выбрать другое решение.
Зачем вам serializable, если вам достаточно read committed (даже не repeatable read!) + select with shared lock? И какие «все вытекающие» здесь будут, по-вашему?
Вытекающие будут из serializable. А на счет read committed + select with shared lock, это уже несколько другая плоскость. Тут требуются явные дополнительные действия (как и в случае с обычными писсимистическими блокировками вне БД). Ну и требуется отлавливать падения на дедлоках (именно так оно выглядит в случае MySQL) и запускать транзакцию снова и снова, пока она таки не пройдет успешно. Об этом надо как минимум знать. И это не всегда удобно. А у 51 % опрошенных «этот вопрос вообще никак не стоит».

Оба решения имеют свои плюсы и минусы. Оптимистические блокировки не всегда лучше, чем писсимистические. А когда мы выходим за пределы БД, использование транзакций для синхронизации может стать проблемой. Так что в идеале нужно выбирать каждый раз индивидуально. И с этим могут быть проблемы, потому что мало кто из PHP-программистов на столько хорошо владеет темой, как, например, вы.
Вытекающие будут из serializable.

Какие именно?

А на счет read committed + select with shared lock, это уже несколько другая плоскость. Тут требуются явные дополнительные действия

Это какие же? Прописать with shared lock в sql-команду?

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

… как и в случае потери связи с СУБД. В норме у вас не должно быть дедлоков.

А когда мы выходим за пределы БД, использование транзакций для синхронизации может стать проблемой.

Вот поэтому транзакции используются не для синхронизации, а для обеспечения консистентности данных.
Я думаю, что как только php-разработчик начинает писать хотя бы cron-сервис, то он уже сталкивается с проблемой коллизий. Самый банальный пример: вызов сервиса раз в минуту и ситуация, когда время его выполнения превышает эту минуту. Те, кто не пишет приложения, вызываемые из CLI, пожалуй редко сталкиваются с подобными проблемами.
У нас в проекте, к примеру, все cron-сервисы имеют блокировку. Также работают любые финансовые операции в системе. Есть разумеется и обёртывание в транзакции. Есть также и сервисы для callback запросов, там тоже приходилось ставить блокировки во избежании коллизий.
Но кстати, для регистрации блокировок используется БД, а не memcached. В Redis необходимости не было, а ради одной такой задачи ставить целый сервис смысла нет.
Я в случае подобных cron сервисов просто делаю проверку не запущен ли уже процесс. Если запущен, то выхожу из текущей копии и все.
Можно и так.
В нашем случае есть абстрактный класс, имеющий в себе механизм блокировки, от которого наследуются классы сервисов (а их сейчас около 15). Запускается это например как «php cron.php --s=mailSender». Поэтому проверять наличие процесса в памяти уже было бы не слишком удобно. А использовать запись/проверку pid в файлы в общем-то те же яйца, что таблица блокировок в БД.
Если процессы запускаются на одной машине — то самый простой вариант реализации мьютекса — flock. Проверяем — можем ли получить lock — остальное за нас сделает *nix.
Вот интересно. Большая часть участников опроса полагается на транзации. Гораздо меньше людей используют блокировки. 55 % сообщают, что вопрос блокировок у них не стоит вообще. Но при решении проблемы с файлом, которая приведена в примере (т.е. проблема счетчика-ограничителя в общем случае), транзакция поможет только при уровне SERIALIZABLE. А это убивает производительность. Кроме того, по умолчанию в MySQL стоит REPEATABLE READ. Не думаю, что все повально его меняют на SERIALIZABLE. А это очень распространенная задача.

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

Воспроизвести пример и поэксперементировать с ним можно с помощью этого:
test.php
test.sql

Это очень странно, учитывая, что 40 % опрошенных отметили, что разбираются в теме.

Как задача счетчика-ограничителя решается у вас?
Я не понимаю, почему вы противопоставляете транзакции и блокировки. Транзакция внутри себя использует блокировки. Соответственно, serializable-транзакция — это пессимистичная блокировка со всеми вытекающими отсюда (например, потерей производительности при одновременных запросах). Если вам нужна повысить производительность — вы переходите на оптимистичные сценарии блокировки (с проверкой условий в момент завершения транзакции), при этом усложняя логику и повышая вероятность отказа транзакции на поздних этапах.

Кстати, в задаче со счетчиком достаточно уровня изоляции repeatable read.
Транзакция внутри себя использует блокировки

Блокировки блокировкам рознь. Полная защита работает только при максимальном уровне изоляции — SERIALIZABLE. Т.е. я вроде не противопоставляю блокировки и транзакцию, а говорю о том, что просто транзакции не достаточно. Вообще, исходная задача транзакции несколько иная — обеспечить возможность отката пачки изменений. А не синхронизация. Но по факту функционал транзакций позволяет использовать их для сихронизации при соблюдении условий и выбора соответствующего уровня изоляции.

Кстати, в задаче со счетчиком достаточно уровня изоляции repeatable read.

Не достаточно. Очень легко запустить приведенный по ссылкам выше код на любой машине, где есть PHP и MySQL. А после просто удалить файл и выполнить блок очистки, чтобы мусора не осталось. Попробуйте запустить его с уровнем REPEATABLE READ.
Вообще, исходная задача транзакции несколько иная — обеспечить возможность отката пачки изменений. А не синхронизация.

Транзакция — это ACID. Вы сейчас говорите только про atomicity. А синхронизация — это consistency/isolation. Так что (правильно примененная) транзакция — это блокировки + еще что-то.

Не достаточно. Очень легко запустить приведенный по ссылкам выше код на любой машине, где есть PHP и MySQL. А после просто удалить файл и выполнить блок очистки, чтобы мусора не осталось. Попробуйте запустить его с уровнем REPEATABLE READ.

У меня нет ни PHP, ни MySQL. Но, если на пальцах, то проблемы возникать не должно, потому что repeatable read в момент первого селекта должен заблокировать запись на чтение для всех, кроме этого потока. Теперь добавим второй поток (для простоты, его селект происходит всегда после селекта в первом потоке). У нас получается два сценария:

  1. инсерт (второй, первый никому не интересен) первого потока случается раньше второго. Этот инсерт напарывается на блокировку, выставленную вторым потоком при селекте, и начинает ждать, пока ее отпустят. Дальше случится одно из двух (в смысле, одно случится раньше другого): либо закончится таймаут, инсерт провалится, транзакция откатится, либо второй поток тоже запустит инсерт. Надо понимать, что инсерт второго потока тоже наткнется на блокировку (выставленную первым потоком), и дальше уже случится дедлок, который либо вывалит первую (она раньше) транзакцию с таймаутом, либо будет разрешен движком СУБД в любую сторону.
  2. инсерт второго потока случится раньше первого. Он напорется на блокировку, выставленную первым… а дальше все аналогично первому сценарию, либо таймаут, либо дедлок.


Что характерно, инвариант будет сохранен в обоих случаях.

Repeatable read не работает, когда у вас проверки идут поверх диапазона строк (например, вы считаете фактически загруженные файлы как число строк для данного пользователя). Вот там нужен serializable.

Но это мы еще не переходили к оптимистическим блокировкам вообще и их реализации на основе версионирования строк в частности (SNAPSHOT isolation и READ COMMITTED SNAPSHOT).
REPEATABLE READ ничего не блокирует. А SERIALIZABLE блокирует. Об этом написано. И это прослеживается на практике. Ниже в спойлере копипаст из консоли mysql. После первого селекта в другой консоли было изменено значение fact на 2. Как видно, UPDATE в данной странзакции сделал его 3-кой.

Копипаст из консоли mysql
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT `limit`, fact FROM users WHERE id = 1;
+-------+------+
| limit | fact |
+-------+------+
|     2 |    1 |
+-------+------+
1 row in set (0.00 sec)

mysql> UPDATE users SET fact = fact + 1 WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT `limit`, fact FROM users WHERE id = 1;
+-------+------+
| limit | fact |
+-------+------+
|     2 |    3 |
+-------+------+
1 row in set (0.00 sec)



И я еще раз подчеркиваю, что да, возможности синхронизации через БД тут есть, но:
  1. они не работают по умолчанию, т.е. нужно менять уровень изоляции;
  2. требуется такой уровень изоляции, который под нагрузкой все уложит и преведет к дедлокам на дедлоке в случае MySQL.


Так что не делая здесь хоть какую-то явную блокировку, получаем дырку. А теперь вопрос, в каком количестве случае это делается правильно, а об этом даже не думают?
REPEATABLE READ ничего не блокирует.

А, так это передайте привет конкретному MySQL, у которого REPEATABLE READ по умолчанию работает через снепшоты (т.е. заточен под оптимистическую блокировку). Там, кстати, дальше написано, что с этим делать: FOR UPDATE или LOCK IN SHARE MODE (второй приводит REPEATABLE READ к стандартному поведению, которое я и описал).

Вот вам цитата из доки на MS SQL (выделение мое):
REPEATABLE READ

Specifies that statements cannot read data that has been modified but not yet committed by other transactions and that no other transactions can modify data that has been read by the current transaction until the current transaction completes.


они не работают по умолчанию, т.е. нужно менять уровень изоляции;

Они не работают по умолчанию на MySQL, есть разница.

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

… в случае любой БД, если реализовать криво.

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

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

Это, повторюсь, специфика конкретного MySQL. Делая все то же самое на MS SQL, «дырки» вы не получите; а делая все то же самое на EntityFramework поверх MS SQL, можете получить еще и работающую оптимистичную блокировку взамен пессимистичной. Вопрос выбираемых вами инструментов и понимания их работы.
В MySQL много чего кривого, но только работает она быстро и жрет относительно мало. Плюсов тоже хватает. И ей дефакто пользуются практически все PHP-девелоперы. А весь топик посвящен именно PHP-девелоперам.

Можно углубляться сколько угодно, только вот у задачи со счетчиком есть конкретное решение, которое будет прекрасно работать и под нагрузкой (и я не согласен, что нагрузка и блокировки не совместимы). Так как в данном примере мы блокируем только одного пользователя, и это защита от взлома, она не будет проявляться при нормальной работе. Так вот достаточно добавить ограничение на количество открытых динамических запросов с одного IP и/или от одного пользователя. Первые N запросов от него действительно будут поедать память сервера и «висеть» на блокировке, но после превышения лимита запросы будут сразу отваливаться. А ожидание блокировки должно быть определено таймаутом, после которого и ждущие будут тоже отваливаться. Ну и не нужно делать блокировки, которые будут висеть десятки секунд. И транзакции тут тем более не подойдут. Когда время ожидания большое, нужно действовать асинхронно.

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

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

Так вот достаточно добавить ограничение на количество открытых динамических запросов с одного IP и/или от одного пользователя.

… и как вы его сделаете без транзакции?

И транзакции тут тем более не подойдут.

Просто готовьте их правильно, и все будет хорошо.

Хотя да, с тем, что «если все сделать правильно, будет хорошо работать под большой нагрузкой», я не спорю.
Так вот достаточно добавить ограничение на количество открытых динамических запросов с одного IP и/или от одного пользователя.
… и как вы его сделаете без транзакции?

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

У меня такая штука сделана без синхронизации и без транзакции, я ее тестил через ab, результаты были очень хорошие.
Т.к. здесь допустима погрешность

Если погрешность допустима, то можно просто использовать неблокирующее решение, в чем проблема-то?

Но вообще, если уже придираться, то можно использовать атомарный инкремент в Memcache или Redis.

… который фактически тоже транзакция, просто без D.

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

… аналогично.
Если погрешность допустима, то можно просто использовать неблокирующее решение, в чем проблема-то?

Погрешность допустима при подсчете количества запросов, которые могут работать одновременно для пользователя и/или IP. Будет их 10 или 12, разницы никакой (ну ~100 Кб памяти разница на время таймаута блокировки). А вот если применить неблокирующее решение задачи, то мы получим взлом системы — юзер загрузит больше файлов, чем можно. Это уже проблема. Ну или я неправильно вас понял. Потому что если под неблокирующим решением вы понимали lock-free алгоритмы, т.е. тот же CAS, то такое решение я уже выдавал в самом начале — UPDATE table SET fact = fact + 1 WHERE id=1 AND limit > fact + affected rows.
Погрешность допустима при подсчете количества запросов, которые могут работать одновременно для пользователя и/или IP. Будет их 10 или 12, разницы никакой (ну ~100 Кб памяти разница на время таймаута блокировки).

Ну то есть у вас на самом деле внутри все равно честная транзакция с блокировкой, просто вы снаружи поставили дополнительную, более дешевую блокировку, чтобы уменьшить количество потенциально заблокированных потоков внутри. Так тоже можно, только вы сложность системы увеличили вдвое, тем самым удорожив поддержку — а тут уже вопрос, что дешевле, ускорять транзакции или тратить на поддержку.
Истину глаголите в частном случае. Но в общем случае, надо отметить, что данное ограничение на количество висящих коннектов у меня существует не для и не только из-за блокировок. Даже если бы не было ни единой блокировки, все равно была бы эта защита от DoS. Т.е. я не плачу дополнительно, а просто пользуюсь и так работающим функционалом.
… а внутри у вас обычные транзакции с блокировками. Что как бы демонстрирует нам, что это нормальное работающее решение.
UFO just landed and posted this here
Ага, по крону удалить успешно закачанный файл…

А представьте, что мы не файлы закачиваем, а серверы выдаем. Или что-то еще дороже.

Нет здесь косяка — надежное соблюдение бизнес-логики — это обязательное требование. Или можете считать так: в моем примере это именно обязательное требование со стороны бизнеса.
Вы про eventual consistency не слышали?
А вот при попытке повторить тоже самое при SERIALIZABLE я уже как и ожидалось получаю блокировку, а в итоге второй запрос получает:

#1213 - Deadlock found when trying to get lock; try restarting transaction 


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

Опять же копипаст из консоли
mysql> SET SESSION tx_isolation='SERIALIZABLE';
Query OK, 0 rows affected (0.00 sec)

mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT `limit`, fact FROM users WHERE id = 1;
+-------+------+
| limit | fact |
+-------+------+
|     2 |    3 |
+-------+------+
1 row in set (0.01 sec)

mysql> UPDATE users SET fact = fact + 1 WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT `limit`, fact FROM users WHERE id = 1;
+-------+------+
| limit | fact |
+-------+------+
|     2 |    4 |
+-------+------+
1 row in set (0.00 sec)

mysql>



Так что точно могу сказать, что здесь все совершенно не тривиально, как это кажется изначально. SNAPSHOT isolation и READ COMMITTED SNAPSHOT в MySQL не. (MySQL — это default СУБД для PHP-разработчика.)
READ COMMITTED SNAPSHOT в MySQL не.

Как раз наоборот, он включен там по умолчанию, из-за чего вы и получаете поведение, описанное в документации.
Могу ошибаться, но в вашем случает стоит использовать SELECT FOR UPDATE.
UFO just landed and posted this here
А блокировки можно делать штатными средствами MySQL, без flock и т.д.

Ну это если MySQL есть :) Ну и возможности блокировок там все же ограничены.

Через несколько лет возможно появится в проектах.

Спрос очень мелкий… А проблем оно может притянуть массу. Так что может и не появиться. Вот какие реальные юзкейсы, где многопоточность выиграет у многозадачности (=многопроцессности)?
Вот какие реальные юзкейсы, где многопоточность выиграет у многозадачности (=многопроцессности)?

Проверка статуса выполнения задачи на n удаленных агентах.
Если это веб-интерфейс, то скорее всего лучше будут асинхронные запросы на проверку каждого агента отдельно. Тогда если один или несколько агентов будут долго отвечать, это будет видно интерактивно.

А вот если это консольное приложение, демон (сервис)… Тут момент такой, что ждать ответа по сети можно и асинхронно. Для этого не нужно заводить треды. Тот же eventloop отлично подойдет. Особенно, если агентов много (ну сотни, например). Ну под линуксом во всяком случае. Треды они все же нужны для активной работы, а не пассивной, имхо. Но тут я могу быть неправым, т.к. сам-то их никогда почти и не юзал…
Если это веб-интерфейс, то скорее всего лучше будут асинхронные запросы на проверку каждого агента отдельно. Тогда если один или несколько агентов будут долго отвечать, это будет видно интерактивно.

… тем самым вы (а) перекладываете сложность на сторону клиента и (б) нагружаете канал связи между клиентом и сервером (ну и (ц) увеличиваете потребление ресурсов на сервере, но тут уж ничего не скажешь).

Тут момент такой, что ждать ответа по сети можно и асинхронно.

Его и в веб-интерфейсе можно ждать асинхронно. Только немедленно возникает вопрос — а нужно ли программисту думать над собственной реализацией событийной модели, или можно как-то иначе себя развлечь. И тут немедленно начинает выигрывать тот фреймворк, в котором computation-bound work и IO-bound work реализованы через одну абстракцию.

Ну и да, вычислительные работы тоже случаются иногда.
Как вариант решения для данного задачи можно использовать ReactPHP лишь в одном процессе. Тут больше нужна асинхронность, нежели многопроцессность.

upd: уже ответили, опоздал.
Автору спасибо! Хорошую тему создал.

Но проблема освещена с неверного угла.
В вебе существует сервер и множество параллельных клиентов. Они дерутся за ресурсы.
Чтобы данные были согласованы при параллельном доступе необходимо транзакции.
Существуют 2 типа транзакций: системные транзакции(БД, API, etc) и бизнес транзакции.

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

Но большинство проблем начинается тогда, когда необходима 1 транзакция на несколько запросов.
В данном случае на 1 бизнес транзакцию приходится несколько запросов и соответственно несколько системных транзакций.
На помощь приходят паттерны Optimistic Offline Lock и Pessimictic Offline Lock.

Первый паттерн великолепно реализован в PHP Doctrine 2(смотрите в документации Doctrine 2 ORM 2 documentation — 13.2.1. Optimistic Locking).
Необходимость второго паттерна возникает редко. Когда возникает — реализую в минимально простом виде(в двух словах паттерн не описать, погуглите).

Т.е. в общем и целом всё отлично в PHP с параллелизмом!
Знаете, прижелании можно и в NodeJS впарить пногопоточность. Но зачем?

П. С. image
Коммюнити PHP такое коммюнити. А ведь парень дело говорит :/ Я и сам сначала подумал, что это просто биндинги под `pthread`.
На мой взгляд, большинство разработчиков не вникает в этот вопрос, так как большинство проектов на рынке не требуют консистентность данных. Разумеется, консистетность является желательной в 100% проектов, но при этом в 95% из них она нужна не настолько сильно, чтобы тратить ресурсы на её поддержание.
То есть на вопрос «почему разработчики не уделяют внимание параллельным запросам» самый простой ответ — это не востребовано рынком.
Sign up to leave a comment.

Articles