하지만 즐거운...
파일 디스크립터는 “파일 번호”가 아니라, 커널이 관리하는 I/O 대상에 접근하기 위한 참조값이다
파일 디스크립터를 단순히 파일을 가리키는 숫자로 이해하면 범위가 너무 좁다.
실제로는 파일뿐 아니라 소켓, 파이프, 표준 입력/출력처럼 커널이 관리하는 다양한 I/O 대상을 가리키는 핸들에 가깝다.
즉, 프로세스는 파일 디스크립터를 통해 어떤 대상에 대해 read/write 같은 I/O 작업을 수행한다.
이 관점에서 보면 파일, 네트워크 소켓, 파이프가 서로 완전히 다른 것이 아니라, 사용자 입장에서는 공통된 I/O 모델로 다뤄지는 대상이라는 점이 중요하다.
이벤트 루프는 단순히 “계속 돌면서 처리하는 스레드”가 아니다.
실제로는 OS가 제공하는 I/O 대기 메커니즘 위에서 동작한다.
네트워크 서버는 여러 연결을 다뤄야 하므로, 각 연결마다 무작정 스레드를 막아 세우는 방식보다
어떤 파일 디스크립터에 읽을 데이터가 준비되었는지 OS에 물어보고,
준비된 것만 처리하는 방식이 필요하다.
이때 등장하는 것이 select, poll, epoll 같은 I/O multiplexing 방식이다.
그래서 이벤트 루프를 제대로 이해하려면 프레임워크 API보다 먼저,
커널이 I/O 준비 상태를 어떻게 알려주는지,
그리고 스레드가 무엇을 기다리며 block되는지를 이해해야 한다.
OS는 스레드를
“무슨 일을 하고 있느냐”보다
지금 CPU에서 실행 가능한 상태인가, 아니면 무엇인가를 기다리고 있는가의 관점으로 본다.
즉, OS 입장에서 중요한 건 아래 두 가지다.
그래서 OS의 스레드 상태는 자바의 BLOCKED, WAITING 같은 분류와는 조금 다르다.
자바 상태가 애플리케이션/JVM 관점의 의미라면,
OS 상태는 스케줄링과 자원 대기 관점에 더 가깝다.
스레드가 실제로 CPU 위에서 실행 중인 상태다.
이건 말 그대로 지금 일을 하고 있는 상태다.
CPU 코어 수가 4개라면, 동시에 running일 수 있는 스레드 수도 최대 4개다.
스레드는 실행할 준비가 끝났지만, 아직 CPU를 받지 못한 상태다.
즉,
이 상태는 “쉬고 있다”가 아니라
당장 돌릴 수 있는데 CPU가 부족하거나 스케줄 순서를 기다리는 상태라고 보는 게 맞다.
스레드가 어떤 조건이 충족되기를 기다리며 멈춰 있는 상태다.
이건 다시 두 가지로 나눠서 보는 게 좋다.
Interruptible sleep
이벤트나 신호를 받으면 깰 수 있는 대기 상태다.
예를 들면:
소켓 데이터 도착 대기, 타이머 대기, 어떤 이벤트 발생 대기
리눅스에서 보통 S 상태로 보게 된다.
Uninterruptible sleep
주로 커널 I/O 완료를 기다리는 상태다.
예를 들면
디스크 I/O 대기,일부 커널 자원 대기.
리눅스에서는 흔히 D 상태라고 부른다.
이 상태는 load average와도 연결되기 때문에 운영에서 특히 중요하다.
둘 다 “지금 실행 중이 아님”이라는 점은 같지만, 의미는 완전히 다르다.
Runnable
CPU만 받으면 바로 실행 가능하다.
즉 병목이 CPU 쪽일 가능성이 크다.
Sleeping
CPU를 줘도 바로 실행할 수 없다.
무언가를 기다리고 있기 때문이다.
이 스레드는 CPU를 바로 쓰진 못하지만, 시스템 입장에선 여전히 끝나지 않은 작업.
CPU의 부하가 아닌, 시스템의 부하이다.
근데 왜 uninterruptible하게 설계했을까?
그 대기를 중간에 깨워도 유의미한 작업인 경우가 없고, 오히려 커널 내부 상태와 장치 상태의 일관성을 안전하게 유지하기 더 어렵기 때문.
중간에 커널이 요청 큐에 넣고, 디스크 작업을 걸고, 버퍼/page cache/블록 계층 상태를 잡고, 완료 시점에 후처리 -> 이거 어케보장?
또한, 디스크 응답이 아직 안 옴 / 필요한 페이지가 아직 안 올라옴 / 블록 장치 작업이 아직 안 끝남 -> 어케보장?
blocking I/O
blocking 소켓에 대해 while loop에서 read()를 호출하면, 데이터가 없을 때 스레드는 busy waiting을 하는 것이 아니라 read() 안에서 block된다. 이 경우 스레드는 데이터가 도착할 때까지 sleep 상태로 들어갈 수 있다.
이벤트 루프
이벤트 루프는 보통 non-blocking 소켓을 사용하므로, read() 자체가 오래 block되는 구조가 아니다. 대신 epoll_wait()나 poll() 같은 I/O multiplexing 호출에서 이벤트가 올 때까지 block되고,
준비된 소켓에 대해서만 read()를 수행한다.