콘텐츠로 이동

Ch.3 사례 전 - I/O는 "어디서" 기다리는가: Device와 Queue

< 환경 세팅 | 사례와 코드 >


본격적인 사례로 들어가기 전에, 지난 챕터가 남긴 숙제 하나를 먼저 풀고 간다. 이 그림을 손에 쥐고 사례를 보면 훨씬 잘 보인다.

지난 챕터가 남긴 숙제

Ch.2에서 print() 하나가 응답을 140배 느리게 만드는 걸 봤다. 그리고 그 140배를 "요청당 write() System Call 3,600번 × 한 번에 수천 사이클"로 설명했다. 그런데 그 산술을 끝까지 해보면 뭔가 안 맞는다.

요청당 write 3,600번 × 수천 사이클 ≈ 수천만 사이클
3GHz 기준 ≈ 길어야 수십 ms

그런데 그때 측정한 100 VU 평균 응답 시간은 2,160ms였다. 단일 요청 산술로는 수십 ms인데 실제는 2초가 넘는다. 두 자릿수 배가 비어 있다. 이 간극은 syscall 단가로는 설명되지 않는다. 답은 큐(Queue)에 있다. 그리고 그 큐가 어디서 생기는지를 알려면, I/O Bound가 정확히 "무엇을 기다리는지"부터 봐야 한다.

I/O Bound의 "기다림"은 Device를 기다리는 것

I/O Bound는 "외부를 기다려서 느린" 상태다. 그럼 그 "외부"가 뭔가? Device(장치)다. print()write()는 stdout이라는 장치에 데이터를 넘기고 그 장치가 받아줄 때까지 기다린다. DB 쿼리는 네트워크 소켓으로, 파일 읽기는 디스크로 간다. 전부 외부 device를 기다리는 시간이다.

Unix 계열 OS는 이 장치들을 전부 "파일"이라는 하나의 인터페이스로 다룬다.

everything is a file (모든 것은 파일이다)

Unix 계열 OS의 설계 원칙이다. 터미널, 디스크 파일, 네트워크 소켓, 파이프 - 입출력 대상을 전부 "파일"이라는 같은 인터페이스(File Descriptor, fd)로 다룬다.

그래서 write(fd, ...) 하나로 터미널에도, 파일에도, 소켓에도 쓸 수 있다. Ch.2에서 print()가 도달한 write(1, ...)의 fd=1(stdout)이 그 예다.

(출처: Brian Kernighan & Rob Pike, "The UNIX Programming Environment", 1984)

핵심은 여기다. device 하나는 동시에 들어온 요청을 한꺼번에 처리하지 못한다. 커널은 그 device로 가는 요청을 큐에 줄 세우고 차례로 내보낸다. 디스크든 소켓이든 stdout이든, device마다 요청 큐가 있고 접근은 직렬화된다.

그림 1 - 큐는 쌓이는데, Device는 하나다

이게 140배의 못 푼 부분이다. 요청이 동시에 몰리면, 같은 device(stdout, fd=1) 하나에 write가 줄을 선다.

   여러 요청이 동시에 몰려든다              처리구는 하나
        req ──┐
        req ──┤    ┌───────────────────────────┐
        req ──┼──▶ │ ▣ ▣ ▣ ▣ ▣ ▣ ▣ ▣   (Queue) │──▶ [ Device ] ──▶ 완료
        req ──┤    └───────────────────────────┘         한 번에 1건씩
        req ──┘            ▲ 줄(대기 행렬)                  (직렬 처리)
        req ──┘            └ 부하가 늘수록 길어진다

   한 요청의 응답 시간 = 내 처리 시간  +  앞에 줄 선 요청들이 빠질 때까지의 대기

도착하는 속도가 빠지는 속도를 넘어서면 줄은 계속 길어진다. 그래서 동시 사용자가 100명이 되면, 각 요청의 응답 시간에 "앞사람들의 대기"가 통째로 얹힌다. 단일 요청이면 수십 ms로 끝날 일이, 100명이 같은 device에 줄을 서면서 2,160ms가 된 것이다. "왜 100 VU일 때 더 느린가"의 답이 이 큐다.

