Tistory View

wait(), notify(), notifyAll()


스레드간에 서로 변수를 동시에 바꿔 발생하는 문제는 지난 글의 내용이고 이제 쓰레드간의 정보의 전달을 위해 사용하는 방법을 배워보자, 정확히 주로 정보의 전달에 사용하기 위한 방법으로 사용되는 방식일 뿐, 정보를 전달하는 것은 아니다.


쓰레드A에서 변수의 값을 변경했다면, 쓰레드B에서 변경되었다는 신호를 인지하여 변경한 값을 가져오는 방식에 대부분 사용되기 때문에 이런 설명을 달았다. 다른 용도로도 당연히 쓸 수 있으니...


세개의 함수가 있는 데, 이는 전부 Java의 Object객체에 달려있는 메소드이다. 따라서 객체로 되어있는(Object에서 상속받은) 모든 객체는 이 함수를 쓸 수 있다.


일단 이 함수가 어떻게 동작하는 지 설명할테니, 천천히 이해하면서 읽어 보기를 바란다.



wait() : 객체의 잠금을 풀고, 다른 쓰레드에서 notify(All)을 호출해주기를 기다린다(잠잔다). notify가 도착하면, 다시 잠금 시도하여 잠기면 실행을 이어 나간다.



wait를 풀어보면 다음과 같이 된다. code1을 풀면 code2와 같이 된다.

// code 1

synchronized(obj) {

// CodeA

obj.wait();

// CodeB

}

//CodeC


위의 코드는 다음과 같이 동작한다.

// code 2

synchronized(obj) {

// CodeA

}

// wait : notify를 기다린다.

synchronized(obj) {

// CodeB

}
//CodeC




notify() : wait를 하고 있는 쓰레드(한개만)에 [notify신호]를 보낸다. 여러 쓰레드에서 wait를 하고 있어도 한 개만 깨우게 된다. 이 때, 어떤 쓰레드를 깨울지는 알 수 없다. wait를 하고 있는 쓰레드가 없는 경우 [notify신호]는 그냥 소멸한다.


notifyAll() : wait를 하고 있는 모든 쓰레드를 깨운다. 이 때 깨어나는 순서는 어떤 것이 먼저 깰지 모른다. 또한 wait하는 Thread가 없으면, [notify신호]는 그냥 소멸된다.


이 세가지 함수는 해당 객체가 이미 잠금이 되어 있어야 한다. 다른 말로 synchronized 블럭안에 있어야 한다.



synchronized안에 있어야하는 이유

wait 메소드는 위에서 설명했 듯, 잠금을 풀고 notify신호를 기다려야 하기 때문에 해당 객체는 이미 잠금상태여야한다. 따라서, synchronized안에 있어야하고, 그 것도 wait하는 객체가 잠겨 있어야 하기 때문(다른 객체를 잠그는 것이 아니다)에, synchronized 블럭안에 있어야한다.



notify[All]의 경우는 synchronized안에 넣는 2가지 이유가 있는 데, 첫번째 이유는 값이 변했음을 통지하는 변수[아래의 예제에서 occupied]가 변화되는 것을 synchronized되게 하려는 것이다. 기본 synchronized편에서 한쪽만 synchronized걸거나 아예 걸지않은 방식으로 동작하는 것을 방지하기위해 notify[All]또한 synchronized 블럭안에 넣어야한다. 예제의 상황에서는 occupied변수도 당연히 synchronized 블럭안에 있어야 한다. 두번째 이유는 좀 어려우니 처음 접하는 사람은 그냥 "synchronized 블럭안에 있어야 한다"만 기억하길 바란다. notify신호를 wait에게 전달을 하는 과정에서 wait하는 thread가 없을 경우 notify신호는 사라지게 된다. 이 과정에서 wait가 notify신호가 직후에 실행이 되어버리면, 그 wait는 notify신호를 받지못하게 된다. 이 것을 방지하려면 lock을 획득 후 wait가 실행어야하고(이제 wait가 실행되고 있음을 보증하게되고), notify신호를 보내는 Thread는 wait가 실행된 이후에 실행되도록 하기위해 notify[All]또한 잠금을 한 후에 실행되어야 한다. 이런 이유로 notify[All]또한 synchronized되어야 하는 것이다.




예제를 보자.

좀 더 정확히 그리고 전체적인 이해를 위해 다음의 코드를 보고, 설명을 천천히 읽어보기를 바란다.


적절한 예를 들어 설명하는 것이 가장 좋은 방법일 것같아.. 예제를 작성하였다.

흔히들 쓰는 방식으로 Producer/Consumer방식을 하나 작성을 해 보도록 하겠다.


