본문 바로가기

인터럽트와 I/O

장치가 CPU에 일이 생겼음을 알리는 인터럽트와 프로그램이 일으키는 트랩, 예외의 차이를 구분하고, 인터럽트가 처리되는 과정을 따라갑니다. 폴링, 인터럽트, DMA가 각각 무엇을 정하는 방식인지와, 타이머 인터럽트가 선점형 멀티태스킹을 가능하게 하는 원리를 살펴봅니다.

프로그램이 실행되는 중에 키보드의 키를 누르면, CPU는 그 사실을 어떻게 알까요? 가장 단순한 방법은 CPU가 틈날 때마다 "키보드에 입력이 왔나? 디스크 읽기는 끝났나? 네트워크 패킷은 도착했나?" 하고 모든 장치를 돌아가며 확인하는 것입니다. 하지만 장치는 수십 개이고 대부분은 대부분의 시간에 아무 일도 없으니, 이렇게 확인하는 데 드는 CPU 시간은 거의 낭비가 됩니다.

그래서 컴퓨터는 반대 방향의 방법을 씁니다. 장치가 할 일이 생겼을 때 CPU에 신호를 보내 알리는 것입니다. 이 신호를 인터럽트(interrupt)라고 합니다. 인터럽트는 운영체제가 CPU를 관리할 수 있게 해 주는 바탕입니다. 디스크 읽기를 기다리던 프로세스가 깨어나는 것도, 무한 루프에 빠진 프로그램에게서 운영체제가 CPU를 되찾는 것도 인터럽트 덕분입니다. 이 글에서는 인터럽트의 종류와 처리 과정, 장치와 데이터를 주고받는 방식, 그리고 타이머 인터럽트가 멀티태스킹에서 하는 역할을 차례로 살펴봅니다.

하던 일을 멈추게 하는 신호

인터럽트는 CPU에게 "지금 하던 일을 잠시 멈추고 이것부터 처리하라"고 알리는 신호입니다. 요리사가 스테이크를 굽다가 주방 벨이 울리면, 굽던 정도를 기억해 두고 벨에 대응한 뒤 다시 굽던 자리로 돌아옵니다. CPU도 같습니다. 인터럽트가 오면 지금 실행하던 프로그램의 상태를 저장하고, 인터럽트를 처리하는 코드를 실행한 뒤, 저장한 상태를 되살려 하던 일을 이어 갑니다. 다만 요리사는 벨이 울린 것을 알지만, 프로그램은 인터럽트가 있었다는 사실을 알지 못한다는 점이 비유와 다릅니다.

이렇게 프로그램이 모르게 처리된다는 성질이 인터럽트 설계의 핵심입니다. 실행 중이던 프로그램은 자기 코드가 처음부터 끝까지 끊김 없이 실행된 것처럼 동작해야 합니다. 그래서 인터럽트를 처리하는 쪽은 프로그램이 쓰던 레지스터를 빠짐없이 저장했다가 되살려야 합니다.

장치의 인터럽트는 프로그램의 실행과 상관없는 시점에 일어난다는 뜻에서 비동기적(asynchronous)입니다. 다만 신호가 왔다고 그 즉시 처리되는 것은 아닙니다. CPU가 인터럽트를 잠시 막아 둔 상태이거나 더 급한 인터럽트를 처리하는 중이라면, 처리는 그 뒤로 미루어집니다.

커널에 들어오는 세 가지 길

커널 모드로 실행이 넘어가는 원인은 크게 세 가지입니다. 무엇이 원인인지, 그리고 지금 실행 중인 명령어와 관계가 있는지에 따라 구분합니다.

종류원인지금 명령어와의 관계예
하드웨어 인터럽트장치나 타이머상관없이 아무 때나 온다키 입력, 디스크 읽기 완료, 패킷 도착, 타이머 만료
트랩(trap)프로그램이 일부러 실행한 명령어그 명령어를 실행한 바로 그때시스템 콜, 디버거의 중단점
예외(exception)명령어를 실행하다 생긴 오류나 특수 상황그 명령어를 실행한 바로 그때0으로 나누기, 페이지 폴트, 잘못된 명령어

트랩과 예외는 같은 프로그램을 같은 입력으로 실행하면 같은 자리에서 일어나므로 동기적(synchronous)이라고 합니다. 용어는 책과 아키텍처마다 조금씩 다르게 쓰이며, 트랩과 예외를 묶어 넓은 뜻의 예외로 부르기도 합니다.

장치가 보내는 하드웨어 인터럽트

장치는 일이 생기면 인터럽트를 보냅니다. 키를 누르면 키보드 컨트롤러가 인터럽트를 보내고, 커널의 드라이버가 어떤 키인지 읽어 해당 프로그램에 전달합니다. 디스크 읽기가 끝나면 디스크 컨트롤러가 인터럽트를 보내고, 커널은 그 데이터를 기다리던 프로세스를 깨웁니다. 네트워크 카드는 패킷이 도착했음을 알리고, 하드웨어 타이머는 정해진 시간이 지났음을 알립니다. 예전에는 장치가 전용 신호선으로 인터럽트를 보냈지만, PCIe 장치는 메모리에 쓰는 형태의 메시지로 인터럽트를 보내는 MSI(message signaled interrupt)를 주로 씁니다.

