본문 바로가기

개인공부/네트워크

epoll vs 멀티스레딩

epoll 이란?

 

  • epoll은 다수의 파일 디스크립터(소켓 포함)를 효율적으로 모니터링하기 위한 Linux 커널의 이벤트 감시 인터페이스입니다.
  • 멀티스레드 방식과 비교해 스레드 오버헤드 없이 많은 클라이언트를 처리할 수 있습니다.

1.2 epoll의 주요 특징

  • 이벤트 기반: 파일 디스크립터에서 특정 이벤트가 발생했을 때만 처리.
  • 비동기 처리: 블로킹 없이 대규모 연결을 효율적으로 처리.
  • 높은 성능: select나 poll과 달리 O(1) 시간 복잡도로 이벤트를 처리.

2. epoll의 주요 함수

 

epoll_create1()

  • epoll 인스턴스를 생성합니다.
  • 반환값: epoll 파일 디스크립터

int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
    perror("epoll_create1 failed");
    exit(EXIT_FAILURE);
}

 

epoll_ctl()

  • 파일 디스크립터(소켓 등)를 epoll 인스턴스에 등록/수정/삭제.
  • 주요 작업:
    • EPOLL_CTL_ADD: 새로운 파일 디스크립터 등록.
    • EPOLL_CTL_MOD: 기존 파일 디스크립터 수정.
    • EPOLL_CTL_DEL: 파일 디스크립터 제거.

struct epoll_event event;
event.events = EPOLLIN; // 읽기 이벤트 감지
event.data.fd = socket_fd; // 파일 디스크립터 설정
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket_fd, &event) == -1) {
    perror("epoll_ctl failed");
    exit(EXIT_FAILURE);
}

 

epoll_wait()

  • 등록된 파일 디스크립터에서 이벤트를 대기.
  • 반환값: 발생한 이벤트 수.

struct epoll_event events[MAX_EVENTS];
int n_ready = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (n_ready == -1) {
    perror("epoll_wait failed");
    exit(EXIT_FAILURE);
}

 

3. epoll 기반 서버의 기본 설계

  1. epoll 인스턴스 생성
    • 서버가 시작될 때 epoll 인스턴스를 생성.
  2. 서버 소켓 등록
    • 서버 소켓을 epoll에 등록하여 클라이언트 연결 이벤트를 감시.
  3. 클라이언트 소켓 등록
    • 클라이언트 연결이 생성되면 해당 소켓을 epoll에 등록.
  4. 이벤트 처리 루프
    • epoll_wait로 이벤트를 감시하고, 이벤트가 발생하면 처리.

 

 

 

1. 멀티스레드 vs epoll 비교

1.1 멀티스레드 방식

  • 작동 방식:
    • 각 클라이언트 연결에 대해 하나의 스레드를 생성하여 독립적으로 처리.
    • 각 스레드는 블로킹 방식으로 클라이언트와 통신(recv, send 등).
  • 장점:
    1. 구현이 직관적:
      • 클라이언트마다 별도의 흐름(스레드)이 존재하므로, 코드가 직관적이고 이해하기 쉬움.
    2. 독립성:
      • 클라이언트별로 독립적인 작업을 처리하므로, 하나의 클라이언트가 느려도 다른 클라이언트에 영향을 주지 않음.
  • 단점:
    1. 스레드 오버헤드:
      • 스레드 생성/종료 시 CPU와 메모리 오버헤드가 발생.
      • 클라이언트 수가 많아지면 스레드 스케줄링으로 인해 성능 저하(컨텍스트 스위칭 비용 증가).
    2. 메모리 사용량:
      • 각 스레드는 스택 메모리를 점유(기본 8MB).
      • 대규모 연결(수천~수만 개)에서는 메모리 부족 가능.

1.2 epoll 방식

  • 작동 방식:
    • 비동기 이벤트 기반 I/O 처리 방식.
    • 단일 스레드(또는 소수의 워커 스레드)가 다수의 클라이언트 소켓을 감시(epoll_wait)하고, 데이터가 준비된 소켓만 처리.
  • 장점:
    1. 효율적인 스케일링:
      • 수천 개 이상의 클라이언트를 효율적으로 처리 가능.
      • 소켓이 비활성화 상태일 때(예: 클라이언트가 요청을 기다릴 때) CPU 리소스를 거의 사용하지 않음.
    2. 낮은 메모리 사용량:
      • 단일 스레드 또는 소수의 워커 스레드만 사용.
    3. 컨텍스트 스위칭 감소:
      • 스레드 수가 적으므로 CPU 스케줄링 비용이 줄어듦.
  • 단점:
    1. 복잡한 구현:
      • 이벤트 기반 프로그래밍은 상태 관리가 필요하므로 코드가 복잡해짐.
    2. 단일 스레드 병목:
      • 단일 스레드가 모든 소켓을 처리하는 경우, 소켓 작업량이 많으면 병목이 발생.

