본문 바로가기
공부(~2016)/멀티쓰레드

[멀티쓰레드패턴] #3. Guarded Suspension 패턴

by soy; 2015. 1. 5.

이 내용은 "Java 언어로 배우는 디자인 패턴 입문 - 멀티쓰레드편" 책의 Chapter 03 부분을 정리한 것이다.


Guarded Suspension 패턴

- guarded : 보호받고 있는, suspension : 일시정지

- 지금 이 처리를 실행하면 안 될 때, 처리하기 직전에 쓰레드를 기다리게 하는 패턴. 쓰레드를 기다리게 하여 인스턴스의 안전성을 보호함.


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 + " ]";
    }
}

 Request 객체.


import java.util.Queue;
import java.util.LinkedList;
 
public class RequestQueue {
    private final Queue<Request> queue = new LinkedList<Request>();
    public synchronized Request getRequest() {
        while (queue.peek() == null) {
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }
        return queue.remove();
    }
    public synchronized void putRequest(Request request) {
        queue.offer(request);
        notifyAll();
    }
}

Request 객체를 보관하는 Queue를 구현한 RequestQueue 클래스.

- 일반적인 Queue(FIFO)의 작동 방식과 같다.

- getRequest 메소드와 putRequest 메소드는 queue 필드를 보호하기 위하여 synchronized로 선언되었다. (Single Threaded Execution 패턴)



참고사항: Queue / LinkedList의 작동

위의 코드에서, Queue queue 필드에 저장되는 것은 LinkedList 클래스의 인스턴스임

- java.util.Queue<E>는 인터페이스임. 이를 implements한 서브클래스 중 하나가 LinkedList 클래스임.

- java.util.LinkedList 클래스는 thread-safety 하지 않음. (ava,util.concurrent.LinkedBlockingQueue는 thread-safety)

- E remove() 메소드는 Queue의 맨 앞에서 element 하나를 제거하고, 그 것을 반환한다. 만약 Queue가 비어있다면 java.util.NoSuchElementException을 발생시킨다.

- boolean offer(E e) 메소드는 Queue의 맨 뒤에 인자로 받은 element를 삽입한다.

- E peek() 메소드는, Queue가 비어있지 않다면 맨 앞의 element를 반환하고, 비어있다면 null을 반환한다. 이 메소드는 element를 Queue에서 제거하지는 않는다.


import java.util.Random;
 
public class ClientThread extends Thread {
    private final Random random;
    private final 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++) {
            Request 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) {
            }
        }
    }
}

ClientThread 클래스는 Request를 생성하여 RequestQueue에 넣는 일을 한다.

1) RequestQueue 인스턴스를 필드로 가짐.

2) Request 객체를 생성하여 RequestQueue에 삽입함 (putRequest 메소드 호출).

3) 삽입이 완료되면 0~1000밀리초 동안 sleep함.

4) 2~4 과정을 반복함.


import java.util.Random;
 
public class ServerThread extends Thread {
    private final Random random;
    private final 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) {
            }
        }
    }
}

ServerThread 클래스는 RequestQueue에서 Request를 꺼내는 일을 한다.

1) RequestQueue 인스턴스를 필드로 가짐.

2) RequestQueue에서 Request 객체를 하나 꺼내옴 (getRequest 메소드 호출)

3) 꺼내기가 완료되면 해당 Request 객체의 정보를 출력함. => 이 과정을 Request를 처리하는 것으로 보면 됌.

4) 출력이 완료되면 0~1000밀리초 동안 sleep함.

5) 2~5 과정을 반복함.


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();
    }
}

메인메소드. RequestQueue 인스턴스를 생성하고, 두 쓰레드를 각각 생성 및 기동함. 특별한 건 없음~!



이 패턴의 핵심은 RequestQueue 클래스이다.

Request Queue 클래스의 getRequest() 메소드를 좀 더 살펴보자.


    public synchronized Request getRequest() {
        while (queue.peek() == null) {
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }
        return queue.remove();
    }

getRequest() 메소드의 목적은 "Queue의 맨 앞에 있는 Request 객체를 꺼내서 반환" 하는 것이다.

- 이를 목적을 안전하게 실행하려면 Queue가 빈 상태가 아니여야 한다. 따라서 (queue.peek() != null) 을 만족해야 한다.

