C# 네트워킹 심층 탐구: io_uring 파트 5 - 스레드풀 비판

발행: (2026년 5월 25일 AM 02:32 GMT+9)
11 분 소요
원문: Dev.to

Source: Dev.to

Part 5는 Kestrel에 통합하는 내용이 될 예정이었지만, 이번에는 io_uring과 스레드풀에 대한 불평을 하려고 합니다.
이 이야기가 io_uring에서 시작된 건 아니에요. 솔직히 저는 epoll을(플롯 트위스트 :o) 정말 좋아합니다. 제가 7개월째 io_uring을 실험하고 연구하는 이유는 네트워킹에서 epoll보다 더 나은 대안인지 확인하고 싶어서였죠.

틀린 생각 하지 마세요, io_uring은 훌륭합니다. 저는 그 점이 정말 마음에 들어요. 원래 디스크/파일 I/O를 위해 만들어졌고 그 분야에서는 뛰어나지만, 제 겸손한 의견으로는 일반적인 네트워킹·백엔드 애플리케이션에는 맞지 않을 수도 있습니다.

아마 지금 “도대체 무슨 말이야? 왜 이렇게 말하냐?”는 생각이 들겠지만, 많은 사람들이 io_uring을 사막에서 마시는 코카콜라라고 주장하거든요. 숫자를 끌어내고 싶어 하는 벤치마크 매니아에게는 io_uring이 확실히 빠릅니다. 바로 그 점이 바로 마이크로 벤치마크에서 빛을 발한다는 얘기죠.

io_uring은 이번 시리즈에서 탐구해 온 리액터 패턴과 완벽하게 맞아떨어집니다. 리액터가 전체 수명 동안 하나의 스레드에 고정돼 있을 때 특히 빠르게 동작하고, 이는 IValueTaskSource가 동작하는 방식과도 딱 맞아떨어집니다. 아직 벤치마크 결과를 공유하지는 않았지만, Minima는 정말 빠릅니다. 무서울 정도로 빠른데, 구체적인 수치는 다음 파트에서 공개하겠습니다.

하지만 이 속도에는 제약이 있습니다.

io_uring의 속도는 (Minima 멀티 리액터 모델에서) 조건부입니다. 두 가지가 동시에 만족돼야 하는데, 첫째는 리액터가 링에 대한 유일한 제출자이어야 한다는 점(SINGLE_ISSUE + DEFER_TASKRUN)이고, 둘째는 핸들러가 리액터 스레드에서 인라인으로 실행돼 IValueTaskSource가 스레드를 떠나지 않고 재개돼야 한다는 점입니다. 이 두 조건은 핸들러가 리액터를 떠나지 않을 때만 유지됩니다. 그런데 .NET 백엔드 세계는 “리액터를 떠나는” 것이 기본이죠. 스레드 풀, async/await의 비스레드 재개, 그리고 물론 Kestrel의 “연결을 풀에 넘긴다” 모델이 그것입니다. 즉, 실제 async 작업을 await 하면 핸들러는 리액터 스레드를 떠나게 되고, 그 스레드에서는 응답을 제출할 수 없으니 교차 스레드 핸드오프가 강제됩니다. 여러 스레드가 얽히면서 경쟁 상태와 데드락 가능성이 생기고, 이를 해결하는 데 비용이 듭니다.

SQE를 링에 넣고 SQ tail을 올리는 것만으로는 아무 일도 일어나지 않습니다. 커널은 io_uring_enter( SQPOLL이 없을 때) 호출이 있을 때만 SQ를 확인합니다. 이는 명시적인 시스템 콜이며, 리액터만( Minima 모델을 사용해) 호출할 수 있습니다. 리액터가 깨우는 시점은 완료(CQE)가 있을 때이며, 루프는 io_uring_enter(to_submit, min_complete=1, GETEVENTS) 안에서 블록됩니다. 이때 대기 중인 작업을 제출하고, 최소 하나의 CQE가 생길 때까지 커널에서 잠들어 있습니다.

상황을 살펴보면

  • 모든 연결이 유휴 상태(keep‑alive, 진행 중인 요청 없음)이며, 각 리액터는 io_uring_enter 안에서 잠들어 있습니다.
  • 핸들러가 풀 스레드에서 작업을 마치고 연결 C(리액터 R이 소유)로 응답을 보내야 합니다. 핸들러는 SEND SQE를 만들고 R의 링에 넣어 tail을 올립니다.
  • 하지만 핸들러는 io_uring_enter를 호출하지 않습니다(단일 발행자이므로 R만 제출 가능). 이제 SEND SQE는 링에 남아 있지만 아직 제출되지 않은 상태입니다.
  • R은 CQE가 와야만 다시 실행됩니다. R을 깨울 수 있는 유일한 CQE는 바로 그 SEND의 완료인데, SEND는 아직 제출되지 않았으니 CQE도 발생하지 않습니다. 만약 C가 유일한 작업이라면 다른 완료도 없으니 R은 영원히 잠들어 있습니다.

결과적으로 풀 스레드는 리액터가 SQE를 제출하기를 기다리고, 리액터는 자신이 제출해야 할 작업의 완료를 기다리면서 서로를 기다리는 데드락에 빠집니다.

회피 방법

예를 들어 타임아웃을 받는 다른 시스템 콜을 사용하면, CQE가 없을 경우 타임아웃이 발생해 리액터 루프가 스핀하게 할 수 있습니다. 이것이 바로 zerg의 모델 해결책인데, “타임아웃이 너무 자주 발생하면 성능이 떨어지고, 너무 짧으면 트래픽이 적을 때 CPU를 낭비한다”는 역설적인 문제가 있습니다.

Minima는 이 문제를 더 우아하게 해결합니다. 작업을 큐에 넣은 뒤 풀 스레드 핸들러가 특수 시스템 콜을 호출해 커널에게 인위적인 wake‑CQE를 생성하도록 요청합니다. 이렇게 하면 리액터가 깨워져서 대기 중인 SEND를 바로 제출할 수 있습니다. 하지만 이 방법도 아이러니하게도 추가 시스템 콜을 요구합니다. 바로 우리가 epoll에서 벗어나려 했던 이유와 동일합니다.

SQPOLL으로 해결될까?

이론적으로는 SQPOLL이 문제를 해결합니다. 커널 스레드가 제출 큐를 폴링하므로 풀 스레드의 SQE가 리액터가 io_uring_enter를 호출하지 않아도 바로 제출됩니다. 하지만 여기에도 아이러니가 있습니다. 폴러 자체가 잠들 수 있는데, 그때는 SQPOLL 폴러를 깨워야 하니 또 다른 “wake” 문제가 생깁니다. 게다가 SQPOLL과 DEFER_TASKRUN은 서로 배타적이어서, 원래 모델의 핵심인 완료 배칭을 포기하게 됩니다. 즉, SQPOLL은 wake를 없애는 것이 아니라 다른 곳으로 옮기고, 리액터당 커널 스레드를 하나씩 소모하게 하며 DEFER_TASKRUN 비용을 추가합니다.

SQPOLL을 절대 잠들지 않게 설정하거나 자동으로 깨우는 복잡한 메커니즘을 구현할 수도 있겠지만, 솔직히 말해 저는 SQPOLL을 사용한 io_uring 성능이 기대에 못 미쳤습니다. 그래서 개인적으로는 epoll을 고수하는 편이 낫다고 생각합니다.

epoll이 더 간단한 이유

epoll은 I/O와 제출을 절대로 분리하지 않습니다. send와 recv는 어떤 스레드든 직접 호출하는 일반 시스템 콜이므로 구현이 훨씬 깔끔하고 해킹이 필요 없습니다.

실제로 io_uring은 epoll보다 얼마나 빠른가?

스레드풀에 작업을 위임하지 않는 마이크로 벤치마크에서는 5~10% 정도 미세하게 빠를 수 있습니다(제 자체 테스트 기준). 하지만 실제 워크로드에서는 그 정도 차이도 거의 없으며, 보안 이슈, 특수 커널 접근, 최신 커널 요구사항, 구현 복잡성 등을 고려하면 오늘날 기준으로는 큰 장점이 되지 않습니다. 제 의견으로는 아직은 가치가 없지만, 더 연구하면 생각이 바뀔 수도 있습니다.

언제 io_uring을 쓰면 좋은가?

핸들러·엔드포인트 로직이 가볍고, 웹소켓이나 Redis·nginx 같은 프레임워크가 목표로 하는 동기식 워크로드와 같이 멀티 리액터 아키텍처를 활용할 때는 여전히 유용합니다.

다음 파트에서는 Minima를 계속 진행하면서 리액터 패턴과 send 브랜치를 탐구할 예정입니다. 비록 io_uring이 Kestrel에 최적은 아니더라도, 특정 애플리케이션 유형에서는 뛰어난 성능을 발휘할 수 있습니다.

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.