2. 어떤 상황에서 epoll이 더 빠른가?

  1. 다수의 클라이언트를 처리해야 하는 경우:
    • 1000개 이상의 클라이언트 연결이 있는 경우, epoll이 멀티스레드보다 훨씬 효율적입니다.
    • 멀티스레드 방식은 클라이언트마다 스레드를 생성하므로, 스레드 생성 오버헤드 컨텍스트 스위칭 비용으로 인해 성능이 급격히 저하됩니다.
  2. I/O 대기 시간이 긴 경우:
    • 클라이언트가 데이터를 전송하지 않거나, 서버가 클라이언트의 응답을 기다려야 하는 경우.
    • epoll은 대기 중인 소켓을 감시하는 데 거의 리소스를 사용하지 않으므로 유리합니다.
  3. 리소스 제한이 있는 경우:
    • 시스템에서 사용할 수 있는 스레드와 메모리가 제한적인 경우, epoll은 단일 스레드로 많은 연결을 처리할 수 있어 적합합니다.

3. 어떤 상황에서 멀티스레드가 더 나은가?

  1. 클라이언트 수가 적은 경우:
    • 100~200개 이하의 클라이언트라면, 멀티스레드 방식도 충분히 빠르고 구현이 간단합니다.
  2. CPU 연산이 많은 경우:
    • 클라이언트별로 많은 연산(예: 파일 처리, 복잡한 계산)이 필요한 경우, 멀티스레드 방식이 더 적합합니다.
    • 각 스레드가 병렬적으로 작업을 수행하므로 CPU를 더 효율적으로 사용할 수 있습니다.
  3. 구현 단순성이 중요한 경우:
    • 멀티스레드는 직관적이며 디버깅이 쉬움.
    • 이벤트 기반 코드는 상태 관리가 필요하므로 디버깅이 어렵습니다.

4. 결정: 멀티스레드 유지 vs epoll 전환

유지 (멀티스레드)

  • 클라이언트 수가 적고(수백 개 이하), 구현의 단순성이 더 중요하다면 멀티스레드 방식을 유지합니다.

전환 (epoll)

  • 서버가 대규모 연결(수천~수만 개의 클라이언트)을 처리해야 한다면, epoll로 전환하는 것이 적합합니다.
  • 또한, 리소스 효율성이 중요한 서버에서는 epoll이 더 적합합니다.



 

해서 현재 나의 채팅서버를 멀티스레딩방식으로 클라이언트들을 관리하는 아키텍쳐에서 epoll 로 바꾸려고 한다.

현재 내 코드:
client_manager.c
#include <sched.h>         // CPU affinity 설정
#include <unistd.h>        // POSIX API
#include <netinet/in.h>    // sockaddr_in
#include <netinet/tcp.h>   // TCP_NODELAY
#include <stdio.h>         // 입출력
#include <stdlib.h>        // 동적 메모리 할당
#include <string.h>        // 문자열 처리
#include <sys/stat.h>      // 파일 상태
#include <sys/sendfile.h>  // sendfile 함수
#include <fcntl.h>         // 파일 제어
#include "server.h"        // 서버 정의

void handle_file_upload(int client_socket, const char *filename);
void handle_file_download(int client_socket, const char *filename);

// 클라이언트 요청을 처리하는 스레드 함수
void *handle_client(void *arg) {
     thread_args_t *args = (thread_args_t *)arg; // 전달된 인자를 구조체로 캐스팅
     client_t *client = args->client;           // 클라이언트 정보 추출
     int core_number = args->core_number;       // 코어 번호 추출
char buffer[BUFFER_SIZE];
free(args); // 동적으로 할당된 구조체 메모리 해제

printf("New client connected : %d\n", client->socket);


// Low Latency 최적화 (TCP_NODELAY) 설정
int flag = 1;
setsockopt(client->socket,IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(int)); 

// Low Latency 최적화 CPU 고정 ( 버전 오류로 주석처리 )
// cpu_set_t cpuset;
// CPU_ZERO(&cpuset);
// CPU_SET(core_number, &cpuset); // 동적으로 지정된 코어 번호
// pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);



while(1) {
// 데이터 수신
int bytes_received = recv(client->socket, buffer, sizeof(buffer), 0);
// 수신 실패 또는 클라이언트종료
if(bytes_received <= 0){
printf("Client disconnected : %d\n", client->socket);
close(client->socket);
free(client);
break;
}

buffer[bytes_received] = '\0'; //문자열처리

//클라이언트 요청 확인, 처리하는
if(strncmp(buffer, "UPLOAD:", 7) == 0) {
char *filename = buffer + 7;
handle_file_upload(client->socket, filename);
} else if(strncmp(buffer, "DOWNLOAD:" , 9) == 0){
char *filename = buffer + 9;
handle_file_download(client->socket, filename);
} else { //일반메시지
// 메시지를 다른 클라이언트들에게 브로드캐스트
broadcast_message(buffer, client->socket);
}
}

return NULL;
}



// 클라이언트 업로드 파일 처리
void handle_file_upload(int client_socket, const char *filename) {
char filepath[256];
snprintf(filepath,sizeof(filepath), "uploads/%s", filename); // 업로드 디렉토리에 파일저장 경로 설정


FILE *file = fopen(filepath, "wb");
if(!file) {
perror("File open failed");
return;
}

char buffer[FILE_BUFFER_SIZE];
int bytes_received;
while((bytes_received = recv(client_socket, buffer, sizeof(buffer), 0)) > 0) {
fwrite(buffer, 1, bytes_received, file);

}

fclose(file);
printf("File '%s' uploaded successfully\n", filename);
}


// 클라이언트 다운로드 파일 처리

void handle_file_download(int client_socket, const char *filename) {
char filepath[256];
snprintf(filepath,sizeof(filepath), "uploads/%s", filename); // 업로드 디렉토리에 파일저장 경로 설정


int file_fd = open(filepath, O_RDONLY); 
if(!file_fd < 0) {
perror("File open failed");
return;
}

struct stat file_stat;
fstat(file_fd, &file_stat); //파일 크기 확인

// 파일 크기를 클라이언트에게 전송
send(client_socket, &file_stat.st_size, sizeof(file_stat.st_size), 0);

//파일 내용 전송
sendfile(client_socket, file_fd, NULL, file_stat.st_size);

close(file_fd);
printf("File '%s' downloaded successfully\n", filename);
}

server.c


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include "server.h"

//서버 시작 함수
void start_server();
//전역 변수 정의
client_t *clients[MAX_CLIENTS] = {0};
pthread_mutex_t clients_mutex = PTHREAD_MUTEX_INITIALIZER;

core_number = 1;

int main() {
start_server();
return 0;
}

void start_server(){
int server_socket, new_socket;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);

// 1. 서버 소켓 생성
server_socket = socket(AF_INET, SOCK_STREAM, 0);
if(server_socket < 0){
perror("Socket creation failed");
exit(EXIT_FAILURE);
}

// 2. 서버 주소 설정
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY; //모든 네트워크 인터페이스에서 연결 허용

// 3. 소켓 바인딩
if(bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("Bind failed");
close(server_socket);
exit(EXIT_FAILURE);
}


// 클라이언트 연결 대기
if(listen(server_socket, 10) < 0) { // 최대 10개 대기열
perror("Listen failed");
close(server_socket);
exit(EXIT_FAILURE);
}

printf("Server is running on port %d\n", PORT);

//뮤텍스로 코어 경쟁상태 방지
pthread_mutex_t core_mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&core_mutex);
if (core_number < 4) {
    core_number++;
} else {
    core_number = 1;
}
pthread_mutex_unlock(&core_mutex);

while(1) {
new_socket = accept(server_socket, (struct sockaddr* )&client_addr, &client_len);
if(new_socket < 0) {
perror("Accept failed");
continue; //다음 요청 처리
}

// 새로운 클라이언트 처리 : 메모리 할당, 스레드 생성
client_t *new_client = (client_t*)malloc(sizeof(client_t));
new_client->socket = new_socket;
new_client->address = client_addr;

pthread_t tid; //스레드 ID 
thread_args_t *args = (thread_args_t *)malloc(sizeof(thread_args_t));
args->client = new_client;
args->core_number = core_number; // 동적으로 코어 번호 전달

pthread_create(&tid, NULL, handle_client, (void *)args);// 클라이언트 요청 처리 스레드 생성 (앞으로 여기서 계속 처리한다.

// 스레드가 종료되면 리소스 자동으로 해제.
pthread_detach(tid);
}
}


보다시피 현재 아키텍쳐는 클라이언트가 들어오면 새로운 스레드 안에 클라이언트 처리 프로세스를 할당하고 클라이언트가 접속을 종료할때까지 그 스레드를 살려둔다. 이후 클라이언트가 접속을 종료하면 detach 로 해제해버리지만

문제는 이런 구조는 1만명 10만명 까지 인원수가 늘어났을때는 스레드를 할당할때 오버헤드 (스레드의 정보의 포인터를 갖고있는 작은 블럭) 하고 다른코어로 돌아가면서 다른 스레드에 접근을 할때 컨텍스트 스위칭으로 설명했다 시피 문제가 많아진다.

해서 epoll 아키텍쳐로 바꾼 코드는 아래와 같다.

 

 

 

 

이런식이다.

결과는?


 



서버가 돌아간다. 자. 클라이언트 두명을 추가해보자.





두명이 접속했다.







채팅 기능도 잘 된다.


'개인공부 > 네트워크' 카테고리의 다른 글

epoll 의 EAGAIN  (0) 2025.01.23
epoll 의 Edge-Triggered 방식  (0) 2025.01.23
DPDK 패킷 처리  (0) 2025.01.22
Low Latency 최적화  (0) 2025.01.22
소켓프로그래밍#11 표준 입출력 함수와 시스템 함수  (1) 2024.10.29