자바의정석 - 쓰레드의 동기화

쓰레드의 동기화

전편에 이어 쓰레드의 동기화 관련해서 정리하고자 한다.

싱글쓰레드 환경에서는 프로세스 내에 하나의 쓰레드로 작업이 수행되기에 데이터의 사용에 문제가 없지만 멀티쓰레드의 환경에서는 동일한 자원(객체)를 공유하여 작업을 하게되면 서로의 작업에 영향을 주게 된다. 1번 과 ,2번 2개의 쓰레드가 있고 동일한 자원(객체)를 활용하여 작업이 진행된다고 가정해보자. 이때 1번 쓰레드의 작업 중 2번 쓰레드에 제어권이 넘어가게 될때 동일 자원(객체)에 변경에 이루어 진다면 1번 쓰레드의 작업결과는 원하 값이 도출되지 못 할 수 있다. 이러한 발생을 방지하기 위해 자원을 방해받지 않는 개념이 임계 영역(critical section)잠금(Lock) 개념이다.

공유할 자원에 대한 소스코드 영역을 임계 영역으로 지정해놓고 공유 자원(객체)에 하나의 쓰레드가 Lock 획득하여 사용하며 Lock을 획득한 쓰레드 이외에 다른 쓰레드는 자원을 사용하지 못하게 된다. Lock을 획득한 쓰레드가 자원 사용을 완료하여 Lock을 반납하게되면 다른 쓰레드가 접근하여 사용할 수 있게 된다. 이러한 방법으로 하나의 쓰레드가 진행중일때 다른 쓰레드가 간섭하지 못하게 막는 것을 쓰레드의 동기화(Syncronization)이라고 한다.

synchronized를 이용한 동기화

동기화 방법에는 몇가지가 있지만 가장 간단한 synchronized키워드를 이용한 동기화에 대해 살펴보면 사용법은 2가지가 있다. 메서드 앞에 키워드를 붙이게 되면 메서드 자체가 임계 영역으로 설정이되며, 메서드 호출 시점부터 영역내의 모든 객체에 lock을 얻어 작업을 수행하게 되며, 메서드 종료시 lock을 반환한다. 두번째로는 코드의 일부 영역을 임계영역으로 설정하고 객체의 참조변수를 붙이는 방법인데 lock을 걸고자 하는 객체를 정의해주면 된다.

// 메서드 전체를 임계 영역으로 지정
public synchronized void A() {

}

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

}

wait()과 notify()

synchronized를 통해 동기화하여 공유 자원을 보호하는 것은 좋지만 lock을 잡고 오랫동안 반환하지 않게 되면 다른 쓰레드는 무한정 대기하게 되는 상황이 발생하게 된다. 이러한 상황을 개선하기위해 개발된 것이 wait()notify()이다. 임계 영역내 작업 진행 시 지연이 걸리는 경우이거나 더이상 작업 진행이 어려울 경우 다른 쓰레드가 lock을 얻을 수 있게 wait()을 호출하여 쓰레드를 대기상태로 전환한 뒤 lock을 반환한다. wait()으로 lock을 반환한 쓰레드가 다시 작업을 진행할 수 있는 상황이 되면 notify()를 호출하여 lock을 획득할 수 있게 대기상태를 해제시킨다. 하지만 notify()를 호출하게 되면 공유 자원(객체)를 사용하길 기다리는 다른 쓰레드 들이 순서대로 lock을 획득하진 못한다. 동일한 waiting pool에 대기하던 임의의 쓰레드에게 사용할 수 있는 상황이다 라는 통보를 한 후 lock을 획득하여 자원을 사용하게 된다. notifyAll()은 waiting pool에 대기 중인 모든 쓰레드에게 통보할 수 있다. 하지만 뭐가됐든 임계 영역이 정의된 블럭엔 하나의 쓰레드만 올 수 있기에 임의의 하나의 쓰레드만 lock을 획득하고 나머지의 쓰레드는 다시 waiting pool에 대기하게 된다.

wait(), notify(), notifyAll()

  • Objecy에 정의되어 있다.
  • 동기화 블록 내에서만 사용할 수 있다.
  • 보다 효율적인 동기화를 가능하게 한다.

