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

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


UPD: FrenzyKryger верно заметил, что sem_remove($sem) должна быть вне условия

Внимательный читатель заметит, что у нас добавилась повторная проверка на существование файла. Когда первый поток захватит семафор, удалит файл и отпустит семафор, второй поток сможет продолжить выполнение и он уже не должен удалять файл, которого нет, поэтому мы и добавляем проверку ещё раз.

Здесь важную роль играет функция 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.

Замечания и правки приветствуются в личку!
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 77

    0
    А чем обусловлен выбор именно такого метода.
    Чем плох мемкэш? Он рассматривался как вариант?
      +1
      Да, рассматривался, мемкеш — отличный выбор, если понимать его плюсы и минусы по сравнению с данными способами: если мемкеш на отдельном сервере, а все скрипты крутятся на другом, то это лишние сетевые издержки.

      Здесь я просто хотел показать альтернативные мемкешу варианты, т.к. на хабре не нашёл упоминаний таких решений.
        +2
        А если нужный сервис крутится на нескольких машинах?
        Ведь для отказоустойчивости нужно минимум на двух-трёх машинах запускаться.
        Хотя конечно зависит от того, какие задачи решаются, и нужна ли глобальная блокировка.
        Можно намекнуть, для чего примерно используется это решение?
          +1
          Конкретно это решение изменено и завуалировано, чтобы никто не заподозрил меня в плохих делах =)
            –1
            Если на нескольких машинах, то либо использовать тяжеловесный ZooKeeper (недавно была статья про него тут), либо же — синхронизироваться через мастер-БД (в постгресе есть pg_try_advisory_lock, который подойдет для большинства случаев). Можно и через мемкэшевый cas, да, но только мемкэш может прилечь или очиститься внезапно (мало ли, какие бывают форс-мажоры), а если приляжет мастер-база, то уж будет точно не до блокировок. :)
          +1
          Как известно, APC сейчас стоит почти на всех продакшн-машинах с пхп (будь шаред хостинг, или свой сервер) просто для кеширования опкода, так что нет смысла ставить мемкешд (тем более что вы проиграете в производительности) до тех пор, пока кеш-сервера не надо шардить. Да и там встанет вопрос — почему бы не редись, который по мне так на голову лучше
            0
            редис*
              0
              Есть мнение что user vars кеш в APC имеет проблемы с фрагментацией.Сам сталкивался всего пару раз, но осадочек остался…
                0
                Мемкэш быстрей примерно в 146% раз. Поэтому блокировки на нём самое то. А редис — это всё таки БД, а не кэш.
                  0
                  Сетевой демон быстрее shared memory или семафоров? Ого! Расскажите поподробнее, очень интересно.
                    0
                    Да это мне чё-то в голову пришло, что разговор про блокировку на файлах. Не знаю почему.
                0
                Как мне кажется, сложность с мемкешом в том, что если у нас есть несколько бекендов, то:
                — придется вводить префиксы для серверов (каждому бекенду придется иметь отдельный конфиг)
                — появятся затраты на сетевое взаимодействие
                  0
                  я имел в виду одну блокировку на все машины, см. комментарий выше
                    +2
                    Всё так, но вот APC и семафорами при нескольких бэкендах пользоваться точно не стоит =)
                    +2
                    А чем он тут хорош? Расточительно по памяти — мемкешд использует корзины, да и при нехватке памяти корзины буду молча вываливаться из кеша.

                    В данном случае, я бы просто проверил на is_writeable, подавил бы ошибку и не заморачивался.
                      0
                      на мемкеше ситуация когда куча потоков пытаются одновременно просчитать и сохранить тяжелые данные в хайлоаде тоже более чем актуальна.
                      Обходится при помощи php_memcached и cas, но читаемость и предсказуемость кода резко падает.
                      +9
                      ПС: Мне кажется, на хабре пора вводить разделение статей на уровень подготовки читателя)
                        +1
                        Не у Вас одного в голове такие мысли появляются.
                        Причем уровни:
                        -Чайник
                        -Джуниор
                        -Интермедиэйт
                        -Сениор

                        как минимум, ибо статьи 1-3 категори вообще не интересно читать :)
                          0
                          Возможно стоит у статьи добавить помимо голосов ещё и рейтинг сложности материала, плюсовать/минусовать который могут люди с повышенной кармой или все.
                          0
                          Согласен. Для комфортного чтения статьи нужны знания в следующих обсластях.
                            0
                            Пока можно просто ставить теги, чтоб не заходить в статьи, которые не интересны, но это на совести авторов.
                            +5
                            И все-таки, почему не изпользовать flock?
                              0
                              *использовать
                                +3
                                Не все удосуживаются почитать документацию и поэтому не знают об этой прекрасной функции.
                                Кстати возможности блокировки можно использовать в file_put_contents указав флаг.
                                Например:

                                file_put_contents($filename, $data, FILE_APPEND | LOCK_EX);
                                  0
                                  Жаль, что у file_get_contents такого флага нет.
                                    0
                                    Перед использованием flock() необходимо получить дескриптор файла, например с помощью fopen() — это обращение к диску. Зачем? У абстрактного race condition =) (пример из статьи не очень подходит) в критической части операций с файлами может не быть вообще, и добавлять сюда файловую систему (да, операционка много что закеширует, но тем не менее) — лишняя трата ресурсов.
                                      +4
                                      А, то есть file_exists и метод validate() автора статью к диску не обращаются?

                                      Неувязочка :)
                                        0
                                        Да, я выше написал, что конкретно этот пример здесь не очень подходит, т.к. обращения всё-равно остаются. Буду выбирать примеры понагляднее, обещаю! =)
                                          +2
                                          Был бы у Вас наглядный пример — я бы и не начинал дискуссию.
                                          В любом случае использование flock — признак хорошего тона.
                                      0
                                      Когда-то в мануале было написано, что FILE_APPEND и LOCK_EX взаимоисключают друг-друга, т.к FILE_APPEND сама по себе атомарная операция. Теперь этот текст почему то убрали: ru2.php.net/manual/en/function.file-put-contents.php
                                        0
                                        Быть может потому, что они теперь работают вместе, не?

                                        (:
                                          0
                                          И что тогда это было? Косяк доки, или изменение принципа работы «по-тихому»?
                                            0
                                            Я никогда не видел, чтобы их нельзя было юзать вместе, поэтому не могу ответить.
                                    0
                                    Тоже тестил движок, на 1000 одновременных запросов (с помощью ab) и c кэшем начались твориться чудеса, пришлось выкручиваться.
                                    Кусочек кода из метода записи данных в кеш:

                                    $cache_size_file	= fopen(CACHE.'/size', 'c+b');
                                    $time					= microtime(true);
                                    while (!flock($cache_size_file, LOCK_EX)) {
                                    	if ($time < microtime() - .5) {
                                    		fclose($cache_size_file);
                                    		return false;
                                    	}
                                    	time_nanosleep(0, 1000000);
                                    }
                                    unset($time);
                                    /**
                                    	Some work here
                                    */
                                    flock($cache_size_file, LOCK_UN);
                                    fclose($cache_size_file);
                                    

                                    return false потому, как кеш — необязательный, и чтобы долго не ждать — можно и пропустить запись.
                                    Работает стабильно, с помощью штатных функций, не нужно дополнительных классов.
                                      0
                                      У меня к Вам вопрос: habrahabr.ru/post/148527/#comment_5014784
                                        0
                                        Если файл уже блокирован другим процессом, flock() вернёт false
                                          0
                                          Нет. Он будет висеть и ждать. false будет только если Вы сделаете flock($f, LOCK_EX | LOCK_NB).
                                            0
                                            Вы правы, проверил, добавил себе LOCK_NB
                                          0
                                          Спасибо, знал только про sleep() и time_nanosleep()
                                        +1
                                        Есть два потока 1 и 2.

                                        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;
                                        }
                                        


                                        1: file_exists($filename) — true
                                        2: file_exists($filename) — true
                                        1: !$this->validate() -true
                                        2: !$this->validate() -true
                                        1: $this->canUseApc() -true
                                        2: $this->canUseApc() -true
                                        2: apc_add('some_key', 1)
                                        2: unlink($filename);
                                        2: apc_delete('some_key');
                                        1: apc_add('some_key', 1)
                                        1: unlink($filename); — NO SUCH FILE!

                                        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;
                                        }
                                        


                                        1: file_exists($filename) — true
                                        2: file_exists($filename) — true
                                        1: !$this->validate() — true
                                        2: !$this->validate() — true
                                        1: $sem = sem_get(1);
                                        1: sem_acquire($sem) && file_exists($filename) — true
                                        1: unlink($filename);
                                        1: sem_remove($sem);
                                        2: $sem = sem_get(1);
                                        2: sem_acquire($sem)
                                        2: file_exists($filename) — false!
                                        2: return false;
                                        3: зависнет на веки т.к. семафор так и не был сброшен
                                        php.net/manual/ru/function.sem-get.php
                                        resource sem_get ( int $key [, int $max_acquire = 1 [, int $perm = 0666 [, int $auto_release = 1 ]]] )
                                        sem_get() returns an id that can be used to access the System V semaphore with the given key.

                                        A second call to sem_get() for the same key will return a different semaphore identifier, but both identifiers access the same underlying semaphore.


                                        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;
                                        }
                                        


                                        OK :)

                                        Не знаю насколько вероятно что подобные сценарии когда-нибудь возникнут на реально работающей системе — но, насколько я знаю в случае с многопоточным программированием если что-то может произойти — оно обязательно случиться на продакшне :)
                                          +4
                                          Кстати говоря, если в случае устаревания файла мы его всёравно удаляем, то разве @unlink($filename) не лучшее решение?) в том и другом случае возвращаем false. Нам впринципе не важно был ли файл удален нами или кто-то до нас уже заметил что он устарел и удалил его…
                                            0
                                            Лучшее, но, подозреваю, смысл статьи показать как поступать в случаях, когда атомарности не добиться.
                                              0
                                              Ну тогда случай можно было выбрать поудачнее. Как минимум тот, в котором был смысл решать «проблему» с race condition.
                                              +1
                                              Нет, только не собака, пожалуйста, не надо!!!
                                              @unlink($filename) вернёт false например ещё тогда, когда файл не удалён, а просто прав на удаление не хватило ну и ещё кучу других забавных ситуаций можно придумать с собакой.
                                              0
                                              Первый пример странен в этом месте:
                                              1: $this->canUseApc() -true
                                              2: $this->canUseApc() -true
                                              2: apc_add('some_key', 1)
                                              2: unlink($filename);
                                              2: apc_delete('some_key');
                                              1: apc_add('some_key', 1)

                                              скорее вот так:
                                              1: $this->canUseApc() -true
                                              2: $this->canUseApc() -true
                                              2: apc_add('some_key', 1) -true
                                              1: apc_add('some_key', 1) -false
                                              2: unlink($filename);
                                              2: apc_delete('some_key');
                                              1: return;

                                              а второй — замечание верное, sem_remove($sem); надо вынести из условия
                                                +1
                                                Поправил в статье.
                                                  0
                                                  А что мешает второму потоку получить лок, удалить файл, освободить лок до того как первый поток получит шанс взять лок? :) Я согласен что это маловероятно — более того, если бы мы обсуждали код на С то в некоторых случаях (архитектура, количество ядер… ) это было бы даже невозможно — но мы говорим о php :) Какие гарантии дает php о последовательности выполнений инструкций в нескольких потоках? Тут можно сказать что это невероятно, но в таком случае тем труднее будет потом отловить возникшую багу, если она таки случиться, например, 2 раза… за два месяца :) и оба раза под носом у заказчика…
                                                    0
                                                    > Какие гарантии дает php о последовательности выполнений инструкций в нескольких потоках?

                                                    Никаких. В пхп потоков нет. Скрипты работают абсолютно независимо друг от друга.
                                                  +2
                                                  «Зависнет на веки»? OMG :)
                                                0
                                                Тело Вашего цикла никогда не будет выполнено, разве нет?

                                                while (!flock($cache_size_file, LOCK_EX)) {
                                                	if ($time < microtime() - .5) {
                                                		fclose($cache_size_file);
                                                		return false;
                                                	}
                                                	time_nanosleep(0, 1000000);
                                                }
                                                
                                                  0
                                                  Извиняюсь, промахнулся.
                                                  0
                                                  Почему бы не отдать чистку кеша третьей стороне? Допустим пусть крон стартует регулярно и чистит устарелые данные. Сами же скрипты будут заниматься своим делом и брать всегда данные из кеша, если он есть и не заниматься проверками и удалением.
                                                    0
                                                    Лучше не крон — очередь.
                                                      0
                                                      Если речь идет именно об удалении.
                                                        0
                                                        Не важно очередь или метка времени, факт в том чтоб основные потоки не удаляли ничего, чтоб этим залималось третье лицо. А очередь это будет или нет не важно, крон тоже с очередью может работать.
                                                      0
                                                      Я бы вообще вопрос ребром поставил: зачем чистить кеш? Не лучше ли использовать кеш с необходимым автовытеснением.
                                                        0
                                                        Ну кэш разный бывает. Бывает, что нужно пересоздать его из актуальных данных. В некоторых случаях для этого можно применить очистку и последующее создание его заново.
                                                          0
                                                          А в таких ситуациях необходимо иметь номер ревизии. При изменении факторов эта ревизия должна автоматически увеличиваться.
                                                      0
                                                      Может, я чего-то не понимаю, но почему не добавить проверку на существование файла сразу перед удалением? А для надежности, можна после удаления еще файловый кеш почистить, как тут в первом примере
                                                        0
                                                        Между проверкой и удалением может и вклинится при хорошей нагрузке другая операция удаления.
                                                      • UFO just landed and posted this here
                                                        • UFO just landed and posted this here
                                                            0
                                                            Отлично работают, пока, как в примере автора, только один сервер используется.
                                                          0
                                                          А зачем вам кешировать данные в файл? Может лучше сразу в APC?
                                                            +1
                                                            Боже, пошли заказчика, который после таких писателей в отчаении обратится и заплатит и так не дешовый рейт * 3, лишь бы исправить проблемы и вернуть проект к жизни.

                                                            Я даже не хочу пояснять в чем тут проблема, ибо ответы очевидны — дам лишь намёк: у вас поток в 2-3 тысячи запросов в секунду и тут кеш протухает…
                                                              +2
                                                              Мне показалось, что в коде ошибка, т.к. проверять на наличие файла нужно внутри критической секции.
                                                              Иначе оба инстанса примерно одновременно проверяют, первый удаляет, второй пытается удалить и… warning, т.к. unlink выкидывает warning при отсутствии файла.
                                                                +1
                                                                Вы можете меня бить палками, но на более-менее нагруженных проектах кешем должен заниматься отдельный скрипт по крону и только. Или подобные варианты как отдельно висящий кеш демон.
                                                                  0
                                                                  Уточняю: валидацией кеша занимается только демон. Если кеш-страница отсутствует, то лочится кеш-страница до момента окончания её генерации (все скрипты ожидают её генерации или таймаут). А вот генерацией может заниматься первый поток, который наткнулся на отсутсвие.
                                                                    0
                                                                    Хотя, вариант валидировать в скрипте после отдачи контента вполне годный. Но тоже не удаление, а лишь установка флага невалидности.
                                                                    0
                                                                    Да, @ зло, но не проще ли было использовать @unlink(); в данном конкретном случае?

                                                                    Объективно — зачем городить здесь решение «проблемы» с race condition в данном случае?
                                                                      +1
                                                                      Ага, это все равно, что заменять goto на do..while + break, думая, что плохо использовать *слово* goto, а не конструкцию управления.

                                                                      Плюс в данной ситуации у автора в коде ошибка, и его решение проблемы поэтому полурабочее.
                                                                      0
                                                                      а почему просто не обернуть удаление файла try catch? И в catch записывать, например в лог-файл или БД ошибку и потом кроном проверять.

                                                                      Ведь если ошибка возникает из-за того, что файл был удален в «соседнем потоке», то такая ошибка подавится.
                                                                      А если ошибка была вызвана из-за другой причины, то такая ошибка так же вылетит и в коде из топика.

                                                                        0
                                                                        Ошибка и exception — разные штуки.
                                                                        0
                                                                        А еще файл можно помечать как «удаленный», эту информацию хранить в БД и кроном потом чистить.
                                                                          0
                                                                          В качестве первого аргумента sem_get можно передавать любое число? Это не вызовет коллизий с другими процессами в системе?

                                                                          Only users with full accounts can post comments. Log in, please.