여기서는 앞서 배웠던 compare_and_swap()
과 mutex 락, 세마포어, 모니터 등 동기화 도구들을 실제 문제에서 어떻게 적용하는지 알아볼 것입니다. 또한, Linux, Unix 및 Windows 운영체제에서 사용되는 동기화 기법을 살펴보고 Java 및 POSIX 시스템의 API 세부 사항을 설명할 것입니다.
여기서 언급할 문제들은 다음과 같습니다.
해당 문제에서 소비자와 생산자는 다음과 같은 자료구조를 공유합니다.
int n;
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0;
생산자 프로세스와 소비자 프로세스는 다음과 같은 코드로 구성될 수 있으며, 대칭성을 가집니다.
// 생산자 프로세스
while (true) {
. . .
/* produce an item in next_produced */
. . .
wait(empty);
wait(mutex);
. . .
/* add next_produced to the buffer */
. . .
signal(mutex);
signal(full);
}
// 소비자 프로세스
while (true) {
wait(full);
wait(mutex);
. . .
/* remove an item from buffer to next_consumed */
. . .
signal(mutex);
signal(empty);
. . .
/* consume the itme in next_consumed */
. . .
}
생산자는 소비자를 위해 꽉 찬 버퍼를 생산합니다.
소비자는 생산자를 위해 비어 있는 버퍼를 생산해 내는 것으로 해석할 수 있습니다.
하나의 데이터베이스가 다수의 병행 프로세스 간에 공유된다고 가정해 봅시다.
여러 reader가 동시에 공유 데이터에 접근하더라도, 문제는 발생하지 않습니다.
하지만, 하나의 writer와 어떤 다른 스레드가 동시에 데이터베이스 접근하면 문제가 발생할 수 있습니다.
writer가 쓰기 작업 동안 공유 데이터베이스에 대한 배타적 접근 권한을 가지게 할 필요가 있습니다.
이 동기화 문제를 Readers-Writers 문제라고 합니다.
여기서는 우선순위를 어떻게 설정하느냐에 따라서 두 가지 상황으로 구분할 수 있습니다.
즉, 읽기 작업을 하는 도중 들어오는 읽기 작업과 쓰기 작업의 우선순위를 나누는 것입니다.
여기서는 첫 번째 상황에 대한 코드를 다루고 있습니다.
첫 번째 경우에는 writer가 기아 상태에 빠질 수 있고, 두 번째 경우에는 reader가 기아 상태에 빠질 수 있습니다. 그렇기에 다른 병형들도 생각해 봐야 합니다.
첫 번째 경우의 해결안에서, reader 프로세스는 다음과 같은 자료구조를 공유합니다.
semaphore rw_mutex = 1;
semaphore mutex = 1;
int read_count = 0;
rw_mutex 세마포어의 경우, 첫 번째 reader와, 임계구역을 빠져나오는 마지막 reader에 의해서도 사용됩니다.
이제 다음의 코드를 확인해 봅시다.
// Writer 프로세스
while (true) {
wait(rw_mutex);
. . .
/* writing is performed */
. . .
signal(rw_mutex);
}
// Reader 프로세스
while (true) {
wait(mutex);
read_count++;
if (read_count == 1)
wait(rw_mutex);
signal(mutex);
. . .
/* reading is performed */
. . .
wait(mutex);
read_count--;
if (read_count == 0)
signal(rw_mutex);
signal(mutex);
}
Readers-Writers 문제와 그의 해결안들은 일반화 되어 몇몇 시스템에서는 reader-writer 락을 제공합니다.
읽기 모드일때는 여러 프로세스가 동시에 획득하는 것이 가능하며, 쓰기 모드일때는 공유 데이터를 배타적으로 접근해야 하므로 오직 하나의 프로세스만이 reader_writer 락을 획득할 수 있습니다.
Reader-Writer 락은 다음과 같은 상황에서 가장 유용합니다.
식사하는 철학자들 문제는 고전적인 동기화 문제로, 교착 상태와 기아를 발생시키지 않고 여러 스레드에게 여러 자원을 할당해야 할 필요를 단순하게 표현한 것입니다.
간단하게 설명하자면, 5명의 철학자들이 원탁 테이블에 둘러 앉아 있고, 원탁 테이블 위에는 철학자들 사이마다 젓가락이 놓여 있습니다. 철학자들 앞에는 밥이 놓여 있고 생각을 하다가 먹고 싶어 졌을 때 자신의 양 옆에 있는 두 젓가락을 집어서 식사를 할 수 있습니다.
이 때, 젓가락을 집을 때는 한번에 하나의 젓가락만 집을 수 있습니다.
한 가지 간단한 해결책은 각 젓가락을 하나의 세마포어로 표현하는 것입니다.
철학자는 그 세마포어에 wait()
연산을 실행하여 젓가락을 집으려고 시도합니다. 그는 또한 해당 세마포어 signal()
연산을 실행함으로써 자신의 젓가락을 놓습니다.
semaphore chopstick[5];
여기서 chopstick
의 원소들은 모두 1로 초기화됩니다.
// 철학자 i의 구조
while (true) {
wait(chopstick[i]);
wait(chopstick[(i + 1) % 5]);
. . .
/* eat for a while */
. . .
signal(chopstick[i]);
signal(chopstick[(i + 1) % 5]);
. . .
/* think for awhile */
. . .
}
이 해결안은 인접한 두 철학자가 동시에 식사하지 않는다는 것을 보장하지만, Deadlock을 야기할 가능성이 있기 때문에 채택할 수 없습니다.
만약, 5명의 철학자 모두가 동시에 배가 고프게 되어, 각각 자신의 왼쪽 젓가락을 잡는다고 가정해 봅시다. 이러한 경우 교착 상태가 발생합니다.
여러 가지 해결책들이 있는데 다음과 같습니다.
물론 이러한 해결안들은 교착상태가 없도록 해줄 수 있지만 기아의 가능성을 반드시 제거하는 것은 아닙니다.
이 해결안은 철학자가 반드시 양쪽 젓가락 모두 얻을 수 있을 때만 젓가락을 집을 수 있다는 제한을 강제합니다.
이 해결안을 구현하려면, 철학자가 처할 수 있는 세 가지 상태를 구분할 필요가 있습니다.
enum {THINKING, HUNGRY, EATING} state[5];
철학자 i는 그의 "양쪽 두 이웃이 식사하지 않을 때만" 변수 state[i] = EATING
으로 설정할 수 있습니다.
또한 다음을 선언할 필요가 있습니다.
condition self[5];
self
는 철학자 i가 배고프지만 자신이 원하는 젓가락을 집을 수 없을 때 젓가락 집기를 미룰 수 있게 합니다.
젓가락의 분배는 모니터 DiningPhilosophters에 의해 제어됩니다.
각 철학자는 식사하기 전에 pickup()
연산을 반드시 호출해야 하며, 이 행동은 철학자 프로세스의 일시 중지를 낳을 수 있습니다.
연산이 성공적으로 끝나면, 철학자는 식사를 합니다. 식사를 마친 후, 철학자는 putdown()
연산을 호출합니다.
monitor DiningPhilosophers
{
enum {THINKING, HUNGRY, EATING} state[5];
condition self[5];
void pickup(int i) {
state[i] = HUNGRY;
test(i);
if (state[i] != EATING)
self[i].wait();
}
void putdown(int i) {
state[i] = THINKING;
test((i + 4) % 5);
test((i + 1) % 5);
}
void test(int i) {
if ((state[(i + 4) % 5] != EATING) &&
(state[i] == HUNGRY) &&
(state[(i + 1) % 5] != EATING)) {
state[i] = EATING;
self[i].signal();
}
}
initialization_code() {
for (int i = 0; i < 5; i++)
state[i] = THINKING;
}
}
이 해결안 또한 동시에 식사하는 상황이 발생하지 않는다는 것을 보장하지만 교착 상태가 발생하지 않는다는 것을 보장하지는 않습니다.
여기서는 Windows, Linux 운영체제에서 제공되는 동기화 기법을 설명할 것입니다.
Windows 운영체제는 실시간 응용과 다중 처리기 지원을 제공하는 다중 스레드 커널입니다.
Windows 커널은 단일 처리기
에서 전역 정보를 액세스할 때에는 동일한 전역 정보를 액세스할 가능성이 있는 인터럽트 핸들러가 실행되지 않도록, 인터럽트를 잠시 동안 못 걸리게 막습니다.
다중 처리기 시스템
에서는 Windows는 스핀락을 써서 전역 정보 액세스를 통제합니다. 하지만 Windows 커널은 짧은 코드에 대해서만 스핀락을 사용하고 효율성을 위해 스레드가 스핀락을 가지고 있는 동안에는 선점되지 않도록 보장합니다.
커널 외부에서 스레드를 동기화하기 위하여 dispatcher 객체를 제공합니다.
스레드는 dispatcher 객체를 사용하여 mutex 락, 세마포어, event 및 타이머를 포함한 다양한 기법에 맞추어 동기화를 할 수 있습니다.
Dispatcher 객체는 signaled 상태에 있을 수도 있고 nonsignaled 상태에 있을 수도 있습니다.
Signaled 상태는 객체가 사용 가능하고 그 객체를 얻을 때 그 스레드가 봉쇄되지 않음을 뜻합니다. Nonsignaled 상태는 객체가 사용할 수 없고 그 객체를 얻으려고 시도하면 그 스레드가 봉쇄됨을 뜻합니다.
스레드가 nonsignaled 상태에 있는 dispatcher 객체 때문에 봉쇄되면 그 스레드의 상태는 준비로부터 대기 상태로 바꾸고 그 스레드는 그 객체의 대기 큐에 넣어지게 됩니다.
즉, dispatcher 객체로 인해서 봉쇄되었다면 스레드의 상태가 대기로 바뀌면서 CPU 대기큐가 아닌 접근하려는 객체(데이터)의 대기 큐에 넣어지게 되는 것입니다.
추후 dispatcher 객체의 상태가 signaled 상태로 바뀌면 커널은 그 객체를 기다리는 스레드가 있는지 여부를 알아내어 있으면 그 하나의 스레드(가능하다면 여러 스레드)를 대기 상태로부터 준비 상태로 바꾸어 다시 실행을 재개할 수 있도록 조치합니다.
커널이 대기 큐로부터 선택하는 스레드의 개수는 각 스레드가 기다리고 있는 dispatcher 객체의 유형에 따라 결정됩니다.
Mutex 객체는 오직 하나의 스레드만 소유할 수 있어서, 하나를 선택합니다.
Event 객체의 경우에는, 이 이벤트를 기다리고 있는 모든 스레드를 선택하게 됩니다.
예시로 들어보자.
한 스레드가 nonsignaled
상태에 있는 mutex dispatcher
객체를 얻으려고 하면, 그 스레드는 일시 중지 되고 mutex 객체의 대기 큐에 넣어집니다.
Mutex가 signaled 상태로 바뀌면(다른 스레드가 그 Mutex의 락을 해제한 결과로) 대기 큐의 선두에서 기다리던 스레드가 대기 상태로부터 준비 상태로 바뀌고 mutex 락을 얻게 됩니다.
Critical-section 객체는 커널의 개입 없이 획득하거나 방출할 수 있는 사용자 모드 mutex입니다.
다중 처리기 시스템에서 critical-secion 객체는 처음에는 스핀락을 사용하여 다른 스레드가 객체를 방출하기를 기다립니다. 회전이 길어지게 되면 락을 획득하려는 프로세스는 커널 mutex를 할당하고 CPU를 양도합니다.
Critical-section 객체에서 커널 mutex는 객체에 대한 경쟁이 발생했을 때만 할되기 때문에 특히 효율적입니다. 실제로 경쟁은 거의 발생하지 않기 때문에 CPU 절약은 상당히 좋아집니다.
여기서 효율이 좋아진다는 것은 "Context-Switching"으로 인한 추가적인 오버헤드를 피할 수 있고, 평소에는 race condition이 자주 발생하지 않기 때문입니다.
⚡️ 스핀락(Spinlock)과 커널 mutex ⚡️
Windows에서 critical-section 객체의 동기화 방식은
사용자 모드
와커널 모드
간의 효율적인 전환을 통해 성능을 최적화합니다. 이 과정에서 스핀락과 커널 mutex가 사용됩니다.스핀락과 회전(Spinlock and Spinning)
- 스핀락: 초기 단계에서 critical-section 객체에 대한 접근을 시도하는 스레드는 스핀락을 사용하여, 객체가 방출될 때까지 루프(looping)을 통해 대기합니다.
- 회전이 길어질 경우: 대기해야 하는 시간이 길어지게 되면(즉, 스핀락을 통해 대기 시간이 일정 임계값을 초과하게 되면), 스핀락만으로 CPU 자원을 낭비하게 됩니다. 이때 더 효율적인 동기화 매커니즘이 필요하게 됩니다.
커널 Mutex와 CPU 양도
- 커널 Mutex: 스핀락으로 인한 대기 시간이 길어질 경우, critical-section 객체의 동기화는 커널 모드로 전환되며, 커널에서 관리하는 mutex가 할당됩니다. 이 커널 mutex는 스레드간의 동기화를 위해 커널의 스케줄러에 의해 관리됩니다.
- CPU 양도: 커널 mutex에 대한 대기 상태로 전환되면, 해당 스레드는 CPU를 양도하고 대기 상태(Blokcing state)로 들어가게 됩니다. 이는 Context Switching을 수반하며, 해당 스레드는 critical-section 객체가 사용 가능해질 때까지 실행을 중단합니다. 이 방식은 불필요한 CPU 사용을 줄이고, 다른 스레드가 CPU를 사용할 수 있도록 합니다.
"커널 Mutex"
정리하자면 커널 Mutex는 critical-section 객체를 안전하게 동기화하기 위해서 내부적으로 사용되는 mutex입니다. 사용자 모드에서 스핀락으로 해결되지 않는 대기 상황을 커널모드에서 처리하기 위해 사용되는 것입니다.
Linux는 다양한 동기화 기법을 제공하는데 그 중 하나가 원자적 연산을 제공하는 것입니다.
원자적 연산은 락 기법을 사용할 떄의 오버헤드가 필요하지 않기 때문에 효율적이지만, 특정한 상황에서만 유용하다는 제약이 있습니다.
더 정교한 락킹 도구를 위해서 mutex 락을 제공합니다.
태스크는 임계구역에 들어가기 전에 mutex_lock()
함수를 호출해야 하고 나오기 전에 mutex_unlock()
함수를 호출해야 합니다.
만약 mutex 락을 획득할 수 없으면 mutex_lock()
을 호출한 태스크는 수면 상태에 놓이고 락의 소유자가 mutex_unlock()
을 호출할 때 깨어나게 됩니다.
Linux 커널은 커널 안에서의 락킹을 위하여 스핀락과 세마포어 및 두 락의 reader-writer 버전도 제공합니다. SMP 기계에서의 기본적인 락킹 기법은 스핀락
입니다.
그리고 스핀락이 단지 "짧은 시간 동안만" 소유하도록 커널이 설계되었습니다.
Linux 커널에서 스핀락과 Mutex 락은 재귀적이지 않습니다. 이러한 설계는 두 개의 락을 획득하는 것을 불가능하게 합니다.
태스크의 정보를 담고있는 thread_info
구조체에 preempt_count
를 넣어서 락을 소유하고 있는지 확인하고 락을 소유하고 있지 않는 것이 확인되면 선점하도록 합니다.
POSIX API는 사용자 수준
에서 프로그래머가 사용할 수 있으며 특정 운영체제 커널의 일부가 아닙니다.
Mutex 락은 Pthreads에서 사용할 수 있는 기본적인 동기화 기법을 대표합니다.
다음의 코드를 볼 수 있습니다.
#include <pthread.h>
pthread_mutex_t mutex;
/* create and initialize the mutex lock */
pthread_mutex_init(&mutex, NULL);
/* acquire the mutex lock */
pthread_mutex_locK(&mutex);
/* ciritical section */
/* release the mutex lock */
pthread_mutex_unlock(&mutex);
모든 mutex 함수는 연산이 성공했을 경우 0 값을 반환합니다. 만약 오류가 발생한 경우에는 이 함수들은 0이 아닌 오류 코드를 반환하게 됩니다.
POSIX는 기명(named)과 무명(unnamed)의 두 유형의 세마포어를 명기하고 있습니다.
기본적으로 이 두가지는 매우 유사하지만 프로세스 간에 생성 및 공유하는 방식이 다릅니다.
다음 코드를 봐봅시다.
#include <semaphore.h>
sem_t *sem;
/* Create the semaphore and initialize it to 1 */
sem = sem_open("SEM", O_CREAT, 0666, 1);
sem_open()
함수는 POSIX 기명 세마포어를 생성하고 여는데 사용됩니다.
이 경우 세마포어를 SEM이라고 부르고 있습니다. O_CREAT
플래그는 세마포어가 존재하지 않는 경우 생성될 것임을 나타냅니다. 또한 세마포어는 매개변수 0666
을 통해 다른 프로세스에 읽기 및 쓰기 접근 권한을 부여하고, 1로 초기화됩니다.
기명 세마포어의 장점은 여러 관련 없는 프로세스가 세마포어 이름만 참조하여 동기화 기법으로 공통 세마포어를 동기화 기법으로 쉽게 사용할 수 있다는 것입니다.
다음과 같이 사용할 수 있습니다.
/* acquire the semaphore */
sem_wait(sem);
/* critical section */
/* release the semaphore */
sem_post(sem);
무명 세마포어는 sem_init()
함수를 사용하여 생성 및 초기화되며 세 개의 매개변수가 전달됩니다.
#include <semaphore.h>
sem_t sem;
/* Create the semaphore and initialize it to 1 */
sem_init(&sem, 0, 1);
플래그 0을 전달하면 세마포어를 만든 프로세스에 속하는 스레드만
이 세마포어를 공유할 수 있음을 나타냅니다.(0이 아닌 값을 제공한 경우 세마포어를 공유 메모리 영역에 배치하여 서로 다른 프로세스 간에 공유할 수 있습니다.)
또한 세마포어를 값 1로 초기화 합니다.
다음과 같이 사용할 수 있습니다.
/* acquire the semaphore */
sem_wait(&sem);
/* critical section */
/* release the semaphore */
sem_post(&sem);
POSIX 조건 변수는 모니터 문맥 내에서 사용되며 모니터가 데이터 무결성을 보장하는 락 기법을 제공합니다.
Pthread는 일반적으로 C 프로그램에서 사용되며 C에는 모니터가 없으므로 "조건 변수"에 mutex 락을 연결하여 락킹을 제공합니다.
다음 코드는 조건 변수 및 관련 mutex 락을 생성하고 초기화합니다.
pthread_mutex_t mutex;
pthread_cond_t cond_var;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond_var, NULL);
pthread_cond_wait()
함수는 조건 변수를 기다리는 데 사용됩니다.
다음 코드는 조견 변수를 사용하여 조건 a== b 가 true가 될 때까지 대기하는 방법을 보여주고 있습니다.
pthread_mutex_lock(&mutex);
while (a != b)
pthread_cond_wait(&cond_var, &mutex);
pthread_mutex_unlock(&mutex);
조건 변수와 연관된 mutex 락은 pthread_cond_wait()
함수가 호출되기 전에 획득되어야 합니다.
이는 가능한 경쟁 조건으로부터 조건 절의 데이터를 보호하는 데 사용되기 때문입니다. 이 락을 획득하면 스레드가 조건을 확인할 수 있습니다.
조건이 true가 아닌 경우 스레드는 pthread_cond_wait()
함수에 필요한 매개 변수를 전달하여 호출합니다. mutex 락이 해제되어 다른 스레드가 공유 데이터에 접근하고 해당 값을 갱신하여 조건 절이 true로 계산될 수 있습니다.
이때 루프 절안에 조건 절을 배치하는 것이 중요합니다.
공유 데이터를 변경하는 스레드는 pthread_cond_signal()
함수를 호출하여 조건 변수를 기다리는 하나의 스레드에 신호할 할 수 있습니다.
pthread_mutex_lock(&mutex);
a = b;
pthread_cond_signal(&cond_var);
pthread_mutex_unlock(&mutex);
Mutex 락을 해제하는 것은 pthread_mutex_unlock()
호출이 수행합니다. Mutex 락이 해제되면 신호받은 스레드는 mutex 락의 소유자가 되고 pthread_cond_wait()
호출에서부터 제어를 넘겨받아 실행을 재개합니다.
Java는 원래 동기화 기법으로 Java 모니터를 다뤘습니다.
릴리스 1.5에서 도입된 세 가지 추가 기법인 재진입 락, 세마포어 및 조건 변수가 등장했습니다.
이들은 가장 일반적인 락킹 및 동기화 기법입니다.
이 외에도 JAVA API는 원자적 변수 및 CAS 명령 지원등을 제공합니다. 여기서는 다루지 않습니다.
Java는 스레드 동기화를 위한 모니터와 같은 병행성 기법을 제공합니다.
BoundedBuffer
클래스는 생산자와 소비자 문제의 해결안을 구현하며 생산자와 소비자는 각각 insert()
및 remove()
메서드를 호출합니다.
Java의 모든 객체는 하나의 락과 연결되어 있습니다.
메서드가 synchronized
로 선언된 경우 메서드를 호출하려면 그 객체와 연결된 락을 획득해야 합니다.
synchronized
메서드를 호출하려면 BoundedBuffer
의 객체 인스턴스와 연결된 락을 소유해야 합니다. 다른 스레드가 이미 락을 소유한 경우 synchronized
메서드를 호출한 스레드는 봉쇄되어 객체의 락에 설정된 진입 집합(entry set)에 추가됩니다.
락이 해제되어 가용 상태로 전환될 경우, 랄에 대한 진입 집합이 비어 있지 않으면 JVM은 이 집합에서 락 소유자가 될 스레드를 임의로 선택합니다.(보통 FIFO 정책)
다음은 Java 동기화를 사용한 유한 버퍼코드 예시입니다.
pulbic class BoundedBuffer<E>
{
private static final int BUFFER_SIZE = 5;
private int count, in, out;
private E[] buffer;
public BoundedBuffer() {
count = 0;
in = 0;
out = 0;
buffer = (E[]) new Object[BUFFER_SIZE];
}
/* Producers call this method */
public synchronized void insert(E item) {
/* 아래에서 다루도록 하겠습니다. */
}
/* Consumers call this method */
public synchronized E rmove() {
/* See under code */
}
}
락을 갖는 것 외에도 모든 객체는 스레드 집합으로 구성된 대기 집합과 연결됩니다. 대기 집합 즉, 버퍼가 가득 찬 경우에는 wait()
함수를 이용할 수 있습니다.
스레드가 wait()
메서드를 호출하면 다음이 발생합니다.
insert()
메서드를 호출하고 버퍼가 가득 찬 것을 확인하면 wait()
메서드를 호출하게 됩니다.
소비자 스레드는 생산자에게 진행할 수 있다는 것을 알리기 위해 notify()
메서드를 사용합니다.
일반적인 이탈 스레드는 객체와 연결된 락만 해제하여 진입 집합에서 스레드를 제거하고 락 소유권을 넘겨줍니다. 하지만, notify()
메서드를 사용함으로써 wait()
를 호출한 스레드에게 가용 가능을 알려줄 수 있습니다.
notify()
메서드는 다음과 같은 일을 합니다.
전체 코드는 다음과 같습니다.
/* Producers call this method */
public synchronized void insert(E item) {
while (count == BUFFER_SIZE) {
try {
wait();
}
catch (InterruptedException ie) { }
}
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++;
notify();
}
/* Consumers call this method */
public synchronized E remove() {
E item;
while (count == 0) {
try {
wait();
}
catch (InterruptedException ie) { }
}
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
notify();
return item;
}
객체의 대기 집합에 스레드가 없으면
notify()
호출은 무시됩니다.
synchronized wait()
및 notify()
기법은 Java에서 처음부터 제공되었습니다.
이제 더 융통성 있고 강력한 락 기법을 알아봅시다.
API에서 사용 가능한 가장 간단한 락 기법은 ReentrantLock
입니다.
여러가지 면에서 ReentrantLock
은 synchronized
명령문처럼 작동합니다.
ReentrantLock
은 단일 스레드가 소유하며 공유 자원에 대해 상호 배타적 액세스를 제공하는 데 사용됩니다.
그러나 ReentrantLock
은 공정성 매개변수 설정과 같은 몇 가지 추가 기능을 제공합니다.
이 공정성은 오래 기다린 스레드
에 락을 줄 수 있는 설정입니다.
다음 코드를 먼저 봐봅시다.
Lock key = new ReentrantLock();
key.lock();
try {
/* critical section */
}
finally {
key.unlock();
}
스레드는 lock()
메서드를 호출하여 ReentrantLock
락을 획득합니다.
락을 소유할 수 있거나 해당 스레드가 이미 락을 소유하고 있는 경우, lock()
은 호출 스레드에게 락 소유권을 주고 제어를 반환합니다. 이것이 재진입이라고 명명된 이유입니다.
락을 사용할 수 없는 경우 호출 스레드는 소유자가 unlock()
을 호출하여 락이 배정될 때까지 봉쇄됩니다.
해당 코드에서 unlock()
을 finally
절로묶음으로써 임계구역이 완료되거나 try 블록 내에서 예외가 발생하면 락이 해제되는 것을 보장합니다.
만약에,
key.lock();
코드를 try 구문 안에 넣으면 어떻게 될까요?
lock()
이 호출될 때 unchecked
예외가 발생하면 락을 획득한 상태가 아니기 때문에 finally 절의 key.unlock()
을 실행할 경우 uncheked IllegalMonitorStateException
을 발생시킵니다.
이 예외는 발생한 unchecked 예외를 대신하게 되고 프로그램이 처음에 실패한 원인을 모호하게 만듭니다.
그렇다면, 재진입이란 것은 왜 필요한 것일까요??
여기서부터는 좀 더 깊이 있는 내용을 다뤄야 합니다. 그러기 위해서 동작을 명확히 할 필요가 있습니다.
그러기 전에 먼저 Synchronized Lock
과 ReentrantLock
의 차이를 명확히 해놓고 갑시다.
Synchronized Lock
synchronized
키워드는 메서드 전체나 특정 코드 블록에 대한 동기화를 제공합니다. 한번에 하나의 스레드만 실행되도록 하기 위해 객체의 모니터 락을 사용하여 동기화를 구현합니다.synchronized
키워드가 적용된 영역에 진입하려는 스레드는 먼저 해당 객체의 락(또는 모니터 락)을 획득해야 합니다.ReentrantLock
ReentrantLock
은 java.util.concurrent.locks
패키지에 속하는 클래스로, Lock
인터페이스를 구현합니다. 프로그래머가 더 세밀한 제어를 원할 때 사용할 수 있으며, lock()
과 unlock()
메서드를 통해 명시적으로 락을 획득하고 해제할 수 있습니다.ReentrantLock
을 사용하는 스레드는 lock()
메서드를 호출하여 락을 획득하고, unlock()
메서드를 호출하여 락을 해제합니다. 락을 획득한 스레드가 여러 번 lock()
을 호출할 수 있고(카운트 증가), 횟수만큼 unlock()
이 호출되어 해제되어야 합니다.이제 재진입 기능을 어떻게 구현하는 것인지 알아보자. 먼저 코드를 제시해 두겠습니다.
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantExample {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void performAction() {
lock.lock();
try {
System.out.println("Lock acquired by thread: " + Thread.currentThread().getName());
count++;
// 재귀적으로 performAction을 호출할 수 있습니다.
if (count < 3) {
System.out.println("Re-entering lock by thread: " + Thread.currentThread().getName());
performAction();
}
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantExample example = new ReentrantExample();
example.performAction();
}
}
코드는 비교적 간단합니다. 로직을 수행하고 count가 3이 되기 전까지는 재귀 호출을 수행하여 반복적으로 락을 획득합니다.
왜 이렇게 사용하는 것일까요?
이것은 재귀적 메서드 호출이나 반복적인 락 획득이 필요한 경우 매우 중요합니다. 하지만 이러한 상황이 있을까요?
간단하게 예를 들어보면 되는데, 만약 하나의 메서드 내에서 동기화 작업이 필요한 임계 구역이 있고 진입하기 위해 lock을 획득해야 한다고 가정해 봅시다.
이 메서드 내부에서 다른 메서드를 호출하는데 다른 메서드의 내부에도 임계 구역이 존재하여 lock이 필요한 상황입니다.
이러한 경우에, 만약 각각의 메서드의 임계 구역에 서로 다른 스레드가 들어갔다면 Deadlock이 발생할 수 있습니다. 이것을 같은 lock을 사용하도록 제한하여 불필요한 Deadlock 발생을 회피할 수 있겠죠.
하지만, 이렇게 설계를 할 경우 두 메서드는 강한 결합이 생겨버리고 맙니다.
물론, 설계 전반에 걸친 고려사항을 생각하여 판단해야 하겠지만, 같은 lock을 사용한다는 것은 같은 자원에 접근한다는 의미고 그렇다면 결국 같은 lock을 사용하는 것이 맞기 때문에 이러한 설계가 필요하게 될 것 입니다.
다른 예시도 살펴볼 수 있습니다.
만약, 하나의 메서드가 재귀적인 호출을 수행하여 최소 n번 이상 동작해야 된다고 가정해 봅시다. 이 과정에서 단순히 synchronized
로 임계구역을 제한한다면 여러 스레드가 동시에 접근할 때 수행이 서로 계속 늦춰지는 상황이 발생할 수 있습니다.
이러한 경우에도 ReentrantLock
의 재진입을 활용한다면 하나의 스레드가 종료된 후에 다른 스레드가 접근할 수 있기 때문에 효율적인 처리를 구현할 수 있습니다.
ReentrantLock은 상호 배제를 제공하지만 여러 스레드가 공유 데이터를 읽기만 하고 쓰지 않을 때에는 너무 보수적인 전략일 수 있습니다.
이러한 필요성을 해결하기 위해 Java API는 ReentrantReadWriteLock
을 제공합니다.
ReentrantReadWriteLock
에서 Reader는 여러 개일 수 있지만 Writer는 반드시 하나이어야 하는 락입니다.
Java API는 카운팅 세마포어도 제공합니다.
다음 코드를 통해 확인해 볼 수 있습니다.
Semaphore sem = new Semaphore(1);
try {
sem.acquire();
/* critical section */
}
catch (InterruptedException ie) { }
finally {
sem.release();
}
여기서 new Semaphore()
의 매개변수 value
는 세마포어의 초기 값을 지정합니다(음수 값 허용).
세마포어 역시 finally 절 안에 release()
호출을 배치합니다.
Java API의 조건 변수는 wait()
및 notify()
메서드와 유사한 기능을 제공합니다.
따라서 상호 배제를 제공하려면 조건 변수를 재진입 락과 연관시켜야 합니다.
먼저 ReentrantLock
을 생성하고 newCondition()
메서드를 호출하여 조건 변수를 생설할 수 있습니다.
Lock key = new ReentrantLock();
Condition condVar = key.newCondition();
모니터를 사용하면 wait()
및 signal()
연산을 기명 조건 변수에 적용하여 스레드가 특정 조건을 기다리거나 특정 조건이 충족될 때 알림을 받을 수 있습니다.
하지만, 언어 수준에서 Java는 기명 조건 변수에 대한 지원을 제공하지 않습니다. 각 Java 모니터는 무명 조건 변수 하나에만 연결되며 wait()
및 notify()
연산은 이 하나의 조건 변수에만 적용됩니다.
조건 변수는 통지받을 특정 스레드를 지정할 수 있게하여 이를 해결합니다.
다음 코드를 확인해 봅시다.
/* threadNumber is the thread that wishes to do some work */
public void doWork(int threadNumber)
{
lock.lock();
try {
/**
* If it's not my turn, then wait
* until I'm Signaled.
*/
if (threadNumber != turn)
condVars[threadNumber].await();
/**
* Do some work for awhile ...
*/
/**
* Now Signal to the next thread.
*/
turn = (turn + 1) % 5;
condVars[turn].signal();
}
catch (InterruiptedException ie) { }
finally {
lock.unlock();
}
}
ReentrantLock은 상호 배제를 제공하므로 doWork()
는 synchronized
로 선언할 필요가 없습니다.
스레드가 조건 변수에서 await()
를 호출하면 연관된 ReentrantLock
이 해제되어 다른 스레드가 상호 배제 락을 획득할 수 있습니다. 이와 유사하게 signal()
이 호출될 때 조건 변수에만 신호가 전달됩니다. 락은 unlock()
을 호출하여 해제합니다.
이후에 대체 방안들로 트랜잭션 메모리의 개념을 이용하여 프로세스 동기화 전략을 제공하고 있습니다. 즉,
commit & roll-back
을 이용하는 것입니다. 다른 방안으로는OpenMP
를 사용하여 컴파일러 디렉티브를 사용하는 방식도 제공하고 있습니다. 이에 대한 설명들은 넘어가도록 하겠습니다.