기아 현상과 경쟁 상태

waitingPool A, B의 쓰레드가 동기화 블럭으로 감싸져있는 공유 자원을 사용한다고 가정해보자.

  1. A쓰레드가 lock을 획득하여 사용, waiting pool에는 B쓰레드가 대기 (첫번째 그림)
  2. wait()호출 하여 A쓰레드 대기 상태로 전환 -> waiting pool에 A,B 쓰레드 (가운대 그림)
  3. notify() 호출
  4. A쓰레드가 lock을 획득하여 사용, waiting pool에는 B쓰레드가 대기 (세번째 그림)

notify()를 통해 waiting pool에 대기 중인 쓰레드들에게 자원 사용할 기회를 주지만 그림 flow처럼 B쓰레드는 lock을 획득하지 못하는 경우가 발생한다. 이러한 현상을 기아 현상이라고 한다. 이러할 경우 notifyAll()을 통해 waiting pool에 대기중인 모든 쓰레드에게 통보를 할 수 있다. 하지만 대기중인 모든 쓰레드간 lock을 획득하기 위해 경쟁 상태에 놓기에 된다. 이처럼 기아 현상, 경쟁 상태를 개선하기 위해 아래에 정리할 LockConfition을 이용하여 쓰레드에 선별을 주어 lock획득을 컨트롤 할 수 있다.

Lock과 Condition을 이용한 동기화

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

ReentrantLock은 위에 설명한 synchronized와 거의 유사하다 단지 선언하는 부분이 다를 뿐이다. ReentrantReadWriteLock은 읽기를 위한 lock과 쓰기를 위한 lock을 제공한다. 임계 영역에 읽기 lock이 걸려 있으면 다른 쓰레드에서 중복으로 읽을 수 있지만 쓰기lock이 걸려 있으면 허용되지 않는다. StampedLock은 보통 동기화가 걸린 lock은 읽기 lock이 걸려 있으면, 쓰기 lock을 얻기 위해 읽기 lock이 풀릴 떄까지 기다려야하는데 반해 쓰기 lock에 의해 읽기 lock이 바로 풀려버린다. 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.

일반적으로 사용하는 ReentrantLock에 대해서만 정리해보자.

// synchronized
synchronized(lock) {
    // 임계영역
}

// ReentrantLock
lock.lock();
try {
    // 임계영역
} finally {
    lock.unlock();
}

일반 적으로 .unlock();의 경우에는 혹시 예외가 발생하여 lock을 반환하지 못할 경우가 있기에 임계영역을 try로 감싸고 finally에 unlock()을 선언해 주어야 한다.

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

private Condition aThread = lock.newCondition();    // a 쓰레드용 Condition 생성
private Condition bThread = lock.newCondition();    // b 쓰레드용 Condition 생성

// 사용시
// synchronized
wait();     // lock 반환 -> 쓰레드 대기로 전환
notify();   // 쓰레드가 lock 획득 가능

// ReentrantLock
aThread.await();    // a 쓰레드 대기로 전환
aThread.signal();   // a 쓰레드 lock 획득 시키기

정리한 내용을 바탕으로 소스를 통해 정리해보자!

재고를 체크하는 소스를 만들어 볼까한다. 여기에 상품과 고객이라는 쓰레드를 생성하여 재고 클래스에 상품 쓰레드를 통해 재고가 입고되고 고객 쓰레드를 통해 재고를 소진하는 예제 소스를 만들어 보려 한다.

동기화를 하지 않은 쓰레드

[출력결과]

[상품]  2 구매하였습니다. 남은수량: 4
[상품]  1 입고 되었습니다. 남은수량: 4
[상품]  2 구매하였습니다. 남은수량: 3
[상품]  1 입고 되었습니다. 남은수량: 3
[상품]  1 입고 되었습니다. 남은수량: 4
[상품]  2 구매하였습니다. 남은수량: 4
[상품]  1 입고 되었습니다. 남은수량: 3
[상품]  2 구매하였습니다. 남은수량: 3
[상품]  2 구매하였습니다. 남은수량: 2
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  1 입고 되었습니다. 남은수량: 0
[상품]  2 구매하였습니다. 남은수량: 0
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 2   // 동기화 문제!
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 0
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 2   // 동기화 문제!
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  1 입고 되었습니다. 남은수량: 1
[상품]  2 구매하였습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 2   // 동기화 문제!
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
[상품]  1 입고 되었습니다. 남은수량: 2

