Боремся с race condition в PHP

в 13:44, , рубрики: php, race condition, Песочница, семафоры, метки: , ,

Ошибки типа «Состояние гонки» (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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js