(엄밀히는 stdout 하나만 병목인 건 아니다. FastAPI의 threadpool 한도, Python의 GIL도 같은 방향으로 줄을 세운다. 그래도 공통점은 하나다 - 좁은 통로 하나에 여럿이 줄을 선다. 이게 I/O 성능의 근본 구도다.)

그럼 이 병목을 어떻게 푸는가

"통로가 하나라 줄이 길어진다"가 문제라면, 해소책의 방향도 정해진다. 통로를 늘리거나, 줄 세우는 방식을 똑똑하게 만들거나, 누가 얼마나 들어갈지를 통제하는 것이다. 실제 OS와 하드웨어가 이걸 어떻게 하는지 보면, 우리가 쓰는 async의 정체도 드러난다.

해소책 1 - 통로를 늘린다: 여러 Queue, 여러 Device

먼저 device 자체가 정말 "하나"여야 할 이유는 없다. 하나의 드라이버가 여러 개의 장치를 관리할 수 있다.

Device Number (major / minor number)

Unix에서 장치는 두 개의 숫자로 식별된다.

  • major number: 어떤 드라이버가 담당하는가 (예: 디스크 드라이버)
  • minor number: 그 드라이버가 관리하는 여러 장치 중 몇 번인가 (예: 0번 디스크, 1번 디스크)

하나의 드라이버(major)가 여러 장치(minor)를 동시에 관리한다. /dev/nvme0n1, /dev/nvme0n2처럼.

여기서 한 걸음 더 나아간 게 멀티 큐다. 통로(큐)를 device당 하나가 아니라 CPU 코어 수만큼 두는 방식이다.

   단일 큐 (직렬 병목)              멀티 큐 (병렬)

   req ─▶ [ Queue ] ─▶ [Dev]       코어0 ─▶ [Queue0] ─▶ ┐
                                   코어1 ─▶ [Queue1] ─▶ ┤
                                   코어2 ─▶ [Queue2] ─▶ ┼─▶ [ Device ]
                                   코어3 ─▶ [Queue3] ─▶ ┘   (큐를 병렬로 흡수)

   요청이 한 줄에 다 몰림           요청을 N개 큐로 분산 → 줄이 짧아짐

리눅스 블록 계층의 blk-mq(multi-queue), NVMe SSD의 코어별 제출/완료 큐(SQ/CQ), 네트워크 카드의 RSS(여러 RX 큐)가 전부 이 아이디어다. "device는 하나"처럼 보여도, 내부적으로는 여러 큐를 병렬로 굴려서 단일 큐 직렬화를 피한다.

그런데 큐를 여러 개 둔다고 끝이 아니다. 들어온 요청을 어느 큐로 보낼지 - 디스패치 정책 - 가 device마다 다르다. 크게 두 갈래다.

 (A) 고정 채널(파티셔닝)               (B) 통합 인입 + 일 훔치기(work-stealing)

  flow1 ─▶ [Q0] ─▶ 엔진0               req ─┐
  flow2 ─▶ [Q1] ─▶ 엔진1               req ─┼─▶ [ 공용 Queue ] ─▶ 엔진0
  flow3 ─▶ [Q2] ─▶ 엔진2               req ─┘            │ (한가한 엔진이
                                                         └─▶ 엔진1  남의 일을 훔쳐감)
  키(플로우·연결·CPU)로 큐를 못박음      아무 엔진이나 빈 놈이 집어 처리
  순서·캐시 지역성 유지, 핫스팟 위험      부하 자동 균형, 순서·지역성은 약함

(A)는 요청을 어떤 키(플로우 해시, 연결, CPU)로 특정 큐에 묶는다. NIC의 RSS(플로우별 RX 큐 고정), CPU별 NVMe 제출 큐가 이쪽이다. 순서와 캐시 지역성이 유지되는 대신 특정 큐만 뜨거워질 수 있다. (B)는 공용 큐에 넣고 비어 있는 처리 유닛이 집어가거나, 한가한 쪽이 바쁜 쪽 큐에서 일을 훔쳐온다(work-stealing). 커널 workqueue의 워커 풀, Go·tokio 같은 런타임 스케줄러가 이 방식이다. 부하는 알아서 균형이 맞지만 순서 보장은 약하다.

여기에 한 겹 더 있는 게 primary/secondary 구조다. 프런트엔드(primary) 하나가 요청을 받아 뒤의 여러 처리 유닛(secondary/engine)으로 분배하거나 직접 디코드한다 - 받는 입구는 하나여도 처리 위치는 유동적이다. 다만 하드웨어 스펙상 특정 작업(장치 관리·동기화·모드셋처럼)은 반드시 primary에서만 돌아야 하는 예외가 있다. 그래서 "어디서 처리되든 상관없다"가 항상 성립하는 건 아니고, 강제된 작업만 primary에 고정하고 나머지는 분산하는 절충이 흔하다.

요점은 이렇다. 큐를 늘리는 것과, 그 큐에 일을 어떻게 태우느냐는 별개의 결정이다. 그리고 그 선택은 device의 성격 - 순서 보장이 필요한가, 처리 유닛이 대칭인가, 특정 작업이 primary 전용인가 - 에 따라 갈린다.

해소책 2 - 줄 세우는 방식을 정교하게: Ring Buffer와 Semaphore

통로를 늘리는 것과 별개로, 큐 자체를 어떻게 구현하느냐도 중요하다. 여기서 사용자가 흔히 헷갈리는 지점이 하나 있다. "그 큐는 원형 버퍼인가, 아니면 그냥 일반 큐인가?" 답은 계층마다 다르다.

하드웨어와 드라이버가 주고받는 제출/완료 큐는 보통 원형 버퍼(circular ring)다. 크기가 고정된 링에 생산자는 tail에, 소비자는 head에서 처리한다.

   드라이버 ↔ 하드웨어 큐 : 원형 링 버퍼 (고정 크기 N슬롯)

                tail (생산자: 드라이버가 요청을 넣는 곳)
         ┌──┬──┬──▼──┬──┬──┬──┐
         │  │  │ ▣ │ ▣ │ ▣ │  │     ▣ = 처리 대기 중인 요청
         └──┴──┴──┴──▲──┴──┴──┘
                head (소비자: 하드웨어가 꺼내 처리하는 곳)

   생산자가 tail에 요청을 쓰고 'doorbell'을 울린다 → 소비자가 head에서 꺼낸다
   링이 가득 차면(tail이 head를 따라잡으면) 생산자는 빈 슬롯이 날 때까지 기다린다

그런데 이 "가득 차면 기다리고, 비면 깨운다"를 누가 관리하나? 세마포어(Semaphore)다.

Semaphore (세마포어)

"몇 개까지 허용할지"를 세는 카운터 기반 동기화 장치다. down()으로 하나 줄이고(없으면 대기), up()으로 하나 늘린다(기다리던 쪽을 깨움).

  • 값이 1이면 사실상 잠금(mutex)처럼 동작한다.
  • 값이 N이면 "동시에 N개까지 허용"을 뜻한다(counting semaphore).

고정 크기 링은 전형적인 생산자-소비자(bounded buffer) 문제이고, 세마포어 두 개로 깔끔하게 풀린다.

   빈 슬롯 세마포어(free) : 처음 N  ── 생산자가 넣기 전 down(), 가득 차면 여기서 대기
   찬 슬롯 세마포어(used) : 처음 0  ── 소비자가 빼기 전 down(), 비어 있으면 여기서 대기

   생산자: free.down() → 링 tail에 기록 → used.up()
   소비자: used.down() → 링 head에서 처리 → free.up()
   (head·tail 포인터 자체는 mutex/스핀락으로 보호)

세마포어 값이 곧 "동시에 처리 중인 요청 수(queue depth)"의 상한이 된다. 이 값을 적절히 두는 게 I/O 성능 튜닝의 핵심 중 하나다.

한편 커널의 일반 workqueue는 이야기가 조금 다르다. 이건 원형 버퍼가 아니라, 처리할 일감(work_struct)을 연결 리스트에 매달아 두고 워커 스레드 풀(kworker)이 하나씩 꺼내 실행하는 구조다(리눅스 cmwq).

