인터럽트는 Worker 스레드에 작업을 중단하라는 신호를 보내는것이다. 스레드가 강제로 종료되는 것이 아니라, 스레드가 스스로 이 신호를 감지하고 정상적으로 종료할 수 있도록 협조하는 방식이다.
특정 스레드가 Thread.sleep()을 통해 쉬고 있는데,처리해야 하는 작업이 있어 급하게 깨우거나,종료를 지시해야할 경우가 있다.
인터럽트를 요청이 오고,Thread.sleep()와 같은WAITING,TIMED_WAITING 같은 대기 상태의 메서드를 실행 중이거나 만나게 되면 InterruptException예외가 발생하고 이 예외를 처리하면 스레드는 다시 실행 가능한 상태(RUNNABLE)로 전환된다.
thread.sleep(3000)
thread.interrupt();
static class MyTask implements Runnable{
try {
while (true) {
log("작업 중");
Thread.sleep(3000);
}
}
catch (InterruptedException e) {
log("work 스레드 인터럽트 상태2 = " +
Thread.currentThread().isInterrupted());
log("interrupt message=" + e.getMessage());
log("state=" + Thread.currentThread().getState());
}
}
위 코드에서 MyTask를 위임 받은 thread에 interrupt()를 호출하면 인터럽트 상태가 true가 되고 sleep() 및 join() 수행중이라면 InterruptException이 발생하면서 인터럽트 플래그가 false,RUNNABLE상태로 바뀌고 정상 수행이된다.
인터럽트 상태 단순 체크: Thread.currentThread().isInterrupted()
인터럽트 상태 체크 후 초기화: Thread.interrupted()
Thread.currentThread().isInterrupted()로 단순히 인터럽트를 확인만 한다면 추후 Thread.sleep()을 만나게 되면 다시 인터럽트가 걸리게 된다.
인터럽트의 목적을 달성하면 인터럽트 상태를 다시 정상(false)으로 돌려두어야 한다. Thread.interrupted()와 InterruptException으로 catch문에서 잡았다면 인터럽트 플래그는 false가 된다.
만약 현재 CPU에서 실행되고 있는 스레드가 다른 스레드에게 양보하려면 어떡해야할까??
sleep(1)을 사용한다면 호출한 스레드는 RUNNABLE에서 TIMED_WAITING상태로 Context-Switching이 일어나면서 스케쥴링 대기열에서 이탈한다. 이 방법은 CPU 효율성이 낮아질수 있다.(코어가 많은 상황에서 양보할 스레드가 없는 경우)
이런 경우를 대비하여 yield()를 사용한다. yield를 호출한 스레드는 다시 스케쥴링 큐에 들어가면서 다른 스레드에게 CPU 사용 기회를 넘긴다.(RUNNABLE을 유지하여 Context-Switching 비용이 들지 않고 운영체제에 의해 효율적인 CPU 관리 가능)
(1)실시간성으로 확인은 하는데(RUNNABLE 상태이면서) 다른 스레드에 양보하고 싶을때
(2)입/출력 스레드에서 출력 스레드에 QUEUE가 비어있다면 yield()를 통해 입력 스레드에 양보
만약 메인 스레드가 worker 스레드와 공유하는 객체의 boolean 멤버 변수를 변경한다면, 해당 변경 사항이 worker 스레드에 즉시 반영되지 않을 수 있다.
CPU가 데이터를 읽기 위해 메인 메모리에 접근하면 데이터 전송 지연(Latency)으로 인해 성능이 저하되며, CPU 클럭 속도(GHz)와 메모리 속도(MHz) 차이로 인해 병목 현상이 발생할 수 있다. 이를 해결하기 위해 각 CPU 코어에는 캐시 메모리(L1, L2, L3 캐시)가 존재한다.
스레드는 자신이 실행 중인 CPU 코어의 캐시 메모리를 사용하여 데이터를 읽고 쓰므로, 메인 스레드에서 생성한 worker 스레드의 Runnable 내 boolean 값을 변경하더라도 메인 스레드의 캐시 메모리 변수만 변경되며 이는 메인 메모리,worker 스레드의 캐시 메모리에 반영되지 않는다. -> 컨텍스트 스위칭으로 갱신될수 있다.
CPU 코어의 캐시 사용을 하지 않고 모든 스레드의 특정 변수를 읽기 위해 메인 메모리에 접근하면 된다. 자바에선 volatile이라는 키워드를 통해 구현할수 있다.
메모리 가시성이란 한 스레드에서 변경한 값을 다른 스레드에서 즉시 확인할 수 있는지를 의미한다.
여러 스레드가 동시에 조건 검사 로직을 통과하게 되면, 비즈니스 로직이 원자적으로 처리되지 않아 Race Condition이 발생할 위험이 있다. 따라서 단순한 메모리 가시성 보장만으로는 동시성이 보장되지 않는다.
두 스레드가 비지니스 로직을 수행할때 검증 과정을 동시에 진입하여 통과하면 공유자원이 예상치 못하게 변경될 수 있으므로, 이를 방지하기 위한 동기화가 필요하다.
은행 계좌에서 두 사용자가 동시에 인출을 시도할 경우, 검증 단계가 동시에 실행되면 실제 인출된 잔액이 반영되지 않고 동일한 값으로 갱신되거나, 값이 갱신되기 전에 순차적으로 검증에 진입하면 잔액이 음수가 되는 오류가 발생할 수 있다.
검증,계산 이 두 단계를 한 번에 하나의 스레드에서만 실행되어야 한다. 그래야 공유자원이 중간에 변하지 않고 안전하게 계산을 수행할수 있다.
여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻한다. 자바에서 synchronized 키워드를 통해 임계 영역을 한번에 하나의 스레드만 실행할수 있도록 보호할 수 있다.
모든 객체는 내부적으로 하나의 고유한 락(모니터)을 가지고 있다. 만약 여러 스레드가 동일한 인스턴스의 synchronized 메서드를 동시에 호출하려고 하면, 한 번에 한 스레드만 해당 메서드에 진입할 수 있다. 이는 메서드를 실행하기 전에 인스턴스의 락을 획득해야 하기 때문이다. 스레드가 메서드 실행을 마치고 락을 반환하면 다음 대기 중이던 스레드가 그 락을 얻어 실행된다.
락 획득 순서는 보장되지 않고 volatile을 사용하지 않고도 메모리 가시성 문제가 해결된다.
임계 영역을 최대한 짧게 하는것이 성능 향상에 좋다. 따라서 자바에서 코드 블럭 단위로 설정할수 있다.
synchronized(this){
...
}
또한 각각의 스레드의 스택프레임에 할당된 지역변수는 동기화를 걱정하지 않아도 된다.
기존 synchronized의 단점은 락을 기다리는(BLOCKED된) 스레드가 인터럽트에 걸리지 않고 무한정 대기 및 공정성(WAITING 순서와 상관 없이 스케쥴링)에 대한 단점이 있다. 이 문제를 해결하기 위해서 LockSupport가 활용된다.
WAITING 상태로 변경한다.TIMED_WAITING 상태로 변경한다.WAITING상태의 대상 스레드를 RUNNABLE 상태로 변경한다.두 상태 모두 CPU 실행 스케쥴링에 들어가지 않기 때문에 CPU 입장에서 비슷한 상태이다. 차이점을 알아보자.
BLOCKED: 자바의 synchronized에서 락을 획득하기 위해 대기할때 걸리는 상태이다.
TIMED_WAITING,WAITING: Thread.join(),Thread.park() 등등 대기하는 메서드에서 걸리는 상태이다.
BLOCKED는 인터럽트가 걸려도 빠져나오지 못한다. 반면,TIMED_WAITING,WAITING은 인터럽트가 걸리면 대기 상태를 빠져나와RUNNABLE로 변한다.
LockSupport는 너무 저수준이기 때문에 synchronized처럼 고수준 level인 ReentrantLock을 사용한다.
기존 synchronized의 단점은 아래와 같다.
BLOCKED 상태의 스레드는 락이 풀릴때 까지 무한 대기한다. (타임아웃X,인터럽트X)BLOCKED 상태의 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다.lock() : 현재 스레드가 락을 획득할 때까지 무한정 대기한다.
unlock() : lock()으로 획득한 락을 한다.
tryLock() : 락을 즉시 시도하고, 락을 획득할 수 있으면 true를 반환하며, 락을 획득할 수 없으면 false를 반환한다. 대기하지 않는다.
tryLock(long time, TimeUnit unit) : 지정된 시간 동안만 락을 시도한다. 일정 시간 동안 락을 획득할 수 없으면 false를 반환하고, 락을 획득하면 true를 반환한다.
lockInterruptibly(): 락을 획득할 때까지 기다리되, 다른 스레드에서 인터럽트가 발생하면 InterruptedException을 던지고 락 획득을 중단한다.(lock()은 인터럽트가 발생해도 대기를 유지한다.)
newCondition() : Condition 객체를 반환한다. Condition은 스레드 간의 동기화를 위한 메서드 (await(), signal(), signalAll())를 제공한다.
생산자가 큐(Queue)에 데이터를 넣고 소비자가 큐에서 데이터를 꺼내는 상황을 가정할 때, synchronized 키워드를 사용하여 put()과 take() 메서드를 구현할 수 있다.
이 경우 먼저 실행되는 주체(생산자 또는 소비자)에 따라 동작 로직이 달라진다. 예를들어,
sleep()을 호출한 스레드는 TIMED_WAITING 상태로 들어가지만, 여전히 객체의 락(Lock)을 소유한 상태이다.이 문제를 해결하기 위해 Java에서는 Object 클래스의 wait()와 notify() 메서드를 제공한다.
synchronized로 설정된 임계 영역 안에서, 데이터가 가득 차거나 빈 경우 wait()을 호출하게 되면 객체의 락을 반납하고 대기한다. 이후 데이터를 소비하거나 공급한 경우 notify()로 알리게 되는데 객체 마다 Lock과 함께 존재하는 대기 집합의 스레드 중 1을 WAITING->BLOCKED 상태로 깨우게 된다. BLOCKED 상태인 이유는 임계 영역에서 깨어났기 때문에 LOCK을 소유하고 있지 않아 BLOCKED 된 상태이다.
이로써 락을 들고 WAITING 상태로 들어가는 DeadLock 문제를 해결할수 있다. 하지만 같은 종류의 스레드를 깨울 때 비효율이 발생하고 오래 대기한 스레드가 선택받지 못하는 다음과 같은 문제가 발생한다.
소비자가 데이터를 소비하고 notify()를 통해 대기 중인 스레드를 호출할때 큐가 비어있고 소비자가 또 호출된다면 다시 대기 집합으로 들어가야 하는 비효율이 발생한다.
또한 이런 상황이 반복되면 일부 소비자 스레드는 계속 선택되지 못한 채 대기 상태에 머무르게 되어, 특정 스레드만 계속 실행되는 비효율적인 상황이 발생할 수 있다. 이러한 현상이 Thread Starvation(스레드 기아)이다. 이를 해결하기 위해 스레드를 다깨우는 notifyAll() 방법이 있지만 역시나 비효율이다.
객체의 Lock(모니터)을 사용하는 생산자가 생산자를 깨우고 소비자가 소비자를 깨우는 기존 문제를 ReentrantLock의 Condition을 통해 해결할수 있다. Condition은 스레드 대기 공간이며 다음과 같이 생산자,소비자의 대기열을 분리하여 교차해서 Signal()을 보내 비효율을 방지할수 있다.
private final Condition producerCond = lock.newCondition();
private final Condition consumerCond = lock.newCondition();
synchronized vs ReentrantLock
락을 획득하기 위한 대기 장소는 따로 존재한다.synchronized의 스케쥴링은 무작위이고 ReentrantLock은 FIFO로 동작한다.
synchronized은 락을 획득할때 BLOCKED 상태로 대기하며 wait()을 호출하면 스레드 대기 집합에서 대기한다.ReentrantLock은 락을 획득할때 WAITING 상태로 대기하며 INTTERUPT가 가능하고 AWAIT()을 호출하면 마찬가지로 WAITING 상태로 대기한다.자바의 모든 객체는 멀티스레드와 임계 영역을 다루기 위해 다음 3가지 기본 요소를 가진다.
ReentrantLock을 활용하여 생산자-소비자의 문제를 개선한 공식적인 자료구조가 BlockingQueue이다. 다음과 같은 메서드가 존재한다.
null을 반환한다.null을 반환한다.IllegalStateException 예외를 던진다.NoSuchElementException예외를 던진다.NoSuchElementException 예외를 던진다.false를 반환한다.null을 반환한다.