icon

안동민 개발노트

11장 : I/O 시스템

I/O 처리 방식


CPU가 I/O 장치에 작업을 요청한 후, 완료될 때까지 어떻게 기다릴 것인가? 이 질문에 대한 답이 세 가지 I/O 처리 방식입니다. 각 방식의 트레이드오프를 이해하면 시스템 성능을 어떻게 끌어올리는지 보입니다.


프로그래밍된 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

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

실무에서의 혼합 사용

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

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

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

블로킹 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를 동시에 감시합니다. 웹 서버가 수천 개의 클라이언트 연결을 하나의 스레드로 처리할 수 있는 비결입니다.

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

목차