본문 바로가기

프로그래밍 언어/자바

[Java] 멀티쓰레드 프로그래밍

학습할 것 (필수)

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위와 의미
  • Main 쓰레드
  • 동기화
  • 데드락

참고자료

 

Thread 클래스와 Runnable 클래스

Thread 클래스의 인스턴스를 생성하는 어플리케이션은 반드시 해당 쓰레드에서 실행할 코드를 제공해야 한다. 이를 위한 두 가지 방법이 존재한다.

  • Runnable 객체 사용하기
    Runnable 인터페이스는 run 이라는 메소드를 정의한다. 해당 메소드는 쓰레드에서 실행시킬 코드를 의미한다. Runnable 객체는 Thread 생성자의 파라미터로 전달된다.
 public class RunnableImpl implements Runnable{
    @Override
    public void run() {
        System.out.println("Thread run!");
    }

    public static void main(String[] args) {
        (new Thread(new RunnableImpl())).start();
    }
}
  • Thread 객체 사용하기
    Thread 클래스는 자체적으로 Runnable 인터페이스를 구현한다. 그러나 Threadrun 메소드는 비어있는 상태이다. 어플리케이션에서는 run 메소드를 오버라이딩 함으로써 Thread클래스를 상속할 수 있다.
public class ThreadInherit extends Thread{
    @Override
    public void run() {
        super.run();
    }

    public static void main(String[] args) {
        (new ThreadInherit()).start();
    }
}

 

Thread 클래스를 사용하는 것이 더 쉬운 방법이긴 하다. 하지만, 커스텀한 쓰레드 클래스를 만들려면, Thread 클래스를 상속해야 하기 때문에, 다중 상속 문제로 다른 클래스를 상속할 수 없다는 단점이 있다. Runnable 인터페이스를 사용하면 implements 만 하면 되기 때문에, 다형성의 측면에서 더 이점이 있다.

 

Pausing Execution with Sleep

Thread.sleep 은 메소드를 호출한 쓰레드가 특정 기간동안 잠들도록 한다. 위 메소드를 통해 다른 쓰레드가 프로세서의 시간을 사용할 수 있도록 하는 효과적인 방법이다. 또한 sleep 메소드는 다른 쓰레드를 기다리고 해당 쓰레드의 실행과정에 맞춰가는데 사용될 수 있다.

 

sleep 메소드를 오버라이딩한 두 가지 버전이 존재한다. 하나는 밀리세컨드 단위, 다른 하나는 나노세컨드 단위로 기다리도록 한다. 하지만, 이러한 sleep time 은 정확하게 그 시간 동안 기다린다는 것을 보장하지 못한다. 왜냐하면, 잠자는 기간동안 interrupts 가 발생할 수 있기 때문이다. 따라서, sleep 메소드가 명시된 시간만큼 정확하게 기다릴 거라고 기대해서는 안된다.

 

sleep 메소드 사용 예제

