본문으로 건너뛰기

안동민 개발노트

본문 시작

I/O 처리 방식

폴링·인터럽트·DMA가 CPU와 장치 사이의 완료 대기와 데이터 전송을 처리하는 방식과 비용을 비교합니다.

CPU가 I/O 장치에 작업을 요청한 후, 완료될 때까지 어떻게 기다릴 것인가?

이 질문에 대한 답이 세 가지 I/O 처리 방식입니다.

각 방식의 트레이드오프를 이해하면 시스템 성능을 어떻게 끌어올리는지 보입니다.

CPU가 완료를 기다리는 방식은 완료 신호의 주인이 다르다

Polling, Interrupt, DMA는 장치 완료를 CPU가 확인하는지, 장치가 알려 주는지, 전송 자체를 넘기는지의 차이다.

  1. Polling

    CPU가 상태 레지스터를 반복해서 읽는다. 단순하지만 기다리는 동안 CPU 시간이 소모된다.

  2. Interrupt

    장치가 완료 시점을 알려 CPU가 다른 일을 하다가 handler에서 결과를 처리한다.

  3. DMA

    블록 전송은 DMA 컨트롤러가 메모리로 옮기고, CPU는 완료 인터럽트만 처리한다.

방식CPU 역할장치/메모리 역할잘 맞는 경우
Polling상태 비트를 계속 읽는다.완료 플래그를 세운다.짧고 드문 I/O, 구현 단순성이 더 중요한 경우
Interrupt다른 작업을 하다가 handler로 전환한다.완료 시 인터럽트를 보낸다.키보드, 네트워크처럼 완료 시점이 불규칙한 경우
DMA전송 범위만 설정하고 빠진다.컨트롤러가 메모리 블록을 직접 옮긴다.디스크, 대량 네트워크처럼 블록 전송이 큰 경우

프로그래밍된 I/O (폴링)

폴링(Polling)은 CPU가 장치의 상태 레지스터를 반복적으로 확인하여 작업 완료 여부를 체크하는 방식입니다.

"다 됐어?

아직?

다 됐어?

아직?"을 계속 묻는 것과 같습니다.

polling_example.c
#include <stdint.h>

/* 포트 I/O를 통한 폴링 방식 (개념 코드) */
#define STATUS_REG  0x64   /* 키보드 상태 레지스터 */
#define DATA_REG    0x60   /* 키보드 데이터 레지스터 */
#define OUTPUT_READY 0x01  /* 출력 버퍼 채워짐 비트 */

uint8_t inb(uint16_t port);  /* 포트에서 1바이트 읽기 */

uint8_t poll_keyboard(void) {
    /* CPU가 바쁜 대기(busy wait)를 수행 */
    while ((inb(STATUS_REG) & OUTPUT_READY) == 0) {
        /* 장치가 준비될 때까지 무한 루프 */
        /* 이 동안 CPU는 아무 일도 하지 못함 */
    }
    return inb(DATA_REG);  /* 준비되면 데이터 읽기 */
}

장점은 구현이 단순하고 응답이 즉각적이라는 것입니다.

I/O가 매우 빠르게 완료되는 경우(CPU 캐시 플러시, 고속 NIC 등), 인터럽트의 컨텍스트 전환 비용보다 폴링이 효율적일 수 있습니다.

단점은 CPU가 I/O 완료를 기다리면서 다른 일을 하지 못한다(Busy Waiting)는 것입니다.

디스크 접근(수 ms)을 폴링으로 기다리면 CPU 수백만 사이클이 낭비됩니다.

프로그래밍 I/O는 단순하지만, 상태 레지스터를 반복해서 읽는 동안 CPU가 실제 계산을 하지 못한다는 점이 핵심 한계입니다.

프로그래밍 I/O 비용

폴링 방식은 장치가 준비됐는지 CPU가 직접 확인한다. 구현은 단순하지만 기다리는 동안 CPU 시간을 계속 소비하므로 인터럽트와 DMA의 필요성이 드러난다.

  1. 상태 레지스터를 읽는다

    CPU는 장치가 ready인지 확인하기 위해 같은 주소를 반복적으로 읽는다.

  2. 준비되면 데이터를 옮긴다

    장치 data register와 메모리 사이를 CPU 명령으로 직접 복사한다.

  3. 대기 비용을 평가한다

    장치가 느릴수록 CPU는 실제 계산보다 상태 확인에 시간을 낭비한다.

  4. 준비 확인

    CPU가 어떤 상태 bit를 기다리는지 설명할 수 있는가.

  5. 낭비

    폴링 루프가 CPU 시간을 어떻게 소모하는가.

  6. 대안

    인터럽트와 DMA가 각각 어떤 비용을 줄이는지 구분하는가.

