Java 이벤트 리스너는 어떻게 동작하는가?

Java Nio 예시

요새 일에 여유가 생겼는지.. 개발하다가 뜬금 없이 이벤트 리스너가 어떻게 구현 된건지 궁금했다.

단순 while(true)로 동작한다면 별 찍기를 잘못 구현했을 때처럼 CPU 100%를 칠텐데, 실제로 Spring 서버를 띄워 놓고 보면 거의 0%를 유지한다.

간단한 구현

while (true) {
    if (eventOccurred()) {
        doSomething();
    }
}

쉬지 않고 이벤트가 발생했는지 체크한다. 당연히 CPU는 100%가 찍힌다.

Java의 실제 구현 (OS에 위임)

  • Spring은 서버 시작 시 내부적으로 selector.select()를 호출해 이벤트를 대기한다.

실제 구현은 이렇다. (Java nio 클래스, 스프링 로그에 보이는 nio thread 맞음)

// EPollSelectorImpl.java 중에서..
@Override
protected int doSelect(Consumer<SelectionKey> action, long timeout) throws IOException {
    int numEntries;
 
    int to = (int) Math.min(timeout, Integer.MAX_VALUE);                   // 안전하게 timeout 처리
    numEntries = EPoll.wait(epfd, pollArrayAddress, NUM_EPOLLEVENTS, to);  // 리눅스의 epoll_wait() 호출, 이벤트 발생 전까지 스레드는 Blocked 상태가 되어 CPU 점유 X
    
    return processEvents(numEntries, action);     // OS로부터 wake-up 이벤트 발생하면 block 풀리고, numEntries 반환
}
 
private int processEvents(int numEntries, Consumer<SelectionKey> action) {
    for (int i=0; i<numEntries; i++) {
        long event = EPoll.getEvent(pollArrayAddress, i);
        int fd = EPoll.getDescriptor(event);     // fileDescriptor offset 획득
        // ... 생략
        processReadyEvents(...) // 핸들러 실행
    }
}
  1. selector.select()를 호출하는 순간, doSelect() 호출 -> 리눅스 epoll_wait() 호출

  2. OS는 해당 스레드를 Blocked 상태로 전환. 이 상태에서 CPU 점유율 0%

  3. 이벤트가 발생하면 커널이 스레드를 깨우면서 이벤트가 발생한 소켓(채널) 목록을 넘김

그럼 epoll_wait() 동안 이벤트를 리스닝하며 대기하는 로직도 결국 while (true) 아닌가?

그럼 epoll_wait()을 구현하기 위해, 또 결국 while(true) 와 같은 대기가 필요한게 아닌가? 라는 생각이 들었다.

예시 경우에서는 NIC로부터 패킷이 들어올 때 epoll_wait() 블로킹이 풀려야 하는데, 이 때 hardwareInterrupt가 사용된다.

컴퓨터 구조 강의에서 분명 들었던 것 같은데.. 진짜 듣기만 했나보다. 가물가물하다.
structure

  1. NIC가 패킷을 수신하면, PIC를 통해 CPU에 IRQ 신호를 쏜다.

  2. CPU는 매 명령어 사이클이 끝날 때마다 Interrupt request bit를 체크한다.
    인터럽트가 감지되면 현재 PC와 PSW를 저장하고, ISR(Interrupt Service Routine) 주소로 점프한다.

  3. ISR이 커널에 이벤트를 알리고, 커널이 Blocked 상태였던 프로세스를 Ready 상태로 바꾼다.
    ISR이 끝나면 저장해뒀던 PC, PSW를 복원하고 원래 프로세스로 돌아온다.

CPU는 기본적으로 while(true)처럼 뭔가 항상 하고 있기 때문에.. interrupt로 내꺼 먼저 처리해달라 요구하는 개념이다.

결론

핵심은 OS에 위임이다.

이벤트 리스너 프로그램이 CPU를 잡아먹지 않는 이유는, 결국 '기다리는 로직'을 OS에 맡기기 때문이다.
Java는 epoll_wait()로 OS에 위임하고, OS는 hardware interrupt로 하드웨어에 위임한다.

글에서는 네트워크 이벤트를 예시로 들었으나, JS의 onClick() 과 같은 고수준 이벤트 리스너도 깊게 들어가면 결국 epoll을 사용한다.