태그 보관물: CountDownLatch

자바로 다중 작업(Multi Task)을 효과적으로 처리하는 방법들

자바로 다중 작업(Multi Task)을 효과적으로 처리하는 방법을 살펴보자. 자바에서 다중 작업을 처리하는데 기본으로 Thread 클래스와 Runnable 인터페이스를 사용한다. 하지만 이 Thread나 Runnable의 경우에는 병행처리를 하는 데 필요한 태스크 관리와 태스크의 분리(Divide)와 처리를 직접 구현해야 하는 단점이 있다. 그래서 자바 java.util.concurrent 패키지에는 다중 작업을 효과적으로 처리할 방법을 여러 가지 제공한다.

1. Executor 프레임웍

이 프레임웍은 처리해야 할 작업을 프로듀서-컨슈머 패턴의 큐에 저장하고, N개의 스레드를 사용해서 병행으로 처리하는 프레임웍이다. 일반적으로 프로듀스는 큐에 작업을 추가하고, 스레드 풀(컨슈머 스레드)에서 작업을 처리한다.

2. 카운트다운래치(CountDownLatch) & 사이클릭배리어(CyclicBarrier)

카운트다운래치(CountDownLatch)와 사이클릭배리어(CyclicBarrier)는 둘 다 큰 작업을 작은 작업으로 분리(개별 구현)하고, 이 분리한 작업의 실행을 완료한 뒤에 흐름을 진행하도록 흐름을 동기화시키는 클래스이다.
그림에서 보듯이, 카운트다운래치는 메인 스레드에서 작업을 처리하는 실행하는 스레드를 만들고 실행한다. 그리고 개별 스레드가 카운트다운래치의 값을 하나씩 제거하면, 메인 스레드가 시그널을 받아서 흐름을 진행하는 형태이다.

사이클릭배리어는 두 스레드 사이에 로직을 동기화하는 데 사용한다. 그래서 배리어를 사용해서 N개의 스레드 실행 흐름을 중단하다가, 배리어액션(단계를 지날 수 있는 과정)을 완료하면 N개의 스레드 실행 결과를 병합할 수 있는 형태이다. 그리고, 두 개의 스레드가 데이터를 교환하는데 사용하는 Exchanger는 사이클릭배리어의 특정 예이다.

3. ForkJoin 프레임웍

포크조인(ForkJoin) 프레임웍은 작업을 분리(분리하기 쉽게 선언)하고, 실행하고, 그리고 결과를 병합하기 쉽게 도와준다. 포크(Fork)는 작업을 분리하고, 조인(Join)은 분리한 작업이 완료한 뒤에 결과를 병합하는 과정이다. 이 프레임웍에 대한 자세한 내용은 http://www.oracle.com/technetwork/articles/java/fork-join-422606.html 에서 살펴보자.

이 그림에서 보듯이, 작업은 내부에서 fork()로 작업을 분리하고, join()으로 태스크를 병합한다. 그리고 이 작업은 재귀(Recursive)로 작업을 분리하는 형태라서 RecursiveTask 클래스를 상속해서 구현한다.

이제 어느 상황에서 이런 다중 작업을 처리하는 것이 좋은지 살펴보자.

1. 작업이 분리되지 않는 경우 “Executor”를 사용하자.

– 가장 많이 사용하는 형태이다.

2. 작업이 분리되는 경우에는 “카운트다운래치” 또는 “포크조인”을 사용하자.

– 많은 양의 데이터 정렬, 최대, 최소값 찾는 경우.
– 많은 양의 데이터를 합산하는 경우.
– 네트웍을 스캔하는 경우.
– 기타
개인적으로는 카운트다운래치를 많이 사용했지만, 작업 분리는 동일하지만 스레드 관리가 불편하다. 그래서 포크조인을 사용하는 것이 좋겠다.

3. 작업이 분리될 수도 있고 아닐 수도 있는 단계가 있는 작업은 “사이클릭배리어”를 사용하자.

– 예를 들어서 게임의 단계를 배리어도 두고 여러 스레드의 작업을 제어할 수 있다. 1단계를 지나면서, 랭킹정보, 개인정보, 게임정보등등을 가져오는 스레드를 실행하고, 필요한 정보를 다 가져온 뒤에 화면을 넘기는 예로 사용할 수 있겠다.

자바 CountDownLatch를 이용한 동시성 높이기..

각종 환경에서 성능을 높이기 위해서 취하는 방법이 멀티 프로세스와 멀티 쓰레드이다. 자바에서는 성능을 높이기 위해서 기본적으로 멀티 쓰레드의 형태를 취하고 있다. 물론, JNI를 사용해서 Native의 프로세스를 띄울 수 있기도 하다. 자바의 기본 패키지중에서 쓰레드를 사용해서 동시성을 극대화 시키는 각종 자료구조와 클래스들은 java.uti.concurrent 패키지를 확인해 보면 알 수 있다.

수학적인 계산을 제외한, 거의 모든 컴퓨터 성능이슈는 느린 I/O 처리를 어떻게 빠르게 처리하냐가 주된 관심사가 아닐까 한다. 

그래서, 많이 쓰이는 쓰레드의 형태를 살펴보면..
1. 잡을 개별 쓰레드로 위임한다.
2. 잡을 개별 쓰레드가 처리하고 결과를 취합해서, 취합된 결과를 기준으로 처리를 한다.

보통은, 위의 1.번으로 처리를 하는 경우가 많지만, 간혹, 2.번의 형태가 필요한 경우가 발생한다. 이 경우에 보통은 메인 쓰레드에서 잡 쓰레드를 생성하고, 자신은 wait하고 있다가 notify를 받는 구조를 많이 취하게 된다. 바로 이런 형태로 2.번의 상황을 처리할 수 있게 해 주는 클래스가 CountDownLatch이다.

자, 간단하게 예제를 살펴보자.

package net.sjava.example.countdownlatch;

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
	static final int max = 10;
	/**
	 * 단일 쓰레드 테스트
	 */
	public static void testSingle() throws Exception {
		long start = System.currentTimeMillis();
		for (long i = 0; i < max; i++) {
			Thread.sleep(1000);
		}

		long elapsedTime = System.currentTimeMillis() - start;
		System.out.println("testSingle elapsed time -> " + elapsedTime);
	}

	/**
	 * CountDownLatch 테스트
	 */
	public static void testCountDownLatch() throws Exception {
		final CountDownLatch latch = new CountDownLatch(max);

		long start = System.currentTimeMillis();
		for (long i = 0; i < max; i++) {
			new Thread(new Worker(latch)).start();
		}

		latch.await();
		long elapsedTime = System.currentTimeMillis() - start;
		System.out.println("testCountDownLatch elapsed time -> " + elapsedTime);
	}

	/**
	 * Job 쓰레드
	 */
	static class Worker implements Runnable {
		private CountDownLatch latch;

		public Worker(CountDownLatch latch) {
			this.latch = latch;
		}

		@Override
		public void run() {
			try {
				Thread.sleep(1000);
			} catch (InterruptedException ex) {
				ex.printStackTrace();
			} finally {
				if (this.latch == null)
					return;

				latch.countDown();
			}
		}
	}

	/**
	 * @param args
	 */
	public static void main(String[] args) throws Exception {
		testSingle();
		testCountDownLatch();
	}
}

위의 코드의 결과는 아래와 같아서, 너무 극단적이긴 하지만, CountDownLatch의 사용을 위한 쉬운 예가 될 것이다.

testSingle elapsed time -> 10,000
testCountDownLatch elapsed time -> 1,004