while ((status & READY) == 0) { /* busy wait */ }
*buffer = data_register;

인터럽트 기반 I/O

인터럽트(Interrupt) 방식에서는 CPU가 I/O 요청을 보낸 후 다른 작업을 수행합니다.

I/O 장치가 작업을 완료하면 인터럽트 신호를 보내고, CPU가 현재 작업을 중단하고 인터럽트 핸들러(ISR, Interrupt Service Routine)를 실행합니다.

인터럽트 처리 과정

  1. CPU가 I/O 컨트롤러에 명령을 보냅니다.
  2. CPU는 다른 프로세스를 실행합니다.
  3. I/O 장치가 작업을 완료합니다.
  4. 장치가 인터럽트 요청(IRQ) 신호를 인터럽트 컨트롤러(PIC/APIC)에 보냅니다.
  5. 인터럽트 컨트롤러가 CPU에 인터럽트를 전달합니다.
  6. CPU는 현재 명령을 마치고, 상태(레지스터, PC)를 스택에 저장합니다.
  7. 인터럽트 벡터 테이블(IVT)에서 해당 IRQ의 핸들러 주소를 찾습니다.
  8. 핸들러가 I/O 완료를 처리합니다(데이터 복사, 프로세스 깨우기 등).
  9. 저장된 상태를 복원하고 원래 작업으로 돌아갑니다.
interrupt_handler.c
/* Linux 커널 인터럽트 핸들러 (개념 코드) */
#include <linux/interrupt.h>

/* 인터럽트 핸들러 함수 */
irqreturn_t my_irq_handler(int irq, void *dev_id) {
    /* 1. 장치의 상태 레지스터를 읽어 원인 확인 */
    /* 2. 데이터를 버퍼로 복사 */
    /* 3. 대기 중인 프로세스를 깨움 */
    /* 중요: 핸들러는 가능한 짧아야 함 (인터럽트 비활성 상태) */
    return IRQ_HANDLED;
}

/* 드라이버 초기화 시 핸들러 등록 */
/* request_irq(irq_num, my_irq_handler, IRQF_SHARED, "mydev", dev); */

Top Half와 Bottom Half

인터럽트 핸들러는 인터럽트가 비활성화된 상태에서 실행되므로, 오래 걸리면 다른 인터럽트를 놓칩니다.

Linux는 이를 두 단계로 분리합니다.

Top Half: 즉시 처리해야 할 최소 작업만 수행합니다.

하드웨어 레지스터 읽기, ACK 보내기, Bottom Half 스케줄링.

인터럽트 비활성 상태에서 실행됩니다.

Bottom Half: 나머지 작업을 나중에 처리합니다.

데이터 파싱, 프로토콜 처리 등.

softirq, tasklet, workqueue 메커니즘으로 구현됩니다.

인터럽트가 활성화된 상태에서 실행됩니다.

인터럽트 과부하 (Interrupt Storm)

대량의 데이터가 쏟아지면 인터럽트가 초당 수십만 건 발생할 수 있습니다.

인터럽트 처리 자체가 CPU를 지배하는 Livelock 상태가 됩니다.

네트워크 카드의 NAPI(New API)는 이 문제에 대한 Linux의 해결책입니다.

첫 패킷은 인터럽트로 알리되, 이후에는 폴링 모드로 전환하여 한 번에 여러 패킷을 처리합니다.

패킷이 줄어들면 다시 인터럽트 모드로 돌아갑니다.


DMA (Direct Memory Access)

인터럽트 방식에서도 데이터 전송은 CPU가 합니다.

1바이트씩 레지스터에서 메모리로 복사합니다.

대용량 전송에서 CPU가 데이터 복사에만 매달리는 것은 비효율적입니다.

DMA는 CPU 대신 DMA 컨트롤러가 데이터 전송을 담당합니다.

