본문 바로가기
컴퓨터/Server

select 와 epoll

by 김짱쌤 2015. 3. 28.

select


I/O 통지모델의 할아버지 select

select는 싱글쓰레드로 다중 I/O를 처리하는 멀티플렉싱 통지모델의 가장 대표적인 방법이다. 해당 파일 디스크립터가 I/O를 할 준비가 되었는지 알 수 있다면, 그 파일 디스크립터가 할당받은 커널Buffer에 데이터를 복사해주기만 하면된다. 이런 목적하에 통지모델은 파일디스크립터의 상황을 파악할 수 있게 하는 기능을 할 수 있어야한다. select는 많은 파일 디스크립터들을 한꺼번에 관찰하는 FD_SET 구조체를 사용하여 빠르고 간편하게 유저에게 파일 디스크립터의 상황을 알려준다. 


FD_SET

FD_SET은 하나의 FD(파일 디스크립터)의 상태를 하나의 비트로 표현한다. 파일 디스크립터의 번호는 고유하기 때문에, 파일 디스크립터의 번호를 인덱스로하여 해당 비트가 어떤 값을 가지고 있느냐에 따라서 준비상황을 통지 받을 수 있는 것이다. 먼저 파일 디스크립터의 번호를 FD_SET에 등록하면 해당 비트의 값이 1로 저장된다. 그리고 I/O처리 준비가 되면 SELECT를 통해 해당 비트의 값을 갱신하고 프로세스는 변경된 값을 보고 커널 버퍼에 데이터를 복사하면 되는 것이다. 귀찮은 비트연산을 단순화하여 다음의 메크로를 제공한다.


FD_ZERO(fd_set* set);        //fdset을초기화
FD_SET(int fd, fd_set* set);  //fd를 set에 등록
FD_CLR(int fd, fd_set* set);  //fd를 set에서 삭제
FD_ISSET(int fd, fd_set* set);//fd가 준비되었는지 확인


select

select는 read/ write/ error 3가지 I/O에 대한 통지를 받는다. 또한 select에 timeout을 설정하여 대기시간을 설정할 수 있다. signature 는 다음과 같다.


int select( int maxfdNum, //파일 디스크립터의 관찰 범위 (0 ~ maxfdNum -1)
            fd_set *restrict readfds, //read I/O를 통지받을 FD_SET의 주소, 없으면 NULL
            fd_set *restrict writefds,//write I/O를 통지받을 FD_SET의 주소, 없으면 NULL
            fd_set *restrict errorfds,//error I/O를 통지받을 FD_SET의 주소, 없으면 NULL
            struct timeval *restrict timeout //null이면 변화가 있을 때까지 계속 Block, 
                                             //아니면 주어진 시간만큼 대기후 timeout.
           );
//반환값 : 오류 발생시 -1, timeout에 의한 반환은 0, 정상 작동일때 변경된 파일 디스크립터 개수


FD의 개수가 계속해서 바뀔 수 있으므로, 전체 파일 디스크립터의 개수를 저장하는 변수가 필요하다. 그리고 인자로 넘긴 FD_SET의 값은 변경되므로, 관찰할 FD의 목록이 변하지 않는다면 select로 넘기는 FD_SET은 복사된 값을 넘기는 것이 현명하다. 서버에서 select를 실제 사용하는 예시는 다음과 같다.


... struct timeval timeout; //타임 아웃에 사용할 timeval 변수 fd_set reads, cpy_reads; //read용 FD_SET과 그 사본을 저장할 변수 int fd_max = 0, fd_num = 0; //관찰 범위, 변경된 fd 개수 ... FD_ZERO(&reads); //reads초기화 FD_SET(server_sock, &reads); //server_socket 등록 max_fd = server_socket; //server_socket부터 관찰 범위에 추가 while(TRUE){ cpy_reads = reads; //FD_SET보존을 위한 복사 timeout.tv_sec = 5; //time out 값 설정 timeout.tv_usec = 5000; fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout); //FD_SET사본으로 select 호출 if(fd_num == -1) break; //에러 if(fd_num == 0) continue; //timeout for(int fd = 0; fd < fd_max + 1 ; ++fd) { if(FD_ISSET(fd, &cpy_reads)) //fd가 준비 완료 { if(i == server_socket) //fd가 서버인 경우 { //accpet 처리 (FD_SET으로 등록할 것) } else //fd가 클라이언트 세션인 경우 { //recv및 closesocket 처리 (FD_CLR로 삭제할 것) } } } close(server_socket); return 0;


