[UNIX] I/O 멀티플렉싱 (Multiplexing) 및 select
- -
I/O가 성능에 미치는 영향
I/O란 데이터 입출력을 일컫는 말이다. 네트워크 전송, 콘솔 출력, 파일 입출력 등을 모두 포함한다.
함수 실행과 같이 CPU를 사용하는 작업으로 인한 blocking은 개발자가 할 수 있는게 별로 없지만,
I/O로 인한 blocking은 CPU를 긴 시간 idle하게 두기 때문에 다른 작업을 할 수 있음에도 오랫동안 할 수 없어 매우 비효율적이다.
I/O에서 발생하는 시간은 CPU를 사용하는 시간과 대기 시간 중에 대기 시간에 속하기 때문에
I/O가 많아진다는 것은 CPU가 아무것도 하지 못하고 대기하는 시간이 길어진다는 의미고, 프로세스의 처리 속도가 같이 느려진다.
Blocking I/O
I/O 작업이 진행되는 동안 유저 프로세스가 자신의 작업을 중단한 채, I/O가 끝날 때까지 대기하는 방식
유저 프로세스가 입출력 함수를 호출(시스템콜)하면 커널에 I/O를 요청하게 되고,
커널 단의 I/O버퍼가 다 쌓이고 나서 유저 프로세스의 I/O버퍼에게 옮겨 담을 때까지 프로그램은 block 되어 다른 작업을 수행하지 못한다.
Non Blocking I/O
I/O 작업이 진행되는 동안 유저 프로세스의 작업을 중단하지 않고 다른 일을 수행할 수 있게 하는 방식유저 프로세스는 중간중간 시스템 콜을 보내서 I/O가 완료 됐는지 확인한다.
유저 프로세스가 입출력 함수를 호출하면 커널의 I/O 작업 완료 여부와는 무관하게 즉시 응답한다.
이는 커널이 시스템 콜을 받자마자 CPU 제어권을 다시 넘겨주어 다른 작업을 수행할 수 있게 한다.
다중접속 서버의 구현 방법
멀티프로세스 (Multi-process)
클라이언트가 접속 시, 자식 프로세스를 만들어 해당 클라이언트에 대한 일을 자식 프로세스에게 넘기며 다수의 프로세스를 생성하는 방식
프로세스 생성에 많은 시간과 자원이 필요하고 프로세스간의 통신(Inter-Process Communication, IPC)이 복잡하다.
멀티스레드 (Multi-thread)
클라이언트가 접속 시, 자식 프로세스 대신에 스레드를 만들어 넘기는 방식
스레드의 생성 및 컨텍스트 스위칭(Context switching)은 프로세스의 생성 및 컨텍스트 스위칭보다 빠르다.
스레드 사이에서의 데이터 교환에는 특별한 기법이 필요하지 않다.
스레드 생성 시, 메모리를 복사하지 않아 자원을 비교적 덜 사용한다.
I/O 멀티플렉싱 (Multi-plexing)
하나의 스레드가 여러 입출력 관련 파일을 관리하는 방식
커널은 감시해야 할 파일 디스크립터(이하 fd)들을 감시하고, 해당 fd에 읽기/쓰기 등의 이벤트가 발생했을 경우 유저 프로세스에게 이벤트를 보내준다. 유저 프로세스는 이벤트가 발생된 fd의 소켓(socket)이랑만 통신할 수 있게 된다.
따라서 싱글 프로세스, 싱글 스레드로 여러 클라이언트와 통신이 가능하다.
적은 물리적 자원으로 좋은 효율을 낼 수 있다.
프로세스와 스레드 사이에 컨텍스트 스위칭에 대한 비용이 없다.
fd와 소켓에 대한 더 자세한 설명은 아래의 링크 참고
클라이언트가 send/write로 data를 보내면 서버 kernel의 socket 버퍼에 data가 쌓이고, 이후 서버 프로세스에게 read 이벤트를 보내 소켓 버퍼의 데이터를 읽을 수 있음을 알린다.
이후 서버 프로세스는 recv/read를 통해 해당 data를 kernel 버퍼에서 유저 프로세스 버퍼로 복사해온다.
커널이 fd를 감시할 때 필요한 함수들(select, poll, epoll, kqueue, iocp)이 존재하고 해당 함수를 통해 I/O 멀티플렉싱 모델 구현이 진행된다.
해당 포스팅에선 select()에 대해 소개한다.
입출력 함수(read/write/recv/send)를 호출했을 경우 데이터를 커널 버퍼에서 유저 프로세스 버퍼로 옮겨오는 과정에 대한 비동기인 AIO 는 본 포스팅에서 다루지 않는다.
select()
fd_set 이라는 fd 비트 배열에 등록하고 다음과 같은 이벤트가 발생했을 경우 확인하는 방식
수신, 전송, 예외에 따라서 구분해서 모아야 한다.
#include <sys/select.h>
#include <sys/time.h>
int select(
int maxfd, // 검사 대상이 되는 fd 수
fd_set* readset, // 수신 데이터 감지 fd_set
fd_set* writeset, // 블로킹 없고 데이터 전송 가능 감지 fd_set
fd_set* exceptset, // 예외 상황 발생 여부 감지 fd_set
const struct timeval* timeout); // 무한정 blocking 되지 않도록 timeout 설정
fd에 변화가 생기지 않으면 무한정 blocking 되는데, 이를 막기 위해 마지막 인자로 넘긴 시간마다 select()가 반환하게 한다. (반환 값이 0이면 타임아웃)
이벤트 종류
- 수신 이벤트 : 소켓이 수신한 데이터를 지니고 있음
- 전송 이벤트 : 소켓이 blocking 되지 않으면서 데이터의 전송이 가능한 상황이 됨
- 예외 이벤트 : 소켓이 예외 상황이 발생함
fd 관리 및 fd_set 구조체
fd 하나당 0과 1의 상태로 표현하는 fd_set 구조체가 존재한다.
프로그래머가 직접 fd_set에 비트 값을 넣을 필요는 없고, 매크로 함수 활용하면 된다.
FD_ZERO(fd_set* fdset) // 0으로 초기화
FD_SET(int fd, fdset* fdset) // fd 등록
FD_CLR(int fd, fdset* fdset) // fd 삭제
FD_ISSET(int fd, fdset* fdset) // 해당 fd가 등록되어 있다면 참 반환
FD_ISSET은 select()의 호출 결과를 확인하는 용도로 사용된다.
select()가 이벤트 감지 시, 서버 socket이면 새로운 client 등록, 아니면 해당 socket에 대한 이벤트 처리를 해주면 된다.
select() 에코 서버 예제
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>
#include <fcntl.h>
#define BUF_SIZE 10000
void handle_event(int server_socket)
{
fd_set reads, writes, cpy_reads;
struct timeval timeout;
char buf[BUF_SIZE];
int fd_max = server_socket;
int fd_num;
FD_ZERO(&reads);
FD_SET(server_socket, &reads);
while(1)
{
cpy_reads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
if((fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1)
break;
if(fd_num == 0)
{
printf("timeout\n");
continue;
}
for(i=0; i<fd_max+1; i++)
{
if(FD_ISSET(i, &cpy_reads))
{
if(i == server_socket) // connection request!
{
int client_socket = accept(server_socket, NULL , NULL);
int flag = fcntl(client_socket, F_GETFL, 0);
fcntl(client_socket, F_SETFL, flag | O_NONBLOCK);
FD_SET(client_socket, &reads);
if(fd_max < client_socket)
fd_max = client_socket;
printf("connected client: %d \n", client_socket);
}
else // read message!
{
ssize_t res = read(i, buf, BUF_SIZE);
if(res == 0) // close request!
{
FD_CLR(i, &reads);
close(i);
printf("closed client: %d \n", i);
}
else
{
write(i, buf, res); // echo!
}
}
}
}
}
close(server_socket);
}
select() 문제점
1. select()를 호출할 때마다 인자로 매번 fd 정보들을 전달해야 하는 것
(응용 프로세스에서 운영체제로 정보를 전달하는 것은 큰 부담이다.)
2. 모든 fd를 대상으로 하는 반복문
(하나의 fd만 이벤트가 발생하더라도 모든 fd를 순회하며 FD_ISSET으로 확인해야 한다.)
3. 감지할 수 있는 이벤트의 종류가 한정적 (시그널, 파일 시스템 변경, AIO 완료 등 감지 불가)
4. fd 수가 증가함에 따라 잘 확장되지 않으며, 병목현상이 발생하여 성능이 저하될 수 있음
- 이를 해결한 여러 운영체제별 개선 함수가 존재한다. Linux의 epoll, MacOS의 kqueue, Windows의 iocp
하지만 개선된 모델들은 운영체제별로 호환되지 않는 단점이 있다.
- 서버의 접속자 수가 많지 않고 다양한 운영체제에서 운영이 가능해야 하면 select()를 사용하는 것이 좋다.
『윤성우의 열혈 TCP/IP 소켓 프로그래밍』 책을 읽고 정리한 글입니다.
검색을 허용하지 않고, 수익을 창출하지 않습니다.
'Operating System > UNIX' 카테고리의 다른 글
[UNIX] 파이프 (Pipe) 제대로 사용하기 (0) | 2023.12.04 |
---|---|
[UNIX] 멀티 프로세스 (Multi Process) 프로그래밍 (1) | 2023.12.04 |
[UNIX] 입출력 및 리다이렉션 (I/O Redirection) (0) | 2023.11.20 |
[UNIX] TCP/IP 소켓 (Socket) 프로그래밍 (0) | 2023.11.16 |
[UNIX] 파일 디스크립터 (File Descriptor) (0) | 2023.11.15 |
소중한 공감 감사합니다