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 ( file lock )

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

  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개의 프로세스에서 공유하는 공유자원이 됩니다.

$ cat test.sh 
#!/bin/bash

lockfile=$0

get_task_id ()
{  
    flock 9
    echo critical section : start ... $BASHPID
    echo critical section : working ... $BASHPID
    sleep 1.$(( RANDOM % 10 ))
    echo critical section : end ... $BASHPID

} 9< "$lockfile"

preprocessing () {
    echo preprocessing ... $BASHPID
}
postprocessing () {
    sleep 5
    echo postprocessing END $BASHPID
}
start () {
    preprocessing
    get_task_id
    postprocessing
}

start & start & start & wait
echo "ALL DONE."
---------------------------------------------------------------

$ ./test.sh  
preprocessing ... 162841                        # preprocessing 
preprocessing ... 162842                        # postprocessing 은 동시에 실행되고
preprocessing ... 162843                                                               
critical section : start ... 162842             # critical section 구간에서는
critical section : working ... 162842           # 하나의 프로세스만 진입해서 실행된다.
critical section : end ... 162842
critical section : start ... 162841
critical section : working ... 162841
critical section : end ... 162841
critical section : start ... 162843
critical section : working ... 162843
critical section : end ... 162843
postprocessing END 162842
postprocessing END 162841
postprocessing END 162843
ALL DONE.

get_task_id 함수는 다음과 같이 쓸 수도 있지만 이렇게 하면 exec 명령에 의해서 FD 9 번이 스크립트 전체에 적용이 되고 종료 시까지 남게 됩니다.

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>...]
# 이경우 명령은 외부에 파일로 존재해야 실행할 수 있습니다.
# 그러므로 함수를 이 방법으로 실행할 수 없습니다.
# /var/lock/mylock 파일은 존재하지 않을 경우 자동으로 생성됩니다.

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

