[CS300 #096] 입출력과 인터럽트 — CPU 가 장치를 기다리지 않는 방법
컴퓨터공학 300 주제 시리즈의 096번째 글이다. 전체 지도는 여기.
한 줄 요약
CPU 는 장치 레지스터를 읽고 써서 입출력을 지시하고, 일이 끝났는지는 계속 물어보거나(폴링) 장치가 신호를 보내 알려 주게(인터럽트) 해서 안다. 인터럽트는 CPU 가 하던 일을 잠깐 멈추고 미리 등록된 처리 루틴으로 뛰어가게 하는 하드웨어 장치이고, 운영체제의 거의 모든 반응성이 여기서 나온다.
왜 필요한가
디스크·네트워크 카드·키보드는 CPU 에 비해 아주 느리다. CPU 가 패킷이 올 때까지 “왔니? 왔니?” 를 묻고 있으면 그 시간 동안 다른 일을 못 한다. 아래 실험에서 1초 동안 데이터를 기다리는 데 폴링은 CPU 를 0.98초 썼고, 인터럽트 기반 대기는 0.00초를 썼다.
서버 운영에서도 이 층이 보인다. top 의 hi, si (하드웨어·소프트웨어 인터럽트 시간), 네트워크 처리량이 특정 코어 하나에 몰리는 현상, 고성능 네트워크 스택이 오히려 폴링으로 돌아가는 이유가 모두 인터럽트를 알아야 설명된다.
핵심 개념
CPU 가 장치에 말 거는 법
장치에는 상태·명령·데이터 레지스터가 있다. CPU 가 이것을 읽고 쓰는 방법은 두 가지다.
| 방식 | 원리 | 예 |
|---|---|---|
| 포트 맵 I/O | 메모리와 별도인 I/O 주소 공간, 전용 명령어(x86 의 in, out) |
레거시 x86 장치 |
| 메모리 맵 I/O (MMIO) | 장치 레지스터를 물리 주소 공간의 일부에 배치, 일반 load/store 로 접근 | PCIe 장치 대부분, ARM·RISC-V 전반 |
MMIO 영역은 캐시하면 안 된다. 장치 레지스터 값은 CPU 모르게 바뀌기 때문이다. 그래서 페이지 테이블에 “캐시 금지” 속성을 붙여 매핑한다.
완료를 아는 세 가지 방법
| 방식 | CPU 부담 | 지연 | 쓰임 |
|---|---|---|---|
| 폴링 | 기다리는 내내 CPU 사용 | 가장 짧음 | 아주 빠른 장치, 고성능 네트워크·스토리지 |
| 인터럽트 | 사건이 있을 때만 | 처리 루틴 진입 비용 | 대부분의 장치 |
| DMA + 인터럽트 | 데이터 복사도 장치가 함 | 대량 전송에 유리 | 디스크, NIC (다음 글 #097) |
인터럽트 처리 흐름
장치 ──인터럽트 신호──► 인터럽트 컨트롤러 ──► CPU
│ 현재 명령어를 마친 뒤
▼
1. 현재 상태(PC, 플래그 등) 저장
2. 커널 모드로 전환
3. 인터럽트 번호로 벡터 테이블을 찾아
처리 루틴(ISR)으로 점프
4. ISR: 장치 확인, 최소한의 일, 장치에 확인 응답
5. 상태 복원, 원래 코드로 복귀
인터럽트 컨트롤러(x86 의 APIC, ARM 의 GIC)는 여러 장치의 신호를 모아 우선순위를 매기고 어느 CPU 코어로 보낼지 정한다. 현대 PCIe 장치는 전용 선 대신 특정 주소에 메모리 쓰기를 해서 인터럽트를 알리는 MSI/MSI-X 를 쓴다. MSI-X 는 장치 하나가 여러 인터럽트 벡터를 가질 수 있어서, 네트워크 카드의 큐마다 서로 다른 코어로 인터럽트를 보낼 수 있다.
인터럽트, 예외, 트랩
| 이름 | 원인 | 동기성 | 예 |
|---|---|---|---|
| 인터럽트 | 외부 장치 | 비동기(아무 명령어 사이에나) | 타이머, NIC, 디스크 |
| 예외(폴트) | 명령어 실행 중 문제 | 동기 | 페이지 폴트(#095), 0 나누기 |
| 트랩 | 의도적으로 발생 | 동기 | 시스템 콜(syscall), 디버거 중단점 |
셋 다 “하던 일을 멈추고 커널의 처리 루틴으로 간다” 는 같은 메커니즘을 쓴다. 타이머 인터럽트는 특히 중요하다. 이것이 있어야 OS 가 무한 루프를 도는 프로세스에게서도 CPU 를 빼앗아 다른 프로세스를 돌릴 수 있다(선점형 스케줄링).
상반부와 하반부
인터럽트 처리 루틴이 실행되는 동안에는 같은 인터럽트가 막히고 지금 돌던 작업도 멈춰 있다. 그래서 리눅스는 처리를 둘로 나눈다.
- 상반부(hard IRQ): 장치에 확인 응답하고 데이터 위치만 기록하는 최소한의 일. 아주 짧게.
- 하반부(softirq, tasklet, 워크큐 등): 패킷 프로토콜 처리 같은 무거운 일을 나중에 인터럽트를 다시 허용한 상태에서.
인터럽트 폭풍과 NAPI
초당 수십만 개의 패킷이 오는데 패킷마다 인터럽트를 받으면 CPU 가 인터럽트 처리만 하다 끝난다. 리눅스 네트워크 스택의 NAPI 는 첫 패킷에서 인터럽트를 받으면 인터럽트를 끄고 폴링으로 전환해 쌓인 패킷을 한꺼번에 처리하고, 큐가 비면 다시 인터럽트를 켠다. 부하가 낮을 땐 인터럽트, 높을 땐 폴링. 두 방식의 장점을 상황에 따라 고르는 설계다.
직접 해 보기
폴링과 블로킹 대기의 CPU 사용량을 비교한다. 파이프를 장치라고 보고, 1초 뒤에 데이터가 도착한다. 블로킹 쪽은 select 로 커널에 “데이터가 오면 깨워 달라” 고 맡긴다. 커널은 그 사이 이 스레드를 재우고, 사건이 생기면 깨운다. 실제 장치였다면 그 사건의 출발점이 인터럽트다. 리눅스에서 python3 로 확인했다.
import os, select, threading, time
def writer(fd, delay):
time.sleep(delay) # 1초 뒤에 "장치" 가 데이터를 내놓는다
os.write(fd, b"x")
def polling():
r, w = os.pipe()
os.set_blocking(r, False)
threading.Thread(target=writer, args=(w, 1.0)).start()
checks = 0
while True: # 바쁜 대기: 계속 상태를 물어본다
checks += 1
try:
return os.read(r, 1), checks
except BlockingIOError:
pass
def blocking():
r, w = os.pipe()
threading.Thread(target=writer, args=(w, 1.0)).start()
select.select([r], [], []) # 커널이 데이터가 오면 깨워 준다
return os.read(r, 1), 1
for f in (polling, blocking):
c0, t0 = time.process_time(), time.perf_counter()
data, checks = f()
print(f"{f.__name__:9s} wall {time.perf_counter() - t0:.2f}s "
f"cpu {time.process_time() - c0:.2f}s checks {checks:,}")
출력:
polling wall 1.00s cpu 0.98s checks 221,208
blocking wall 1.00s cpu 0.00s checks 1
기다린 시간(wall)은 똑같이 1초다. 폴링은 그 1초 내내 22만 번 물어보며 코어 하나를 다 썼고, 블로킹은 CPU 를 거의 쓰지 않았다. 반면 데이터가 매우 자주, 아주 빨리 오는 상황이라면 잠들었다 깨는 비용이 폴링보다 커질 수 있다. 정답은 상황마다 다르다.
인터럽트가 실제로 얼마나 오는지는 리눅스의 /proc/interrupts 파일에서 인터럽트 번호·CPU 별 누적 횟수·장치 이름으로 볼 수 있다. watch -n1 cat /proc/interrupts 로 보면 타이머와 네트워크 카드 줄의 숫자가 계속 올라간다.
현업에서는
- 네트워크 처리가 한 코어에 몰릴 때: 네트워크 카드의 인터럽트가 전부 CPU 0 으로 가면 트래픽이 많을 때 그 코어의
si가 100% 에 붙고 나머지 코어는 논다. 멀티 큐 NIC 의 큐별 인터럽트를 여러 코어에 나누는 IRQ 친화도 설정,irqbalance, RPS/RFS 같은 소프트웨어 분산이 해법이다. - 폴링으로의 회귀: DPDK, io_uring 의 SQPOLL 모드, NVMe 폴링 모드처럼 아주 낮은 지연이 필요한 곳은 코어 하나를 희생해 폴링한다. 인터럽트 진입·복귀 비용과 지연 변동을 없애기 위해서다.
- 컨테이너 CPU 지표 읽기: 쿠버네티스 노드에서 파드들의 CPU 합이 노드 CPU 사용량보다 훨씬 작다면 인터럽트·소프트IRQ 처리처럼 특정 컨테이너에 귀속되지 않는 커널 작업을 의심한다. 홈랩처럼 NIC 성능이 낮은 노드에서 트래픽이 몰리면 이 몫이 눈에 띄게 커진다.
확인 문제
- 메모리 맵 I/O 영역을 캐시하면 안 되는 이유는.
- 인터럽트와 예외의 차이는.
- 리눅스가 인터럽트 처리를 상반부와 하반부로 나누는 이유는.
- NAPI 가 부하에 따라 인터럽트와 폴링을 바꾸는 이유는.
풀이
- 장치 레지스터는 CPU 와 무관하게 값이 바뀌므로, 캐시된 낡은 값을 읽거나 쓰기가 늦게 반영되면 장치를 잘못 다루게 되기 때문이다.
- 인터럽트는 외부 장치가 비동기적으로 일으키고, 예외는 실행 중인 명령어 때문에 동기적으로 생긴다.
- 인터럽트 처리 중에는 다른 작업과 같은 인터럽트가 막히므로, 꼭 필요한 최소한의 일만 즉시 하고 무거운 일은 미뤄 시스템 반응성을 지키기 위해서다.
- 부하가 낮을 때는 인터럽트가 CPU 를 아끼고, 부하가 높을 때는 인터럽트 폭풍을 피하고 한꺼번에 처리하는 폴링이 효율적이기 때문이다.
더 읽을거리 (References)
- Linux Kernel Documentation, Linux generic IRQ handling
- Linux Kernel Documentation, The MSI Driver Guide HOWTO
- Linux Kernel Documentation, NAPI
- Linux Kernel Documentation, The /proc Filesystem (
/proc/interrupts)