Producer Thread에서 변수에 값을 입력하면, Consumer에서 값을 가져오는 간단한 코드이다. 구현하려는 것은 간단한 문제지만, 구현되는 것은 다음과 같이 좀 복잡하다.


// producer

while( running ) {
    synchronized( locker ) {

        while( occupied ) {
            locker.wait();
        }
       
        v = 1; // change to random
        occupied = true;
        locker.notify(); // 값을 채웠으니 consumer를 깨운다.
       
        Thread.Sleep(2000);
    }
}

// consumer

int v2;
while( running ) {
       
    synchronized( locker ) {
   
        while( !occupied ) {
            locker.wait();
        }
       
        v2 = v;
        occupied = false;
        locker.notify(); // 값을 빼왔으니, Producer가 다시 채울수 있도록 Consumer를 깨운다.
    }
   
   
    // output code
    Log.d( TAG, "Consumer get value from Producer, value=" + v2 );
}

Producer에 의해 값이 채워지면 occupied변수가 true로 되고 Consumer는 전달받을 값을 빼내고 occupied를 다시 false로 바꾼다. 그러면 다시 Producer가 값을 채우게 되는 코드이다.



notify 또는 notifyAll이 다른 쓰레드를 깨운다고 해도 대기중인 쓰레드가 바로 실행되는 것은 아니다.

// producer

while( running ) {
    synchronized( locker ) {
   
        while( occupied ) {
            locker.wait();
        }
       
        // v = 1; // notify 다음으로 이동

        //occupied = true; // notify 다음으로 이동

        locker.notify();

         // 여기로 이동 됨
        v = 1;
        occupied = true;
       
        Thread.Sleep(2000);
    }
}


변수 v를 설정하는 코드가 notify밑으로 이동이 되었다. 이 코드는 잘 못된 동작을 할 것 같다는 생각이 들 수도 있다. 하지만 이 코드는 문제없이 동작한다.


notify가 실행이 되면 wait중인 Thread(현재 상황에서는 Consumer의 wait함수)가 wait를 끝내고 Consumer Thread는 다음의 코드를 실행하려고 한다. 그럼 while loop의 조건을 검사할 테지만, while loop의 조건 검사 구문은 즉시 실행되지 않는다. 이유는 아직 Producer가 synchronized 코드 블럭 내에 있기 때문이다. 즉 잠금은 여전히 Producer가 가지고 있기 때문이다. 실제 Consumer의 조건문이 다시 실행되는 시점은 Producer의 synchronized 코드 블럭이 완전히 종료된 이후에 실행되게 된다. 따라서 notify가 된 후 Sleep에 의해 2초 정도 후에나 Consumer가 실행되게 된다.



"notify VS. notifyAll" 그리고 "조건문을 if를 사용하지 않고 loop를 사용하는 이유"

이 두개는 위에서 설명했듯이 한개의 Thread를 깨울지 전부를 깨울지의 차이지만, 실제 쓰레드 프로그래밍을 할 경우 두개의 차이는 없어 보인다. 보통 쓰레드를 2개(Producer, Consumer)만 만들어서 사용하기 때문에 둘의 차이는 존재하기 않게 된다. 하지만 여러 쓰레드를 만들경우 차이를 느끼기 쉽다.

 

실제 프로그래밍을 하다보면 notify보다는 notifyAll를 써야 되는 경우가 좀 많다. notify든 notifyAll이든 어떤 쓰레드가 먼지 깰지 알 수가 없기 때문에, 일단 notifyAll로 전부 깨우면, 가장 먼저 깬 놈부터 내가 실행해도 되는 조건인지를 판단하여 그 조건에 맞으면 실행하고 아니면 다시 wait를 하는 방식을 주로 쓴다. 그래서 wait를 루프안에 넣는 이유이다. 또한 깨어난 녀석이 수행을 마치고 다시 notify를 호출해서 다른 Thread가 수행할 수 있도록 하는 경우가 대부분이다.


속도면에서는 notify가 notifyAll에 비해 훨씬 빠르다, 당연히 전체를 깨우는 것이 아니고, 복잡한 로직이 있는 것도 아니기 때문에다. 단 2개의 Thread일 경우 notify만으로 속도향상을 볼 수 있지만, 혹시나 다른 곳에서 notify를 해야한다면, 결국은 notifyAll로 처리해야한다. 그래서 notifyAll을 더 많이 쓸 수 밖에 없다.



notify는 하나의 Thread를 깨우게되고, notifyAll은 wait하는 모든 Thread를 깨운다. notifyAll의 경우 모든 Thread가 깨어나 잠금에 성공한 Thread만이 실행되고 이 Thread가 잠금을 풀면 다른 Thread들 중에 잠금에 성공한 녀석이 실행되는 데, 이 때, 잠금을 성공한 Thread는 다시 조건문을 검사하고(이 조건문은 내가 실행해도 되나?라는), 조건에 맞지 않으면 다시 wait를 하게 된다. notify는 깨어난 Thread만 이 작업을 하게 된다. 이렇게 하는 이유는 [어떤게 깰 지 모른다]와 같다. 이유도 조건 검사를 루프에 돌리는 이유이다.


