[network]B9 — epoll 부터하려했지만,

ttttom1·1일 전

Blocking 서버에서 epoll까지 — Linux 네트워크 I/O 모델 직접 구현하기

Linux 네트워크 프로그래밍을 공부하면서 socket(), accept(), recv() 같은 API를 각각 외우는 것보다 더 중요했던 것은 왜 Blocking → Thread → Non-blocking → poll → epoll 순서로 발전하는지 이해하는 것이었다.

이번 실습에서는 가장 단순한 TCP 서버부터 시작해 하나의 스레드로 여러 connection을 처리하는 epoll 서버까지 직접 구현했다.


1. TCP Socket도 File Descriptor다

TCP 서버의 기본 흐름은 다음과 같다.

socket()
   ↓
bind()
   ↓
listen()
   ↓
accept()
   ↓
recv() / send()

socket()의 반환값도 일반 파일처럼 정수형 File Descriptor다.

fd 0 = stdin
fd 1 = stdout
fd 2 = stderr
fd 3 = server socket

즉 Linux에서는 파일뿐 아니라 socket도 FD라는 공통 인터페이스로 관리한다.

서버에서는 특히 두 종류의 socket을 구분해야 한다.

server_fd
= 새로운 connection을 받기 위한 listening socket

client_fd
= 특정 client와 실제 통신하기 위한 connected socket

예를 들면:

              server_fd = 3
             Listening Socket
                    │
          ┌─────────┼─────────┐
          ▼         ▼         ▼
        fd=4      fd=5      fd=6
       Client A  Client B  Client C

accept()가 호출될 때마다 새로운 client_fd가 만들어진다.


2. Single-thread Blocking Server의 문제

가장 단순한 서버는 다음과 같이 만들 수 있다.

while (1) {
    int client_fd = accept(server_fd, NULL, NULL);

    recv(client_fd, buffer, sizeof(buffer), 0);

    send(client_fd, response, response_size, 0);

    close(client_fd);
}

문제는 accept()와 recv()가 기본적으로 blocking이라는 것이다.

예를 들어 Client A가 연결만 하고 아무 데이터도 보내지 않는다면:

Main Thread

accept A
   ↓
recv(A)
   ↓
BLOCK

서버의 유일한 thread가 Client A를 기다리기 때문에 Client B, C를 애플리케이션 레벨에서 처리할 수 없다.

중요한 것은 blocking 자체가 나쁜 것이 아니라는 점이다.

문제는:

하나뿐인 실행 흐름이 특정 connection에서 blocking되면서 다른 connection까지 처리할 수 없어진다.

는 것이다.


3. Thread-per-Connection

가장 직관적인 해결 방법은 connection마다 thread를 만드는 것이다.

Main Thread
   │
   ├── accept A → Worker A → recv(A)
   │
   ├── accept B → Worker B → recv(B)
   │
   └── accept C → Worker C → recv(C)

이제 Client A가 recv()에서 기다려도 Worker A만 block된다.

Main thread는 계속 새로운 connection을 accept()할 수 있다.

하지만 새로운 문제가 생긴다.

100 connections
→ 약 100 worker threads

10,000 connections
→ 약 10,000 worker threads

각 thread에는 stack과 scheduling 상태가 필요하고, runnable thread가 많아지면 context switching 비용도 증가한다.

특히 많은 connection이 실제 작업을 하지 않고 recv()에서 기다리고만 있다면 connection마다 thread 하나를 유지하는 것이 비효율적일 수 있다.


4. Non-blocking I/O

다음 아이디어는 thread를 recv()에서 재우지 않는 것이다.

socket에 O_NONBLOCK을 설정한다.

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

이제 데이터가 없을 때:

recv(fd, buffer, size, 0);

가 기다리지 않는다.

대신:

return = -1
errno = EAGAIN 또는 EWOULDBLOCK

으로 즉시 돌아온다.

즉:

Blocking recv

데이터 없음
→ Thread sleep

에서

Non-blocking recv

데이터 없음
→ EAGAIN
→ 바로 다음 코드 실행

으로 바뀐다.

