I/O 처리 방식
장치의 작업 완료를 알아내는 폴링과 인터럽트, 데이터를 옮기는 프로그램 I/O와 DMA를 서로 다른 두 질문으로 나누어 비용을 비교합니다. 인터럽트 처리의 top half와 bottom half, NAPI, DMA 버퍼와 캐시 동기화, 응용 프로그램 쪽의 블로킹·논블로킹·다중화·비동기 I/O도 예제로 살펴봅니다.
CPU가 장치에 일을 시킨 뒤에는 두 가지를 정해야 합니다. 장치가 일을 마쳤다는 사실을 어떻게 알아낼지, 그리고 장치와 메모리 사이의 데이터를 누가 옮길지입니다. 첫 질문에는 폴링과 인터럽트가, 둘째 질문에는 프로그램 I/O와 DMA가 답입니다. 두 질문은 서로 독립이어서, DMA로 옮기고 인터럽트로 알림을 받을 수도, DMA로 옮기고 폴링으로 확인할 수도 있습니다.
이 글에서는 네 방식의 비용을 비교하고, 응용 프로그램 쪽에서 I/O를 기다리는 방식인 블로킹, 논블로킹, 다중화, 비동기 I/O도 정리합니다.
폴링: 끝났는지 계속 묻기
폴링(polling)은 CPU가 장치의 상태 레지스터를 되풀이해 읽으며 일이 끝났는지 확인하는 방식입니다. 다음은 PC의 키보드 컨트롤러에서 키 입력 하나를 폴링으로 읽는 코드로, 커널 안이나 운영체제가 없는 환경을 가정합니다. 상태 포트 0x64의 0번 비트가 1이면 데이터 포트 0x60에 읽을 바이트가 있다는 뜻입니다.
#include <stdint.h>
#define KBD_STATUS 0x64 /* 상태 레지스터 포트 */
#define KBD_DATA 0x60 /* 데이터 레지스터 포트 */
#define OUTPUT_FULL 0x01 /* 0번 비트: 읽을 데이터가 있다. */
uint8_t inb(uint16_t port); /* 포트에서 1바이트 읽기(in 명령으로 구현) */
uint8_t read_key(void)
{
while ((inb(KBD_STATUS) & OUTPUT_FULL) == 0)
; /* 준비될 때까지 상태를 계속 읽는다(바쁜 대기). */
return inb(KBD_DATA); /* 준비되면 데이터를 읽는다. */
}이렇게 CPU가 다른 일을 하지 못하고 조건만 확인하며 기다리는 것을 바쁜 대기(busy waiting)라고 합니다. 구현이 단순하고, 장치가 끝나자마자 알아챌 수 있다는 장점이 있습니다. 하지만 사람이 키를 누를 때까지 수백 밀리초, 디스크가 읽기를 마칠 때까지 수 밀리초를 이렇게 기다리면, 그동안 CPU는 수백만에서 수억 사이클을 아무 일도 하지 못하고 흘려보냅니다.
인터럽트: 끝나면 알려 달라고 하기
인터럽트(interrupt) 방식에서는 CPU가 장치에 명령을 보낸 뒤 다른 일을 합니다. 장치는 일을 마치면 인터럽트 신호를 보내고, CPU는 하던 일을 멈추고 인터럽트 처리기(interrupt handler)를 실행합니다. 과정은 다음과 같습니다.
- CPU가 장치 컨트롤러에 명령을 보내고, 요청한 스레드는 잠들며 다른 스레드가 실행됩니다.
- 장치가 일을 마치고 인터럽트 요청(IRQ)을 인터럽트 컨트롤러(x86에서는 APIC)에 보냅니다.
- 인터럽트 컨트롤러가 CPU 가운데 하나에 인터럽트를 전달합니다.
- CPU는 실행 중이던 명령을 마치고 현재 상태를 저장한 뒤, 인터럽트 번호에 해당하는 처리기를 표(x86에서는 IDT)에서 찾아 실행합니다.
- 처리기는 장치의 상태를 확인하고 결과를 처리한 뒤, 잠들어 있던 스레드를 깨웁니다.
- 처리가 끝나면 원래 하던 일로 돌아가거나, 스케줄러가 깨어난 스레드를 실행합니다.
Linux 드라이버의 인터럽트 처리기는 대략 다음과 같은 모양입니다. mydev는 설명을 위한 가상의 장치입니다.
static irqreturn_t mydev_irq(int irq, void *dev_id)
{
struct mydev *dev = dev_id;
u32 status = readl(dev->regs + MYDEV_STATUS);
if (!(status & MYDEV_DONE))
return IRQ_NONE; /* 같은 IRQ 선을 쓰는 다른 장치의 인터럽트 */
writel(MYDEV_DONE, dev->regs + MYDEV_STATUS); /* 처리했다고 알려 장치가 신호를 내리게 한다. */
complete(&dev->done); /* 완료를 기다리던 스레드를 깨운다. */
return IRQ_HANDLED;
}
/* 드라이버를 초기화할 때 등록한다.
request_irq(irq, mydev_irq, IRQF_SHARED, "mydev", dev); */인터럽트 덕분에 CPU는 기다리는 동안 다른 일을 할 수 있지만, 공짜는 아닙니다. 처리기로 들어가고 나오는 비용, 캐시가 흐트러지는 비용, 잠든 스레드를 깨워 다시 스케줄하는 비용이 인터럽트마다 듭니다. 다음 코드는 폴링 간격과 인터럽트 비용을 가정하고, 장치가 일하는 시간에 따라 두 방식을 비교합니다.
import math
POLL_INTERVAL = 0.5 # 상태 레지스터를 다시 확인하기까지의 간격(µs)
IRQ_OVERHEAD = 5.0 # 인터럽트 한 번의 진입, 처리, 잠든 스레드를 깨우는 비용(µs)
def polling(device_time):
noticed = math.ceil(device_time / POLL_INTERVAL) * POLL_INTERVAL
return noticed, noticed # (CPU가 쓴 시간, 완료를 알아채기까지)
def interrupt(device_time):
return IRQ_OVERHEAD, device_time + IRQ_OVERHEAD
for device_time in (2, 20, 5000): # 아주 빠른 장치, 빠른 SSD, HDD 정도
(pc, pl), (ic, il) = polling(device_time), interrupt(device_time)
print(f"장치 {device_time}µs | 폴링: CPU {pc:g}µs, 지연 {pl:g}µs"
f" | 인터럽트: CPU {ic:g}µs, 지연 {il:g}µs")장치 2µs | 폴링: CPU 2µs, 지연 2µs | 인터럽트: CPU 5µs, 지연 7µs
장치 20µs | 폴링: CPU 20µs, 지연 20µs | 인터럽트: CPU 5µs, 지연 25µs
장치 5000µs | 폴링: CPU 5000µs, 지연 5000µs | 인터럽트: CPU 5µs, 지연 5005µs2µs 만에 끝나는 장치라면 폴링이 CPU도 덜 쓰고 완료도 더 빨리 알아챕니다. 장치가 20µs 걸리면 인터럽트가 CPU 시간을 15µs 아껴 주는 대신, 완료를 알아채는 것은 5µs 늦어집니다. HDD처럼 수 밀리초가 걸리는 장치를 폴링으로 기다리는 것은 낭비가 큽니다. 실제 값은 하드웨어에 따라 다르지만, 장치가 빠를수록 폴링이 유리해지는 경향은 같습니다. 그래서 느린 장치에는 인터럽트를 쓰고, 마이크로초 단위로 응답하는 NVMe SSD나 고속 네트워크에서는 폴링을 함께 쓰기도 합니다.
Linux에서 CPU마다 지금까지 받은 인터럽트의 수는 /proc/interrupts에서 볼 수 있습니다.
cat /proc/interrupts # IRQ 번호, CPU별 횟수, 인터럽트 컨트롤러, 장치 이름처리를 둘로 나누기
Linux에서 인터럽트 처리기는 그 CPU의 인터럽트를 끈 채로 실행되므로, 처리기가 길어지면 그 CPU에서 다른 인터럽트와 일이 모두 밀립니다. 또 처리기는 특정 프로세스를 위해 도는 것이 아니어서 잠들 수 없습니다. 그래서 Linux는 인터럽트 처리를 두 부분으로 나눕니다.
top half는 인터럽트를 받자마자 실행되는 부분으로, 장치의 상태를 읽고 처리했다고 알리는 것처럼 당장 해야 할 일만 하고 나머지는 나중으로 예약합니다. bottom half는 예약된 나머지 일로, 받은 데이터를 해석하고 프로토콜을 처리하는 것처럼 시간이 걸리는 일을 인터럽트를 받을 수 있는 상태에서 합니다. bottom half를 구현하는 방법으로는 softirq, tasklet, 워크큐(workqueue), 스레드 처리기(threaded IRQ handler)가 있고, 실행되는 문맥이 서로 다릅니다. 보통의 워크큐와 스레드 처리기는 커널 스레드에서 돌기 때문에 잠들 수도 있습니다.
인터럽트가 너무 많을 때
고속 네트워크 카드가 패킷마다 인터럽트를 보낸다면, 초당 수백만 번의 인터럽트를 처리하느라 CPU가 정작 패킷을 처리하지 못하는 상태에 빠질 수 있습니다. 인터럽트를 받는 데 시간을 다 써서 일이 진척되지 않는 이 상태를 수신 라이브락(receive livelock)이라고 합니다.
Linux 네트워크 드라이버는 이를 NAPI라는 방식으로 피합니다. 첫 패킷이 도착하면 인터럽트를 받되, 그 큐의 인터럽트를 끄고 폴링으로 바꾸어 쌓인 패킷을 한 번에 여러 개씩 처리합니다. 처리할 패킷이 바닥나면 인터럽트를 다시 켭니다. 부하가 낮을 때는 인터럽트로 빠르게 반응하고, 부하가 높을 때는 폴링으로 인터럽트 비용을 줄이는 셈입니다. 네트워크 카드도 패킷 몇 개나 몇 마이크로초 단위로 인터럽트를 모아 보내는 인터럽트 병합(interrupt coalescing)을 지원하며, 설정은 ethtool -c로 볼 수 있습니다. 자세한 동작은 NAPI 문서에 있습니다.
DMA: 데이터를 장치가 직접 옮기기
폴링이든 인터럽트든, CPU가 데이터 레지스터에서 한 워드씩 읽어 메모리에 옮긴다면 이를 프로그램 I/O(programmed I/O, PIO)라고 합니다. 키보드처럼 데이터가 적은 장치에는 충분하지만, 디스크에서 1MiB를 읽으면서 CPU가 4바이트씩 26만 번 넘게 옮겨야 한다면 인터럽트로 아낀 시간을 복사에 다시 쓰게 됩니다.
DMA(direct memory access)는 장치가 CPU를 거치지 않고 메모리와 데이터를 직접 주고받는 방식입니다. CPU는 메모리의 어느 주소에 몇 바이트를 어느 방향으로 옮길지 장치에 알려 준 뒤 다른 일을 하고, 장치는 전송을 마치면 인터럽트로 알립니다. 1MiB를 옮기는 데 CPU가 할 일이 설정 몇 번과 인터럽트 한 번으로 줄어듭니다. 예전에는 메인보드의 DMA 컨트롤러가 전송을 맡았지만, 오늘날 PCIe 장치는 대부분 스스로 메모리에 접근합니다. 장치가 메모리를 쓰는 동안 CPU의 메모리 접근이 잠시 밀리기도 하는데, 이를 사이클 스틸링(cycle stealing)이라고 부릅니다.
연속되지 않은 여러 메모리 조각을 한 번에 옮기는 분산·수집(scatter-gather) DMA도 있습니다. 드라이버가 (주소, 길이) 쌍의 목록을 메모리에 만들어 주면, 장치가 목록을 따라가며 조각들을 차례로 옮깁니다. 가상 메모리에서는 연속된 버퍼도 물리적으로는 흩어진 페이지일 수 있고, 네트워크 패킷의 헤더와 본문이 따로 놓이기도 하므로, 고속 장치는 거의 모두 이 기능을 씁니다.
DMA 중인 버퍼는 장치의 것
장치에서 메모리로 읽는 DMA의 역할
주소·길이·방향과 매핑 수명을 준비한다. 동기화 후 버퍼 내용을 사용한다. 각 단계의 값과 전환 이유를 비교합니다.
I/O 처리 · 전송과 완료
DMA로 전송한 버퍼는 완료 확인과 필요한 동기화 후 CPU가 사용합니다
장치 → 메모리로 읽는 전송입니다. CPU의 드라이버는 DMA 주소·길이·버퍼 수명을 설정하고, 장치가 데이터를 옮깁니다. 완료 확인 방식인 IRQ/폴링과 전송 주체인 DMA를 서로 다른 축으로 봅니다.
장치→메모리 전송의 개념 예시입니다. 드라이버는 장치가 사용할 DMA 주소와 버퍼 수명·동기화를 관리합니다. 완료는 IRQ 또는 폴링으로 확인하며 실제 알림 횟수는 설정에 따릅니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 CPU 설정부터 CPU 사용까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 장치에서 메모리로 읽는 DMA의 역할 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| CPU 설정 주소·길이·방향과 매핑 수명을 준비한다. |
|
|
| 장치 전송 DMA 엔진이 메모리 버퍼로 데이터를 옮긴다. |
| |
| 완료 확인 IRQ 또는 폴링으로 완료를 확인한다. |
|
|
| CPU 사용 동기화 후 버퍼 내용을 사용한다. |
|
|
장치에서 메모리로 읽는 DMA의 역할
주소·길이·방향과 매핑 수명을 준비한다. 동기화 후 버퍼 내용을 사용한다. 각 단계의 값과 전환 이유를 비교합니다.
I/O 처리 · 전송과 완료
DMA로 전송한 버퍼는 완료 확인과 필요한 동기화 후 CPU가 사용합니다
장치 → 메모리로 읽는 전송입니다. CPU의 드라이버는 DMA 주소·길이·버퍼 수명을 설정하고, 장치가 데이터를 옮깁니다. 완료 확인 방식인 IRQ/폴링과 전송 주체인 DMA를 서로 다른 축으로 봅니다.
장치→메모리 전송의 개념 예시입니다. 드라이버는 장치가 사용할 DMA 주소와 버퍼 수명·동기화를 관리합니다. 완료는 IRQ 또는 폴링으로 확인하며 실제 알림 횟수는 설정에 따릅니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 CPU 설정부터 CPU 사용까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 장치에서 메모리로 읽는 DMA의 역할 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| CPU 설정 주소·길이·방향과 매핑 수명을 준비한다. |
|
|
| 장치 전송 DMA 엔진이 메모리 버퍼로 데이터를 옮긴다. |
| |
| 완료 확인 IRQ 또는 폴링으로 완료를 확인한다. |
|
|
| CPU 사용 동기화 후 버퍼 내용을 사용한다. |
|
|
DMA를 쓰려면 지금 버퍼를 누가 쓰고 있는지 분명히 해야 합니다. 그림처럼 CPU가 버퍼를 준비해 장치에 넘긴 뒤부터 전송이 끝날 때까지 그 버퍼는 장치의 것이며, CPU는 완료를 확인하고 필요한 동기화를 한 뒤에야 내용을 쓸 수 있습니다. 장치가 쓰고 있는 버퍼를 해제하면, 장치는 이미 다른 용도로 쓰이는 메모리에 데이터를 써 넣게 됩니다.
동기화가 필요한 이유는 CPU 캐시에 있습니다. 장치는 메모리에 직접 쓰는데, CPU 캐시에 그 주소의 옛 내용이 남아 있으면 CPU는 캐시의 옛 값을 읽게 됩니다. 다음 코드는 이 상황을 단순하게 흉내 냅니다.
memory = {"buf": b"old data"} # 주기억장치
cache = {} # CPU 캐시
def cpu_read(addr):
if addr not in cache: # 캐시에 없으면 메모리에서 가져와 캐시에 둔다
cache[addr] = memory[addr]
return cache[addr]
def device_dma_write(addr, data):
memory[addr] = data # 장치는 CPU 캐시를 거치지 않고 메모리에 쓴다
print("DMA 전에 읽음:", cpu_read("buf"))
device_dma_write("buf", b"new data")
print("DMA 후 그냥 읽음:", cpu_read("buf")) # 캐시에 남은 옛 값
cache.pop("buf", None) # 캐시의 그 줄을 무효화한다
print("무효화한 뒤 읽음:", cpu_read("buf"))DMA 전에 읽음: b'old data'
DMA 후 그냥 읽음: b'old data'
무효화한 뒤 읽음: b'new data'DMA가 끝났는데도 캐시를 무효화하기 전까지는 옛 값이 보였습니다. x86처럼 하드웨어가 DMA와 캐시의 일관성을 맞춰 주는 시스템도 있지만, 많은 ARM 시스템에서는 소프트웨어가 캐시를 비우거나 무효화해야 합니다. Linux 드라이버는 dma_map_single(), dma_sync_single_for_cpu() 같은 DMA API로 버퍼를 장치에 넘기고 돌려받습니다. 이 함수들이 플랫폼에 맞게 캐시를 처리하고, IOMMU가 있다면 장치가 쓸 주소도 매핑해 줍니다. 버퍼를 넘기고 돌려받는 규칙은 DMA API 안내서에 정리되어 있습니다.
두 질문은 따로 정한다
| 질문 | 선택지 | 따져 볼 비용 |
|---|---|---|
| 완료를 어떻게 알까 | 폴링, 인터럽트 | 상태를 반복해 읽는 CPU 시간과, 인터럽트마다 드는 처리 비용과 지연 |
| 데이터를 누가 옮길까 | 프로그램 I/O, DMA | CPU의 복사 시간과, DMA 설정·매핑·캐시 동기화 비용 |
두 질문의 답은 자유롭게 조합됩니다. NVMe SSD는 DMA로 데이터를 옮기고 보통 인터럽트로 완료를 알리지만, Linux에서는 폴링으로 완료를 확인하도록 설정할 수도 있습니다. 네트워크 카드는 DMA로 패킷을 메모리에 쓰고, 앞에서 본 NAPI처럼 부하에 따라 인터럽트와 폴링을 오갑니다.
응용 프로그램이 I/O를 기다리는 방식
지금까지는 커널과 장치 사이의 이야기였습니다. 응용 프로그램이 커널에 I/O를 요청하고 기다리는 방식에도 몇 가지가 있습니다.
블로킹 I/O(blocking I/O)는 결과가 준비될 때까지 호출한 스레드를 잠재웁니다. read()와 write()의 기본 동작이 이것이며, 커널은 그동안 다른 스레드를 실행합니다. 데이터가 이미 페이지 캐시나 소켓 버퍼에 있다면 잠들지 않고 바로 돌아옵니다.
논블로킹 I/O(non-blocking I/O)는 결과를 바로 줄 수 없으면 기다리지 않고 EAGAIN 오류를 돌려줍니다. 파이프, 소켓, 터미널을 O_NONBLOCK으로 열거나 그렇게 바꾸면 이렇게 동작합니다.
import os
r, w = os.pipe()
os.set_blocking(r, False) # 읽는 끝을 논블로킹으로 바꾼다
try:
os.read(r, 100) # 아직 아무도 쓰지 않았다
except BlockingIOError as error:
print("데이터가 없을 때:", type(error).__name__)
os.write(w, b"ping")
print("쓴 뒤에 읽기:", os.read(r, 100))
os.close(r)
os.close(w)데이터가 없을 때: BlockingIOError
쓴 뒤에 읽기: b'ping'파이썬은 EAGAIN을 BlockingIOError 예외로 알려 줍니다. 다만 open(2)에 적힌 대로 일반 파일과 블록 장치에는 O_NONBLOCK이 효과가 없어서, 디스크를 읽어야 할 때는 잠깐씩 기다리게 됩니다.
I/O 다중화(I/O multiplexing)는 여러 fd를 한꺼번에 지켜보다가 읽거나 쓸 준비가 된 것만 알려 줍니다. select(), poll(), 그리고 감시할 fd가 많을 때 효율적인 Linux의 epoll이 이 일을 하며, 스레드 하나가 연결 수천 개를 다루는 웹 서버가 이 방식을 씁니다.
import selectors
import socket
sel = selectors.DefaultSelector()
pairs = {name: socket.socketpair() for name in ("A", "B", "C")}
for name, (reader, writer) in pairs.items():
reader.setblocking(False)
sel.register(reader, selectors.EVENT_READ, data=name)
pairs["B"][1].sendall(b"hello") # B 연결에만 데이터가 도착한다
for key, _ in sel.select(timeout=1): # 준비된 것만 돌려받는다
print(key.data, "준비됨:", key.fileobj.recv(100))
sel.close()
for reader, writer in pairs.values():
reader.close()
writer.close()B 준비됨: b'hello'연결 세 개를 감시했지만 데이터가 도착한 B만 준비되었다고 돌아왔습니다. 파이썬의 selectors 모듈은 운영체제에 맞는 방식을 골라 쓰며, Linux에서는 epoll을 씁니다. 준비되었다는 알림은 지금 읽으면 기다리지 않는다는 뜻일 뿐이므로, 실제로 읽은 양은 recv()의 반환값으로 확인해야 합니다.
비동기 I/O(asynchronous I/O)는 요청을 제출하고 바로 돌아온 뒤, 나중에 완료를 따로 확인합니다. Linux의 io_uring은 응용 프로그램과 커널이 함께 쓰는 제출 큐와 완료 큐를 두어, 요청을 여러 개 모아 제출하고 끝난 결과를 큐에서 꺼내 가게 합니다. 준비되었는지를 알려 주는 다중화와 달리, 일이 끝났다는 것을 알려 준다는 점이 다릅니다.
더 깊이: epoll의 레벨 트리거와 에지 트리거
epoll은 준비되었다는 알림을 두 방식으로 줄 수 있습니다. 기본인 레벨 트리거(level-triggered)에서는 읽을 데이터가 남아 있는 동안 epoll_wait()를 부를 때마다 그 fd가 다시 돌아오므로, epoll(7)의 표현대로 의미가 같은 더 빠른 poll()입니다. 반면 EPOLLET를 주어 등록한 에지 트리거(edge-triggered)에서는 감시하는 fd에 변화가 생길 때, 곧 새 데이터가 도착할 때만 알림이 옵니다.
이 차이 때문에 에지 트리거에는 함정이 있습니다. 매뉴얼의 예에서 파이프에 2KB가 들어와 알림을 받은 프로그램이 1KB만 읽고 다시 epoll_wait()를 부르면, 버퍼에 1KB가 남아 있는데도 새 데이터가 오기 전까지 기다리게 될 수 있습니다. 그동안 상대는 이미 보낸 데이터에 대한 응답을 기다리고 있을지도 모릅니다. 그래서 매뉴얼은 에지 트리거를 쓸 때 fd를 논블로킹으로 두고, read()나 write()가 EAGAIN을 돌려준 뒤에야 다음 알림을 기다리라고 권합니다.
그런데 fd 하나의 데이터를 끝까지 비우려다 보면, 데이터가 많을 때 다른 fd들이 처리되지 못하는 기아가 생길 수 있습니다. 매뉴얼은 준비된 fd의 목록을 따로 기억해 두고 그 목록을 돌아가며 처리하는 방법을 제안합니다. 또 여러 스레드가 같은 epoll fd에서 기다리는 중에 에지 트리거로 등록한 fd가 준비되면 그 가운데 한 스레드만 깨어나므로, 모든 스레드가 한꺼번에 깨어나는 일을 피할 수 있습니다.
정리
- 장치의 일이 끝났는지는 폴링이나 인터럽트로 알아내고, 데이터는 CPU가 옮기는 프로그램 I/O나 장치가 옮기는 DMA로 전달합니다. 두 질문은 서로 독립입니다.
- 폴링은 빠른 장치에서 지연과 비용이 작지만, 느린 장치를 기다리면 CPU를 낭비합니다. 인터럽트는 기다리는 동안 CPU를 풀어 주지만 인터럽트마다 처리 비용과 지연이 듭니다.
- Linux는 인터럽트 처리를 짧은 top half와 나중에 실행하는 bottom half로 나누고, 고속 네트워크에서는 NAPI로 인터럽트와 폴링을 오갑니다.
- DMA 중인 버퍼는 장치의 것이며, CPU는 완료를 확인하고 캐시를 동기화한 뒤에 버퍼를 써야 합니다. Linux 드라이버는 DMA API로 이 규칙을 지킵니다.
- 응용 프로그램은 블로킹 I/O,
EAGAIN을 받는 논블로킹 I/O, 준비된 fd를 알려 주는 다중화, 완료를 알려 주는 비동기 I/O 가운데에서 고릅니다.
연습 문제
1. 폴링으로 바꿀 만할까
본문 모델의 가정(폴링 간격 0.5µs, 인터럽트 비용 5µs)으로, 10µs 만에 응답하는 NVMe SSD와 5ms가 걸리는 HDD에서 폴링과 인터럽트를 비교하세요. 각각 폴링으로 바꿀 만한가요?
해설 보기
NVMe SSD에서 폴링은 CPU 10µs를 쓰고 10µs 만에 완료를 알아채며, 인터럽트는 CPU 5µs를 쓰고 15µs 만에 알아챕니다. CPU를 5µs 더 쓰는 대신 지연을 3분의 1 줄이므로, 지연이 중요한 작업이라면 폴링을 고려할 만합니다. HDD에서 폴링은 CPU 5,000µs를 써서 지연을 5,005µs에서 5,000µs로 0.1%쯤 줄일 뿐이므로, 그 CPU 시간에 다른 일을 하는 편이 훨씬 낫습니다. 장치가 빠를수록 인터럽트의 고정 비용이 차지하는 비중이 커져 폴링이 유리해집니다.
2. 인터럽트가 너무 많을 때
네트워크 카드가 초당 패킷 100만 개를 받고, 패킷마다 인터럽트를 보냅니다. 인터럽트 한 번에 5µs가 든다면 인터럽트를 받는 데만 CPU 코어 몇 개 분량의 시간이 드나요? NAPI가 인터럽트 한 번에 패킷 64개씩 처리한다면 어떻게 달라지나요?
해설 보기
100만 번 × 5µs = 초당 5초이므로, 인터럽트를 받는 데만 CPU 코어 다섯 개를 온전히 써야 합니다. 코어 하나에 인터럽트가 몰린다면 패킷을 처리할 시간이 남지 않는 수신 라이브락에 빠집니다. 패킷 64개를 한 번에 처리하면 인터럽트는 초당 15,625번으로 줄고, 그 비용은 초당 약 78ms, 곧 코어 하나의 8% 정도가 됩니다. 패킷 하나하나를 처리하는 비용은 그대로 남지만, 인터럽트 비용이 패킷 수가 아니라 묶음 수에 비례하게 바뀝니다.
3. DMA 버퍼를 너무 일찍 건드리면
드라이버가 장치에 DMA 읽기를 시작시킨 뒤, 완료 인터럽트를 받기 전에 실수로 버퍼를 해제했습니다. 또 다른 드라이버는 완료 인터럽트를 받은 뒤 dma_sync_single_for_cpu()를 부르지 않고 바로 버퍼를 읽었습니다. 각각 어떤 문제가 생길 수 있나요?
해설 보기
첫 번째 경우, 해제된 메모리는 곧 다른 용도로 할당될 수 있는데 장치는 그것을 모르고 전송을 계속하므로, 다른 코드의 데이터를 장치가 덮어쓰게 됩니다. 소프트웨어 버그와 달리 장치가 쓴 것이라 어디서 망가졌는지 찾기가 매우 어렵습니다. 두 번째 경우, 하드웨어가 캐시 일관성을 맞춰 주지 않는 시스템에서는 CPU가 캐시에 남은 옛 내용을 읽어 잘못된 데이터를 처리합니다. x86에서는 우연히 문제가 드러나지 않을 수도 있어 더 위험합니다. 두 경우 모두 버퍼를 장치에 넘긴 뒤 돌려받기 전까지는 CPU가 손대지 않는다는 소유권 규칙을 어긴 것입니다.
다음 글 디스크 스케줄링과 I/O 최적화에서는 HDD의 구조와 요청의 순서를 정하는 디스크 스케줄링 알고리즘, 그리고 SSD가 등장하면서 달라진 점을 살펴봅니다.