고성능 병렬 큐 (High-Performance Parallel Queue)

병렬 큐(Parallel Queue) 설계의 기초 통합 자료입니다. 커널 내부에 국한하지 않고 커널-커널, 커널-사용자, 사용자-사용자, 하드웨어 가속기, 분산 경계까지 아우르는 제출(submit)·완료(completion) 동기화 큐의 설계 공간을 정리합니다. 큐 타입 분류(SPSC/MPSC/SPMC/MPMC), 시나리오별 고려 사항과 대응 방안, 기존 구현 활용 방향과 완전 직접 구현(내재화) 방향을 모두 다루어, 독자가 자신의 요구에 맞는 조합을 선택할 수 있도록 돕는 것이 목적입니다.

전제 조건: Lock-free 자료구조와 메모리 배리어 / 메모리 모델 문서를 먼저 읽으세요. 병렬 큐의 성능과 정확성은 원자적(Atomic) 연산, 메모리 순서, 캐시(Cache) 동작에 의존하므로, lock-free 원리와 메모리 모델을 먼저 이해해야 합니다.
일상 비유: 이 개념은 공항 수하물 컨베이어 벨트와 비슷합니다. 작업 제출은 수하물을 벨트에 올리는 일이고, 완료 회수는 수하물이 나오는 것을 확인하는 일입니다. 벨트가 하나(K2K 단순 경로)일 수도, 여러 항공편(가속기·사용자 프로세스(Process))으로 분산될 수도 있으며, 벨트가 가득 차면 투입을 멈추는 백프레셔(Backpressure)가 필요합니다.

핵심 요약

  • 병렬 큐 — 생산자(Producer)와 소비자(Consumer)의 수에 따라 SPSC·SPMC·MPSC·MPMC 네 가지로 분류되는 동시성 자료구조입니다.
  • 제출·완료 비대칭 — 1P:N 제출은 분할이 유리하고, 완료 큐는 항상 MPSC 이상이므로 제출과 완료의 큐 타입이 다릅니다.
  • 링 버퍼(Ring Buffer) — 고정 크기 배열과 head/tail 인덱스로 구현하는 대표적인 lock-free 큐 형태입니다.
  • 알림 방식 — 폴링(Polling) 중심의 데이터 경로와 이벤트(eventfd·futex·IRQ) 중심의 컨트롤 경로를 구분해야 합니다.
  • 활용 vs 내재화 — 기존 구현(kfifo·llist·Ptr Ring·io_uring·AF_XDP·virtio·DPDK)을 조합하는 방향과 Vyukov 큐처럼 직접 구현하는 방향은 요구 사항에 따라 선택합니다.

단계별 이해

  1. 큐 타입 정하기
    생산자와 소비자의 수, 순서 보장(Ordering) 요구로 SPSC/MPSC/SPMC/MPMC 중 하나를 정합니다. 타입이 틀리면 이후 설계가 전부 무너집니다.
  2. 토폴로지(Topology) 선택
    단일 공유 큐(MPMC)와 생산자가 분산해서 쓰는 분할 큐(SPSC 팬아웃) 중에서 확장성과 공정성(Fairness) 요구를 기준으로 고릅니다.
  3. 알림과 흐름 제어(Flow Control)
    폴링·이벤트·하이브리드 알림을 정하고, 큐가 가득 찼을 때의 동작(블록/드롭/오버플로)을 정의합니다.
  4. 활용 또는 내재화
    기존 구현이 요구를 충족하면 재사용하고, 맞지 않으면 직접 구현한 뒤 LKMM/litmus와 KCSAN으로 검증합니다.

개요

고성능 병렬 큐는 "작업을 넘겨주는 쪽(생산자)과 처리하는 쪽(소비자)이 서로 다른 실행 흐름에 있을 때, 최소한의 동기화 비용으로 작업과 결과를 주고받는" 구조입니다. 구체적으로는 다음 두 가지 기능이 항상 함께 필요합니다.

이 문서에서 다루는 범위는 다음과 같습니다.

성능 수치 표기는 이 사이트의 규칙(재현 환경·출처 명시 원칙)을 따르며, 수치가 필요한 부분은 정성적 표현과 조건부 서술을 우선 사용합니다.

병렬 큐의 분류와 용어

병렬 큐는 생산자(Producer)와 소비자(Consumer)의 수 조합으로 분류합니다. S는 Single(단일), M은 Multiple(다중)을 뜻하며, 첫 글자가 생산자, 둘째 글자가 소비자입니다.

소비자(Consumer) 1개 소비자(Consumer) 여러 개 생산자 (Producer) 1개 생산자 (Producer) 여러 개 SPSC 경합 없음 · 순서 보장 자동 P 큐 C 예: per-CPU 통신, kfifo 기본 모드 SPMC 모든 소비자가 같은 흐름 구독 P 큐 C1 C2 예: 브로드캐스트 로그, 구독자별 복사 MPSC 꼬리 인덱스 경합만 존재 P1 P2 큐 C 예: llist, io_uring 완료 큐 MPMC 머리·꼬리 모두 CAS 경합 P1 P2 큐 C1 C2 예: DPDK rte_ring, Vyukov 큐 P = 생산자(Producer) · C = 소비자(Consumer) 제출 큐(submit)와 완료 큐(completion)는 서로 다른 타입이 되는 것이 일반적입니다
생산자·소비자 수 조합에 따른 네 가지 큐 타입(개념 개요). 제출 큐와 완료 큐는 서로 다른 타입이 되는 것이 일반적입니다.

인큐(Enqueue)는 큐에 항목을 넣는 연산, 디큐(Dequeue)는 항목을 꺼내는 연산입니다. 이 문서에서는 제출 큐의 인큐를 "제출", 완료 큐의 인큐를 "완료 게시", 완료 큐의 디큐를 "완료 회수"로 표현합니다. 두 큐가 한 쌍으로 동작하는 구조는 제출·완료 구조 절에서 자세히 다룹니다.

링 버퍼: 큐를 배열로 구현하는 원리

병렬 큐의 대부분은 고정 크기 배열을 원형으로 돌려 쓰는 링 버퍼(Ring Buffer)입니다. 배열 앞에서 항목을 꺼낼 때마다 나머지 항목을 앞으로 당기면 항목 수에 비례하는 복사 비용이 들기 때문에, 대신 head(다음에 쓸 위치)와 tail(다음에 읽을 위치) 두 개의 인덱스만 기억해 항목을 전혀 옮기지 않습니다.

핵심은 인덱스를 0부터 다시 시작하지 않고 절대 카운터(Absolute Counter)로 계속 증가시키고, 배열 첨자만 나머지 연산으로 감싸는 것입니다. head와 tail이 "몇 바퀴를 돌았는지"를 함께 운반하므로 head == tail은 빈 상태, head - tail == size는 가득 찬 상태를 정확히 표현합니다. 만약 인덱스를 크기로 나눈 나머지만 저장하면 두 상태를 구분할 수 없어, 슬롯 하나를 항상 비워 두거나 별도의 길이 카운터가 필요해집니다.

원형 배열과 head·tail 인덱스 0 1 2 tail 3 4 5 head 6 7 사용 중 (tail→head) 비어 있음 (head→tail) 인덱스의 의미 tail — 다음에 읽을 위치 소비자(Consumer)만 증가 · 절대 카운터 head — 다음에 쓸 위치 생산자(Producer)만 증가 · 절대 카운터 빈 상태 · 가득 찬 상태 판정 head == tail → 비어 있음 head - tail == size → 가득 참 슬롯 번호 계산 (되감기 없음) 슬롯 = 카운터 & (size - 1) 예: 카운터 8 → 슬롯 0 (8 & 7) 사용 중 (tail→head) — 아직 읽지 않은 항목 비어 있음 (head→tail) — 새 항목을 쓸 수 있는 공간
링 버퍼의 구간 개념(개념 개요). 슬롯 2·3·4(어두운 배경)가 아직 읽지 않은 사용 중 구간이고, head부터 tail까지가 새 항목을 채울 수 있는 빈 구간입니다. head와 tail은 되감기 없이 계속 증가하며 배열 첨자만 마스킹합니다.

배열 크기를 2의 거듭제곱으로 고정하면 나머지 연산이 비트 AND 한 번으로 줄어듭니다. i % size 대신 i & (size - 1)을 쓰는 이유입니다. 리눅스 커널의 kfifo도 이 설계를 그대로 따르며, 아래가 실제 구조(include/linux/kfifo.h, v6.12)입니다.

/* include/linux/kfifo.h (v6.12) — kfifo의 핵심 구조 */
struct __kfifo {
    unsigned int in;      /* 게시(put)된 항목 수 — 절대 카운터 */
    unsigned int out;     /* 소비(get)된 항목 수 — 절대 카운터 */
    unsigned int mask;    /* 크기 - 1 (크기는 반드시 2의 거듭제곱) */
    unsigned int esize;   /* 요소 하나의 바이트 크기 */
    void        *data;   /* 버퍼 시작 주소 */
};

/* 실제 매크로는 문장 표현식(statement expression) 형태지만 의미는 아래와 같습니다 */
#define kfifo_is_empty(fifo) ((fifo)->kfifo.in == (fifo)->kfifo.out)
#define kfifo_is_full(fifo)  ((fifo)->kfifo.in - (fifo)->kfifo.out > (fifo)->kfifo.mask)
코드 설명
  • in · out두 카운터는 부호 없는 정수로 계속 증가하며 "몇 바퀴 돌았는지"를 포함합니다. 배열 첨자는 in & mask처럼 마스킹만 합니다.
  • 빈/가득 판정in == out이면 비어 있고, in - out > mask(길이가 크기에 도달)면 가득 찬 상태입니다. 절대 카운터 덕분에 슬롯을 낭비하지 않습니다.
  • SPSC 전제헤더 주석대로 "읽는 쪽 1명·쓰는 쪽 1명"일 때만 잠금 없이 안전합니다. 생산자/소비자가 여럿이면 스핀락을 얹습니다.

왜 잠금으로는 부족한가: lock-free의 개념

이 절은 Lock-free 자료구조와 메모리 배리어 / 메모리 모델 문서의 핵심을, 큐 설계에 필요한 만큼만 다시 정리한 것입니다.

큐 연산(인큐·디큐) 전체를 잠금(Lock)으로 감싸면 정확성은 쉽게 얻을 수 있습니다. 문제는 성능입니다. 잠금을 쓰면 잠금 변수를 둘러싼 캐시라인(Cache Line)의 소유권(Ownership)이 CPU 사이를 옮겨 다니며, 잠금을 잡지 못한 CPU는 홀더가 해제할 때까지 멈춰 있어야 합니다. 코어 수가 늘수록 이 대기 시간(Latency)이 누적되어, 오히려 처리량이 줄어드는 구간이 생깁니다.

lock-free는 "잠금을 아예 쓰지 않고, 원자적 연산(Atomic Operation)으로 공유 상태를 한 번에 갱신"하는 방식입니다. 대표적인 원자적 연산이 CAS(Compare-and-Swap)입니다. CAS는 "메모리 값이 내가 기대한 값과 같으면 새 값으로 교체하고 성공을, 다르면 아무것도 바꾸지 않고 실패를 돌려주는" 단일 연산입니다.

/* CAS(Compare-and-Swap) — 읽고-비교하고-쓰는 동작을 하나의 원자적 연산으로 */
int old = atomic_read(&x);            /* 기대값 읽기 */
int cur = atomic_cmpxchg(&x, old, old + 1);  /* x == old 이면 old+1 로 교체 */
if (cur == old) {
    /* 성공 — x를 갱신한 유일한 실행 흐름 */
} else {
    old = cur;   /* 실패 — 누군가 먼저 갱신함. 최신 값으로 즉시 재시도 */
}