Listening Socket 역시 non-blocking으로 설정할 수 있다.

그 이유는 accept() 또한 blocking이기 때문이다.

server_fd non-blocking
→ accept() 때문에 event loop가 멈추지 않도록

client_fd non-blocking
→ recv() 때문에 event loop가 멈추지 않도록

5. Non-blocking만으로는 해결되지 않는다

처음에는 모든 client socket을 배열에 넣고 직접 순회했다.

while (1) {

    accept(...);

    for (모든 client_fd) {
        recv(client_fd, ...);
    }
}

그러면 대부분의 socket에서:

fd 4 → EAGAIN
fd 5 → EAGAIN
fd 6 → EAGAIN
fd 7 → EAGAIN
...

이 반복된다.

Thread는 block되지 않지만 CPU가 계속 모든 FD를 확인하게 된다.

즉 Busy Polling 문제가 발생한다.

Blocking 문제 해결
        ↓
Non-blocking
        ↓
새로운 문제

"어떤 FD에 실제 데이터가 있는지
어떻게 효율적으로 알 수 있을까?"

여기서 I/O Multiplexing이 등장한다.


6. poll() — 여러 FD를 한 번에 기다리기

poll()에서는 감시할 FD 목록을 kernel에 전달한다.

struct pollfd fds[MAX_CLIENTS];

fds[0].fd = server_fd;
fds[0].events = POLLIN;

poll(fds, count, -1);

poll()은 이벤트가 생길 때까지 thread를 sleep시킨다.

Application
    │
    │ poll()
    ▼
Kernel

"이 FD들 중
준비되는 것이 생기면 깨워줘."

이제 application이 계속 recv()를 호출해보면서 확인할 필요가 없다.

events와 revents도 구분할 필요가 있다.

events
= 내가 관심 있는 이벤트

revents
= 실제로 이번에 발생한 이벤트

예를 들어:

fds[i].events = POLLIN;

은 읽기 가능한 상태에 관심 있다는 뜻이고:

if (fds[i].revents & POLLIN)

으로 실제 발생 여부를 확인한다.

Listening Socket 역시 poll()에서 감시할 수 있다.

server_fd + POLLIN
→ accept() 가능한 connection 존재

client_fd + POLLIN
→ recv() 가능한 상태

이 방식으로 한 thread가 여러 socket을 동시에 기다릴 수 있다.


7. poll의 한계

poll()은 Busy Waiting 문제를 해결한다.

하지만 수많은 FD를 관리할 때 여전히 비효율적인 부분이 있다.

예를 들어 10,000개의 FD 중 2개에만 이벤트가 발생했다고 하자.

poll() 이후 application은 여전히 pollfd[] 배열을 순회하며 어떤 FD에 이벤트가 발생했는지 찾아야 한다.

fds[0]
fds[1]
fds[2]
...
fds[9999]

그래서 Linux에서는 많은 connection을 효율적으로 다루기 위한 epoll을 제공한다.


8. epoll의 핵심

epoll의 핵심 API는 세 개다.

epoll_create1()
epoll_ctl()
epoll_wait()

역할은 다음과 같다.

epoll_create1()
→ epoll 인스턴스 생성

epoll_ctl()
→ 감시할 FD를 등록 / 수정 / 제거

epoll_wait()
→ 이벤트가 발생한 FD들을 기다림

먼저 epoll instance를 만든다.

int epfd = epoll_create1(0);

Listening Socket을 등록한다.

struct epoll_event ev = {0};

ev.events = EPOLLIN;
ev.data.fd = server_fd;

epoll_ctl(
    epfd,
    EPOLL_CTL_ADD,
    server_fd,
    &ev
);

그 후:

struct epoll_event events[64];

int ready = epoll_wait(
    epfd,
    events,
    64,
    -1
);

을 호출한다.


9. epoll event loop

전체 서버 구조는 다음처럼 된다.

                    Main Thread
                         │
                         ▼
                   epoll_wait()
                         │
          ┌──────────────┴──────────────┐
          │                             │
          ▼                             ▼
     server_fd                      client_fd
      EPOLLIN                        EPOLLIN
          │                             │
          ▼                             ▼
       accept()                       recv()
          │
          ▼
      client_fd
          │
          ▼
     epoll_ctl(ADD)
          │
          └───────────────→ epoll_wait()

