지난 글에서는 LMAX Disruptor에 관한 논문 하나를 천천히 읽어보며 부족한 부분을 채웠다.
이번 글에서는 실제로 Disruptor 코드를 보고, 이를 어떻게 적용했을 때 정말 빠른지 Apache JMeter 로 보려고 한다.
지난 글에서 참고로 언급을 했지만, 실제 Disruptor 개발자 분 께서 블로그로 이에 대해 설명해주는 글이 있는데,
전반적으로 해당 글의 흐름대로 코드도 다 들여다보려고 한다. (disseecting the disruptor writing to the ring buffer)
안타깝게도 해당 글은 좀 오래되어서 현 버전과 안맞을 수 있다. 고로, 글을 다 읽어보고 현재와 다른점을 짚어볼 필요가 있다.
그럼에도! 큰 흐름은 변하지 않았으리라 판단한다.
gradle 기준으로 dependency는 'com.lmax:disruptor:4.0.0' 를 통해 코드를 보려고 한다.
목표
1. Disruptor 의 코드를 이해한다.
1. Disruptor 핵심 UML 살펴보기 + User Guide 초입

이전에 보았던 논문에서는 Disruptor에서는 3개의 부분으로 나누어서 관리를 하고 있기 때문에 성능이 좋다고 말했다.
저장공간, 생산자 그리고 소비자를 분리했다.
문제는 이것들이 있어도 솔직히 이해를 잘 못하겠다.
그래... 다 관심사를 나눠서 성능 향상을 도모한건 좋은데, 어떻게 내부적으로 돌아가는지를 모르겠다.
우선 핵심적인 Interface 들과 Producer, Consumer 관련 설명을 User-Guide에서 알아보았다.
Discuptor의 도메인의 유비쿼터스 언어에 대해 생각해보자.
Ring Buffer
링 버퍼란 고정된 크기의 버퍼를 순환적으로 사용하는 자료구조이다.
여기 Disruptor에서는 3.0 버전 이상부터는 Ring Buffer는 Disruptor를 통해 움직이는 데이터를 저장하거나 수정하는것만 담당한다.
Sequence
Disruptor는 시퀀스(Sequence)를 특정 컴포넌트가 어디에 있는지를 식별하는 수단으로 사용한다.
각각의 소비자(Consumer - Event Processor)는 Disruptor 그것 자체로 Sequence로 동작하도록 한다.
동시성 코드의 대다수는 이 시퀀스 값(Sequence Value)의 움직임에 좌우되며 따라서 Sequence는 AtomicLong의 많은 특징을 지원한다.
AtomicLong과의 유일한 차이점은 Sequence는 Sequence들과 다른 값들 사이에 가짜공유(false sharing)을 막는 추가적인 기능이 있다는 점이다.
글을 읽어보니 Sequence 가 하나가 아닌 여러개로 이해된다.
Sequencer
Sequencer는 Disruptor의 레알 핵심(real core)이다. 이 인터페이스의 single producer, multi producer 구현체는 producer와 consumer사이에 빠르고 정확한 데이터 전송 동시성 알고리즘을 구현한다.
Sequence Barrier
Sequencer는 main published Sequence에서 종속 consumer Sequence들과 Sequence 까지 참조를 포함한 Sequence Barrier를 생성한다. 배리어에는 컨슈머가 처리할 수 있는 이벤트가 있는지 여부를 판단하는 로직이 포함되어 있다.
Wait Strategy
대기 전략은 소비자가 생산자가 디스럽터에 이벤트를 배치할 때까지 기다리는 방식을 결정한다.
자세한 내용은 선택적 락 프리(lock-free) 관련 섹션을 참조하십시오. (아 뭐야... 더 정리해주지...)
위에 UML을 다시 봐보면 WaitStrategy의 구현체를 볼 수 있는데 SpinWait, YieldWait, BlockingWaitStrategy가 있다.
BlockingWaitStrategy를 통해서 선택적으로 실제 lock을 구현할 수 있다고 설명한다.

