programming language/Java

[Java] LMAX Disruptor 공식문서 읽기 1 : 1. Overview ~ 2.6 Pipelines and Graphs

공대키메라 2026. 4. 8. 22:25

사실 필자는 해당 내용에 대해서는 그다지 관심이 없었다.

 

최근 가장 큰 관심사는 Netty를 확장해서 프레임워크를 구축하는 것인데, Log4J에 대한 공식문서를 읽고 정리할 까 고민했다.

 

Log4j를 가져다가 사용하는게 괜찮은지 보다가 성능이며 동시성 문제며 이러한 여러가지 performance 분석에 대해서 보니.. 가져다가 쓰는것이 많은 부분에서 장점이라고 느껴졌다.

 

다만, LMAX에서 Discruptor라는걸 만들었는데, 이것을 활용해서 Apache Log4j 2의 성능을 극적으로 올렸다고 했다.

 

내가 꼳힌 부분은 Discruptor가 도대체 뭔지 글을 좀 진득~하게 읽어보고 싶었고 요약을 좀 하고 싶었기에 해당 글을 적는다.

 

물론 AI에게 물어보면서 모르는 부분들을 채우고 그림도 한번 도식화하면서 이해할 예정이다.

 

아무래도 글의 흐름을 공식문서를 순서대로 읽는것을 골자로 한다. 

 

다만, 논문은 구글 번역기를 쓰고, 부족한 부분 필자가 살을 붙여서 첨언하려고 한다. 


목표

1. LMAX Distruptor에 대해 이해한다.

2. LMAX Technical Paper를 읽고 원리에 대해 이해한다.                                                                                                                                                   


0. 들어가기 앞서

0.1) java.lang Package 들여다보기

사실 관심이 없기도 하고, 그렇게 깊게 들여다 볼 일이 없었는데, 이번기회에 한번 읽어보자.

자바 프로그래밍 언어 설계에 필수적인 클래스들을 제공합니다.

...

ClassLoader, Process, ProcessBuilder, Runtime, SecurityManager 및 System 클래스는 클래스의 동적 로딩, 외부 프로세스 생성, 호스트 환경 정보 조회(예: 시간), 보안 정책 적용 등을 관리하는 "시스템 작업"을 제공합니다.

...

출처: https://docs.oracle.com/javase/8/docs/api/java/lang/package-summary.html#package.description 中

 

그냥 테스트용으로 막 찍는 용도인 System.out.println()은 java.lang Package 에 속하며 System클래스는 클래스의 동적 로딩, 외부 프로세스 생성 등 자바 프로그래밍 언어 설계에 필수적인 기능을 제공한다.

0.2) System.out.println의 문제점

System 클래스에 대해서 어떻게 설명하는지 보자.

 

System 클래스는 여러 유용한 클래스 필드와 메서드를 포함합니다. 이 클래스는 인스턴스화할 수 없습니다.

System 클래스가 제공하는 기능으로는 표준 입력, 표준 출력 및 오류 출력 스트림, 외부에서 정의된 속성 및 환경 변수에 대한 접근, 파일 및 라이브러리 로드 방법, 배열의 일부를 빠르게 복사하는 유틸리티 메서드 등이 있습니다.

출처 : https://docs.oracle.com/javase/8/docs/api/java/lang/System.html 中

 

우리는 테스트시에 System.out.println()을 찍지 말고 별도의 logging library를 가져와서 사용하는게 표준이고 알파이자 오메가이다.

 

이게 그러면 왜 그래서 그런지... 코드를 들여다보면

 

1. System은 인스턴스화가 불가능하며 static으로 사용하게만 한다.

2. 그리고 내부에 PrintStream이 field로 선언되어 있다.

3. PrintStream -> println을 따라가면 print 하고 newLine을 출력한다.

4. BufferWriter, OutputStreamWriter를 통해서 이 작업들을 시행한다. 

 

궁금하면 꼬리에 꼬리를 물어보면 되는데, 이것을 다 일일히 보기는좀 그렇고, 핵심인 println(String x) 메소드를 보면 된다.

 

 package java.io 내부 PrintStream.java 파일 내 pritnln호출 메소드

...

public void println(String x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}

...

출처 : https://docs.oracle.com/javase/8/docs/api/java/io/PrintStream.html


print, newLine 메소드를 호출하는데 문제는 synchronized로 감싸여 있다.

그래서 성능적으로 문제가 있기 때문에 쓰지 말라는 뻔하디 뻔한 이야기다. (구관이 명관이다~)

 

0.3 왜 Netty에서는 System.arrayCopyOf 메소드를 사용할까?

여기서 필자가 궁금했던 부분은 왜 Netty에서 Bytebuf 같은 것을 사용하면 메모리 사용을 최적화 할 수 있다고 했는데,

Netty에서 내부 코드에서 System.arrayCopyOf 를 사용한다고 한다. 왜 그렇지?

netty 4.1.128 final 에 UnpooledHeapByteByf 코드 중 일부 - capacity 메소드

 

솔직히 뭐 좋아서 했겠지. 그런데 어떻게 뭐가 좋은지는 잘 모르겠다.

 

대충 짐작하거니와, 이것도 메모리에 직접 접근해서 해서 그런거 아닐까? (사실 다 검색햇지만... 아는척좀 하겠습니다.)

 

