I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
느린 장치와 효율적으로 대화하는 법 — 폴링 vs 인터럽트, top/bottom half, DMA, 인터럽트 합치기(NAPI)를 애니메이션으로 설명합니다.
“디스크에서 한 블록 읽는 동안 CPU는 뭘 하고 있나”
CPU가 한 명령을 처리하는 데 1ns가 안 걸립니다. 그런데 SSD에서 한 블록을 읽는 데는 수십 μs, HDD라면 수 ms가 걸립니다. CPU 시간으로 환산하면 수십만~수백만 사이클입니다. 이 차이를 사람 시간으로 바꾸면, CPU의 1초가 디스크에겐 며칠~몇 주입니다.
그러니 질문은 단순합니다. 그 며칠을 CPU가 멍하니 기다려야 하나? 만약 그렇다면 디스크를 읽을 때마다 컴퓨터 전체가 얼어붙을 겁니다. 이 글은 OS가 “느린 장치”라는 근본 문제를 푸는 세 가지 장치 — 인터럽트(기다리지 않기), DMA(CPU를 데이터 운반에서 빼기), 소프트 IRQ/NAPI(인터럽트 폭주 막기) — 를 움직이는 그림으로 따라갑니다. 외울 건 없습니다. 매번 “그럼 CPU 사이클이 어디서 낭비되나”만 물으면 됩니다.
폴링 vs 인터럽트: 기다림을 없애는 법
장치가 일을 끝냈는지 아는 방법은 두 가지뿐입니다.
- 폴링(polling): CPU가 장치의 상태 레지스터를 계속 들여다봅니다. “끝났니? 끝났니? 끝났니?” 끝날 때까지 그 루프를 도느라 CPU는 다른 일을 못 합니다(busy-wait). 수 ms짜리 디스크 I/O를 폴링하면 수백만 사이클을 검사에만 태웁니다.
- 인터럽트(interrupt): CPU는 I/O를 시작시킨 뒤 딴 일을 하러 떠납니다. 장치가 일을 끝내면 하드웨어 신호(IRQ) 를 보내 CPU를 강제로 멈추고, CPU는 하던 일을 잠시 제쳐둔 채 인터럽트 핸들러로 점프합니다 — “끝났대” 한 번이면 됩니다.
아래에서 위쪽 폴링은 CPU가 장치를 끝없이 확인하며(빨강=낭비) 다른 일을 전혀 못 하고, 아래쪽 인터럽트는 CPU가 자기 일(초록)을 하다가 장치의 완료 신호 한 번(파랑)에만 반응합니다.
현실 체크 — “그럼 폴링은 항상 나쁜가?” 아닙니다. 장치가 극도로 빠르고(NVMe SSD), I/O가 쉴 새 없이 쏟아질 때는 오히려 폴링이 이깁니다. 인터럽트는 매번 모드 전환·핸들러 진입 비용을 치르는데, 초당 수십만 건이면 그 비용이 인터럽트 폭주(interrupt storm)가 되어 CPU를 잡아먹습니다. 그래서 Linux의 NVMe 드라이버에는
io_poll모드가 있고, 네트워크의 NAPI는 트래픽이 몰리면 인터럽트를 끄고 잠시 폴링으로 전환합니다. “느리고 드물면 인터럽트, 빠르고 잦으면 폴링” 이 핵심 직관입니다.
인터럽트 핸들러는 왜 둘로 쪼개지나 — Top half / Bottom half
인터럽트가 들어오면 CPU는 하던 일을 멈추고 핸들러로 뜁니다. 그런데 핸들러가 오래 걸리면 그동안 다른 인터럽트가 막히고(대개 인터럽트 비활성 상태로 실행), 시스템 응답성이 무너집니다. 그래서 리눅스는 인터럽트 처리를 둘로 나눕니다.
- Top half (하드 IRQ): 인터럽트 즉시 실행되는 최소한의 긴급 처리. 장치에 “받았다” ACK를 보내고, 데이터를 안전한 곳에 옮길 준비만 한 뒤 빠르게 빠져나옵니다. 인터럽트가 막히는 구간이라 최대한 짧아야 합니다.
- Bottom half (소프트 IRQ / tasklet / threaded IRQ): 무거운 후처리(패킷 프로토콜 처리, 버퍼 복사 등)는 인터럽트를 다시 켠 상태에서 나중에 처리합니다.
softirq,tasklet, 그리고 커널 스레드로 도는threaded IRQ가 그 수단입니다.
flowchart LR
DEV["장치<br/>완료"] -->|IRQ 신호| TH["Top half (하드 IRQ)<br/>ACK·최소 처리·매우 짧게"]
TH -->|"나중에"| BH["Bottom half<br/>softirq / tasklet / threaded IRQ<br/>무거운 후처리"]
BH --> DONE["커널·유저 데이터 전달"]
이 분리가 “인터럽트는 짧게, 일은 나중에”라는 커널 설계의 황금률입니다. /proc/interrupts로 어느 IRQ가 어느 CPU에서 얼마나 발생하는지, /proc/softirqs로 bottom half 부하를 볼 수 있습니다.
PIO vs DMA: CPU를 데이터 운반에서 빼다
인터럽트로 “기다림”은 없앴지만, 문제가 하나 더 남습니다. 데이터를 누가 옮기나? 디스크에서 4KB를 읽어 메모리에 넣는다고 합시다.
- PIO (Programmed I/O): CPU가 장치 레지스터에서 한 워드씩 읽어 메모리에 쓰기를 반복합니다. 4KB면 CPU가 수천 번 직접 복사합니다 — 데이터 운반에 CPU를 통째로 묶어 두는 셈입니다.
- DMA (Direct Memory Access): CPU는 DMA 컨트롤러에게 “이 장치에서 이만큼을 이 메모리 주소로 옮겨라”라고 명령만 내립니다. 그러면 DMA 컨트롤러가 CPU를 거치지 않고 장치↔메모리를 직접 전송하고, 다 끝나면 인터럽트 한 번으로 알립니다. 그동안 CPU는 다른 일을 합니다.
아래에서 데이터 블록들이 CPU를 우회해 장치에서 메모리로 직접 흐르고(초록), 전송이 끝난 뒤에야 완료 인터럽트(파랑)가 CPU로 올라갑니다. CPU 박스가 전송 내내 “다른 일 중”인 점을 보세요.
현실 체크 — 네트워크가 빠른 진짜 이유. 10Gbps NIC가 초당 수백만 패킷을 받는데 패킷마다 CPU가 복사하고 인터럽트를 걸면 CPU는 그 일만 하다 끝납니다. 그래서 현대 NIC는 DMA로 패킷을 메모리에 직접 꽂고, 인터럽트를 모아서(coalescing) 걸며, NAPI로 폭주 시 폴링으로 전환합니다. 더 나아가 zero-copy(
sendfile,splice)·커널 우회(DPDK, io_uring)까지 가는 이유가 전부 “CPU를 데이터 경로에서 빼라”는 한 문장입니다.
인터럽트 폭주를 길들이기 — Coalescing과 NAPI
빠른 장치에서 인터럽트가 너무 자주 오면, 처리보다 인터럽트 진입/복귀 오버헤드가 더 커집니다(live-lock에 가까운 상태). 두 가지로 길들입니다.
- 인터럽트 합치기(coalescing): 장치가 패킷 하나마다가 아니라, N개가 모이거나 일정 시간이 지나면 인터럽트를 한 번 겁니다. 지연(latency)을 약간 희생해 처리량(throughput)을 얻는 전형적 트레이드오프입니다(
ethtool -c로 조정). - NAPI (New API): 트래픽이 몰리면 NIC 인터럽트를 끄고 커널이 잠시 폴링으로 한꺼번에 긁어옵니다. 한가해지면 다시 인터럽트로 돌아갑니다 — “느리고 드물면 인터럽트, 빠르고 잦으면 폴링”을 자동 전환으로 구현한 것입니다.
마지막으로 장치의 두 부류만 짚고 갑시다. 블록 디바이스(디스크·SSD)는 고정 크기 블록 단위로 임의 접근하고 보통 페이지 캐시·I/O 스케줄러를 거칩니다(다음 글 주제). 캐릭터 디바이스(터미널·키보드·/dev/random)는 바이트 스트림으로 순차 처리합니다. 둘 다 결국 디바이스 드라이버가 위의 인터럽트·DMA 메커니즘을 장치별로 구현해 커널의 통일된 인터페이스 뒤에 숨깁니다.
직접 들여다보기
1
2
3
4
5
6
7
8
9
10
11
12
# 어떤 IRQ가 어느 CPU에서 몇 번 발생했나 (장치별 인터럽트 분포)
cat /proc/interrupts
# bottom half(소프트 IRQ) 부하 — NET_RX/NET_TX/TIMER 등
cat /proc/softirqs
# NIC 인터럽트 합치기 설정 보기/조정 (지연↔처리량 트레이드오프)
ethtool -c eth0
# 블록 장치 I/O 통계 — await(대기), %util(포화도)
iostat -x 1
# CPU별 인터럽트 처리 비율 (%irq, %soft)
mpstat -P ALL 1
# 특정 IRQ를 특정 CPU에 고정 (인터럽트 친화도)
cat /proc/irq/<N>/smp_affinity
%soft(softirq)나 특정 IRQ가 한 CPU에 쏠려 있다면, 그 코어가 인터럽트 처리로 포화된 것입니다 — irqbalance나 RSS/RPS로 분산하는 게 네트워크 성능 튜닝의 단골입니다.
면접/리뷰 단골 질문
- Q. 폴링과 인터럽트는 언제 각각 쓰나? → 느리고 드문 I/O는 인터럽트(기다리지 않고 CPU를 양보). 빠르고 잦은 I/O(NVMe·고속 NIC)는 인터럽트 오버헤드가 커져 폴링/NAPI가 유리. 절충이 NAPI·coalescing.
- Q. 인터럽트 핸들러를 top/bottom half로 나누는 이유는? → top half는 인터럽트가 막힌 채 실행되므로 ACK 등 최소 처리만 빠르게. 무거운 일은 인터럽트를 켠 상태에서 bottom half(softirq/tasklet/threaded IRQ)로 미뤄 응답성 보장.
- Q. DMA가 없으면 무엇이 문제인가? → CPU가 PIO로 데이터를 한 워드씩 직접 복사해야 해서, 대용량 전송 동안 CPU가 운반에 묶인다. DMA는 CPU를 데이터 경로에서 빼고 완료 인터럽트만 받게 한다.
- Q. 인터럽트와 시스템콜의 공통점·차이는? → 둘 다 커널로 진입하는 트랩. 시스템콜은 소프트웨어가 자발적으로(syscall), 인터럽트는 하드웨어가 비자발적으로 발생시킨다.
- Q. 인터럽트 storm이 뭐고 어떻게 막나? → 인터럽트가 너무 잦아 처리 오버헤드가 시스템을 잡아먹는 상태. coalescing(모아서 한 번), NAPI(폴링 전환), 친화도 분산으로 완화.
정리
- 장치는 CPU보다 수십만~백만 배 느리다 → 핵심 질문은 늘 “CPU 사이클이 어디서 낭비되나”.
- 인터럽트는 기다림(busy-wait)을 없앤다: CPU는 다른 일을 하다 완료 신호 한 번에만 반응한다.
- 핸들러는 top half(짧게)/bottom half(나중에) 로 쪼개 응답성을 지킨다.
- DMA는 CPU를 데이터 운반에서 뺀다: 컨트롤러가 장치↔메모리를 직접 전송하고 완료 인터럽트만 올린다.
- 빠른 장치에선 인터럽트가 되레 독 → coalescing·NAPI·폴링으로 길들인다. 모든 처방의 한 문장은 “CPU를 데이터 경로에서 빼라”.
다음 글: DMA로 메모리에 올라온 데이터는 어디에 머무는가 — 같은 파일을 두 번째 읽을 때 빨라지는 비밀, 블록 I/O와 페이지 캐시로 이어집니다. 인터럽트·시스템콜이 커널로 들어가는 트랩이라는 출발점은 1편에서 다뤘습니다.
