이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.
자바 컨커런시와 자바 메모리 모델에 관한 자료를 찾던 중 발견한 이 튜토리얼의 깔금한 이미지와 예제, 명료한 설명에 반하여 번역-소개한다. 자바 컨커런시 외에도 유익한 자료가 많으니 관심이 있다면 꼭 들러보길 바란다(특히 자바 아키텍처 기반 대용량 웹서비스와 관련이 있다면).
원문 URL : http://tutorials.jenkov.com/java-concurrency/locks.html
!! 쉬운 이해를 위한 의역이 다소 포함되었다.
락은 synchrnonized 블록과 유사한 쓰레드 동기화 메카니즘이다. 락은 synchronized 블록보다 더 정교하고 세련된 방식의 동기화를 가능하게 한다. 락은 synchronized 블록을 이용해 구현되며, 때문에 락을 사용한다고 해서 synchronized 블록이 완전히 사라지는 것은 아니다.
Java 5 부터 추가된 java.util.concurrent.locks 패키지는 몇 가지 락 구현을 포함하고 있기 때문에 락을 스스로 구현할 필요는 없어졌다. 하지만 락을 어떻게 사용하는지는 알아야 하고, 락 구현이 어떤 이론을 바탕으로 하는지 안다면 도움이 될 것이다. 더 자세한 정보를 원한다면 여기를 보는 것이 좋다.
간단한 락
synchronized 블록을 이용한 락에서 시작해보자.
public class Counter{
private int count = 0;
public int inc(){
synchronized(this){
return ++count;
}
}
}
inc() 메소드 안의 synchronized 블록을 보자. 이 블록은 코드 return ++count; 의 실행이 한 시점에 오직 한 쓰레드에 의해서만 수행될 수 있음을 보장한다. synchronized 블록 안에는 더 나은 코드가 있었을 수도 있겠지만, 여기서 synchronized 블록의 의미를 이해하기 위해서는 ++count 만으로 충분하다.
Counter 클래스는 synchronized 블록 대신 Lock 을 이용하여 다음과 같이 작성되었다.
public class Counter{
private Lock lock = new Lock();
private int count = 0;
public int inc(){
lock.lock();
int newCount = ++count;
lock.unlock();
return newCount;
}
}
lock() 메소드는 Lock 인스턴스에 락을 걸어 lock() 을 호출하는 모든 쓰레드가 unlock() 메소드가 호출될 때까지 블록되도록 한다.
다음은 간단한 Lock 구현이다.
public class Lock{
private boolean isLocked = false;
public synchronized void lock()
throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}
여기서 while(isLocked) 루프는 '스핀 락' 이라 불린다. 스핀 락과 wait(), notify() 메소드에 대해서는 쓰레드 시그널링에서 보다 자세히 다뤄진다. isLocked 이 true 인 동안은 lock() 을 호출하는 쓰레드는 wait() 메소드 호출을 통해 대기 상태가 된다. 쓰레드가 notify() 가 호출되지 않았음에도 예상치 못하게 깨어나는 경우, isLocked 의 값으로 쓰레드가 다시 대기 상태로 들어가야 할지, 아니면 다음 동작을 수행해야 할지 결정하도록 한다. isLocked 이 false 라면 쓰레드는 while(isLocked) 루프에서 벗어나 isLocked 를 true 로 세팅하여 Lock 인스턴스에 락이 걸렸음을 표시한다.
크리티컬 섹션(lock() 과 unlock() 사이)의 코드 실행을 완료한 쓰레드는 unlock() 을 호출하여 isLocked 을 false 로 세팅하고 lock() 메소드에서 대기중인 쓰레드를 깨운다. 락 재진입 자바의 synchronized 블록은 재진입이 허용된다. 한 쓰레드가 synchronized 블록에 진입하며 모니터 객체의 락을 획득하면, 쓰레드는 자신이 획득한 모니터 객체에 대한 다른 synchronized 블록으로도 진입할 수 있다. public class Reentrant{
public synchronized outer(){
inner();
}
public synchronized inner(){
//do something
}
} |
outer(), inner() 메소드 모두 synchronized 로 선언되어 있다. 한 쓰레드가 outer() 를 호출했다면, outer() 메소드 안에서는 inner() 마음대로 호출할 수 있다. 두 메소드의 synchronized 블록은 같은 모니터 객체, 'this' 에 대한 동기화를 의미하기 때문이다. 한 쓰레드가 한 모니터 객체에 대한 락을 획득하면 이 쓰레드는 자신이 획득한 모니터 객체에 대한 모든 synchronized 블록으로 진입할 수 있다. 이를 재진입이라 한다.
먼저 선보인 락 구현은 재진입이 불가능하다. Reentrant 클래스를 아래와 같이 다시 작성하면, outer() 를 호출하는 쓰레드는 inner() 메소드 안의 lock.lock() 에서 블록된다.
public class Reentrant2{
Lock lock = new Lock();
public outer(){
lock.lock();
inner();
lock.unlock();
}
public synchronized inner(){
lock.lock();
//do something
lock.unlock();
}
}
outer 를 호출하는 쓰레드는 먼저 Lock 인스턴스에 락을 걸고 inner() 를 호출한다. inner() 메소드 안에서 쓰레드는 다시 Lock 인스턴스에 락을 시도하는데, 이 시도는 실패하며 쓰레드는 블록된다. Lock 인스턴스는 이미 outer() 메소드에서 락이 걸렸기 때문이다.
쓰레드가 두 번째 lock() 을 호출하면서 블록되는 이유는 lock() 메소드 구현을 보면 간단히 알 수 있다.
public class Lock{
boolean isLocked = false;
public synchronized void lock()
throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
...
}
이유는 쓰레드가 lock() 메소드를 벗어날지 말지를 결정하는 while 루프(스핀 락) 내부의 조건에 있다. 현재의 조건은 어떤 쓰레드가 락을 걸었느냐와 관계없이 lock() 을 벗어나기 위해서는 isLocked 은 반드시 false 여야 한다.
Lock 클래스를 재진입 가능한 락으로 만들기 위해 약간의 수정이 필요하다.
public class Lock{
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;
public synchronized void lock()
throws InterruptedException{
Thread callingThread = Thread.currentThread();
while(isLocked && lockedBy != callingThread){
wait();
}
isLocked = true;
lockedCount++;
lockedBy = callingThread;
}
public synchronized void unlock(){
if(Thread.curentThread() == this.lockedBy){
lockedCount--;
if(lockedCount == 0){
isLocked = false;
notify();
}
}
}
...
}
이제 while 루프(스핀 락)가 Lock 인스턴스에 락을 건 쓰레드를 가지고 어떻게 동작하는지 보자. 락이 걸리지 않았거나(isLocked = false), 쓰레드가 락을 건 쓰레드와 동일하다면 while 루프는 실행되지 않는다. 그리고 쓰레드는 메소드를 종료한다.
이에 더하여, 같은 쓰레드에 의해 락이 걸린 횟수를 저장해둘 필요가 있다. 그렇지 않으면 락을 여러번 호출하더라도 한 번의 unlock() 호출로 모든 락이 풀려버리게 된다. 락을 해제하려는 쓰레드가 lock() 호출과 동일한 횟수의 unlock() 을 호출하기 전까지는 락은 풀리지 않아야 한다.
이제 Lock 클래스는 재진입 가능한 락이 되었다.
이에 더하여, 같은 쓰레드에 의해 락이 걸린 횟수를 저장해둘 필요가 있다. 그렇지 않으면 락을 여러번 호출하더라도 한 번의 unlock() 호출로 모든 락이 풀려버리게 된다. 락을 해제하려는 쓰레드가 lock() 호출과 동일한 횟수의 unlock() 을 호출하기 전까지는 락은 풀리지 않아야 한다.
이제 Lock 클래스는 재진입 가능한 락이 되었다.
락 공정성
자바의 synchronized 블록은 쓰레드들의 진입 순서에 대한 어떠한 보장도 하지 않는다. 때문에 다수의 쓰레드들이 동일한 synchronized 블록에의 접근을 계속 시도한다면, 하나 이상의 쓰레드가 영영 접근 권한을 부여받지 못하게 될 위험이 있다. 이 경우, 접근 권한은 언제나 다른 쓰레드들에게만 부여되는데, 이러한 현상을 기아상태라 한다. 이 현상을 피하기 위해 Lock 은 공정성을 가져야 한다. 이 글에서 등장한 Lock 구현은 내부적으로 synchronized 블록을 사용하고, 때문에 공정성을 보장하지 않는다. 기아상태와 공정성은 기아상태와 공정성에서 보다 자세히 다뤄진다.
자바의 synchronized 블록은 쓰레드들의 진입 순서에 대한 어떠한 보장도 하지 않는다. 때문에 다수의 쓰레드들이 동일한 synchronized 블록에의 접근을 계속 시도한다면, 하나 이상의 쓰레드가 영영 접근 권한을 부여받지 못하게 될 위험이 있다. 이 경우, 접근 권한은 언제나 다른 쓰레드들에게만 부여되는데, 이러한 현상을 기아상태라 한다. 이 현상을 피하기 위해 Lock 은 공정성을 가져야 한다. 이 글에서 등장한 Lock 구현은 내부적으로 synchronized 블록을 사용하고, 때문에 공정성을 보장하지 않는다. 기아상태와 공정성은 기아상태와 공정성에서 보다 자세히 다뤄진다.
finally 절에서의 unlock() 호출
Lock 을 이용해 크리티컬 섹션을 보호할 때는 크리티컬 섹션에서 예외가 발생할 수 있음을 기억해야 한다. finally 절에서 unlock() 을 호출하는 일은 중요하다. 다른 쓰레드가 락을 걸 수 있도록 Lock 인스턴스의 락은 반드시 해제되도록 한다.
Lock 을 이용해 크리티컬 섹션을 보호할 때는 크리티컬 섹션에서 예외가 발생할 수 있음을 기억해야 한다. finally 절에서 unlock() 을 호출하는 일은 중요하다. 다른 쓰레드가 락을 걸 수 있도록 Lock 인스턴스의 락은 반드시 해제되도록 한다.
lock.lock();
try{
//do critical section code, which may throw exception
} finally {
lock.unlock();
}
이 구조는 크리티컬 섹션의 코드에서 예외가 발생할 경우에도 락은 해제되도록 한다. finally 절에서 unlock() 을 호출하지 않는 상태로 크리티컬 섹션에서 예외가 발생한다면 락은 영원히 유지되어 lock() 을 호출하는 모든 쓰레드를 무한정 멈춰버릴 것이다.
'Java > Concurrency' 카테고리의 다른 글
재진입 락아웃(Reentrance Lockout) (0) | 2017.05.21 |
---|---|
읽기/쓰기 락(Read/Write Locks in Java) (0) | 2017.05.16 |
슬립 상태(Slipped Conditions) (0) | 2017.04.23 |
중첩 모니터 락아웃(Nested Monitor Lockout) (0) | 2017.04.16 |
기아상태와 공정성(Starvation and Fairness) (0) | 2017.04.09 |