멀티스레딩 환경에서 여러 스레드가 동시에 동일한 리소스에 접근할 경우, 데이터의 일관성과 무결성을 유지하는 것은 매우 중요합니다. 이를 제대로 처리하지 않으면 동시성 문제로 인해 예기치 않은 결과가 발생할 수 있습니다.
자바에서는 이러한 문제를 해결하기 위해 뮤텍스(Mutex)와 세마포어(Semaphore)와 같은 동기화 메커니즘을 제공합니다. 각 메커니즘은 스레드 간의 협력을 관리하며, 특정 상황에 맞는 동기화를 가능하게 합니다.
이 글에서는 뮤텍스와 세마포어의 개념을 먼저 살펴보고, 두 메커니즘의 차이점을 자바 예제와 함께 알아보겠습니다.
멀티 프로그래밍 환경에서 여러 프로세스나 스레드가 n개의 공유 자원에 대한 접근을 제한하기 위해 사용되는 동기화 기법이 세마포어입니다.
세마포어는 정수형 변수를 통해 wait
와 signal
동작을 관리하며, 세마포어 변수는 0 이상의 값을 가집니다. 이를 통해 n개의 공유 자원에 대한 접근을 제한할 수 있으며, 이 경우를 계수 세마포어라고 합니다.
특정 상황에서는 공유 자원이 하나만 있을 때, 이진 세마포어를 사용하여 뮤텍스(Mutex)와 유사한 방식으로 동작시킬 수 있습니다.
또한, 큐에 연결된 스레드를 깨우는 방식에 따라 두 가지 유형으로 나눌 수 있습니다:
• 강성 세마포어: 큐에 연결된 스레드를 깨울 때 FIFO 정책을 따릅니다.
• 약성 세마포어: 큐에 연결된 스레드를 깨울 때 특정 순서를 명시하지 않습니다.
세마포어의 신호(Signal) 매커니즘을 사용하면, 락을 획득하지 않은 스레드도 signal을 보내 락을 해제할 수 있습니다.
다음과 같이 자바의 Semaphore
을 사용하여 구현한 예제 코드입니다.
예제에서는 2개의 공유 자원에 대한 접근을 제한하였습니다.
세마포어를 생성할 때는 몇 개의 스레드가 공유 자원에 접근할 수 있는지를 정수 값으로 지정해야 합니다.
acquire
를 사용하여 자원을 점유한 후, finally
블록에서 release
를 통해 자원을 해제했습니다. 만약 총 4개의 계좌에서 동시에 요청이 들어온다면, 먼저 들어온 2개의 계좌에 대해 작업이 수행되고, 그 이후에 나머지 2개의 계좌에 대한 작업이 처리됩니다.
사진에서 볼 수 있듯이, 2개의 계좌가 먼저 처리된 후 나머지 계좌들이 차례대로 수행된 것을 확인할 수 있습니다.
조금 더 자세하게 사진으로 알아보도록 하겠습니다.
먼저 접근 가능한 수를 2로 설정하여 세마포어를 생성합니다.
3개의 요청이 순서대로 온다고 가정한 1번 쓰레드가 먼저 도달하여 acquire
를 사용하여 점유하게 됩니다.
다음 2번 쓰레드고 acquire
를 사용하여 점유하게 되며 이로인해 세마포어에 더 이상 접근할 수 있는 자원이 없게 됩니다.
이러한 상황에서 3번 쓰레드가 접근하게 된다면 자원이 없기 때문에 Block이 되며 기다리게 됩니다.
1번 쓰레드가 일을 마치고 release
로 자원을 반환합니다.
대기하던 3번 쓰레드가 자원을 가져가게 되며 프로세스를 처리하게 됩니다.
반환값 | 메서드 | 설명 |
---|---|---|
생성자 | semaphore(int permits) | 초기 permits (허용되는 자원 개수)를 설정하여 세마포어를 생성합니다. |
생성자 | semaphore(int permits, boolean fair) | permits 와 fairness 정책을 설정하여 세마포어를 생성합니다. fair 가 true 면, FIFO 순서로 스레드에 자원을 할당합니다. |
void | acquire() | 하나의 자원을 요청합니다. 사용 가능한 자원이 없다면 스레드는 블록됩니다. |
void | acquire(int permits) | 지정된 permits 수만큼 자원을 요청합니다. 자원이 부족하면 스레드는 블록됩니다. |
void | acquireUninterruptibly() | 하나의 자원을 요청하되, 인터럽트에 의해 중단되지 않고 기다립니다. |
void | acquireUninterruptibly(int permits) | 지정된 permits 수만큼 자원을 요청하되, 인터럽트에 의해 중단되지 않고 기다립니다. |
int | availablePermits() | 현재 사용 가능한 자원의 개수를 반환합니다. |
int | getQueueLength() | 대기 중인 스레드의 수를 반환합니다. |
boolean | hasQueuedThreads() | 대기 중인 스레드가 있는지 여부를 반환합니다. |
boolean | isFair() | 세마포어가 공정성 정책을 따르는지 여부를 반환합니다. |
void | release() | 하나의 자원을 반환하여 다른 스레드가 사용할 수 있게 합니다. |
void | release(int permits) | 지정된 permits 수만큼 자원을 반환합니다. |
boolean | tryAcquire() | 자원을 즉시 요청하며, 자원이 사용 가능하면 점유하고 true 를 반환합니다. 그렇지 않으면 false 를 반환합니다. |
뮤텍스(Mutex)는 Mutual Exclusion(상호 배제)의 약자로, 여러 스레드가 동시에 실행되는 환경에서 자원에 대한 접근을 강제하기 위한 동기화 메커니즘입니다. 뮤텍스는 Boolean
타입의 Lock
객체를 사용하여, 자원을 사용하는 스레드가 있을 때 다른 스레드의 접근을 차단하고 대기 상태로 만듭니다.
하나의 스레드가 자원을 점유하고 있는 동안, 다른 스레드는 해당 자원이 해제될 때까지 Blocking 상태로 대기 큐에 머물게 됩니다. 또한, 뮤텍스의 중요한 특징은 Lock을 획득한 스레드만 Lock을 해제할 수 있다는 점입니다. 이를 통해 자원에 대한 일관성과 무결성을 보장할 수 있습니다.
자바에서는 Lock 인터페이스를 활용하여 뮤텍스 개념을 쉽게 구현할 수 있으며 상호 배제를 위한 다양한 기능을 제공하여 자원을 안전하게 보호할 수 있습니다.
다음과 같이 자바의 synchronized
을 사용하여 뮤텍스 개념을 구현한 예제 코드입니다.
다음과 같이 1번 계좌가 먼저 처리된 후 2번 계좌가 처리되는 것을 확인할 수 있습니다.
Lock 인터페이스는 다음과 같은 구조를 가지고 있습니다.
특징에 대해서 간단히 정리해보면 다음과 같습니다.
lock() | 사용 가능할 경우 lock을 얻습니다. lock을 사용할 수 없는 경우 lock을 얻을 때까지 Thread를 블락(block) 시킵니다. |
---|---|
lockInterruptibly() | lock과 유사하며 블락(Block)상태일 때 java.lang.InterruptedException 을 발생시키면서 다시 실행할 수 있습니다. |
tryLock() | lock의 non-bloking버전입니다. 다른 Thread에서 lock이 걸려있으면 lock을 얻으려고 기다리지 않습니다. |
tryLock(long time, TimeUnit unit) | lock()은 lock을 얻을 때까지 Thread를 Block 시키므로 쓰레드의 응답성이 나빠질 수 있습니다. 즉, 응답성이 중요한 경우 지정된 시간을 정해서 그 시간안에 lock을 얻지 못하면 다시 작업을 할 지, 포기할지를 정할 수 있습니다. |
unlock() | lock을 해지하는 것 입니다. |
다음과 같이 자바의 Lock 인터페이스를 사용하여 손 쉽게 작성할 수 있습니다.
동시에 요청을 하였지만 Lock
으로 인해 1번 계좌가 선점하여 먼저 수행된 후 2번 계좌가 수행되는 것을 확인할 수 있습니다.
먼저 접근한 스레드는 lock()
을 사용하여 해당 자원을 선점한 후, 마지막에 finally
블록에서 unlock()
을 호출하여 락을 해제하는 것을 확인할 수 있습니다.
중간에 예외가 발생할 가능성이 있으므로,
finally
블록에서 반드시unlock()
을 호출해야 합니다.
결과를 보면 1번 계좌가 먼저 코드 섹션을 선점하고, 2번 계좌가 그 다음으로 수행되는 것을 확인할 수 있습니다. 로그에서 1번 계좌의 Balance Start Time이 먼저 기록되고, 이후에 2번 계좌의 작업이 이어지는 점을 통해 뮤텍스에 의해 코드가 순차적으로 실행되고 있음을 알 수 있습니다.
Lock을 구현한 구현체로는 다음과 같습니다.
하나씩 알아보도록 하겠습니다.
Reentrant(재 진입할 수 있는)이라는 단어가 붙어 있는 이유는 wati()
, notify()
와 같이 특정 조건에서 lock을 풀고 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있기 때문입니다.
해당 락은 두 개의 생성자를 가지고 있습니다.
매개변수에 true
를 주면 lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게 공정하게 처리합니다.
하지만 공정하게 처리하려면 어떤 쓰레드가 가장 오래 기다렸는지 확인하는 과정을 거칠 수 밖에 없으므로 성능은 떨어질 수 밖에 없습니다.
ReentrantReadWriteLock
은 ReentrantLock
인터페이스의 래퍼로 구현되어 있어 몇 가지 추가 기능과 함께 동일한 기본 기능을 제공합니다.
읽기 우선 정책을 따르기 때문에, 쓰기 잠금이 걸려 있더라도 읽기 잠금이 해제되자마자 즉시 획득할 수 있습니다. 반면 쓰기 잠금을 시도하는 쓰레드는 모든 읽기 및 쓰기 잠금이 해제될 때까지 차단됩니다.
다음과 같이 balance
기능에 대해서는 읽기 잠금을 하였고 writhdraw
기능에 대해서는 쓰기 잠금을 하였습니다.
조회에 대해서는 따로 Block 없이 병렬적으로 처리가 되었습니다.
하지만 변경이 있는 WriteLock에 대해서는 순서대로 처리가 되었습니다.
ReentrantReadWriteLock은 사용해야 할 경우와 사용하지 않는 것이 적합한 경우가 존재합니다.
해당 락은 동시성 제어에서 최대한 배제되는 것을 추천하는 Lock입니다..
추후 내용을 추가하도록 하겠습니다.