Tistory View
condition(wait, notify[All] ) : Java 쓰레드 동기화(Synchronization)
What should I do? 2020. 6. 23. 06:24wait(), 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
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
synchronized( locker ) {
locker.wait();
}
v = 1; // change to random
occupied = true;
locker.notify(); // 값을 채웠으니 consumer를 깨운다.
Thread.Sleep(2000);
}
}
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 );
}
notify 또는 notifyAll이 다른 쓰레드를 깨운다고 해도 대기중인 쓰레드가 바로 실행되는 것은 아니다.
while( running ) {
synchronized( locker ) {
while( occupied ) {
locker.wait();
}
// v = 1; // notify 다음으로 이동
locker.notify();
}
}
변수 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는 하나의 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;
int v2;
while( running ) {
while( !occupied ) {
;
}
v2 = v;
occupied = false;
// output code
Log.d( TAG, "Consumer get value from Producer, value=" + v2 );
}
여담으로 "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) {
// 잠금 영역
여담
다음글 : 없다~, 혹시나 synchronized관련해서 빠진 게 있으면 댓글로 남겨주세요~
'Android Develop > Java' 카테고리의 다른 글
ByteBuffer를 Stream(InputStream,OutputStream)으로 (0) | 2021.03.14 |
---|---|
synchronized : Java 쓰레드 동기화(Synchronization) (0) | 2020.06.12 |
- Total
- Today
- Yesterday
- texture
- 텍스처
- Android
- 애드핏
- 블로그
- 전기료
- 전기요금
- ComputeShader
- 사용료
- 예금
- 에어컨
- 아끼는 법
- 에어콘
- 경제보복
- 애드센스
- choreographer
- 재태크
- OpenGLes
- 공유 컨텍스트
- 티스토리
- 컴퓨트셰이더
- 전기세
- 금리
- 적금
- 안드로이드
- 재테크
- OpenGL ES
- gpgpu
- TTS
- 컴퓨트쉐이더
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |