서버에서 클라이언트 연결을 받고, 응답을 받는 과정을 동기적으로 수행하면, 커널레벨에서 연결은 더 할 수 있는데, 단순 대기로 인해 리소스 낭비 발생. 그래서, 스레드를 이용하여, 연결 해서 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 모델'입니다.
이유를 개념과 동작 구조 면에서 정확히 정리해 드릴게요.
Non-blocking과 Blocking을 가르는 기준은 "소켓 입출력 함수(recv, send, accept)를 불렀을 때 스레드가 멈추는가(Sleep) 아니면 바로 리턴하는가"입니다.
recv(client_fd, ...)를 부르고 데이터가 올 때까지 I/O 블로킹(Waiting/Sleep 상태)에 빠집니다.fcntl로 O_NONBLOCK 설정)으로 바꿉니다.recv()가 멈추지 않고 즉시 -1을 반환하며 EWOULDBLOCK / EAGAIN 에러를 냅니다."Main 스레드(서버 전체) 입장에서 멈추지 않는다"는 점 때문에 헷갈리실 수 있습니다.
recv()할 때 서버 전체(Main 스레드)가 멈춤 ➔ 다른 클라이언트 못 받음recv()할 때 해당 워커 스레드만 멈춤 ➔ Main 스레드는 안 멈추고 계속 accept() 수행즉, 서버 전체의 응답성이 좋아진 것(Concurrency / 동시성 확보)이지, 소켓 I/O 자체가 Non-blocking으로 바뀐 것이 아닙니다.
| 구분 | Thread-per-Connection | Non-blocking + Polling | Non-blocking + Multiplexing (epoll) |
|---|---|---|---|
| 소켓 상태 | Blocking | Non-blocking | Non-blocking |
| I/O 대기 방식 | 스레드가 recv()에서 잠듦 | 루프 돌며 recv() 계속 찔러봄 | epoll_wait()로 이벤트 발생 소켓만 감지 |
| 스레드 구조 | 클라이언트당 스레드 1개 필요 | 스레드 1개로 가능 (단, CPU 100% 낭비) | 스레드 1개(또는 소수)로 수만 개 소켓 처리 |
| 한계 / 단점 | 클라이언트 수가 많아지면 스레드 생성 오버헤드 & 컨텍스트 스위칭 비용으로 서버 마비 (C10K 문제) | Busy Waiting으로 CPU 낭비 심함 | 코드가 복잡함 |
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)을 상세히 정리해 드릴게요.
recv() 함수의 파라미터 역할ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
| 파라미터 | 타입 | 역할 및 의미 |
|---|---|---|
fd | int | 데이터를 읽어올 대상 클라이언트 소켓의 파일 디스크립터(FD) |
buffer | void* | 커널 수신 버퍼에서 퍼온 데이터를 저장할 유저 메모리 공간(C 언어 배열) |
sizeof(buffer) - 1 | size_t | 한 번에 최대 퍼올 수 있는 바이트 수 |
(- 1을 한 이유는 나중에 C 문자열의 끝을 알리는 널 문장인 \0을 안전하게 넣을 공간을 1바이트 남겨두기 위함) |
| 0 | int | 수신 옵션 플래그 (Flags)
보통 0을 넣으면 기본 동작으로 수신 버퍼 데이터를 읽고 버퍼에서 제거함 (필요에 따라 MSG_PEEK 등을 사용) |
n의 조건별 상황 분석recv() 함수는 커널 수신 버퍼에서 유저 버퍼로 실제 복사해온 바이트 수(n)를 리턴합니다.
if (n > 0) : 정상 데이터 수신 성공n 바이트만큼 데이터를 가져온 상태입니다.buffer[n] = '\0'; ➔ 읽어온 데이터 바로 뒤에 널 문자를 붙여 완벽한 C 스타일 문자열 형태를 만듭니다.printf(...) ➔ 터미널에 수신된 메시지를 출력합니다.send(...) ➔ 수신 성공에 대한 응답 메시지("echo from poll server")를 상대방 소켓으로 다시 전송합니다.else if (n == 0) : 정상적인 연결 종료 (EOF / Graceful Shutdown)close()를 호출하여 TCP 연결을 정상적으로 끊은 상태입니다.recv()에 0을 리턴합니다.close(fd); ➔ 서버 측에서도 해당 클라이언트 소켓 자원을 해제합니다.fds[i].fd = -1; ➔ pollfd 감시 배열의 해당 슬롯을 빈자리(-1)로 되돌려 놓아 차후 새로운 클라이언트가 재사용할 수 있게 합니다.else (n < 0 / n == -1) : 읽기 오류 발생 및 비동기 예외if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("recv");
close(fd);
fds[i].fd = -1;
}
errno == EAGAIN 또는 EWOULDBLOCK인 경우 (정상 비동기 흐름):if 조건에 걸리지 않고 아무것도 하지 않은 채 그냥 지나갑니다 (다음 poll() 이벤트 때 다시 읽기 위함).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() 호출 후 잠듦