여기서 System.arraycopy에 가면 @IntrinsicCandidate가 있다.

 

System.java 파일 중 arraycopy 메소드

 

이게 아무래도 native 라서 메모리에 직접적으로 접근하기에 빠른 것 같다. 

 

이에 대한 질문을 Gemini에게 했고 다음은 대화의 결과이다. 궁금하면 열어서 읽어보길 바란다.

 

더보기
키메라:
Netty에서 왜 이렇게 한거야?
이게 효율적인거 맞아?
native 로 선언되어잇는거 보니 메모리를 직접적으로 조작 가능해서 빠른거 같은데

Gemini:

System.arraycopy를 보고 질문하신 거라면, 작성하신 추론이 정확하게 맞습니다.
native로 선언되어 있어 JVM 외부의 로우레벨(C/C++) 메모리 제어 방식을 빌려오기 때문에 훨씬 효율적입니다.
그 원리와 "진짜 무조건 효율적인가?"에 대한 구조적 사실을 정리해 드리겠습니다.

1. System.arraycopy가 빠른 이유 (원리)
Java에서 배열을 복사할 때 일반적인 for 문을 사용하면, JVM은 매 루프마다 **배열 경계 검사(Array Bounds Checking)**를 수행하고 데이터를 하나씩 꺼내서 옮깁니다.

반면, System.arraycopy는 이 과정을 생략하고 다음과 같은 최적화를 거칩니다.

블록 단위 메모리 복사 (Block Copy): JNI를 통해 내부적으로 C/C++의 memmove() 같은 로우레벨 함수를 호출합니다. 요소 하나하나가 아니라 지정된 길이만큼의 메모리 블록(Memory Block) 자체를 시작 주소에서 목적지 주소로 한 번에 덮어씌웁니다.

하드웨어 가속 (SIMD): 하부 OS와 CPU 아키텍처에 따라, 한 번의 명령으로 여러 개의 데이터를 병렬로 처리하는 SIMD(Single Instruction Multiple Data) 명령어 셋을 활용하도록 컴파일되어 있어 대량의 데이터를 옮길 때 압도적인 속도를 냅니다.

2. 항상 효율적인가? (구조적 트레이드오프)
결론부터 말씀드리면 "배열의 크기가 클수록 압도적으로 효율적이지만, 아주 작은 배열에서는 오히려 비효율적일 수 있다" 입니다.

JNI 오버헤드의 존재: Java 영역에서 native 영역으로 넘어갔다 오는 데는 실행 컨텍스트를 전환하는 비용(Context Switching)이 듭니다.JIT 컴파일러의 루프 최적화: 최신 JVM의 JIT 컴파일러는 매우 똑똑해서, 요소가 몇 개 안 되는(보통 수십 개 이하) 단순 for 문 복사는 루프 언롤링(Loop Unrolling) 기법 등을 통해 JNI 호출보다 더 빠르게 기계어로 최적화해 버립니다.

따라서 데이터 크기가 작을 때는 for 문과 속도가 비슷하거나 미세하게 느릴 수 있지만, 배열 크기가 커지는 순간 JNI 호출 오버헤드는 무시할 수 있는 수준이 되고 메모리 블록 복사의 속도 이점이 극대화됩니다.

3. 고성능 아키텍처 관점에서의 의미
이 메서드는 Java 성능 최적화의 근간이 되는 기술입니다. 우리가 자주 쓰는 ArrayList가 내부 배열 용량이 꽉 차서 크기를 늘릴 때(Resizing) 기존 데이터를 새 배열로 옮기는 데 사용하는 것이 바로 System.arraycopy입니다.

더 나아가, 대용량 트래픽을 처리하는 환경에서 바이트 데이터를 쉴 새 없이 조작해야 할 때(예: NIO의 ByteBuffer나 그 기반의 고성능 네트워크 프레임워크 내부 로직) 패킷을 자르고 합치는 버퍼 슬라이싱 과정의 병목을 없애기 위해 가장 많이 호출되는 핵심 메서드이기도 합니다.

1. LMAX 사이트 탐방

해당 사이트에 LMAX Discriptor 에 대해 자세히 설명해주고 있다. (LMAX Discriptor)

심지어 해당 사이트에서는 왜 이게 그렇게 빠른지 Technical Paper도 제공하고 있다. (Technical Paper)

 

Technical Paper에 대해서는 필자가 읽어보려고 했으나, 너무 깊은 내용들이 있고 필자도 사실 운영체제 개념과 lock 개념에 대해 

많이 까먹은 상태라서 이에 대한 정보도 찾아야겠다는 마음을 먹었다.

 

하여간 LMAX Discriptor 사이트에서 Technical Paper 를 먼저 읽어보라고 한다. 그래서 이걸 읽어보려고 한다.

 

우선 LMAX가 뭔지 알아보자.

LMAX는 최고 수준의 성능을 자랑하는 금융 거래소를 구축하기 위해 설립되었습니다. 

...

현대 CPU의 작동 방식을 고찰하면서, 우리가 "기계적 공감"이라고 부르는 방식을 적용하고, 설계 관행을 활용하여 문제점을 꼼꼼히 분석한 결과, 디스럽터(Disruptor)라는 데이터 구조와 사용 패턴을 개발했습니다.

