최근 Redis나 Node.js의 Event Loop 그리고 톰캣의 NIO 등, 최신 기술인데 싱글 스레드로 작동하는 것이 신기했다. 아무리 멀티 스레드의 Context Switching 같은 오버헤드가 크다 해도 요즘 발달한 CPU 스케줄링을 잘 활용하여 멀티 스레드가 유리하다 생각했기 때문이다.
그리고 조금 찾아보니 IO Multiplexing 기술의 강력함에 대해 흥미를 가져 글을 정리하게 됐다.
간단하게 한 줄 요약하자면, IO 같은 무거운 작업은 전문가가 맡도록 하자!
멀티플렉서라고 이미 하드웨어에서 여러 채널의 정보를 하나의 장치가 처리하도록 만든 기술이 있다. 이 이름을 따서 IO Multiplexing 기술이라 부른다고 한다.
O_NONBLOCK
옵션을 줘서 구성user process는 여전히 IO 완료만 기다리며 context switching만 빈번하게 일어남
Synchronous 모델에서 여러 작업을 동시에 처리하려면 멀티쓰레드로 동작해야 한다. 하지만 IPC나 동기화 (Semaphore, Mutex 등)을 고려해야 하기 때문에 복잡함
이 때문에 위 방식보다 Multiplexing , 다중화 기법이 각광받음❗
"파일"이란 유저 공간에서 커널 공간에 진입하는 인터페이스(다리) 역할
Multiplexing (다중화) : 여러 개의 신호나 데이터 스트림을 하나의 채널에서 관리하는 하드웨어 기법에서 영감을 받음 (저비용 고효율)
Server-Client 구조에서 server에서 여러 socket을 관리하여 클라이언트가 접근할 수 있게 구성된다. socket 또한 IP와 PORT 메타 정보를 가지는 파일
IO Multiplexing 기술은 하나의 스레드가 소켓의 FD를 감시하고 관리해주고 바로 처리할 수 있는 상태만 골라준다.
Asynchronous Blocking IO 모델
정리하자면, IO Multiplexing 기법으로 여러 IO 작업을 따로 맡겨서 관리할 수 있다.
출처: https://notes.shichao.io/unp/ch6/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>
#include <time.h>
#define BUF_SIZE 100
// 간단한 TCP 서버를 구현한 C 프로그램
// @function 동시에 여러 클라이언트와 통신할 수 있고,
// 받은 데이터를 다시 클라이언트에게 돌려주는(echo) 기능 수행
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock; // fd : file descriptor
struct sockaddr_in serv_addr, clnt_addr; // server and client address
struct timeval timeout;
fd_set reads, cpy_reads; // collection of file descriptor
int fd_max, fd_num;
socklen_t addr_size; // size of client socket
int i, str_len;
char buf[BUF_SIZE];
// num of arg should 2
if (argc != 2)
{
printf("Usage : ./program_name <port>\n");
exit(1);
}
// make server socket (IPv4)
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) // if making socket fail
{
perror("error : failed socket()");
}
// 서버 주소 설정 및 소켓 바인딩
memset(&serv_addr, 0, sizeof(serv_addr)); // 메모리 초기화
serv_addr.sin_family = AF_INET; // 주소 체계 저장
serv_addr.sin_port = htons(atoi(argv[1])); // 인자로 받은 port 번호
// 소켓에 주소 할당
// sockaddr* : sockaddr_in / sockaddr_un 이든 형변환
if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
{
perror("error : failed bind()");
return 0;
}
// 연결 대기 및 큐 생성
if (listen(serv_sock, 5) == -1)
{ // 클라이언트 연결 대기 & 요청 queue에 저장
perror("error : failed listen()");
return 0;
}
// select I/O
FD_ZERO(&reads); // fd_set 구조체를 0으로 초기화
FD_SET(serv_sock, &reads); // 서버 소켓을 fd_set에 추가하여 select() 함수에서 감시할 수 있도록 설정
fd_max = serv_sock; // 현재 열려있는 fd 중 가장 큰 값 저장
// 클라이언트 연결 처리
while (1)
{
// 이전 상태 저장
cpy_reads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 50000;
// 시간 측정을 위해 현재 시간을 기록
printf("Before select: %ld seconds\n", time(NULL));
// 소켓에 입력이 있는지 감시
// select(nfds, readfds, write, err, timeout)
// @param nfds 감시할 fd 최대값 + 1
// @param &cpy_reads 감시할 fd_set
// @param &timeout 타임아웃 설정
if ((fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout)) == -1)
{
printf("fd_num : %d\n", fd_num);
perror("select() error");
break;
}
// select 함수가 끝난 후의 시간 출력
printf("After select: %ld seconds\n", time(NULL));
// bit 값이 1인 필드 없음 = 발견된 read data 없음
if (fd_num == 0)
continue;
// 발견되면 fd 다 훑음 = O(n)
for (i = 0; i < fd_max + 1; i++)
{
// select가 반환한 fd 중 입력이 있는 소켓 탐색
if (FD_ISSET(i, &cpy_reads))
{
if (i == serv_sock)
{ // data 발생한 fd 찾으면
printf("putin serv_sock\n");
addr_size = sizeof(clnt_addr);
// queue에서 연결 요청 하나씩 꺼내서 해당 client와 server socket 연결
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &addr_size);
FD_SET(clnt_sock, &reads);
if (fd_max < clnt_sock)
// loop 돌아야 하므로 fd 큰쪽으로 맞춤
fd_max = clnt_sock;
printf("connected client : %d \n", clnt_sock);
}
else
{
str_len = read(i, buf, BUF_SIZE); // 클라이언트로부터 데이터 수신
if (str_len <= 0)
{
FD_CLR(i, &reads);
close(i);
printf("close client : %d \n", i);
}
else
{
// client로echo 응답
write(i, buf, str_len);
}
}
}
}
}
close(serv_sock);
return 0;
}
이 과정은 I/O Multiplexing 방식에서, 다수의 소켓 중 하나 이상의 소켓이 읽기 가능한 상태가 되었을 때 이벤트를 받기 위한 전형적인 방식이다.
select는 다수의 소켓에 대해 어떤 것이 데이터를 읽을 준비가 되었는지 확인하는 시스템 호출이고, 데이터가 준비되면 recvfrom 호출을 통해 커널에서 애플리케이션의 버퍼로 데이터를 복사해 온다.
두 번의 블로킹이 발생하는데, 첫 번째는 select 호출에서 데이터가 준비될 때까지 대기하는 것, 두 번째는 recvfrom 호출에서 데이터를 복사하는 동안의 대기이다.
이 과정을 통해 애플리케이션은 효율적으로 네트워크 I/O 작업을 수행할 수 있게 되며, 특히 다수의 소켓에 대한 비동기 작업을 수행할 때 유용하다고 한다.