BlockingWaitStrategy는 consumer thread가 새로운 이벤트가 도착할 때까지 대기할 수 있도록 조건을 설정하기 위해서만 사용한다고 한다.
Event Processor
Disruptor로부터 이벤트들을 관리하고 consumer의 Sequence의 소유권을 가진 메인 이벤트 루프.
BatchEventProcessor라고 불리는 단일 표현(single representation)이 있는데 event loop의 효과적인 구현을 포함하고EventHandler 인터페이스의 사용자 제공 구현체로 callback할 수 있다.
Event Handler
User에 의해 구현된 인터페이스이고 Disruptoir의 소비자를 나타낸다.
Producer
이벤트를 큐에 추가하기 위해 디스럽터를 호출하는 사용자 코드. 이 개념 역시 코드상으로는 표현되지 않습니다.
2. ProducerBarriers

이것을 trishagee 님 께서 그려놓은 그림이 내가 보기 힘들어서 다시 그린 그림이다.
엄연히 따지면 6과 9 사이에 7, 8이 있어야 하지만 그리기 힘들어서 무시했다. 있다고 생각을 바란다.
재미있는 점은 Producer, consumer에 대해 열심히 설명했는데 정작 Producer는 실제로 코드로 구현된 것이 아닌 개념일 뿐이다.
해당 섹션인 ProducerBarriers에서 interface가 없다며 이에 대한 이유를 설명한다.
그 어느것도 Producer에 접근할 필요가 없이 우리는 그것에 대해서만 알면 되기 때문이다.
하지만 이를 소비하는 입장(consuming side)같이 Producer Barrier는 ring buffer에 의해 생성되고 producer는 쓰기를 위해 이를 사용한다.
ring buffer에 쓰기작업을 하기위해선 두 단계의 commit을 한다.
첫째, producer가 버퍼에서 다음 slot을 요청해야한다.
둘째, producer가 slot쓰기를 끝냈다면 ProducerBarrier에 commit을 호출한다(call).
단순하게 ProducerBarrier에서 nextEntry를 호출하면 next slot을 받아오게된다. return 은 Entry로 준다.
3. The ProducerBarrier makes sure the ring buffer doesn't wrap
ProducerBarrier는 어떤게 next slot인지 알고 쓰기를 할 수 있는지 찾는다.
우선 상황을 부여하자. ring buffer에 쓰기 작업을 하는데 하나의 producer만 존재한다고 가정하자.
다중 프로듀서는 뒤에서 다룬다.

ConsumerTrackingProducerBarrier는 Ring Buffer에 접근중인 Consumer의 목록을 가지고 있다.
Consumer 1는 sequence number 12에 있고, Consumer 2는 sequence number가 3이다.
Consumer 2는 Consumer 1보다 약간 뒤에 있는데 Consumer1을 따라잡으려면 잔체 길이만큼 가야 한다.
Producer는 현재 Sequence 3이 점유하고 있는 링 버퍼의 슬롯에 쓰기를 원합니다.
왜냐하면 이 슬롯은 현재 링 버퍼 커서 바로 다음 슬롯이기 때문입니다. 하지만 ProducerBarrier는 소비자가 해당 슬롯을 사용 중이므로 쓸 수 없다는 것을 알고 있다.
따라서 ProducerBarrier는 소비자가 다음 단계로 넘어갈 때까지 대기하며 계속 작동합니다.
그림에서 보면 wait가 계~속 돌아서 Producer Barrier 내에서 대기중인것이다.
4. Claiming the next slot
이제 Consumer 2가 entry를 끝냈다면 sequence number를 옮긴다.
그리고 그림에서 9까지 갔다고 해보자.

ProducerBarrier는 next slot을 바라본다. 그리고 이전의 3번 sequence number slot이 사용이 가능하다.
그러면 이제 다음 번호로 이걸 채번해놓는다.

5. Committing the new value
두 번째 단계는 commit 이다.

