본문 바로가기

프로그래밍언어/JAVA

[JAVA] 쓰레드의 동기화

반응형

멀티 쓰레드 프로그램에서는 프로세스의 자원을 여러 쓰레드가 공유한다. 그렇기 때문에 각각의 작업이 다른 작업에게 영향을 끼치게 된다. 이러한 경우 공유 데이터의 변경으로 인해 개발자가 의도했던 결과가 나오지 않을 수 있다. 이러한 문제를 해결하기 위해서 임계영역 (critical section) 과 락 (lock) 개념을 도입하여 한 쓰레드가 작업을 종료할 때까지 다른 쓰레드에게 방해받지 않도록 한다. 공유 데이터를 임계영역으로 지정해놓고, 해당 영역의 lock 을 획득한 쓰레드만이 공유 데이터를 사용할 수 있도록 하여 문제를 해결한다.

 

이처럼 한 쓰레드가 진행 중인 다른 쓰레드를 방해하지 못하도록 막는 것을 쓰레드의 동기화 (synchronization) 이라고 한다. 자바에서는 synchronized 블럭을 이용해서 쓰레드의 동기화를 지원했지만, JDK 1.5 부터는 java.util.concurrent.lock java.util.concurrent.atomic 패키지를 통해 다양학 방식을 제공한다.

 

 

1. synchronized

 

가장 간단한 방법인 synchronized 키워드를 이용한 동기화 방법이다. synchronized 키워드는 임계 영역을 설정하는데 사용된다.

 

// method 전체를 임계 영역으로 지정
public synchronized void method1() { ... }

// 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조 변수) { ... }

 

첫번째 방법은 메서드에 synchronized 키워드를 붙이는 방법이다. 이 방법을 사요하면 해당 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock 을 쓰레드가 얻어서 작업을 수행하다가 메서드가 종료되면 lock 을 반환한다.

 

두번째방법은 메서드 내의 코드 일부를 블럭으로 감싸고 synchronized 를 선언해주는 방법이다. 이때 참조변수로는 락을 걸고자하는 객체를 참조하는 것이어야 한다. 이 영역에 들어가게 되면 해당 참조변수로 얻은 객체의 락을 얻고, 영역이 끝나면 반환한다. 

 

 

2. wait(), notify()

 

synchronized 키워드를 통해서 공유 자원을 보호할 수 있지만, 특정 한 쓰레드의 작업이 길어지면서 다른 쓰레드가 해당 자원에 접근하지 못하게되면 전체 작업이 지연되는 문제가 된다. 이러한 문제를 해결하기 위해 나온 것이 wait() notify() 이다. 동기화된 임계 영역의 코드가 wait() 을 호출하면 락을 반납하고 기다리게 된다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다. 후에 작업을 진행할 수 있는 상황이 되면 notify() 를 호출해서 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 한다.

 

다만 notify() 가 호출되었을때 어떤 쓰레드가 락을 획득하는지는 알 수 없다. wait() 이 호출되면 실행중이던 쓰레드는 해당 객체의 waiting pool로 들어가고 notify() 가 호출되면 대기중이던 모든 쓰레드 중에서 임의의 쓰레드가 락을 획득한다.

 

wait() notify() 또는 notifyAll() 이 호출될 때까지 기다리지만, 매개변수가 있는 wait() 은 지정된 시간동안만 기다린다. , 지정된 시간이 지난 후 자동적으로 notify() 가 호출되는 것과 같다.

 

waiting pool 은 모든 객체마다 존재하는 것이기 때문에 notifyAll() 이 호출된다고 해서 모든 객체의 waiting pool 에 있는 쓰레드가 깨워지는 것은 아니다. notifyAll() 이 호출된 객체의 waiting pool 에 대기중인 쓰레드만 해당된다.

 

- starvation, race condition

 

notify() 가 호출 되었을 때, 최악의 경우에 특정 쓰레드만 락을 획득하고 다른 쓰레드가 오랫동안 기다리게 되는 상황이 발생할 수 있다. 이런 상황을 기아 현상, starvation 이라고 한다. 이 현상을 막으려면 notifyAll() 을 호출하여 모든 쓰레드에게 통지를 하면 결국 다른 쓰레드도 락을 획득할 수 있게된다.

 

