Workspace IndexAlgorithms › Day 41

Kernel Bypass and Zero-Copy — io_uring TODO

Algorithms · Day 41 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

Traditional socket I/O crosses the user-kernel boundary on every system call and copies data between kernel and user buffers, so for workloads with very many small requests, that overhead dominates the total cost. Zero-copy is a family of techniques that eliminates or reduces that copying — sendfile or splice for file transfer, which keep data from passing through user space, are the classic examples. io_uring provides an asynchronous interface built on two ring buffers shared between kernel and user space (a submission queue and a completion queue): you write requests into the queue and read completions out of it, and you can submit many requests in a single system call, or in polling mode make progress with no system calls at all. Kernel bypass goes further still, mapping NIC queues directly into a user-space driver and skipping the kernel network stack entirely — latency drops sharply, but the application now has to take on the protocol handling and protection the kernel used to provide. What all these techniques share is reducing the number of boundary crossings and the number of copies, at the cost of complexity and portability.

In matching engines or ultra-high-frequency RPC gateways where microsecond-level latency matters, the bottleneck is often system-call and copy overhead rather than application logic, so you need to be able to tell how much of the cost is kernel cost.

Code & Formula

# 커널 바이패스와 zero-copy — io_uring 의 핵심 아이디어(제출 큐 SQ / 완료 큐 CQ 를 유저-커널이 공유)를 순수 파이썬으로 흉내낸다.
# 진짜 io_uring 은 시스템 콜 없이 링 버퍼만으로 요청/완료를 주고받지만, 여기서는 그 인터페이스 모양만 재현한다.

from collections import deque

class TinyIoUring:
    def __init__(self):
        self.submission_queue = deque()   # SQ: 유저가 써넣고 커널이 소비
        self.completion_queue = deque()   # CQ: 커널이 써넣고 유저가 소비
        self._next_id = 0

    def submit(self, op, payload):
        """유저 공간: 요청을 SQ 에 밀어넣는다 — 요청마다 시스템 콜을 하지 않고 큐에만 쌓는다."""
        req_id = self._next_id
        self._next_id += 1
        self.submission_queue.append((req_id, op, payload))
        return req_id

    def kernel_process_batch(self):
        """커널 쪽 처리를 흉내: SQ 에 쌓인 요청을 한 번에(배치로) 처리해 CQ 에 완료를 채운다.
        여기서 핵심은 요청 N개를 시스템 콜 1번(=이 함수 호출 1번)으로 끝낸다는 것 — zero-copy 의 핵심도
        '경계를 넘는 횟수'와 '복사 횟수'를 줄이는 데 있다."""
        processed = 0
        while self.submission_queue:
            req_id, op, payload = self.submission_queue.popleft()
            if op == "read":
                result = f"data({payload})"      # 실제로는 유저 버퍼로 직접 DMA 되어 복사가 생략됨
            elif op == "write":
                result = f"written:{len(payload)}bytes"
            else:
                result = None
            self.completion_queue.append((req_id, result))
            processed += 1
        return processed

    def reap_completions(self):
        """유저 공간: CQ 에서 완료된 결과를 꺼낸다 — 이것도 시스템 콜 없이 공유 메모리 읽기만으로 끝난다."""
        out = []
        while self.completion_queue:
            out.append(self.completion_queue.popleft())
        return out

ring = TinyIoUring()

# 전통적 블로킹 I/O 라면 read() 5번 = 시스템 콜 5번이지만, 여기서는 SQ 에 5개를 한꺼번에 밀어넣는다.
req_ids = [ring.submit("read", f"/file{i}") for i in range(5)]
req_ids.append(ring.submit("write", "payload-bytes"))

requests_processed = ring.kernel_process_batch()   # 이 함수 호출 자체가 "시스템 콜 1회"에 대응한다
enter_syscalls = 1                                  # io_uring_enter() 를 딱 한 번만 부른 셈
completions = ring.reap_completions()

print("submitted requests:", len(req_ids))
print("requests completed in this batch:", requests_processed)
print("io_uring_enter() calls needed:", enter_syscalls,
      f"-> 전통적 blocking read/write였다면 {len(req_ids)}번의 시스템 콜이 필요했다")
print("completions:", completions)

Exercise

Implement the same echo server on epoll and on io_uring, and compare p99 latency and system calls per second (via strace -c or perf) under identical load.

Practical Connection

When a chain node or RPC proxy handling huge numbers of small JSON-RPC requests shows CPU time concentrated in kernel time, batched submission and reduced copying are worth checking before adding hardware.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

커널 바이패스와 zero-copy TODO

Algorithms · Day 41 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

io_uring

개념

