Блокировки в bash скриптах

Иногда бывает нужно сделать так, чтобы в каждый момент времени работало не больше одного экземпляра вашего bash скрипта. Если на вашей платформе есть команда flock, то это сделать достаточно просто:


#!/bin/bash

LOCK_FILE=/tmp/my-script.lock
LOCK_FD=9

get_lock() {
    # need to use eval here for proper expansion
    eval "exec $LOCK_FD>$LOCK_FILE"
    flock -n $LOCK_FD
}

get_lock || exit

# ...

Используя этот подход необходимо помнить, что все дочерние процессы наследуют дескрипторы файлов, открытых родительским процессом. У меня был скрипт, который запускался из крона. Этот скрипт стартовал ssh-agent, если он еще не был запущен, и выполнял через ssh команды на нескольких серверах. ssh-agent наследовал дескриптор лок файла и как следствие скрипт выполнялся только один раз при запуске ssh-agent. Для избежания подобной ситуации необходимо явно закрыть лок файл при вызове команды, которая порождает дочерний процесс. В моем случае пришлось сделать так:


#!/bin/bash

LOCK_FILE=/tmp/my-script.lock
LOCK_FD=9
SSH_KEY=/root/.ssh/id_rsa.for.ssh-agent

get_lock() {
    # need to use eval here for proper expansion
    eval "exec $LOCK_FD>$LOCK_FILE"
    flock -n $LOCK_FD
}

get_lock || exit

socket=$(find /tmp/ssh-*/agent.* -user root 2>/dev/null || true)
if [ -z "$socket" ]; then
    # need to use eval here for proper expansion
    # we need to close explicitly fd of the lock file
    # otherwise open fd is kept by ssh-agent and lock can't be aquired until ssh-agent exits
    eval ". <(ssh-agent $LOCK_FD>&-)"
    ssh-add $SSH_KEY
    return
else
# ...
fi
#...

Если по какой-то причине вы не можете использовать flock необходимую функциональность можно реализовать используя исключительно bash:


#!/bin/bash

set -u

PID_LIST=/tmp/test-get-lock.pid

get_lock() {
    local pid
    while true; do
        while read pid; do
            kill -0 $pid || continue
            [ "$pid" != "$BASHPID" ] && return 1
            echo $BASHPID >$PID_LIST.new && mv $PID_LIST.new $PID_LIST && return 0
        done < $PID_LIST
        echo $BASHPID >>$PID_LIST
    done
}

if get_lock 2>/dev/null; then
    sleep 1
    pids="$(cat $PID_LIST)"
    pid=$(echo "$pids"|head -n1)
    [ "$BASHPID" != "$pid" ] && echo "pid: $BASHPID unexpected pid: $pid $pids"
    echo "pid: $BASHPID get_lock success"
else
    echo "pid: $BASHPID get_lock failed"
fi

Вот как это работает:



Насколько это решение надежно? В процессе отладки я использовал следующую команду для тестирования:


rm -f /tmp/*.log
for x in {0000..9999}; do ./lock-test.sh >/tmp/$x.log 2>&1 & done
wait
echo "success: $(grep success /tmp/*.log|wc -l), failure: $(grep failed /tmp/*.log|wc -l), unexpected pid: $(grep unexpected /tmp/*.log|wc -l)"

Отсутствие неожиданных pid-ов означало, что код работал правильно. Для финального тестирования я использовал вот такую команду:


for y in {000..999}; do
  echo -n " $y"
  bash -c 'rm -f /tmp/*.log
    for x in {0000..9999}; do ./lock-test.sh >/tmp/$x.log 2>&1 & done
    wait' 2>/dev/null; grep unexpected /tmp/*.log && break
done

Я прогнал этот тест на своем ноутбуке с 4-х ядерным i7, на виртуальной машине с 2-мя ядрами и на сервере с 24-мя ядрами. Ни в одном из случаев проблем обнаружен не было. Тем не менее я допускаю, что мое тестирование было не исчерпывающим и предлагаемый код может сработать неправильно при каком-то стечении обстоятельств. Впрочем, если вы будете использовать данный код, для того, чтобы скрипт, запускаемый из крона, работал в единственном экземпляре, с большой вероятностью проблем не будет.

@kt97679
14.02.2023 08:58 UTC
Первоисточник

Комментарии

@vaniacer
14.02.2023 05:54 UTC
-2

Сохранять пид в файле, и проверять активен ли этот пид, нет?

@vaniacer
14.02.2023 06:01 UTC
0
code
#!/bin/bash
pidf=file.pid
pid=$(cat $pidf)
[[ -e /proc/${pid:-0} ]] && { echo fail; exit 1; }

echo  $$ > $pidf
sleep $1
14.02.2023 10:11 UTC
+1

Думаю, вы сами найдете в этом скрипте все race conditions, где оно заглючит. Блокировка - чертовски хрупкая штука в целом, когда нет нужного готового и оттестированного примитива в среде.

17.02.2023 08:06 UTC
0

Можно и без файла обойтись:

code
#!/bin/bash
ps -ef | grep -v $$ | grep $0 && exit 1

sleep $1
echo FIN
17.02.2023 15:28 UTC
0

Если вы параллельно запустили редактирование vim /path/to/script ваш подход не сработает. Если случилось так, что у вашего скрипта пид 1111, а уже работает процесс с пидом 11111, то тоже не сработает.

@xforce
14.02.2023 06:47 UTC
+1
Технически, PID не уникален и может быть выдан другому процесс в промежутках между проверками, а вы потом будете вечно ждать его завершения, если это что-то долгоживущее окажется.
@vadimr
14.02.2023 07:42 UTC
0

Самый простой способ – создать в /tmp файл с любым именем и дать команду rename -o mytemp.file my-script.lock my-script.lock для переименования его в my-script.lock. Нулевой код возврата из переименования – работаем, ненулевой – отваливаемся.

@VXP
20.02.2023 10:43 UTC
0

А если mytemp.file не создастся?

20.02.2023 11:34 UTC
0

Плохо дело, если не создаются файлы в /tmp

20.02.2023 19:17 UTC
0

Что делать, если скрипт завершился аварийно и не удалил my-script.lock?

20.02.2023 19:22 UTC
0

Надо скрипт вызывать из другого скрипта, который удаляет my-script.lock. А так вообще этот вопрос неразрешим на сто процентов, только с большой вероятностью. Может и задача на семафоре подвиснуть, и пид повторно выделиться.

20.02.2023 19:41 UTC
0

Но ведь при удаление my-script.lock происходит race condition?

20.02.2023 19:47 UTC
0

Race condition не происходит, просто по жизни не определено, какой в точности момент считать окончанием работы скрипта. Когда скрипт уже вышел на терминальную ветку и начал завершаться, уже можно запускать вторую копию или ещё нельзя?

20.02.2023 20:01 UTC
0

А как вы поступаете в своих скриптах, использующих такой подход?

20.02.2023 20:11 UTC
0

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

Например, если это суточный бэкап в полночь, и так получилось, что он сам по себе продолжается сутки, то нам не очень важно, завершился он в 00:00:01 или в 23:59:59 – в любом случае, пропустив следующий запуск в 00:00:00, мы фактически ничего не теряем.

27.04.2025 02:52 UTC
0

если скрипт завершится аварийно, то есть такая функция trap, в которой это можно обработать.

у меня была более интересная ситуация, когда я этот файл оставлял и при запуске по его наличию как раз и определял, было ли аварийное завершение?

в целом, мягкая блокировка файла через flock - это лучший способ, который был мной найден.

27.04.2025 11:08 UTC
0

trap не поможет, если отключилось питание и файл остался на диске. Если доступен flock безусловно надо использовать его, именно это я и написал в самом начале статьи.

@plutarh
14.02.2023 08:12 UTC
0

Не претендую на сколь-нибудь полное понимание вопроса, но приведу свою версию запрета выполнения нескольких экземпляров скрипта:

@DmitryKoterov
14.02.2023 10:17 UTC
+1

Здесь race condition конечно же: этот скрипт стартует одновременно дважды, оба процесса входят в ветку else, и вот вам две копии запущенные. Причем кажется, что оно должно редко происходить, но если вдруг на 10-ядерной машине окажется ненадолго load average 50 (баг где-нибудь или что-то еще), то вероятность многократно возрастает.

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

@AndreyVys
15.02.2023 18:54 UTC
+2

Это какой-то вид садизма, вместо кода выкладывать скриншот из notepad++?

@DmitryKoterov
14.02.2023 10:23 UTC
0

В баше лучше однозначно flock, но его нет в MacOS! (Как это вообще возможно в 2022 году, что flock нет в MacOS и нет в Node, для меня полная загадка.)

Вариант автора с pid-ами… ну спорно: несмотря на все тесты, есть ли четкое доказательство, что оно прям всегда работает? а с условием ротации пидов? а если Load Average искусственно загнать под сотню?

Зато железобетонный flock есть в… Perl. И perl есть во всех OS, причем один и тот же, и запускается быстро. Поэтому если надо уж прям совсем кросс-платформенное скриптовое решение, то я использую perl + exec в нем (процесс умирает - лок сам освобождается, и за счет exec от perl-а не остается и следа в pstree).

@kt97679
17.02.2023 15:38 UTC
0

Ротация пидов действительно может вызвать проблемы. Возможно для учета не только пида, но и имени процесса, можно использовать ps -eo pid,command, но я не знаю насколько портабельно это решение.

27.04.2025 03:00 UTC
0

в этом подходе есть одна очень важная и распространённая ошибка - определять содержание по форме.

Вы же не исключаете такой вариант развития событий, что скрипт может быть переименован и/или перенесён в другое место?

поэтому я в свих изысканиях сразу отказался от использования имени скрипта/пути запуска, как от идентификатора.

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

если я Вас правильно понял)

27.04.2025 11:13 UTC
0

Тогда над принять во внимание и сценарий, когда скрпит использует flock, но он был отредактирован и имя лок файла поменялось. Думаю в оценке рисков имеет смысл не доводить до абсурда.