하드웨어 인터럽트는 막을 수 있는 것(maskable)과 막을 수 없는 것(non-maskable interrupt, NMI)으로 나뉩니다. 대부분의 장치 인터럽트는 막을 수 있어서, 커널은 짧은 시간 동안 인터럽트를 꺼 두고 중요한 작업을 끝낼 수 있습니다. 그런데 인터럽트를 끄는 것은 그 CPU 한 곳에만 적용됩니다. 다른 CPU 코어가 같은 자료구조에 동시에 접근하는 것까지 막으려면 잠금이 따로 필요하며, 이 내용은 Linux 커널의 잠금 안내서에 정리되어 있습니다. NMI는 꺼 둘 수 없으며, 심각한 하드웨어 오류를 알리거나 시스템이 멈추었는지 감시하는 용도로 쓰입니다.

프로그램이 일부러 일으키는 트랩

트랩은 프로그램이 커널에 들어가려고 일부러 실행하는 명령어 때문에 일어납니다. 대표적인 예가 앞 장에서 본 시스템 콜입니다. 옛 32비트 Linux는 int 0x80이라는 소프트웨어 인터럽트 명령으로 시스템 콜을 불렀기 때문에, 시스템 콜을 소프트웨어 인터럽트라고 부르기도 합니다. x86-64의 syscall 명령은 이보다 빠른 전용 진입 명령이어서 인터럽트 처리 경로를 그대로 거치지는 않지만, 프로그램이 원하는 시점에 커널로 들어간다는 성질은 같습니다.

명령어를 실행하다 생기는 예외

예외는 CPU가 명령어를 실행하다가 그대로 진행할 수 없는 상황을 만났을 때 일어납니다. 0으로 나누면 CPU가 예외를 일으키고, Linux 커널은 그 프로세스에 SIGFPE 신호를 보냅니다. 이 신호의 기본 동작은 프로세스를 끝내는 것입니다. 허락되지 않은 주소에 접근하면 SIGSEGV가, CPU가 해석할 수 없는 명령어를 실행하려 하면 SIGILL이 전달됩니다.

모든 예외가 오류인 것은 아닙니다. 페이지 폴트(page fault)는 접근하려는 페이지가 지금 물리 메모리에 매핑되어 있지 않을 때 일어나는데, 대부분은 정상적인 상황입니다. 처음 쓰는 메모리에 페이지를 배정하거나, 디스크에 있던 페이지를 읽어 오거나, 공유하던 페이지를 복사해 나누는 일을 커널이 처리한 뒤 프로그램은 같은 명령어부터 다시 실행합니다. 정말로 잘못된 접근일 때만 SIGSEGV로 이어집니다. 이 과정은 9장에서 자세히 다룹니다.

인터럽트가 처리되는 과정

x86-64 컴퓨터에서 디스크 컨트롤러가 읽기 완료를 알리는 인터럽트를 보냈다고 합시다. 그 순간 CPU는 사용자 모드에서 어떤 프로그램을 실행하고 있었습니다. 처리 과정을 차례로 따라가 보겠습니다.

  1. 장치가 인터럽트를 보내면 인터럽트 컨트롤러(x86에서는 APIC)가 이를 받아 CPU에 인터럽트 번호와 함께 전달합니다. 이 번호를 벡터(vector)라고 합니다.
  2. CPU는 실행 중이던 명령어를 마친 뒤, 다음 명령어로 넘어가기 전에 인터럽트를 받아들입니다. 인터럽트가 꺼져 있으면 켜질 때까지 기다립니다.
  3. CPU는 인터럽트 서술자 테이블(interrupt descriptor table, IDT)에서 벡터 번호에 해당하는 항목을 찾아 처리 코드의 주소를 얻습니다. 이 테이블은 커널이 부팅할 때 미리 채워 둡니다.
  4. 사용자 모드에서 실행 중이었으므로 CPU는 커널 스택으로 바꾸고, 돌아올 주소와 플래그 레지스터, 원래의 스택 위치를 커널 스택에 저장한 뒤 커널 모드로 처리 코드에 들어갑니다.
  5. 커널의 진입 코드가 나머지 레지스터를 저장하고, 해당 장치의 인터럽트 서비스 루틴(interrupt service routine, ISR)을 실행합니다. ISR은 장치의 상태를 읽어 무슨 일이 일어났는지 확인하고, 데이터를 기다리던 프로세스를 깨우는 등 필요한 일을 합니다.
  6. 처리가 끝나면 커널은 사용자 모드로 돌아가기 전에 스케줄링이 필요한지 확인합니다. 방금 깨운 프로세스가 더 먼저 실행되어야 한다면, 원래 프로그램으로 돌아가기 전에 다른 프로세스로 전환할 수 있습니다.
  7. 원래 프로그램으로 돌아갈 차례가 되면 저장한 레지스터를 되살리고, iret 명령으로 저장해 둔 주소에서 실행을 이어 갑니다.

실제 하드웨어의 세부는 더 복잡합니다. 문자열을 반복해 복사하는 명령처럼 오래 걸리는 명령어는 중간에 인터럽트를 받고 나중에 이어서 실행하기도 하고, 예외의 종류에 따라 돌아갈 위치가 그 명령어인지 다음 명령어인지도 다릅니다. 이미 커널 모드에서 실행하던 중에 인터럽트가 왔다면 스택을 바꾸지 않을 수도 있습니다. 옛 실제 모드(real mode)의 x86은 IDT 대신 인터럽트 벡터 테이블(interrupt vector table, IVT)이라는 더 단순한 표를 썼으므로, 두 이름이 섞여 쓰이기도 합니다.

