Введение
Сегодня мы поговорим о блокировках и покажем свою реализацию. Каждый из разработчиков не раз сталкивался с проблемой, когда необходимо обеспечить однопоточное использование какого-либо ресурса.
Часто для обеспечения такой блокировки используется схема с созданием специального файла, наличие которого определяет факт занятости того или иного ресурса.
Такой подход достаточно прост в реализации, но имеет ряд недостатков. Среди недостатков можно выделить:
- отсутствие 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 );
}
}
Надеемся, что наша реализация будет вам полезна.
Ждём ваши вопросы и комментарии.
Автор: andreysapegin