트레이딩 시스템, 커널 드라이버, 고처리량 데이터베이스에서 쓰는 SPSC(단일 생산자-단일 소비자) 락프리 링 버퍼를 C로 구현하며 제로-카피와 메모리 오더링의 핵심을 설명한다.
URL: https://x.com/Adriksh/status/2018481305441419476
제로-카피를 듣고 “그래 그래 ㅋㅋ” 싶었다면, 이 글이 딱이다. 트레이딩 시스템, 커널 드라이버, 고처리량 데이터베이스는 메모리를 복사하느라 몇 마이크로초라도 낭비할 수 없을 때 이 패턴을 쓴다. 여기서는 이론이 아니라, 실제로 동작하는 C 기반의 락프리 링 버퍼를 만든다.
프로듀서–컨슈머 문제는 어디에나 있다. 한 스레드가 데이터를 만들고, 다른 스레드가 처리한다. 순진한 접근? 큐에 뮤텍스 하나 씌우고 끝. 하지만 함정이 있다. 뮤텍스는 엄청 비싸다. 컨텍스트 스위치, 커널 개입, 캐시 코히어런시 트래픽… 초당 수백만 메시지를 밀어 넣으려면 이런 비용이 전부 누적된다.
그리고 복사 문제도 있다. 매 메시지마다 malloc() 하고 memcpy()로 데이터를 옮기면 CPU 사이클을 그냥 태우는 셈이다. 제로-카피란 데이터를 한 번만, 제자리에서 쓰고, 컨슈머가 같은 위치에서 직접 읽게 하는 것이다.
링 버퍼는 매우 단순하다: 고정 크기 배열, head 인덱스, tail 인덱스. 프로듀서는 head에 쓰고, 컨슈머는 tail에서 읽는다. 배열 끝에 도달하면 0으로 다시 감긴다(wrap). 그게 전부다.
아래가 실제 코드다:
c#include <stdatomic.h> #include <stdint.h> #define BUFFER_SIZE 1024 #define CACHE_LINE 64 typedef struct { uint8_t data[BUFFER_SIZE]; alignas(CACHE_LINE) atomic_size_t head; alignas(CACHE_LINE) atomic_size_t tail; } ring_buffer_t;
alignas(CACHE_LINE) 보이나? 이건 false sharing(가짜 공유) 를 막기 위한 것이다. head와 tail이 같은 캐시 라인에 있으면 프로듀서와 컨슈머가 서로의 캐시를 계속 무효화하면서 성능이 박살난다. 캐시 라인 핑퐁은 악이다.
cbool ring_push(ring_buffer_t *rb, uint8_t *src, size_t len) { size_t head = atomic_load_explicit(&rb->head, memory_order_relaxed); size_t tail = atomic_load_explicit(&rb->tail, memory_order_acquire); size_t available = (tail - head - 1) & (BUFFER_SIZE - 1); if (len > available) return false; // 제로-카피: 링 버퍼에 직접 기록 for (size_t i = 0; i < len; i++) { rb->data[(head + i) & (BUFFER_SIZE - 1)] = src[i]; } atomic_store_explicit(&rb->head, (head + len) & (BUFFER_SIZE - 1), memory_order_release); return true; }
malloc 없음. memcpy 없음. 버퍼에 바로 쓴다. 여기서 제로-카피가 의미하는 바는 진짜로 이것이다: 한 번 쓰고, 한 번 읽고, 중간 복사 없음.
cbool ring_pop(ring_buffer_t *rb, uint8_t *dst, size_t len) { size_t tail = atomic_load_explicit(&rb->tail, memory_order_relaxed); size_t head = atomic_load_explicit(&rb->head, memory_order_acquire); size_t available = (head - tail) & (BUFFER_SIZE - 1); if (len > available) return false; // 제로-카피: 링 버퍼에서 직접 읽기 for (size_t i = 0; i < len; i++) { dst[i] = rb->data[(tail + i) & (BUFFER_SIZE - 1)]; } atomic_store_explicit(&rb->tail, (tail + len) & (BUFFER_SIZE - 1), memory_order_release); return true; }
컨슈머는 버퍼에서 바로 읽고 tail을 진행한다. 프로듀서와 컨슈머는 같은 atomic 변수에 대해 둘 다 쓰지 않으므로 서로를 블로킹하지 않는다.
핵심은 이거다. 다른 스레드의 인덱스를 로드할 때는 memory_order_acquire가 필요하고, 자기 인덱스를 저장할 때는 memory_order_release가 필요하다.
왜냐? CPU와 컴파일러는 명령을 재배치(reorder)한다. 프로듀서가 atomic_store(&head, new_head, memory_order_release)를 하면, 그 이전의 모든 쓰기(실제 데이터)가 head 업데이트보다 먼저 관측되도록 보장한다. 컨슈머가 atomic_load(&head, memory_order_acquire)로 읽으면 그 쓰기들을 보게 된다.
반대로 memory_order_relaxed만 여기저기 쓰면, 컨슈머가 업데이트된 head는 봤는데 데이터는 낡은 값을 읽을 수도 있다. 그건 데이터 레이스고, 프로그램은 정의되지 않은 동작(UB) 영역으로 간다. 별로 멋지지 않다.
패턴은 다음과 같다:
미묘하지. 그래서 락프리 코드는 매콤하다.
락이 없으면 syscall이 없다. 스케줄러 개입도 없다. 할당이 없으면 힙도 안 건드린다. 복사가 없으면 캐시가 뜨겁게 유지된다. 이건 보통의 하드웨어에서도 초당 수백만 개의 작은 메시지를 밀어 넣을 수 있다.
트레이딩 시스템은 이걸 마켓 데이터에 쓴다. 데이터베이스 WAL 구현은 로그 쓰기에 쓴다. 커널은 디바이스 I/O 버퍼에 쓴다. 레이턴시 퍼센타일이 진짜 중요할 때—p99가 50µs가 아니라 10µs면 돈이 되는 상황—이 패턴이 이긴다.
이 구현은 단일 생산자, 단일 소비자(SPSC) 에만 해당한다. 생산자가 여러 명이면 CAS 루프가 필요해지고 훨씬 복잡해진다. 버퍼는 고정 크기라 동적으로 늘릴 수도 없다. 그리고 락프리 코드는 디버깅이 지옥이다. 버그가 비결정적이고 부하가 걸릴 때만 나타나는 경우가 많다.
하지만 SPSC 워크로드에서는 사실상 골드 스탠더드다. 레이턴시가 진짜로 중요할 때 C가 아직도 이기는 이유다. 메모리 레이아웃, 캐시 동작, 동기화 프리미티브를 전부 통제할 수 있다. 런타임 없음, GC 멈춤 없음, 깜짝 놀랄 일 없음.
마음껏 고쳐 봐라. 버퍼 크기도 바꿔보고(2의 거듭제곱이 모듈로 트릭에 가장 좋다), 뮤텍스 기반 큐와 벤치마크도 해 보고, 망가뜨렸다가 고치고, 네 것으로 만들어라. 코드는 단순하지만 원리는 보편적이다.
행운을 빌며, 당신의 캐시 라인이 항상 뜨겁기를.