출처 : https://lmax-exchange.github.io/disruptor/

 

LMAX는 'A dynamic vision for global FX and digital assets' (글로벌 외환 및 디지털 자산에 대한 역동적인 비전)

을 제공하는 회사의 이름으로 ' LMAX Group is a global financial technology company' (LMAX Group은 글로벌 금융 기술 회사) 라고 소개하고 있다.

 

Discruptor란 뭔가?(What is the discruptor)

LMAX는 세계에서 가장 빠른 트레이딩 플랫폼을 지향하는데 자바 플랫폼 위에서 저지연(low-latency), 고처리량(high-throughput) 작업을 해야하는데 일반적인 queue를 사용하면 이것이 잘 안되서 최적화를 하는데 집중했다고 한다.

 

Discruptor는 이러한 과정에서 나온 결과로 CPU-level에서 캐시 미스와 커널 관리(kernel arbitration)를 요구하는 락들이 극도록 비용이 많이 들어서 lock-free이면서 작동하는 하드웨어를 위한 기계적 동감(mechanical sympathy)을 만족하는 프레임워크를 만들었다.

 

여기서 재미있는 표현이 나오는데 Mechanical sympathy라는 표현이다.

찾아보니 'Hardware and software working together in harmony' 라는데 하드웨어와 소프트웨어가 조화롭게 잘 동작함을 말한다. 

 

이것은 금융 어플리케이션에서만 작동하게 디자인 되지 않았다. Discruptor는 동시성 프로그래밍 (concurrent programming) 의 어려운 문제를 풀기 위한 범용적인 목적의 메커니즘이다.

 

What's the big deal?

https://lmax-exchange.github.io/disruptor/ 중 What’s the big deal?

 

ArrayBlockingQueue를 사용했을 때와 Disruptor를 사용했을 때의 성능 비교 그래프를 보여준다.

 

log-log scale이니 한 번 보고 가자.

2. LMAX Disruptor Technical Paper 읽어보기  - Abstacrt(초록)

해당 글은 사실 여기 안에 다 있다.

(LMAX Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads)

 

사이트에 올라와 있는 글 같지만 이는 논문으로, pdf로도 찾아서 다운받을 수 있다.

 

논문은 보통 연구의 배경, 목록, 방법, 주요 결과 및 결론을 한 문단으로 간결하게 요악하는 초록(Abstract)를 둔다고 한다.

 

이를 다 읽어 보았지만, 요약은 AI가 잘하기에 Gemini에게 요약을 요청했다.

 

제시해주신 LMAX Disruptor 백서 초록의 핵심 내용을 4가지 관점으로 요약해 드립니다.

기존 방식의 한계 (문제 제기)
기존 애플리케이션에서 프로세스 간 데이터 교환을 위해 사용하는 큐(Queue)는 디스크 I/O에 필적할 만큼 심각한 지연 시간(Latency)을 발생시킵니다.

특히 멀티스레드 구현 시 발생하는 구조적인 경합(Contention) 문제로 인해 고성능 시스템을 구축하는 데 근본적인 한계가 있었습니다.

해결책 (Disruptor의 원리)
이러한 문제를 해결하기 위해 CPU 하드웨어의 실제 동작 방식을 이해하고 최적화하는 '기계적 공감(Mechanical Sympathy)' 원칙을 적용했습니다.

복잡한 관심사를 분리하여 쓰기 경합을 최소화하고, CPU 캐시 친화적(Cache-friendly)으로 동작하는 새로운 데이터 구조이자 동시성 프로그래밍 패턴인 Disruptor를 고안했습니다.

압도적인 성능 개선
기존 큐 기반 아키텍처와 비교했을 때 지연 시간은 3자릿수(1/1000) 수준으로 감소했으며, 동일한 환경에서 처리량(Throughput)은 8배 증가했습니다.

적당한 클럭의 프로세서에서도 초당 2,500만 건 이상의 메시지를 처리하며 50 나노초(ns) 이하의 지연 시간을 기록했습니다.

범용성과 적용성
이는 단순히 금융 도메인(LMAX 거래소)에만 국한된 특수 기술이 아닙니다. 고처리량 및 초저지연이 필수적인 모든 비동기 이벤트 처리 아키텍처에 적용할 수 있는 범용적인 동시성 제어 메커니즘이며, 기존 메커니즘보다 오히려 구현이 더 단순합니다.

요약하자면, 멀티스레드 환경의 락(Lock)과 경합 병목을 하드웨어(CPU 캐시) 수준의 이해를 바탕으로 풀어내어, 소프트웨어의 극한 성능을 이끌어낸 아키텍처 패턴이라고 할 수 있습니다.

 

필자는 기존의 하드웨어 구조가 어떻게 되어 있길래, 이를 이용해서 초고성능을 끌어낸 것인지가 궁금하다. 

 

이를 쭈욱 읽어보면 많은 인사이트를 얻을 수 있겠다!

 

글을 번역기를 이용해 쭈욱 읽으면서 모르는 부분은 중간 중간에 설명을 적었다.

 

2.  동시성의 복잡성(The Complexities of Concurrency)

