728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

LockSupport

LockSupport 라는 것을 배우기 전에 먼저 다시 복습을 해보자. synchronized는 자바 1.0부터 제공되는 매우 편리한 기능이지만, 다음과 같은 한계가 있다.

  • 무한대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃도 없다.
    • 중간에 인터럽트도 못한다.
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.

결국, 더 유연하고 더 세밀한 제어가 가능한 방법들이 필요하게 되었다. 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent 라는 동시성 문제 해결을 위한 라이브러리 패키지가 추가된다. 

 

이 라이브러리에는 수 많은 클래스가 있지만, 가장 기본이 되는 LockSupport에 대해서 먼저 알아보자. LockSupport를 사용하면 synchronized의 가장 큰 단점인 무한 대기 문제를 해결할 수 있다.

 

LockSupport 기능

LockSupport는 스레드를 WAITING 상태로 변경한다. WAITING 상태는 누가 깨워주기 전까지는 계속 대기한다. 그리고 CPU 스케쥴링에 들어가지 않는다. LockSupport의 대표적인 기능은 다음과 같다.

  • park(): 스레드를 WAITING 상태로 변경한다.
  • parkNanos(nanos): 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경한다. 지정한 나노초가 지나면 TIMED_WAITING 상태에서 빠져나오고 RUNNABLE 상태로 변경된다.
  • unpark(thread): WAITING 상태의 대상 스레드를 RUNNABLE 상태로 변경한다.

 

LockSupportMainV1

package thread.sync.lock;