전통적인 소켓 I/O는 시스템 콜마다 유저-커널 경계를 넘고 커널 버퍼와 유저 버퍼 사이에서 데이터를 복사하므로, 작은 요청이 매우 많은 워크로드에서는 이 오버헤드가 전체 비용을 지배한다. zero-copy는 그 복사를 제거하거나 줄이는 기법으로, 파일 전송의 sendfile이나 splice처럼 데이터가 유저 공간을 거치지 않게 하는 방식이 대표적이다. io_uring은 커널과 유저 공간이 공유하는 두 개의 링 버퍼(제출 큐와 완료 큐)를 두어 요청을 큐에 써 넣고 완료를 큐에서 읽는 비동기 인터페이스이며, 여러 요청을 한 번의 시스템 콜로 제출하거나 폴링 모드에서는 시스템 콜 없이도 진행시킬 수 있다. 커널 바이패스는 한 걸음 더 나아가 NIC 큐를 유저 공간 드라이버에 직접 매핑해 커널 네트워크 스택 자체를 건너뛰는 접근으로, 지연은 크게 줄지만 커널이 제공하던 프로토콜 처리와 보호를 애플리케이션이 떠안게 된다. 즉 이 계열 기법의 공통 축은 '경계 넘기 횟수'와 '복사 횟수'를 줄이는 것이며, 그 대가는 복잡도와 이식성이다.

지연이 마이크로초 단위로 중요한 매칭 엔진이나 초고빈도 RPC 게이트웨이에서는 애플리케이션 로직이 아니라 시스템 콜·복사 비용이 병목이 되므로, 어디까지가 커널 비용인지 구분할 줄 알아야 한다.

코드 · 수식

# 커널 바이패스와 zero-copy — io_uring 의 핵심 아이디어(제출 큐 SQ / 완료 큐 CQ 를 유저-커널이 공유)를 순수 파이썬으로 흉내낸다.
# 진짜 io_uring 은 시스템 콜 없이 링 버퍼만으로 요청/완료를 주고받지만, 여기서는 그 인터페이스 모양만 재현한다.

from collections import deque

class TinyIoUring:
    def __init__(self):
        self.submission_queue = deque()   # SQ: 유저가 써넣고 커널이 소비
        self.completion_queue = deque()   # CQ: 커널이 써넣고 유저가 소비
        self._next_id = 0

    def submit(self, op, payload):
        """유저 공간: 요청을 SQ 에 밀어넣는다 — 요청마다 시스템 콜을 하지 않고 큐에만 쌓는다."""
        req_id = self._next_id
        self._next_id += 1
        self.submission_queue.append((req_id, op, payload))
        return req_id

    def kernel_process_batch(self):
        """커널 쪽 처리를 흉내: SQ 에 쌓인 요청을 한 번에(배치로) 처리해 CQ 에 완료를 채운다.
        여기서 핵심은 요청 N개를 시스템 콜 1번(=이 함수 호출 1번)으로 끝낸다는 것 — zero-copy 의 핵심도
        '경계를 넘는 횟수'와 '복사 횟수'를 줄이는 데 있다."""
        processed = 0
        while self.submission_queue:
            req_id, op, payload = self.submission_queue.popleft()
            if op == "read":
                result = f"data({payload})"      # 실제로는 유저 버퍼로 직접 DMA 되어 복사가 생략됨
            elif op == "write":
                result = f"written:{len(payload)}bytes"
            else:
                result = None
            self.completion_queue.append((req_id, result))
            processed += 1
        return processed

    def reap_completions(self):
        """유저 공간: CQ 에서 완료된 결과를 꺼낸다 — 이것도 시스템 콜 없이 공유 메모리 읽기만으로 끝난다."""
        out = []
        while self.completion_queue:
            out.append(self.completion_queue.popleft())
        return out

ring = TinyIoUring()

# 전통적 블로킹 I/O 라면 read() 5번 = 시스템 콜 5번이지만, 여기서는 SQ 에 5개를 한꺼번에 밀어넣는다.
req_ids = [ring.submit("read", f"/file{i}") for i in range(5)]
req_ids.append(ring.submit("write", "payload-bytes"))

requests_processed = ring.kernel_process_batch()   # 이 함수 호출 자체가 "시스템 콜 1회"에 대응한다
enter_syscalls = 1                                  # io_uring_enter() 를 딱 한 번만 부른 셈
completions = ring.reap_completions()

print("submitted requests:", len(req_ids))
print("requests completed in this batch:", requests_processed)
print("io_uring_enter() calls needed:", enter_syscalls,
      f"-> 전통적 blocking read/write였다면 {len(req_ids)}번의 시스템 콜이 필요했다")
print("completions:", completions)

연습

같은 에코 서버를 epoll 기반과 io_uring 기반으로 각각 구현해 동일 부하에서 p99 지연과 초당 시스템 콜 수(strace -c 또는 perf)를 비교해 보라.

실무 · Verex 연결

체인 노드나 RPC 프록시처럼 수많은 작은 JSON-RPC 요청을 처리하는 구간에서 CPU가 커널 시간에 몰려 있다면, 배치 제출과 복사 감소가 하드웨어 증설보다 먼저 검토할 카드다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 40. 브랜치 예측·프리페치·데이터 지향 설계42. 이벤트 루프 vs 스레드 vs 액터 모델 →