6. 작업실행
대부분 병렬 어플리케이션은 '작업task'을 실행하는 구조가 효율적으로 구성되어있다.
- 작업: 추상적이면서 명확하게 구분된 업무의 단위
작업은 원론적으로 완전히 독립적인 동작으로 상태,결과, 부수효과 등에 영향을 받지 않아야함
작업을 실행하는 가장 간단한 방법
- 단일 스레드에서 작업 목록을 순차적으로 실행하는 방법
6.1.1 작업을 순차적으로 실행
하나의 스레드가 모든 요청을 순차적으로 처리하는 경우
class SingleThreadWebServer {
public static void main(String[] args) {
ServerSocket socket = new ServerSocket(80);
while (true) {
Socket conn = socket.accept();
handleRequest(conn);
}
}
}
* 실제 적용시 성능이 엄청 떨어짐.
단일 스레드에서 IO작업을 하는동안 CPU가 대기하고 있어야하는 경우가 있고 서버 하드웨어 자원을 제대로 활용하지 못하는 문제가 있다.
6.1.2 작업마다 스레드를 직접 생성
- 요청이 들어올 때마다 새로운 스레드를 하나씩 만들어 생성하게 되는 경우
class ThreadPerTaskWebServer {
public static void main(String[] args) {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket conn = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(conn);
}
};
new Thread(task).start();
}
}
}
클라이언트의 요청 내용 처리를 메인스레드에서 처리하는 것이 아니라 새로운 스레드를 생성하여 처리
- 작업을 처리하는 기능이 메인 스레드에서 떨어져 나옴(클라이언트 접속을 기다리는 부분이 빨라짐)
- 동시에 여러 작업을 병렬로 처리할 수 있기 때문에 두 개 이상의 요청을 받아 동시에 처리가능
- 실제 작업을 처리하는 스레드의 안전성을 확봐해야 한다.
- 클라이언트가 접속 요청을 전송하는 속도에 비해 요청을 처리해 응답을 넘겨주는 속도가 빨라야한다.
6.1.3 스레드를 많이 생성할 때 문제점
1) 스레드 라이프 사이클 문제
- 생성과 제거 작업에도 자원이 소모됨
- 클라이언트의 요청을 처리할 때 딜레이가 생기고 JVM과 운영체제도 기초적입 작업을 수행
2) 자원 낭비
- 스레드는 시스템자원, 특히 메모리를 소모함
- 프로세서보다 많은 스레드를 만들어 동작중이라면 대부분의 스레드는 idle 상태에 머무름
- 많은 스레드는 많은 메모리를 필요로함
- GC에 가해지는 부하가 늘어남
3) 안전성 문제
- 모든 시스템에는 생성할 수 있는 스레드의 개수가 제한되어 있음
- Thread 클래스에 필요한 스택의 크기의 제한된 양을 모두 사용하면 OutOfMemoryError 발생하고 해결방법은 별로 없다.
- OutOfMemoryError이 발생하지 않도록 미연에 방지해야함
6,2 Executor 프레임워크
순차 스레드, 각각의 작업을 스레드에서 담아 처리하는 방법에는 문제점이 있다.
순차적인 방법 : 응답속도와 성능이 크게 떨어짐
작업별로 스레드를 생성하는 방법 : 관리측면에서 허점이 있음Thread Pool은 스레드를 관리하는 측면에서 메모리를 모두 소모해 버리지 않도록 통제력을 가지고 있다.
public interface Executor {
void execute(Runnable command);
}
- Executor은 비동기적 작업 실행 프레임워크의 근간을 이루는 인터페이스
- Task Submission(작업등록) 과 Task Execution(작업실행)을 분리
- 각 작업의 라이프 사이클을 관리와 모니터링하는 기능 제공
- 각 작업은 Runnable 형태로 정의
- 일반적으로 Producer-Consumer 패턴에 기반
6.2.1 ex) Executor를 사용한 웹 서버
class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec = Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket conn = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(conn);
}
};
exec.execute(task);
}
}
}
6.2.2 실행정책
- 작업을 등록하는 부분과 실행하는 부분을 서로 분리시켜두면 실행정책(execution policy)를 쉽게 변경할 수 있다. 실행 정책은 '무엇을, 어디에서, 언제, 어떻게' 실행하는지 지정할 수 있다.
1) 작업을 어느 스레드에서 실행할 것인가?
2) 작업을 어떤 순서로 실행할 것인가?(FIFO, LIFO, 기타 다양한 우선순위 정책)
3) 동시에 몇 개의 작업을 병렬로 실행할 것인가?
4) 최대 몇 개까지의 작업이 큐에서 실행을 대기할 수 있게 할 것인가?
5) 시스템 부하가 많이 걸려서 작업을 거절해야할 경우, 어떤 작업을 희생양으로 삼야아 할 것이며, 작업을 요청한 프로그램에 어떻게 알려야 할 것인가.
6) 작업을 실행하기 직전이나 실행한 직후에 어떤 동작이 있어야 하는가?
* 프로그램 어디에서든 간에
new Thread(runnable).start(); 와 같은 코드가 남아 있다면 이런 부분에 유연한 실행 정책을 적용할 준비를 해야할 것이며, 나중을 위해서 Executor를 사용해 구현하는 방안을 심각하게 고려해봐야 한다.
6.2.3 스레드 풀
- Thread Fool : 스레드 작업을 처리할 수 있는 작업 공간
실행작업
-> 먼저 큐에서 실행할 다음작업을 가져옴
-> 작업을 실행
-> 실행할 다음 작업이 나타날 때까지 대기하는 일을 반복
장점
- 매번 스레드를 생성하는 대신 이전에 사용했던 스래드를 재사용하므로 계속해서 스레드를 생성할 필요 없음
- 여러 개의 요청을 처리하는 데 필요한 시스템 자원이 줄어드는 효과가 있다.
- 크기를 적절히 조절하면 하드웨어 프로세서가 쉬지 않고 동작할 수 있다
- 메모리를 전부 소모하거나 여러 스레드가 한정된 자원을 가지고 서로 경쟁하여 성능을저하는 현상도 없앨 수 있음(메모리가 부족하여 죽는 일이 발생하지 않음)
- 작업별로 스레드를 생성하는 전략(Thread-per-task)에서 풀을 기반으로 하는 전략(pool-based)으로 변경되어 안전성 측면에서 장점을 가지고 있음
자바 스레드풀 메소드
1) newFixedThreadPool
- 처리할 작업이 등록되면 실제 작업할 스레드를 하나씩 생성한다.
- 생성할 수 있는 스레드의 최대 개수는 제한
- 제한된 개수까지 스레드를 생성하고 나면 더 이상 생성하지 않고 스레드 수를 유지
- 스레드가 작업중에 예외가 발생해서 스레드가 종료되면 하나씩 더 생성
2) newCachedThreadPool
- 현재 Pool에 가지고 있는 스레드의 수가 처리할 작업의 수보다 많아서 쉬는 스레드가 많이 발 생하면 쉬는 스레드를 종료시켜 유연하게 대응
- 처리할 작업의 수가 많아지면 필요한 만큼 스레드를 생성
- 스레드의 수에는 제한을 두지 않음
3) newSingleThreadExecutor
- 단일 스레드로 동작하는 Executor로 작업을 처리하는 스레드가 단 하나
- 작업중 Exception이 발생하여 비정상적으로 종료되면 새로운 스레드를 생성하여 나머지 작업 처리
- 등록된 작업은 설정된 큐에서 지정하는 순서에 따라 순차적으로 처리
4) newScheduledThreadPool
- 일정시간 이후에 실행하거나 주기적으로 작업을 실행
- 스레드의 수가 고정되어 있는 Executor.Timer클래스의 기능과 유사
6.2.4 Executor 동작 주기
- Executor를 구현하는 클래스는 대부분 작업을 처리하기 위한 스레드를 생성하도록 되어있음
- JVM은 모든 스레드가 종료되기 전에 종료하지 않고 대기하기 때문에 Executor를 제대로 종료하지 않으면 JVM자체가 종료되지 않고 대기하기도 한다.
- Executor은 비동기적으로 실행되고 작업의 상태를 특정 시점에 정확하게 파악하기는 어려움
그래서ExecutorService 인터페이스에는 동작 주기를 관리하는 여러 메소드가 있다.
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination( long timeout, TimeUnit unit)
throws InterruptedException;
//... 작업을 등록할 수 있는 몇가지 추가 메소드
}
- 내부적으로 ExecutorService가 갖고 있는 동작주기
1) 실행중(Running) : ExecutorService를 처음 생성했을 때
2) 종료 중(shutting down) : 어느시점에 shutdown 메소드를 실행하면 안전한 종료 절차를 진행하기 위해 종료중 상태로 전환(새로운 작업을 등록받지 않고, 이전에 등록되어 있는 작업까지는 모두 끝마칠 수 있다) shutdownNow 메소드는 강제종료를 수행
3) 종료(terminated) : 스레드 종료
- ThreadPoolExecutor : 이미 종료된 절차가 시작되거나 종료된 이후에 새로운 작업을 등록하려하면 실행 거절 핸들러 (reject execution handler)를 통해 오류를 처리
클라이언트 측에서 특정한 형태의 HTTP 요청을 전송하는 방법
public class LifecycleWebServer {
private final ExecutorService exec = Executors.newFixedThreadPool(1);
public void start() throws IOException {
ServerSocket socket = new ServerSocket(80);
while(!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute(new Runnable() {
public void run() {
handlerRequest(conn);
}
});
} catch (Exception e) {
if (!exec.isShutdown()) {
}
}
}
}
public void stop() {
exec.shutdown();
}
6.2.5 지연작업, 주기적 작업
- Timer 클래스는 특정시간 이후에 원하는 작업을 실행하는 지연작업이나 주기적인 작업을 실행할 수 있다. but, Timer 그 자체로는 단점이 있기 때문에 ScheduledThreadPoolExecutor를 사용하는 방법을 추천 (이유 : Timer 클래스는 상대시각과 절대시각도 지원한다. 절대시각을 사용하면 시스템 하드웨어의 시각을 변경시키면 Timer에 스케쥴된 작업도 함께변경됨, 그러나 ScheduledThreadPoolExecutor는 상대시각만 지원)
- 만약 스케줄 방법을 지원하는 스케줄링 서비스를 구현해야 할 필요가 있다면 BlockingQueue를 구현하면서 ScheduledThreadPoolExecutor와 비슷한 기능을 제공하는 DelayQueue 클래스를 사용하는 것을 추천
6.3 병렬로 처리할 만한 작업
6.3.2 결과가 나올때까지 대기 : Callable과 Future
- Runnable은 run메소드의 실행이 끝나면 결과 값을 리턴해 줄 수 없고, 예외가 발생해도 throws 구문으로 표현할 수 없다.
- Callable은 call메소드를 실행하고 결과값을 돌려받을 수 있고 Exception도 발생시키도록 되어 있다.
- Executor에서 실행한 작업은 생성(created), 등록(submitted), 실행(started), 종료(completed) 네 가지 상태를 통과한다.
- Future는 특정 작업이 정상적으로 완료되었는지, 취소되었는지 등에 대한 정보를 확인할 수 있다.
6.3.5 CompletionService : Executor와 BlockingQueue의 연합
- CompletionService는 Executor의 기능과 BlockingQueue의 기능을 하나로 모은 인터페이스이다. 필요한 Callable 작업을 등로해 실행시킬 수 있고, take나 poll과 같은 큐 메소드를 사용해 작업이 완료되는 순간 완료된 작업의 Future 인스턴스를 받아올 수 있다.
public class Renderer {
private final ExecutorService executor;
public Renderer(ExecutorService executor) {
this.executor = executor;
}
void renderPage(CharSequence source) {
final List<ImageInfo> info = scanForImageInfo(source);
CompletionService<ImageData> completionService = new
ExecutorCompletionService<ImageData>(executor);
for (final ImageInfo im : info) {
completionService.submit(new Callable<ImageData>() {
public ImageData call() throws Exception {
return im.downloadImage();
}
});
}
renderText(source);
try {
for (int t = 0, n= info.size(); t<n; t++) {
Future<ImageData> f = completionService.take();
ImageData data = f.get();
renderImage(data);
}
} catch (Exception e) {
Thread.currentThread().interrupt();
}
}
private void renderText(CharSequence source) {
// TODO Auto-generated method stub
}
private List<ImageInfo> scanForImageInfo(CharSequence source) {
// TODO Auto-generated method stub
return null;
}
}
6.3.7 작업실행 제한 시간
- 일정시간 이내에만 작업을 처리하도록 만들고자 할 때 무한정 그 결과를 기다려줄 수 없다. 그래서 타임아웃을 지정할 수 있는 Future.get() 메소드를 사용하여 해결할 수 있다.
'Language > Java' 카테고리의 다른 글
[Java] Lamda Expressions(람다식) (0) | 2023.04.20 |
---|---|
[Java] Generic(제네릭) (0) | 2023.04.20 |
volatile 키워드 (0) | 2017.04.13 |
NIO TCP blocking channel 소켓서버 & 클라이언트 (0) | 2017.03.30 |
비동기 파일채널 (0) | 2017.03.29 |