인터럽트 처리는 짧게: 상반부와 하반부

인터럽트 처리의 원칙은 빨리 끝내는 것입니다. ISR이 실행되는 동안에는 같은 인터럽트가 막혀 있을 수 있고, 그 CPU에서는 다른 프로세스도 실행되지 못합니다. ISR에서 네트워크 패킷을 해석하는 것처럼 오래 걸리는 일을 하면, 다른 인터럽트에 대한 반응이 늦어지고 시스템 전체의 응답성이 떨어집니다.

그래서 Linux는 인터럽트 처리를 두 부분으로 나눕니다. 상반부(top half)는 ISR에서 바로 실행되며, 장치에 인터럽트를 받았다고 알리고 급한 데이터를 챙기는 최소한의 일만 한 뒤, 나머지 처리를 예약해 두고 빠져나옵니다. 하반부(bottom half)는 예약된 나머지 일을 나중에 처리합니다. 하반부에는 여러 방식이 있습니다. softirq와 tasklet은 인터럽트 처리에 가까운 문맥에서 실행되므로 잠들 수 없습니다. 반면 워크큐(workqueue)의 일반적인 작업은 커널 스레드에서 실행되므로 잠들 수 있습니다. 다만 Linux 6.9에서 추가된 WQ_BH 워크큐는 softirq 문맥에서 실행되므로, 이름만 보고 잠들 수 있다고 판단해서는 안 됩니다. 자세한 규칙은 Linux 워크큐 문서에 있습니다.

네트워크 카드가 좋은 예입니다. 패킷이 많이 몰려올 때 패킷마다 인터럽트를 받으면 CPU가 인터럽트 처리에만 매달리게 됩니다. 그래서 Linux의 NAPI는 첫 인터럽트를 받으면 그 장치의 인터럽트를 잠시 끄고, 처리를 예약한 뒤 쌓인 패킷을 폴링으로 한꺼번에 가져옵니다. 패킷이 줄어들면 다시 인터럽트를 켭니다. 인터럽트와 폴링을 상황에 따라 바꾸어 쓰는 셈이며, 자세한 동작은 NAPI 문서에 정리되어 있습니다.

더 깊이: 신호선 대신 메모리 쓰기로 보내는 인터럽트

옛 PCI 장치는 인터럽트 전용 신호선(핀)의 상태를 바꾸어 인터럽트를 알렸습니다. PCI 2.2 규격에는 메시지 신호 인터럽트(message signaled interrupt, MSI)가 생겼는데, 장치가 미리 정해진 특별한 주소에 값을 한 번 쓰면 그 쓰기가 CPU에 인터럽트로 전달됩니다. PCI 3.0에서 추가된 MSI-X는 이를 넓힌 방식으로, 장치 하나가 1개부터 2048개까지의 인터럽트를 쓸 수 있습니다. MSI는 많아야 32개이고, 그 수도 2의 거듭제곱이어야 합니다.

Linux의 MSI 안내서는 이 방식이 나은 이유를 세 가지로 듭니다. 첫째, 핀 방식의 인터럽트는 여러 장치가 한 선을 함께 쓰는 경우가 많아서, 인터럽트가 오면 커널이 그 선에 걸린 처리 코드를 모두 불러 보아야 합니다. MSI는 다른 장치와 나누어 쓰지 않으므로 이런 비용이 없습니다. 둘째, 장치가 메모리에 데이터를 쓴 뒤 핀으로 인터럽트를 올리면, 데이터가 메모리에 다 닿기 전에 인터럽트가 먼저 도착할 수 있습니다. 그래서 핀 방식의 드라이버는 처리 코드에서 장치의 레지스터를 한 번 읽어 앞선 쓰기가 끝나기를 기다려야 하지만, MSI에서는 인터럽트를 일으키는 쓰기가 앞선 데이터 쓰기를 앞지를 수 없으므로 그럴 필요가 없습니다.

셋째 이유는 인터럽트의 개수입니다. 핀 방식에서는 PCI 기능(function) 하나에 인터럽트가 하나뿐이라, 드라이버가 장치에 무슨 일이 일어났는지 매번 따로 물어보아야 합니다. 인터럽트를 여러 개 쓸 수 있으면 오류처럼 드문 사건에 별도의 인터럽트를 주거나, 네트워크 카드의 수신 큐마다 인터럽트를 하나씩 둘 수 있습니다. 큐마다 둔 MSI-X 인터럽트는 각각 특정 CPU로 보낼 수 있어서, 여러 코어가 한 카드로 들어오는 패킷을 나누어 처리할 수 있습니다.

장치와 데이터를 주고받는 방식

장치의 입출력을 이해할 때는 두 가지 질문을 따로 해야 합니다. 첫째, 장치가 일을 끝냈다는 것을 CPU가 어떻게 알아내는가. 둘째, 데이터를 장치와 메모리 사이에서 누가 옮기는가. 첫째 질문의 답은 폴링이나 인터럽트이고, 둘째 질문의 답은 CPU가 직접 옮기는 방식(programmed I/O, PIO)이나 DMA입니다.