notify : 한놈을 깨우고 깨어난 놈이 또 notify해서 다른 놈을 깨우는 방식으로 코딩

          나머지 Thread는 여전히 wait를 수행중이다


notifyAll : 다 깨우고, 자기가 할 일을 골라 처리하며, 할일이 없다면 다시 wait하는 방식으로 코딩

          한 놈만 synchronizd영역에 진입한다. 나머지 깨어난 Thread들은 잠금이 풀릴 때까지 기다린다.




어떤 쓰레드가 먼저 깨어날지는 모른다.

실제 대부분의 상황에서는 먼저 wait한 놈이 먼저 깨어나지만, 아닐 수도 있다. 따라서 먼저 wait한 놈이 "먼저 깨어날 것이다"라는 가정으로 코드를 작성하지 말고, "깨어난 놈이 어떤 일을 찾아서 할 것인가?"방식으로 코드를 작성해야 한다. 좀 더 설명하면 "깨어난 녀석은 할 일이 정해져 있으니, 내가 할 일이 아니면, 다른 Thread가 깨어나게 한다"식으로 코드를 작성해서는 안된다. 이유는 이놈(A)이 딴놈(B)을 깨웠지만, 그놈(B)이 자기도 할게 없다해서 다시 notify하고 wait하게될 텐데, 이 때, (A)를 깨워버릴 수도 있다. 계속 이렇게 되면 지들끼리 계속 놀 뿐이다.



깨어나는 순서를 정할 수는 없나?

깨어나는 순서를 정할 수가 있기는 할 것이다. 이를 schedule이라고 하는데, 이른 변경하는 것은 좀 위험하며, 아마 변경하기도 그리 쉽지는 않을 것이다. 아예, 그냥 포기하고 위에서 언급한 방식으로 코드를 만들어야 한다. (시도조차 하지 않기를 바란다. 시간 낭비다. 설명하기에는 좀 오버스럽다.)




Condition VS Semaphore

Java의 wait와 notify[All]식으로 동작하는 녀석을 condition이라고 하는 데, 여기서 condition의 뜻은 [조건]이라는 뜻도 좀 포함하지만, [상태]라는 뜻의 비중이 더 많다.


필자가 Java로 이 wait, notify... 를 사용하면서 잘 못 이해하고 있었던 부분이기도 하다. Java의 Object의 wait와 notify의 동작방식이 Semaphore라고 알고 프로그래밍을 했더니, 계속 잠기기만 해서 DeadLock이 발생했다 때로는 처리도 안되고 끝나버렸다. Semaphore는 notify(정확히 notify보다는 release)가 현재 wait하는 쓰레드가 없어도 이 후에 wait하는 Thread에도 적용이 되지만(심지어 자신이 wait해도), Condition은 그냥 소멸해 버린다는 것이다. 결국 이 둘은 서로 다른 방식으로 동작을 한다.




Condition VS Busy-Wait

Java는 왜 이런 Condition이라는 방식을 선택했을까? Semaphore도 있고, 뭐 다른 방식을 선택했을 수도 있었을텐데 말이다.

이유는 속도와 효율성 문제에 있다.


Busy-Wait가 뭔지 모르는 독자를 위해 간단히 설명해 보겠다.

다음의 코드는 Condition이나 lock(Synchronized)을 사용하지 않고 위 Producer/Consumer문제를 작성해 보겠다.


Busy-Wait

// producer
while( running ) {

        while( occupied ) {
            ;
        }
        v = 1;

        occupied = true;
}


// consumer
int v2;
while( running ) {
       
        while( !occupied ) {
             ;
        }
       
        v2 = v;
        occupied = false;
   
   
    // output code
    Log.d( TAG, "Consumer get value from Producer, value=" + v2 );
}




이 코드는 테스트해보지 않았지만 설명을 하려는 용도로는 충분해 보인다. 값이 채워졌는지 아닌지를 결정하는 occupied변수를 지속적으로 검사를 한다. Consumer의 입장에서 해석하면 Producer에서 v변수가 채워지면, Consumer는 while 루프를 탈출하고 이제 자기 할일을 하게 된다. 채워질 때까지 그냥 계속 값을 검사한다.