Workqueue (워크큐) - circular냐 일반이냐

같은 "큐"라도 계층에 따라 자료구조가 다르다.

  • 하드웨어 ↔ 드라이버 제출/완료 큐(NVMe SQ/CQ, NIC 디스크립터 링, virtio vring): 고정 크기 원형 링 버퍼. head/tail 포인터로 관리.
  • 커널 generic workqueue(cmwq): 원형 버퍼가 아니라 연결 리스트 + 워커 스레드 풀. 일감을 등록하면 워커가 나중에 꺼내 실행한다(블로킹 가능한 context로 처리를 미루는 용도).

즉 "원형이냐 일반이냐"는 어느 층의 큐를 말하느냐에 따라 갈린다. 하드웨어에 붙은 큐일수록 원형 링, 커널 내부 일감 위임일수록 연결 리스트 쪽이다.

공통점은 분명하다. 어느 쪽이든 "제출하는 쪽"과 "처리하는 쪽"을 큐로 분리한다. 제출자는 큐에 넣고 곧장 돌아가고, 처리는 소비자(하드웨어, 워커 스레드)가 나중에 한다. 제출과 처리가 분리되면, 제출자는 device가 일하는 동안 멈춰 서서 기다릴 필요가 없어진다.

그림 4 - 사실 이게 async와 같은 그림이다

제출과 처리를 큐로 분리한다 - 이 문장이 익숙하다면 정확하다. 우리가 애플리케이션에서 쓰는 async/await가 정확히 같은 구조다. I/O를 큐에 제출하고, 블로킹하지 않고, 완료되면 통지받는다.

리눅스의 최신 비동기 I/O 인터페이스 io_uring은 이 그림을 사용자 공간까지 그대로 끌어올린다.

   사용자 공간(application)                커널 / device

   제출 링(SQ) ── 요청을 넣는다 ──▶  io_uring ──▶ device 큐 ──▶ [Device]
   완료 링(CQ) ◀─ 완료를 받는다 ──            ◀──────────────  완료 통지

   - SQ/CQ 둘 다 원형 링 버퍼이고, 커널과 사용자 공간이 공유한다
   - 이벤트 루프 = '완료된 일(ready queue)'을 비우는 단일 소비자

async가 I/O Bound에만 효과가 있는 이유가 여기서 분명해진다. device 큐에 요청을 넣고 응답을 기다리는 동안 CPU(또는 이벤트 루프)는 놀고 있다. 그 빈 시간에 다음 요청을 받아 또 다른 device로 보내면, 여러 요청의 기다림이 겹쳐진다. async/await는 "device를 기다리는 시간"을 다른 요청으로 메우는, 큐-제출-후-통지 모델의 사용자 공간 판이다.

반대로 CPU Bound 작업은 기다릴 device가 없다. CPU가 직접 계산하고 있으니 큐에 넣고 빠질 시간 자체가 없고, async가 메울 빈틈도 없다. 같은 async라도 어디에 거느냐에 따라 약이 되고 독이 되는 이유가 이것이다.

정리하고, 사례로

여기까지가 사례를 보기 위한 밑그림이다.

  • I/O Bound의 기다림은 device를 기다리는 것이고, device 앞에는 가 있다.
  • 통로(device·큐)가 하나면 요청이 직렬화되고, 부하가 늘면 큐가 길어져 응답이 느려진다. Ch.2 print 140배의 못 푼 부분이 이 큐였다.
  • 시스템은 이걸 여러 큐(blk-mq·멀티큐), 정교한 큐 구현(원형 링 + 세마포어), 제출/처리 분리(workqueue)로 푼다.
  • async/await는 같은 모델의 사용자 공간 판이다 - 제출하고, 안 기다리고, 완료되면 통지받는다.

이제 이 "큐 + device + 동시성" 모델을 들고, 한 가지 질문으로 넘어간다. async가 이렇게 좋은 거라면, async로 바꿨는데 왜 더 느려지는 경우가 있을까? 다음 페이지의 이미지 업로드 사례에서 직접 확인한다.


< 환경 세팅 | 사례와 코드 - async로 했는데 왜 안 빨라지지? >