I/O · 서로 다른 두 선택

DMA 전송도 폴링이나 인터럽트로 완료를 확인합니다

완료 확인 방식과 데이터 이동 방식을 따로 선택합니다. 폴링 → 인터럽트 → DMA라는 배타적인 발전 순서가 아닙니다.

데이터 이동과 완료 확인의 독립 선택한 I/O 요청에서 데이터 이동 방식은 PIO 또는 DMA이고 완료 확인 방식은 폴링 또는 인터럽트입니다. 화살표는 선택할 수 있는 항목을 분류한 관계이며 시간 순서나 데이터 전송 경로가 아닙니다. 두 영역에서 각각 하나를 선택하므로 DMA와 폴링도 함께 사용할 수 있습니다.같은 I/O 요청두 선택을 따로 구분데이터 이동누가 옮기는가완료 확인어떻게 알아내는가PIOCPU 명령으로데이터 이동DMA준비된 버퍼를하드웨어가 이동폴링CPU가 상태를조회해 확인인터럽트장치의 통지를처리해 확인
데이터 이동과 완료 확인의 독립 선택한 I/O 요청에서 데이터 이동 방식은 PIO 또는 DMA이고 완료 확인 방식은 폴링 또는 인터럽트입니다. 화살표는 선택할 수 있는 항목을 분류한 관계이며 시간 순서나 데이터 전송 경로가 아닙니다. 두 영역에서 각각 하나를 선택하므로 DMA와 폴링도 함께 사용할 수 있습니다.같은 I/O 요청두 선택을 따로 구분데이터 이동누가 옮기는가PIOCPU 명령으로데이터 이동DMA준비된 버퍼 사이를하드웨어가 이동완료 확인어떻게 알아내는가폴링CPU가 상태를조회해 확인인터럽트장치의 통지를처리해 확인

분류 화살표는 선택 관계이며 실행 순서가 아닙니다. 각 질문에서 고른 방식의 조합이 실제 I/O 경로를 구성합니다.

질문 1 · 완료를 어떻게 확인하는가

폴링: CPU가 상태를 조회합니다.

인터럽트: 장치의 알림을 처리합니다.

대기 시간과 부하에 따라 비용이 달라집니다.

질문 2 · 데이터를 누가 옮기는가

PIO: CPU 명령으로 옮깁니다.

DMA: 설정된 장치·버퍼 사이를 하드웨어가 옮깁니다.

CPU의 준비·동기화·완료 처리 책임은 남습니다.

조합 예 · DMA + 폴링 / DMA + 인터럽트

DMA로 옮긴 데이터를 폴링으로 회수하거나 인터럽트 통지 후 처리할 수 있습니다.

DMA 자체가 전송당 인터럽트 한 번을 보장하지 않습니다. 장치·큐·버퍼·인터럽트 병합 설정에 따라 통지 횟수가 달라집니다.

그림처럼 두 질문에서 각각 하나씩 골라 조합합니다. 흔히 "폴링에서 인터럽트로, 인터럽트에서 DMA로 발전했다"고 설명하지만, 이렇게 보면 DMA를 쓰면서 완료는 폴링으로 확인하는 조합을 이해할 수 없습니다. 아래에서는 각 방식을 의사 코드로 보고, 어떤 조건에서 유리한지 비교합니다. 장치 컨트롤러의 레지스터와 DMA를 하드웨어 쪽에서 본 설명은 컴퓨터구조 교재의 I/O와 DMA에 있습니다.

상태를 계속 확인하는 폴링

폴링(polling)은 CPU가 장치의 상태 레지스터를 반복해서 읽으며 일이 끝났는지 확인하는 방식입니다. 다음은 개념을 보여 주기 위한 의사 코드입니다.

폴링 (의사 코드)
send_command_to_disk(READ, sector, buffer);

while (read_status_register() != READY) {
    /* 끝날 때까지 상태만 계속 확인한다: 바쁜 대기(busy waiting) */
}

copy_from_controller(buffer, size);

디스크 읽기가 5ms 걸린다면, 3GHz CPU는 그동안 3 × 10⁹ × 0.005 = 15,000,000클록을 상태 확인에만 씁니다. 루프 한 바퀴가 1클록이라는 뜻은 아니고, 그 시간 동안 CPU가 다른 일을 전혀 하지 못한다는 뜻입니다. 그 시간이면 다른 프로그램의 명령어를 수백만 개 실행할 수 있었을 것입니다.

그렇다고 폴링이 늘 나쁜 것은 아닙니다. 기다리는 시간이 아주 짧다면, 프로세스를 재우고 인터럽트로 깨우는 비용이 오히려 더 클 수 있습니다. 그래서 Linux는 응답이 수 마이크로초 안에 오는 고성능 NVMe SSD를 위해 블록 계층에서 폴링 방식의 입출력을 지원합니다.

끝나면 알려 주는 인터럽트

인터럽트 방식에서 CPU는 장치에 명령만 보내고 다른 일을 하다가, 장치가 일을 끝내고 인터럽트를 보내면 그때 처리합니다. 아래 의사 코드는 앞의 폴링 코드와 같은 읽기를 인터럽트로 바꾼 것입니다. 위쪽 두 줄은 입출력을 요청한 프로세스가 실행하고, 아래의 disk_interrupt_handler는 장치가 인터럽트를 보냈을 때 커널이 실행합니다. 두 부분은 이어서 실행되는 코드가 아니라 서로 다른 시점에 실행된다는 점을 보면 됩니다.