2.0) Overview

이 문서와 일반적인 컴퓨터 과학 맥락에서 동시성이란 두 개 이상의 작업이 병렬로 실행되는 것뿐만 아니라, 자원에 대한 접근을 두고 경쟁하는 것을 의미합니다.

경쟁하는 자원은 데이터베이스, 파일, 소켓 또는 메모리 위치일 수 있습니다.

 

코드의 동시 실행은 상호 배제와 변경 사항의 가시성이라는 두 가지 요소에 달려 있습니다.

 

상호 배제는 특정 자원에 대한 경쟁적인 업데이트를 관리하는 것입니다. 변경 사항의 가시성은 이러한 변경 사항이 다른 스레드에 언제 표시되는지를 제어하는 ​​것입니다.

 

경쟁적인 업데이트가 필요하지 않도록 할 수 있다면 상호 배제는 필요하지 않습니다. 알고리즘이 특정 자원을 오직 하나의 스레드만이 수정할 수 있도록 보장한다면 상호 배제는 불필요합니다.

 

읽기 및 쓰기 작업에는 모든 변경 사항이 다른 스레드에 표시되어야 합니다. 그러나 경쟁적인 쓰기 작업에만 변경 사항에 대한 상호 배제가 필요합니다. 동시 실행 환경에서 가장 비용이 많이 드는 작업은 경쟁적인 쓰기 접근입니다.

 

여러 스레드가 동일한 자원에 쓰기를 하려면 복잡하고 비용이 많이 드는 조정이 필요합니다. 일반적으로 이는 일종의 잠금 전략을 사용하여 구현됩니다.

 

2.1) The Cost of Locks

락(Lock)은 상호 배제를 제공하고 변경 사항이 순서대로 표시되도록 보장합니다. 

 

락은 경합이 발생할 경우 중재가 필요하기 때문에 매우 비쌉니다. 

 

이 중재는 운영 체제 커널로 컨텍스트 스위치를 수행하여 락을 기다리는 스레드를 락이 해제될 때까지 일시 중단함으로써 이루어집니다. 

 

이러한 컨텍스트 스위치 동안 운영 체제에 제어권을 넘겨주는 것 외에도, 운영 체제가 제어권을 확보한 동안 다른 유지 관리 작업을 수행할 수 있으며, 실행 컨텍스트에서 이전에 캐시된 데이터와 명령어가 손실될 수 있습니다. 

 

이는 최신 프로세서의 성능에 심각한 영향을 미칠 수 있습니다. 빠른 사용자 모드 락을 사용할 수도 있지만, 이는 경합이 발생하지 않을 때만 실질적인 이점을 제공합니다.

간단한 예제를 통해 락의 비용을 설명하겠습니다. 

 

이 실험의 목표는 64비트 카운터를 5억 번 반복해서 증가시키는 함수를 호출하는 것입니다. 이 함수는 Java로 작성할 경우 2.4GHz Intel Westmere EP 프로세서에서 단일 스레드로 단 300ms 만에 실행할 수 있습니다. 이 실험에서는 언어가 중요하지 않으며, 동일한 기본 요소를 사용하는 모든 언어에서 결과는 유사합니다.

상호 배제를 제공하기 위해 잠금이 도입되면, 아직 경쟁이 없는 잠금 상태일지라도 비용은 크게 증가합니다. 두 개 이상의 스레드가 경쟁하기 시작하면 비용은 몇 배나 더 증가합니다. 이 간단한 실험의 결과는 아래 표에 나와 있습니다.

 

Table 1. Comparative costs of contention

Method Time(ms)
Single thread 300
Single thread with lock 10,000
Two threads with lock 224,000
Single thread with CAS 5,700
Two threads with CAS 30,000
Single thread with volatile write 4,700

 

정리하자면 락이 발생하면 OS 커널 개입과 컨텍스트 스위칭이 발생하는데 이는 고비용이다.

더군다나 컨텍스트 스위치가 발생하면 캐시도 날려버리는 엄청난 비효율이 발생한다.

여기서 상호배제를 도입하면 더욱 비용은 증가한다. 

쓰레드가 많아지면 비용은 또! 증가한다.

해당 글을 읽으니 과거 thread가 많으면 좋은가? 하는 질문에 대한 글을 정리한 적 이있는데, 이를 읽어보면 좋을것 같다.

(여기 클릭)


2.2 The Costs of "CAS"

단일 워드(single word)를 업데이트할 때 락을 사용하는 것보다 더 효율적인 메모리 업데이트 방법이 있습니다. 

 

이러한 대안은 최신 프로세서에 구현된 원자적 또는 인터록 명령어를 기반으로 합니다. 

 

이는 일반적으로 CAS(Compare And Swap) 연산으로 알려져 있으며, 예를 들어 x86 아키텍처의 "lock cmpxchg"가 있습니다. 

 

CAS 연산은 메모리 워드를 조건부로 원자적으로 설정할 수 있는 특수한 기계어 명령어입니다. 

"카운터 증가 실험"에서 각 스레드는 루프를 돌면서 카운터를 읽고 새로운 값으로 원자적으로 설정하려고 시도할 수 있습니다. 

 