별도의 "epoll 서버"나 epoll 전용 thread가 있는 것이 아니다.

현재 서버의 main thread가 epoll_wait()에서 여러 FD의 이벤트를 동시에 기다린다.

이전에는:

accept() 하나만 기다림
또는
recv(A) 하나만 기다림

이었다면 이제는:

server_fd
client A
client B
client C
...

중 무엇이든 준비되면 깨워줘.

가 된다.

이게 event loop의 핵심이다.


10. accept()도 EAGAIN까지 반복한다

Listening Socket에 EPOLLIN 이벤트가 발생했다고 해서 새로운 connection이 정확히 하나만 있다는 뜻은 아니다.

Kernel의 accept queue에:

[A] [B] [C]

처럼 여러 connection이 들어와 있을 수도 있다.

따라서:

while (1) {

    int client_fd = accept(server_fd, NULL, NULL);

    if (client_fd == -1) {

        if (errno == EAGAIN ||
            errno == EWOULDBLOCK) {
            break;
        }

        perror("accept");
        break;
    }

    ...
}

와 같이 처리한다.

accept A
accept B
accept C
accept
→ EAGAIN

→ 현재 받아들일 connection을 모두 처리함

Listening Socket을 non-blocking으로 설정했기 때문에 더 이상 connection이 없으면 accept()가 기다리지 않고 EAGAIN을 반환한다.


11. Client socket도 EAGAIN까지 읽는다

Client socket에서도 같은 패턴을 사용했다.

while (1) {

    ssize_t n = recv(
        active_fd,
        buffer,
        sizeof(buffer) - 1,
        0
    );

    if (n > 0) {
        ...
    }

    else if (n == 0) {
        // peer 정상 종료
        ...
        break;
    }

    else {

        if (errno == EAGAIN ||
            errno == EWOULDBLOCK) {
            break;
        }

        ...
    }
}

즉 현재 kernel receive buffer에서 읽을 수 있는 데이터는 계속 읽고:

recv()
recv()
recv()
...
EAGAIN

이 나오면 다시 epoll_wait()로 돌아간다.


12. Level Triggered

현재 구현에서는:

client_ev.events = EPOLLIN;

만 사용했다.

따라서 기본 방식인 Level Triggered(LT)다.

예를 들어 socket buffer에:

HELLOWORLD

가 들어왔는데 일부만 읽어서:

WORLD

가 남아 있다면 다음 epoll_wait()에서도 읽을 데이터가 있다고 다시 알려준다.

즉:

읽을 데이터가 남아 있는 동안
→ 계속 readable 상태

다.

반면 Edge Triggered에서는 상태가 변한 순간을 중심으로 알려주기 때문에 데이터를 EAGAIN까지 비우는 패턴이 더욱 중요해진다.

이번 구현에서는 LT를 사용했지만 recv()를 EAGAIN까지 반복하는 구조를 사용했다.


13. poll과 epoll의 차이

개념적으로 비교하면:

poll

전체 FD 목록
   ↓
poll()
   ↓
이벤트 발생
   ↓
application이 전체 목록을 확인

반면 epoll은:

FD들을 미리 kernel에 등록

        ↓

epoll_wait()

        ↓

실제 준비된 이벤트 목록 반환

예를 들어:

등록된 socket = 10,000개

이벤트 발생:
fd 17
fd 921

이라면:

ready = 2;

events[0] → fd 17
events[1] → fd 921

처럼 준비된 이벤트 목록을 중심으로 처리할 수 있다.

그래서:

for (int i = 0; i < ready; i++)

만 순회하면 된다.


14. 현재 epoll Echo Server 구조

현재 구현한 서버의 핵심은 다음과 같다.