CAS가 성공하는 실행 흐름은 순간에 하나뿐이지만, 실패한 쪽은 "되어 있는 값"을 읽고 즉시 재시도하면 되므로 잠금처럼 남의 해제를 기다리지 않습니다. 이 성질 덕분에 잠들 수 없고 선점(Preemption)될 수도 없는 콘텍스트(예: 인터럽트, NMI)에서도 사용할 수 있습니다. 커널에서 lock-free는 흔히 시스템 전체의 진행을 의미합니다. 즉 어떤 실행 흐름이 중간에 죽거나 멈춰도 나머지 흐름은 반드시 진행할 수 있어야 합니다(더 강한 보장인 wait-free는 각 흐름이 유한한 단계 안에 끝납니다). 이 기준에서 보면 Vyukov 큐는 "모든 흐름의 무한 진행"을 보장하지 않으므로 완전한 lock-free가 아닌데, 이 점은 뒤의 직접 구현(내재화) 방향 절에서 다시 짚습니다.

잠금(Lock) 방식 한 번에 한 CPU만 진입 원자적 연산(CAS) 방식 모두 시도 · 실패자는 즉시 재시도 CPU A CPU B CPU C 공유 카운터 (캐시라인 1개) 잠금 보유 · 진행 대기(스핀) 대기(스핀) 락을 놓을 때까지 나머지 CPU는 스핀하며 대기 CPU D CPU E CPU F 슬롯의 seq 확인 + CAS (슬롯 1개) 성공 실패 실패 재시도 한 번에 하나만 성공 — 실패자는 최신 값을 읽고 즉시 재시도
잠금 방식과 CAS 방식의 차이(개념 개요). 잠금은 "한 명이 일하는 동안 모두가 멈추는" 구조이고, CAS는 "모두가 시도하고 한 번에 하나만 성공하며 실패자는 즉시 재시도"하는 구조입니다.

이 그림에서 보듯 어느 쪽이든 공유되는 캐시라인 자체는 존재하므로, 경합(Contention)을 줄이는 것은 큐 토폴로지(공유 vs 분할)의 몫입니다. 이 점은 설계 고려 축 절에서 다룹니다.

제출·완료 구조와 방향 비대칭

요구 사항이 "1 생산자 : N 소비자"라면, 완료 방향을 함께 설계해야 합니다. N개의 소비자가 각자의 처리를 마치고 결과를 게시하므로 완료 큐는 항상 MPSC 이상이 됩니다. 완료 회수자(Reaper)가 생산자 1명이면 MPSC, 완료 회수자도 여러 명이면 MPMC입니다. 따라서 일반적인 구성은 다음과 같습니다.

생산자(Producer) 작업 제출 1개 흐름 SPSC 제출 링 A SPSC 제출 링 B SPSC 제출 링 C 소비자(Consumer) A 처리 후 완료 게시 소비자(Consumer) B 처리 후 완료 게시 소비자(Consumer) C 처리 후 완료 게시 MPSC 완료 링 완료(completion) 집계 완료 회수자(Reaper) 배치로 디큐
1P:N 제출·완료 구조의 전형. 제출 경로는 SPSC 팬아웃(분할), 완료 경로는 MPSC 집계로 비대칭입니다. 이 그림은 개념 개요이며, 실제 구현에서는 소비자 수만큼 링을 늘리거나 완료 회수자를 여러 개 두는 변형이 가능합니다.

이 구조의 변형으로는 완료 회수자 다중화(Multiplexing)(MPMC 완료 링 + 복수 회수자), 완료 곧바로 재제출(완료 항목이 다음 파이프라인(Pipeline) 단계의 제출 링으로 재인큐되는 형태), 완료 통합·간소화(오류가 있는 항목만 별도 큐로 보내는 형태)가 있습니다. 어떤 변형을 쓰든, 제출과 완료의 큐 타입을 "각각 독립적으로" 결정해야 합니다.

설계 고려 축과 대응 방안

어떤 큐를 만들든 다음 여덟 가지 축을 빠짐없이 점검해야 합니다. 각 축은 서로 독립적이지 않으며, 특히 순서·알림·백프레셔는 토폴로지 선택과 강하게 결합됩니다.

축고려 사항대응 방안
생산자·소비자 수 1:1인지 1:N인지, 완료 회수자가 복수인지에 따라 큐 타입이 결정됩니다. 수 조합을 표로 정리하고, 완료 방향도 별도로 타입을 정합니다.
순서 보장(Ordering) 전역 FIFO가 필요한지, 요청 단위(링크드) 순서면 충분한지에 따라 분할 토폴로지 가능 여부가 갈립니다. 전역 순서가 필수면 공유 큐로 회귀하고, 분할 큐에서는 요청 단위 순서만 보장합니다.
지연(Latency)과 처리량(Throughput) 요청 하나의 왕복 시간이 중요한지, 총 처리량이 중요한지에 따라 배치(batching) 크기가 달라집니다. 배치 디큐·청크 단위 인덱스 예약으로 처리량을 올리고, 필요 시 첫 항목 즉시 처리 경로를 둡니다.
알림 방식 폴링(Polling)은 CPU를 점유하지만 지연이 낮고, 이벤트 알림(eventfd·futex·IRQ)은 CPU를 아끼지만 알림 오버헤드(Overhead)가 있습니다. 데이터 경로는 폴링, 컨트롤 경로는 이벤트. 둘을 합친 하이브리드(알림으로 깨우고 폴링으로 회수)도 널리 쓰입니다.
백프레셔(Backpressure) 큐가 가득 찼을 때 생산자가 블록할지, 항목을 드롭할지, 오버플로 경로로 보낼지 정해야 합니다. 블록은 대기 큐(Wait Queue) 연동, 드롭은 손실 허용 계약, 오버플로는 보조 큐·중단(Backpressure) 신호로 대응합니다.
확장성 코어 수가 늘어날 때 단일 링의 원자적 연산(Atomic Operation) 경합(Contention)이 병목(Bottleneck)이 되는 지점이 있습니다. 공유 MPMC는 소수 코어에, 수십 코어 이상은 per-CPU/per-consumer 분할 큐를 사용합니다.
공정성 특정 생산자·소비자가 계속 실행되어 다른 쪽이 굶는(Starvation) 상황을 막아야 합니다. 배치 예산(최대 처리 개수)과 라운드로빈 스케줄, 우선순위(Priority) 별도 큐로 대응합니다.
메모리 수명 슬롯 재사용 시점과 객체 해제 시점이 어긋나면 use-after-free가 발생합니다. 파워오브투 인덱스와 시퀀스 번호, 오브젝트 풀(objpool) 연동, RCU 또는 hazard pointer로 대응합니다.