import java.util.concurrent.locks.LockSupport;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class LockSupportMainV1 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new ParkTest(), "Thread-1");
        thread1.start();

        sleep(100);
        log("Thread-1 state: " + thread1.getState());

        log("main -> unpark(Thread-1)");
        LockSupport.unpark(thread1);
        // thread1.interrupt();
    }

    static class ParkTest implements Runnable {
        @Override
        public void run() {
            log("park 시작");
            LockSupport.park();
            log("park 종료, state " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}

실행결과

2024-07-22 20:57:28.683 [ Thread-1] park 시작
2024-07-22 20:57:28.765 [     main] Thread-1 state: WAITING
2024-07-22 20:57:28.766 [     main] main -> unpark(Thread-1)
2024-07-22 20:57:28.766 [ Thread-1] park 종료, state RUNNABLE
2024-07-22 20:57:28.772 [ Thread-1] 인터럽트 상태: false
  • main 스레드가 Thread-1start()하면 Thread-1RUNNABLE 상태가 된다.
  • Thread-1LockSupport.park()를 호출한다. Thread-1RUNNABLE → WAITING 상태가 되면서 대기한다.
  • main 스레드가 Thread-1unpark()로 깨운다. Thread-1은 대기 상태에서 실행 가능 상태로 변한다.
    • WAITING → RUNNABLE 상태로 변한다.

이처럼, LockSupport는 특정 스레드를 WAITING 상태로 또 RUNNABLE 상태로 변경할 수 있다.

그런데, 대기 상태로 바꾸는 LockSupport.park()는 매개변수가 없는데, 실행 가능 상태로 바꾸는 LockSupport.unpark(thread1)은 왜 특정 스레드를 지정하는 매개변수가 있을까? 왜냐하면 실행 중인 스레드는 LockSupport.park()를 호출해서 스스로 대기 상태에 빠질 수 있지만, 대기 상태의 스레드는 자신의 코드를 실행할 수 없기 때문이다. 따라서 외부 스레드의 도움을 받아야 깨어날 수 있다.

 

인터럽트를 사용해서 WAITING → RUNNABLE로 바꾸기

WAITING 상태의 스레드에 인터럽트가 발생하면 WAITING 상태에서 RUNNABLE 상태로 변하면서 깨어난다.

 

위 코드를 딱 이렇게 변경해보자.

//LockSupport.unpark(thread1);
thread1.interrupt();

 

실행결과

2024-07-22 21:04:04.277 [ Thread-1] park 시작
2024-07-22 21:04:04.357 [     main] Thread-1 state: WAITING
2024-07-22 21:04:04.357 [     main] main -> unpark(Thread-1)
2024-07-22 21:04:04.357 [ Thread-1] park 종료, state RUNNABLE
2024-07-22 21:04:04.362 [ Thread-1] 인터럽트 상태: true

 

실행 결과를 보면 스레드가 RUNNABLE 상태로 깨어난 것을 확인할 수 있다. 그리고 해당 스레드의 인터럽트의 상태도 true인 것을 확인할 수 있다. 이처럼 WAITING 상태의 스레드는 인터럽트를 걸어서 중간에 깨울 수 있다.

 

시간 대기

이번에는 스레드를 특정 시간 동안만 대기하는 parkNanos(nanos)를 호출해보자.

  • parkNanos(nanos): 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경한다. 지정한 나노초가 지나면 TIMED_WAITING 상태에서 빠져나와서 RUNNABLE 상태로 변경된다.
  • 참고로 밀리초 동안만 대기하는 메서드는 없다. parkUntil(밀리초)라는 메서드가 있는데, 이 메서드는 특정 에포크(Epoch) 시간에 맞추어 깨어나는 메서드이다. 정확한 미래의 에포크 시점을 지정해야 한다.

LockSupportMainV2

package thread.sync.lock;

import java.util.concurrent.locks.LockSupport;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class LockSupportMainV2 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new ParkTest(), "Thread-1");
        thread1.start();

        sleep(100);
        log("Thread-1 state: " + thread1.getState());

    }

    static class ParkTest implements Runnable {
        @Override
        public void run() {
            log("park 시작");
            LockSupport.parkNanos(2_000_000_000); // 2초
            log("park 종료, state " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}
  • 여기서 스레드를 깨우기 위한 unpark()를 사용하지 않는다.
  • parkNanos(나노초)를 사용하면 지정한 시간 이후에 스레드가 알아서 일어난다.
  • 1초 = 1000밀리초(ms)
  • 1밀리초 = 1,000,000 나노초(ns)
  • 2초 = 2,000,000,000 나노초(ns)

BLOCKED vs WAITING

WAITING 상태에 특정 시간까지만 대기하는 기능이 포함된 것이 TIMED_WAITING이다. 여기서는 둘을 묶어서 WAITING 상태라 표현하겠다. 

 

인터럽트

  • BLOCKED 상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못한다. 여전히 BLOCKED 상태이다.
  • WAITING, TIMED_WAITING 상태는 인터럽트가 걸리면 대기상태를 빠져나온다. 그래서 RUNNABLE 상태로 변한다.

용도

  • BLOCKED 상태는 자바의 synchronized에서 락을 획득하기 위해 대기할 때 사용된다.
  • WAITING, TIMED_WAITING 상태는 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태이다.
  • WAITING 상태는 다양한 상황에서 사용된다. 예를 들어, Thread.join(), LockSupport.park(), Object.wait()과 같은 메서드 호출 시 WAITING 상태가 된다.
  • TIMED_WAITING 상태는 Thread.sleep(ms), Object.wait(long timeout), Thread.join(long millis), LockSupport.parkNanos(nanos) 등과 같은 시간 제한이 있는 대기 메서드를 호출할 때 발생한다.

BLOCKED, WAITING, TIMED_WAITING 상태 모두 스레드가 대기하며, 실행 스케쥴링에 들어가지 않기 때문에 CPU 입장에서 보면 실행하지 않는 비슷한 상태이다. 

  • BLOCKED 상태는 synchronized에서만 사용하는 특별한 대기 상태라고 이해하면 된다.
  • WAITING, TIMED_WAITING 상태는 범용적으로 활용할 수 있는 대기 상태라고 이해하면 된다.

 

LockSupport 정리

LockSupport를 사용하면 스레드를 WAITING, TIMED_WAITING 상태로 변경할 수 있고, 또 인터럽트를 받아서 스레드를 깨울 수도 있다. 이런 기능들을 잘 활용하면 synchronized의 단점인 무한 대기 문제를 해결할 수 있을 것 같다.

 

synchronized의 단점

  • 무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한으로 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X  parkNanos(nanos)를 사용하면 특정 시간까지만 대기할 수 있게 된다.
    • 중간에 인터럽트 X  park(), parkNanos(nanos)는 인터럽트를 걸 수 있다.

이처럼 LockSupport를 활용하면, 무한 대기하지 않는 락 기능을 만들 수 있다. 물론 그냥 되는게 아니라 LockSupport를 활용해서 안전한 임계 영역을 만드는 어떤 기능을 개발해야 한다. 그렇지만! 이런 기능을 직접 만드는 건 너무 어렵다. 예를 들어 스레드 10개를 동시에 실행했는데, 그 중에 딱 한 개의 스레드만 락을 가질 수 있도록 락 기능을 만들어야 한다. 그리고 나머지 9개의 스레드가 대기해야 하는데 어떤 스레드가 대기하고 있는지 알 수 있는 자료구조도 필요하다. 그래야 이후에 대기 중인 스레드를 찾아서 깨울 수 있다. 여기서 끝이 아니고 대기 중인 스레드 중에 어떤 스레드를 깨울지에 대한 우선순위 결정도 필요하다.

 

즉, LockSupport는 사용할만 한 게 못된다. synchronized를 사용하는 게 날 것 같다. 하지만! 자바는 Lock 인터페이스와 ReentrantLock 이라는 구현체로 이런 기능들을 이미 다 구현해두었다. ReentrantLockLockSupport를 활용해서 synchronized의 단점을 극복하면서도 매우 편리하게 임계 영역을 다룰 수 있는 다양한 기능을 제공한다. 즉, 이 녀석을 사용하면 된다.

 

 

ReentrantLock

자바는 1.0부터 존재한 synchronizedBLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위해 자바 1.5부터 Lock 인터페이스와 ReentrantLock 구현체를 제공한다. 

 

synchronized 단점

  • 무한대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X
    • 중간에 인터럽트 X
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.

Lock 인터페이스

public interface Lock {
     void lock();
     void lockInterruptibly() throws InterruptedException;
     boolean tryLock();
     boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
     void unlock();
     Condition newCondition();
}

Lock 인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위한 락을 구현하는데 사용된다.

Lock 인터페이스는 다음과 같은 메서드들을 제공하는데 대표적인 구현체는 ReentrantLock이 있다.

 

  • void lock()
    • 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때 까지 현재 스레드는 대기(WAITING)한다. 이 메서드는 인터럽트에 응답하지 않는다.
주의! 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock이 제공하는 기능이다. 모니터 락과 BLOCKED 상태는 synchronized에서만 사용된다.
  • void lockInterruptibly()
    • 락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있도록 한다. 만약, 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때 까지 대기한다. 대기 중에 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
  • boolean tryLock()
    • 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면, false를 반환하고 그렇지 않으면 락을 획득하고 true를 반환한다.
  • boolean tryLock(long time, TimeUnit unit)
    • 주어진 시간동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
  • void unlock()
    • 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
    • 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면 IllegalMonitorStateException이 발생할 수 있다.
  • Condition newCondition()
    • Condition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait, notify, notifyAll 메서드와 유사한 역할을 한다. 참고로 이 부분은 뒤에서 자세히 다루겠다.

 

이 메서드들을 사용하면 고수준의 동기화 기법을 구현할 수 있다. Lock 인터페이스는 synchronized 블록보다 더 많은 유연성을 제공하며, 특히 락을 특정 시간 만큼만 시도하거나 인터럽트 가능한 락을 사용할 때 유용하다. 이 메서드들을 보면 알겠지만 다양한 메서드들을 통해 synchronized의 단점인 무한 대기 문제도 깔끔하게 해결할 수 있다.

 

참고로, lock() 메서드는 인터럽트에 응하지 않는다고 되어있다. 이 메서드의 의도는 인터럽트가 발생해도 무시하고 락을 기다리는 것이다. 앞서 대기(WAITING) 상태의 스레드에 인터럽트가 발생하면 대기 상태를 빠져나온다고 했다. 그런데 lock() 메서드의 설명을 보면 WAITING 상태인데 인터럽트에 응하지 않는다고 되어 있다. 어떻게 된 것일까? lock()을 호출해서 락을 얻기 위해 대기중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다. 그래서 아주 짧지만 WAITINGRUNNABLE이 된다. 그런데 lock() 메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제로 변경해버린다. 이런 원리로 인터럽트를 무시하는 것이다. 참고로 인터럽트가 필요하면 lockInterruptibly()를 사용하면 된다. 새로운 Lock은 개발자에게 다양한 선택권을 제공한다.

 

공정성

Lock 인터페이스가 제공하는 다양한 기능 덕분에 synchronized의 단점인 무한 대기 문제가 해결되었다. 그런데 공정성에 대한 문제가 남아 있다.

 

synchronized의 단점

  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.

Lock 인터페이스의 대표적인 구현체로 ReentrantLock이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다.

private final Lock unFairLock = new ReentrantLock(); // 비공정 모드 락
private final Lock fairLock = new ReentrantLock(true); // 공정 모드 락

ReentrantLock 락은 공정성 모드와 비공정 모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에서 차이가 있다.

 

비공정 모드

비공정 모드는 ReentrantLock의 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락을 빨리 획득할 수 있지만, 특정 스레드가 장기간 락을 획득하지 못할수도 있다. 

 

비공정 모드 특징

  • 성능 우선: 락을 획득하는 속도가 빠르다.
  • 선점 가능: 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
  • 기아 현상 가능성: 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.

공정 모드

생성자에서 true를 전달하면 된다. 공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간 공정성을 보장한다. 그러나 이로 인해 성능이 저하될 수 있다. 

 

공정 모드 특징

  • 공정성 보장: 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득한다.
  • 기아 현상 방지: 모든 스레드가 언젠가 락을 획득할 수 있게 보장한다.
  • 성능 저하: 락을 획득하는 속도가 느려질 수 있다.

정리를 하자면, Lock 인터페이스와 ReentrantLock 구현체를 이용하면 synchronized 단점인 무한 대기와 공정성 문제를 모두 해결할 수 있다!

 

ReentrantLock 활용

이제 이 ReentrantLock을 직접 이용해보자! 앞에 사용했던 예제 BankAccountV3를 가지고 해보자.

 

BankAccountV4

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV4 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

    public BankAccountV4(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        lock.lock();

        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

            log("거래 종료:");
            return true;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public int getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}
  • Lock lock = new ReentrantLock()을 사용하도록 선언했다.
  • 임계영역을 안전하게 처리하는 부분에서 synchronized(this) 대신에 lock.lock()을 사용해서 락을 건다. 이 지점에서 다른 스레드도 락을 얻기 위해 시도했을 때 이미 락을 누가 가져갔다면, 대기(WAITING)상태로 빠진다.
  • lock() ↔ unlock() 사이는 안전한 임계영역이 된다.
  • 임계 영역이 끝나면 반드시! 락을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
    • 따라서 어떤 예외가 터지더라도 어떤 예측 불가능한 상황이 발생하더라도 락을 반납할 수 있도록 try - finally 구문에서 finally 블록안에 락을 반납하는 lock.unlock()을 호출한다.
주의! 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock이 제공하는 기능이다! 모니터 락과 BLOCKED 상태는 synchronized에서만 사용된다.

 

이제 이 BankAccountV4를 사용해보자!

BankMain

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV4 account = new BankAccountV4(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}

실행결과

2024-07-23 17:46:13.130 [       T2] 거래 시작: BankAccountV4
2024-07-23 17:46:13.130 [       T1] 거래 시작: BankAccountV4
2024-07-23 17:46:13.141 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-23 17:46:13.141 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-23 17:46:13.604 [     main] t1 state: WAITING
2024-07-23 17:46:13.604 [     main] t2 state: TIMED_WAITING
2024-07-23 17:46:14.143 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-23 17:46:14.144 [       T2] 거래 종료:
2024-07-23 17:46:14.145 [       T1] [검증 시작] 출금액: 800, 잔액: 200
2024-07-23 17:46:14.146 [       T1] [검증 실패] 출금액: 800, 잔액: 200
2024-07-23 17:46:14.153 [     main] 최종 잔액: 200

정확한 계산 결과가 반영됐다. 중간에 t1 스레드는 WAITING이고 t2 스레드는 TIMED_WAITING이다. 이 t1WAITING인 이유는 t2 스레드가 먼저 임계 영역에 진입했기 때문에 락을 얻지 못해 t1은 기다리는 상태이고, t2 스레드는 로직중에 sleep()에 걸려있을 때 찍힌 TIMED_WAITING이다.

 

실행 결과를 자세히 분석해보자!

  • t1, t2가 출금을 시작한다. 여기서는 t1이 약간 먼저 실행된다고 가정하겠다.
  • ReentrantLock 내부에는 락과 락을 얻지 못해 대기하는 스레드를 관리하는 대기 큐가 존재한다.
  • 여기서 이야기하는 락은 객체 내부에 있는 모니터 락이 아니다! ReentrantLock이 제공하는 기능이다.

  • t1: ReentrantLock에 있는 락을 획득한다.
  • 락을 획득하는 경우 RUNNABLE 상태가 유지되고, 임계 영역의 코드를 실행할 수 있다.

  • t1: 임계 영역의 코드를 실행한다.

  • t2: ReentrantLock에 있는 락 획득을 시도한다. 하지만 락이 없다.

  • t2: 락을 획득하지 못하면 WAITING 상태가 되고, 대기 큐에서 관리된다.
    • LockSupport.park()가 내부에서 호출된다.
  • 참고로 tryLock(long time, TimeUnit unit)과 같은 시간 대기 기능을 사용하면 TIMED_WAITING 상태가 되고 대기 큐에서 관리된다.

  • t1: 임계 영역의 수행을 완료했다. 이 때 잔액은 balance=200이 된다.

  • t1: 임계 영역을 수행하고 나면 lock.unlock()을 호출한다.
    • t1: 락을 반납한다.
    • t1: 대기 큐의 스레드를 하나 깨운다. LockSupport.unpark(thread)가 내부에서 호출된다.
    • t2: RUNNABLE 상태가 되면서 깨어난 스레드는 락 획득을 시도한다.
      • 이때 락을 획득하면 lock.lock()을 빠져나오면서 대기 큐에서도 제거된다.
      • 이때 락을 획득하지 못하면 다시 대기 상태가 되면서 대기 큐에 유지된다.
      • 참고로 락 획득을 시도하는 잠깐 사이에 새로운 스레드가 먼저 락을 가져갈 수 있다.
      • 공정 모드의 경우 대기 큐에 먼저 대기한 스레드가 먼저 락을 가져간다.

  • t2: 락을 획득한 t2 스레드는 RUNNABLE 상태로 임계 영역을 수행한다.

  • t2: 잔액(200)이 출금액(800)보다 적으므로 검증에 실패한다. 따라서 return false가 호출된다.
  • 이때 finally 구문이 있으므로 finally 구문으로 이동한다.

  • t2: lock.unlock()을 호출해서 락을 반납하고, 대기 큐의 스레드를 하나 깨우려고 시도한다. 대기 큐에 스레드가 없으므로 이때는 깨우지 않는다.

  • 종료된다.
참고로, volatile을 사용하지 않아도 Lock을 사용할 때 접근하는 변수의 메모리 가시성 문제는 해결된다. 모든 동기화를 돕는 기술을 사용하면 알아서 메모리 가시성 문제는 해결된다. (synchronized, LockSupport, ...)

 

ReentrantLock 활용 2 - 대기 중단

바로 위에서 해봤던건 락을 얻을때까지 대기하는 lock()을 사용했다. 시간도 정해지지 않았고 인터럽트도 먹지 않는 방법이다.

이번에는 tryLock(), tryLock(long time, TimeUnit unit) 메서드들을 사용해서 한번만 시도해보고 얻을 수 있으면 얻고 그렇지 않으면 얻는걸 포기하는 방법과 주어진 시간까지만 시도해보는 방법을 알아보자.

 

boolean tryLock()
  • 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 false를 반환하고, 그렇지 않으면 락을 획득하고 true를 반환한다.
boolean tryLock(long time, TimeUnit unit)
  • 주어진 시간동안 락 획득을 시도한다. 주어진 시간안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
아무런 파라미터도 받지 않는 tryLock()은 인터럽트 예외가 발생할 수가 없다. 즉시 결과를 반환하기 때문에 기다리는 시간이 존재하지 않으니까!

 

BankAccountV5

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV5 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

    public BankAccountV5(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        if (!lock.tryLock()) {
            log("[진입 실패] 이미 처리중인 작업이 있습니다.");
            return false;
        }

        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock();
        }

        log("거래 종료:");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}
  • lock.tryLock()을 사용한다. 락을 획득할 수 없으면 바로 포기하고 대기하지 않는다. 락을 획득하지 못하면 false를 반환한다.

실행결과

2024-07-24 11:21:56.984 [       T2] 거래 시작: BankAccountV5
2024-07-24 11:21:56.984 [       T1] 거래 시작: BankAccountV5
2024-07-24 11:21:56.987 [       T1] [진입 실패] 이미 처리중인 작업이 있습니다.
2024-07-24 11:21:56.992 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-24 11:21:56.993 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-24 11:21:57.465 [     main] t1 state: TERMINATED
2024-07-24 11:21:57.466 [     main] t2 state: TIMED_WAITING
2024-07-24 11:21:57.996 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-24 11:21:57.997 [       T2] 거래 종료:
2024-07-24 11:21:58.001 [     main] 최종 잔액: 200
  • t2: 먼저 락을 획득하고 임계 영역을 수행한다.
  • t1: 락이 없다는 것을 확인하고 lock.tryLock()에서 즉시 빠져나온다. 이때 false가 반환된다.
  • t1: "[진입 실패] 이미 처리중인 작업이 있습니다."를 출력하고 false를 반환하면서 메서드를 종료한다.
  • t2: 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.

 

이번에는 시간을 사용해서 특정 시간만 기다리는 예제를 살펴보자.

BankAccountV6

package thread.sync;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV6 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

    public BankAccountV6(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        try {
            if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
                log("[진입 실패] 이미 처리중인 작업이 있습니다.");
                return false;
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock();
        }

        log("거래 종료:");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}
  • lock.tryLock(500, TimeUnit.MILLISECONDS): 락이 없을 때 락을 대기할 시간을 지정한다. 해당 시간이 지나도 락을 얻지 못하면 false를 반환하면서 해당 메서드를 빠져나온다. 여기서는 0.5초로 설정했다.
  • 스레드의 상태는 대기하는 동안 TIMED_WAITING 상태가 되고, 대기 상태를 빠져나오면 RUNNABLE이 된다.

실행결과

2024-07-24 11:27:10.788 [       T1] 거래 시작: BankAccountV6
2024-07-24 11:27:10.788 [       T2] 거래 시작: BankAccountV6
2024-07-24 11:27:10.800 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-24 11:27:10.800 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-24 11:27:11.267 [     main] t1 state: TIMED_WAITING
2024-07-24 11:27:11.267 [     main] t2 state: TIMED_WAITING
2024-07-24 11:27:11.293 [       T2] [진입 실패] 이미 처리중인 작업이 있습니다.
2024-07-24 11:27:11.801 [       T1] [출금 완료] 출금액: 800, 잔액: 200
2024-07-24 11:27:11.802 [       T1] 거래 종료:
2024-07-24 11:27:11.805 [     main] 최종 잔액: 200
  • t1: 먼저 락을 획득하고 임예 영역을 수행한다.
  • t2: lock.tryLock(500, TimeUnit.MILLISECONDS)를 호출하고 락 획득을 시도한다. 락이 없으므로 0.5초 대기한다. 
    • 이때 t2TIMED_WAITING이 된다. 
    • 내부에서는 LockSupport.parkNanos(nanos)가 호출된다.
  • t2: 대기 시간인 0.5초간 락을 획득하지 못했다. lock.tryLock(500, TimeUnit.MILLISECONDS)에서 즉시 빠져나온다. 이때 false가 반환된다. 
    • 스레드는 TIMED_WAITING → RUNNABLE이 된다.
  • t2: "[진입 실패] 이미 처리중인 작업이 있습니다."를 출력하고 false를 반환하면서 메서드를 종료한다.
  • t1: 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.

 

정리

자바 1.5에서 등장한 Lock 인터페이스와 ReentrantLock 구현체 덕분에 synchronized의 단점인 무한 대기공정성 문제를 극복하고, 또 더욱 유연하고 세밀한 스레드 제어가 가능하게 되었다. 다음 포스팅엔 정말 중요한 생산자 소비자 문제에 대해 알아보자! 어떻게 보면 이 부분이 멀티스레드의 핵심이라고 볼 수 있다!

728x90
반응형
LIST

+ Recent posts