Guarded Suspension 패턴은 쓰레드를 기다리게 하여 인스턴스의 안전성을 지킵니다. 이는 마치 우편 배달원을 현관에서 기다리게 하고 자기의 프라이버시를 지키는 것과 비슷합니다. Guarded Suspension 패턴에는 guarded wait, spin lock 등 여러 가지 명칭이 있습니다.
하나의 쓰레드(ClientThread)가 다른 쓰레드(ServerThread)에 Request 인스턴스를 건네주는 것입니다. 이것은 매우 간단한 쓰레드 간의 통신입니다.
이름 |
해설 |
Request |
하나의 리퀘스트를 표현한 클래스 |
RequestQueue |
리퀘스트를 순서대로 비축해두는 클래스 |
ClientThread |
리퀘스트를 내주는 클래스 |
ServerThread |
리퀘스트를 받아 해석하는 클래스 |
Main |
동작 테스트용 클래스 |
여기에서 :ClientThread와 :ServerThread의 상자가 굵은선으로 그려져 있습니다. 굵은선의 사각형은 그 객체가 쓰레드와 연관되어 있는 것을 나타낼 때 사용합니다. 결국 그 객체는 능동적으로 메소드를 호출할 수 있게 됩니다.
:ClientThread나 :ServerThread를 능동 객체(active object), :RequestQueue를 수동 객체(passive object)라고 부릅니다.
1. Request 클래스
Request 클래스는 리퀘스트를 표시한 것입니다. 리퀘스트라고 해도 ClientThread에서 ServerThread에 건네진 인스턴스로서 사용될 뿐이어서 특별한 처리는 하지 않습니다. Request는 이름(name 필드)만을 가지고 있는 클래스 입니다.
public class Request { private final String name; public Request(String name) { this.name = name; } public String getName() { return name; } public String toString() { return "[ Request " + name + " ]"; } }
2. RequestQueue 클래스
RequestQueue 클래스는 Request를 순서대로 저장해 놓은 클래스입니다. 클래스에는 getRequest와 putRequest라는 두 개의 메소드가 있습니다.
getRequest 메소드
getRequest 메소드는 RequestQueue 속에 저장되어 있는 리퀘스트 중에서 가장 오래된 것을 하나 선택하여 값을 고칩니다. 만약 리퀘스트가 하나도 없으면 누군가 다른 쓰레드가 putRequest할때까지 기다립니다.
putRequest 메소드
putRequest 메소드를 사용하면 리퀘스트를 하나 추가할 수 있습니다. 이 메소드는 쓰레드가 RequestQueue 속에 Request의 인스턴스를 추가할 때에 호출됩니다.
요약하면 RequestQueue는 Request의 인스턴스를 putRequest로 밀어넣어 그 순서대로 getRequest로 꺼내는 클래스라는 것입니다. 이와 같은 구조를 일반적으로 큐(queue) 혹은 FIFO(First In First Out)이라고 합니다.
import java.util.LinkedList; public class RequestQueue { private final LinkedList queue = new LinkedList(); public synchronized Request getRequest() { while(queue.size() <= 0) { try { wait(); } catch(InterruptedException e) { } } return (Request)queue.removeFirst(); } public synchronized void putRequest() { queue.addLast(request); notifyAll(); } }
- getRequest, putRequest 모두 synchronized 메소드이다.
- getRequest의 처음에 while문이 있고 조건을 테스트하고 있다.
- while문 속에 wait를 하고 있다.
- while문 뒤에 실제로 하고 싶은 처리(removeFirst)를 하고 있다.
- putRequest 속에 notifyAll을 하고 있다.
public synchronized Request getRequest() { while(queue.size() <= 0) { try { wait(); } catch(InterruptedException e) { } } return (Request)queue.removeFirst(); }
가드에 의해 지켜지고 있다.
우선 getRequest 메소드로 실행해야 하는 목적이 무엇일까 생각해 보겠습니다. 이 메소드의 목적은 queue에서 Request의 인스턴스를 하나 꺼내고 싶다는 것입니다.
return (Request)queue.removeFirst();
queue.size() < 0 // 가드 조건
getRequest 중 while문의 조건식을 잘 살펴보면 while문의 조건식은 가드 조건의 논리 부정이되어 있습니다. 이 while문에 의해 removeFirst 메소드가 불려질 때에 반드시 가드 조건이 만족되어야 함을 보증할 수 있습니다.
queue.size() >= 0 // 가드 조건의 논리 부정
가드 조건의 논리 부정이 만족되고 있는 사이(요컨대 가드 조건이 만족되지 않는 사이)에는 while문의 다음(removeFirst의 호출)으로는 결코 진행되지 않습니다.
1) 기다리지 않는 경우, 기다리는 경우
쓰레드가 while 문에 도착한 시점에 가드 조건이 충족되는 때와 충족되지 않는 때 두 개의 경우를 생각해 볼 수 있습니다.
가드 조건이 충족될 때, 쓰레드는 while문의 본체에 들어가지 않고 바로 while문 다음으로 넘어가 removeFirst 메소드를 호출합니다. 이 경우 wait에는 가지 않으므로 쓰레드를 갖지 않습니다.
가드 조건이 만족되지 않을 때, 쓰레드는 while문의 본체에 들어가 wait를 실행하고 기다리게 됩니다. 쓰레드가 wait를 실행하는 것은 가드 조건이 충족되어 있지 않은 경우뿐입니다.
2) wait에 의해 조건의 변화를 기다린다
가드 조건이 충족되지 않은 경우 쓰레드는 wait을 실행하고 "기다리게" 됩니다. 그런데 쓰레드는 무엇을 기다리고 있는 것일까요?
당연히 notify/notifyAll 되는 것을 기다리고 있습니다. wait하고 있는 쓰레드는 notify/notifyAll 되지 않으면 wait 셋에서 기다리는 상태 그대로이게 되니까요. 그러나 조금 더 깊이 의미를 생각해보겠습니다. 쓰레드가 정말로 기다리는 것은 인스턴스의 상태 변화입니다. 쓰레드가 기다리게 되는 것은 가드 조건이 충족되지 않아서입니다. 이 조건이 가드가 되어 전진하고 싶어도 앞으로 나아 갈 수 없었기 때문입니다. 기다리게 되는 쓰레드는 가드가 되어 조건의 변화를 기다리고 있습니다. 쓰레드는 가드 조건이 충족되는 것을 기다리고 있는 것입니다.
당연한 것을 지겹도록 되풀이하는 것 같지만 이부분은 확실히 새겨두는 편이 좋습니다. 왜냐하면 쓰레드가 기다리고 있는 것은 무엇인가를 알면 언제 notify/notifyAll 하면 좋을까를 알 수 있기 때문입니다.
wait하고 있는 쓰레드는 가드 조건이 만족되어진 때에 notify/notifyAll 합니다. while 문의 다음으로 나아갈 수 있는 것은 그 경우뿐이기 때문입니다.
3) while 다음으로 진전하는 시점에서 말할 수 있는 것
어떻든 간에 while문의 다음 처리가 진전됐다고 하겠습니다. 그 때에 while문의 가드 조건은 반드시 참이 되어 있습니다.
즉 removeFirst 메소드를 호출하는 시점에서는
queue.size() > 0 // 가드 조건
while("가드 조건"의 논리 부정) { wait에서 기다린다; } "목적의 처리"를 행한다;
본래의 처리를 행하기 직전에는 가드 조건은 반드시 만족되어 있습니다. 어떤 처리를 행하기 전에 반드시 충족되고 있는 조건을 사전 조건(precondition)이라고 합니다. 가드 조건은 "목적"의 사전 조건이 됩니다.
이번에는 putRequest 메소드입니다. 이쪽은 짧기 때문에 금방 읽을 수 있습니다.
public synchronized void putRequest() { queue.addLast(request); notifyAll(); }
addLast 메소드의 실행으로 queue의 말미에는 request 하나가 추가되었습니다. 이 시점에서 queue에 쌓여져 있는 요소는 적어도 하나 존재합니다. 그러므로 다음의 식(queue.size() > 0)은 참이 됩니다. 조금 전 getRequest에서 wait중인 쓰레드가 기다리고 있는 것은 무엇이었습니까? 가드 조건이 충족되었고, 그럼 여기에서 notifyAll을 하게 됩니다. 이것이 putRequest 메소드입니다.
synchronized의 의미
getRequest 메소드와 putRequest 메소드를 읽었습니다. 그런데 이들은 양쪽 모두 synchronized 메소드로 되어 있습니다. synchronized를 보면 이전에 이야기했던것 처럼 이 synchronized는 무엇을 지키고 있는가를 생각해 보겠습니다. 이 synchronized는 queue 필드(LinkedList의 인스턴스)를 지키고 있습니다.
- queue 필드 요소의 개수가 0보다 큰지 판단
- queue 필드에서 요소를 하나 꺼냄
이라는 두 개의 처리를 반드시 "딱 하나의 쓰레드가 행하도록" 유지하고 있습니다. 이것은 Single Threaded Execution 패턴이 됩니다.
Wait와 락
쓰레드가 어떤 인스턴스의 wait 메소드를 실행하려고 합니다. 그러기 위해서는 쓰레드는 그 인스턴스의 락을 취하고 있을 필요가 있습니다. 예 프로그램과 같이 synchronized 메소드 중에서 wait 메소드를 호출하고 있으면 wait를 실행하는 시점에서는 this의 락을 취하고 있는 것이 됩니다.
그런데 this의 wait 메소드를 실행한 쓰레드는 this의 wait 셋에 들어가지만 이 때에 쓰레드가 가지고 있던 this의 락은 해제됩니다.
그러면 notify, notifyAll 혹은 interrupt에 의해 쓰레드는 wait 셋에서 나옵니다. 그러나 실제로 처리를 계속하기 전에는 this의 락을 다시 한번 취해야 합니다.
3. ClientThread 클래스
ClientThread 클래스는 리퀘스트를 꺼내는 쓰레드를 나타내는 클래스입니다. ClinetThread는 RequestQueue의 인스턴스를 기다려 그것에 대해 리퀘스트를 몇번이고 putRequest합니다. 리퀘스트의 이름은 "No.0", "No.1", "No.2" ... 로 합니다.
리퀘스트를 꺼내는(putRequest하는) 타이밍을 변화시키기 위해서 java.util.Random 클래스를 사용하여 0이상 1000미만 범위의 난수를 발생시켜 그 시간(밀리 초 단위) 동안만 sleep하고 있습니다.
import java.util.Random; public class ClientThread extends Thread { private Random random; private RequestQueue requestQueue; public ClientThread(RequestQueue requestQueue, String name, long seed) { super(name); this.requestQueue = requestQueue; this.random = new Random(seed); } public void run() { for(int i = 0; i < 10000; i++) { Reqeust request = new Request("No." + i); System.out.println(Thread.currentThread().getName() + "requests" + request); requestQueue.putRequest(request); try { Thread.sleep(random.nextInt(1000)); } catch(InterruptedException e) {} } } }
4. ServerThread 클래스
ServerThread 클래스는 request를 해석하는 쓰레드를 나타내는 클래스입니다. 이 클래스도 RequestQueue의 인스턴스(requestQueue)를 가지고 있스니다. ServerThread에서는 getRequest 메소드로 리퀘스트를 해석합니다. ClientThread와 같이 ServerThread이라도 난수를 사용해서 Sleep하고 있습니다.
import java.util.Random; public class ServerThread extends Thread { private Random random; private RequestQueue requestQueue; public ServerThread(RequestQueue requestQueue, String name, long seed) { super(name); this.requestQueue = requestQueue; this.random = new Random(seed); } public void run() { for(int i = 0; i < 10000; i++) { Request request = requestQueue.getRequest(); System.out.println(Thread.currentThread().getName() + " handles " + request); try { Thread.sleep(random.nextInt(1000)); } catch (InterruptedException e) { } } } }
5. Main 클래스
Main 클래스에서는 우선 RequestQueue의 인스턴스(requestQueue)를 만듭니다. 그리고 Alice라는 이름의 ClientThread 인스턴스와 Bobby라는 이름의 ServerThread 인스턴스를 만들어 양쪽에 requestQueue를 건네주고 start합니다.
public class Main { public static void main(String[] args) { RequestQueue requestQueue = new RequestQueue(); new ClientThread(requestQueue, "Alice", 3141592L).start(); new ServerThread(requestQueue, "Bobby", 6535897L).start(); } }
Alice가 리퀘스트를 꺼내고(requests) Bobby가 리퀘스트를 처리하는(handles) 것을 알 수 있습니다.
Alice requests [ Request No. 0 ]
Bobby handles [ Request No. 0 ]
Alice requests [ Request No. 1 ]
Alice requests [ Request No. 2 ]
Bobby handles [ Request No. 1 ]
Bobby handles [ Request No. 2 ]
Alice requests [ Request No. 3 ]
Bobby handles [ Request No. 3 ]
Alice requests [ Request No. 4 ]
Bobby handles [ Request No. 4 ]
Alice requests [ Request No. 5 ]
Alice requests [ Request No. 6 ]
Bobby handles [ Request No. 5 ]
Bobby handles [ Request No. 6 ]
queue 필드 모양인 java.util.LinkedList 클래스의 역할을 정리해둡니다. LinkedList는 리스트에 관계된 객체의 집합을 나타내는 클래스입니다. 예제 프로그램에서는 다음과 같은 세 개의 메소드를 이용하고 있습니다.
Object removeFirst()
리스트의 선두에서 요소를 하나 제거하고 그 요소를 되돌립니다. 만일 요소가 하나도 없을 때에는 java.util.NoSuchElementException을 던집니다.
void addLast(Object obj)
리스트의 말미에 요소 obj를 첨가합니다.
int size()
리스트에 관계된 요소의 개수를 되돌립니다.
Guarded Suspension 패턴의 등장인물은 다음과 같습니다.
GuardedObject(가드 되는 객체)
GuardedObject는 가드된 메소드(guardedMethod)를 가지고 있는 클래스 입니다. 쓰레드가 guardedMethod를 실행할 때에 가드 조건이 충족되면 바로 실행할 수 있습니다.
그러나 가드 조건이 만족되지 않으면 기다리게 됩니다. 가드 조건의 진위는 GuardedObject의 상태에 따라서 변화합니다.
GuardedObject는 guardedMethod 외에 인스턴스의 상태를 변화시키는(특히 가드 조건을 변화시키는) 메소드(stateChangingMethod)를 갖는 경우도 있습니다.
자바에서는 while문과 wait 메소드를 사용해서 guardedMethod를 실현할 수 있습니다. 또한 notify/notifyAll 메소드를 사용해서 stateChangingMethod를 실현할 수 있습니다.
예제 프로그램에서는 RequestQueue 클래스가 이 역할을 합니다. getRequest 메소드가 guardedMethod, putRequest 메소드가 stateChangingMethod에 대응합니다.
조건부 synchronized
Single Threaded Execution 패턴에서는 쓰레드가 하나여도 이 쓰레드가 Critical Section에 들어가 있으면 다른 쓰레드는 Critical Section에 들어갈 수 없어 기다리게 됩니다.
한편 Guarded Suspension 패턴에서는 쓰레드가 기다리고 있는지 여부는 가드 조건에 의해서 결정됩니다. Guarded Suspension 패턴은 Single Threaded Execution 패턴에 조건을 부가한 것입니다. 결국 조건부 Synchronized와 같은 것입니다.
멀티 스레드 판의 if
당연히 싱글 쓰레드 프로그램에서 Guarded Suspension 패턴은 불필요합니다. 싱글 쓰레드에서 동작의 주체가 되는 쓰레드는 하나입니다. 그 유일한 쓰레드가 기다리는 상태에 들어가면 인스턴스의 상태를 바꾸어주는 쓰레드는 어디에도 없게 됩니다. 그러므로 인스턴스의 상태가 "지금" 부적절하면 쓰레드가 언제까지나 기다리게 될 때 상태는 부적절하게 됩니다.
싱글 쓰레드 프로그램에서는 가드 조건 테스트가 간단한 if 문으로 해결됩니다. 말하자면 Guarded Suspension 패턴은 "멀티 쓰레드 판의 if"와 같은 것입니다.
상태 변경 잊어버리기와 생존성
wait하고 있는 쓰레드는 notify/notifyAll 될때마다 가드 조건을 테스트 합니다. 아무리 notify/notifyAll이 되어도 가드 조건이 만족되지 않으면 쓰레드는 while에 의해 다시 wait 하게 됩니다.
만약 프로그램의 실수로 GuardedObject의 상태를 변경하는 것을 잊어버리면 가드 조건은 시간이 지나도 성립하지 않습니다. 그 경우 아무리 notify/notifyAll을 해도 쓰레드의 처리는 진전되지 않고 생존성을 잃어버리게 됩니다.
wait하고 있지만 시간이 너무나 많이 흘러도 notify/notifyAll 되지 않는 경우 어떤 에러가 발생했다고 판단하여 처리를 중단하고 싶다고 합시다. 일정 시간 후에 처리를 중단하고 싶은 경우에는 wait 메소드를 호출할 때에 인수로 타임아웃(timeout) 시간을 지정하도록 합니다.
wait과 notify/notifyAll의 책임(재사용성)
예제 프로그램을 잘 보면 wait/notifyAll이 등장하는 것은 RequestQueue 클래스뿐입니다. ClientThread, ServerThread, Main의 각 클래스에는 wait/notifyAll은 나타나지 않습니다. Guarded Suspension 패턴의 구현체는 RequestQueue 클래스 안에 가둬져 있습니다.
wait/notifyAll이 숨겨져 있다는 것은 RequestQueue 클래스의 재사용성이라는 점에서 중요합니다. RequestQueue를 이용하는 측은 wait나 notifyAll은 생각할 필요가 없이 단지 getRequest 메소드나 putRequest 메소드를 불러내면 되기 때문입니다.
여러 가지 통칭
Guarded Suspension 패턴에 유사한 처리에는 여러 가지 통칭이 있습니다. 또한 참고 문헌이나 문맥에 따라서는 같은 이름으로 되어 있어도 다른 의미가 되는 경우도 있습니다.
- 루프(반복)가 있는 점
- 조건의 테스트가 있는 점
- 어떤 의미로 "기다리는" 점
guarded suspension
"가드되어 실행을 일시 중단한다"는 의미입니다. 이 이름에서는 구현 방법에 대해서는 표현되지 않습니다.
guarded wait
"가드되어 기다린다"라는 의미입니다. 쓰레드가 wait에서 기다리고 notify/notifyAll 되어 조건을 다시 테스트하는 듯한 구현 방법입니다. wait에서 기다리고 있는 동안 wait 셋 중에 실행을 정지하고 있기 때문에 자바 실행 처리계의 처리 시간을 헛되게 소비하는 것은 아닙니다.
// 기다리는 예 while (!ready) { wait(); } // 일으키는 예 ready = true; notifyAll();
busy wait
"바쁘게 기다린다"라는 의미입니다. 쓰레드가 wait에서 기다리는 것이 아니라 yield(다른 쓰레드에 될 수 있는 한 우선권을 준다)하면서 조건을 테스트하는 구현 방법입니다. 기다리는 쪽의 쓰레드도 계속해서 움직이고 있기 때문에 자바 실행 처리계의 시간을 헛되게 소비하고 있습니다. yield는 Thread 클래스 메소드 입니다.
// 기다리는 예 while(!ready) { Thread.yield(); } // 일으키는 예 ready = true;
Thread.yield는 락을 해제하지 않으므로 이 코드를 synchronized 내에 쓰면 안됩니다. 또한 ready 필드는 volatile로서 선언될 필요가 있습니다.
spin lock
"회전해서 락한다"는 의미입니다. 조건이 성립되기까지 while 루프에서 "순회하며"기다리는 모습이 표현되고 있습니다. spin lock은 guarded wait와 같은 의미로 사용되는 경우도 있고 busy wait와 같은 의미로 사용되는 경우도 있습니다. 또한 제일 처음에 몇번인가 busy wait에서 기다려도 그 후 guarded wait로 바뀌는 처리라는 의미로 사용되는 경우도 있습니다. 또한 하드웨어 상에서 spin lock 이라고 불리는 것도 있습니다.
polling
"여론 조사를 하다"라는 의미입니다. 어떤 이벤트가 일어나는 것을 반복해서 조사하러 가고 이벤트가 일어나면 그것을 처리하는 방법입니다.
상태를 가지고 있는 개체가 있습니다. 그리고 그 객체는 자기의 상태가 적절한 때만 목적을 처리할 때 쓰레드에 실행시키고 싶어합니다.
그 때문에 우선 객체가 적절한 상태인지를 "가드 조건"으로 표현합니다. 그리고 목적을 처리하기 전에 가드 조건이 만족하고 있는지 테스트합니다. 가드 조건이 만족되어 있을 때만 목적을 처리합니다. 가드 조건이 만족되어 있지 않으면 만족될 때까지 기다립니다.
자바에서는 조건의 테스트에 while문을 사용하며 기다리기 위해 wait 메소드를 사용합니다. 그리고 조건이 변화하면 notify/notifyAll 메소드를 사용해서 통지합니다. 이것이 Guarded Suspension 패턴입니다.
'프로그래밍(TA, AA) > JVM 언어' 카테고리의 다른 글
[자바] Event-Dispatching Thread (0) | 2018.05.09 |
---|---|
[자바] Worker Thread 패턴 (0) | 2018.05.09 |
[자바] 스트림과 병렬 데이터 처리 (0) | 2018.04.28 |
[자바] 람다 표준 API의 함수 인터페이스 (1) | 2018.04.22 |
[자바] 스레드 고급 동기화 (1) (0) | 2018.04.19 |