while (1) {

    int ready =
        epoll_wait(epfd, events, MAX_EVENTS, -1);

    for (int i = 0; i < ready; i++) {

        int active_fd = events[i].data.fd;

        if (active_fd == server_fd) {

            while (1) {

                int client_fd =
                    accept(server_fd, NULL, NULL);

                if (client_fd == -1) {

                    if (errno == EAGAIN ||
                        errno == EWOULDBLOCK) {
                        break;
                    }

                    break;
                }

                set_nonblocking(client_fd);

                struct epoll_event client_ev = {0};

                client_ev.events = EPOLLIN;
                client_ev.data.fd = client_fd;

                epoll_ctl(
                    epfd,
                    EPOLL_CTL_ADD,
                    client_fd,
                    &client_ev
                );
            }
        }

        else {

            while (1) {

                ssize_t n =
                    recv(active_fd, buffer, size, 0);

                if (n > 0) {
                    send(active_fd, buffer, n, 0);
                }

                else if (n == 0) {

                    epoll_ctl(
                        epfd,
                        EPOLL_CTL_DEL,
                        active_fd,
                        NULL
                    );

                    close(active_fd);
                    break;
                }

                else {

                    if (errno == EAGAIN ||
                        errno == EWOULDBLOCK) {
                        break;
                    }

                    close(active_fd);
                    break;
                }
            }
        }
    }
}

이 서버는 thread 하나로 여러 TCP connection을 관리한다.


15. TCP는 메시지가 아니라 Byte Stream이다

epoll 서버를 만든 뒤 TCP의 또 다른 중요한 특성을 확인했다.

Client에서:

send(fd, "HELLO", 5, 0);
send(fd, "WORLD", 5, 0);
send(fd, "ABCDE", 5, 0);

를 호출했다.

하지만 서버에서는:

HELLOWORLDABCDE

15바이트가 한 번에 들어오기도 했다.

이것은 정상이다.

TCP는:

send A
send B
send C

라는 호출 경계를 보존하지 않는다.

TCP가 제공하는 것은 ordered byte stream이다.

따라서 다음 결과가 모두 가능하다.

recv #1 → HELLOWORLDABCDE

또는:

recv #1 → HELLO
recv #2 → WORLD
recv #3 → ABCDE

또는:

recv #1 → HEL
recv #2 → LOWOR
recv #3 → LDABCDE

그래서 실제 application protocol은 메시지 경계를 직접 정의해야 한다.

대표적인 방법은:

Fixed Length

Delimiter

Length Prefix

등이다.


16. Partial Read / Partial Write

TCP가 byte stream이라는 사실 때문에 다음도 중요하다.

recv(fd, buffer, 1000, 0);

했다고 반드시 1000바이트가 반환되는 것이 아니다.

요청: 1000 bytes
실제 recv: 200 bytes

일 수 있다.

필요한 데이터가 모두 모일 때까지 connection별 buffer에 누적해야 한다.

반대로:

send(fd, buffer, 1000, 0);

했다고 1000바이트 전체가 처리된다는 보장도 없다.

예를 들어:

전송 요청 = 1000

send 반환 = 600

남은 데이터 = 400

이 가능하다.

특히 non-blocking socket에서는 송신 버퍼에 공간이 없으면:

send()
→ -1
→ errno = EAGAIN

도 발생한다.

실제 event-driven 서버라면 남은 데이터를 connection별 write buffer에 저장한 후 EPOLLOUT 이벤트를 이용해 다시 전송해야 한다.

현재 echo server는 이 부분까지는 구현하지 않았다.


17. Connection Reset by Peer — RST도 직접 만났다

실험 중 서버에 다음 로그가 발생했다.

[fd=5] recv: HELLOWORLDABCDE
recv error: Connection reset by peer

처음에는 데이터까지 정상적으로 받았는데 왜 바로 error가 나는지 이상했다.

Client는:

send
send
send

sleep

close

만 하고 서버가 돌려준 echo를 읽지 않았다.

서버에서는 받은 데이터를 다시 client에게 전송하고 있었다.

Client                         Server

HELLOWORLDABCDE ─────────────▶

                               recv()
                               ↓
                               echo 전송

              ◀───────────────