하지만 notifyAll() 을 호출하더라도 어떤 쓰레드가 락을 획득하게 도리지 알 수 없다. 여러 쓰레드가 서로 락을 획득하기 위해 경쟁하기 때문인데, 이를 경쟁상태, race condition 이라고 한다. 이 상태를 해결하기 위해서는 각 쓰레드를 구분하여 통지할 수 있어야 한다.

 

 

3. Lock, Condition

 

synchronized 를 사용한 방법 외에도 'java.util.concurrent.locks' 패키지가 제공하는 lock 클래스들을 이용하여 동기화를 수행할 수 있다. 이 패키지는 JDK 1.5 에 추가되었다.

 

synchronized 는 자동으로 lock 이 잠기고 풀리기 때문에 편리하지만 같은 메서드 내에서만 lock 을 걸 수 있다는 제약이 불편하기도하다. 이러한 문제를 해결하기 위해서 lock 클래스를 사용한다. lock 클래스의 종류는 다음과 같이 3가지가 있다.

 

ReentrantLock: 재진입이 가능한 lock. 가장 일반적인 배타 lock
ReentrantReadWriteLock: 읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock: ReentrantReadWriteLock 에 낙관적인 lock 의 기능을 추가

 

ReentrantLock 은 가장 일반적인 lock 이다. 이름에서 알 수 있듯이 특정 조건에서 lock 을 풀고 나중에 다시 lock 을 얻어 임계영역으로 재진입해서 이후의 작업을 수행할 수 있다. wait(), notify() 의 예시와 유사하다.

 

ReentrantReadWriteLock 은 읽기를 위한 lock 과 쓰기를 위한 lock 을 제공한다. 읽기의 경우에는 lock 이 걸려있어도 다른 쓰레드가 읽기 lock 을 중복해서 걸고 읽기를 수행할 수 있다. 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가 되지 않는다. 그러나 읽기 lock 이 걸린 상태에서 쓰기 lock 을 중복해서 거는 것은 허용되지 않는다. 반대의 경우도 마찬가지다. 읽기를 할 때는 읽기 lock 을 걸고, 쓰기를 할 때는 쓰기 lock 을 건다.

 

StampedLock lock 을 걸거나 해지할 때 스탬프 (long  타입의 정수값) 을 사용하며, 읽기와 쓰기를 위한 lock 외에 낙관적 읽기 lock (optimistic reading lock) 이 추가된 것이다. 읽기 lock 이 걸려있으면 쓰기 lock 을 얻기 위해서는 읽기 lock 이 풀릴 때까지 기다려야 하는데 비해, 낙관적 읽기 lock 은 쓰기 lock 에 의해 바로 lock 이 풀린다. 그래서 낙관적 읽기에 실패하면, 읽기 lock 을 얻어서 다시 읽어 와야 한다. 무조건 읽기 lock 을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock 을 걸도록 한다.

 

- ReentrantLock 의 생성자

 

ReentrantLock()
ReentrantLock(boolean fair)

 

생성자의 매개변수를 true 로 주면, lock 이 풀렸을 때 가장 오래 기다린 쓰레드가 lock 을 획득할 수 있게, 즉 공정하게 처리한다. 그러나 공정하게 처리하려면 쓰레드의 대기 시간을 비교해야하기 때문에 성능은 떨어지게 된다. 대부분은 굳이 공정하게 처리하지 않아도 문제가 되지 않기 때문에 성능을 선택한다.

 

자동적으로 lock 의 잠금과 해제를 관리하는 synchronized 와 달리 lock 클래스들은 수동으로 이를 수행해야 한다. lock() 을 호출하여 락을 걸고, unlock() 을 호출하여 락을 해제한다. 임계영역에서 예외가 발생하는 경우 unlock() 이 호출되지 않고 빠져나가게 될수도 있기 때문에 unlock() 은 try-finally 문으로 감싸는 것이 일반적이다.

 

이외에도 tryLock() 이란 메서드가 있다. 이 메서드는 lock() 과 달리 다른 쓰레드에 의해 lock 이 걸려있으면 기다리지 않거나 지정된 시간만큼만 기다린다. lock 을 얻으면 true 를 반환하고, 얻지 못하면 false 를 반환한다. lock() 은 lock 을 얻을 때까지 쓰레드를 블락시키므로 쓰레드의 응답성이 나빠질 수 있다. 그렇기 때문에 응답성이 중요한 경우 tryLock() 을 이용해서 지정된 시간동안 lock 을 엊디 못하면 다시 작업을 시도할 것인지 포기할 것인지 사용자가 설정할 수 있게 하는 것이 좋다. 그리고 이 메서드는 InterruptedException 을 발생시킬 수 있는데, 이것은 지정된 시간동안 lock 을 얻으려고 기다리는 중에 interrupt() 에 의해 작업을 취소될 수 있도록 코드를 작성할 수 있다는 뜻이다.

 