길어보이지만 중요한 부분은 이미 모두 설명이 되었다. 클라이언트 접속/ 종료시 FD_SET을 관리하고, I/O 준비 완료된 fd는 해당 함수를 호출해주면 select를 충분히 사용할 수 있다고 생각한다.


select의 한계

select가 모든 fd를 순회하면서 recv()를 호출하는 방법보다는 훨씬 잘 구현된 멀티플렉싱인 것은 자명하다. 하지만 만들어진지 오래되다보니 그 한계점이 뚜렷하다. 동작 환경에 따라 다르지만 일반적으로 검사할 수 있는 fd개수가 최대 1024개로 제한된다. 그리고 관찰 영역에 포함되는 모든 파일 디스크립터에 대해서 순회하면서 한번씩 FD_ISSET으로 체크하는 것도 불필요한 체크인것 처럼 보인다. 실제로 상태가 변화된 fd의 목록을 넘겨준다면 더 빠르게 작동할 수 있지 않을까?

그리고 관찰 대상에 대한 정보인 FD_SET을 계속해서 select문을 통해서 운영체제에게 전달하는 것도 큰 부하를 일으킨다. 처음 FD_SET을 만들었을 때, 그리고 관찰 대상이 새로 추가되거나 삭제되는 경우에만 운영체제에게 데이터를 전달한다면 이 부하를 많이 줄일 수 있을 것이라고 생각한다.


select의 정체성

select를 사용해서 I/O의 상황을 알기 위해서는 프로세스가 커널에게 직접 상황 체크를 요청해야한다. 프로세스가 커널의 상황을 지속적으로 확인하고 그에 맞는 대응을 하는 형태로 구성되기 때문에 프로세스와 커널이 서로 동기화된 상태에서 정보를 주고 받는 형태로 볼 수 있다. 따라서 select의 통지형태를 동기형 통지방식이라 부를 수 있다. 

그리고 select 그 자체는 I/O를 담당하지 않지만, 통지하는 함수의 호출방식이 timeout에 따라 non-blocking 또는 blocking 형태가 된다. timeout을 설정하지 않으면, 관찰 대상이 변경되지 않는 이상 반환되지 않으므로 blocking 함수가 되고, timeout이 설정되면 주어진 시간이 지나면 시간이 다되었다는 정보를 반환하므로 non-blocking 함수가 된다. 

epoll



select의 대체자 epoll

epoll은 select의 단점을 보완하여 리눅스환경에서 사용할 수 있도록 만든 I/O 통지 기법이다. 전체 파일 디스크립터에 대한 반복문을 사용하지 않고, 커널에게 정보를 요청하는 함수(select 같은)를 호출할 때마다 전체 관찰 대상에 대한 정보를 넘기지도 않는다. 

계속해서 정보를 넘기지 않기 위해서 관찰 대상인 fd들의 정보를 담은 저장소를 직접 운영체제가 담당한다. 운영체제에게 관찰대상의 저장소를 만들어달라고 요청하면 그 저장소에 해당하는 파일 디스크립터(이하 epoll_fd)를 리턴해준다. 관찰 영역이 변경되면(관찰대상 추가 삭제) epoll_fd를 통해 변경을 요청할 수 있다. 그리고 관찰 대상의 변경사항을 체크할때도 epoll_fd를 통해 확인을 한다. 따라서 전체 파일디스크립터를 순회하면서 FD_ISSET을 하는 문제는 더이상 발생하지 않는다.

계속해서 정보를 넘기지 않기 위해서 관찰 대상인 fd들의 정보를 담은 저장소를 직접 운영체제가 담당한다. 운영체제에게 관찰대상의 저장소를 만들어달라고 요청하면 그 저장소에 해당하는 파일 디스크립터(이하 epoll_fd)를 리턴해준다. 관찰 영역이 변경되면(관찰대상 추가 삭제) epoll_fd를 통해 변경을 요청할 수 있다. 그리고 관찰 대상의 변경사항을 체크할때도 epoll_fd를 통해 확인을 한다. 따라서 전체 파일디스크립터를 순회하면서 FD_ISSET을 하는 문제는 더이상 발생하지 않는다.


epoll

위의 동작을 코드상에 구현하려면 3가지 요청이 필요하다. 우선 epoll_fd를 만들어 주는 epoll_create 함수. 운영체제에 의해 만들어진 fd로 다른 fd와 같이 소멸시 close를 통한 반환이 필요하다.


int epoll_create(int size); //size는 epoll_fd의 크기정보를 전달한다.
//반환 값 : 실패 시 -1, 일반적으로 epoll_fd의 값을 리턴


관찰 대상이 되는 파일 디스크립터들을 등록, 삭제하는데 사용되는 epoll_ctl


