리눅스 내부 구조 07 - I/O와 디바이스
블록 디바이스와 캐릭터 디바이스
리눅스에서 디바이스는 크게 두 가지로 분류된다. 블록 디바이스는 데이터를 고정된 크기의 블록 단위로 읽고 쓰는 장치이다. 하드 디스크, SSD, USB 드라이브가 여기에 해당한다. 캐릭터 디바이스는 바이트 스트림으로 데이터를 순차적으로 처리하는 장치이다. 키보드, 마우스, 시리얼 포트, 터미널이 캐릭터 디바이스에 속한다.
이 구분이 왜 필요한가? 블록 디바이스는 임의 접근이 가능하다. 디스크의 어떤 위치든 원하는 블록을 직접 읽을 수 있다. 반면 캐릭터 디바이스는 순차적으로만 접근할 수 있으며, 이미 지나간 데이터를 되돌려 읽을 수 없다. 이 근본적인 차이 때문에 커널은 두 종류의 디바이스를 완전히 다른 경로로 처리하는 것이다. 블록 디바이스에는 I/O 스케줄러, 페이지 캐시, 버퍼 캐시 같은 복잡한 인프라가 필요하지만, 캐릭터 디바이스는 비교적 단순한 드라이버 인터페이스만으로 충분하다.
/dev 디렉터리에서 ls -l을 실행하면 각 디바이스 파일의 첫 글자가 b(블록) 또는 c(캐릭터)로 표시되는 것을 확인할 수 있다.
$ ls -l /dev/sda /dev/tty0
brw-rw---- 1 root disk 8, 0 Feb 12 10:00 /dev/sda
crw--w---- 1 root tty 4, 0 Feb 12 10:00 /dev/tty0
메이저 번호와 마이너 번호
위 출력에서 파일 크기 자리에 표시된 8, 0이나 4, 0 같은 숫자 쌍이 메이저 번호와 마이너 번호이다. 메이저 번호는 해당 디바이스를 담당하는 드라이버를 식별하고, 마이너 번호는 같은 드라이버가 관리하는 여러 디바이스 중 특정 디바이스를 식별한다.
예를 들어, 메이저 번호 8은 SCSI 디스크 드라이버를 나타낸다. /dev/sda의 마이너 번호 0은 첫 번째 디스크 전체를, /dev/sda1의 마이너 번호 1은 첫 번째 파티션을 가리킨다. 커널은 프로세스가 디바이스 파일에 접근할 때 이 번호 쌍을 사용하여 적절한 드라이버로 요청을 전달하는 것이다.
과연 이러한 번호 기반 체계가 현대 시스템에서도 적절한가? 사실 디바이스 수가 폭발적으로 증가한 현대 환경에서는 정적인 번호 할당에 한계가 있다. 이 문제를 해결하기 위해 리눅스는 동적 메이저 번호 할당과 udev라는 디바이스 관리 시스템을 도입했는데, 이에 대해서는 뒤에서 다루겠다.
I/O 스케줄러
블록 디바이스, 특히 회전하는 플래터를 가진 HDD의 경우 디스크 헤드의 물리적 이동이 성능의 병목이 된다. I/O 스케줄러는 여러 프로세스가 발생시킨 I/O 요청의 순서를 재배치하여 디스크 헤드의 이동 거리를 최소화하는 역할을 한다.
리눅스에서 사용되어 온 주요 I/O 스케줄러를 살펴보면 각각의 설계 철학을 이해할 수 있다.
noop(현재의 none) 스케줄러는 이름 그대로 아무런 재배치를 하지 않는다. 요청이 들어온 순서대로 디바이스에 전달한다. SSD처럼 탐색 시간이 없는 장치에서는 재배치가 오히려 불필요한 오버헤드이므로 noop이 적합한 것이다.
CFQ(Completely Fair Queuing)는 프로세스별로 별도의 큐를 유지하며 각 프로세스에 공정한 I/O 대역폭을 할당한다. 데스크탑 환경에서 여러 프로그램이 동시에 디스크에 접근할 때 특정 프로그램이 I/O를 독점하지 못하도록 하는 데 효과적이었다.
deadline 스케줄러는 각 I/O 요청에 만료 시간을 부여한다. 읽기 요청은 500ms, 쓰기 요청은 5초라는 기본 기한이 있으며, 기한이 임박한 요청을 우선 처리한다. 이 방식은 I/O 재배치로 인한 기아 현상을 방지하면서도 합리적인 수준의 재배치를 수행한다.
BFQ(Budget Fair Queuing)는 CFQ의 후속으로, 각 프로세스에 I/O 예산을 할당하고 이 예산 내에서 공정하게 디스크를 사용하도록 한다. 저속 디바이스나 대화형 워크로드에서 특히 좋은 응답성을 보인다.
$ cat /sys/block/sda/queue/scheduler
[mq-deadline] kyber bfq none
최신 리눅스 커널은 멀티큐 블록 레이어(blk-mq)를 사용하며, 위 출력에서 보이는 mq-deadline, kyber, bfq, none 중에서 선택할 수 있다. NVMe SSD의 등장으로 스토리지 디바이스 자체가 여러 하드웨어 큐를 가지게 되었고, 기존의 단일 큐 스케줄러로는 이 병렬성을 활용할 수 없었기 때문에 블록 레이어 전체가 재설계된 것이다.
DMA: CPU를 거치지 않는 데이터 전송
대용량 데이터를 디스크에서 메모리로 옮길 때 CPU가 바이트 하나하나를 직접 복사한다면 어떻게 될까? CPU는 데이터 전송이 완료될 때까지 다른 작업을 할 수 없게 된다. DMA(Direct Memory Access)는 이 문제를 해결하는 하드웨어 메커니즘이다.
DMA를 사용하면 CPU는 DMA 컨트롤러에 "어디서 어디로 얼마만큼의 데이터를 옮겨라"라는 명령만 내리고, 실제 데이터 전송은 DMA 컨트롤러가 수행한다. 전송이 완료되면 DMA 컨트롤러가 인터럽트를 발생시켜 CPU에 통보한다. 그 동안 CPU는 다른 프로세스를 실행하거나 다른 계산을 수행할 수 있는 것이다.
DMA 없이 (PIO 모드) DMA 사용
┌─────┐ ┌──────┐ ┌─────┐ ┌──────┐
│ CPU │◄──►│디스크│ │ CPU │ │디스크│
│ │ │ │ │ │ │ │
│바이트│ │ │ │명령만│ │ │
│복사 │ │ │ │전달 │ │ │
└─────┘ └──────┘ └──┬──┘ └──┬───┘
│ │
▼ ▼
┌────────────────┐
│ DMA 컨트롤러 │
│ (직접 전송) │
└───────┬────────┘
│
▼
┌──────┐
│메모리│
└──────┘
현대의 거의 모든 블록 디바이스 드라이버는 DMA를 사용한다. 네트워크 카드에서도 DMA는 필수적인데, 고속 네트워크에서 패킷 하나하나를 CPU가 복사하는 것은 불가능에 가깝기 때문이다.
버퍼드 I/O와 다이렉트 I/O
프로세스가 read 시스템 콜로 파일을 읽으면 데이터가 곧바로 디스크에서 사용자 버퍼로 복사되는 것일까? 일반적으로는 그렇지 않다. 리눅스는 기본적으로 버퍼드 I/O를 사용하며, 디스크에서 읽은 데이터는 먼저 커널의 페이지 캐시에 저장된 후 사용자 버퍼로 복사된다.
이 추가적인 복사가 왜 유리한가? 같은 데이터를 여러 프로세스가 읽거나, 같은 프로세스가 반복적으로 읽는 경우 디스크 접근 없이 페이지 캐시에서 곧바로 제공할 수 있기 때문이다. 대부분의 워크로드에서 이 캐싱 효과는 추가 복사 비용을 크게 상회한다.
하지만 데이터베이스 같은 응용 프로그램은 자체적인 캐싱 전략을 가지고 있어 커널의 페이지 캐시가 오히려 불필요한 메모리 소비와 추가 복사를 유발한다. 이런 경우에 O_DIRECT 플래그를 사용하여 다이렉트 I/O를 수행할 수 있다. 다이렉트 I/O는 페이지 캐시를 우회하여 디스크와 사용자 버퍼 사이에서 직접 데이터를 전송하는 것이다.
// 버퍼드 I/O (기본)
int fd = open("/data/file", O_RDONLY);
// 다이렉트 I/O
int fd = open("/data/file", O_RDONLY | O_DIRECT);
다이렉트 I/O를 사용하려면 사용자 버퍼가 메모리 정렬 요구사항을 만족해야 하며, 읽기/쓰기 크기도 블록 크기의 배수여야 한다. 이러한 제약이 있음에도 데이터베이스 시스템들이 다이렉트 I/O를 선호하는 것은 자신의 캐시 관리가 범용 페이지 캐시보다 해당 워크로드에 더 최적화되어 있기 때문인 것이다.
페이지 캐시
페이지 캐시는 리눅스 I/O 성능의 핵심이다. 커널은 사용 가능한 메모리의 대부분을 페이지 캐시로 활용하여 디스크 접근을 최소화한다. free 명령의 출력에서 buff/cache로 표시되는 영역이 바로 이것이다.
$ free -h
total used free shared buff/cache available
Mem: 16Gi 4.2Gi 1.8Gi 256Mi 10Gi 11Gi
Swap: 4.0Gi 0B 4.0Gi
위 출력에서 16GB 메모리 중 10GB가 buff/cache로 사용되고 있지만, available은 11GB이다. 이는 페이지 캐시가 언제든지 해제될 수 있는 메모리이기 때문이다. 프로세스가 더 많은 메모리를 필요로 하면 커널은 페이지 캐시를 축소하여 메모리를 확보한다. 따라서 페이지 캐시가 많다는 것은 메모리가 부족하다는 것이 아니라, 사용 가능한 메모리가 효율적으로 캐싱에 활용되고 있다는 의미인 것이다.
페이지 캐시의 동작은 쓰기에서도 중요하다. write 시스템 콜로 데이터를 쓰면 데이터는 즉시 디스크에 기록되지 않고 페이지 캐시의 해당 페이지가 dirty로 표시된다. 커널의 writeback 스레드(pdflush 또는 현재의 bdi flusher)가 일정 시간이 지나거나 dirty 페이지 비율이 임계치를 초과하면 비동기적으로 디스크에 기록한다. fsync 시스템 콜을 호출하면 특정 파일의 dirty 페이지를 즉시 디스크에 쓰도록 강제할 수 있다.
폴링과 인터럽트
디바이스가 작업을 완료했는지 확인하는 방법은 두 가지이다. 폴링은 CPU가 주기적으로 디바이스의 상태 레지스터를 확인하는 방식이다. 인터럽트는 디바이스가 작업 완료 시 CPU에 신호를 보내는 방식이다.
과연 인터럽트가 항상 폴링보다 우수한가? 대부분의 경우 그렇다. 폴링은 디바이스가 준비될 때까지 CPU 사이클을 낭비하기 때문이다. 그러나 초고속 디바이스에서는 상황이 역전된다. NVMe SSD나 고속 네트워크 카드처럼 I/O 완료가 매우 빈번한 환경에서는 인터럽트의 처리 비용이 오히려 병목이 된다. 인터럽트마다 컨텍스트 스위칭이 발생하고, 인터럽트 핸들러가 실행되며, 캐시가 오염되기 때문이다.
이런 이유로 최신 고성능 드라이버에서는 하이브리드 방식을 사용한다. 평소에는 인터럽트 모드로 동작하다가 I/O가 폭증하면 폴링 모드로 전환하여 인터럽트 오버헤드를 제거하는 것이다. 리눅스 네트워크 스택의 NAPI(New API)가 이 하이브리드 접근법의 대표적인 구현이다.
udev와 디바이스 관리
과거 리눅스에서는 /dev 디렉터리의 디바이스 파일을 관리자가 수동으로 생성해야 했다. 수백 개의 디바이스 파일이 실제 하드웨어 유무와 관계없이 미리 만들어져 있었고, 새로운 하드웨어가 추가되면 mknod 명령으로 직접 디바이스 파일을 생성해야 했다.
udev는 이 문제를 근본적으로 해결한다. 커널이 하드웨어를 감지하면 uevent를 발생시키고, udev 데몬이 이 이벤트를 수신하여 규칙에 따라 디바이스 파일을 자동으로 생성하거나 삭제한다. USB 드라이브를 꽂으면 /dev/sdb가 자동으로 나타나고, 뽑으면 사라지는 것이 udev 덕분인 것이다.
udev의 규칙 파일은 /etc/udev/rules.d/ 디렉터리에 위치하며, 디바이스의 속성에 따라 이름, 권한, 소유자를 지정하거나 디바이스 연결 시 실행할 스크립트를 설정할 수 있다.
# /etc/udev/rules.d/99-usb-storage.rules
# 특정 USB 장치에 고정된 이름을 부여하는 규칙 예시
SUBSYSTEM=="block", ATTRS{idVendor}=="0781", ATTRS{idProduct}=="5567", SYMLINK+="myusb"
이 규칙은 특정 벤더/제품 ID를 가진 USB 장치가 연결되면 /dev/myusb라는 심볼릭 링크를 자동으로 생성한다. 디바이스가 연결될 때마다 /dev/sdb, /dev/sdc처럼 이름이 바뀌는 문제를 해결할 수 있는 것이다.
udev는 단순한 디바이스 파일 관리 이상의 역할을 한다. 커널의 장치 모델과 /sys 파일 시스템을 연결하는 다리 역할을 하며, 하드웨어 이벤트에 반응하는 자동화의 기반이 되는 것이다. systemd의 등장 이후 udev는 systemd-udevd로 통합되어 시스템 관리의 일부가 되었다.
다음 포스트에서는 네트워킹 스택의 내부 구조를 살펴본다.