- ReentrantLock 과 Condition

 

Condition 은 쓰레드를 구분하여 각각의 waiting pool 에 따로 대기시킬 수 있도록 한다. Condition 은 이미 생성된 lock 으로부터 newCondition() 을 호출해서 생성한다.

 

private ReentrantLock lock = new ReentrantLock(); // lock 생성

private Condition cond1 = lock.newCondition(); // lock 으로 condition 을 생성
private Condition cond2 = lock.newCondition():

cond1.await() // wait()
cond1.signal() // notify()

 

위의 코드에서 두개의 Condition 을 생성했다. 그 다음에는 wait(), notify() 대신에 Condition await(), signal() 을 사용하면 된다.

 

 

4. Volatile

 

volatile 은 변수의 값을 캐시가 아닌 메모리에서 읽어오도록 한다.

멀티 코어 프로세스에서는 코어마다 별도의 캐시를 가지고 있기 때문에 메모리에 저장된 값이 변경되었는데 캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 변수의 값과 불일치하는 경우가 생길 수 있다. 이때 volatile 을 사용하면 코어가 변수의 값을 읽어올 때, 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 값의 불일치가 해결된다.

 

volatile 대신에 synchronized 블럭을 사용해도 같은 효과를 얻을  수 있다. 쓰레드가 synchronized 블럭으로 들어갈 때와 나올 때, 캐시와 메모리간의 동기화가 이루어지기 때문에 값의 불일치가 해소되기 때문이다.

 

 

5. fork, join

 

JDK 1.7 부터는 fork join 프레임워크가 추가되었다. 이 프레임워크는 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어준다.

 

RecursiveAction: 반환값이 없는 작업을 구현할 때 사용
RecursiveTask: 반환값이 있는 작업을 구현할 때 사용

 

먼저 수행할 작업에 따라 위의 두개 클래스 중에 하나를 상속받아 구현한다. 두 클래스는 모두 compute() 라는 추상 메서드를 가지고 있는데 이를 상속받아서 구현하면 된다. 상속한 후에 작업을 시작하는 메서드는 invoke() 이다.

 

class TestTask extends RecursiveTask<Integer> {
    ...
    public Integer compute() {
	    ...
    }
}

...

ForkJoinPool pool = new ForkJoinPool();
TestTask task = new TestTask();

Integer result = task.invoke();

 

위 예제에서 ForkJoinPool fork & join 프레임워크에서 제공하는 쓰레드 풀로, 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 한다.

 

- compute()
 

compute() 를 구현할 때는 수행할 작업 외에도 작업을 어떻게 나눌 것인가에 대해서도 알려줘야 한다.

 

public Integer compute() {
    int value = this.value;
    if(value < 2) return value;

    int half = value / 2;
    TestTask left = new TestTask(half);
    TestTask right = new TestTask(value - half);

    left.fork(); // 작업 left 를 작업 큐에 넣는다.
    return right.compute() + left.join();
}

 

- work stealing

 

fork() 가 호출되어 작업 큐에 추가된 작업 역시, compute() 에 의해 더 이상 나눌 수 없을 때까지 반복해서 나누고, 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와서 수행한다. 이를 work stealing 이라고 하고, 쓰레드 풀에 의해서 자동으로 이루어진다.

 

- fork(), join()

 

fork(): 해당 작업을 쓰레드 폴의 작업 큐에 넣는다. 동기 메서드.
join(): 해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다. 비동기 메서드.

 

fork() 는 작업을 쓰레드의 작업 큐에 넣는 것이다. 작업 큐에 들어간 작업은 더 이상 나눌 수 없을 떄까지 나뉜다. compute() 로 나누고 fork() 로 작업 큐에 넣는 작업이 계속해서 반복된다. 그렇게 나눠진 작업은 쓰레드가 골고루 나눠서 처리하고, 작업의 결과는 join() 을 호출해서 얻을 수 있다.

반응형