Ошибки типа «Состояние гонки» (race condition) редко встречаются на малонагруженных проектах, а с ростом нагрузки ситуация медленно, но верно меняется. И однажды обычное кеширование данных в файле, например, вот такое:
function getFlagFromFile($filename) {
if (file_exists($filename)) {
if (!$this->validate()) { // а не устарел ли файл?
unlink($filename);
return false;
}
else {
return file_get_contents($filename);
}
}
return false;
}
выдаёт ошибку в строке unlink(): файл $filename не существует!
Самое интересное и непонятное в том, что ошибка возникает в случайные моменты времени, а при попытке дебага — не воспроизводится!
Ошибка race condition возникает при таком состоянии системы, в котором один и тот же код выполняется одновременно (несколько параллельных потоков). В указанном выше примере, если код выполняется в несколько потоков, проверки file_exist($filename) и !$this->validate() могут быть выполнены с положительным результатом обоими потоками одновременно, но выполнить unlink($filename) одному потоку удастся раньше, чем другому — и тогда второй поток вызовет ошибку.
Давайте рассмотрим два способа борьбы с данной ошибкой в рамках одного сервера, которые не используют файловую блокировку flock() (при множественных запросах критичного участка кода лишние обращения к файловой системе — не самый удачный выход).
Препятствовать состоянию гонки можно, используя APC и семафоры, — и там и там есть соответствующие атомарные операции. Но, давайте по-порядку.
Решение на APC
Альтернативный кеш в PHP (Alternative PHP Cache — APC) кеширует байткод выполняемых скриптов, тем самым предотвращая затраты ресурсов по анализу исходного кода, это знают если не все, то почти все. Но далеко не все знают, что у APC есть собственное key-value хранилище, особенностью которого является сохранение значений между запросами. Заданное однажды значение будет хранится в APC до перезагрузки веб-сервера, либо до принудительного удаления значения (либо истечёт время хранения значения, если оно было задано).
Для эксклюзивной блокировки следует использовать функцию apc_add(ключ, значение, время жизни) — она вернёт false, если значение уже было присвоено раньше (для повторного присваивания значения ключу существует apc_store()). Полное условие возможности пользования решением такое (изменения в getFlagFromFile() помечены комметариями *** ):
function canUseApc() {
return extension_loaded('apc') && ini_get('apc.enabled') && php_sapi_name() !== 'cli';
}
function getFlagFromFile($filename) {
if (file_exists($filename)) {
if (!$this->validate()) {
if ($this->canUseApc() && apc_add('some_key', 1)) { //***
unlink($filename);
apc_delete('some_key'); //***
}
return false;
}
else {
return file_get_contents($filename);
}
}
return false;
}
Удаление файла здесь будет выполняться только в том случае, если потоку удалось задать значение в APC, а значит параллельных удалений не будет, как и ошибки. А вот если забыть удалить значение из APC с помощью apc_delete(), то удалить его поможет только перезапуск веб-сервера.
Это самое простое решение по реализации. Однако, главный минус решения в том, что APC не работает для CLI-скриптов. Для них подойдет решение на семафорах.
Решение на семафорах
Семафор проще представить как переменную (флаг), которую можно увеличивать или уменьшать. При захвате семафора одним процессом, его значение уменьшается на единицу, а при отпускании — увеличивается на единицу. При этом, если текущее значение семафора равно нулю, процессу не удастся его захватить и он будет ожидать освобождения семафора.
Для получения ресурса семафора используется функция sem_get(целочисленный идентификатор, значение семафора = 1). Функцией можно получить семафор со значением, отличающимся от единицы, и тогда захватить семафор смогут несколько потоков. Собственно, для захвата используется функция sem_acquire(ресурс семафора), возвращающая true, если захват удался, и false в противном случае.
Наш пример при использовании семафоров будет выглядеть так:
function getFlagFromFile($filename) {
if (file_exists($filename)) {
if (!$this->validate()) {
$sem = sem_get(1); //***
if (sem_acquire($sem) && file_exists($filename)) { //***
unlink($filename);
sem_remove($sem); //***
}
return false;
}
else {
return file_get_contents($filename);
}
}
return false;
}
Внимательный читатель заметит, что у нас добавилась повторная проверка на существование файла. Когда первый поток захватит семафор, удалит файл и отпустит семафор, второй поток сможет продолжить выполнение и он уже не должен удалять файл, которого нет, поэтому мы и добавляем проверку ещё раз.
Здесь важную роль играет функция sem_remove(ресурс семафора), которая отпускает занятый семафор. Если семафор не отпустить, то параллельный поток останется в состоянии ожидания вплоть до окончания работы текущего.
В этом и заключается минус данного решения: не всегда приемлема ситуация, при которой параллельный поток может ждать. Часто сервер должен как можно быстрее дать ответ, а не ждать получения эксклюзивного доступа, даже несмотря на то, что выполнить требуемое действие не удалось. Плюс, по сравнению с предыдущим решением, в том, что семафоры работают в cli-скриптах.
Подведём итоги
Каждый из рассмотренных способов имеет свои минусы и плюсы. Удобнее всего объединить оба решения в один класс, скрыв непосредственную реализацию. Тогда мы получим хороший и простой инструмент для предотвращения состояния гонки:
function getFlagFromFile($filename) {
if (file_exists($filename)) {
if (!$this->validate()) {
if ($race = RaceCondition::prevent('FLAG_'.$filename)) { //***
unlink($filename);
$race->release(); //***
}
return false;
}
else {
return file_get_contents($filename);
}
}
return false;
}
Полностью готовое решение не выкладываю, оставляя его в качестве домашнего задания =) А простое гугление быстро даст более подробные ответы о семафорах, потоках и APC.
Замечания и правки приветствуются в личку!
Автор: ostanin