인터럽트 기반 입출력 (의사 코드)
send_command_to_disk(READ, sector, buffer);
sleep_until_io_done();   /* 이 프로세스는 잠들고, CPU는 다른 프로세스를 실행한다. */

/* ... 나중에 디스크가 인터럽트를 보내면 ... */

void disk_interrupt_handler(void)
{
    copy_from_controller(buffer, size);
    wake_up_waiting_process();   /* 기다리던 프로세스를 실행 가능 상태로 */
}

입출력을 요청한 프로세스는 결과를 받을 때까지 대기(waiting) 상태로 잠들고, 그동안 CPU는 다른 프로세스를 실행합니다. 이것이 다중 프로그래밍의 핵심입니다. 입출력을 기다리는 프로세스 대신 실행할 수 있는 다른 프로세스에게 CPU를 주는 것입니다. 인터럽트가 와서 커널이 프로세스를 깨우면 그 프로세스는 준비(ready) 상태가 되는데, 이것이 곧바로 실행된다는 뜻은 아닙니다. 아래 단계 개념도는 잠든 프로세스가 다시 CPU를 받기까지의 상태 변화를 보여 줍니다.

블로킹 I/O 후 다시 실행되기까지

CPU를 사용하던 중 블로킹 요청을 한다. 스케줄러가 선택하고 디스패치한다. 각 단계의 값과 전환 이유를 비교합니다.

블로킹 I/O · 대기와 실행

깨워진 작업은 먼저 Ready가 되고 CPU 배정을 기다립니다

즉시 완료할 수 없어 잠드는 요청의 예시입니다. 완료 조건이 충족되어 실행 가능해지는 것과 스케줄러가 실제로 실행시키는 것은 서로 다른 단계입니다.

I/O 대기와 실행 재개Running에서 요청 대기 시 Waiting, 완료 조건 충족 시 Ready, 스케줄러 선택 후 Running으로 바뀝니다.Running블로킹 요청 시작Waiting완료 조건을 기다림Ready완료 처리로 실행 가능Running스케줄러가 CPU 배정
I/O 대기와 실행 재개Running에서 요청 대기 시 Waiting, 완료 조건 충족 시 Ready, 스케줄러 선택 후 Running으로 바뀝니다.Running블로킹 요청 시작Waiting완료 조건을 기다림Ready완료 처리로 실행 가능Running스케줄러가 CPU 배정

요청을 기다리며 잠드는 경우의 상태 변화입니다. 완료 처리로 Ready가 되어도 즉시 실행되는 것은 아니며, 스케줄러의 CPU 배정을 기다립니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 실행 중 요청부터 CPU 배정까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목블로킹 I/O 후 다시 실행되기까지
설명·조건요청의 완료를 기다리며 잠드는 경우의 상태 변화입니다. 완료 처리로 Ready가 되어도 즉시 실행되는 것은 아니며, 스케줄러의 CPU 배정을 기다립니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
실행 중 요청

CPU를 사용하던 중 블로킹 요청을 한다.

  • 프로세스 상태 1: Running
  • 프로세스 상태 2: 지금 결과 제공 불가
  • Waiting으로 잠듦
완료 조건 충족

대기하던 조건이 충족된다.

  • 프로세스 상태 1: Waiting→Ready
  • 실행 가능; 아직 CPU 미배정
CPU 배정

스케줄러가 선택하고 디스패치한다.

  • 프로세스 상태 1: Ready→Running
  • 실제 실행 재개

블로킹 I/O 후 다시 실행되기까지

CPU를 사용하던 중 블로킹 요청을 한다. 스케줄러가 선택하고 디스패치한다. 각 단계의 값과 전환 이유를 비교합니다.

블로킹 I/O · 대기와 실행

깨워진 작업은 먼저 Ready가 되고 CPU 배정을 기다립니다

즉시 완료할 수 없어 잠드는 요청의 예시입니다. 완료 조건이 충족되어 실행 가능해지는 것과 스케줄러가 실제로 실행시키는 것은 서로 다른 단계입니다.

I/O 대기와 실행 재개Running에서 요청 대기 시 Waiting, 완료 조건 충족 시 Ready, 스케줄러 선택 후 Running으로 바뀝니다.Running블로킹 요청 시작Waiting완료 조건을 기다림Ready완료 처리로 실행 가능Running스케줄러가 CPU 배정
I/O 대기와 실행 재개Running에서 요청 대기 시 Waiting, 완료 조건 충족 시 Ready, 스케줄러 선택 후 Running으로 바뀝니다.Running블로킹 요청 시작Waiting완료 조건을 기다림Ready완료 처리로 실행 가능Running스케줄러가 CPU 배정

요청을 기다리며 잠드는 경우의 상태 변화입니다. 완료 처리로 Ready가 되어도 즉시 실행되는 것은 아니며, 스케줄러의 CPU 배정을 기다립니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 실행 중 요청부터 CPU 배정까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목블로킹 I/O 후 다시 실행되기까지
설명·조건요청의 완료를 기다리며 잠드는 경우의 상태 변화입니다. 완료 처리로 Ready가 되어도 즉시 실행되는 것은 아니며, 스케줄러의 CPU 배정을 기다립니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
실행 중 요청

CPU를 사용하던 중 블로킹 요청을 한다.

  • 프로세스 상태 1: Running
  • 프로세스 상태 2: 지금 결과 제공 불가
  • Waiting으로 잠듦
완료 조건 충족

대기하던 조건이 충족된다.

  • 프로세스 상태 1: Waiting→Ready
  • 실행 가능; 아직 CPU 미배정
CPU 배정

스케줄러가 선택하고 디스패치한다.

  • 프로세스 상태 1: Ready→Running
  • 실제 실행 재개

실행 중이던 프로세스가 입출력을 요청하면 결과를 지금 줄 수 없으므로 대기 상태로 잠듭니다. 인터럽트로 기다리던 조건이 채워지면 준비 상태가 되지만, 아직 CPU를 배정받지는 않았습니다. 스케줄러가 이 프로세스를 골라야 비로소 실행(running) 상태가 되어 멈추었던 곳부터 이어 실행됩니다. 이 상태들은 3장에서 자세히 다룹니다.

인터럽트 방식에도 약점이 있습니다. 데이터를 CPU가 직접 옮기면서 작은 단위마다 인터럽트를 받으면, 큰 데이터를 옮길 때 인터럽트 처리 비용이 쌓입니다. 1MB를 1바이트씩 옮기며 바이트마다 인터럽트를 받는다면 약 100만 번의 인터럽트를 처리해야 합니다.

폴링과 인터럽트의 손익 계산

두 방식 가운데 어느 쪽이 CPU 시간을 덜 쓰는지는 기다리는 시간에 달려 있습니다. 다음 Python 코드는 이를 아주 단순한 모델로 계산합니다. 폴링은 기다리는 시간 내내 CPU를 쓰고, 인터럽트 방식은 잠들고 깨어나는 데 고정 비용이 든다고 봅니다. 이 고정 비용을 5μs로 잡은 것은 설명을 위한 가정이며, 실제 값은 하드웨어와 커널에 따라 다릅니다.

SWITCH_COST_US = 5  # 잠들고 깨어나는 데 드는 CPU 시간(가정)

def polling_cpu(wait_us):
    return wait_us          # 기다리는 내내 상태를 확인한다

def interrupt_cpu(wait_us):
    return SWITCH_COST_US   # 기다리는 동안 CPU는 다른 일을 한다

for wait in [2, 5, 20, 5000]:
    p, i = polling_cpu(wait), interrupt_cpu(wait)
    better = "폴링" if p < i else "인터럽트" if i < p else "같음"
    print(f"대기 {wait:>4}μs: 폴링 {p:>4}μs, 인터럽트 {i}μs -> {better}")
대기    2μs: 폴링    2μs, 인터럽트 5μs -> 폴링
대기    5μs: 폴링    5μs, 인터럽트 5μs -> 같음
대기   20μs: 폴링   20μs, 인터럽트 5μs -> 인터럽트
대기 5000μs: 폴링 5000μs, 인터럽트 5μs -> 인터럽트

기다리는 시간이 잠들고 깨어나는 비용보다 짧으면 폴링이 CPU를 덜 쓰고, 길면 인터럽트가 유리합니다. 디스크를 5ms 기다리는 경우라면 차이가 천 배에 이릅니다. 실제 시스템에서는 응답 지연, 다른 프로세스가 기다리는 정도, 전력 소비도 함께 따지므로 이 모델만으로 결정하지는 않지만, 두 방식이 쓰이는 자리가 왜 다른지는 이것으로 설명됩니다.

데이터를 장치가 직접 옮기는 DMA

DMA(direct memory access)는 데이터를 옮기는 일을 CPU 대신 장치(또는 DMA 컨트롤러)가 맡는 방식입니다. CPU는 "이 위치의 데이터 N바이트를 이 메모리 주소로 옮겨라"라고 한 번 지시하고, 장치가 메모리에 직접 데이터를 씁니다.

DMA (의사 코드)
prepare_dma_buffer(buffer, size);             /* 장치가 쓸 메모리를 준비한다. */
start_dma_read(disk_addr, buffer, size);
sleep_until_io_done();

/* 이 예에서는 전송 전체가 끝났을 때 인터럽트로 알린다고 가정한다. */
void dma_complete_interrupt_handler(void)
{
    finish_dma_buffer(buffer, size);          /* CPU가 데이터를 읽기 전 정리 */
    wake_up_waiting_process();                /* 데이터는 이미 메모리에 있다. */
}

오늘날 디스크, 네트워크, 그래픽처럼 많은 데이터를 옮기는 장치는 거의 모두 DMA를 씁니다. 그렇다고 CPU가 할 일이 사라지는 것은 아닙니다. 장치가 쓸 버퍼를 준비하고 장치가 볼 수 있는 주소로 매핑해야 하며, 캐시에 남은 옛 데이터와 장치가 쓴 새 데이터가 어긋나지 않게 맞추고, 완료를 처리해야 합니다. 이런 일은 Linux DMA API 안내서가 정한 절차를 따릅니다.

DMA로 데이터를 옮긴 뒤 완료를 확인하는 방법은 여전히 인터럽트와 폴링 가운데 고를 수 있습니다. 드라이버가 DMA 전송을 시작하고 CPU가 다른 일을 하다가 완료 인터럽트를 받으면 DMA와 인터럽트의 조합입니다. 같은 DMA 전송에서 CPU가 완료 큐를 직접 확인해 결과를 가져오면 DMA와 폴링의 조합입니다. 데이터를 옮기는 방식은 같고, 완료를 알아내는 방식만 다릅니다. 또 DMA라고 해서 전송 한 번에 인터럽트가 꼭 한 번 오는 것도 아닙니다. 장치가 여러 완료를 모아 인터럽트 한 번으로 알리기도 합니다.

장치 레지스터에 접근하는 두 방법

CPU가 장치 컨트롤러의 레지스터를 읽고 쓰는 방법도 두 가지입니다. 포트 I/O(port-mapped I/O)는 메모리와 별도로 장치 전용 주소 공간을 두고, in과 out 같은 전용 명령어로 접근하는 방식입니다. x86이 전통적으로 써 온 방식으로, 예를 들어 PC 키보드 컨트롤러의 데이터 레지스터는 포트 0x60에 있습니다.

메모리 맵 I/O(memory-mapped I/O, MMIO)는 장치의 레지스터를 메모리 주소 공간의 일부에 배치하는 방식입니다. 그러면 일반적인 메모리 읽기와 쓰기 명령어로 장치를 제어할 수 있어, 전용 명령어가 필요 없고 C의 포인터로도 접근할 수 있습니다. ARM 프로세서와 대부분의 현대 장치가 MMIO를 씁니다. 다만 장치 레지스터는 일반 메모리와 달리 읽기만 해도 장치의 상태가 바뀔 수 있으므로, 컴파일러와 CPU가 접근을 생략하거나 순서를 바꾸지 않게 하는 전용 접근 함수를 씁니다. GPU처럼 메모리를 가진 장치는 그 메모리의 일부를 주소 공간에 매핑해 CPU가 직접 쓰게 하기도 하지만, 그곳에 값을 쓴다고 바로 화면의 픽셀이 바뀌는지는 드라이버와 디스플레이 구조에 따라 다릅니다.

타이머 인터럽트와 선점형 멀티태스킹

운영체제는 하드웨어 타이머를 설정해 일정한 간격으로 인터럽트가 오게 합니다. 이 주기적인 인터럽트 하나하나를 틱(tick)이라고 합니다. Linux에서 틱의 빈도는 커널을 빌드할 때 정하는 HZ 값으로, 100, 250, 300, 1000 가운데 하나를 고르는 경우가 많습니다. HZ가 1000이면 틱 간격은 1ms입니다.

타이머 인터럽트가 오면 커널은 시간과 관련된 일을 처리합니다. 지금 실행 중인 프로세스가 CPU를 얼마나 썼는지 기록하고, 이 기록이 top 같은 도구가 보여 주는 CPU 사용 시간의 바탕이 됩니다. 시각이 된 커널 타이머를 처리하고, 지금 프로세스에게 준 실행 시간이 다 되었는지 확인해 다 되었다면 다른 프로세스로 바꾸도록 표시합니다. 현재 시각 자체는 틱 수를 세는 것이 아니라 더 정밀한 하드웨어 시계를 읽어 계산하며, 이 내용은 Linux 시간 관리 문서에 있습니다.

타이머 인터럽트가 없다면 어떻게 될까요? 무한 루프에 빠진 프로그램은 시스템 콜도 부르지 않고 예외도 일으키지 않으므로, 커널이 실행될 기회가 영영 오지 않습니다. 그러면 다른 프로그램은 CPU를 받지 못합니다. 타이머 인터럽트가 있으면 프로그램이 무엇을 하든 일정 시간마다 커널이 실행되므로, 커널은 그 프로그램에게서 CPU를 빼앗아 다른 프로세스에게 줄 수 있습니다. 이렇게 운영체제가 실행 중인 프로세스를 강제로 멈추고 CPU를 넘기는 방식을 선점형 멀티태스킹(preemptive multitasking)이라고 합니다.

이와 달리 프로세스가 스스로 CPU를 양보해야만 다른 프로세스가 실행되는 방식을 협력형 멀티태스킹(cooperative multitasking)이라고 합니다. Windows 3.x와 Mac OS 9까지의 클래식 Mac OS가 이 방식이었는데, 프로그램 하나가 양보하지 않으면 시스템 전체가 멈춘 것처럼 보이는 문제가 있었습니다. 오늘날의 범용 운영체제는 선점형을 쓰며, 협력형 방식은 이벤트 루프처럼 한 프로그램 안에서 작업을 나누는 곳에 남아 있습니다. 예를 들어 JavaScript의 setTimeout 콜백은 커널이 아니라 런타임의 이벤트 루프가 실행하므로, 앞의 콜백이 끝나지 않으면 뒤의 콜백은 시간이 되어도 실행되지 못합니다.

일이 없어 쉬고 있는 CPU에서도 틱마다 깨어나면 전력이 낭비됩니다. 그래서 Linux는 쉬고 있는 CPU에서는 주기적인 틱을 멈추는 틱 없는(tickless) 방식을 지원합니다. 설정에 따라 실행할 작업이 하나뿐인 CPU에서도 틱을 줄일 수 있으며, 자세한 내용은 NO_HZ 문서에 있습니다. 그래서 오늘날의 Linux에서는 모든 CPU가 늘 같은 간격으로 타이머 인터럽트를 받는다고 볼 수 없습니다.

