Блокировка запуска второго экземпляра программы на Perl

    image

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

    Нужно проверить — является ли запускаемый процесс единственным, запущенным в данный момент, экземпляром программы, или уже есть другой, запущенный экземпляр?

    Есть несколько методов такой проверки, отличающихся надежностью.

    Основные методы


    1) Проверка существования пид-файла

    Скрипт запускается и проверяет наличие пид-файла. Если пид-файл уже существует — значит, другой экземпляр скрипта уже запущен и второй раз запускаться не следует. Если же пид-файла не существует, то скрипт создает пид-файл и начинает работать.

    Проблема в том, что первый экземпляр может упасть, не удалив пид-файл. И теперь запустить скрипт будет вообще невозможно, так как запускаемый скрипт всегда будет обнаруживать пид-файл, считать себя вторым экземпляром и отказываться запускаться, пока пид-файл не будет удален вручную. Кроме того, существует проблема с гонкой условий, так как проверка существования файла и последующее создание этого файла являются двумя отдельными операциями, а не одной атомарной.

    2) Проверка наличия пида в списке процессов

    Скрипт запускается, читает пид-файл и затем проверяет — есть ли процесс с прочитанным пидом в таблице процессов. Если такой процесс существует — значит, другой экземпляр скрипта уже запущен и второй раз запускаться не следует. Если же такого процесса не существует, то скрипт записывает свой пид в пид-файл и начинает работать.

    Проблема в том, что первый экземпляр может упасть, а пид, с которым он работал, может быть выдан другому процессу. После этого, как и в первом методе, возникнет проблема с запуском скрипта. Конечно, вероятность возникновения такой ситуации несколько ниже, чем в первом случае, ведь повторно пид будет выдан не сразу. Да и вероятность того, что посторонний процесс получит точно такой же пид, как и наш процесс, не очень большая, однако она есть, так как пидов не бесконечное количество и они выдаются по кругу. Ну и плюс гонка условий, так как операций тут еще больше, чем в первом методе.

    3) Блокировка пид-файла

    Скрипт запускается и пытается заблокировать пид-файл. Если заблокировать не удалось — значит, другой экземпляр скрипта уже запущен и второй раз запускаться не следует. Если же заблокировать пид-файл удалось, то скрипт продолжает работать.

    Этот метод не имеет проблем, возникающих в предыдущих двух методах:

    1. Падение первого экземпляра скрипта автоматически снимает блокировку с пид-файла, поэтому следующий экземпляр может быть запущен без проблем
    2. Не возникает гонка условий, так как блокировка является атомарным действием

    Таким образом, этот метод гарантированно обеспечивает блокировку запуска второго экземпляра программы.

    Метод блокировки пид-файла


    Рассмотрим подробно реализацию этого метода.

    #!/usr/bin/perl
    
    use Carp;
    use Fcntl qw(:DEFAULT :flock);
    
    check_proc('/tmp/testscript.pid') and die "Скрипт уже запущен, запуск дубля отклонен!\n";
    
    # Тут находится код,
    # который должен исполняться
    # в единственном экземпляре
    sleep 15;
    
    # Проверка существования запущенного экземпляра
    sub check_proc {
        my ($file) = @_;
        my $result;
    
        sysopen LOCK, $file, O_RDWR|O_CREAT or croak "Невозможно открыть файл $file: $!";
    
        if ( flock LOCK, LOCK_EX|LOCK_NB  ) {
            truncate LOCK, 0 or croak "Невозможно усечь файл $file: $!";
    
            my $old_fh = select LOCK;
            $| = 1;
            select $old_fh;
    
            print LOCK $$;
        }
        else {
            $result = <LOCK>;
    
            if (defined $result) {
                chomp $result;
            }
            else {
                carp "Отсутствует PID в пид-файле $file";
                $result = '0 but true';
            }
        }
    
        return $result;
    }
    


    В первую очередь скрипт вызывает функцию check_proc, проверяющую наличие другого запущенного экземпляра, и, если проверка завершается успешно, скрипт останавливается с соответствующим сообщением.

    Обратите внимание, что в этой строке функции check_proc и die объединены через условный оператор and. Обычно подобные связки делаются через оператор or, но в нашем случае логика связки другая — мы как бы говорим скрипту: «Осознай бессмысленность своего существования и умри!».

    Функция check_proc возвращает пид уже запущенного экземпляра, если он действительно запущен, либо undef. Соответственно, истинный результат выполнения этой функции означает, что один экземпляр программы уже запущен и второй раз запускаться не нужно.

    Функция check_proc


    Теперь разберем построчно саму функцию check_proc.

    1) Функция sysopen открывает файл на чтение и запись

    Тут важно то, что файл нужно открывать в неразрушающем режиме, иначе содержимое файла будет уничтожено. Из-за этого нельзя воспользоваться простой функцией open, так как она не умеет открывать файлы в неразрушающем режиме.

    Функция sysopen c флагами O_RDWR|O_CREAT открывают файл именно в неразрушающем режиме. Флаг O_RDWR означает открытие одновременно на чтение и запись, флаг O_CREAT создает файл, если его не существует на момент открытия. Флаги импортируются из модуля Fcntl (можно обойтись без Fcntl, если использовать численные значения флагов).

    2) Функция flock блокирует файл

    Поскольку нам нужно сделать так, чтобы блокировка была только у одного процесса, то нужно запрашивать эксклюзивную блокировку. Эксклюзивная блокировка задается флагом LOCK_EX. Как только процесс получает эксклюзивную блокировку — всё, никто другой такую блокировку параллельно получить не сможет. На этом, собственно, и основан механизм блокировки запуска второго экземпляра программы, это ключевая функция.

    Если функция flock обнаруживает, что кто-то другой уже заблокировал файл, то она будет ждать, пока блокировка не будет снята. Такое поведение не подходит для нашей проверки. Нам не нужно ждать освобождения файла, нам нужно, чтобы при обнаружении блокировки функция check_proc сразу вернула положительный результат. Для этого нужно использовать флаг LOCK_NB.

    Дальнейшее поведение зависит от того, удалось ли получить блокировку (3) или не удалось (4).

    3а) Функция truncate очищает файл

    Поскольку мы открыли файл в неразрушающем режиме, то старое содержимое файла осталось нетронутым. Это содержимое нам не нужно, и даже может мешать, поэтому файл нужно очистить.

    3б) Комбинация функций select и переменной $| отключает буферизацию

    Нам нужно записать пид ткущего процесса в пид-файл. Но вывод в файл буферизуется поблочно, поэтому запись пида будет (казалось бы) выполнена, но на самом деле в файле будет (пока еще) пусто. Из-за этого какой-нибудь другой процесс, пытающийся прочитать из пид-файла пид нашего запущенного процесса, обнаружит там пустоту. Наша проверка основана на блокировке пид-файла, а не на проверке пида, поэтому для наших процессов отсутствие пида не станет катастрофой. Но для процессов, которым важен сам пид, это создаст проблему.

    Чтобы отключить буферизацию вывода, нужно связанную с дескриптором этого вывода переменную $| установить в истинное значение. Первый select устанавливает текущим дескриптором дескриптор нашего пид-файла, затем переменная устанавливается в истинное значение, потом второй select возвращает STDOUT обратно на место текущего дескриптора. После этого запись в файл будет происходить немедленно, без буферизации.

    4а) Читаем пид из пид-файла

    Само чтение из файла тривиально, но нужно иметь в виду, что возможна ситуация, когда пид в файле не будет обнаружен. Это будет означать, что экземпляр программы уже запущен (ведь блокировку получить не удалось), но пид этого запущенного экземпляра почему-то не записан. Это не должно стать проблемой для нашей проверки, ведь она основана не на проверке пида. Но функция check_proc должна возвращать истинное значение в случае обнаружения запущенного экземпляра, поэтому вместо отсутствующего пида нужно вернуть что-то другое, являющееся, тем не менее, истиной.

    Подходящим значением в этом случае будет «истинный ноль». Это магическое значение (которых в перле много), которое в числовом контексте равно нулю, а в булевом равно истине. Вариантов записи истинного ноля несколько, я использую вариант «0 but true».

    Заключение


    Метод блокировки пид-файла является самым надежным способом обеспечения работы программы в единственном экземпляре.

    Функцию check_proc и подключение модуля Fcntl можно вынести в отдельный модуль (например, c названием MacLeod.pm), в этом случае обеспечение работы программы в одном экземпляре будет делаться всего в две строчки:

    use MacLeod;
    
    check_proc('/tmp/testscript.pid') and die "Скрипт уже запущен, запуск дубля отклонен!\n";
    

    Либо, проверку можно сделать немного более развернутой:

    use MacLeod;
    
    my $pid = check_proc('/tmp/testscript.pid');
    
    if ($pid) {
        die "Скрипт с пидом $pid уже запущен, запуск дубля отклонен!\n";
    }
    else {
        print "Поехали!\n";
    }
    

    В этом случае возвращаемый функцией check_proc пид запущенного процесса записывается в переменную $pid и его можно вывести в сообщении.
    Поделиться публикацией

    Комментарии 49

      0
      Использовал способ номер два и даже не задумывался что PID может быть повторно выдан другому процессу. Правда и проблем данного рода не возникало.
        0
        > Не хватает исходного кода check_pid().

        Простите, не понял. Я что-то пропустил?
        0
        Альтернативный подход — хранить пид в симлинке (тогда не нужен flock). Подробности тут.
          0
          А что считать признаком наличия дубля?
            0
            Если в симлинке содержится пид работающего процесса — значит наш процесс дубль.
              0
              А где гарантия, что это пид нужного процесса?
                0
                Если несколько разных программ используют одно и то же имя, чтобы хранить пид, то гарантии нет.
                  0
                  Даже если не одно и то же, любая другая программа с таким пидом. Откуда видно, что запущен наш дубль, а не посторонняя программа?
                    0
                    У меня такой проверки нет, но ее можно добавить.
                      0
                      Это усложняет процедуру. Я поэтому и пишу в первых двух вариантах — проверка по пиду, где бы он ни хранился, ненадежна.
          0
          А еще можно заглянуть в исходники metacpan.org/source/DETI/Proc-Daemon-0.14/lib/Proc/Daemon.pm и посмотреть, как решается данная проблема там.
            +1
            Мне нравятся подобные советы:) Типа — можно заглянуть в ядро Линукса и узнать вообще всё на свете.
              0
              Всегда интересно, полезно, правильно знать, как решили такую же задачу другие ребята.
              Но, в случае ядра Linux, это может занять неоправданно много времени. В случае Proc::Daemon кода совсем мало, так почему бы и не заглянуть.

              Забавно, что ваша статья как раз появилась в тот момент, когда я раздумывал над той же проблемой. Но у меня сервис может запускаться как в консольном режиме, так и режиме демона. И нужно предотвратить, в том числе, одновременный запуск в режиме приложения и демона одновременно. А поскольку я использую Proc::Daemon, то правильным ваиантом для меня будет, видимо, подход, который используется в Proc::Daemon.
                0
                Я не смотрел исходники Proc::Daemon, но, скорее всего, он внутри делает именно так, как я описываю.
                  0
                  На самом деле да, но без блокировки + дополнительно возможность поиска среди процессов по command line, на случай если не удалось прочитать pid file.
                  Кстати, в вашем коде вы проверяете:
                  if (defined $result) {
                  chomp $result;
                  }

                  а автор Proc::Daemon не поленился написать if (! $pid || ( $pid && $pid =~ /\D/s )
                    0
                    Не готов сейчас погрузиться в изучение исходников Proc::Daemon, но если там не делается блокировка, то гарантий избавления от дубля нет. Возможно, самонадеянно с моей стороны такое заявлять, но, видимо, Proc::Daemon плохо решает эту задачу.

                    Что касается проверки на то, что пид — число, то это, видимо, из-за того, что в Proc::Daemon это число используется для каких-то дальнейших действий. А у меня оно не используется, поэтому я такую проверку не делаю.
            0
            Можно ещё запускать через lockf примерно так:
            lockf -t 1 /var/run/script.lock ./script.pl
              +1
              Это да, но это не Перл:)
              0
              функции check_proc и die объединены через условный оператор and

              Не самый удобочитаемый вариант.
                0
                А как было бы удобнее?
                  +1
                  Традиционно при ошибке возвращается ложное значение, а не истинное. Вариант ... or die лично я видел много раз — а вот ... and die я вижу впервые.
                    0
                    Да, но тут возвращается не ошибка, а осмысленное значение — номер пида. Оно не обязательно должно использоваться для связки c die, есг можно просто куда-нибудь выводить или еще что-нибудь.
                      0
                      Возможно, удобочитаемость поправила бы вторая функция, возвращающая именно булево значение.
                        0
                        Да, пожалуй.
                0
                Что-то она у меня всё время выдаёт с «Отсутствует PID в пид-файле»
                Хотя он там есть
                  0
                  Мм… даже не знаю. Перепроверил сейчас, не пропустил ли я какую запятую — скопировал код из статьи в новый файл, запустил — все работает.
                    0
                    Просто под виндой не срабатывает отключение буферизации, и pid попадает в файл только после завершения процесса.
                  0
                  Делал специальный модуль, в который пихал все что часто пригождается
                  можете использовать, там есть блокировка двойного запуска процесса

                  search.cpan.org/~lagutas/Logic-Tools-0.02/lib/Logic/Tools.pod

                  проверяю вот так

                  unless( -e "/proc/$pid" )
                  {
                  die "[FAILED] Не удается удалить файл блокировки $pid_f\n" if ( !unlink $pid_f );
                  }
                  else
                  {
                  die «Процесс уже запущен. Процесс с pid=$pid\n»;
                  }
                    0
                    Это первый метод из моей статьи и это ненадежно.
                      0
                      Это не так. pid файл вы размещаете в директории /var/run/. Я у себя также делаю, но при запуске второго экземпляра, я проверяю содержимое /var/run/pid, а затем ищу папку в каталоге /proc

                      если скрипт даже упадет то из папки /proc каталог с его пиром удалится автоматом, средствами linux.

                      а в скрипте pid файл удалится при последующем запуске
                      die "[FAILED] Не удается удалить файл блокировки $pid_f\n" if ( !unlink $pid_f );
                        0
                        А, ну тогда это второй вариант, с проверкой процесса, что немного лучше, но все-равно ненадежно.
                          0
                          Согласен, но более чем зв 8 лет использования этой конструкции вероятность выдачи именно этого пида что остался в Pid файле — не наступала.
                    +1
                    Всегда использовал Proc::PID::File, зачем изобретать велосипед в данном случае совсем не ясно.
                      0
                      Это статья о механизме, лежащем в основе Proc::PID::File, а не о «новом крутом модуле».
                        0
                        и всё же зачем велосипед?
                        тоже сразу подумал о Proc::PID::File, он на много интереснее
                          0
                          Переформулирую — это статья про таблицу умножения, а не про калькулятор.
                      0
                      Тут важно то, что файл нужно открывать в неразрушающем режиме, иначе содержимое файла будет уничтожено. Из-за этого нельзя воспользоваться простой функцией open, так как она не умеет открывать файлы в неразрушающем режиме.

                      умеет, в режиме '+<'
                      perldoc.perl.org/functions/open.html
                      You can put a + in front of the > or < to indicate that you want both read and write access to the file; thus +< is almost always preferred for read/write updates--the +> mode would clobber the file first

                      и ещё про три строчки с select… есть же метод autoflush(1).
                        0
                        умеет, в режиме '+<'

                        Да, но так файл не будет создан, если он отсутствует.

                        и ещё про три строчки с select… есть же метод autoflush(1).

                        Да, но для этого нужен сторонний модуль.
                          0
                          Да, но так файл не будет создан, если он отсутствует.

                          Ага, понятно.

                          Да, но для этого нужен сторонний модуль.

                          Ну, это смотря что считать «сторонним» и что значит «нужен» ;)

                          perl -e 'use strict; use warnings; open my $f, ">", "zz.tmp"; $f->autoflush(1)'
                          

                          работает нормально
                            0
                            Хммм… Я почему-то был уверен, что autoflush — это метод из модуля IO:Handle, и что модуль нужно подгрузить явным образом.
                              0
                              Нашёл perldoc.perl.org/5.14.0/perldelta.html
                              When a method call on a filehandle would die because the method cannot be resolved and IO::File has not been loaded, Perl now loads IO::File via require and attempts method resolution again

                              Наверное, всё равно, лучше явно загружать.

                              В любом случае, я бы не назвал IO «сторонним модулем». Он такой же модуль ядра как и use strict, use warnings, или Errno и utf8 (последние два тоже, автоматически загружается)
                                0
                                эвона как… Ок, спасибо, буду теперь знать.
                            0
                            Немножко понекропостю, авось кому пригодится. Тоже сталкивался с сабжевой задачей, решил аналогичным способом (пид-файл с блокировкой), а с открытием всё решается и средствами перлового open: режим "+>>" — открытие с созданием но без затирания. Только указатель надо будет сбросить в начало файла:

                            open($PID_HANDLE, '+>>', $PID_FILE);
                            # ...
                            seek($PID_HANDLE, 0, SEEK_SET);
                          0
                          В perl есть еще хорошая штука для '0 but true' — '0E0'.
                            0
                            Там еще несколько вариантов есть, да. Но мне больше всего нравится именно значение '0 but true' — оно выразительное, плюс его рекомендует сам Ларри:)
                            +1
                            а как насчет

                            use Fcntl ':flock'; 
                            open my $self, '<', $0 || die $!;
                            flock $self, LOCK_EX | LOCK_NB or croak "already running";
                            

                            pid-файл не нужен, блокировка работает, привязки к идентификаторам процессов нет
                              0
                              Это тот же третий метод, только упрощенная версия — блокировочный файл есть, но пид в него не пишется.
                                0
                                блокировочным файлом тут выступает сам файл скрипта
                                  0
                                  А я что сказал?

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое