본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] Worker Thread 패턴

worker라는 것은 "일을 하는 사람", "작업자"라는 의미입니다. Worker Thread 패턴에서는 워커 스레드(Worker Thread: 작업자 쓰레드)가 일을 하나씩 가지러 가서 처리를 합니다. 일이 없으면 워커 쓰레드는 새로운 일이 도착할 때까지 기다립니다. Worker Thread를 Background Thread라고 부르는 일도 있습니다. 또한 워커 쓰레드를 여러 개 보유하고 있는 장소에 주목하여 Thread Pool 이라고 부르기도 합니다.


ClientThread 클래스의 쓰레드가 Channel 클래스에 일의 request(의뢰)합니다. Channel 클래스의 인스턴스는 워커 쓰레드(WorkerThread) 다섯 명을 맡고 있습니다. 워커 쓰레드는 모두 일의 리퀘스트가 오기를 기다리고 있습니다.


일의 리퀘스트가 오면 워커 쓰레드는 Channel에서 일의 리퀘스트를 한 개 얻어서 처리합니다. 처리가 끝난 워커 쓰레드는 Channel로 돌아와서 "다음 리퀘스트는 언제 올까"하고 기다립니다.


 이름

 해설

 Main

 동작 테스트용 클래스

 ClientThread

 일의 리퀘스트를 내는 쓰레드를 나타내는 클래스

 Request

 일의 리퀘스트를 내는 클래스

 Channel

 일의 리퀘스트를 받아 들여 워커 쓰레드에 전달하는 클래스

 WorkerThread

 워커 쓰레드를 나타내는 클래스





ClientThread

1. 일을 Request의 인스턴스라는 형태로 표현한다.

2. putRequest하는 것이 일의 리퀘스트를 내는 것에 해당한다.

3. putRequest하면 일이 실행되는 것을 기다리지 않고 바로 돌아간다.


WorkerThread

1. Request를 취하려고 했지만 도착해 있지 않아 기다린다.

2. Request를 취할 수 있게 되었다.

3. execute를 호출하는 것이 일의 실행에 해당한다.




1. Main 클래스

Main 클래스는 다섯명의 워커 쓰레드를 가지고 있는 Channel 인스턴스를 하나 만듭니다. 그리고 그것을 세 명의 ClientThread의 인스턴스(Alice, Bobby, Chris)와 공유 시킵니다.

public class Main {
	public static void main(String[] args) {
		Channel channel = new Channel(5);	// 워커 쓰레드의 개수
		channel.startWorkers();
		new ClientThread("Alice", channel).start();
		new ClientThread("Bobby", channel).start();
		new ClientThread("Chris", channel).start();
	}
}


2. ClientThread 클래스

ClientThread 클래스는 일의 리퀘스트를 내는 클래스입니다. "일의 리퀘스트를 낸다"는 행위는 이 예제 프로그램에서는 "Request의 인스턴스를 만든다", "그 인스턴스를 Channel 클래스의 putRequest 메소드에 건네준다"라는 처리입니다. 움직임에 변화를 주기 위해서 난수를 사용해서 sleep하고 있습니다.

import java.util.Random;

public class ClientThread extends Thread {
	private final Channel channel;
	private static final Random random = new Random();
	public ClientThread(String name, Channel channel) {
		super(name);
		this.channel = channel;
	}
	public void run() {
		try {
			for (int i = 0; true; i++) {
				Request request = new Request(getName(), i);
				channel.putRequest(request);
				Thread.sleep(random.nextInt(1000));
			}
		} catch (InterruptedException e) {
		}
	}
}


3. Request 클래스

Request 클래스는 일의 리퀘스트를 나타내는 클래스입니다. execute 메소드는 이 리퀘스트의 "처리"를 기술하고 있는 메소드입니다. 처리라고 해도 실제로 하고 있는 일은 실행하고 있는 메소드의 이름과 리퀘스트 내용(의뢰자 이름과 번호)의 표시뿐입니다. "처리"에 시간이 걸리는 것을 표현하기 위해서 sleep하고 있습니다.

import java.util.Random;

public class Request {
	private final String name;	// 의뢰자
	private final int number;	// 리퀘스트 번호
	private static final Rnadom random = new Random();
	public Request(String name, int number) {
		this.name = name;
		this.number = number;
	}
	public void execute() {
		System.out.println(Thread.currentThread().getName() + " executes " + this);
		try {
			Thread.sleep(random.nextInt(1000));
		} catch (InterruptedException e) {
		}
	}
	public String toString() {
		return "[ Request from " + name + " No." + number + " ]";
	}
}


4. Channel 클래스

Channel 클래스는 일의 리퀘스트를 주고받는 것과 워커 쓰레드의 보유를 위한 클래스입니다.


Channel 클래스는 일의 리퀘스트를 주고받기 위해서 requestQueue라는 필드를 가지고 있습니다. 이 필드는 리퀘스트를 보유해 두는 큐의 역할을 합니다. 큐에 넣은 것은 putRequest 메소드이고 큐에서 꺼내는 것은 takeRequest 메소드입니다. takeRequest를 실현하기 위해 Guarded Suspension 패턴이 쓰이고 있습니다.


Channel 클래스는 워커 쓰레드의 보유를 위해서 threadPool이라는 필드를 가지고 있습니다. threadPool은 WorkerThread의 배열입니다. 생성자 중에서는 이 threadPool를 초기화해서 WorkerThread의 인스턴스를 생성하고 있습니다. 배열의 크기는 MAX_REQUEST로 정합니다.


워커 쓰레드에는 Worker-0, Worker-1, Worker-2 ... 라는 이름을 붙이고 있습니다. startWorkers 메소드는 워커 쓰레드를 모두 기동하기 위한 메소드입니다.

public class Channel {
	private static final int MAX_REQUEST = 100;
	private final Request[] requestQueue;
	private int tail;	// 다음에 putRequest하는 장소
	private int head;	// 다음에 takeRequest하는 장소
	private int count;	// Request의 수

	private final WorkerThread[] threadPool;

	public Channel(int threads) {
		this.requestQueue = new Request[MAX_REQUEST];
		this.head = 0;
		this.tail = 0;
		this.count = 0;

		threadPool = new WorkerThread[threads];
		for (int i = 0; i < threadPool.length; i++) {
			threadPool[i] = new WorkerThread("Worker-" + i, this);
		}
	}
	public void startWorkers() {
		for (int i = 0; i < threadPool.length; i++) {
			threadPool[i].start();
		}
	}
	public synchronized void putRequest(Request request) {
		while (count >= requestQueue.length) {
			try {
				wait();
			} catch (InterruptedException e) {
			}
		}
		requestQueue[tail] = request;
		tail = (tail + 1) % requestQueue.length;
		count++;
		notifyAll();
	}
	public synchronized Request takeRequest() {
		while (count <= 0) {
			try {
				wait();
			} catch (InterruptedException e) {
			}
		}
		Request request = requestQueue[head];
		head = (head + 1) % requestQueue.length;
		count--;
		notifyAll();
		return request;
	}
}


5. WorkerThread 클래스

WorkerThread 클래스는 워커 쓰레드를 나타내느 클래스입니다. 워커 쓰레드는 일을 실행합니다. "일을 실행한다'는 것은 이 예제 프로그램에서는 다음 처리를 뜻합니다.


Channel의 인스턴스에서 takeRequest 메소드를 사용해서 request의 인스턴스를 하나 받는다.

▽▽▽▽▽▽

그 인스턴스의 execute 메소드를 호출한다.


워커 쓰레드는 한번 기동하면 영원히 일을 실행해 나갑니다. 즉 "새로운 Request의 인스턴스를 하나 받아서 execute 메소드를 호출한다"라는 처리를 반복하는 것입니다.


Thread-Per-Message 패턴에서는 일을 실행할 때마다 새로운 쓰레드를 기동하고 있습니다. 그러나 Worker Thread 패턴에서는 워커 쓰레드가 반복해서 일을 실행하고 있으므로 새로운 쓰레드를 기동할 필요는 없습니다.


WorkerThread가 갖고 있는 필드는 일의 리퀘스트를 얻기 위한 Channel의 인스턴스(channel)뿐입니다. WorkerThread는 리퀘스트의 구체적 내용은 어느것도 알지 못합니다. WorkerThread가 알고 있는 것은 "Request 클래스는 execute를 갖고 있다"라는 것뿐입니다.

public class WorkerThread extends Thread {
	private final Channel channel;
	public WorkerThread(String name, Channel channel) {
		super(name);
		this.channel = channel;
	}
	public void run() {
		while(true) {
			Request request = channel.takeRequest();
			request.execute();
		}
	}
}

예제 프로그램을 실행하면 Worker-0~Worker-4라는 다섯명의 WorkerThread가 Alice, Bobby, Chris라는 세 사람의 ClientThread가 보낸 리퀘스트를 실행하고 있는 것을 알 수 있을 것입니다.


리퀘스트를 내고 있는 ClientThread와 리퀘스트를 실행하고 있는 WorkerThread와의 사이에 고정적인 관계는 없습니다. Alice의 리퀘스트는 No.0은 Worker-0이 실행했지만 Alice에 의한 다음의 리퀘스트 No.1은 Worker-3이 실행하고 다음의 리퀘스트 No.2는 Worker-2가 실행하고 있습니다.


워커 쓰레드는 그 리퀘스트를 누가 발행했는지 생각하지 않고 받아들여 그저 리퀘스트를 실행할 뿐입니다.




Client(의뢰자)

Client는 일의 리퀘스트를 Request로서 작성하여 Channel에 보냅니다. 예제 프로그램에서는 ClientThread 클래스가 이 역할을 하고 있습니다.


Channel(통신로)

Channel은 Client에서 Request를 받아 들여 Worker에 보냅니다. 예제 프로그램에서는 Channel 클래스가 이 역할을 하고 있습니다.


Worker(작업자)

Worker는 Channel에서 Request를 받아 그 일을 실행합니다. 일이 끝나면 다음의 Request를 받으러 갑니다. 예제 프로그램에서는 WorkerThread 클래스가 이 역할을 합니다.


Request(의뢰)

Request는 일을 나타내기 위한 것입니다. Request는 그 일을 실행하는 것에 필요한 정보를 보유하고 있습니다. 예제 프로그램에서는 Request 클래스가 이 역할을 합니다.




자기 일을 다른 사람에게 맡길 수 있다면 다음 일을 할 수가 있습니다. 쓰레드도 마찬가지입니다. 다른 쓰레드에 일을 맡기는 것이 가능해지면 자신은 다음 일을 할 수 있습니다. 이것이 Thread-Per-Message 패턴의 주제입니다.


그러나 쓰레드를 새롭게 기동한다는 것은 시간이 걸리는 처리입니다. 그러므로 Worker Thread 패턴에서는 쓰레드 재활용은 매우 중요합니다.



용량의 제어


Worker Thread에는 테마가 하나 더 있습니다. 그것은 capacity(용량, 동시에 제공할 수 있는 서비스의 양)의 제어입니다.


Worker의 수

예제 프로그램을 봐도 알 수 있듯이 Worker의 수는 자유롭게 정할 수 있습니다. 예제 프로그램에서는 Channel의 생성자에 주는 인수 threads가 이에 해당합니다. WorkerThread의 인스턴스는 Threads개 만들 수 있습니다.


Woker의 수가 많으면 그만큼 병행해서 일을 실행할 수 있습니다. 그러나 동시에 리퀘스트되는 일의 수보다도 많은 Worker가 있어도 도움이 되지 않습니다. 넘치는 Worker는 일을 하지 않고 메모리를 점유하고 있을 뿐이기 때문입니다. 용량을 높이려고 한다면 소비하는 리소스도 늘립니다. 실제로 프로그램이 동작하는 환경에 따라서 Worker의 수를 조정할 필요가 있습니다.


Worker의 수는 항상 기동 시에 정해 두어야 할 필요는 없습니다. 다음과 같이 동적으로 변화시킬 수도 있습니다.


 - 처음에는 몇 개의 Worker로 시작한다.

 - 일이 늘어나는데 맞추어서 Worker를 늘려간다.

 - 그러나 지나치게 늘리면 메모리를 차지하고 있을 뿐이므로 어느 상한 이상은 늘리지 않는다.

 - 반대로 일이 줄어들면(즉 대기하고 있는 Worker가 늘어나면) 조금씩 Worker를 줄여간다.



Request의 수

Channel은 Request를 보유하고 있습니다. Worker가 능숙히 일을 처리해 주면 Channel에 "머물러 있는" Request는 그렇게 많이 늘어나지는 않습니다. 그러나 Worker들의 처리 능력을 넘는 일이 들어오는 경우에는 Channel이 Request로 가득찹니다. 가득 차면 새로운 Request를 Channel로 보내려고 한 Client는 기다리게 됩니다. 예제 프로그램으로 이야기하자면 Channel 클래스의 putRequest 중에 쓰레드가 wait하는 것이 됩니다.


Channel이 보유해 둘 수 있는 Request의 수가 많으면 Client와 Worker의 처리 속도와 차이를 극복할 수 있습니다. 그러나 한편으로 Request를 보유하기 위해 대량의 메모리를 소비하게 됩니다. 여기에서는 용량과 리소스의 트레드오프가 있는 것을 알 수 있습니다. 이 부분은 Producer-Consumer 패턴에서도 나타나는 이슈입니다.



invocation과 execution의 분리


Worker Thread 패턴에 있어서 "일의 리퀘스트"를 "통상의 메소드 호출"과 대조시켜 생각해 봅시다.


Client는 일의 리퀘스트를 합니다. 일의 내용을 Request라고 하는 형태로 정리하여 Channel에 건네줍니다. 이 부분은 통상의 메소드 호출로 이야기하면 "인수를 평가해서 메소드를 기동한다"라는 부분입니다. 대체로 "인수의 평가" 부분이 "Request의 작성"이고 "Channel에 건네주는" 부분이 "메소드의 기동"입니다.


한편 Worker는 일을 실행합니다. Channel에서 받은 Request를 사용해서 실제 처리를 합니다. 이부분을 통상의 메소드 호출로 말하자면 "메소드의 실행"에 해당합니다.

통상 메소드 호출을 할 때 "메소드의 기동"과 "메소드의 실행"은 계속해서 일어납니다. 메소드를 기동하면 그 메소드는 곧바로 실행된다는 것입니다. 통상 메소드 호출에서는 기동과 실행은 불가분한 것입니다.


그러나 Worker Thread 패턴이나 Thread-Per-Message 패턴에서는 의식적으로 메소드의 기동과 메소드의 실행을 분리합니다. 메소드를 기동하는 것을 invocation이라고 하며 메소드를 실행하는 것을 execution이라고 합니다. Work Thread 패턴이나 Thread-Per-Message 패턴은 메소드의 invocation과 execution을 분리하고 있다고 해도 좋습니다. invocation과 execution의 분리는 Command 패턴의 주제이기도합니다.


그러면 invocation과 execution을 분리하는 것은 어떤 의미가 있는지 생각해보겠습니다.


응답성의 향상

