컴퓨터공학 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 성능이 낮은 노드에서 트래픽이 몰리면 이 몫이 눈에 띄게 커진다.

확인 문제

  1. 메모리 맵 I/O 영역을 캐시하면 안 되는 이유는.
  2. 인터럽트와 예외의 차이는.
  3. 리눅스가 인터럽트 처리를 상반부와 하반부로 나누는 이유는.
  4. NAPI 가 부하에 따라 인터럽트와 폴링을 바꾸는 이유는.

풀이

  1. 장치 레지스터는 CPU 와 무관하게 값이 바뀌므로, 캐시된 낡은 값을 읽거나 쓰기가 늦게 반영되면 장치를 잘못 다루게 되기 때문이다.
  2. 인터럽트는 외부 장치가 비동기적으로 일으키고, 예외는 실행 중인 명령어 때문에 동기적으로 생긴다.
  3. 인터럽트 처리 중에는 다른 작업과 같은 인터럽트가 막히므로, 꼭 필요한 최소한의 일만 즉시 하고 무거운 일은 미뤄 시스템 반응성을 지키기 위해서다.
  4. 부하가 낮을 때는 인터럽트가 CPU 를 아끼고, 부하가 높을 때는 인터럽트 폭풍을 피하고 한꺼번에 처리하는 폴링이 효율적이기 때문이다.

더 읽을거리 (References)