이전 값과 새 값은 이 명령어의 매개변수로 제공됩니다. 연산이 실행될 때 카운터 값이 제공된 예상 값과 일치하면 카운터는 새 값으로 업데이트됩니다. 반대로 값이 예상과 다르면 CAS 연산은 실패합니다. 

 

그러면 변경을 시도하는 스레드는 카운터를 다시 읽고 해당 값부터 증가시키는 과정을 반복하여 변경이 성공할 때까지 계속합니다. 

 

CAS(ConcurrentArbitration System) 방식은 커널로 컨텍스트 스위치를 하지 않고도 중재가 가능하기 때문에 락(lock) 방식보다 훨씬 효율적입니다. 

 

하지만 CAS 연산 자체에도 비용이 발생합니다. 프로세서는 원자성을 보장하기 위해 명령어 파이프라인을 잠가야 하고, 다른 스레드에서 변경 사항을 확인할 수 있도록 메모리 배리어를 사용해야 합니다.

 

Java에서는 `java.util.concurrent.Atomic*` 클래스를 통해 CAS 연산을 사용할 수 있습니다.

프로그램의 임계 영역이 단순히 카운터를 증가시키는 것보다 복잡한 경우, 경합을 조율하기 위해 여러 CAS 연산을 사용하는 복잡한 상태 머신이 필요할 수 있습니다. 

 

락을 사용하는 동시성 프로그램을 개발하는 것도 어렵지만, CAS 연산과 메모리 배리어를 사용하는 락 프리 알고리즘을 개발하는 것은 훨씬 더 복잡하고 그 정확성을 입증하기도 매우 어렵습니다.

이상적인 알고리즘은 단일 스레드가 단일 리소스에 대한 모든 쓰기 작업을 담당하고 다른 스레드는 그 결과를 읽는 방식입니다. 다중 프로세서 환경에서 결과를 읽으려면 다른 프로세서에서 실행되는 스레드에서 변경 사항을 확인할 수 있도록 메모리 배리어가 필요합니다.

 

여기서 CAS라는 단어가 나온다. Lock을 사용하면 끔찍한 오버헤드(OS 개입, CPU 캐시 증발)을 위해 등장한 lock-free 기술인데 Compare-And-Swap의 약자다.

OS의 개입이 어때서?

락을 얻지 못한 스레드는 계속 헛돌며(Spin) CPU를 낭비하지 않기 위해 스스로 '대기 상태(Sleep)'로 들어간다.

문제는 스레드 스스로 자신을 대기열에 넣고 다른 스레드에게 CPU 제어권을 넘겨주는 행위(스케줄링)는 유저 모드의 권한 밖이라는 점인데, CPU 코어를 누구에게 할당할지 결정하는 것은 오직 운영체제 커널의 고유 권한이다. 

락을 얻지 못한 스레드는 다음의 과정을 거친다. 

1. 시스템 콜을 호출한다 : 락을 못얻어서 sleep한다. 
2. 커널 모드를 전환한다 : 권한이 격상된다. 
3. 컨텍스트 스위칭이 발생한다 : OS 커널이 현재 스레드 상태를 메모리에 백업 후 대기열로 보낸다. (CPU캐시 날아감)
4. 다른 스레드를 스케줄링한다 : 대기열에 다른 스레드의 상태를 복원해 CPU코어에 올린다.
5. 유저모드로 복귀한다 : 새로 실행되는 스레드가 유저모드 전환되어 작업을 시작한다. 

락을 얻지 못한 스레드는 스스로 CPU 코어를 반납하는데, 이 반납하고 실제로 일을 할 수 있는 thread가 cpu코어를 점유하는 과정이 컨텍스트 스위칭이다.

CAS는 그럴 필요가 없다. 비교해서, 맞으면 교환하는것이다.(Compare And Swap!)

CAS 연산은 세 가지 값을 인자로 받는다.

V (Value in memory): 실제 메모리에 들어있는 현재 값
E (Expected value): 내가 읽었던 예상(기존) 값
N (New value): 내가 새로 쓰고 싶은 값

CAS가 좋은 이유는 운영체제의 무거운 개입을 철저히 배제하고, CPU 하드웨어의 힘을 직접 빌려 빛의 속도로 동시성을 제어하기 때문이다.

Java 에서는 AtomicInteger와 ConcurrentHashMap이 대표적이다.

하지만 CAS도 연산 비용이 있으므로 가장 이상적인 점은

'단일 스레드가 단일 리소스에 대한 모든 쓰기 작업을 담당하고 다른 스레드는 그 결과를 읽는 방식'으로 다중 프로세서 환경에서 결과를 읽으려면 다른 프로세서에서 실행되는 스레드에서 변경 사항을 확인할 수 있도록 메모리 배리어가 필요 하다고 마지막줄에 언급한다.

 

2.3 Memory Barriers

최신 프로세서는 성능 향상을 위해 명령어 실행 순서와 메모리 및 실행 장치 간 데이터 로드 및 저장 순서를 비정기적으로 처리합니다. 

 

프로세서는 실행 순서와 관계없이 프로그램 논리가 동일한 결과를 생성한다는 것만 보장하면 됩니다. 

 

이는 단일 스레드 프로그램의 경우에는 문제가 되지 않습니다. 

 

