AtomicLong vs LongAdder: Micrometer 성능의 숨겨진 디테일

GREEN0 points약 1개월 전

개요

애플리케이션의 메트릭을 관리할 때 많은 개발자들이 간편하고 익숙한 AtomicLong을 사용합니다. 이는 흔히 할 수 있는 실수지요. 하지만 높은 동시성 환경에서 AtomicLong의 사용은 성능상 큰 손해를 초래할 수 있습니다. AtomicLong은 하나의 값을 CAS(compare-and-swap) 방식으로 업데이트하기 때문에, 다수의 스레드가 동시에 접근하면 contention(경합)이 발생하고 처리 성능이 급격히 떨어집니다. 반면, LongAdder는 내부적으로 여러 셀(cell)에 값을 분산시켜 동시성 이슈를 크게 감소시킵니다. 이 글에서는 Micrometer 같은 메트릭 라이브러리에서 AtomicLong 대신 LongAdder를 사용해야 하는 이유를 명확하게 설명합니다.

1. 왜 Micrometer 카운터에서 LongAdder를 사용해야할까?

이유는 다음과 같습니다.

  • AtomicLong은 여러 스레드가 동시에 값을 변경하면 CAS(compare-and-swap) 연산으로 인해 성능 저하가 발생
  • LongAdder는 값을 여러 개의 셀(cell)로 나누어 각 스레드가 독립적으로 접근하도록 해 contention을 크게 줄임
  • 특히 다수의 스레드 환경에서 처리량(throughput)이 AtomicLong 대비 최대 수십 배 높음

변경이 적게 발생하고 정확한 현재 값을 자주 읽어야 하는 경우엔 AtomicLong이 나을 수 있지만, 일반적인 메트릭을 다루는 상황에서는 LongAdder가 유리합니다.

다음 코드를 통해 성능을 테스트 해볼 수 있습니다.

package evergreen

import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.LongAdder
import kotlin.concurrent.thread
import kotlin.system.measureTimeMillis

fun testAtomicLong(threads: Int, incrementsPerThread: Int): Long {
    val atomicLong = AtomicLong()
    return measureTimeMillis {
        val threadList = List(threads) {
            thread {
                repeat(incrementsPerThread) { atomicLong.incrementAndGet() }
            }
        }
        threadList.forEach(Thread::join)
    }
}

fun testLongAdder(threads: Int, incrementsPerThread: Int): Long {
    val longAdder = LongAdder()
    return measureTimeMillis {
        val threadList = List(threads) {
            thread {
                repeat(incrementsPerThread) { longAdder.increment() }
            }
        }
        threadList.forEach(Thread::join)
    }
}

fun main() {
    val threadCounts = listOf(1, 2, 4, 8, 16, 32)
    val incrementsPerThread = 100_000_000

    println("| Threads | AtomicLong (ms) | LongAdder (ms) |")
    println("|---------|-----------------|----------------|")

    threadCounts.forEach { threads ->
        val atomicTime = testAtomicLong(threads, incrementsPerThread)
        val adderTime = testLongAdder(threads, incrementsPerThread)

        println("| ${threads.toString().padEnd(7)} | ${atomicTime.toString().padEnd(15)} | ${adderTime.toString().padEnd(14)} |")
    }
}

스레드가 각각 1개, 2개, 4개, 8개, 16개, 32개인 상황이고 1천만번 더하기를 수행합니다. 결과는 다음과 같아요.

ThreadsAtomicLong (ms)LongAdder (ms)
17474
245977
4140573
8390781
1611019134
3220823271

실제로 Micrometer에서 사용하는 StepTimer 구현체에서도 LongAdder를 사용하고 있어요.

public class StepTimer extends AbstractTimer implements StepMeter {

    private final LongAdder count = new LongAdder();

    private final LongAdder total = new LongAdder();

    // 생략

2. LongAdder의 성능의 비밀

LongAdder가 이러한 성능 우위를 보이는 것에는 크게 두 가지 이유가 존재합니다.

  1. 내부적인 값(카운터)을 Cell로 분산
    • AtomicLong에서는 모든 스레드가 하나의 volatile 값에 대해 CAS 연산을 수행하기 때문에 경합으로 인한 성능 저하가 발생합니다.
    • LongAdder에서는 여러개의 Cell을 만들어 스레드간 경합을 낮춥니다. 32개의 스레드가 있다면 나누어진 Cell 내에서 각각 경쟁하도록 유도하는 것입니다.
    • 하지만 시스템 프로그래밍에 조예가 있는 개발자면 False Sharing으로 인한 성능 저하를 우려할 것입니다.
  2. False Sharing 문제 해결
    • 같은 캐시 라인에 있는 스레드가 내용을 수정하면 False Sharing이 발생합니다.
    • LongAdder의 Cell정의를 보면 Contended라는 에노테이션이 붙어 있습니다. 이는 JVM에게 클래스나 인스턴스에게 padding을 추가하라는 힌트입니다. 이 힌트를 보고 JVM은 Cell 클래스의 공간을 여유롭게 잡습니다. 패딩을 두면 캐시 라인을 공유하지 않기 때문에 false sharing이 발생하지 않습니다.

LongAdder의 구현을 확인해보면 이 글에 대한 이해가 한층 더 높아질거에요.

또, LongAdder를 선택할 때는 주의 사항이 있는데요. LongAdder에서는 정확한 값을 얻을 수 없어요. 이는 성능과 정합성의 trade-off 결과입니다. 그래서 정확한 카운트가 필요한 곳에서는 사용해서는 안돼요.

3. 자바인간인 내가 뭘 할 수 있는데?

앞서 배운 사실을 바탕으로 앞으로 다음을 고려하면 더 좋을 것입니다.

  1. 메트릭/통계에는 LongAdder 사용: 쓰기가 빈번한 경우 고려할만함
  2. 배열/데이터 구조에는 캐시라인 크기를 고려한 padding 사용
  3. 상태 공유는 안하면 베스트

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

로그인하기