public class SleepMethod {
    public static void main(String[] args){
        Thread thread1 = new Thread(() -> {
            try {
                Thread.sleep(6000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread1!");
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(4000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread2!");
        });

        thread1.start();
        thread2.start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main thread!");
    }
}

// 출력결과
// main thread!
// Thread2!
// Thread1!

sleep이 없을 때의 출력문의 실행 순서는 thread1 → thread2 → main 이지만, sleep으로 인해 실제 출력문은 main → thread2 → thread1 순서로 진행된다.

 

Interrupts

인터럽트는 스레드에게 멈추라고 지시를 내리는 것이다. 쓰레드가 인터럽트에 실제 어떻게 반응하는지는 프로그래머의 선택이지만, 쓰레드가 종료되는 것이 보편적이다.

 

쓰레드는 Thread 클래스에 있는 interrupt 를 호출하여 인터럽트를 보낸다. 인터럽트 매커니즘이 올바르게 동작하려면, 인터럽트을 받는 쓰레드는 반드시 interruption 을 지원해야 한다.

 

Supporting Interruption

쓰레드는 어떻게 interruption을 지원할까? InterruptedException 을 catch함으로써 가능하다. 위에서 언급했듯이 다른 쓰레드가 현재 쓰레드에게 interrupt를 발생시킬 수 있다.

// Thread.java

public void interrupt() {
        if (this != Thread.currentThread())
            checkAccess();

        synchronized (blockerLock) {
            Interruptible b = blocker;
            if (b != null) {
                interrupt0();           // Just to set the interrupt flag
                b.interrupt(this);
                return;
            }
        }
        interrupt0();
    }
}

 

interrupt를 발생시킬 쓰레드의 interrupt 플래그를 변경시켜 해당 쓰레드에 interrupt가 발생했다는 표시를 한다.

 

실제 interrupt를 발생시키는 메소드인 interrupt0 은 native 메소드이다. 이를 보아 해당 플랫폼에서 네이티브 코드를 사용하여 쓰레드의 플래그를 변경시킴을 유추할 수 있다.

public class InterruptTest{
    public static void main(String[] args) {
        InterruptedThread interruptTest = new InterruptedThread();
        interruptTest.start();
        InterruptThread interruptThread = new InterruptThread(interruptTest);
        interruptThread.start();
        System.out.println("main is finished!");
    }
}

class InterruptedThread extends Thread{
    @Override
    public void run() {
        try{
            Thread.sleep(4000);
            boolean isInterrupted = Thread.interrupted();
            System.out.println("Is interrupted? " + isInterrupted);
            System.out.println("This Thread is finished!");
        } catch (InterruptedException e) {
            System.out.println("Interrupt occur!");
            return;
        }
    }
}

class InterruptThread extends Thread{
    Thread otherThread;
    InterruptThread(Thread thread){
        this.otherThread = thread;
    }
    @Override
    public void run() {
        interruptOtherThread(otherThread);
    }
    public void interruptOtherThread(Thread thread){
        try {
            thread.interrupt();
        }catch (SecurityException e){
            e.printStackTrace();
        }
    }
}

// 출력
// main is finished!
// Interrupt occur!

 

Joins

join 메소드는 쓰레드가 다른 쓰레드가 종료될 때까지 기다리도록 한다. 특정 시간을 넘기지 않게 기다리도록 파라미터를 설정할 수도 있다.

public class JoinTest extends Thread{
    @Override
    public void run() {
        try {
            sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Child Thread finished!");
    }

    public static void main(String[] args) {
        JoinTest joinTest = new JoinTest();
        joinTest.start();
        try {
            joinTest.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Main Thread finished!");
    }
}

// 출력
// Child Thread finished!
// Main Thread finished!

 

쓰레드의 상태

  • NEW : Thread 객체가 만들어졌지만, start되지 않은 상태.
  • RUNNABLE : 실제 쓰레드가 JVM에서 동작하는 상태.
  • BLOCKED : lock이 걸려있는 block 코드에 들어가지 못하고 대기하고 있는 상태.
  • WAITING : wait or join or park 메소드가 호출될 때 이 상태로 전환된다. 다른 쓰레드가 특정한 행동을 하는 것을 기다리는 상태.
  • TIME_WAITING : WAITING과 유사하지만, 특정 시간을 기다린다는 차이점 존재. sleep or parkNanos or parkUntil 메소드가 추가.
  • TERMINATED : 쓰레드가 실행을 종료한 상태.
public class StatusTest {
    public static void main(String[] args) throws InterruptedException {

        // state : NEW
        Thread newStateThread = new Thread(new NewStateThread());
        System.out.println("State : " + newStateThread.getState());

        // state : RUNNABLE
        newStateThread.start();
        System.out.println("State : " + newStateThread.getState());

        // state : TIME_WAITING
        Thread timeWaitingStateThread = new SleepThread();
        timeWaitingStateThread.start();
        timeWaitingStateThread.join(1000);
        System.out.println("State : " + timeWaitingStateThread.getState());

        // state : WAITING
        Thread waitStateThread = new WaitStateThread();
        waitStateThread.start();
        waitStateThread.join(1000);
        System.out.println("State : " + waitStateThread.getState());

        // state : TERMINATED
        Thread.currentThread().sleep(2000);
        System.out.println("State : " + timeWaitingStateThread.getState());

    }
}

class NewStateThread implements Runnable {
    @Override
    public void run() {
        for(int i = 0 ; i < 1000000; i++){
            for(int j = 0 ; j < 1000000; j++){}
        }
    }
}

class SleepThread extends Thread{
    @Override
    public void run() {
        try {
            sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class WaitStateThread extends Thread{
    @Override
    public void run() {
        Thread thread = new SleepThread();
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 출력
// State : NEW
// State : RUNNABLE
// State : TIMED_WAITING
// State : WAITING
// State : TERMINATED

 

쓰레드의 우선순위

자바에서 쓰레드의 우선순위는 1부터 10의 Integer 변수로 표현된다. 클 수록 높은 우선순위를 의미한다. 쓰레드 스케줄러는 우선순위 값을 보고 어떤 쓰레드를 먼저 실행시킬 지 결정한다.

Thread 클래스에서는 우선순위와 관련한 세 가지 타입을 정의한다.

  • Minimum priority(1)
  • Normal priority(5, defualt)
  • Maximum priority(10)

JVM은 여러 쓰레드가 실행 가능 상태일 때, Runnable 상태이고 우선순위가 높은 순서대로 실행시킨다. 실행 중인 쓰레드가 멈추거나 Not Runnable한 상태가 되면, 다음 우선순위를 갖는 쓰레드가 실행된다. 만약, 우선순위가 동일한 두 쓰레드가 존재할 때, JVM은 FIFO 순서에 맞춰 실행시킬 것이다.

그러나 종종, 쓰레드 스케줄러는 starvation을 피하기 위해 우선순위가 낮은 쓰레드를 실행시키는 경우도 존재한다.

 

실험

  • 시나리오
    메인쓰레드와 2개의 자식 쓰레드가 존재한다. 메인 쓰레드는 5, 하나의 자식 쓰레드는 1, 다른 자식 쓰레드는 10의 우선순위를 갖고 있다.
// Main Thread's priority : 5
// child Thread1's priority : 1
// child Thread2's priority : 10
public class PriorityTest {
    public static void main(String[] args) throws InterruptedException {
        Thread currentThread = Thread.currentThread();
        Thread childThread1 = new ChildThread(currentThread,"child thread 1");
        Thread childThread2 = new ChildThread(currentThread,"child thread 2");

        childThread1.setPriority(1);
        childThread2.setPriority(10);

        childThread1.start();
        childThread2.start();
        currentThread.sleep(4000);

    }
}

class ChildThread extends Thread{
    private Thread parentThread;
    private String name;

    ChildThread(Thread parentThread, String name){
        this.parentThread = parentThread;
        this.name = name;
    }

    @Override
    public void run() {
        try {
            System.out.println(name + " start!");
            sleep(1000);
            parentThread.join();
            System.out.println(name + " end!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 출력1
// child thread 2 start!
// child thread 1 start!
// child thread 1 end!
// child thread 2 end!

// 출력2
// child thread 2 start!
// child thread 1 start!
// child thread 2 end!
// child thread 1 end!

여러 번 실행했을 때, 대체로 우선순위가 높은 2번 쓰레드가 먼저 실행되었다. 하지만, 1번 쓰레드가 먼저 실행되는 경우도 종종 있었다. 따라서, 우선순위 만으로 쓰레드들의 실행 순서를 예상하고 개발하는 것은 매우 위험하다는 결론을 얻었다.

 

그럼 실무에서 쓰레드의 우선순위를 활용할까?

확인해보니 대부분 사용하지 않는 것으로 보인다. 왜냐하면, OS 스케줄링에 거의 영향을 주지 않기 때문이다.

 

스레드 실행의 우선순위는 OS가 결정한다. 또한, Implementation dependent하기 때문에 각각의 OS들 마다 처리방식이 다르다. 따라서, OS와의 관계에서 JVM은 Process이기 때문에 스레드의 실행 우선순위를 결정할 권한이 없다.

 

책 ‘자바 병렬 프로그래밍(저자 : 브라이언 게츠)’에 따르면, 아래와 같다고 한다.

스레드 우선순위를 사용하려는 유혹을 피하세요. 스레드 우선순위는 플랫폼 의존성을 높이고 활성 문제를 일으킬 수 있기 때문입니다. 대부분의 concurrent application은 the default priority를 사용합니다.

 

또한, 스레드 우선순위를 지정한다고 해도, OS에서 해당 힌트들을 적용한다는 보장이 없기 때문에, 차라리 사용하지 않는 것이 혼동이 없는데 도움이 될 것 같다.

 

- 참고자료

Java Thread priority has no effect

Why thread priority rarely matters

 

Main 쓰레드

모든 자바 프로그램은 main() 메소드를 갖으며, main() 메소드는 프로그램을 실행시키는 엔트리 포인트이다. 따라서, JVM이 프로그램을 실행시킬 때, 해당 프로그램을 실행시키는 main thread를 생성한다.

 

JVM이 main thread를 생성할 때, main thread를 위한 스택을 생성한다. 해당 스택에는 main thread가 호출하는 메소드들이 스택 프레임의 형태로 저장된다. 만약 지정된 스택의 사이즈를 넘어서서 메소드를 호출한 다면 stack overflow errorthrow할 것이다.

 

그렇다면, thread가 생성될 때 마다 stack이 생성되는 것일까? 아니면 main thread만의 특별한 경우일까?

JVM은 쓰레드를 생성할 때 각각의 쓰레드를 위한 stack을 할당해준다. 각각의 쓰레드는 자신만의 고유한 stack을 갖게 되며, main thread에서와 마찬가지로 메소드 호출시 각각의 stack에 스택 프레임 형태로 적재된다.

 

그런데, main thread는 되도록이면 마지막으로 실행되는 thread이어야 한다. 왜냐하면, main thread가 프로그램이 종료될 때의 로직들을 수행하기 때문이다.

그렇다면, main thread가 종료된다면, 자식 스레드들도 종료될까?

// 메인 스레드가 종료될 때, 자식 스레드들도 자동으로 종료되는 지 확인합니다.
public class MainThreadStopTest {
    public static void main(String[] args) throws InterruptedException {
        Thread childThread = new ChildTestThread();
        childThread.run();
        Thread.currentThread().sleep(1000);
        return;
    }
}

class ChildTestThread extends Thread{
    @Override
    public void run() {
        for(int i = 0 ;i < 5; i++){
            System.out.println("child thread run! count " + i + "!");
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
// 출력
// child thread run! count 0!
// child thread run! count 1!
// child thread run! count 2!
// child thread run! count 3!
// child thread run! count 4!

 

결론은, 종료되지 않았다. main thread가 종료되더라도, child thread들은 정상적으로 동작하였다. 따라서, 만약 main() 메소드에서 프로그램이 종료될 때 반드시 수행되어야 할 로직들을 담고 있다면, main threadchild thread들이 종료될 때까지 기다린 이후에 종료되도록, 코드를 작성해야 한다.

 

동기화

쓰레드들은 주로 필드나 객체에 대한 접근을 공유함으로서 다른 쓰레드들과 소통한다. 이러한 소통 방식은 매우 효과적이지만, 두 가지 에러 상황을 만든다. thread interference & memory consistency errors. 이러한 에러들을 방지하기 위한 도구가 synchronization 이다.

Thread Interference

public class Counter {
    private int c = 0;

    public void increment(){
        c++;
    }

    public void decrement(){
        c--;
    }

    public int value(){
        return c;
    }
}

Counter 클래스는 간단하게 더하기, 빼기를 수행하는 메소드를 갖는 클래스이다. 해당 메소드들이 JVM에서 동작하는 순서를 자세하게 살펴보자.

increment() 메소드를 보겠다.

  1. 변수 c의 현재 값을 가져온다.
  2. 가져온 값에 1을 더한다.
  3. 더해진 결과값을 변수 c에 저장한다.

즉, c++;의 코드는 실제 JVM에서 동작할 때, 위와같이 세 단계로 수행된다. 만약 두 개의 쓰레드에서 Counter 객체를 공유하고 하나의 쓰레드는 increment() 메소드를, 다른 하나는 decrement() 메소드를 수행한다고 가정하자.(c는 0이라고 가정한다.)

  1. incrementThread : c의 값 가져오기(ret == 0)
  2. decrementThread : c의 값 가져오기(ret == 0)
  3. incrementThread : 가져온 값에 1 더하기(ret == 1)
  4. decrementThread : 가져온 값에 1 빼기(ret == -1)
  5. incrementThread : 변수 c에 결과값 저장하기(c == 1)
  6. decrementThread : 변수 c에 결과값 저장하기(c == -1)

최종적으로 변수 c에는 -1이 저장된다. incrementThread에서 변수 c에 저장했던 것은 decrementThread에 의해 덮혀지게 된다. 이처럼, 자바코드가 바이트코드로 변환되어, JVM에서 실제 수행될 때는 위와 같이 실행들이 짬뽕될 수 있다. 이러한 에러 상황을 Thread Interference 라고 말한다.

 

public class ThreadInterference {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread incrementThread = new CounterThread(counter,"increment");
        Thread decrementThread = new CounterThread(counter,"decrement");

        incrementThread.start();
        decrementThread.start();

        incrementThread.join();
        decrementThread.join();
        System.out.println(counter.value());
    }
}

class CounterThread extends Thread{
    Counter counter;
    String methodName;

    CounterThread(Counter counter, String methodName){
        this.counter = counter;
        this.methodName = methodName;
    }

    @Override
    public void run() {
        if(methodName == "increment") for (int i = 0; i < 1000000; i++) counter.increment();
        else for (int i = 0; i < 1000000; i++) counter.decrement();
    }
}

// 예상 출력
// 0

// 실제 출력
// -4715

 

각각의 쓰레드가 메소드들이 온전히 수행되는 것을 보장했다면, 실제 출력은 “0”일 것이다. 하지만, 메소드가 완전히 실행되고 종료되는 것을 보장하지 않기 때문에 위 결과 처럼, 예상과는 다른 값이 도출되게 된다.

Memory Consistency Errors

이 에러는 Thread Inference와 매우 유사해 보인다. 하지만, Thread Inference는 JVM에서 자바 소스코드가 atomically하게 실행된는 것을 보장해주지 않는다는 것이며, Memory Consistency Errorscpu level에서 multi core 사용시 발생할 수 있는 에러를 말한다. H/W 레벨에서 발생하는 concurrency 문제를 해결하기 위해, 각 코드가 실행되는 관계를 명시함으로서 해결 가능하다.

 

Synchronized Methods

메소드를 synchronized하게 만들기 위해 synchronized 키워드를 사용하면 된다.

public class SynchronizedCounter {
    private int c = 0;
    public synchronized void increment(){
        c++;
    }

    public synchronized void decrement(){
        c--;
    }

    public synchronized int value(){
        return c;
    }
}

기존 Counter 클래스의 메소드에 synchronized 키워드를 추가하였다. 이를 통해 다음과 같은 효과를 얻을 수 있다.

  1. 한 쓰레드가 synchronized 메소드를 실행시키면, 다른 쓰레드는 해당 메소드를 실행시키지 못하고 블락된다.
  2. synchronized 메소드가 종료되면, 자동으로 해당 메소드를 실행하려고 했던 쓰레드들에게 이 사실을 알린다. 이때 다른 쓰레드들은 해당 메소드를 실행시킬 수 있는 상태로 변경된다.

 

자바 공식문서에서는, 공유하는 객체로의 접근에 대해 절차적 수행을 보장하는 것 처럼 말한다. 그래서, 공유 객체를 다루는 코드에 대해서만 블락되는 것으로 오해하였다. 하지만, 실제 테스트 결과 synchronized 키워드가 붙은 메소드에 대해 메소드 레벨에서 블락되는 것을 확인하였다.

public class SynchronizedMethodTest {
    public static void main(String[] args) throws InterruptedException {
        SynchronizedCounter counter = new SynchronizedCounter();
        Thread incrementThread = new SynchronizedCounterThread(counter,"increment");
        Thread decrementThread = new SynchronizedCounterThread(counter,"decrement");

        incrementThread.start();
        decrementThread.start();

        incrementThread.join();
        decrementThread.join();
        System.out.println(counter.value());
    }
}

class SynchronizedCounterThread extends Thread{
    SynchronizedCounter counter;
    String methodName;

    SynchronizedCounterThread(SynchronizedCounter counter, String methodName){
        this.counter = counter;
        this.methodName = methodName;
    }

    @Override
    public void run() {
        if(methodName == "increment") for (int i = 0; i < 1000000; i++) counter.increment();
        else for (int i = 0; i < 1000000; i++) counter.decrement();
    }
}

// 예상 출력
// 0
// 실제 출력
// 0

 

위 내용에 대해 많이 혼동이 되어 애먹었다. synchronized 키워드는 3가지로 구분될 수 있는데,

synchronized method, static synchronized, synchronzied block 으로 나눌 수 있다. 각각 lock이 적용되는 영역이 다르기 때문에 주의 해야한다. 테스트 해봤을 때는 아래 결과와 같았다.

 

lock 적용여부 synchronized method static synchronized method
normal method X X
synchronized method O X
static synchronized method X O

 

synchronized method는 객체(object)에 대해서 lock이 걸린다. 만약 같은 클래스의 서로다른 인스턴스(A,B) 가 존재하며, 두 스레드가 각각에 인스턴스의 synchronized method에 접근할 경우 lock이 걸리지 않는다.

1개의 유니크한 객체의 synchronized method에 대해 여러 스레드가 접근할 때 lock이 걸리는 것이다. 단수형(method)이 아닌 복수형(methods) 임에 유의하자!! (필자는 매번 헷갈린다...ㅠ)

 

static synchronized method는 객체(object)가 아닌 Class에 대해 lock이 걸린다.

'Class'라는 단어 때문에, 그러면 Class에 있는 모든 method에 대해 lock이 걸리나??? 라고 오해할 수 있다.

'Class' 클래스가 보유하고 있는 static synchronized method들 에 대해 lock이 걸린다.

 

아래 글은 위 내용을 정리하는 데 활용한 자료이며, 필자 기준에서, method level & class level 의 차이를 가장 잘 설명했다고 생각되어 링크를 남긴다.

 

https://stackoverflow.com/questions/9525882/if-a-synchronized-method-calls-another-non-synchronized-method-is-there-a-lock

 

If a synchronized method calls another non-synchronized method, is there a lock on the non-synchronized method

In Java, if a synchronized method contains a call to a non-synchronized, can another method still access the non-synchronized method at the same time? Basically what I'm asking is everything in the

stackoverflow.com

 

 

추가적으로, sysnchronized 키워드는 생성자에 붙을 수 없다. 왜냐하면, 생성자가 실행되는 동안에 해당 객체에 접근할 수 있는 쓰레드는 오직 생성자를 호출한 쓰레드이기 때문이다. 생성자에는 synchronized가 적용되지 않기 때문에, 생성자에서 다른 synchronized method를 호출해서는 안된다.

 

 

 

 

데드락

데드락은 여러 쓰레드가 서로를 기다리느라 영원히 block되는 상황을 말한다. 다음 4가지 조건이 충족되면 데드락이 발생한다.

  1. 상호배제(Mutual Exclusion)
    하나의 자원에 대해 하나의 쓰레드만 사용할 수 있다.
  2. 점유와 대기(Hold and Wait)
    어느 한 쓰레드가 자원을 사용하고 있을 때, 해당 자원을 사용하기 위해 Block된 쓰레드가 존재해야 한다.
  3. 비선점(No Preemption)
    쓰레드가 자원을 사용하는 동안, 다른 쓰레드가 자원을 뺏을 수 없다.
  4. 순환대기(Circular wait)
    여러 쓰레드가 존재할 때(t1, t2, … , tn), t2는 t1이 사용중인 자원을 기다리고, tn은 tn-1이 사용중인 자원을 기다리고, t1은 tn이 사용중인 자원을 기다려야 한다.