위 축 중에서도 토폴로지(공유 vs 분할)는 가장 큰 분기점입니다. 공유 MPMC 링 하나는 구현이 단순하고 순서 보장이 쉽지만, 원자적 연산이 머리·꼬리 인덱스에 집중되어 코어 수가 늘어나면 Lock-free 자료구조 문서의 캐시라인(Cache Line)·거짓 공유(False Sharing) 분석에서 설명하는 확장성 한계에 부딪힙니다. 분할 SPSC 팬아웃은 경합이 없어 대규모 코어에서 유리하지만, 부하 불균형과 순서 약화를 감수해야 합니다.

시나리오: 커널 ↔ 커널

커널 내부에서 커널 스레드(Kernel Thread), 인터럽트(Interrupt) 처리기, softirq, 작업 큐(Workqueue) 워커 사이에 작업을 전달하는 경로입니다. 가장 일반적인 형태는 "하드웨어 인터럽트가 패킷(Packet)·이벤트를 받아서 작업 큐에 밀어넣고, 워커 스레드가 배치로 처리하는" 구조입니다.

고려 사항

기존 구현 활용

직접 구현 방향

시나리오: 커널 ↔ 사용자

커널과 사용자 공간(User Space) 사이의 제출·완료 큐입니다. 이 경계에서는 시스템 콜(System Call) 오버헤드와 데이터 복사 비용이 지배적이며, 이를 제거하기 위해 메모리 맵(Memory Map) 공유 링을 사용합니다.

고려 사항

기존 구현 활용

직접 구현 방향

시나리오: 사용자 ↔ 사용자

같은 호스트에서 서로 다른 프로세스 사이에 공유 메모리로 큐를 만드는 경로입니다. 커널을 거치지 않는 데이터 경로가 목표이므로, 순수 데이터 전송은 lock-free 공유 링 + 알림만 커널(futex)을 사용합니다.

고려 사항

기존 구현 활용

직접 구현 방향

시나리오: 하드웨어 가속기(ASIC·NIC·FPGA·DPU)

NIC, SmartNIC, FPGA 가속기, 보안 처리 ASIC 등 하드웨어와 호스트 CPU 사이의 제출·완료 큐입니다. 하드웨어 큐는 아래 세 가지가 소프트웨어 큐와 근본적으로 다릅니다.

고려 사항

기존 구현 활용

직접 구현 방향

시나리오: 분산·네트워크 경계

RDMA(InfiniBand, RoCE)처럼 네트워크를 가로지르는 제출·완료 큐입니다. 큐 쌍(Queue Pair, QP)은 전송 큐(Send Queue, SQ)와 수신 큐(Receive Queue, RQ)로 구성되며, 완료 큐(Completion Queue, CQ)는 가장 오래된 "하드웨어 제출·완료" 설계 중 하나입니다.

분산 경계에서 큐를 직접 구현하려면 네트워크 전송 계층까지 책임져야 하므로, 일반적으로 RDMA Verbs 또는 메시지 전달 라이브러리를 재사용하고, 자체 큐는 "로컬 송수신 측의 배치 버퍼"로만 관여하는 것이 현실적입니다. 이 문서에서는 InfiniBand / RDMA 문서를 참고 자료로 연결합니다.

기존 구현 활용 방향

기존 구현을 조합해 요구를 충족하는 것이 항상 첫 번째 후보입니다. 자체 구현은 검증·유지보수 비용이 크므로, 아래 표를 기준으로 "왜 기존 구현으로 안 되는지"를 먼저 명확히 하고 넘어가는 것이 원칙입니다.

구현경계큐 타입적합 시나리오
kfifo커널 내부SPSC(기본), 잠금 얹은 MPSC드라이버 수신 버퍼, 단순 1:1 경로
llist커널 내부MPSC생산자 다수·소비자 1명 경로
Ptr Ring (ptr_ring)커널 내부SPSC 최적 + 스핀락으로 다중 허용네트워크 코어 경로
Workqueue (CMWQ)커널 내부MPSC + per-CPU 분산인터럽트 → 워커 분산 처리
objpool커널 내부per-CPU 링 기반 MPMC 풀(v6.7+)요청 객체의 할당·반환(NMI-safe)
io_uring커널 ↔ 사용자SPSC 링 + SQPOLL, MSG_RING고성능 비동기 I/O 인터페이스
AF_XDP커널 ↔ 사용자per-queue SPSC 4링패킷 고속 전달(zero-copy)
virtio / vhost, vDPA사용자 ↔ 사용자, 게스트 ↔ 호스트, 하드웨어virtqueue(Split/Packed)반가상화(Paravirtualization) I/O, HW 오프로드
DPDK rte_ring사용자 ↔ 사용자SPSC/MPSC/SPMC/MPMC공유 메모리 기반 데이터플레인
Completion·대기 큐커널 내부신호(값 없음)완료 알림, 대기·웨이크업

활용 전략의 핵심은 계층 조합입니다. 예를 들어 "커널 인터럽트 → 작업 큐 분산 → 사용자 io_uring 완료 통지"처럼, 각 경계에서 가장 검증된 구현을 골라 잇는 것이 단일 구현을 확장하는 것보다 안전합니다. 기존 구현으로 안 되는 대표적인 경우는 다음과 같습니다.

메모리 순서: 왜 release/acquire가 필요한가

lock-free 큐는 "항목 쓰기 → 인덱스 게시"라는 두 단계로 동작한다고 했습니다. 그런데 컴파일러와 CPU는 서로 관련이 없어 보이는 메모리 접근의 순서를 바꿀 수 있습니다. 이를 순서 재배열(Reordering)이라고 합니다. 다음 코드를 보겠습니다.

/* 잘못된 예 — 순서 보장이 없으면 소비자가 미완성 항목을 볼 수 있음 */
WRITE_ONCE(r->slots[i], item);   /* ① 항목 쓰기 */
WRITE_ONCE(r->head, i + 1);   /* ② 인덱스 게시 */