int epoll_ctl(int epoll_fd,             //epoll_fd
              int operate_enum,         //어떤 변경을 할지 결정하는 enum값
              int enroll_fd,            //등록할 fd
              struct epoll_event* event //관찰 대상의 관찰 이벤트 유형
              ); 
//반환 값 : 실패 시 -1, 성공시 0


operate_enum 값은 EPOLL_CTL_ADD(새로운 fd를 등록). EPOLL_CTL_DEL (기존 fd 삭제), EPOLL_CTL_MOD (등록된 fd의 이벤트 발생상황을 변경) 으로 구성된다. 3번째가 조금 이해가 안될지도 모르지만 차차 알게 될 것이다. 문제는 앞으로 계속 사용될 epoll_event* 구조체의 정체이다. 


struct epoll_event
{
  __uint32_t events;
  epoll_data_t data;
}

typedef epoll_data
{
   void* ptr;
   int fd;
   __uint32_t u32;
   __uint64_t u64;
}epoll_data_t;

enum Events
{
   EPOLLIN,   //수신할 데이터가 있다.
   EPOLLOUT,  //송신 가능하다.
   EPOLLPRI,  //중요한 데이터(OOB)가 발생.
   EPOLLRDHUD,//연결 종료 or Half-close 발생
   EPOLLERR,  //에러 발생
   EPOLLET,   //엣지 트리거 방식으로 설정
   EPOLLONESHOT, //한번만 이벤트 받음
}


epoll_event는 파일 디스크립터와 event, 그리고 기타 정보를 묶어서 만든 구조체이다. 처음 fd를 설정하는 EPOLL_CTL_MOD에서도 epoll_event 구조체를 사용하여 초기화하며, select와 같은 역할을 하는 epoll_wait 함수에서도 비어있는 epoll_event의 배열을 넘겨서 반환값을 받는 구조체로 사용한다. 중간에 나오는 엣지 트리거에 대해서는 이후에 설명하도록 한다.

실제로 변경된 fd들의 집합을 요청하는 함수는 epoll_wait이다. select의 select와 같은 역할을 한다. 앞서 설명한 함수들은 이 함수를 위한 포석이다. 


int epoll_wait( int epoll_fd,              //epoll_fd
                struct epoll_event* event, //event 버퍼의 주소
                int maxevents,             //버퍼에 들어갈 수 있는 구조체 최대 개수
                int timeout                //select의 timeout과 동일 단위는 1/1000
              );
//성공시 이벤트 발생한 파일 디스크립터 개수 반환, 실패시 -1 반환


두 번째 인자로 들어가는 포인터는 epoll_event 구조체의 배열을 넘긴다. 함수가 정상 반환시 배열에 이벤트가 발생한 fd와 이벤트의 종류가 묶여서 들어온다. 따라서 모든 fd에 대하여 순회하면서 체크할 필요가 없다. 이벤트가 있는 fd들이 배열에 담겨오고 그 개수를 알 수 있으니 꼭 필요한 event만 순회하면서 처리할 수 있다는 장점이 여기서 발생한다.


epoll 사용하기

epoll을 서버에서 사용한 실제 예


int epoll_fd = epoll_create(EPOLL_SIZE); struct epoll_event* events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE); struct epoll_event init_event; init_event.events = EPOLLIN; init_event.data.fd = server_socket; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_socket, &init_event); while(TRUE) { int event_count = epoll_wait(epoll_fd, events, EPOLL_SIZE, -1); if( event_count = -1 ) break; for( int i = 0 ; i < event_count; ++i ) { if(events[i].data.fd == server_socket) //서버 소켓에 이벤트 { //accept 처리 ... init_event.events = EPOLLIN; init_event.data.fd = new_client_socket; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_client_socket, &init_event); } else //이벤트가 도착한 소켓들 { //read, write, closesocket처리 } } } closesocket(server_socket); close(epoll_fd); return 0;


epoll의 정체성

epoll은 select의 단점을 많이 개선한 형태의 통지방식이다. FD_SET을 운영체제가 직접 관리하는 것으로 많은 부분이 개선되었다. 하지만 그 본질적인 동작 구조는 select와 크게 다르지 않다. 프로세스가 커널에게 지속적으로 I/O 상황을 체크하여 동기화 하는 개념은 여전히 유효하다. 따라서 epoll의 통지모델 역시 동기형 통지모델이다. 

그리고 timeout개념이 select와 동일한 방식으로 동작하기 때문에 timeout에 들어온 인자가 어떠냐에 따라 blocking이기도 하고 non-blocking이기도 하다. 따라서 epoll의 전체적인 개념모델은 select와 같다고 생각한다.