- 이렇게, 목적하는 처리를 실행하기 직전에 반드시 충족되어 있어야 하는 조건을 Guarded Suspension 패턴의 '가드 조건'이라고 부른다. (좀 더 일반화하면, pre-condition.. 사전조건.)

- getRequest() 메소드의 while문 조건식 (queue.peek() == null) 을 보면 가드 조건의 논리부정이다. 이 while문은 8 line의 queue.remove();가 호출될 때에는 반드시 가드조건이 충족된 상태라는 것을 보장한다.


경우 1) 쓰레드가 while문 조건식에 도착했을 때 가드조건이 충족되어있는 경우 - while문에 들어가지 않고 바로 목적을 실행

경우 2) 쓰레드가 while문 조건식에 도착했을 때 가드조건이 충족되어있지 않은 경우 - while문에 들어가서 wait() 함


경우 2에 해당되어서 wait 하고 있는 쓰레드는, 자신이 notify 되기를 기다린다.

notify 되기를 기다린다는 것은 인스턴스의 상태 변화를 기다린다는 의미이고, 가드조건이 충족되기를 기다린다는 것이다. (당연한 이야기이지만... '쓰레드가 wait하고 있는 이유'를 정확히 이해한다면 언제 notify하면 좋을지를 판단하기 좋다.)


그러면, RequestQueue 클래스의 putRequest 메소드를 살펴보자. 

    public synchronized void putRequest(Request request) {
        queue.offer(request);
        notifyAll();
    }


putRequest 메소드의 목적은 "인자로 받은 Request 객체를 Queue의 맨 뒤에 삽입" 하는 것이다.

- 이 목적을 안전하게 실행하려면 필요한 가드조건은 특별히 없다. 따라서 목적하는 처리를 곧바로 실행한다.

- 3 line의 queue.offer(request); 실행이 완료되면, 이 시점에서는 Queue 안에 꺼낼 수 있는 elements가 적어도 한 개는 존재하게 된다. 다시 말해 getRequest() 메소드의 가드조건인(queue.peek() != null) 은 참이 된다. 이 때 notifyAll()을 호출함으로써 가드조건이 충족되기를 기다리는 쓰레드들을 깨워준다.


정리하자면,

getRequest() 메소드는 '가드조건을 충족'해야만 처리를 수행할 수 있는 메소드이다. 가드조건을 충족하지 않으면 wait() 한다.

putRequest() 메소드는 '가드조건의 상태를 변경' 해주는 메소드이다. 가드조건의 상태가 변경되면 notifyAll() 한다. (엄밀히 말하면, '가드조건의 상태를 확실하게 변경'해준다고 하기 보다는, '가드조건의 상태가 변경될 가능성이 있도록 인스턴스의 상태를 변경'해준다고 보는게 맞겠다.)

이 두 메소드는 반드시 synchronized 처리 되어야 한다.

 


조건부 synchronized

Single Threaded Execution 패턴에서는 한 개의 쓰레드라도 크리티컬 섹션 안에 있으면 다른 쓰레드는 크리티컬 섹션에 들어가지 못하고 대기한다.

Guarded Suspension 패턴에서는 쓰레드의 대기 여부가 가드조건에 의해 결정된다. 즉, Single Threaded Execution 패턴에 조건을 부가한 것으로서 '조건부 synchronized'라고 생각할 수 있다.



멀티쓰레드판 if문

싱글쓰레드 프로그램에서는 Guarded Suspension 패턴이 필요 없다. (만약 유일한 쓰레드가 wait 상태가 되버리면..? 가드조건의 상태를 변경해줄 쓰레드 자체가 존재하지 않기 때문에 깨어나지 못한다.)

싱글쓰레드 프로그램에서 가드조건의 테스트는 if문으로 충분하다. 따라서 Guarded Suspension 패턴은 '멀티쓰레드판 if문'이라고 생각할 수 있다.



재사용성

위의 예제에서 wait()와 notifyAll()은 RequestQueue 클래스에서만 등장한다. 즉, Guarded Suspension 패턴의 구현은 RequestQueue 클래스 안에 갇혀있다.

wait/notify가 감추어져 있다는 것은 RequestQueue 클래스의 재사용성 관점에서 중요하다. RequestQueue를 이용하는 측은 wait/notify를 신경쓸 필요가 없이 단지 getRequest 메소드와 putRequest 메소드를 호출하기만 하면 되기 때문이다.



Guarded Suspension 패턴의 다른 이름들