# lockfile 로 스크립트 자신을 사용할 수도 있습니다.
$ flock ./script.sh ./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 사용을 위해 외부 파일( inode )을 이용합니다. 이 외부 파일 ( 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 [option] ./test.sh ./test.sh 11 22 33

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

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

/proc/locks

현재 시스템에서 사용중인 locks 들을 볼 수 있습니다. 이 정보를 이용하는 명령이 lslocks 입니다.

$ cat /proc/locks
. . .
38: FLOCK  ADVISORY  WRITE  1150  00:18:861      0           EOF
39: POSIX  ADVISORY  READ   2703  08:05:5912530  1073741826  1073742335
. . .

위의 두 번째 컬럼에서 FLOCK 은 flock 시스템 콜에 의해 생성되는 file lock 이고 POSIX 는 fcntl 시스템 콜에 의해 생성되는 lock 으로 파일에 byte 단위로 lock 을 설정할 수가 있습니다. 마지막 두 컬럼이 start 와 end 를 나타내는데 file lock 은 항상 0 ~ EOF 이됩니다. 각 항목에 대한 자세한 설명은 man 페이지를 참조하세요.

$ LESS=+//proc/locks man proc

$ man lslocks

Quiz

Shell 에서 특정 프로세스 개수를 유지하면서 동시에 작업을 처리하려면 어떤 방법이 있을까요?

xargs 명령의 -P ( --max-procs ) 옵션을 이용하면 특정 프로세스 개수를 유지하면서 동시에 작업을 처리할 수 있습니다. 다음 예제의 경우 mycommand 프로세스 4 개를 생성하여 동시에 실행하는데 이때 args.txt 에 설정되어 있는 인수 값이 전달되어 사용됩니다. 하나의 프로세스가 작업을 마치고 종료하면 새로 프로세스가 생성되어 4 개를 유지합니다.


# args.txt 파일의 각각의 라인이(-L1) mycommand 의 인수로 사용됩니다. 
$ cat args.txt | xargs -P4 -L1 mycommand

--------------------------------------------------------------------

# 다음은 -I ARGS 옵션을 사용할 경우 
$ cat args.txt | xargs -P4 -I ARGS sh -c "mycommand ARGS"

# 인수값에 "aaa bbb" 와 같이 quotes 이 사용될 경우
$ cat args.txt | while read -r line; do printf "%q\n" "$line"; done |
    xargs -P4 -I ARGS sh -c "mycommand ARGS"

---------------------------------------------------------------------

# 다음은 직접 shell script 명령 라인을 인수로 전달하여 실행할 경우
$ cat commands.txt | while read -r line; do printf "%q\n" "$line"; done |
    xargs -P4 -I CMD bash -c CMD

2 .

C 프로그래밍에서는 conditional variables 을 이용하면 프로세스간에 작업순서 동기화도 가능합니다. 가령 프로세스 A ( producer ) 가 버퍼에 데이터를 쓰기를 하는동안 프로세스 B ( consumer ) 는 대기를하고있다가, producer 가 쓰기를 완료하면 깨어나 읽기를 하고, 읽기를 완료하면 다시 producer 가 깨어나 데이터를 생산하는 식으로 순서를 유지할 수 있습니다.

#include <stdio.h> 
#include <unistd.h>
#include <pthread.h> 

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t fill  = PTHREAD_COND_INITIALIZER,
               empty = PTHREAD_COND_INITIALIZER;

#define MAX 5

int buffer[MAX];

void *producer(void *arg)
{
    static unsigned char item = 'A';

    while (1) 
    {
        for (int i = 0; i < MAX; i++) {      // producer 는 버퍼에 데이터를 채운다.
            usleep(500000);
            buffer[i] = item++;
            printf("PRODUCE      %c\n", buffer[i]);
        }
        pthread_mutex_lock(&lock);
        pthread_cond_signal(&fill);          // 버퍼를 다 채웠으므로 consumer 를 깨우고
        pthread_cond_wait(&empty, &lock);    // consumer 가 읽기를 완료할 때까지 wait 한다.
        pthread_mutex_unlock(&lock);
    }
}

void *consumer(void *arg) 
{
    pthread_cond_wait(&fill, &lock);         // 시작시 consumer 는 먼저 wait 한다.
    pthread_mutex_unlock(&lock);
    while (1) 
    {                                        // wait 하고 있다가 producer 가 깨우면
        for (int i = 0; i < MAX; i++) {      // 일어나 버퍼 내용을 읽어 들인다.
            usleep(500000);
            printf("CONSUME         %c\n", buffer[i]);
        }
        pthread_mutex_lock(&lock);
        pthread_cond_signal(&empty);         // 읽기를 완료하였으므로 다시 producer
        pthread_cond_wait(&fill, &lock);     // 를 깨우고 wait 한다.
        pthread_mutex_unlock(&lock);
    }
}

int main () 
{ 
    pthread_t thread; 
    pthread_create(&thread, NULL, producer, NULL); 
    pthread_create(&thread, NULL, consumer, NULL); 

    pthread_exit (NULL);
}

위에서 pthread_cond_signal()pthread_cond_wait()pthread_mutex_lock() 를 이용해 원자적으로 처리하는 이유는 예를 들어 producer 가 signal 을 보낸 후에 wait 하기 전에 consumer 가 먼저 소비를 마치고 signal 을 보낼 수 있기 때문입니다 ( 스케줄러에 의해서 ). wait 하기 전에 받은 signal 은 무시되므로 결과적으로 producer 와 consumer 가 동시에 wait 하게 되는 deadlock 상태가 될 수 있습니다.

참고로 wait 상태가 될 때는 잡고 있던 lock 이 자동으로 unlock 이 되어 다른 스레드가 임계 영역에 진입할 수 있게 되고 나중에 signal 을 받고 깨어나게 될 때는 자동으로 lock 을 다시 잡고 진행하게 됩니다. 따라서 consumer 함수의 첫 번째 pthread_cond_wait() 뒤에는 pthread_mutex_unlock() 이 와야 합니다.

$ gcc test.c -pthread

$ ./a.out 
PRODUCE      A
PRODUCE      B
PRODUCE      C            // producer 가 데이터 생산을 완료하면 
PRODUCE      D
PRODUCE      E
CONSUME         A
CONSUME         B
CONSUME         C         // consumer 가 소비를 하고, 소비를 완료하면
CONSUME         D
CONSUME         E
PRODUCE      F
PRODUCE      G
PRODUCE      H            // 다시 producer 가 생산을 한다.
PRODUCE      I
PRODUCE      J
CONSUME         F
CONSUME         G
CONSUME         H
CONSUME         I
CONSUME         J
. . .

위의 방법은 buffer 가 하나이기 때문에 producer 가 다시 생산을 시작하려면 consumer 가 소비를 완료할 때까지 기다려야 합니다. 하지만 buffer 를 2 개를 두면 consumer 가 buffer 0 에서 소비를 하는 동안 producer 는 대기하지 않고 buffer 1 에서 생산을 지속할 수가 있습니다. 다시 말해서 producer 와 consumer 가 동시에 작업할 수 있게 됩니다.

#include <stdio.h> 
#include <unistd.h>
#include <pthread.h> 

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t fill  = PTHREAD_COND_INITIALIZER,
               empty = PTHREAD_COND_INITIALIZER;

#define BMAX 5       // 버퍼당 문자수
#define BNUM 2       // 버퍼 개수

int buffer[BNUM][BMAX];
int head = 0, tail = 0;

void *producer(void *arg)
{
    static unsigned char item = 'A';

    while (1) 
    {
        for (int i = 0; i < BMAX; i++) {
            usleep(500000);
            buffer[head][i] = item++;
            printf("PRODUCE buffer %d    %c\n", head, buffer[head][i]);
        }
        pthread_mutex_lock(&lock);
        pthread_cond_signal(&fill);
        head = (head + 1) % BNUM;
        if (head == tail)
            pthread_cond_wait(&empty, &lock);
        pthread_mutex_unlock(&lock);
    }
}

void *consumer(void *arg) 
{
    pthread_cond_wait(&fill, &lock);
    pthread_mutex_unlock(&lock);
    while (1) 
    {
        for (int i = 0; i < BMAX; i++) {
            usleep(500000);
            printf("CONSUME buffer %d       %c\n", tail, buffer[tail][i]);
        }
        pthread_mutex_lock(&lock);
        pthread_cond_signal(&empty);
        tail = (tail + 1) % BNUM;
        if (head == tail)
            pthread_cond_wait(&fill, &lock);
        pthread_mutex_unlock(&lock);
    }
}

int main () 
{ 
    pthread_t thread; 
    pthread_create(&thread, NULL, producer, NULL); 
    pthread_create(&thread, NULL, consumer, NULL); 

    pthread_exit(NULL);
}
$ ./a.out 
PRODUCE buffer 0    A
PRODUCE buffer 0    B
PRODUCE buffer 0    C
PRODUCE buffer 0    D
PRODUCE buffer 0    E
PRODUCE buffer 1    F
CONSUME buffer 0       A          // consumer 가 buffer 0 에서 소비를 하는 동안
PRODUCE buffer 1    G
CONSUME buffer 0       B
PRODUCE buffer 1    H             // producer 는 buffer 1 에서 생산을 지속할 수 있다.
CONSUME buffer 0       C
PRODUCE buffer 1    I
CONSUME buffer 0       D
PRODUCE buffer 1    J
CONSUME buffer 0       E
CONSUME buffer 1       F
PRODUCE buffer 0    K
CONSUME buffer 1       G
PRODUCE buffer 0    L
CONSUME buffer 1       H
PRODUCE buffer 0    M
. . .