그러나 스레드가 상태를 공유하는 경우, 데이터 교환이 성공적으로 이루어지려면 모든 메모리 변경 사항이 필요한 시점에 순서대로 나타나는 것이 중요합니다.

 

프로세서는 메모리 배리어를 사용하여 메모리 업데이트 순서가 중요한 코드 영역을 표시합니다. 

 

이는 스레드 간 하드웨어 순서 지정 및 변경 사항 가시성(update visibility)을 확보하는 수단입니다. 

 

컴파일러는 컴파일된 코드의 순서를 보장하기 위해 보완적인 소프트웨어 배리어를 추가할 수 있으며, 이러한 소프트웨어 메모리 배리어는 프로세서 자체에서 사용하는 하드웨어 배리어에 추가됩니다.

최신 CPU는 현재 세대의 메모리 시스템보다 훨씬 빠릅니다. 

 

이러한 속도 격차를 해소하기 위해 CPU는 체이닝 없이도 효과적으로 빠른 하드웨어 해시 테이블 역할을 하는 복잡한 캐시 시스템을 사용합니다. 이러한 캐시는 메시지 전달 프로토콜을 통해 다른 프로세서 캐시 시스템과 일관성을 유지합니다. 

 

또한, 프로세서는 쓰기 작업을 캐시로 오프로드하는 "저장 버퍼"와 쓰기 작업이 발생하기 직전에 캐시 일관성 프로토콜이 무효화 메시지를 신속하게 승인할 수 있도록 하는 "무효화 큐"를 가지고 있습니다.

 

 여기서 저장버퍼(Store Buffer)와 무효화 큐(Invalidate Queue) 가 있는데, CPU가 이를 사용하는데 너무 빨라서 베리어가 필요하다고 한다. 가시성 결여가 발생하기 때문이다.


이는 데이터 측면에서 볼 때, 어떤 값의 최신 버전이 기록된 후 어느 단계에서든 레지스터, 저장 버퍼, 여러 계층의 캐시 또는 메인 메모리에 존재할 수 있음을 의미합니다. 

 

스레드가 이 값을 공유하려면 순서대로 접근 가능하게 해야 하며, 이는 캐시 일관성 메시지의 조정된 교환을 통해 이루어집니다. 이러한 메시지의 적시 생성은 메모리 배리어를 통해 제어될 수 있습니다.

읽기 메모리 배리어는 캐시에 들어오는 변경 사항에 대해 무효화 큐에 표시를 함으로써 해당 명령을 실행하는 CPU의 로드 명령어 순서를 지정합니다. 이를 통해 읽기 배리어 이전에 순서가 지정된 쓰기 작업에 대해 일관된 상태를 유지할 수 있습니다.

쓰기 배리어는 저장 버퍼에 표시를 함으로써 해당 명령을 실행하는 CPU의 저장 명령어 순서를 지정하고, 캐시를 통해 쓰기 작업을 플러시합니다. 이 배리어는 쓰기 배리어 이전에 발생하는 저장 작업에 대한 순서 있는 관점을 제공합니다.

완전한 메모리 배리어는 로드와 저장 작업 모두에 순서를 부여하지만, 이는 해당 작업을 실행하는 CPU에서만 적용됩니다.

일부 CPU는 이 세 가지 기본 요소 외에도 더 많은 변형을 제공하지만, 이 세 가지만으로도 관련된 복잡성을 이해하기에 충분합니다. Java 메모리 모델에서 volatile 필드의 읽기와 쓰기는 각각 읽기 배리어와 쓰기 배리어를 구현합니다. 이는 Java 5 출시와 함께 정의된 Java 메모리 모델[3]에 명시적으로 설명되어 있습니다.

 

결국 volatile의 진짜 정체는 멀티스레딩 환경에서 JVM에서 CPU가 너무 빨라서 생기는 문제를 해결하기 위해 메모리 베리어를 생성하는 것이다. 신기하네~

 

2.4) Cache Lines

최신 프로세서에서 캐싱이 사용되는 방식은 고성능 작동의 성공에 매우 중요합니다. 이러한 프로세서는 캐시에 저장된 데이터와 명령어를 처리하는 데는 엄청나게 효율적이지만, 캐시 미스가 발생할 때는 상대적으로 매우 비효율적입니다.

하드웨어는 바이트나 워드 단위로 메모리를 이동시키지 않습니다. 효율성을 위해 캐시는 일반적으로 32~256바이트 크기의 캐시 라인으로 구성되며, 가장 일반적인 캐시 라인 크기는 64바이트입니다. 캐시 일관성 프로토콜은 이러한 단위에서 작동합니다.

캐시 라인은 CPU가 메인 메모리(RAM)에서 데이터를 가져올 떄 사용하는 최소 화물 운송 단위이다.

캐시 일관성(Cache Coherence) 프로토콜은 멀티 코어 프로세서 환경에서 각 코어가 가진 로컬 캐시(L1, L2 등)와 공유 메모리(L3 캐시 또는 메인 메모리) 간의 데이터 불일치를 방지하고 동기화하는 하드웨어 수준의 메커니즘이다. 어렵네... 

 

