10배 빠르게 통계 처리하기
자바로 개발된 많은 엔터프라이즈 응용프로그램들은 일반적으로 데이터베이스에 의존하여 데이터를 처리하지만, 처리해야 할 데이터가 너무 크거나 실시간 집계가 필요한 경우 자바로 직접 개발하여 통계를 내기도 합니다. 현재는 데스크탑에서도 4개 이상의 멀티코어를 사용하기 때문에, 서버에서는 수십 개의 스레드가 데이터를 처리하여 집계하는 경우를 흔하게 볼 수 있습니다.
중급 이상의 개발자라면 프로세서가 지원하는 CAS (Compare-And-Swap) 인스트럭션을 기반으로 한 AtomicInteger나 AtomicLong 클래스를 이용하여 병렬 집계 처리를 하겠지만, 자바 8은 멀티코어 환경에서 성능이 더 나은 옵션을 제공합니다. 바로 LongAdder 클래스입니다.
AtomicLong 테스트 코드
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
public class Benchmark {
private AtomicLong counter = new AtomicLong(0);
public class Counter implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10000000; i++)
counter.incrementAndGet();
}
}
public void run() throws InterruptedException {
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);
long begin = System.currentTimeMillis();
for (int i = 0; i < cores; i++)
pool.submit(new Counter());
pool.shutdown();
pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
long end = System.currentTimeMillis();
System.out.println(counter.get());
System.out.println((end - begin) + "ms");
}
public static void main(String[] args) throws Exception {
new Benchmark().run();
}
}
LongAdder 테스트 코드
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;
public class Benchmark {
private LongAdder counter = new LongAdder();
public class Counter implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10000000; i++)
counter.increment();
}
}
public void run() throws InterruptedException {
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);
long begin = System.currentTimeMillis();
for (int i = 0; i < cores; i++)
pool.submit(new Counter());
pool.shutdown();
pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
long end = System.currentTimeMillis();
System.out.println(counter.sum());
System.out.println((end - begin) + "ms");
}
public static void main(String[] args) throws Exception {
new Benchmark().run();
}
}
측정 결과 #1
- 인텔 코어 i7-4770 3.4GHz, 4 코어, 8 스레드
- AtomicLong: 1546ms, 1542ms, 1534ms
- LongAdder: 155ms, 141ms, 143ms
측정 결과 #2
- 인텔 제온 E5-2687W v3 3.1GHz, 20 코어, 40 스레드
- AtomicLong: 6701ms, 7126ms, 6880ms
- LongAdder: 351ms, 349ms, 332ms
성능 차이의 원인
멀티스레드 응용에서 AtomicLong을 이용해서 집계를 수행하는 경우, 모든 코어의 CPU 사용율이 100%에 근접하기 때문에 최대 속도로 동작하고 있다고 단순하게 생각하기 쉽습니다. 실제로는 같은 메모리 변수에 대해 다수의 코어가 경합을 벌이게 되면서, 값 변경이 성공하게 될 때까지 무한히 재시도 하는 상태가 됩니다. 스레드 수가 많을수록 더 극심한 경합이 발생하고 성능이 더 떨어집니다.
LongAdder는 여러 개의 카운터로 구성된 배열을 이용해서 코어 간 경합이 발생할 확률을 떨어뜨립니다. 마지막에 각 카운터를 합산하여 최종 결과를 계산하는 방식으로, 집계를 수행할 때는 경합을 최소화하고 정확한 결과를 얻을 수 있습니다.
LongAdder는 멀티스레드 집계 상황에서 성능이 좋은 대신, 메모리를 상대적으로 많이 사용합니다. 아래 코드에서 보이듯이 LongAdder는 경합이 발생하면 2배씩 카운터 배열을 확장합니다.
else if (cellsBusy == 0 && casCellsBusy()) {
try {
if (cells == cs) // Expand table unless stale
cells = Arrays.copyOf(cs, n << 1);
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
집계 카운터가 많은 경우, 이러한 메모리 사용량은 받아들이기 어려울 수 있습니다. 로그프레소는 하드웨어 환경에 따라 적절한 메모리를 사용하면서 백오프(Backoff)를 이용해 경합을 감소시키는 알고리즘을 사용합니다.