소비자는 head가 i+1이 된 것을 확인한 뒤에야 슬롯을 읽습니다. 그런데 CPU가 ②를 먼저 실행하거나(원인 중 하나는 CPU 내부의 쓰기 버퍼(Store Buffer)입니다), 소비자 CPU가 ②의 새 값을 먼저 보게 되면? 소비자는 아직 ①의 쓰기가 끝나지 않은 슬롯을 읽게 됩니다. 정확한 큐라면 절대 있어서는 안 되는 동작입니다.

이것을 막는 것이 release/acquire 한 쌍입니다. release 쓰기는 "이 쓰기보다 앞선 모든 메모리 쓰기가, 이 값을 읽는 쪽에는 반드시 먼저 보이도록" 보장하고, acquire 읽기는 "이 읽기가 관측한 값 이후의 모든 메모리 읽기가, 그 값이 게시한 상태를 반드시 보도록" 보장합니다. 정리하면 다음과 같습니다.

/* 올바른 예 — 항목 쓰기(일반 쓰기) 다음에 release로 게시 */
WRITE_ONCE(r->slots[i], item);          /* ① 항목 쓰기 */
smp_store_release(&r->head, i + 1);    /* ② release 게시 */

/* 소비자: acquire로 head를 읽으면, 그 뒤의 슬롯 읽기는 게시된 항목을 반드시 봄 */
u32 head = smp_load_acquire(&r->head);   /* ③ acquire 확인 */

여기서 WRITE_ONCE/READ_ONCE는 "한 번의 메모리 접근으로 읽고 쓴다"는 보장으로, 중간 값을 쪼개서 보는 일을 막습니다. 이 보장만으로는 순서가 잡히지 않으므로 release/acquire와 함께 써야 합니다. 구체적인 게시·확인 순서와 다이어그램은 아래 직접 구현(내재화) 방향 절의 SPSC 예제에서 다룹니다. 재배열이 왜 일어나는지(스토어 버퍼, 파이프라인)와 더 많은 사례는 메모리 배리어 / 메모리 모델 문서를 참조합니다.

직접 구현(내재화) 방향

직접 구현을 선택했다면, 이 절의 알고리즘 카탈로그에서 시작합니다. 모든 직접 구현은 다음 원칙을 따른 것입니다: 데이터 경로에 잠금을 두지 않고, 원자적 연산과 메모리 배리어(Memory Barrier)로 순서를 보장합니다.

SPSC 링(가장 기본)

head/tail 인덱스를 각각 생산자·소비자가 독점해서 갱신하면 원자적 연산이 거의 필요 없습니다. 항목 게시는 smp_store_release, 게시 확인은 smp_load_acquire로 합니다.

/* 단일 생산자·단일 소비자(SPSC) 링 — 인덱스만 순서 보장하면 됩니다 */
struct spsc_ring {
    u32 size;              /* 2의 거듭제곱 크기 */
    u32 mask;
    u32 head;             /* 생산자 전용 기록 지점 */
    u32 tail;             /* 소비자 전용 기록 지점 */
    void *slots[];        /* 항목 배열 */
};

bool spsc_push(struct spsc_ring *r, void *item)
{
    u32 head = READ_ONCE(r->head);
    u32 tail = READ_ONCE(r->tail);

    if ((head - tail) >= r->size)
        return false;          /* 가득 참 — 백프레셔 */
    WRITE_ONCE(r->slots[head & r->mask], item);
    smp_store_release(&r->head, head + 1);  /* 항목 게시 */
    return true;
}

void *spsc_pop(struct spsc_ring *r)
{
    void *item;
    u32 tail = READ_ONCE(r->tail);
    u32 head = smp_load_acquire(&r->head);

    if (tail == head)
        return NULL;           /* 비어 있음 */
    item = READ_ONCE(r->slots[tail & r->mask]);
    WRITE_ONCE(r->tail, tail + 1);     /* 슬롯 반환 */
    return item;
}
코드 설명
  • head/tailhead는 생산자만, tail은 소비자만 갱신하므로 두 인덱스가 동시에 쓰이지 않습니다. 슬롯 배열은 항목 포인터를 저장하며, 인덱스는 2의 거듭제곱으로 마스킹해 wrap-around를 처리합니다.
  • 가득 참 판정(head - tail)이 size 이상이면 가득 찬 것으로 보고 false를 반환합니다. 호출자는 이 때 블록하거나 드롭하거나 다른 경로로 보냅니다.
  • release/acquire항목 쓰기(W) 다음에 release로 head를 게시하면, 소비자의 acquire head 읽기가 항목 쓰기를 반드시 관찰합니다. 이 한 쌍이 SPSC의 유일한 순서 보장 지점입니다.
  • 슬롯 반환생산자가 슬롯을 재사용하기 전에 소비자가 tail을 증가시켰음을 보장해야 하므로, tail 갱신은 head 게시 이후에만 관찰됩니다.
SPSC 게시·확인 순서 (release/acquire) 생산자(Producer) 소비자(Consumer) ① slots[i] = item (일반 쓰기) ② smp_store_release(&head, i+1) ③ 계속 진행 (다음 항목 생산) ④ head = smp_load_acquire(&head) ⑤ head == i+1 이면 slots[i] 읽기 ⑥ tail++ 로 슬롯 반환 (WRITE_ONCE) release → acquire 보장: ②(release)는 ①의 쓰기가 먼저 보이게 하고, ④(acquire)가 head == i+1 을 관측한 순간 ⑤의 슬롯 읽기는 ①의 결과를 반드시 봅니다. 역방향(N): 소비자가 ⑥(tail++)을 release로 게시하면, 생산자가 acquire로 tail 을 읽어 슬롯 반환(재사용)을 확인합니다.
SPSC 링의 게시·확인 순서(개념 개요). ①~⑥은 위 SPSC 코드와 대응하며, release → acquire 교차선이 항목 쓰기와 슬롯 읽기를 잇는 유일한 순서 보장 지점입니다.

MPMC 링(Vyukov 큐 코어)

