Введение
Сегодня мы поговорим о блокировках и покажем свою реализацию. Каждый из разработчиков не раз сталкивался с проблемой, когда необходимо обеспечить однопоточное использование какого-либо ресурса.
Часто для обеспечения такой блокировки используется схема с созданием специального файла, наличие которого определяет факт занятости того или иного ресурса.
Такой подход достаточно прост в реализации, но имеет ряд недостатков. Среди недостатков можно выделить:
- отсутствие 100% гарантии блокировки при большом количестве потоков;
- блокировка работает в рамках одного сервера;
- и самое неприятное – если процесс, который поставил блокировку почему-то её не снял, то остальные процессы так и не смогут получить доступ к этому ресурсу, пока вручную или каким-то другим способом эта блокировка не будет снята.
Когда нужны блокировки?
Каждый раз потребности разные, в основном они сводятся к исключению одновременных повторных действий, обеспечению последовательной работы с каким-то ресурсом, обеспечению равномерной нагрузки.
Как сделать самому?
Чтобы реализовать правильные блокировки, нужно понимать принципы атомарности и транзакционности. Их мы описывать в данной статье не будем, т.к. в интернете есть уже много информации на эти темы.
При реализации мы определили основные операции при работе с блокировками:
- взять блокировку на определённое время
- снять блокировку через заданное время
- продлить блокировку
- отработать кейс когда блокировка была перехвачена
На самом деле не очень важно, что будет использоваться в качестве провайдера блокировок, это могут быть и файлы, и mysql, и memcache, и любой другой удобный вам инструмент.
Нам был ближе Redis, поэтому мы реализовали свой механизм блокировок, который не имеет недостатков перечисленных выше, на Redis-е.
Как сделаны блокировки у нас
Сегодня мы представляем вам нашу реализацию «как есть» с дополнительными комментариями почти к каждой строке и примером использования.
Наша реализация используется в проекте на фреймворке Yii и использует для подключения к Redis через библиотеку Rediska. Но завязка на Yii, как и на Rediska, небольшая, поэтому этот код можно будет использовать в любом проекте на PHP.
Итак, приступим к самому интересному:
Базовый класс блокировки
<?php /** * Универсальный класс блокировок * */ class Lock { /** * Возвращает ключ по которому производится блокировка * * @param string $key * @return string */ static protected function getKey( $key ) { return $key; } /** * Сообщает true, если блокировку сделал текущий инстанс скрипта * * @param string $key - ключ лока * @param float $timeWait - время ожидания на захват лока в секундах * @param float $maxExecuteTime - время на выполнение операции в секундах * @return bool */ static public function getLock( $key, $timeWait = 0, $maxExecuteTime = 3600 ) { throw new Lock_Exception('Not defined method getLock'); return false; } /** * Определение идентификатора текущего процесса кросс-серверно * * @return string */ static protected function getCurrentProcessId() { static $myProcessId = false; if ( $myProcessId === false ) { $uname = posix_uname(); $mypid = getmypid(); $myProcessId = $uname['nodename'] . '_' . $mypid; } return $myProcessId; } /** * Снятие лока * * @param string $key - ключ лока * @param float $delayAfter - установка времени жизни блокировки после снятия лока в секундах * @return bool */ static public function releaseLock( $key, $delayAfter = 0 ) { throw new Lock_Exception('Not defined method releaseLock'); return false; } /** * Попытка продлить время лока * * @param string $key - ключ лока * @param float $timeProlongate - время продления лока в секундах * @return bool - если вернулся false, значит продлить не получилось */ static public function prolongate( $key, $timeProlongate ) { throw new Lock_Exception('Not defined method prolongate'); return false; } } class Lock_Exception extends Exception { } class Timeout_Lock_Exception extends Lock_Exception { } class LostLock_Timeout_Lock_Exception extends Timeout_Lock_Exception { }
Класс RedisLock
В этом классе блокировки осуществляются с использованием Redis:
<?php /** * Универсальный класс блокировок на базе Redis * */ class RedisLock extends Lock { /** * Возвращает ключ в noSQL хранилище * * @param string $key * @return string */ static protected function getKey( $key ) { // используем префикс lock@ для разделения с остальными ключами return 'lock@'.$key; } /** * Сообщает true, если блокировку сделал текущий инстанс скрипта * * @param string $key - ключ лока * @param float $timeWait - время ожидания на захват лока в секундах * @param float $maxExecuteTime - время на выполнение операции в секундах * @param integer $policy - политика захвата лока: * 0 - если лок занят на долго, то даже не пытаемся его брать, * 1 - проверяем каждые 10мс, освободился ли лок * @return bool */ static public function getLock( $key, $timeWait = 0, $maxExecuteTime = 3600, $policy = 0 ) { /** * время, когда залочивание станет неактуальным */ $timeStop = microtime(true) + $timeWait; // в данном примере используется Yii и подключение к Redis через библиотеку Rediska $rediska = Yii::app()->rediskaConnection->connect(); while ( true ) { $currentTime = microtime(true); if ( $policy == 0 ) { /** * посмотрим время, когда планируется освободить лок */ $expireAt = $rediska->getFromHash( self::getKey($key), 'expireAt' ); /** * если время, на которое был поставлен лок, превышает наше допустимое время ожидания, * то даже не встаем в очередь за локом */ if ( $expireAt > $timeStop ) { return false; } /** * иначе если надо немного подождать, то подождём то самое время, когда лок освободится */ elseif ( $expireAt > $currentTime ) { usleep( 1000000 * intval($expireAt - $currentTime) ); $currentTime = microtime(true); } } elseif ( $policy == 1 ) { /** * посмотрим время, когда планируется освободить лок */ $expireAt = $rediska->getFromHash( self::getKey($key), 'expireAt' ); while ( $expireAt > $timeStop || $expireAt > $currentTime ) { usleep( 10000 ); /** * посмотрим время, когда планируется освободить лок */ $expireAt = $rediska->getFromHash( self::getKey($key), 'expireAt' ); $currentTime = microtime(true); if ( $currentTime >= $timeStop ) { return false; } } } $getLock = false; /** * пробуем перехватить старый лок * конструкция getConnectionByKeyName нужна для тех проектов, где используется более одного инстанса Redis */ $transaction = $rediska->transaction( $rediska->getConnectionByKeyName( self::getKey($key) ) ); $transaction->watch( self::getKey($key) ); $arData = $rediska->getHash( self::getKey($key) ); // идентификатор владельца текущего захвата $daddy = isset($arData['daddy']) ? $arData['daddy'] : ''; // ожидаемое время освобождения захвата $expireAt = isset($arData['expireAt']) ? $arData['expireAt'] : 0; /** * если лок не снят, а время его блокировки истекло, то пробуем перехватить лок * также этот кейз сработает в самый первый раз */ if ( $daddy != self::getCurrentProcessId() && $expireAt < $currentTime ) { $transaction->setToHash( self::getKey($key), array( 'daddy' => self::getCurrentProcessId(), 'expireAt' => $currentTime + $maxExecuteTime ) ); $transaction->expire( self::getKey($key), ceil($currentTime + $maxExecuteTime), true ); try { $transaction->execute(); $getLock = 1; } catch ( Rediska_Transaction_Exception $e ) { /** * перехват лока не произошел */ $getLock = false; } } else { $getLock = false; $transaction->discard(); } /** * попытка взять лок обычным способом */ if ( $getLock != 1 ) { // HSETNX $getLock = $rediska->setToHash( self::getKey($key), 'daddy', self::getCurrentProcessId(), false ); } /** * если лок наш */ if ( $getLock == 1 ) { /** * обновим время, до которого поставлена блокировка */ $rediska->setToHash(self::getKey($key), 'expireAt', $currentTime + $maxExecuteTime); $rediska->expire(self::getKey($key), ceil($currentTime + $maxExecuteTime), true); return true; } else { /** * если ещё есть время на повторную попытку, то сделаем её с небольшой задержкой */ if ( $timeStop > $currentTime ) { usleep(20000); } /** * иначе сообщаем, что лока нет */ else { return false; } } } } /** * Снятие лока * * @param string $key - ключ лока * @param float $delayAfter - установка времени жизни блокировки после снятия лока в секундах * @return bool */ static public function releaseLock( $key, $delayAfter = 0 ) { $currentTime = microtime(true); $rediska = Yii::app()->rediskaConnection->connect(); $transaction = $rediska->transaction( $rediska->getConnectionByKeyName( self::getKey($key) ) ); $transaction->watch( self::getKey($key) ); $arData = $rediska->getHash( self::getKey($key) ); if ( is_array($arData) && isset($arData['daddy']) && isset($arData['expireAt']) ) { $daddy = $arData['daddy']; $expireAt = $arData['expireAt']; } else { $daddy = false; $expireAt = 0; } /** * Если на текущий момент лок был поставлен этим же процессом, то пробуем снять лок */ if ( $daddy == self::getCurrentProcessId() ) { $transaction->setToHash(self::getKey($key), 'expireAt', $currentTime + $delayAfter); $transaction->expire(self::getKey($key), ceil($currentTime + $delayAfter), true); $transaction->deleteFromHash( self::getKey($key), 'daddy' ); /** * Попытка атомарно снять лок */ try { $transaction->execute(); $result = true; } catch (Rediska_Transaction_Exception $e) { $result = false; } } else { $transaction->discard(); $result = false; } // если разлочивание сделали позже обозначенного времени if ( $expireAt < $currentTime ) { if ( $result ) { /** * всё получилось, но продлевать надо вовремя */ throw new Timeout_Lock_Exception('Timeout Lock on release'); } else { /** * значит лок был перехвачен другим процессом */ throw new LostLock_Timeout_Lock_Exception('Timeout Lock and it was lost before release'); } } return $result; } /** * Попытка продлить время лока * * @param string $key - ключ лока * @param float $timeProlongate - время продления лока в секундах * @return bool - если вернулся false, значит продлить не получилось */ static public function prolongate( $key, $timeProlongate ) { $rediska = Yii::app()->rediskaConnection->connect(); $transaction = $rediska->transaction( $rediska->getConnectionByKeyName( self::getKey($key) ) ); $transaction->watch( self::getKey($key) ); $arData = $rediska->getHash( self::getKey($key) ); $daddy = $arData['daddy']; $expireAt = $arData['expireAt']; $currentTime = microtime(true); $result = false; if ( $daddy == self::getCurrentProcessId() ) { $transaction->setToHash( self::getKey($key), 'expireAt', $currentTime + $timeProlongate ); $transaction->expire(self::getKey($key), ceil($currentTime + $timeProlongate), true); try { $transaction->execute(); $result = true; } catch (Rediska_Transaction_Exception $e) { $result = false; } } else { $transaction->discard(); $result = false; } if ( $expireAt < $currentTime ) { if ( $result ) { throw new Timeout_Lock_Exception('Timeout Lock on prolongate'); } else { throw new LostLock_Timeout_Lock_Exception('Timeout Lock and Lost them on prolongate'); } } return $result; } }
Пример использования
Допустим, у нас есть скрипт, который формирует отчёты. И этот скрипт не имеет смысла запускать одновременно в несколько потоков, т.к. они все в результате выдадут один и тот же результат, но каждый потребует в ходе работы большое количество ресурсов. Мы знаем, что этот скрипт в среднем отрабатывает за 40-50 минут, поэтому сделаем небольшой запас и поставим блокировку на 60 минут.
$lockKey = 'cron-report'; $timeWait = 0; $timeLock = 3600; if ( RedisLock::getLock( $lockKey, $timeWait, $timeLock ) ) { // лок взяли, теперь работаем с ресурсом ... // когда время подошло к концу или закончили работу с ресурсом, то пробуем снять лок try { RedisLock::releaseLock( $lockKey, 0 ); echo 'Ok'; } catch ( Timeout_Lock_Exception $e ) { // опасная ситуация // этот кейс говорит о том, что ресурс бронировался на меньшее время, чем он был использован и сейчас он ещё не занят другим процессом echo 'Timeout_Lock_Exception ' . ( $endTime - $currentTime ); } catch ( LostLock_Timeout_Lock_Exception $e ) { // плохая ситуация // этот кейс говорит о том, что ресурс бронировался на меньшее время, чем он был использован и на момент снятия лока уже был занят другим процессом echo 'LostLock_Timeout_Lock_Exception' . ( $endTime - $currentTime ); } }
Надеемся, что наша реализация будет вам полезна.
Ждём ваши вопросы и комментарии.
ссылка на оригинал статьи http://habrahabr.ru/company/alawar/blog/158799/
Добавить комментарий