[기본 개념] 8 | (1.6) 스레드 동기화(Lock, Condition, Volatile, Fork&Join 프레임웍

728x90

[기본 개념] 8 | (1.6) 스레드 동기화(Lock, Condition, Volatile, Fork&Join 프레임웍

1 프로세스와 쓰레드

2 쓰레드의 구현과 실행

3 start( )와 run( )

4 싱글쓰레드와 멀티쓰레드

5 쓰레드의 우선순위

6 쓰레드 그룹(thread group)

7 데몬 쓰레드(daemon thread)

8 쓰레드의 실행제어

9 쓰레드의 동기화

    9.1 synchronized를 이용한 동기화

    9.2 wait( )와 notify( )

    9.3> Lock과 Condition을 이용한 동기화

    9.4> volatile

    9.5> fork & join 프레임웍

9.3. Lock과 Condition을 이용한 동기화

 synchronized블럭으로 동기화를 하면 같은 메서드 내에서만 lock을 걸 수 있다는 것이 불편하다. 그럴 때 3가지 종류의 lock 클래스를 사용한다.

 

ReentrantLock                             재진입이 가능한 lock, 가장 일반적

ReentrantReadWriteLock      읽기에는 공유적이고, 쓰기에는 배타적

StampedLock                               ReentrantReadWriteLock에 낙관적인 lock기능 추가

 

 ReentrantLock은 가장 일반적이며, 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻어 작업을 수행할 수 있다.

 

 ReentrantReadWriteLock읽기를 위한 lock쓰기를 위한 lock을 제공한다. 읽기 lock이 걸려있으면, 다른 쓰레드가 중복해서 읽기 lock을 걸고 수행할 수 있다. 읽기는 내용 변경이 안되므로 동시에 읽어도 문제가 되지 않는다. 하지만 읽기 lock이나 쓰기 lock중 하나씩만 걸 수 있다.

 

 StampedLock은 lock을 걸거나 해지할 때 스탬프를 사용하며, 읽기와 쓰기 lock 외에 '낙관적 읽기 lock'이 추가된 것이다. 낙관적 읽기 lock은 쓰기 lock에 의해 바로 풀린다. 따라서 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 건다.

 

 ReentrantLock을 알면 나머지도 비슷하다. 따라서 ReentrantLock에 대해서만 알아보자.

 

ReentrantLock의 생성자

 ReentrantLock은 두 개의 생성자를 가지고 있다.

 

ReentrantLock( )

ReentrantLock(boolean fair)

 

 생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 가져가게 공정하게 처리한다. 하지만 확인하는 과정에서 성능이 떨어지므로 대부분 공정함보다 성능을 선택한다.

 

void lock( )                          lock을 잠근다.

void unlock( )                    lock을 해지한다.

boolean isLocked( )       lock이 잠겼는지 확인한다.

 

 ReentrantLock과 같은 lock클래스들은 수동으로 lock을 잠그고 해제해야 한다. 임계 영역 내에서 예외가 발생하거나 return문으로 빠져나가게 되거나 lock을 해제하는 것 잊어버리면, lock이 풀리지 않을 수 있으므로 unlock( )은 try-finally문으로 감싸서 무조건 수행되게 한다.

 

lock.lock( ) ;

try {

    // 임계 영역

} finally {

    lock.unlock( );

}

 

 이외에도 tryLock( )이라는 메서드가 있는데 lock이 걸려있으면 지정된 시간만큼만 기다리며, lock을 얻으면 true를 반환하고 얻지 못하면 false를 반환한다.

 

 lock( )은 lock을 얻을 때까지 쓰레드를 블락시키므로 응답성이 나빠질 수 있다. 응답성이 중요할 경우, tryLock( )을 이용해서 지정시간 동안 lock을 얻지 못하면 다시 작업을 시도할 것인지 포기할 것인지 사용자가 결정할 수 있게 하는 것이 좋다.

 

 또한 이 메서드는 InterruptedException을 발생시킬 수 있어 lock을 얻으려고 기다리는 중에 interrupt( )에 의해 취소되도록 할 수 있다.

 

ReentrantLock과 Condition

 쓰레드에게 구분해서 통지하도록 각각 쓰레드를 위한 Condidtion을 만들어서 각각의 waiting pool에서 따로 기다리도록 하는 것이다.

 

 Condition은 이미 생성된 lock으로부터 newCondition( )을 호출해서 생성한다.

 

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

 

// lock으로 condiditon을 생성

private Condition forCook = lock.newCondition( );

private Condition forCust = lock.newCondition( );

 

 하나는 요리사 쓰레드를 위한 것이고 다른 하나는 손님 쓰레드를 위한 것이다. 그리고 wait( ) & notify( ) 대신 await( ) & signal( )을 사용하면 된다.

 

Object Condition
void wait( ) void await( )
void awaitUninterruptibly( )
void wait(long timeout) boolean await(long time, TimeUnit unit)
long awaitNanos(long nanosTimeout)
boolean awaitUntil(Date deadline)
void notify( ) void signal( )
void notifyAll( ) void signalAll( )

 

 wait( ) 대신 forCook.await( )과 forCust.await( )를 사용하면 대기와 통지의 대상이 명확히 구분된다.

9.4. volatile

 도중에 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아 메모리에 저장된 값이 다른 경우가 발생한다.

 

 이때, 변수 앞에 volatile을 붙이면 코어가 변수의 값을 읽어올 때 메모리에서 읽어오기 때문에 캐시와 메모리간의 값의 불일치가 해결된다.

 

volatile voolean suspended = false;

volatile voolean stopped = false;

 

 변수에 volatile을 붙이는 대신에 synchronized블럭을 사용해도 같은 효과를 얻을 수 있다.

 

volatile로 long과 double을 원자화

 데이터를 4 byte로 처리하기 때문에, int나 int보다 작은 타입들은 하나의 명령어로 읽거나 쓰기가 가능하다. 하나의 명령어는 최소의 작업 단위로 작업의 중간에 다른 쓰레드가 끼어들 틈이 없다.

 

 하지만 8 byte인 long과 double타입의 변수는 변수의 값을 읽거나 쓰는 과정에 다른 쓰레드가 끼어들 수 있다. 이때, 변수를 읽고 쓰는 모든 문장을 synchronized블럭으로 감싸거나 변수를 선언할 때 volatile을 붙이면 된다.

 

 이때 volatile은 해당 변수에 대한 읽거나 쓰기가 원자화된다. 작업을 더 이상 나눌 수 없게 한다는 의미인데, synchronized블럭도 일종의 원자화라 할 수 있다.

 

 그렇다고 해서 volatile은 원자화할 뿐 동기화는 아니어서 동기화가 필요할 때 synchronized블럭을 사용해야 한다.

9.5. fork & join 프레임웍

 'fork & join 프레임웍'은 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다.

 

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

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

 

 두 클래스 모두 compute( )라는 추상 메서드를 가지고 있는데 상속을 통해 구현해야 한다. 그다음 쓰레드풀과 수행할 작업을 생성하고, invoke( )로 작업을 시작한다.

 

ForkJoinPool pool = new ForkJoinPool( );      // 쓰레드 풀을 생성

SumTask = new SumTask(from, to);                   // 수행할 작업을 생성

 

Long result = pool.invoke(task);                           // invoke( )를 호출해서 작업을 시작

 

 ForkJoinPool은 fork & join 프레임웍에서 제공하는 쓰레드 풀로, 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 재사용할 수 있게 한다. 쓰레드가 수행하는 작업이 담긴 큐를 제공하며, 각 쓰레드는 큐에 담긴 작업을 순서대로 처리한다.

 

compute( )의 구현

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

 

public Long compute( ) {

    long size = to - from + 1;      // from ≤ i ≤ to

    

    if (size <= 5)        // 더할 숫자가 5개 이하면

        return sum( );       // 숫자의 합을 반환. sum( )은 from부터 to까지의 수를 더해서 반환

 

    // 범위를 반으로 나눠서 두 개의 작업을 생성

    long half = (from + to) / 2;

    

    SumTask leftSum = new SumTask(from, half);

    SumTask rightSum = new SumTask(half + 1, to);

 

    leftSum.fork( );       // 작업(leftSum)을 큐에 넣는다.

 

    return rightSum.compute( ) + leftSum.join( );

}

 

 실제 수행할 작업은 sum( )뿐이고 나머지는 수행할 작업의 범위를 반으로 나눠서 새로운 작업을 생성해 실행시키기 위한 것이다.

 

 위의 코드는 지정된 범위를 절반으로 나눠서 나눠진 범위의 합을 계산하기 위해 SumTask를 생성하고, 작업이 더 이상 나눠질 수 없을 때까지, size의 값이 5보다 작거나 같을 때까지 반복된다.

 

 compute( )가 처음 호출되면, 지정된 범위를 반으로 나눠서 한쪽에는 fork( )를 호출해서 작업 큐에 저장한다. 하나의 쓰레드는 compute( )를 재귀호출하면서 작업을 계속 반으로 나누고, 다른 쓰레드는 fork( )에 의해 작업 큐에 추가된 작업을 수행한다.

 

다른 쓰레드의 작업 훔쳐오기

 fork( )가 호출되어 작업 큐에 추가된 작업도 compute( )에 의해 더 이상 나눌 수 없을 때까지 반복해서 나뉘고, 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와 수행(작업 훔쳐오기)을 하며 모두 쓰레드풀에 의해 자동적으로 이뤄진다.

 

fork( )와 join( )

 fork( )는 작업을 쓰레드의 작업 큐에 넣는 것이고, compute( )는 작업을 나눈다. 따라서 compute( )로 나누고 fork( )로 작업 큐에 넣는 작업을 계속 반복한다.

 

 그리고 나눠진 작업은 각 쓰레드가 나눠서 처리하고, 작업결과는 join( )을 호출하여 얻는다.

 

 fork( )는 비동기 메서드이고, join( )은 동기 메서드이다. 비동기 메서드호출만 하고 결과를 기다리지 않지만 동기 메서드호출결과를 기다린다.

 

public Long compute( ) {

. . .

    SumTask leftSum = new SumTask(from, half);

    SumTask rightSum = new SumTask(half + 1, to);

    leftSum.fork( );               // 비동기 메서드. 호출결과를 기다리지 않는다.

 

    return rightSum.compute( ) + leftSum.join( );       // 동기 메서드. 호출결과를 기다린다.

}

 

 return문에서 compute( )가 재귀호출될 때, join( )은 호출되지 않으며 재귀호출이 끝나고 join( )의 결과를 기다렸다가 더해서 결과를 반환한다.

 

 또한, fork & join 프레임웍으로 계산한 결과보다 for문으로 계산하는 것이 더 빠르다. 왜냐하면, 작업을 나누고 다시 합치는 데 소요하는 시간이 있기 때문이다.

 

 이처럼 멀티쓰레드로 처리하는 것이 항상 빠르다고 생각하면 안된다. 테스트를 해보고 이득이 있을 때만 멀티쓰레드로 처리해야 한다.

 

 

 

 

 

출처 | Java의 정석 (남궁 성)

728x90