Guarded Suspension 패턴과 유사한 처리에는 다양한 이름이 붙는다. 이들의 공통된 특징은 다음과 같고, 이름/문맥에 따라 구현 방법이 달라질 수 있다. 

- 1) 루프(반복)이 있다.

- 2) 조건 테스트가 있다.

- 3) 어떠한 의미에서든 '기다린다'


guarded suspension

- "가드되어 있는 실행을 일시중단한다"는 의미이다.


guarded wait

- "가드상태에서 기다린다"는 의미이다. 쓰레드가 wait하여 기다리고, 이후에 notify/notifyAll 되었을 때 조건을 다시 테스트한다. 쓰레드가 wait하여 기다리는 동안은 실행을 중단하기 때문에, 처리 시간을 낭비하는 일이 없다.

- 예제에서 사용된 구현방법과 같다.  while(!ready) {   wait();   }


busy wait

- "바쁘게 기다린다"는 의미이다. 쓰레드가 wait하여 기다리는 것이 아니라, yield(다른 쓰레드에게 우선권을 넘김)하면서 조건을 테스트한다. 기다리는 쪽의 쓰레드도 계속 동작을 하고 있기 때문에 처리 시간을 낭비하게 된다.

- Thread.yield 메소드는 락을 해제하지 않기 때문에, 이 코드를 synchronized 안에 써서는 안된다. 또한 조건에 해당하는 필드는 volatile로 선언해야 한다.

- while(!ready);


spin lock

- "돌면서 락을 한다"는 의미이다. 조건이 충족될때까지 while 루프를 돌면서 기다린다. spin lock은 guarded wait, busy wait와 같은 의미로 사용되기도 한다. 또한 처음 몇 번은 busy wait로 기다리다가 그 후에는 guarded wait로 변환되는 처리라는 의미로 사용되기도 한다.

- 엄밀하게 말하자면, spin lock이 busy wait 개념을 사용한 것이다.


polling

- "조사를 한다"는 의미이다. 어떤 이벤트가 발생하는지 반복적으로 조사하며, 이벤트가 일어난 경우 그것을 처리하는 방법이다.



java.util.concurrent.LinkedBlockingQueue

java.util.concurrent.LinkedBlockingQueue 클래스는 java.util.concurrent.BlockingQueue 인터페이스를 구현하고 있으며, BlockingQueue 인터페이스는 Queue 인터페이스를 상속받고 있다.

LinkedBlockingQueue를 사용하면 위에서 사용한 예제 프로그램의 RequestQueue 클래스를 간단히 만들 수 있다.

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
 
public class RequestQueue {
 
    private final BlockingQueue queue = new LinkedBlockingQueue();
 
    public Request getRequest() {
        Request req = null;
        try {
            req = queue.take();
        } catch (InterruptedException e) {
        }
        return req;
    }
 
    public void putRequest(Request request) {
        try {
            queue.put(request);
        } catch (InterruptedException e) {
        }
    }
}

BlockingQueue의 take 메소드는 Queue의 맨 앞에서 element 하나를 꺼낸다.Queue가 비어있을 때 take 메소드를 호출하면 wait 된다.

BlockingQueue의 put 메소드는 Queue의 맨 뒤에 element를 삽입한다.


take 메소드와 put 메소드가 mutual exclusion을 고려하고 있으므로, getRequest 메소드와 putRequest 메소드를 synchronized로 지정할 필요가 없다. LinkedBlockingQueue 클래스는 내부적으로 Guarded Suspension 패턴을 사용하여 thread-safety함을 보장한다.



관련 패턴

Single Threaded Execution 패턴

- Guarded Suspension 패턴에서 '가드조건을 충족하면 처리를 수행하는 메소드'와 '가드조건의 상태를 변경해주는 메소드'는 synchronized로 선언된다.


Balking 패턴

- Guarded Suspension 패턴에서 쓰레드는 가드조건이 충족될때까지 기다린다.

- Balking 패턴에서 쓰레드는 가드조건이 충족되는 것을 기다리지 않고 돌아간다.


Producer-Consumer 패턴

- Producer-Consumer 패턴에서는 Producer가 데이터를 생산할 때와 Consumer가 데이터를 소비할 때 Guarded Suspension 패턴이 사용된다.


Future 패턴

- Future 패턴에서는 원하는 정보를 취하려 했을 때 아직 준비가 안된 상태라면 Guarded Suspension 패턴을 사용하여 기다린다.

 


댓글