(Ch13) 9. 쓰레드의 동기화 ~

9.5 fork & join 프레임워크

쓰레드의 동기화

멀티쓰레드 프레스스의 경우 여러 쓰레드가 같은 프로세스내의 자원을 공유하여 작업하기 때문에 서로의 작업에 영향을 주게 된다. 서로의 영향으로 인해 의도했던 것과 다른 결과를 얻을 수 있는데, 이러한 일이 발생하는 것을 방지하기 위해서 한 쓰레드가 특정 작업을 마치기 전가지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 임계 영역과 잠금의 개념이다. 공유 데이터를 사용하는 영역을 임계 영역으로 지정하면 lock을 획득한 단 하나의 쓰레드만 해당 영역을 사용할 수 있다. 해당 작업이 끝난 후 lock을 받납해야만 다른 쓰레드가 lock을 획득하여 임계 영역을 사용할 수 있다. 이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화라고 한다.

synchronized 이용

가장 간단한 동기화 방법은 synchronized를 사용하여 메소드나 특정 영역을 임계 영역으로 지정하는 것이다.

public synchronized void method_1 () {
    ...
}

synchronized ( 객체의 참조변수) { // lock을 걸고자 하는 객체의 참조변수
    ...
}

wait()와 notify()

synchronized의 문제점은 특정 쓰레드가 lock을 가진 상태로 무한한 시간을 보낼 수 있다는 점이다. 이러한 상황을 개선하기 위해 wait()와 notify()를 사용할 수 있다. wait()호출시 쓰레드가 lock을 만납하고 대기상태로 만들고, 다른 쓰레드가 lock을 얻게 한다. 대기 상태에 있는 쓰레드에는 추후에 lock을 사용할 수 있을 때 notify()를 호출하여 lock을 갖도록 한다.

기아 현상과 경쟁 상태란?

notify()를 호출하여도 특정 쓰레드가 계쏙 통지를 받지 못하고 오랫동안 기다리게 되는데 이를 기아 현상이라고 한다. 이 때는 notifyAll()을 사용해 모든 쓰레드에게 통지를 하면 언젠가는 lock을 얻어 작업을 진행할 수 있다. 하지만 이 경우 필요한 쓰레드 이외에도 통지를 받아 쓰레드끼리 경쟁 상태에 놓이게 된다.

Lock과 Condition

synchronized블록을 활용하지 않고 lock클래스를 이용하는 방법이 있다. synchronized를 lock의 잠금과 해제가 자동으로 이루어지지만 예외가 발생하여 lock이 풀리거나 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편할 때가 있다. 이럴때는 lock클래스를 사용하는데, lock클래스에는 3가지 종류가 존재한다.

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

notify()를 사용해서는 특정 쓰레드를 구분하여 통지하지 못한다는 한계가 존재했다. 이를 Condition을 통해 해결할 수 있는데, 공유 객체의 waiting pool에 같이 몰아 넣는 대신, 각각의 쓰레드를 위한 Condition을 만들어 각각의 waiting pool에서 따로 기다리도록 하면 문제를 해결할 수 있다.

private ReentrantLock lock = new ReentrantLock();

private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();

각각의 인스턴스에 await(), signal()을 호출하여 특정 쓰레드를 대기상태로 만들거나 lock을 부여할 수 있다.

volatile

멀티 코어 프로세서의 경우 코어별로 캐시를 가지고 있다. 코어는 메모리에서 가져온 값을 캐시에 저장하고 캐시에서 값을 읽어 작업하고, 다시 값을 값을 읽어올 때는 캐시에 있는지 확인하고 없는 경우에만 메모리에서 읽어온다. 그래서 메모리에서는 값이 변경된 상황에서 캐시는 이를 반영하지 못하는 경우가 발생한다. 이때 volatile을 변수 앞에 붙이면 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 값이 불일치하는 상황을 막을 수 있다.

volatile boolean variable_name = false;

JVM은 데이터를 4byte단위로 처리하기 때문에 4byte보다 크기가 작은 타입은 한 번에 읽거나 쓰기가 가능하다. 그러니 long, double 타입과 같은 변수는 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에 다른 쓰레드의 영향을 받을 가능성이 있다. 이 때 변수에 volatile을 붙여 원자화시키면 작업을 더 이상 나눌 수 없게 된다.

volatile long variable1;
volatile double variable2;

fork & join 프레임워크

fork & join 프레임워크를 사용하면 하나의 작업은 작은 단위로 쪼개어 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다. 수행할 작업에 따라 RecursiveAction, RecursiveTask 중 하나를 상속받아 구현한다.

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

각 클래스에는 compute()메소드가 있는데 해당 메소드 구현시 작업을 어떻게 나눌 것인가에 대해서 알려줘야 한다. fork()는 작업을 쓰레드의 작업 큐에 넣는 것이고, 작업 큐에 들어간 작업은 더 이상 나눌 수 없을 때까지 나뉜다. 즉 compute()로 나누고 fork()로 작업 큐에 넣는 작업을 계속해서 반복한다. 그렇게 나눠진 작업을 쓰레드가 골고루 나눠 처리하고 작업의 결과는 join()을 호출하여 얻을 수 있다.

Reference

Java의 정석
남궁성의 정석코딩

profile
개발자 지망생입니다.

0개의 댓글