즉, 두 변수가 동일한 캐시 라인에 있고 서로 다른 스레드에서 쓰기 작업을 수행하는 경우, 마치 하나의 변수인 것처럼 쓰기 경합 문제가 발생합니다. 이를 "가짜 공유(false sharing)"라고 합니다. 따라서 고성능을 위해서는 독립적이지만 동시에 쓰기 작업을 수행하는 변수들이 동일한 캐시 라인을 공유하지 않도록 하여 경합을 최소화하는 것이 중요합니다.

두 변수가 동일한 캐시라인에 있고 서로 다른 스레드에서 쓰기 작업을 수행하는 경우 하치 하나의 변수인 것처럼 쓰기 경합 문제가 생긴다고 한다.

이게 이해가 안돼서 찾아보니 직관적인 비유로 '하나의 결재 서류와 두 명의 담당자'를 찾았다.

서로 같은 서류를 두명이 쓰지만 다른 부분을 수정할 수 있다. 그 경우 문제가 없지만 같은 종이(캐시라인)을 공유한다는 이유만으로 새로운 종이를 뽑으면 안되지 않은가?

결국 이 가짜라는 말은 분리해서 일을 하고 있지만 두 쓰레드(두 명의 담당자)가 경합하고 있다고 잘못 판단한 것을 말한다.

가장 일반적인 캐시 라인 크기는 64byte이니 일부러 겹치지 않게 head와 tail을 64byte크기로 선언하는 꼼수(?)도 있다고 한다.

이것의 문제점은 캐시 라인 전체가 무효화된다는 점이다. 그럼 다시 캐시라인을 불러오겠지? 그런데 자꾸 누가 공유하고 있네? 다시 불러오겠지? 이렇게 반복적으로 재로딩을 하며 결과적으로 심각한 성능 저하를 초래한다.


CPU는 예측 가능한 방식으로 메모리에 접근할 때, 다음에 접근될 가능성이 높은 메모리 영역을 예측하여 백그라운드에서 캐시에 미리 불러옴(prefetching)으로써 메인 메모리 접근에 따른 지연 시간을 줄일 수 있습니다. 

prefetching 이란 프로그램이 필요할 것 같은 데이터를 미리(pre) 가져오는것(patch)을 말한다.

왜? CPU에서 데이터를 사용하는 속도가 너무 빠르기 때문이다.

 

하지만 이는 프로세서가 예측 가능한 "스트라이드"로 메모리를 순회하는 것과 같은 접근 패턴을 감지할 수 있을 때만 가능합니다. 

여기서 스트라이드로 메모리를 순회하는 것이 뭔지 몰랐다.

Stride는 보폭 혹은 걸음거리를 의미하는 말로 메모리에 연속적으로 접근할 때 다음 데이터로 넘어가기 위해 몇 칸을 건너뛰는지 나타내는 간격을 말한다. 이는 메모리 관점에서는 연속된 메모리 접근 사이의 주소 간격을 의미한다.

그렇다면... 예측가능한 "스트라이드"로 메모리를 순회한다는 말은, 과거 메모리 접근 기록을 분석하여 이 보폭에 일정한 패턴이 있는지 예측하고 이것을 맞추면 Cache Hit가 발생해 성능이 극대화된다.

그러면 예측 가능한 스트라이드로 메모리를 순회하는것이 성능을 위해서는 좋겠지?

하지만 위에서 글을 보니 prefetching은 예측 가능한 스트라이드로 메모리를 순회하는 것과 같은 접근 패턴을 감지할 수 있을 때만 가능하다고 한다. 조건이 까다로운것이다. 

 

배열의 내용을 순회할 때 스트라이드는 예측 가능하므로 메모리가 캐시 라인에 미리 로드되어 접근 효율이 극대화됩니다. 일반적으로 프로세서가 이를 감지하려면 스트라이드가 양방향으로 2048바이트 미만이어야 합니다. 

 

그러나 연결 리스트나 트리와 같은 데이터 구조는 노드가 메모리에 넓게 분포되어 있어 접근 스트라이드를 예측하기 어렵습니다. 메모리에서 일관된 패턴이 부족하면 시스템이 캐시 라인을 미리 불러오는 능력이 제한되어 메인 메모리 접근 효율이 2배 이상 떨어질 수 있습니다.

2.5) The Problems of Queues

큐는 일반적으로 요소 저장을 위해 연결 리스트나 배열을 사용합니다. 메모리 내 큐의 크기가 무제한으로 허용될 경우, 많은 문제 유형에서 큐는 메모리 부족으로 인한 치명적인 오류가 발생할 때까지 제어되지 않고 계속 커질 수 있습니다. 

 

이는 생산자가 소비자보다 더 많은 요소를 처리할 때 발생합니다. 생산자가 소비자보다 더 많은 요소를 처리할 것이라는 보장이 있고 메모리가 귀중한 자원인 시스템에서는 무제한 큐가 유용할 수 있지만, 이러한 가정이 성립하지 않고 큐가 무한정 커질 위험은 항상 존재합니다. 이러한 치명적인 결과를 방지하기 위해 큐는 일반적으로 크기를 제한(유한화)합니다. 큐의 크기를 제한하려면 배열 기반 큐를 사용하거나 큐 크기를 적극적으로 추적해야 합니다.

