Mutual Exclusion

Shell 에서는 스크립트를 작성할때 동시성 (concurrency) 를 생각해야 되는 경우가 많지 않지만 그래도 사용자가 여러개의 프로세스를 동시에 백그라운드로 실행할 수 있기 때문에 문제가 되는 경우가 생길 수 있습니다. 동시성 문제를 다룰때 대표적인게 여러 프로세스에 의한 공유자원 접근인데요. shell 에서는 외부 파일을 생각해볼 수 있습니다. 또한 스크립트 파일이나 특정 코드구간이 동시에 실행되면 안 될 경우도 있을 수 있는데요. 각각의 경우를 몇가지 사례를 들어 알아보겠습니다.

첫번째 예는 crontab 에서 script.sh 을 등록하여 실행하는 경우입니다. 스크립트 실행중에 문제가 발생하여 종료되지 못한 채로 남아있게 되어 이후에 계속해서 추가적으로 script.sh 프로세스가 생성되는 상태입니다.

이와 같은 경우 해결책으로 다음과 같은 코드를 생각해볼 수 있습니다.

#!/bin/bash

lockfile=/var/lock/$(basename "$0")

if [ -f "$lockfile" ]; then                          

    # lockfile 이 존재한다는 것은 이미 다른 프로세스가 실행 중이라는 의미가 됩니다.
    # 그러므로 바로 exit 합니다.   

    exit 1
fi                        

# lockfile 이 존재하지 않으면 현재 실행중인 프로세스가 없다는 의미입니다.
# 현재 실행중에 있다는 것을 알리기 위해 먼저 lockfile 을 생성하고 코드를 실행합니다.

touch "$lockfile"

# 스크립트 실행을 종료할때 lockfile 을 제거함으로써 다음 프로세스가 실행 가능하게 합니다.

trap 'rm -f "$lockfile"' EXIT

command1 ...
command2 ...
command3 ...

위 코드의 경우 앞선 프로세스가 실행 중단 상태에 빠지게 되면 뒤에 이어지는 프로세스는 lockfile 을 발견하게 되므로 바로 exit 할 수 있습니다. 그러므로 일정한 시간 간격을 두고 실행하게 되는 crontab 에서는 적절한 해결책이 될 수 있습니다. 하지만 여러 프로세스에 의해 동시에 실행이 된다면 위 코드는 올바르게 동작하지 않을 수 있습니다. 왜냐하면 lockfile 을 체크하고 이어서 lockfile 을 생성하는 과정이 두 부분으로 나누어져 있기 때문입니다. 프로세스 A 가 체크를 통과하고 나서 파일을 생성하는 과정에 있을때 다른 프로세스가 체크를 통과할 수가 있습니다.

shell 에서는 체크와 파일생성을 한번에 할수있는 명령이 있는데 바로 mkdir 명령입니다. mkdir 명령을 아래와 같이 이용하면 여러 프로세스에 의해 동시에 실행이 되어도 올바르게 동작합니다.

#!/bin/bash

lockfile=/var/lock/$(basename "$0")

if ! mkdir "$lockfile" 2> /dev/null; then 

    exit 1
fi                        

trap 'rmdir "$lockfile"' EXIT

command1 ...
command2 ...
command3 ...

다음은 set -C ( noclobber ) 옵션을 이용하는 방법입니다.

#!/bin/bash

lockfile=/var/lock/$(basename "$0")

if ! (set -C; : > "$lockfile") 2> /dev/null
then

    exit 1
fi

trap 'rm -f "$lockfile"' EXIT

command1 ...
command2 ...
command3 ...

flock

두번째 예는 인터넷에서 보게된 내용인데 작업 처리 과정을 간단히 정리해보면 이렇습니다.

  1. tasks.txt 파일에는 처리해야 될 task id 리스트가 들어있습니다.
  2. worker.sh 스크립트 파일은 tasks.txt 파일의 제일 윗줄에서 task id 하나를 읽어들인 후에, 목록에서 삭제합니다.
  3. 읽어들인 task id 에 해당하는 작업을 수행합니다.
  4. 작업이 완료되면 다시 task id 를 읽기위해 2번 으로 갑니다.
    ( 각 task 가 실행 완료하는데 걸리는 시간은 각각 다릅니다. )

수천개에 해당하는 task id 를 처리하기 위해 worker.sh 프로세스를 여러개 생성하여 동시에 실행하였는데 실행중에 같은 task id 를 읽어오는 경우가 발생하였다고 합니다. 이 문제를 해결하기 위해 작업과정을 살펴보면 task id 를 tasks.txt 파일에서 읽어들인 후에 삭제하는 과정이 두 부분으로 되어있는 것을 볼 수 있습니다. 그러니까 프로세스 A 가 tasks.txt 에서 task id 를 하나 읽어들인 후에 삭제하는 과정에 있을때 다른 프로세스 B 가 동일한 task id 를 읽을 수 있다는 것입니다.

그러므로 task id 를 읽고, 삭제하는 코드구간을 하나의 프로세스만 실행할 수 있게 하는것이 필요한데요. 이것을 앞서 사용했던 mkdir 명령으로 한다면 프로세스 A 가 실행중에 있을때 이어지는 프로세스 B 는 task id 를 얻지 못하고 바로 종료해 버리게 되므로 사용할 수가 없습니다. 다시말해서 프로세스 A 가 실행중에 있을때 이어지는 프로세스 B 는 대기상태에 있다가 A 가 종료하게 되면 진입해서 실행하는 것이 필요한데요. 이와 같은 기능을 제공하는 것이 flock 명령입니다.

위 작업과정을 flock 을 이용해 가상으로 구현해 보면 다음과 같습니다.

#!/bin/bash

lockfile=$0
tasks_file=tasks.txt

read_task_id() { ... ;}
delete_task_id() { ... ;}
do_task() { ... ;}

get_task_id ()  # 함수 전체가 critical section 에 해당
{  
    flock 9     # file descriptor 를 이용한 lock

    local task_id

    task_id=$(read_task_id);     # 1. task id 읽어들이기

    if [ -n "$task_id" ]; then   # 2. task id 가 있으면
        delete_task_id           #    목록에서 삭제하고
        echo "$task_id"          #    명령치환 값으로 리턴
    else
        echo 0                   # 3. task id 가 없으면 작업종료를 위해 0 을 리턴
    fi

} 9< "$lockfile"  # lock 을 위한 file descriptor 생성

while true; do
    task_id=$(get_task_id)
    [ "$task_id" -eq 0 ] && break
    do_task "$task_id"           # 작업종료 때까지 반복하여 do_task 실행
done

위 스크립트를 core 가 4 개인 cpu 에서 백그라운드로 4 개의 프로세스를 생성해서 동시에 실행시킨다고 하면 do_task 함수는 각자의 프로세스에서 동시에 실행이 되겠지만 get_task_id 함수를 실행할 때만은 오직 하나의 프로세스만 진입해서 실행이 됩니다. get_task_id 와 같이 여러 프로세스에 의해 동시에 실행돼서는 안되는 코드구간을 critical section 이라고 합니다. 이때 tasks.txt 파일은 4개의 프로세스에서 공유하는 공유자원이 됩니다.

get_task_id 함수는 다음과 같이 쓸수도 있습니다.

get_task_id ()
{
    exec 9< "$lockfile"
    flock 9
    ...
    ...
}

flock 명령의 -u 옵션은 unlock 을 의미합니다. 그러므로 다음과 같이 사용하면 코드의 특정 구간을 critical section 으로 설정할 수 있습니다.

get_task_id ()
{
    command ...
    ...
    flock 9
    ...
    # 두 flock 명령 사이가 critical section 에 해당
    ...
    flock -u 9
    ...
    ...
} 9< "$lockfile"

mkdir 명령을 사용했던 첫번째 예제를 flock 을 이용해 나타내면 다음과 같습니다. 여기서 flock 명령에 사용된 -n ( nonblock ) 옵션은 다른 프로세스가 이미 lock 을 가지고 실행중에 있을경우 대기하지 않고 바로 fail return 합니다. 그러므로 || 에 의해 exit 하게 됩니다.

#!/bin/bash

lockfile=$0

exec 9< "$lockfile"
flock -n 9 || { echo already in use; exit 1 ;}

command1 ...
command2 ...
command3 ...

flock 의 직접 명령 실행