DMA 전송 과정

  1. CPU가 DMA 컨트롤러에 전송 정보를 설정합니다.
    • 소스 주소(장치 레지스터 또는 메모리)
    • 목적지 주소(메모리)
    • 전송할 바이트 수
    • 전송 방향(읽기/쓰기)
  2. DMA 컨트롤러가 버스를 장악하여 장치와 메모리 사이에서 직접 데이터를 전송합니다.
  3. CPU는 그 동안 다른 작업을 수행합니다(캐시 접근은 가능하지만, 메모리 버스를 공유하므로 약간의 대역폭 경쟁이 발생합니다 — 사이클 스틸링).
  4. 전송 완료 시 DMA 컨트롤러가 CPU에 인터럽트 한 번을 보냅니다.
DMA 전송 역할

대용량 I/O에서 CPU가 바이트마다 복사하면 계산 시간이 사라집니다. DMA는 설정, 전송, 완료 통지를 분리해 인터럽트 수와 복사 비용을 줄입니다.

  1. CPU 작업

    Setup CPU 작업 드라이버가 버퍼 주소와 전송 길이를 DMA 디스크립터에 기록합니다. src, dst, count, direction 전송이 시작되면 CPU는 다른 작업으로 돌아갈 수 있습니다.

  2. A
    명령 제출

    장치 큐에 전송 요청을 넣고 시작 신호를 보냅니다.

  3. B
    버스 전송

    DMA가 메모리 버스를 사용해 블록을 직접 복사합니다.

  4. C
    완료 알림

    전송이 끝나면 인터럽트로 커널을 깨웁니다.

  5. 메모리 버퍼

    Result 메모리 버퍼 장치 데이터가 커널 버퍼나 사용자 버퍼로 모이고, CPU는 완료 처리만 수행합니다. interrupt count: 1 바이트마다 깨우지 않기 때문에 대용량 전송에서 효과가 큽니다.

dma_concept.py
class DMAController:
    """DMA 컨트롤러 동작 개념"""

    def __init__(self):
        self.src_addr = 0
        self.dst_addr = 0
        self.count = 0
        self.done = False

    def setup(self, src, dst, count):
        """CPU가 DMA 설정"""
        self.src_addr = src
        self.dst_addr = dst
        self.count = count
        self.done = False
        print(f"DMA 설정: {src:#x}{dst:#x}, {count} bytes")

    def transfer(self, memory):
        """DMA가 CPU 없이 직접 전송"""
        for i in range(self.count):
            memory[self.dst_addr + i] = memory[self.src_addr + i]
        self.done = True
        print(f"DMA 전송 완료 → CPU에 인터럽트 1회")

# 1MB 전송:
# 인터럽트만 사용: 인터럽트 ~100만 번 (1바이트마다)
# DMA 사용: 인터럽트 1번 (전체 전송 완료 시)

Scatter-Gather DMA

현대 DMA 컨트롤러는 Scatter-Gather 기능을 지원합니다.

연속되지 않은 여러 메모리 영역을 한 번의 DMA 연산으로 전송합니다.

디스크립터 테이블에 (주소, 길이) 쌍의 목록을 제공하면, DMA 컨트롤러가 순서대로 전송합니다.

네트워크 패킷처럼 헤더와 본문이 다른 메모리 위치에 있는 경우에 유용합니다.

인터럽트는 완료 알림에 강하고, DMA는 데이터 이동에 강합니다.

실제 드라이버에서는 버퍼 고정, 디스크립터 구성, 완료 인터럽트, bottom half 후처리를 나누어 봐야 합니다.

DMA와 대량 입출력

인터럽트는 완료 알림에 강하고, DMA는 데이터 이동에 강하며, 드라이버는 둘 사이의 큐와 버퍼 소유권을 관리한다.

  1. 버퍼 고정

    driver 커널은 DMA 대상 페이지가 이동하지 않게 준비하고 장치가 이해하는 descriptor를 만든다.

  2. 장치 실행

    controller 디스크나 NIC 컨트롤러가 버스 마스터로 메모리와 장치 사이의 데이터를 직접 옮긴다.

  3. 완료 통지

    interrupt 전송이 끝나면 인터럽트가 발생하고 top half는 빠르게 상태를 확인한 뒤 지연 작업을 넘긴다.

  4. 후속 처리

    bottom half softirq, tasklet, workqueue가 패킷 처리나 대기 프로세스 깨우기처럼 긴 일을 맡는다.