큐 구현에서는 일반적으로 헤드, 테일, 크기 변수에 대한 쓰기 경합이 발생합니다. 큐는 사용 시 소비자와 생산자의 처리 속도 차이로 인해 항상 거의 가득 차 있거나 거의 비어 있는 상태를 유지합니다. 

헤드, 테일, 크기 변수? 생각을 해보면 
단일 큐의 경우 자료구조에서 큐를 직접 코딩하라는 것이 나올텐데 field 변수로 head와 tail을 선언, 그리고 크기를 기록한다.

 

생산 속도와 소비 속도가 균등하게 균형을 이루는 중간 지점에서 작동하는 경우는 매우 드뭅니다. 큐가 항상 가득 차 있거나 항상 비어 있는 경향 때문에 높은 수준의 경합이 발생하거나 캐시 일관성 유지에 많은 비용이 소요됩니다. 

 

문제는 헤드와 테일을 처리하는 메커니즘을 락이나 CAS 변수와 같은 서로 다른 동시성 객체를 사용하여 분리하더라도 일반적으로 동일한 캐시 라인을 차지한다는 점입니다.

이전 2.4절에서 가짜 공유 현상에 대해 기억하는가? 그 문제에 대해 말하고 있다.


생산자가 큐의 헤드를 차지하고, 소비자가 테일을 차지하며, 그 사이에 있는 노드들을 저장하는 문제를 관리해야 하므로, 큐에 단일 대규모 락을 사용하는 것 외에는 동시성 구현 설계가 매우 복잡해집니다. 

 

전체 큐에 대규모 락을 걸어 put 및 take 작업을 처리하는 것은 구현은 간단하지만 처리량에 상당한 병목 현상을 일으킵니다. 큐의 의미론 내에서 동시성 문제를 분리하면 단일 생산자-단일 소비자 구현이 아닌 경우에는 구현이 매우 복잡해집니다.

자바에서는 큐 사용에 또 다른 문제가 있는데, 큐가 상당한 가비지 발생원이라는 점입니다. 첫째, 객체를 할당하여 큐에 저장해야 합니다. 둘째, 연결 리스트를 사용하는 경우 리스트의 노드를 나타내는 객체를 할당해야 합니다. 큐 구현을 지원하기 위해 할당된 이러한 객체들은 더 이상 참조되지 않을 경우 모두 회수해야 합니다.

이전에는 Queue로 생산과 소비를 해결했나보다.

그렇지만 Queue를 사용함으로 오는 문제가 많음을 설명한다.

 

2.6 Pipelines and Graphs

많은 유형의 문제에서 여러 처리 단계(several processing stages)를 파이프라인으로 연결하는 것이 효율적입니다. 이러한 파이프라인은 종종 병렬 경로를 가지며, 그래프와 같은 토폴로지로 구성됩니다. 각 단계 간의 연결은 일반적으로 큐를 통해 구현되며, 각 단계는 자체 스레드를 갖습니다.

하지만 이 접근 방식은 비용이 많이 듭니다. 각 단계에서 작업 단위를 큐에 넣고 빼는 비용이 발생하기 때문입니다. 대상의 수가 많아질수록 이 비용은 증가하며, 경로가 분기될 경우 더욱 커집니다. 또한 분기 후 다시 합류할 때 발생하는 경합으로 인한 비용도 불가피하게 발생합니다.


따라서 단계 사이에 큐를 두지 않고도 의존성 그래프를 표현할 수 있다면 이상적일 것입니다.

여기서 토폴로지 그래프란 무슨 말인가?

'어떻게 연결되어 있는가'를 나타내는 구조나 형태를 토폴로지라고 말하며

그래프는 '점(Node)과 선(Edge)이 거무줄처럼 얽혀 있는 네트워크 구조를 의미한다. 

파이프라인이 토폴로지 그래프처럼 구성이 된다면, 그리고 각 단계 간의 연결을 큐로 구현한다면....

우리가 앞에서 열심히 읽은 문제들이 여러모로 발생한다는 것이다. 

 


 

이렇게 논문을 직접 하나하나 번역해 읽으며 필자가 부족한 부분은 찾아서 매꾸는 방식으로 진행했다.

 

그러면 그냥 번역기 돌리지 뭐하냐고? 

 

이를 보면서 나 자신이 모르는 부분을 찾고 살을 채우는것은 쉬운일이 아니다.

 

기술의 도움을 받아서 학습에 두배 이상의 가속을 경험한 나는 요즘 너무 좋다!

 

이렇게 사용하면서 Gemini에게 질문을 했고 나름 괜찮게 이해를 하게 되었다.

 

다음 이어지는 글에서는 3장을 이어서 읽어보도록 하겠다.

 

참고:

https://docs.oracle.com/javase/8/docs/api/java/lang/package-summary.html

https://docs.oracle.com/javase/8/docs/api/java/lang/System.html

https://logging.apache.org/log4j/2.12.x/

https://logging.apache.org/log4j/2.x/index.html

https://logging.apache.org/log4j/2.12.x/manual/

https://lmax-exchange.github.io/disruptor/

https://konghana01.tistory.com/472

https://dev.to/ariasdiniz/understanding-and-solving-false-sharing-in-multi-threaded-applications-with-an-actual-issue-i-had-57c