I/O 처리 방식
폴링·인터럽트·DMA가 CPU와 장치 사이의 완료 대기와 데이터 전송을 처리하는 방식과 비용을 비교합니다.
CPU가 I/O 장치에 작업을 요청한 후, 완료될 때까지 어떻게 기다릴 것인가?
폴링과 인터럽트는 완료를 알아내는 방식입니다. DMA는 데이터를 옮기는 주체에 관한 선택이므로 같은 축의 세 대안으로 보지 않습니다.
각 방식의 트레이드오프를 이해하면 시스템 성능을 어떻게 끌어올리는지 보입니다.
폴링과 프로그래밍된 I/O
폴링(Polling)은 CPU가 장치의 상태 레지스터를 반복적으로 확인하여 작업 완료 여부를 체크하는 방식입니다.
"다 됐어?
아직?
다 됐어?
아직?"을 계속 묻는 것과 같습니다.
#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가 매우 빠르게 완료되는 경우(고속 NIC 등), 인터럽트 진입·처리 비용보다 폴링이 효율적일 수 있습니다.
단점은 CPU가 I/O 완료를 기다리면서 다른 일을 하지 못한다(Busy Waiting)는 것입니다.
디스크 접근(수 ms)을 폴링으로 기다리면 CPU 수백만 사이클이 낭비됩니다.
프로그래밍 I/O는 단순하지만, 상태 레지스터를 반복해서 읽는 동안 CPU가 실제 계산을 하지 못한다는 점이 핵심 한계입니다.
인터럽트 기반 I/O
인터럽트(Interrupt) 방식에서는 CPU가 I/O 요청을 보낸 후 다른 작업을 수행합니다.
I/O 장치가 작업을 완료하면 인터럽트 신호를 보내고, CPU가 현재 작업을 중단하고 인터럽트 핸들러(ISR, Interrupt Service Routine)를 실행합니다.
인터럽트 처리 과정
- CPU가 I/O 컨트롤러에 명령을 보냅니다.
- CPU는 다른 일을 하거나 유휴 상태로 기다릴 수 있습니다.
- I/O 장치가 작업을 완료합니다.
- 장치가 인터럽트 요청(IRQ) 신호를 인터럽트 컨트롤러(PIC/APIC)에 보냅니다.
- 인터럽트 컨트롤러가 CPU에 인터럽트를 전달합니다.
- CPU는 현재 명령을 마치고, 상태(레지스터, PC)를 스택에 저장합니다.
- 아키텍처의 인터럽트 진입 경로를 거쳐 핸들러를 찾습니다. 현대 x86 보호 모드에서는 IDT를 사용하며, IRQ 번호와 CPU 벡터를 구분합니다.
- 핸들러가 I/O 완료를 처리합니다(데이터 복사, 프로세스 깨우기 등).
- 인터럽트 처리를 마치고 재개합니다. 스케줄링이 필요한 경우 다른 태스크가 먼저 실행될 수도 있습니다.
/* Linux 커널 인터럽트 핸들러 (개념 코드) */
#include <linux/interrupt.h>
/* 인터럽트 핸들러 함수 */
irqreturn_t my_irq_handler(int irq, void *dev_id) {
/* 1. 장치의 상태 레지스터를 읽어 원인 확인
* 공유 IRQ가 우리 장치 것이 아니라면 IRQ_NONE 반환 */
/* 2. 데이터를 버퍼로 복사 */
/* 3. 대기 중인 프로세스를 깨움 */
/* hard IRQ 처리 부분은 짧게 유지하고 수면 가능한 작업을 직접 하지 않음 */
return IRQ_HANDLED;
}
/* 드라이버 초기화 시 핸들러 등록 */
/* request_irq(irq_num, my_irq_handler, IRQF_SHARED, "mydev", dev); */Top Half와 Bottom Half
일반적인 hard IRQ 핸들러가 오래 실행되면 해당 CPU의 다른 처리가 지연됩니다. 무조건 인터럽트를 잃는다는 뜻은 아니며 다른 CPU의 인터럽트까지 모두 금지되는 것도 아닙니다.
Linux는 이를 두 단계로 분리합니다.
Top Half: 즉시 처리해야 할 최소 작업만 수행합니다.
하드웨어 레지스터 읽기, ACK 보내기, Bottom Half 스케줄링.
인터럽트 비활성 상태에서 실행됩니다.
Bottom Half: 나머지 작업을 나중에 처리합니다.
데이터 파싱, 프로토콜 처리 등.
softirq, tasklet, workqueue 메커니즘으로 구현됩니다.
실행 문맥은 메커니즘마다 다릅니다. softirq/tasklet과 일반적인 threaded workqueue는 수면 가능 여부가 다르며 WQ_BH처럼 softirq에서 실행하는 workqueue도 있습니다.
인터럽트 과부하 (Interrupt Storm)
대량의 데이터가 쏟아지면 인터럽트가 초당 수십만 건 발생할 수 있습니다.
처리가 유효한 작업 진행을 압도하면 receive livelock 같은 상황으로 이어질 수 있습니다. 높은 IRQ 수만으로 livelock을 확정하지 않습니다.
네트워크 카드의 NAPI(New API)는 이 문제에 대한 Linux의 해결책입니다.
첫 패킷은 인터럽트로 알리되, 이후에는 폴링 모드로 전환하여 한 번에 여러 패킷을 처리합니다.
패킷이 줄어들면 다시 인터럽트 모드로 돌아갑니다.
DMA (Direct Memory Access)
인터럽트는 알림이고 데이터 전송 주체를 정하지 않습니다. PIO에서는 CPU 명령으로 데이터를 옮기며, DMA를 사용하면서 완료 인터럽트를 받을 수도 있습니다.
DMA는 CPU 대신 DMA 컨트롤러가 데이터 전송을 담당합니다.
DMA 전송 과정
- CPU가 DMA 컨트롤러에 전송 정보를 설정합니다.
- 소스 주소(장치 레지스터 또는 메모리)
- 목적지 주소(메모리)
- 전송할 바이트 수
- 전송 방향(읽기/쓰기)
- 장치의 DMA 기능이 버스·인터커넥트의 중재 규칙에 따라 메모리로 데이터를 전송합니다.
- CPU는 그 동안 다른 작업을 수행합니다(캐시 접근은 가능하지만, 메모리 버스를 공유하므로 약간의 대역폭 경쟁이 발생합니다 — 사이클 스틸링).
- 완료는 인터럽트나 폴링 등으로 확인합니다. 묶음 처리와 디스크립터 설정에 따라 알림 수가 달라집니다.
장치→메모리 전송의 개념 예시입니다. 드라이버는 장치가 사용할 DMA 주소와 버퍼 수명·동기화를 관리합니다. 완료는 IRQ 또는 폴링으로 확인하며 실제 알림 횟수는 설정에 따릅니다.
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가 아니라 Python CPU 루프로 전송 효과를 모사"""
for i in range(self.count):
memory[self.dst_addr + i] = memory[self.src_addr + i]
self.done = True
print("DMA 전송 모사 완료 (완료 알림 1회를 가정)")
# 1MB 전송:
# 이 모형은 전송 뒤 알림 1회를 가정하며 실제 IRQ를 발생시키지 않음
# 인터럽트 횟수는 바이트 수만으로 결정되지 않음Scatter-Gather DMA
현대 DMA 컨트롤러는 Scatter-Gather 기능을 지원합니다.
연속되지 않은 여러 메모리 영역을 한 번의 DMA 연산으로 전송합니다.
디스크립터 테이블에 (주소, 길이) 쌍의 목록을 제공하면, DMA 컨트롤러가 순서대로 전송합니다.
네트워크 패킷처럼 헤더와 본문이 다른 메모리 위치에 있는 경우에 유용합니다.
인터럽트는 완료 알림에 강하고, DMA는 데이터 이동에 강합니다.
실제 드라이버에서는 버퍼 고정, 디스크립터 구성, 완료 인터럽트, bottom half 후처리를 나누어 봐야 합니다.
서로 다른 선택 축
| 선택 | 역할과 확인할 비용 |
|---|---|
| 폴링 / 인터럽트 | 완료를 확인하는 방식. 반복 조회 비용과 알림 처리·지연을 비교 |
| PIO / DMA | 데이터를 옮기는 방식. CPU 복사와 장치 전송·매핑·동기화 비용을 비교 |
| 조합 | DMA와 폴링, DMA와 인터럽트 모두 가능. 항상 가장 효율적인 한 방식은 없음 |
실무에서의 혼합 사용
고성능 네트워크 드라이버(NAPI)가 대표적입니다.
- 장치가 설정된 DMA 버퍼에 데이터를 기록하고 완료 정보를 남깁니다.
- IRQ가 NAPI 처리를 예약하면 해당 큐의 알림을 억제하고 poll 메서드로 완료 항목을 묶어 회수할 수 있습니다.
- 처리할 항목이 소진되면 NAPI 완료와 인터럽트 재활성화를 진행합니다.
NAPI는 설정에 따라 busy polling 등도 지원합니다. DMA가 폴링 전환 뒤에만 시작되는 단계 순서는 아닙니다.
이 적응형 방식으로 저부하에서는 응답성을, 고부하에서는 처리량을 최적화합니다.
부하가 바뀌면 하나의 방식만 고집하지 않고 폴링, 인터럽트, DMA를 섞습니다.
블로킹 I/O vs 논블로킹 I/O
블로킹 I/O: 결과를 즉시 제공할 수 없어 기다려야 하면 호출 스레드가 잠들 수 있습니다. 캐시 적중이나 준비된 데이터는 곧바로 반환할 수 있습니다.
대부분의 기본 read(), write() 호출이 이 방식입니다.
논블로킹(Non-blocking) I/O: I/O가 즉시 완료되지 않으면 에러(EAGAIN)를 반환합니다.
프로세스는 나중에 다시 시도합니다.
지원하는 파일 종류에서는 O_NONBLOCK으로 설정합니다. 일반 파일·블록 장치는 소켓처럼 이 플래그로 모든 대기를 피할 수 있는 대상이 아닙니다.
비동기(Asynchronous) I/O: I/O 요청을 제출하고 즉시 반환됩니다.
완료는 API에 따라 완료 큐·시그널·콜백 등으로 확인합니다.
Linux의 io_uring이 현대적 비동기 I/O 인터페이스입니다.
I/O 멀티플렉싱: select·poll·epoll로 여러 fd의 준비 상태를 감시합니다. 준비 통지는 작업 완료나 임의 크기 I/O의 성공을 보장하지 않아 실제 I/O 반환값도 확인합니다.
웹 서버가 수천 개의 클라이언트 연결을 하나의 스레드로 처리할 수 있는 비결입니다.
다음 절에서는 디스크의 물리적 구조와 디스크 스케줄링 알고리즘, 그리고 SSD의 등장이 가져온 변화를 살펴보겠습니다.