생산자·소비자가 여럿이면 머리·꼬리 인덱스를 CAS로 독점하고, 슬롯마다 시퀀스 번호를 두어 "쓰기 완료"와 "해제 완료"를 구분합니다. 아래 코드는 개념 설명용 축약이며, 실제 구현에서는 게시·해제 순서에 release/acquire 의미론(atomic_set_release 등)을 정확히 적용해야 합니다.

/* 다중 생산자·다중 소비자(MPMC) 링 — Vyukov 큐의 핵심 구조 */
#define MPMC_SIZE 1024              /* 2의 거듭제곱 */

struct mpmc_slot {
    atomic_t seq;              /* 슬롯 시퀀스 번호 */
    void *item;                 /* 항목 포인터 */
};

struct mpmc_queue {
    struct mpmc_slot slots[MPMC_SIZE];
    atomic_t head;            /* 다음 인큐 위치 */
    atomic_t tail;            /* 다음 디큐 위치 */
};

void mpmc_init(struct mpmc_queue *q)
{
    int i;
    for (i = 0; i < MPMC_SIZE; i++)
        atomic_set(&q->slots[i].seq, i);
    atomic_set(&q->head, 0);
    atomic_set(&q->tail, 0);
}

bool mpmc_push(struct mpmc_queue *q, void *item)
{
    for (;;) {
        struct mpmc_slot *s;
        u32 head = atomic_read(&q->head);
        s = &q->slots[head & (MPMC_SIZE - 1)];
        if (atomic_read(&s->seq) != head)
            return false;        /* 아직 비워지지 않음 — 가득 참 */
        if (atomic_cmpxchg(&q->head, head, head + 1) == head) {
            WRITE_ONCE(s->item, item);
            atomic_set(&s->seq, head + 1);   /* 게시: 소비자가 읽을 수 있는 상태 */
            return true;
        }
    }
}

void *mpmc_pop(struct mpmc_queue *q)
{
    for (;;) {
        struct mpmc_slot *s;
        void *item;
        u32 tail = atomic_read(&q->tail);
        s = &q->slots[tail & (MPMC_SIZE - 1)];
        if (atomic_read(&s->seq) != tail + 1)
            return NULL;         /* 아직 게시 전 — 비어 있음 */
        item = READ_ONCE(s->item);
        if (atomic_cmpxchg(&q->tail, tail, tail + 1) == tail) {
            atomic_set(&s->seq, tail + MPMC_SIZE);  /* 슬롯 해제 */
            return item;
        }
    }
}
코드 설명
  • 시퀀스 번호슬롯의 seq는 "꺼낸 만큼" 진행하므로, 캐시라인을 공유하지 않는 구조에서는 생산자와 소비자가 각자 슬롯의 쓰기 완료 여부를 원자적으로 판정합니다.
  • push 경로head를 CAS로 독점한 뒤에만 슬롯에 항목을 쓰고 seq를 head+1로 올립니다. seq가 head와 같지 않으면 아직 슬롯이 해제되지 않았다는 뜻입니다.
  • pop 경로seq가 tail+1일 때만 항목을 읽고, tail을 CAS로 독점한 뒤 seq를 tail+MPMC_SIZE로 올려 다음 라운드에서 재사용 가능하게 합니다.
  • 주의실제 구현은 슬롯 사이의 가짜 공유를 피하기 위한 패딩(Padding), 게시·해제의 release/acquire, 32비트 인덱스의 랩(wrap) 처리를 함께 포함해야 합니다. 문서화된 검증(litmus) 후에만 사용합니다.

위 예제는 가득 참·비어 있음 시 false/NULL을 반환하는 비블로킹 변형입니다. Dmitry Vyukov의 원본 bounded MPMC 큐(1024cores)는 가득 참·비어 있음 시 스핀으로 대기하는 블로킹 변형이며, "모든 스레드의 무한 진행을 보장하는 공식 의미의 lock-free"는 아닙니다. 원본은 enqueue_pos/dequeue_pos를 서로 다른 캐시라인(Cache Line)에 두고 각 슬롯도 캐시라인 단위 패딩으로 분리해 거짓 공유(False Sharing)를 방지합니다. 위 예제는 원본의 핵심 구조(슬롯 시퀀스 번호, 머리·꼬리 1회 CAS)를 따르되, 실제 구현에는 캐시라인 배치와 release/acquire 의미론(atomic_set_release 등)을 반드시 추가해야 합니다.

MPSC 링(llist 기반)

완료 큐의 전형인 MPSC는 커널의 llist(Lock-free Linked List)가 사실상 표준입니다. llist는 단일 연결 리스트의 머리(head) 갱신을 원자적 교환 하나로 처리하므로, 생산자는 노드를 항목에 내장해 llist_add()만 호출하면 됩니다. 아래가 실제 구조(include/linux/llist.h, v6.12)입니다.

/* include/linux/llist.h (v6.12) — 머리만 있는 lock-free 단일 연결 리스트 */
struct llist_head {
    struct llist_node *first;   /* 원자적으로 교체되는 머리(xchg/CAS) */
};

struct llist_node {
    struct llist_node *next;    /* 생산자가 쓰는 동안 아무도 읽지 않음 */
};

/* 생산자(다중): 머리를 CAS로 교체 — 잠금 없음 */
static inline bool llist_add(struct llist_node *new,
                          struct llist_head *head)
{
    return llist_add_batch(new, new, head);  /* lib/llist.c: cmpxchg 기반 */
}

/* 소비자(단일): 전체를 한 번에 꺼냄 */
static inline struct llist_node *llist_del_all(struct llist_head *head)
{
    return xchg(&head->first, NULL);
}

사용 패턴은 아래와 같습니다. 항목 구조체(Struct)에 llist_node를 내장하고, 소비자는 덩어리째 꺼낸 뒤 순서를 되돌려 처리합니다.

/* MPSC 사용 패턴 — 항목에 llist_node를 내장하고 머리만 공유 */
struct work_item {
    struct llist_node node;   /* 반드시 첫 필드일 필요는 없음 */
    u32 kind;
};

static LLIST_HEAD(work_head);   /* 전역 머리 */