정리

  • 인터럽트는 장치가 CPU에 일이 생겼음을 알리는 신호로, 프로그램과 상관없는 시점에 일어나며 프로그램은 그 사실을 모르게 처리됩니다.
  • 커널 모드로 들어가는 원인은 장치의 하드웨어 인터럽트, 프로그램이 일부러 일으키는 트랩, 명령어 실행 중의 예외로 나뉩니다. 페이지 폴트처럼 정상적으로 처리되고 실행이 이어지는 예외도 많습니다.
  • 인터럽트가 오면 CPU는 IDT에서 처리 코드를 찾아 상태를 저장하고 커널에 들어가며, 처리가 끝나면 상태를 되살려 돌아갑니다. Linux는 처리를 짧은 상반부와 나중에 실행하는 하반부로 나눕니다.
  • 완료를 알아내는 방식(폴링, 인터럽트)과 데이터를 옮기는 방식(PIO, DMA)은 따로 고르는 선택입니다. 폴링은 기다리는 시간이 짧을 때, 인터럽트는 길 때 유리합니다.
  • 타이머 인터럽트는 프로그램이 무엇을 하든 커널이 주기적으로 실행되게 하여 선점형 멀티태스킹을 가능하게 합니다.

연습 문제

1. DMA 완료 뒤의 두 주장

DMA 전송이 끝나 커널이 기다리던 프로세스 A를 준비 상태로 바꾸었지만, CPU에서는 아직 프로세스 B가 실행되고 있습니다. 동료가 "DMA를 쓰니 CPU는 이 입출력에서 아무 일도 하지 않았고, 완료 인터럽트가 왔으니 A는 이미 실행 중이다"라고 말했습니다. 이 말에서 고쳐야 할 부분을 데이터 이동, 완료 처리, 스케줄링으로 나누어 설명하세요.

해설 보기

두 주장 모두 고쳐야 합니다. DMA는 데이터를 옮기는 일을 장치가 맡는 방식일 뿐이고, 버퍼를 준비해 매핑하고, 캐시와 메모리의 내용을 맞추고, 완료 인터럽트를 처리하는 일은 여전히 CPU가 합니다. 또 완료 인터럽트를 처리해 A를 준비 상태로 바꾼 것은 A가 실행될 수 있는 조건을 갖추었다는 뜻일 뿐입니다. A가 실제로 실행 상태가 되려면 스케줄러가 B 대신 A를 골라 CPU를 넘겨야 합니다. 인터럽트를 처리하려고 커널에 들어간 일과 다른 프로세스로 전환하는 일은 서로 다른 사건입니다.

2. 디스크와 NVMe SSD의 폴링 비용

3GHz CPU가 폴링으로 입출력 완료를 기다립니다. 응답에 5ms가 걸리는 하드 디스크와 10μs가 걸리는 NVMe SSD에서 각각 몇 클록을 기다림에 쓰는지 계산하세요. 잠들고 깨어나는 데 드는 비용이 5μs라고 가정할 때, 각 장치에서 어느 방식이 CPU 시간을 덜 쓰는지도 판단하세요.

해설 보기

하드 디스크는 3 × 10⁹ × 0.005 = 15,000,000클록, NVMe SSD는 3 × 10⁹ × 0.00001 = 30,000클록을 기다림에 씁니다. 하드 디스크의 대기 5,000μs는 잠들고 깨어나는 비용 5μs보다 천 배 길므로 인터럽트 방식이 훨씬 유리합니다. NVMe SSD의 대기 10μs는 그 비용의 두 배이므로 이 가정에서는 인터럽트 방식이 CPU 시간을 조금 덜 씁니다. 다만 응답을 조금이라도 빨리 받는 것이 중요하다면, 잠든 프로세스가 깨어나 다시 CPU를 받기까지 걸리는 지연을 피하려고 폴링을 고를 수도 있습니다. 대기 시간이 비용보다 짧아질수록 폴링이 유리해집니다.

3. 무한 루프와 운영체제

한 프로그램이 while (1) {}처럼 시스템 콜을 전혀 부르지 않는 무한 루프에 빠졌습니다. 선점형 운영체제에서 다른 프로그램이 계속 실행될 수 있는 이유를 설명하고, 협력형 운영체제였다면 어떻게 되었을지 비교하세요.

해설 보기

무한 루프는 시스템 콜도 부르지 않고 예외도 일으키지 않으므로, 프로그램 스스로는 커널에 들어갈 일이 없습니다. 하지만 선점형 운영체제에서는 하드웨어 타이머가 일정한 간격으로 인터럽트를 보내므로, 그때마다 CPU는 루프를 멈추고 커널의 처리 코드를 실행합니다. 커널은 이 프로그램이 받은 실행 시간이 다 되었음을 확인하고 다른 프로세스로 전환하므로, 다른 프로그램도 차례로 CPU를 받습니다. 협력형 운영체제에서는 프로그램이 스스로 양보해야만 다른 프로그램이 실행되는데, 무한 루프는 양보하지 않으므로 다른 프로그램이 모두 멈춘 것처럼 됩니다.

다음 글 부팅 과정과 운영체제 적재에서는 전원을 켠 순간부터 커널이 메모리에 올라와 첫 프로세스를 실행하기까지의 과정을 살펴봅니다.