프로듀서가 엔트리에 데이터를 기록하는 작업을 완료하면 ProducerBarrier에 커밋하라고 알린다.
ProducerBarrier는 링 버퍼 커서가 현재 위치까지 따라잡을 때까지 기다립니다.
(단일 프로듀서의 경우 이 과정은 다소 무의미할 수 있습니다. 예를 들어 커서가 이미 12에 있고 다른 프로듀서가 링 버퍼에 데이터를 기록하고 있지 않은 경우).
그런 다음 ProducerBarrier는 업데이트된 엔트리의 시퀀스 번호(이 경우 13)로 링 버퍼 커서를 업데이트합니다.
다음으로 ProducerBarrier는 ConsumerBarrier의 WaitStrategy를 호출하여 버퍼에 새로운 데이터가 있음을 소비자에게 알립니다. 즉, "이봐, 깨어나! 무슨 일이 생겼어!"라고 알리는 것입니다. (참고: WaitStrategy 구현 방식에 따라 블로킹 여부 등 처리 방식이 다릅니다.)
이제 consumer 1은 entry 13을 얻을 수 잇고 consumer 2는 13까지 포함해서 모두 가 접근 가능하다.
여기까지는 소비자 2개를 예를 들어서 설명을 진행하고 있다.
Consumer1 이 빠른 소비자라면, Consumer 2는 느린 소비자이다.
Ring Buffer의 크기는 10으로 Ring Buffer Cursor 가 현재 12까지 해서 Sequence Number를 12까지 채번했는데,
Consumer2가 앞으로 가게 되면 ProducerBarrier가 이를 보고 채번을 한다.
즉, 공간 예약과 데이터 발행을 분리(2단계 커밋)함으로써 부분적으로 기록된 쓰레기 값을 소비자가 읽는 것을 방지하고
한 번의 상태 변경(Cursor 업데이트)만으로 속도가 다른 여러 소비자가 병목 없이 각자의 분량을 처리하게 만드는 아키텍처를 설명하고있다. (제미나이가 정리 도와줌 ㅎ)
이 뒤에 작동 방식에 대해서는 번역기를 돌려서 보도록 하고, 그림을 이쁘게 그리고 이해하는데 집중했다.
6. ProducerBarrier batching
흥미롭게도 디스럽터는 컨슈머 측뿐 아니라 프로듀서 측에서도 배치 처리를 할 수 있다.
컨슈머 2가 마침내 프로그램 내용을 이해하고 시퀀스 9에 도달했던 때를 기억하시나요? (네! 네! 선장님!)
프로듀서-배리어는 여기서 매우 영리한 방법을 사용합니다. 버퍼 크기와 가장 느린 컨슈머의 위치를 알고 있기 때문에 현재 사용 가능한 슬롯을 파악할 수 있습니다.
프로듀서-배리어가 링 버퍼 커서가 12에 있고 가장 느린 컨슈머가 9에 있다는 것을 알고 있다면, 컨슈머의 위치를 확인하기 전에 프로듀서가 슬롯 3, 4, 5, 6, 7, 8에 쓰기를 허용할 수 있습니다.
7. Multi Producers
이제 다중 프로세서인 경우 어떻게 동작하는지 설명한다.
위 그림들 중 일부에서 약간 잘못된 정보를 드렸습니다. (???)
ProducerBarrier가 처리하는 시퀀스 번호가 링 버퍼의 커서에서 직접 오는 것처럼 설명했지만, 코드를 자세히 보면 ClaimStrategy를 사용하여 이 번호를 가져오는 것을 알 수 있습니다. 다이어그램을 단순화하기 위해 이 부분을 생략했지만, 단일 프로듀서의 경우에는 그다지 중요하지 않습니다.
여러 프로듀서가 있는 경우에는 시퀀스 번호를 추적하는 또 다른 요소가 필요합니다. 이는 쓰기 가능한 시퀀스 번호입니다. 여기서 중요한 것은 이 시퀀스 번호가 링 버퍼 커서에 하나 더 추가된 것과는 다르다는 점입니다. 버퍼에 쓰기 작업을 하는 프로듀서가 둘 이상인 경우, 아직 커밋되지 않은 쓰기 진행 중인 항목이 있을 수 있습니다.

