[network]B8 — Non-blocking I/O

ttttom1·어제

서버에서 클라이언트 연결을 받고, 응답을 받는 과정을 동기적으로 수행하면, 커널레벨에서 연결은 더 할 수 있는데, 단순 대기로 인해 리소스 낭비 발생. 그래서, 스레드를 이용하여, 연결 해서 fd 받으면, 스레드별로 recv() 하도록 했다. 그런데! 연결되었다고, 계속 recv()를 하는 것도 아니다. 그래서 연결 10000개를 했다고, 10000개에서 계속 데이터를 받는것이 아니다. 10000개 중 데이터가 온 곳만 받으면 된다. 그래서, non-blocking IO, epoll이 나온다.

그래서 아이디어를 바꾼다.
데이터가 없으면 thread를 재우지 말고, "지금 데이터 없음"이라고 바로 돌려보내자.

그게 Non-blocking I/O다.

근데 thread-per-connection 도 엄밀히 이야기하면 nonblockingg아님?? (코드는 깃헙에 있음, 대충 서버에서 accept()하면 스레드별로 recv() 실행하도록하는것 =>여기서 sleep(waiting 상태임)

아니요, 엄밀히 이야기하면 Thread-per-Connection 방식은 Non-blocking이 아니라 대표적인 'Blocking I/O 모델'입니다.

이유를 개념과 동작 구조 면에서 정확히 정리해 드릴게요.


1. 왜 Non-blocking이 아니고 Blocking인가요?

Non-blocking과 Blocking을 가르는 기준은 "소켓 입출력 함수(recv, send, accept)를 불렀을 때 스레드가 멈추는가(Sleep) 아니면 바로 리턴하는가"입니다.

  • Thread-per-Connection 방식:
  • 워커 스레드는 recv(client_fd, ...)를 부르고 데이터가 올 때까지 I/O 블로킹(Waiting/Sleep 상태)에 빠집니다.
  • 소켓 자체의 옵션이 여전히 Blocking Socket이기 때문에, 데이터가 없으면 함수 호출이 리턴되지 않고 스레드가 멈춰 섭니다.
  • 진짜 Non-blocking 방식:
  • 소켓을 Non-blocking 옵션(fcntl로 O_NONBLOCK 설정)으로 바꿉니다.
  • 데이터가 없으면 recv()가 멈추지 않고 즉시 -1을 반환하며 EWOULDBLOCK / EAGAIN 에러를 냅니다.

2. 그렇다면 왜 Non-blocking처럼 느껴졌을까요?

"Main 스레드(서버 전체) 입장에서 멈추지 않는다"는 점 때문에 헷갈리실 수 있습니다.

  • 단일 스레드 Blocking 서버: recv()할 때 서버 전체(Main 스레드)가 멈춤 ➔ 다른 클라이언트 못 받음
  • Thread-per-Connection 서버: recv()할 때 해당 워커 스레드만 멈춤 ➔ Main 스레드는 안 멈추고 계속 accept() 수행

즉, 서버 전체의 응답성이 좋아진 것(Concurrency / 동시성 확보)이지, 소켓 I/O 자체가 Non-blocking으로 바뀐 것이 아닙니다.


3. 한눈에 보는 비교 (Blocking vs Non-blocking vs I/O Multiplexing)

구분Thread-per-ConnectionNon-blocking + PollingNon-blocking + Multiplexing (epoll)
소켓 상태BlockingNon-blockingNon-blocking
I/O 대기 방식스레드가 recv()에서 잠듦루프 돌며 recv() 계속 찔러봄epoll_wait()로 이벤트 발생 소켓만 감지
스레드 구조클라이언트당 스레드 1개 필요스레드 1개로 가능 (단, CPU 100% 낭비)스레드 1개(또는 소수)로 수만 개 소켓 처리
한계 / 단점클라이언트 수가 많아지면 스레드 생성 오버헤드 & 컨텍스트 스위칭 비용으로 서버 마비 (C10K 문제)Busy Waiting으로 CPU 낭비 심함코드가 복잡함

요약

  • Thread-per-Connection은 "블로킹되는 소켓 I/O를 별도의 스레드로 격리하여 Main 스레드가 멈추지 않게 만든 Multi-threaded Blocking I/O 모델"입니다.
  • 진정한 Non-blocking I/O는 스레드를 무한정 늘리지 않고, 소켓 하나하나를 Non-blocking으로 설정한 뒤 epoll / kqueue 같은 I/O 멀티플렉싱을 조합하여 수만 개의 연결을 한두 개 스레드로 처리하는 단계를 말합니다.

tcp 발전 흐름

Blocking recv

데이터 없으면
thread sleep

    ↓ 문제

connection마다 thread 필요

Thread-per-Connection

10,000 connections
→ 10,000 threads

    ↓ 문제

thread 비용 큼

Non-blocking

recv()
→ 데이터 없으면 EAGAIN
→ thread 안 잠듦

    ↓ 문제

10,000 fd를
계속 직접 검사?

select / poll

OS:
"준비된 fd 알려줄게"

    ↓

epoll

많은 fd를 더 효율적으로 관리


client Fd 이벤트 처리

// 6. Client FD 이벤트 처리
        for (int i = 1;i < MAX_CLIENTS + 1;i++) {

            int fd = fds[i].fd;
            if (fd == -1) {
                continue;
            }


            // 읽을 수 있는 상태
            if (fds[i].revents & POLLIN) {
                //소켓의 버퍼와는 다른 버퍼, 가져오는  것
                char buffer[BUF_SIZE];

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


                if (n > 0) {

                    buffer[n] = '\0';

                    printf(
                        "[fd=%d] received: %s\n",
                        fd,
                        buffer
                    );

                    const char *response = "echo from poll server";

제시해주신 코드의 recv() 함수 파라미터와 반환값 n의 조건별 상황(n > 0, n == 0, else)을 상세히 정리해 드릴게요.


1. recv() 함수의 파라미터 역할

ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
파라미터타입역할 및 의미
fdint데이터를 읽어올 대상 클라이언트 소켓의 파일 디스크립터(FD)
buffervoid*커널 수신 버퍼에서 퍼온 데이터를 저장할 유저 메모리 공간(C 언어 배열)
sizeof(buffer) - 1size_t한 번에 최대 퍼올 수 있는 바이트 수


(- 1을 한 이유는 나중에 C 문자열의 끝을 알리는 널 문장인 \0을 안전하게 넣을 공간을 1바이트 남겨두기 위함) |
| 0 | int | 수신 옵션 플래그 (Flags)


보통 0을 넣으면 기본 동작으로 수신 버퍼 데이터를 읽고 버퍼에서 제거함 (필요에 따라 MSG_PEEK 등을 사용) |


2. 반환값 n의 조건별 상황 분석

recv() 함수는 커널 수신 버퍼에서 유저 버퍼로 실제 복사해온 바이트 수(n)를 리턴합니다.

① if (n > 0) : 정상 데이터 수신 성공

  • 상황: 상대방이 데이터를 성공적으로 보냈고, 커널 수신 버퍼에서 n 바이트만큼 데이터를 가져온 상태입니다.
  • 동작:
  1. buffer[n] = '\0'; ➔ 읽어온 데이터 바로 뒤에 널 문자를 붙여 완벽한 C 스타일 문자열 형태를 만듭니다.
  2. printf(...) ➔ 터미널에 수신된 메시지를 출력합니다.
  3. send(...) ➔ 수신 성공에 대한 응답 메시지("echo from poll server")를 상대방 소켓으로 다시 전송합니다.

② else if (n == 0) : 정상적인 연결 종료 (EOF / Graceful Shutdown)

  • 상황: 상대방(클라이언트)이 프로그램을 종료하거나 close()를 호출하여 TCP 연결을 정상적으로 끊은 상태입니다.
  • 왜 0을 반환할까요?
  • TCP 통신에서 상대방이 연결을 닫으면 서버 측으로 FIN 패킷이 날아옵니다.
  • 커널은 이를 수신하고 "이 소켓에는 더 이상 들어올 데이터가 없다(End of File)"는 표식으로 recv()에 0을 리턴합니다.
  • 동작:
  1. 접속 종료 로그 출력.
  2. close(fd); ➔ 서버 측에서도 해당 클라이언트 소켓 자원을 해제합니다.
  3. fds[i].fd = -1; ➔ pollfd 감시 배열의 해당 슬롯을 빈자리(-1)로 되돌려 놓아 차후 새로운 클라이언트가 재사용할 수 있게 합니다.

③ else (n < 0 / n == -1) : 읽기 오류 발생 및 비동기 예외

  • 상황: 데이터를 읽는 도중 시스템 오류가 발생했거나 비동기(Non-blocking) 상태에서 특수 조건에 도달한 상태입니다.
  • 내부 분기 분석:
if (errno != EAGAIN && errno != EWOULDBLOCK) {
    perror("recv");
    close(fd);
    fds[i].fd = -1;
}
  • errno == EAGAIN 또는 EWOULDBLOCK인 경우 (정상 비동기 흐름):
  • Non-blocking 소켓에서 커널 수신 버퍼가 비어있을 때 발생하는 일반적인 비동기 신호입니다.
  • if 조건에 걸리지 않고 아무것도 하지 않은 채 그냥 지나갑니다 (다음 poll() 이벤트 때 다시 읽기 위함).
  • 그 외 진짜 에러인 경우 (네트워크 끊김, 비정상 FD 등):
  • perror("recv");로 발생한 에러를 출력하고, 소켓을 닫고(close) 감시 배열 슬롯을 초기화(fds[i].fd = -1)하여 끊어진 소켓 자원을 정리합니다.

요약

  • n > 0: "데이터 잘 들어옴! (n 바이트 읽음)"
  • n == 0: "상대방이 나 정상 종료할게 하고 나감 (close 수신)"
  • n < 0: "읽기 실패 에러 또는 읽을 버퍼가 비어있음"

[ 스레드 1개 ]
│
▼
poll(fds, ...) 호출 ──> [ 스레드가 완전히 잠듦 (Sleep / Blocked) ] ──> CPU 사용량 0%
│
├─ (하드웨어/네트워크 이벤트 발생!)
▼
스레드 깨어남 (Wake up) ──> fds 배열 순회 (POLLIN이 켜진 fd만 recv 처리)
│
└───────────────────> 다시 poll() 호출 후 잠듦

profile
World Class, The Beginning.

0개의 댓글