/* 인터럽트 처리기 등 여러 실행 흐름에서 호출 가능 */
static void submit_work(struct work_item *w)
{
    llist_add(&w->node, &work_head);
}

/* 단일 소비자(커널 스레드) — 한 번에 모두 꺼내 역순 복원 후 처리 */
static void drain_work(void)
{
    struct llist_node *n = llist_del_all(&work_head);
    struct work_item *w;

    n = llist_reverse_order(n);   /* 최신→최고 순서를 최고→최신으로 */
    while (n) {
        w = llist_entry(n, struct work_item, node);
        n = n->next;
        process_one(w);
    }
}

llist의 두 가지 주의점을 기억합니다. 첫째, llist_del_all()이 돌려주는 연결은 "가장 최근에 넣은 것부터"이므로 순서 복원에 llist_reverse_order()가 필요합니다. 즉 llist로는 전역 FIFO 순서를 보장할 수 없습니다 — 순서가 필수라면 링 버퍼 계열을 검토합니다. 둘째, 소비자가 여러 명이면 llist_del_first()를 동시에 쓸 수 없습니다(헤더 주석의 표: del_first는 다른 소비자의 del_first/del_all과 잠금이 필요). 다중 소비자는 llist_del_all()로 덩어리째 가져가 나누는 방식이 안전합니다. NMI 처리기에서 쓸 때는 CONFIG_ARCH_HAVE_NMI_SAFE_CMPXCHG가 필요하며, 이 점은 커널 ↔ 커널 시나리오에서도 언급했습니다.

알고리즘 카탈로그

알고리즘특징적합한 경우
Lamport SPSC 링이중 버퍼/인덱스 독점, 원자적 연산 최소1:1 고정 경로(드라이버, per-CPU)
Vyukov MPMC시퀀스 번호 + CAS, 유계(Bounded)범용 공유 큐, 완료 회수자 복수
llist 기반 MPSC단일 연결 리스트, CAS로 머리 갱신소비자 1명이 죽지 않는 경로
배치 claim(Prefetch/청크)K개 슬롯을 한 번에 claim완료 게시·회수가 잦은 고처리량 경로
배치 디큐(ptr_ring 방식)K개를 순서대로 인출 후 꼬리를 한 번에 전진softirq → 커널 스레드 대량 처리
seqcount 기반 다중 읽기순차 잠금(Seqlock)으로 안정적 스냅샷완료 집계(카운터)를 자주 읽는 경우

검증 절차

SPSC 링의 "항목 쓰기 → head 게시 → acquire 확인 → 슬롯 읽기" 순서는 커널 메모리 모델의 공식 litmus 테스트 MP+pooncerelease+poacquireonce(tools/memory-model/litmus-tests/, v6.12)와 같은 패턴이며, 이 테스트의 결과는 Never입니다(잘못된 결과 exists (1:r0=1 /\ 1:r1=0)가 발생하지 않음).

/* C MP+pooncerelease+poacquireonce — 커널 공식 litmus 테스트(v6.12) */
/* smp_store_release()/smp_load_acquire()가 메시지 전달(message-passing) 순서를 보장하는지 검증 */

P0(int *buf, int *flag)   /* 생산자(Producer) */
{
    WRITE_ONCE(*buf, 1);
    smp_store_release(flag, 1);
}

P1(int *buf, int *flag)   /* 소비자(Consumer) */
{
    int r0;
    int r1;

    r0 = smp_load_acquire(flag);
    r1 = READ_ONCE(*buf);
}

exists (1:r0=1 /\ 1:r1=0)   /* 나쁜 결과(Bad outcome) — 허용되면 안 됨. 이 테스트의 결과는 Never */

선택 가이드(의사결정 절차)

아래 질문을 순서대로 따라가면 대부분의 요구에서 초안 설계가 나옵니다. 각 질문의 답은 다음 질문의 입력이 됩니다.

단계질문선택지와 의미
1생산자·소비자는 각각 몇 개인가?1:1이면 SPSC, 1:N이면 제출은 분할 SPSC, N:1이면 MPSC, N:M이면 MPMC를 1차 후보로 둡니다.
2전역 FIFO 순서가 필수인가?필수면 공유 큐(MPMC/SPSC)로 좁히고, 아니면 분할 토폴로지를 열어둡니다.
3지연과 처리량 중 무엇이 우선인가?지연 우선은 배치를 줄이고 알림을 빠르게, 처리량 우선은 배치 claim과 인터럽트 코얼레싱을 켭니다.
4소비자가 대기해도 되는가?대기 가능하면 eventfd/futex/대기 큐로 알림하고, 불가능하면 폴링 전용 경로를 설계합니다.
5큐가 가득 찼을 때의 동작은?블록(대기 큐 연동), 드롭(손실 허용 계약과 카운터), 오버플로(보조 큐) 중에서 정합니다.
6기존 구현으로 요구가 충족되는가?기존 구현 활용 방향을 먼저 확인하고, 안 되는 부분만 직접 구현합니다.
7하드웨어 경계를 넘는가?넘으면 도어벨·인터럽트·DMA 규격에 맞는 어댑터와 virtio/vDPA 경로를 비교합니다.
설계 시작 (표의 7단계) ① 생산자·소비자 수는? 1:1 → SPSC, 1:N → 분할 SPSC 팬아웃 N:1 → MPSC, N:M → MPMC ② 전역 FIFO 순서 필수? 필수 → 공유 큐(MPMC/SPSC)로 좁힘 불필요 → 분할 토폴로지 개방 ③ 지연 vs 처리량 우선? 지연 우선 → 배치 축소·즉시 알림 처리량 우선 → 배치 claim·인터럽트 코얼레싱 ④ 소비자 대기 가능? 대기 가능 → eventfd·futex·대기 큐 불가 → 폴링 전용 데이터 경로 ⑤ 가득 찼을 때 동작? 블록(대기 큐 연동) · 드롭(카운터) 오버플로(보조 큐) 중 결정 ⑥ 기존 구현으로 충족? 충족 → 기존 구현 조합 재사용 불충족 → 직접 구현 방향 ⑦ 하드웨어 경계를 넘나? 아니오 → 최종 설계 확정 예 → 도어벨·DMA 어댑터, virtio/vDPA 비교 결론: 큐 타입 + 토폴로지 + 알림·포화 전략 확정
선택 가이드 의사결정 플로우차트(개념 개요). 위 표의 7단계를 순서대로 따라가며 각 답에 따라 큐 타입·토폴로지·알림·포화 전략이 결정됩니다.