지금 까지는 flock 을 이용할때 file descriptor 를 사용하여 스크립트 소스를 직접 수정하였는데요. flock 의 직접 명령실행 방법을 이용하면 소스의 수정없이 스크립트 파일이나 함수 전체를 lock 할 수 있습니다. 이때는 lock 을 위해서 file descriptor 가 아닌 임의의 파일이나 디렉토리가 사용됩니다.

# flock 의 직접 명령실행 방법 1
# flock [options] <file>|<directory> <command> [<argument>...]
# 이경우 명령은 외부에 파일로 존재해야 실행할 수 있습니다.
# 그러므로 함수를 이 방법으로 실행할 수 없습니다.

$ flock /var/lock/mylock ./script.sh 11 22 33

# flock 의 직접 명령실행 방법 2
# flock [options] <file>|<directory> -c <command>
# -c 옵션을 이용한 command string 의 방법은 함수도 실행할 수 있습니다.
# child process 에서 실행되므로 export -f 해야 합니다.

$ export -f func1
$ flock /var/lock/mylock -c 'func1 11 22 33'

mkdir 명령을 이용하여 소스를 수정했던 첫번째 예제를 flock 직접 명령실행을 이용하면 소스 수정없이 다음과 같이 crontab 에 등록할 수 있습니다. 앞선 스크립트 실행이 중단 상태에 빠질 경우 다음번 실행 시도시에 -n 옵션에 의해 바로 fail return 하게 됩니다.

flock -n /var/lock/mylock ./script.sh

Lock propagation

세번째 예제도 crontab 에 server.sh 을 등록하여 사용하는 경우인데요. 이번에 server.sh 의 역할은 매번 실행될 때마다 환경설정 파일을 새로 읽어들인 후에 서버 프로세스를 다시 시작하고 종료하는 것입니다. 그런데 여기서 문제는 첫번째 실행에서 서버 프로세스를 정상적으로 실행하고 종료하였더라도 다음번 실행에서는 server.sh 을 실행할 수가 없다는 것입니다. 왜냐하면 서버 프로세스가 background 로 실행될때 lock 을 소유하기 때문입니다. 서버 프로세스는 종료되지 않고 남아있기 때문에 다음번 cron job 실행시에 flock 에의해 server.sh 을 실행할 수 없게 되는 것입니다.

이와 같은 경우 스크립트에서 child process 가 실행될때 lock 소유하는 것을 방지하기 위해서는 flock 옵션인 -o 를 사용합니다.

flock -n -o /var/lock/mylock ./server.sh

Lock 의 공유와 lockfile

flock 명령은 file descriptor 를 이용하든 직접 명령 실행을 이용하든 lock 사용을 위해 외부 파일을 이용합니다. 이 외부 파일 (lockfile) 은 시스템 내의 모든 프로세스가 공유하므로 스크립트 A.sh, B.sh, C.sh 에서 동일한 lockfile 을 사용한다면 내부에서 어떤 file descriptor 번호를 사용하던 상관없이 lock 이 공유됩니다.

그러므로 만약에 여러 프로세스에 의해 lock 이 공유되고 있는 상태에서 어떤 한 프로세스가 종료시 lockfile 을 삭제한다면 다음에 이어지는 프로세스는 기존 lock 을 사용하지 못하게 됩니다.

다음은 lockfile 을 생성하지 않고 이미 존재하는 파일이나 디렉토리를 이용하여 flock 명령을 사용하는 예입니다.

# lockfile 로 /tmp 디렉토리를 이용

flock [option] /tmp command ...       

# lockfile 로 /dev/null 파일 이용

exec 9> /dev/null                    
flock 9
...
...

# lockfile 로 스크립트 자신을 이용
# 이때 출력기호 '>' 를 사용하면 스크립트 파일 내용이 삭제되므로 
# 입력기호 '<' 나 append 기호 '>>' 를 사용해야 합니다.

exec 9< "$0"
flock 9
...
...

# 아래 문장을 스크립트 파일 제일 위에 두면 여러 프로세스에 의해 동시에 실행되는것을 방지할 수 있습니다.
# 아래 사용된 flock 옵션은 '-n' (nonblock) 이므로 스크립트가 실행중에 있다면 
# 추가적인 실행 시도시 fail return 하게됩니다.

[ "${FLOCKER}" != "$0" ] && exec env FLOCKER=$0 flock -n "$0" "$0" "$@" || :