invocation과 execution을 분리하지 않으면 execution에 시간이 걸리는 경우 invocation의 처리까지 늦어지게 됩니다. 그러나 invocation과 execution을 분리해 두면 execution하는 쪽이 시간이 걸려도 invocation을 하는 쪽이 먼저 다음 단계로 나갈 수 있습니다. 이것은 응답성의 향상으로 이어집니다.


실행 순서의 제어

invocation과 execution을 분리하지 않으면 invoke하자마자 execute해야 합니다. 그러나 invocation과 execution을 분리해두면 invocke한 순서와는 관계없이 execute할 수 있습니다. 이것은 Request에 대해서 우선 순위를 설정하고 Channel이 Worker에 Request를 건네주는 순서를 제어하면 실현됩니다.


취소 가능, 반복 실행 가능

invocation과 execution이 분리되어 있으면 "invocation은 일어나지만 execution은 취소한다"라는 기능을 실현할 수도 있게됩니다. invoke한 결과는 Request라는 객체가 되어 있으므로 그 Request를 보존해 두는 것도 반복해서 execute할 수도 있게 됩니다.


분산 처리의 길

invocation과 execution이 분리되어 있으면 invoke하는 컴퓨터와 execute하는 컴퓨터를 나누기 쉽게 됩니다. Request에 해당하는 개체를 네트워크를 사용해서 다른 컴퓨터에 보내는 것입니다.



Runnable 인터페이스의 의미


java.lang.Runnable 인터페이스는 Worker Thread 패턴의 Request로서 사용되는 일이 있습니다. 이를테면 일의 내용을 표현하는 객체로서 Runnable을 구현한 클래스의 인스턴스를 만들어 그것을 Channel에게 건네주는 것입니다. 


우리는 "쓰레드를 기동한다"는 부분에서 Runnable 인터페이스를 사용하는 방법을 배웠습니다. Thread 클래스의 생성자에 Runnable 객체를 부여하면 기동한 쓰레드는 Runnable 객체의 run 메소드를 호출합니다. 그러나 Runnable 인터페이스의 사용법은 그것만이 아닙니다.


Runnable 객체는 메소드의 인수로서 건네진다든지, 큐에 들어간다든지, 네트워크로 건네진다든지, 파일에 보존한다든지 하는 일이 가능합니다. 그리고 그러한 Runnable 객체는 어떤 컴퓨터에 도착되고 어떤 쓰레드에 건네지며 거기에 비로소 실행되게 됩니다.


이경우 Runnable 인터페이스는 GoF의 Command 패턴에 대한 Command로 간주할 수 있는 것입니다.



다형적인 Request


예제에서 ClientThread가 Channel에 건네지는 것은 Request의 인스턴스뿐이었습니다. 그러나 WorkerThread 클래스의 세부 사항은 알지 못합니다. WorkerThread는 간단히 Request의 인스턴스를 받아서 그 execute 메소드를 실행할 뿐입니다.


이러한 것은 예를 들면 Request 클래스의 서브 클래스를 만들어 그 인스턴스를 Channel에 건네주었다 해도 WorkerThread는 어떤 문제도 없이 그 인스턴스의 execute를 불러 주게 됩니다. Request 클래스는 일을 표현한 것이지만 Request 클래스의 서브 클래스를 새롭게 만든다는 것은 일의 종류를 늘리는 것이됩니다.


일을 실행하는 것에 필요한 정보는 Request에 모두 기술되어 있습니다. 그러므로 다형적인 Request를 만들어 일의 종류를 늘려도 Channel이나 Worker 쪽은 수정할 필요가 없습니다. 일의 종류가 늘어도 Worker는 변함없이 Request의 execute 메소드를 부를 뿐이기 때문입니다. 



한 명의 Worker


Worker가 딱 한 명이라는 상상을 해봅시다. Producer-Consumer 패턴에서도 생각했던 것 같이 워커 쓰레드를 한 명으로 해 두면 워커 쓰레드가 하는 처리의 범위는 싱글 쓰레드가 되므로 배타 제어를 생략할 수 있는 가능성이 있습니다.