실제 사례로 "패킷 수신 → 보안 검사 3단계 파이프라인 → 완료 통지"를 생각하면, 단계 1에서 1P:N 및 완료 회수 1명이므로 제출 = SPSC 팬아웃, 완료 = MPSC가 나오고, 단계 4에서 데이터 경로는 폴링(CPU 고정), 단계 5에서 큐 가득 참은 드롭 + 카운터, 단계 6에서 기존의 작업 큐+NAPI 흐름으로 충분하면 재사용, 부족하면 자체 링으로 진행하는 결론이 나옵니다.

실패 모드와 대응 방안

병렬 큐의 버그는 대부분 미세한 타이밍 문제로 나타나므로, 아래 실패 모드를 설계 단계에서부터 점검합니다.

실패 모드증상원인대응 방안
거짓 공유(False Sharing) 코어 수 증가에도 성능이 늘지 않음 관련 없는 인덱스·슬롯이 같은 캐시라인(Cache Line)에 배치됨 head/tail을 별도 캐시라인에, 슬롯에 패딩 배치
인덱스 경합 병목 한 인덱스의 CAS가 전체 처리량을 제한 공유 MPMC의 머리·꼬리 단일 지점 경합 배치 claim, 분할 SPSC 팬아웃으로 전환
기아(Starvation) 특정 소비자·생산자만 계속 실행 무한 루프(생산자가 계속 성공)나 공정성 부재 배치 예산, 라운드로빈, 우선순위 큐 분리
use-after-free 간헐적 크래시, KASAN 보고 슬롯 해제 전 객체를 재사용 시퀀스 번호로 해제 완료 확인, objpool 연동, RCU
분실 완료/유령 완료 완료 누락·중복 처리 게시 release/acquire 누락, tail 갱신 순서 오류 LKMM/litmus 검증, 시퀀스 번호로 멱등성 보장
백프레셔 데드락 생산자가 영원히 블록 소비자가 죽었거나 완료 신호 설계 오류 타임아웃, 소비자 하트비트, 블록 대신 드롭 경로
크래시 후 유령 데이터 재시작(Reboot) 후 오래된 항목이 남아 있음 공유 메모리(U2U)의 비정상 종료 에포크·매직 번호 검증, 초기화 시 링 전체 해제
NUMA 원격 접근 특정 노드에서만 지연이 큼 소비자 CPU와 링 메모리가 다른 NUMA 노드 per-node 링 또는 링 메모리 배치(policy) 조정

성능 평가와 운영 검증

병렬 큐는 구현 정확성과 성능을 분리해서 검증해야 합니다. 정확성은 위 검증 절차로, 성능은 아래 원칙으로 측정합니다.

수치를 문서화할 때는 단일 숫자 대신 범위와 조건을 함께 표기하고, 근거 없는 구체 수치는 넣지 않습니다.

필수 관련 문서:
  • Lock-free 자료구조 — 병렬 큐의 기반이 되는 lock-free 원리, CAS·ABA, 메모리 회수(Memory Reclaim) 전략과 검증(LKMM/litmus)을 다룹니다.
  • kfifo (Circular Buffer) — SPSC 링 버퍼의 커널 구현으로, 직접 구현 시 인덱스·배리어 설계의 기준이 됩니다.
  • io_uring (Async I/O) — 커널-사용자 제출·완료 큐의 사실상 표준으로, mmap 링·SQPOLL·eventfd·메모리 배리어 설계를 다룹니다.
  • AF_XDP (XDP Sockets) — RX/TX/FILL/COMP 4-링 모델로 제출·완료 큐 쌍의 전형을 보여줍니다.
  • virtio / vhost — virtqueue 제출·완료 프로토콜과 vhost-user·vDPA 하드웨어 오프로드 경계를 다룹니다.
참고 문서:
  • DPDK — rte_ring의 SPSC/MPSC/SPMC/MPMC 4모드와 공유 메모리 사용자-사용자 큐 사례를 다룹니다.
  • objpool (Object Pool) — NMI-safe per-CPU 오브젝트 풀로, 큐 슬롯·요청 객체의 할당/반환에 연동할 수 있습니다.
  • 메모리 배리어 / 메모리 모델 — release/acquire와 캐시라인 동작 등 병렬 큐의 메모리 순서 기초를 다룹니다.
  • Wait Queue (대기 큐) — 생산자·소비자의 블로킹 대기와 웨이크업 알림 경로를 다룹니다.
  • Completion (완료 변수) — 값 없는 완료 신호와 브로드캐스트 완료(complete_all)를 다룹니다.
  • Workqueue (CMWQ) — 커널 내부의 1P:N 작업 분산(per-CPU 워커) 표준 구현을 다룹니다.
  • Futex (Fast Userspace Mutex) — 사용자-사용자 큐의 블로킹 대기·깨우기 연동 방식을 다룹니다.
  • Ring Buffer (ftrace) — per-CPU 페이지 기반 추적 링 버퍼로, NMI-safe 쓰기 설계의 참고가 됩니다.
  • InfiniBand / RDMA — QP·완료 큐(CQ) 구조로 분산 경계의 하드웨어 제출·완료를 다룹니다.
  • Vyukov Bounded MPMC Queue (1024cores) — 시퀀스 번호 + CAS 기반 MPMC 큐의 원본 구현과 분류(블로킹 변형, 캐시라인 패딩)를 다룹니다.
  • io_uring(7) — Linux manual page — 제출·완료 링 구조, SQPOLL, 제한(Restrictions) 등 커널-사용자 큐의 공식 API 설명을 제공합니다.
  • DPDK Ring Library (doc.dpdk.org) — rte_ring의 SP/SC·MP/MC 모드와 생산·소비 경쟁 모델을 다룹니다.