출력결과를 보면 동기화 문제!로 주석처리한 부분은 getQuantity()메서드에서 재고 체크 시 1개가 있어 판매하지 못하는 조건으로 분기처리가 되었고 남은 수량을 알려주는 단계에서는 이미 재고가 들어와 2개로 표시가 되는 것이다.

  1. 고객이 2개 주문 -> warehouse.buy(this.productName, 2);
  2. 판매 가능 재고 체크 -> if(getQuantity(product) >= num) -> 남은수량 1개 판매 불가
  3. 상품 재고입고 -> warehouse.add(this.name, 1); -> 남은수량 2개로 증가
  4. 고객에게 판매불가 통보 시 남은수량 기재 -> 남은수량 2개로 알림

이제 동기화로 재고상태를 보호해보자

[출력결과]

[상품]  1 입고 되었습니다. 남은수량: 6
[상품]  2 구매하였습니다. 남은수량: 4
[상품]  1 입고 되었습니다. 남은수량: 5
[상품]  2 구매하였습니다. 남은수량: 3
[상품]  2 구매하였습니다. 남은수량: 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1

Warehouse클래스의 buy메서드의 임계영역을 블럭으로 설정하였고 add메서드 자체에 임계영역을 설정하였다. 동기화 체크를 추가한 이후 더이상 재고가 2개일때 판매를 안하는 현상은 사라지게 된다. 하지만 여기서 문제가 있다. 재고가 구매하려는 제품보다 2개가 적을 시 재고가 들어올때까지 lock을 반납하지 않을 경우이다. (아래 소스는 일부로 lock반납을 하지 않게 하기 위해 추가 함.)

while(getQuantity(product) < num) {  // 쓰레드 lock 점유를 높이기 위해 추가
    System.out.println("재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : " + products.get(product));
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {

    }
}

이렇게 lock을 잡고 반납하지 못하게 되는 현상을 해소하기 위해서는 wait(), notify()를 사용하면 벗어날 수 가 있다. 아래 소스를 봐보자.

lock을 반납하지 않는 현상을 해결하자

[출력결과]

[상품]  1 입고 되었습니다. 남은수량: 6
[상품]  2 구매하였습니다. 남은수량: 4
[상품]  2 구매하였습니다. 남은수량: 2
[상품]  1 입고 되었습니다. 남은수량: 3
[상품]  2 구매하였습니다. 남은수량: 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  1 입고 되었습니다. 남은수량: 3
[상품]  2 구매하였습니다. 남은수량: 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  2 구매하였습니다. 남은수량: 0
[상품]  1 입고 되었습니다. 남은수량: 1
재고가 부족합니다. 물량 입고되면 다시 시도해 주세요. 현재수량 : 1
[상품]  1 입고 되었습니다. 남은수량: 2
[상품]  1 입고 되었습니다. 남은수량: 3
[상품]  2 구매하였습니다. 남은수량: 1

세번째 소스의 100 Linewait()을 추가하였다. 이렇게 되면 쓰레드의 수행로직 상 Client 쓰레드가 wait()으로 대기상태로 빠지게되며 lock을 반납하게 된다. 그렇게 되면 Product 쓰레드가 재고를 입고시킬 수 가 있게되며. 재고가 입고 된 순간 112 Linenotify()를 통해 Client 쓰레드가 대기상태에서 빠져 나올 수 있게되어 다시 구매가 이루어지게 된다.

이렇게 동기화가 이루어지며 lock을 얻고, 반납 -> 재진입까지 3가지의 소스로 확인해 볼 수 있었다.

위의 모든 정리내용은 자바의 정석을 공부하며 복습차 정리한 내용입니다.

Leave a comment