세 방식의 비교

기준폴링인터럽트DMA
CPU 활용낭비됨 (busy wait)효율적가장 효율적
구현 복잡도단순중간복잡 (하드웨어 필요)
데이터 전송 주체CPUCPUDMA 컨트롤러
인터럽트 횟수0바이트/워드당 1회전체 전송당 1회
적합한 상황고속 단건 I/O일반 I/O대용량 데이터 전송
지연 시간최소인터럽트 > 전환 비용설정 오버헤드 있음

실무에서의 혼합 사용

고성능 네트워크 드라이버(NAPI)가 대표적입니다.

  1. 첫 패킷 도착 → 인터럽트로 알림
  2. 패킷이 쏟아지면 → 인터럽트 비활성화, 폴링 모드로 전환
  3. DMA로 패킷 데이터를 메모리에 복사
  4. 패킷 빈도가 낮아지면 → 다시 인터럽트 모드로 전환

이 적응형 방식으로 저부하에서는 응답성을, 고부하에서는 처리량을 최적화합니다.

부하가 바뀌면 하나의 방식만 고집하지 않고 폴링, 인터럽트, DMA를 섞습니다.

아래 흐름은 이벤트 빈도와 전송량을 기준으로 선택 지점을 나눕니다.

폴링, 인터럽트, DMA는 CPU 개입량으로 구분한다

장치가 느릴수록 CPU가 계속 확인하는 비용이 커지고, 큰 전송은 DMA로 CPU 복사를 줄인다.

  1. Polling

    CPU가 반복 확인 단순하지만 낭비

  2. Interrupt

    장치가 알림 대기 시간 절약

  3. DMA

    메모리 직접 전송 큰 블록에 유리

  4. Hybrid

    상황별 조합 지연/처리량 균형

블로킹 I/O vs 논블로킹 I/O

블로킹(Blocking) I/O: 프로세스가 I/O 요청을 하면 I/O가 완료될 때까지 대기(Sleep) 상태가 됩니다.

대부분의 기본 read(), write() 호출이 이 방식입니다.

논블로킹(Non-blocking) I/O: I/O가 즉시 완료되지 않으면 에러(EAGAIN)를 반환합니다.

프로세스는 나중에 다시 시도합니다.

O_NONBLOCK 플래그로 설정합니다.

비동기(Asynchronous) I/O: I/O 요청을 제출하고 즉시 반환됩니다.

완료되면 시그널이나 콜백으로 알림받습니다.

Linux의 io_uring이 현대적 비동기 I/O 인터페이스입니다.

I/O 멀티플렉싱: select(), poll(), epoll()로 여러 fd를 동시에 감시합니다.

웹 서버가 수천 개의 클라이언트 연결을 하나의 스레드로 처리할 수 있는 비결입니다.

기다릴지, 다시 시도할지, 완료 알림을 받을지 고른다

블로킹, 논블로킹, 비동기, 멀티플렉싱은 프로세스가 I/O 완료를 기다리는 방식을 다르게 설계합니다.

  1. Blocking 요청한 I/O

    끝날 때까지 프로세스가 sleep 상태로 기다린다.

  2. Non-blocking 준비되지 않으면 EAGAIN

    받고 나중에 다시 시도한다.

  3. Async 요청

    제출한 뒤 다른 일을 하고 완료 알림을 받는다.

  4. Multiplexing 여러 fd

    동시에 감시하고 준비된 작업만 처리한다.

방식좋은 경우확인할 비용
Blocking흐름이 단순하고 동시 연결 수가 작다.대기 중 CPU를 쓰지 않지만 스레드가 묶인다.
Non-blocking응답성을 유지하며 직접 재시도 루프를 관리한다.busy wait가 되지 않도록 이벤트 감시와 묶는다.
Async 또는 epoll많은 연결과 높은 처리량을 한 루프로 다룬다.완료 순서, 오류 처리, backpressure 설계가 필요하다.

다음 절에서는 디스크의 물리적 구조와 디스크 스케줄링 알고리즘, 그리고 SSD의 등장이 가져온 변화를 살펴보겠습니다.