변수가 채워졌는지 검사를 하는 동안 CPU를 100%사용하면서 루프는 계속 돌게 된다. "참 비효율적이다"라고 생각할 수도 있고, "CPU를 100%사용하니 엄청느려질 것이다"라고 생각할 수 있다. 실제 이 코드를 실행해보면 CPU는 100%를 사용하지만, 속도는 오히려 Condition을 사용했을 때보다 훨씬 빠르게 수행된다. 게다가 요즘은 CPU가 Multi-core로 만들어지기 때문에 다른 core에서 실행되고 있는 Thread의 속도 저하는 발생하지 않는다.

더 빠른 이유는 일단 lock과 wait/notify에서 잡아먹는 속도저하가 완전히 사라지게 된다. 실제 이 함수들은 정말 상대적으로 느린 함수들이다. 서로 일일이 lock을 걸면서 수행되기 때문에 느려지고, 특히 notify신호를 전달하는 과정에서 잡아먹는 시간은 느껴질 정도로 오래걸리는 작업니다.

그렇지만, 역시나 CPU를 지속적으로 사용한다는 것은 정말 비효율적이다. 이 타협점을 찾은 것이 바로 Condition이다. Condition을 사용하면, Consumer에서 루프에서 조건문을 검사하게 되는 데, 만약 이미 채워져 있다면, wait는 수행되지 않게 되고 이는 기다림 없이 Busy-wait와 같은 방식으로 최대 속도로 동작하게 된다. 채워져 있지 않다면 wait가 실행되어 CPU사용을 하지 않는 결과가 된다. 실제 상당히 효율적인 코드인 것이다.


여담으로 "Busy-wait를 사용하는 곳이 실제 있을 까?"라고 의문점을 떠올리는 독자가 있을 수 있다. 실제로 많이 사용된다. 게임과 같이 미친듯이 계산을 해야되는 코드들이나 Producer/Consumer문제에서 데이터(특히 크기가 작은 데이터 : Sound Wave Data를 잘게 쪼개서 보내는 경우와 같은)가 지속적으로 처리될 때는 busy-wait가 일어나는 시간이 상당히 짧게 때문에 wait에서 발생하는 문제가 없게 된다. 하지만, 무작정 속도를 빠르게 한다고 busy-wait를 사용하면, 밧데리가 순간 사라지는 것을 보게 될 것이니, Busy-wait방식을 선택은 신중을 가해야한다.


실제 이런 상황 빼고는 쓸일없다. 웬만하면 쓰지 마라.



NDK에서 사용하기

NDK에서 synchronized 블럭을 만들려면 MonitorEnter와 MonitorExit함수를 이용한다. Enter가 시작점이고, Exit가 블럭이 끝나는 점을 나타낸다.


wait와 notify는 GetMethodID함수로 다음과 같이 받아와서 호출해 주면된다.


java 코드를 NDK로 바꾸면 다음과 같이 된다.



env->MonitorEnter( obj );    <-      synchronized(obj) {


                    // 잠금 영역


env->MonitorExit(obj);        <-      }


wait와 notify를 사용하려면 다음과 같이 GetMethodID를 이용하여 가져와야 한다.
[이 방법말고 다른 NDK에서 쓸 수 있는 함수가 있으면 댓글 좀... 달아 주세요... 너무 비효율 적이라..]

jclass        clsObject     = env->FindClass("java/lang/Object");
jmethodID midWait      = env->getMethodID( "wait", "()V" );
jmethodID midNotify    = env->getMethodID( "notify", "()V" );
jmethodID midNotifyAll = env->getMethodID( "notifyAll", "()V" );


wait 실행   =>  env->CallVoidMethod( obj, midWait    );
notify 실행 =>  env->CallVoidMethod( obj, midNotify  );



여담

이 wait/notify문제를 처음 접하거나, 정확히 이해하려는 독자에게 모든 것을 차근차근 설명하려고 글을 썼지만, 내용이 하드코어해서인지, 아니면 필자가 설명의 방식을 잘 못 정한 것인지(별명이 정리의 귀재인데..ㅠㅠ), 이 글 또한 하드코어문서가 되어버렸다.ㅠㅠ,,,, 이 내용을 이해하려면 여러번 반복해서 읽기를 바란다.

판사들은 다른 판사의 판결문을 이해하려고 읽을 때, 정확한 이해를 위해 200번 정도 읽는다. 그 들이 괜히 똑똑하고 현명하다는 칭찬을 받는 것이 아니며 또한 그 들이 성적이 좋은 이유이기도 하다. 현명하다는 칭호는 그 들의 노력과 시간투자에 의해 받은 선물인 것이다.
이글을 200번은 아니라도 반복해서 읽으면 이해가........ 될...까?..ㅠㅠ (됩니다~)



다음글 : 없다~, 혹시나 synchronized관련해서 빠진 게 있으면 댓글로 남겨주세요~

Replies
Reply Write