Client application은
echo를 recv하지 않고 socket 종료

이런 상황에서는 connection이 정상적인 FIN 흐름 대신 reset되어 서버가 다음 recv()에서:

errno = ECONNRESET

을 관찰할 수 있다.

그래서:

Connection reset by peer

가 출력되었다.


18. FIN과 RST

TCP connection 종료는 단순히 "socket이 사라진다"가 아니다.

정상적인 종료에서는 peer가 FIN을 보내고:

Client                     Server

close()
   │
   └──── FIN ─────────────▶

                           recv()
                             ↓
                             0

서버의 recv()가 0을 반환한다.

즉:

if (n == 0) {
    // peer 정상 종료
}

로 확인할 수 있다.

반면 RST는:

"이 connection을 즉시 중단한다."

에 가깝다.

서버에서는:

recv()
↓
-1
↓
errno = ECONNRESET

로 관찰할 수 있다.

간단하게 기억하면:

FIN
= 정상적인 stream 종료

RST
= connection 강제 reset

이다.


19. FD 번호가 다시 사용되는 것도 확인했다

로그를 보면 새로운 client가 계속:

fd=5

로 들어오는 경우가 있었다.

이것도 정상이다.

FD는 connection의 영구적인 ID가 아니다.

Process의 File Descriptor Table에서 사용할 수 있는 번호일 뿐이다.

Client A
→ fd 5

close(5)

fd 5 자리 비어 있음

Client B
→ fd 5 재사용

따라서 FD 번호만 보고 이전 connection과 동일한 connection이라고 판단해서는 안 된다.


20. 지금까지의 전체 발전 과정

이번 실습을 한 줄로 연결하면 다음과 같다.

Blocking Server
    ↓
한 client의 recv가 전체 server thread를 막음

Thread-per-Connection
    ↓
connection마다 thread 생성
    ↓
많은 connection에서 thread 비용 증가

Non-blocking
    ↓
recv/accept가 thread를 재우지 않음
    ↓
하지만 모든 FD를 직접 확인
    ↓
Busy Polling

poll()
    ↓
kernel이 여러 FD를 기다려줌
    ↓
하지만 큰 FD 배열 관리/순회

epoll
    ↓
FD를 kernel에 등록
    ↓
준비된 이벤트 중심으로 처리

Event Loop

최종적으로:

One Thread
    │
    ▼
epoll_wait()
    │
    ├── new connection
    │       ↓
    │     accept()
    │
    ├── readable client
    │       ↓
    │     recv()
    │
    └── writable client
            ↓
          send()

형태로 발전한다.


21. Java 백엔드와 연결

이 실습의 목적은 epoll API를 암기하는 것이 아니다.

Java 백엔드에서:

Linux epoll
    ↓
Java NIO Selector
    ↓
Netty EventLoop

로 연결되는 구조를 이해하는 것이 목적이다.

결국 고수준 프레임워크에서 보이는:

EventLoop
Channel
Selector
Worker

같은 개념들도 밑바닥에서는:

Socket
Non-blocking I/O
I/O Multiplexing
Event notification

문제를 해결하기 위한 추상화다.

이번 실습을 통해 epoll_wait()이 단순한 API 하나가 아니라 수많은 connection 중 준비된 I/O를 효율적으로 찾아 처리하기 위한 event-driven 구조의 핵심이라는 것을 이해할 수 있었다.


현재 학습 상태

✅ TCP Socket
✅ Blocking Server
✅ Thread-per-Connection
✅ Non-blocking I/O
✅ EAGAIN / EWOULDBLOCK
✅ Busy Polling
✅ poll()
✅ epoll
✅ Event Loop
✅ TCP Byte Stream
✅ Partial Read 개념
✅ Partial Write 개념
✅ FIN / RST 기초
✅ File Descriptor 재사용

NEXT

→ TCP Connection 종료 과정
→ half-close
→ FIN
→ CLOSE_WAIT
→ TIME_WAIT
→ Socket Buffer
→ Backlog
→ Keepalive / Nagle
→ Java NIO Selector
profile
World Class, The Beginning.

0개의 댓글