슬롯 할당 과정을 다시 살펴보겠습니다. 각 생산자는 ClaimStrategy에 사용 가능한 다음 슬롯을 요청합니다.
생산자 1은 위에서 설명한 단일 생산자 사례처럼 순서 번호 13을 받습니다. 생산자 2는 순서 번호 14를 받습니다. 링 버퍼 커서가 여전히 12를 가리키고 있더라도 ClaimSequence가 할당된 번호를 추적하면서 순차적으로 번호를 부여하기 때문입니다.
이렇게 각 생산자는 새로운 순서 번호가 부여된 고유한 슬롯을 갖게 됩니다.
생산자 1과 해당 슬롯은 녹색으로, 생산자 2와 해당 슬롯은 약간 분홍빛이 도는 보라색으로 표시하겠습니다.

이제 프로듀서 1이 딴생각에 빠져 어떤 이유로든 커밋을 하지 못했다고 가정해 봅시다. 프로듀서 2는 커밋할 준비가 되었고, ProducerBarrier에게 커밋을 요청합니다.
앞서 커밋 다이어그램에서 보았듯이, ProducerBarrier는 링 버퍼 커서가 커밋하려는 슬롯 바로 다음 슬롯에 도달했을 때만 커밋합니다.
이 경우 커서가 13에 도달해야 14를 커밋할 수 있습니다. 하지만 프로듀서 1이 아직 커밋하지 않았기 때문에 커서가 13에 도달할 수 없습니다. 따라서 ClaimStrategy는 링 버퍼 커서가 필요한 위치에 도달할 때까지 계속 대기합니다.

이제 프로듀서 1이 혼수상태에서 깨어나 13번째 항목을 커밋해달라고 요청합니다(녹색 화살표는 프로듀서 1의 요청에 따라 표시됩니다).
ProducerBarrier는 ClaimStrategy에게 링 버퍼 커서가 12에 도달할 때까지 기다리라고 지시하는데, 물론 이미 12에 도달한 상태입니다. 따라서 링 버퍼 커서는 13으로 증가하고, ProducerBarrier는 WaitStrategy를 호출하여 링 버퍼가 업데이트되었음을 알립니다. 이제 ProducerBarrier는 프로듀서 2의 요청을 완료하고 링 버퍼 커서를 14로 증가시킨 후 모든 프로세스에 완료를 알립니다.
링 버퍼는 프로듀서들이 서로 다른 시간에 쓰기를 완료하더라도 초기 nextEntry() 호출 순서에 따라 정렬된 순서를 유지하는 것을 볼 수 있습니다. 이는 프로듀서가 링 버퍼에 쓰기를 일시 중지하더라도, 해당 프로듀서가 차단 해제되면 다른 보류 중인 커밋이 즉시 처리될 수 있음을 의미합니다.
이렇게 2011년 7월 글을 읽었는데, 사실 이거 읽어보는거를 포함해서 실제로 코드로 어떻게 작동하는지 가장 최신 버전에서 위에서 언급된거랑 비교를 할 예정이었느데 글이 너무 길어져서... 다음에 이어서 실제 테스트를 해볼 것이다.
생각해보면 건방지게도 ring buffer가 그렇게 어려운 개념은 아닌거같다.
그래도 뭔가 문제점과 흐름을 알게 되어서 흥미진진했고, 정말로 다음에는 코드를 이용해서 성능을 비교해볼 것이다.
출처:
https://central.sonatype.com/artifact/com.lmax/disruptor?smo=true
https://lmax-exchange.github.io/disruptor/user-guide/index.html
https://medium.com/garantibbva-teknoloji/lmax-disruptor-as-an-architectural-pattern-9719c803a1a5
https://trishagee.com/2011/08/30/disruptor_20__all_change_please/