[운영체제 아주 쉬운 세 가지 이야기 - Concurrency] 31. Semaphores
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
다양한 범주의 병행성 문제 해결을 위해서는 락과 조건 변수가 모두 필요하다. 정확한 역사를 파악하기는 어렵지만, 이 사실을 최초로 인지한 사람 중에 다익스트라가 있다. 이번 장에서 다루게 될 세마포어(semaphore)라는 동기화 기법도 그가 개발한 것이다. 다익스트라와 그의 동료들은 모든 다양한 동기화 관련 문제를 한 번에 해결할 수 있는 그런 기법을 개발하고자 하여 세마포어가 탄생했다. 세마포어는 락과 컨디션 변수로 모두 사용할 수 있다.
여기서는 아래와 같은 내용을 다룬다.
- 세마포어를 어떻게 사용하는가?
- 락과 컨디션 변수 대신에 세마포어를 사용하는 방법은 무엇인가?
- 세마포어의 정의는 무엇인가?
- 이진 세마포어는 무엇인가?
- 락과 컨디션 변수를 사용하여 세마포어를 만드는 것이 가능한가?
- 그 반대로 세마포어를 사용하여 락과 조건 변수를 만드는 것이 가능한가?
세마포어 정의
세마포어는 정수 값을 갖는 객체로서 두 개의 루틴으로 조작할 수 있다. POSIX 표준에서 이 두 개의 루틴은 sem_wait()와 sem_post()이다. 세마포어는 초기값에 의해 동작이 결정되기 때문에, 사용하기 전 “제일 먼저” 값을 초기화를 해야 한다.
아래 그림은 세마포어 초기화 코드이다.
1
2
3
4
#include <semaphore.h>
sem_t s;
sem_init(&s, 0, 1);
이 그림에서는 세마포어 s를 선언 후, 3번째 인자로 1을 전달하여 세마포어의 값을 1로 초기화한다. sem_init()의 두 번째 인자는 모든 예제에서 0이고, 이 값은 같은 프로세스 내의 쓰레드 간에 세마포어를 공유한다는 것을 의미한다.
초기화된 후에는 sem_wait(), 또는 sem_post()라는 함수를 호출하여 세마포어를 다룰 수 있다. 이 두 함수의 동작은 아래 코드에 나타내었다.
1
2
3
4
5
6
7
8
9
int sem_wait(sem_t *s) {
decrement the value of semaphore s by one;
wait if value of semaphore s is negative;
}
int sem_post(sem_t *s) {
increment the value of semaphore s by one;
if there are one or more threads waiting, wake one;
}
이 루틴들은 다수 쓰레드들에 의해 동시에 호출되는 것을 가정한다. 임계 영역은 적절히 보호되어야 한다.
우선 첫 번째로, sem_wait() 함수는 세마포어의 값이 1 이상이면 즉시 리턴하거나, 해당 세마포어 값이 1 이상이 될 때까지 호출자를 대기시킨다. 다수의 쓰레드들이 sem_wait()를 호출할 수 있기 때문에, 대기 큐에는 다수의 쓰레드가 존재할 수 있다. 대기하는 법에는 스핀과 재우기 두 가지 방법이 있다.
두 번째로, sem_post() 함수는 대기하지 않고, 세마포어 값을 증가시키고 대기 중인 쓰레드 중 하나를 깨운다.
세 번재로, 세마포어가 음수라면 그 값은 현재 대기 중인 쓰레드의 개수와 같다. 일반적으로 세마포어 사용자는 이 값을 알 수 없다. 하지만, 이러한 성질을 알고 있는 것이 세마포어 작동을 이해하는 데 도움이 된다.
이 두 개의 함수는 원자적으로 실행된다고 가정한다. 세마포어 루틴 내에서 레이스 컨디션이 발생할 수 있다는 사실은 걱정하지 말자. 그것을 해결하기 위해 곧 락과 컨디션 변수를 사용하게 될 것이다.
이진 세마포어(락)
이제 세마포어를 사용할 준비가 되었다. 우리가 처음으로 세마포어를 적용할 곳은 이미 친숙한 “락”이다.
아래 코드는 이진 세마포어 코드이다.
1
2
3
4
sem_t m;
sem_init(&m, 0, x);
sem_wait(&m);
sem_post(&m);
sem_wait() / sem_post() 쌍으로 임계 영역 부분을 둘러싼 것을 볼 수 있다. 이것이 동작하기 위한 핵심은 세마포어 m의 초기값인 x이다. 이 x의 값이 무엇이 되어야 하겠는가?
sem_wait()와 sem_post()의 정의를 되새겨보면 초기값은 1이 되어야 한다는 것을 알 수 있다.
이 부분을 분명하게 하기 위해 쓰레드가 두 개인 경우를 생각해 보자. 첫 쓰레드가 sem_wait()를 호출하여 세마포어 값을 1 감소시켜 0을 만든다.
쓰레드는 세마포어 값이 음수인 경우에만 대기하고, 세마포어의 값이 0이므로 리턴하고 진행할 수 있다. 쓰레드 0은 이제 임계 영역에 진입할 수 있다. 쓰레드 0이 임계 영역 내에 있을 때 다른 쓰레드가 락을 획득하려고 하지 않는다면, 이 쓰레드가 sem_post()를 불렀을 때 세마포어 값이 다시 1로 설정된다. (대기 중인 쓰레드가 없기 때문에, 아무도 깨우지 않는다.)
아래 그림에 이 시나리오의 흐름이 나타나 있다.
좀 더 흥미로운 상황은 쓰레드 0이 sem_wait()을 호출하여 임계 영역에 진입했지만 아직 sem_post()를 호출하지 않아 “락을 보유하고” 있을 때, 쓰레드 1이 sem_wait()를 호출하여 임계 영역 진입을 시도하는 경우이다. 이 경우, 쓰레드 1이 세마포어 값을 -1로 감소시키고 대기에 들어간다. 쓰레드 0이 다시 실행되면 sem_post()를 호출하고, 세마포어 값을 0으로 증가시키고, 잠자던 쓰레드 1을 깨워 락을 획득할 수 있게 된다. 쓰레드 1의 작업이 끝나면, 세마포어의 값을 다시 증가시켜 1이 되도록 한다.
그림 34.5는 이 예제의 흐름을 나타낸다.
쓰레드들의 동작 외에도 각 쓰레드의 상태도 같이 나타낸다. 상태는 실행, 준비, 대기로 표현되었다. 누군가가 보유하고 있는 락을 획득하려고 할 때 쓰레드 1이 대기 상태에 들어가는 것에 유의하자. 쓰레드 0이 다시 실행이 될 때에만 쓰레드 1이 깨어나서 실행 가능한 상태가 된다.
세마포어를 락으로 쓸 수 있다는 것을 알았다. 락은 두 개의 상태 (사용 가능, 사용 중)만 존재하므로 이진 세마포어(binary semaphore)라고도 불린다.
만약 세마포어를 이진 세마포어로만 사용할 목적이라면, 현재 우리가 설명하는 범용 세마포어보다 더 쉽게 구현할 수 있다.
컨디션 변수로서의 세마포어
어떤 조건이 참이 되기를 기다리기 위해 현재 쓰레드를 멈출 때에도 세마포어는 유용하게 사용될 수 있다. 예를 들어, 리스트에서 객체를 삭제하기 위해 리스트에 객체가 추가되기를 대기하는 쓰레드가 있을 수 있다. 이런 종류의 전형적인 패턴이 하나의 쓰레드가 어떤 사건의 발생을 기다리고, 또 다른 쓰레드는 해당 사건을 발생시킨 후, 시그널을 보내어 기다리는 다른 쓰레드를 깨우는 것이다. 대기 중인 쓰레드가 프로그램에서 어떤 조건이 만족되기를 대기하기 때문에, 세마포어를 컨디션 변수처럼 사용할 수 있다.
그림 34.6과 같이 부모 쓰레드가 자식 쓰레드를 생성한 후, 자식 쓰레드의 종료를 대기하고자 한다. 이 프로그램이 실행하여 다음과 같은 결과를 얻고자 한다.
1
2
3
parent: begin
child
parent: end
세마포어를 이용하여 어떻게 이 효과를 만들 수 있을까? 답은 의외로 간단하다. 코드에서 볼 수 있듯이, 부모 프로세스는 자식 프로세스 생성 후 sem_wait()를 호출하여 자식의 종료를 대기한다. 자식은 sem_post()를 호출하여 종료되었음을 알린다. 그렇다면 세마포어 값을 무엇으로 초기화할까?
정답: 세마포어의 초기 값은 0이다. 두 가지 상황이 발생할 수 있다.
첫 번째, 자식 프로세스 생성 후 아직 자식 프로세스가 실행을 시작하지 않은 경우다. 이 경우 자식이 sem_post()를 호출하기 전에 부모가 sem_wait()를 호출하여 자식이 실행될 때까지 대기해야 한다. 이를 위해서는 wait()호출 전에 세마포어 값이 0보다 같거나 작아야 하므로 0이 초기 값이 되어야 한다.
부모가 실행되면 세마포어 값을 -1로 감소시키고 대기한다. 자식이 실행되었을 때 sem_post()를 호출하여 세마포어의 값을 0으로 증가시킨 후 부모를 깨운다. 그러면 부모는 sem_wait()에서 리턴을 하여 프로그램을 종료시킨다.
두 번째, 부모 프로세스가 sem_wait()를 호출하기 전에 자식 프로세스의 실행이 종료된 경우이다. 이 경우 자식이 먼저 sem_post()를 호출하여 세마포어의 값을 0에서 1로 증기시킨다. 부모가 실행할 수 있는 상황이 되면 sem_wait()를 호출하여 세마포어 값이 1인 것을 발견하고, 0으로 감소시켜 sem_wait()에서 대기 없이 리턴한다. 이 방법 역시 의도한 결과를 만들어 낸다.
생산자/소비자(유한 버퍼) 문제
다음 문제는 생산자/소비자 문제 또는 유한 버퍼라고 불리는 문제이다.
첫 번째 시도
이 문제를 해결하는 첫 번째 시도에서는 empty와 full이라는 두 개의 세마포어를 사용한다. 쓰레드는 empty와 full을 사용하여 버퍼 공간이 비었는지 채워졌는지를 표시한다. put()과 get() 코드는 그림 34.9에 나타내었고 생산자와 소비자 문제를 해결하기 위해 시도한 해법은 그림 32.10에 나타내었다.
이 예제에서 생산자는 버퍼가 비워져서 데이터를 넣을 수 있기를 기다리고, 마찬가지로 소비자는 데이터를 꺼내기 위해 버퍼가 채워지기를 기다린다. MAX=1인 상황이 어떻게 동작할지 생각해 보자.
생산자와 소비자 쓰레드가 각 하나씩 있고 CPU도 하나인 상황에 대해 살펴보자. 소비자가 먼저 실행했다고 가정하면 소비자 쓰레드가 그림에서 C1 라인에 먼저 도달하여 sem_wait(&full)을 호출한다. 변수 full의 값은 0으로 초기화되었기 때문에 해당 명령으로 인해 full의 값은 -1로 감소되고, 소비자는 대기한다. 다른 쓰레드가 sem_post()를 호출해서 full 변수가 증가하기를 기다려야 한다.
그런 이후에 생산자 쓰레드가 실행하여 P1라인에서 sem_wait(&empty) 루틴을 호출한다. empty 변수가 MAX로 설정되었기 때문에 소비자와 다르게 생산자는 다음 문장을 계속 실행한다. empty 변수는 감소하여 0이 되고 생산자가 데이터 값을 버퍼의 첫 번째 공간에 넣는다. 그런 후에 생산자는 P3 라인의 sem_post(&full)을 호출하여 full의 세마포어의 값을 -1에서 0으로 변경하고 소비자 쓰레드를 깨운다.
위의 알고리즘에서 둘 중 하나의 상황이 발생할 수 있다. 생산자가 계속 실행한다면 반복문을 돌아 P1 라인을 다시 실행하게 된다. empty 세마포어의 값이 0이므로 대기 상태로 들어간다. 생산자 프로세스가 인터럽트에 걸리고 소비자 쓰레드가 실행된다면 sem_wait(&full) 문을 호출하면서 버퍼가 찼다는 것을 발견하고 데이터를 소비한다. 어느 경우이든 원하는 결과를 얻을 수 있다. 이 경우를 다수의 쓰레드로 확장하여 검증해도 여전히 제대로 동작한다
이번에는 MAX 값이 1보다 크고 생산자와 소비자 쓰레드들이 여러개 있다고 한다면, 경쟁 조건이 발생한다. put()과 get() 코드를 유심히 보고, 두 개의 생산자 Pa와 Pb가 있다고 해보자. 두 쓰레드가 put()을 거의 동시에 호출하였다고 하고 Pa가 먼저 실행되어서 버퍼의 첫 공간에 값을 넣기 시작한다. Pa 쓰레드가 fill 카운터 변수가 1로 변경하기 전에 인터럽트가 걸렸다. 생산자 Pb가 실행되고 마찬가지로 버퍼의 첫 번째 공간에 데이터를 삽입하면 Pa가 기록한 이전의 값은 새로운 값으로 대체된다. 이렇게 생산자의 데이터의 어느 것도 사라지면 안된다.
해답 : 상호 배제의 추가
위에서는 상호 배제를 고려하지 않았다. 버퍼를 채우고 버퍼에 대한 인덱스를 증가하는 동작은 임계 영역이기 때문에 신중하게 처리해야 한다. 지금까지 배운 이진 세마포어와 몇 개의 락을 추가하여 해결해보자. 아래 그림은 그 시도가 나타나 있다.
put()과 get() 코드에 락을 추가했으며 추가된 라인은 추가됨이라고 표기해놓았다. 하지만 아직 교착 상태가 발생한다.
교착 상태의 방지
생산자와 소비자 쓰레드가 각 하나씩 있다고 하자. 소비자가 먼저 실행되어 mutex를 획득하고 full 변수에 대하여 sem_wait()를 호출한다. 아직 데이터가 없기 때문에 소비자는 대기해야 하고 CPU를 양보한다. 여기서 중요한 것은 소비자가 아직도 락을 획득하고 있다는 것이다.
생산자가 실행되고 실행이 가능하면 데이터를 생성하고 소비자 쓰레드를 깨울 것이다. 불행하게도 이 쓰레드는 먼저 mutex 세마포어에 대해서 sem_wait()를 실행한다. 이미 락은 소비자가 획득한 상태이기 때문에 생산자 역시 대기에 들어간다.
소비자는 mutex를 가지고 있으면서 다른 full 시그널을 발생시키기를 대기하고 있는 순환 고리가 생겼다. full 시그널을 발생시켜야 하는 생산자는 mutex에서 대기 중이다. 생산자와 소비자가 서로를 기다리는 전형적인 교착 상태이다.
제대로 된 해법
이 문제를 해결하기 위해서는 락의 범위를 줄여야 한다. mutex를 획득하고 해제하는 코드를 임계 영역 바로 이전과 이후로 이동하였다. full이나 empty 관련 코드는 mutex 밖으로 배치하였다.
Reader-Writer 락
또 하나의 고전적인 문제가 있다. 다양한 자료 구조를 접근하는 데 여러 종류의 락 기법이 필요하다. 리스트에 삽입하고 간단한 검색을 하는 것과 같은 병행연산이 여러 개 있다고 해 보자. 삽입 연산은 리스트의 상태를 변경하고 검색은 자료 구조를 단순히 읽기만 한다. 삽입 연산이 없다는 보장만 된다면 다수의 검색 작업을 동시에 수행할 수 있다. 이와 같은 경우를 위해 만들어진 락이 reader-writer 락이다. 이 락에 대한 코드는 아래 그림에 나타나 있다.
이 코드는 간단하다. 자료 구조를 갱신하려면 새로운 동기화 연산 쌍을 사용한다. 락을 획득하기 위해서는 rwlock_acquire_writelock()을 사용하고 해제하기 위해서 rwlock_release_writelock()을 사용한다. 내부적으로는 writelock 세마포어를 사용하여 하나의 쓰기 쓰레드만이 락을 획득할 수 있도록 하여, 임계 영역 진입 후에 자료 구조를 갱신한다.
좀 더 재미있는 부분은 읽기 락의 획득과 해제이다. 읽기 락을 획득시 읽기 쓰레드가 먼저 락을 획득하고 읽기 중인 쓰레드 수를 표현하는 reader 변수를 증가시킨다. 첫 번째 읽기 쓰레드가 읽기 락을 획득할 때 중요한 과정이 있다. 읽기 락을 획득시 writelock 세마포어에 대해 sem_wait()을 호출하여 쓰기 락을 함께 획득한다. 획득한 쓰기 락은 읽기 락을 해제할 때 sem_post()로 다시 해제한다.
이 과정을 통해서 읽기 락을 획득하고 난 후, 다른 읽기 쓰레드들이 읽기 락을 획득할 수 있도록 한다. 다만, 쓰기 락을 획득하려는 쓰기 쓰레드들은 모든 읽기 쓰레드가 끝날 때까지 대기하여야 한다. 임계 영역을 빠져나오는 마지막 읽기 쓰레드가 writelock에 대한 sem_post()를 호출하여 대기 중인 쓰기 쓰레드가 락을 획득할 수 있도록 한다.
이 방식은 동작하지만 몇 가지 단점이 있는데, 특히 공정성에 문제가 있다. 상대적으로 쓰기 쓰레드가 불리하다. 쓰기 쓰레드에게 기아 현상이 발생하기 쉽다는 문제이다. 이를 해결하기 위한 힌트는 쓰기 쓰레드가 대기 중일 때는 읽기 쓰레드가 락을 획득하지 못하도록 하는것이다.
마지막으로 읽기-쓰기 락을 사용할 때는 약간의 주의가 필요하다. 기법이 정교할수록 오버헤드가 크다는 사실이다. 때문에 단순하고 빠른 락 종류를 사용하는 것보다 항상 성능이 좋은 것은 아니다.
식사하는 철학자
식사하는 철학자(Dining Philosopher) 문제는 실제 활용도는 낮지만 너무나 유명한 문제이다.
다섯 명의 철학자가 식탁 주위를 둘러앉고, 다섯 개의 포크가 각 철학자 사이에 놓여 있다. 철학자는 식사하는 때가 있고 생각하는 때가 있다. 생각 중일 때는 포크가 필요없다. 자신의 왼쪽과 오른쪽에 있는 포크를 들어야 식사를 할 수 있다. 이 포크를 잡기 위한 경쟁과 그에 따른 동기화 문제가 병행 프로그래밍에서 다루려는 식사하는 철학자 문제이다. 여기 각 철학자의 동작을 나타낸 기본 반복문이 있다.
1
2
3
4
5
6
while (1) {
think();
getforks();
eat();
putforks();
}
주요 쟁점은 getfork()와 putfork()의 루틴을 작성하되 교착 상태의 발생을 방지해야 하고, 어떤 철학자도 기아 상태에 빠지면 안되며 병행성이 높아야 한다. (즉, 가능한 많은 철학자가 동시에 식사를 할 수 있어야 한다.)
Downey의 해법과 같이 문제 해결을 위한 몇 가지 함수를 사용한다. 그 함수들은 다음과 같다.
1
2
int left(int p) { return p; }
int right(int p) { return (p + 1) % 5; }
철학자 p가 자신의 왼쪽에 있는 포크를 잡기 원한다면 left(p)를 호출하고, 오른쪽 포크를 잡기 원한다면 right(p)를 호출한다. 마지막 철학자(p=4)가 자신의 오른쪽의 포크인 포크 0을 잡으려고 할 경우를 위해서 모듈러 연산을 사용한다.
이 문제를 해결하기 위해서 세마포어가 필요하다. 각 포크마다 한 개씩 총 다섯 개가 있고 sem_t fork[5]로 정의한다.
불완전한 해답
이 문제를 해결하기 위한 첫 번째 시도를 해 보자. forks 배열에 있는 각 포크에 대한 세마포어를 1로 초기화를 하였고 각 철학자는 자신의 순번(p)을 알고 있다고 가정하자. 아래 그림과 같이 getforks()와 putforks() 루틴을 작성할 수 있다.
이 해답의 원리는 간단하다. 포크가 필요할 때 단순하게 하나의 “락”을 획득한다. 먼저 왼쪽의 것을 잡고 그 다음에 오른쪽의 것을 잡는다. 긜고 식사가 끝나면 잡은 순서대로 놓는 간단한 방법이기에 불완전하다.
문제는 교착 상태이다. 만약 각 철학자가 자신의 왼쪽의 포크를, 다른 철학자가 자신의 오른쪽의 포크를 먼저 잡았다면, 각 철학자는 하나의 포크만 들고서 다른 하나의 포크를 잡을 수 있게 되기를 평생 기다리게 된다. 구체적으로 살펴보면, 철학자 0이 포크 0을 잡고, 철학자 1이 포크 1를, 철학자 2가 포크 2를, 철학자 3이 포크 3를, 철학자 4가 포크 4를 잡는다. 모든 포크는 다 누군가 잡고 있기 때문에 모든 철학자는 다른 철학자가 갖고 있는 포크를 기다리며 대기하게 된다.
해답 : 의존성 제거
이 문제를 해결하기 위한 가장 간단한 방법은 최소한 하나의 철학자가 다른 순서로 포크를 집도록 하면 된다. 이 방법이 다익스트라가 제시한 해결책이다. 가장 높은 순번의 철학자 4가 포크를 다른 순서로 획득한다고 가정하자.
1
2
3
4
5
6
7
8
9
void getforks() {
if (p == 4) {
sem_wait(&fork[right(p)]);
sem_wait(&fork[left(p)]);
} else {
sem_wait(&fork[left(p)]);
sem_wait(&fork[right(p)]);
}
}
마지막 철학자가 오른쪽의 포크를 먼저 잡기 때문에 각 철학자가 하나의 포크를 든 채로 다른 포크를 기다리는 대기 상황은 발생하지 않는다. 환형 대기 상태가 끊어졌다.
이와 비슷한 유명한 문제들이 더 있다. 예를 들면 흡연가의 문제(Cigarette Smokers Problem)나 잠자는 이발사의 문제(Sleeping Barber Problem)가 있다.
세마포어 구현
마지막으로, 저수준 동기화 기법은 락과 컨디션 변수를 사용하여 우리만의 세마포어를 만들어 보자. 제마포어(Zemaphore)다.
이 작업은 아래 그림과 같다.
그림에서 보는 것과 같이 하나의 락과 하나의 컨디션 변수를 사용하고 세마포어의 값을 나타내는 상태 변수 하나를 사용한다.
다익스트라가 정의한 세마포어와 여기서 정의한 제마포어 간의 중요한 차이 중 하나는 세마포어의 음수 값이 대기 중인 쓰레드의 수를 나타낸다는 부분이다. 사실 제마포어에서는 이 값이 0보다 작을 수 없다. 이 방식이 구현하기도 쉽고 현재 Linux에 구현된 방식이기도 하다.
세마포어를 사용하여 락과 컨디션 변수를 만드는 것은 까다로운 문제이다.
요약
락 Lock
락은 임계 영역에 한 번에 하나의 스레드만 들어갈 수 있도록 허용하여 상호 배제(Mutual Exclusion)를 보장한다. 상호 배제를 보장하기에 POSIX에서는 mutex라고 부른다.
스레드는 임계 영역에 들어가기 전 락을 획득하고, 나올 때 락을 반납한다. 다른 스레드가 이미 락을 가지고 있다면, 락이 반납될 때까지 기다려야 한다.
조건 변수 Condition Variable
조건 변수는 특정 조건이 충족될 때까지 스레드를 대기시킨다. 특정 이벤트가 발생했음을 다른 스레드에게 알려주는 데 사용된다.
스레드는 특정 조건이 거짓이면 wait()을 호출하여 잠들고, 다른 스레드가 조건을 참으로 만든 뒤 signal()을 호출하면 잠자던 스레드 중 하나가 깨어난다. 반드시 락과 함께 사용하여 조건을 검사하고 잠드는 과정에서 발생하는 경쟁 조건을 방지해야 한다.
세마포어 Semaphore
세마포어는 사용 가능한 자원의 개수를 나타내는 카운터이다.
스레드는 자원을 사용하기 전 sem_wait()를 호출하여 세마포어 값을 1 감소시킨다. 만약 값이 0이면 양수가 될 때까지 기다린다. 자원 사용이 끝나면 sem_post()를 호출하여 값을 1 증가시키고, 혹시 대기 중인 스레드가 있다면 깨워준다.
세마포어는 값을 1로 초기화하여 이진 세마포어(Binary Semaphore)로 사용하면 락과 동일하게 동작한다. 또한, 값을 0으로 초기화하여 컨디션 변수처럼 사용할 수도 있기에 가장 범용적이다.
락과 조건 변수로 세마포어를 만들 수 있고, 세마포어로 락과 조건 변수의 기능을 구현 가능하다.
참고