싱글 스레드의 강력함, IO Multiplexing

DongHyun Kim·2024년 9월 25일
0
post-custom-banner

📚 글을 쓰게 된 이유

최근 Redis나 Node.js의 Event Loop 그리고 톰캣의 NIO 등, 최신 기술인데 싱글 스레드로 작동하는 것이 신기했다. 아무리 멀티 스레드의 Context Switching 같은 오버헤드가 크다 해도 요즘 발달한 CPU 스케줄링을 잘 활용하여 멀티 스레드가 유리하다 생각했기 때문이다.
그리고 조금 찾아보니 IO Multiplexing 기술의 강력함에 대해 흥미를 가져 글을 정리하게 됐다.

간단하게 한 줄 요약하자면, IO 같은 무거운 작업은 전문가가 맡도록 하자!

하드웨어에서 Multiplexer (Mux), Demultiplexer (Demux)

멀티플렉서라고 이미 하드웨어에서 여러 채널의 정보를 하나의 장치가 처리하도록 만든 기술이 있다. 이 이름을 따서 IO Multiplexing 기술이라 부른다고 한다.

  • 아날로그 또는 디지털 신호를 하나의 채널에서 수신해서 여러 출력선 중 하나로 전달

  • 장점
    • 비용 절감 하나의 연결마다 연결선이 필요한 것이 아닌 여러 연결을 하나의 채널에서 처리할 수 있으므로 비용이 효율적이다
  • 이를 응용하면 여러 신호를 관리하는 Bus 구조가 있다

Socket 이란? - “Everything Is a File”

  • Linux/Unix 에선 Socket도 하나의 FD 인터페이스로 관리된다고 하여 나온 말
  • Low Level File Handling 기반으로 Socket 기반 데이터 송수신이 가능함
  • 즉, I/O 작업은 Server 내에서 읽기/쓰기 뿐만 아니라 서버-클라이언트 간 네트워크 통신에도 적용되는 개념이다.

Linux 계열의 Multiplexing 기법

1. 기본 개념

  • IO 작업은 user space에서 직접 수행할 수 없기 때문에 user process가 kernel에 IO 작업을 “요청” 하고 “응답”을 받는 구조
  • 응답을 어떤 순서로 받는지 (Synchronous , Asynchronous)
    어떤 타이밍에 받는지 (Blocking , Non-blocking) 에 따라 여러 모델로 분류

Synchronous, 동기

  • 모든 IO 요청, 응답이 순서가 있다 → 작업의 순서를 보장
  • 일련의 Pipeline을 준수하는 구조에서 효율적

Asynchronous, 비동기

  • kernel에 IO 작업을 요청해두고 다른 작업 처리 가능 → 작업의 순서 보장하지 않음
  • 작업 완료를 kernel space에서 통보해줌
  • 각 작업들이 독립적, 작업 별 지연이 큰 경우 효율적

Blocking, 블로킹

  • 요청한 작업이 끝나는 것을 기다리다가 응답 결과를 반환받음
  • 작업을 기다림

Non-Blocking, 논 블로킹

  • 작업 요청 이후 결과를 필요할 때 전달받음
  • 작업을 기다리지 않음
  • 중간중간 상태 확인 가능 (Polling)

Sync, Async, Block, Non-Block 혼동

  • 어떤 작업을 기다리느라 Blocking된 동안 다른 작업이 끼어들 수 있으면 Asynchronous

IO 모델 종류

  • 위에 IO 멀티플렉싱이 Asnyc - Blocking 으로 소개되지만, 구현 방식에 따라 구분이 달라질 수 있다.

Synchronous Blocking IO

  • 가장 흔한 IO 모델, user space에서 process가 kernel에게 IO를 요청 (system call)한 후, kernel의 작업 결과 반환까지 중단된 채 대기
    • 이때 user space의 process는 CPU를 점유하지 않은 채 대기

Synchronous Non-Blocking IO

  • Non-block 동작을 위해 socket 생성 시 O_NONBLOCK 옵션을 줘서 구성
  • socket으로 IO system call을 하게 되면 block되는 것이 아닌 즉시 결과를 반환받음
    • 읽을 데이터가 없으면 -1
    • 일반적으로 EAGAIN , EWOULDBLOCK
    • Synchronous 동작이기에 기다리지는 않지만, 처리할 소켓 상태를 계속 물어보는 Busy Waiting (폴링 방식)

  • user process는 여전히 IO 완료만 기다리며 context switching만 빈번하게 일어남

  • Synchronous 모델에서 여러 작업을 동시에 처리하려면 멀티쓰레드로 동작해야 한다. 하지만 IPC나 동기화 (Semaphore, Mutex 등)을 고려해야 하기 때문에 복잡함

  • 이 때문에 위 방식보다 Multiplexing , 다중화 기법이 각광받음❗

IO Multiplexing (멀티플렉싱, 다중화)

  • "파일"이란 유저 공간에서 커널 공간에 진입하는 인터페이스(다리) 역할

  • Multiplexing (다중화) : 여러 개의 신호나 데이터 스트림을 하나의 채널에서 관리하는 하드웨어 기법에서 영감을 받음 (저비용 고효율)

    • 즉, IO Multiplexing이란 소프트웨어에서 “한 프로세스가 여러 파일(File)을 관리” 하는 기법
  • Server-Client 구조에서 server에서 여러 socket을 관리하여 클라이언트가 접근할 수 있게 구성된다. socket 또한 IP와 PORT 메타 정보를 가지는 파일

    • 이러한 socket을 기존엔 클라이언트가 요청을 하면 서버가 쓰레드를 할당해서 처리 -> 클라이언트 요청마다 쓰레드를 할당하는 비효율적인 쓰레드 사용,
  • IO Multiplexing 기술은 하나의 스레드가 소켓의 FD를 감시하고 관리해주고 바로 처리할 수 있는 상태만 골라준다.

  • 어떤 상태로 대기하냐에 따라 select, poll, epoll (linux), kqueue (bsd), iocp (windows) 기법이 있다

Asynchronous Blocking IO 모델

  • Block? Non-Block 아닌가?
    • 처음 Read()의 응답이 즉각적으로 미완료 상태를 반환하여 Non-blocking socket 동작을 보여주지만, kernel이 미완료 응답을 반환하면 user process에서 FD가 준비가 될 때까지 대기하므로 Application 레벨에서 Blocking

정리하자면, IO Multiplexing 기법으로 여러 IO 작업을 따로 맡겨서 관리할 수 있다.

출처: https://notes.shichao.io/unp/ch6/

select()

  • fd_set을 통해 최대 1024개의 FD를 관리
  • 특정 FD가 사용 가능한 지 확인하기 위해 모든 FD를 루프돌아야함
  • select 함수가 동기인지 비동기인지 검사하는 간단한 TCP 서버 코드
    #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;
    }

pselect()

  • timeval 구조체로 timeout을 관리하던 것과 달리 timespec 구조체로 구현되어 나노초까지 컨트롤
  • select와 달리 sigmask 인자 추가로 signal에 의한 비정상 동작을 방지

poll()

  • 관리 가능한 최대 FD 수가 1024에서 무한 개로 검사
  • select는 모든 FD를 루프도는 반면, poll은 실제 FD 개수만큼 루프를 돔

ppoll()

  • select - pselect 와 마찬가지로 timeout과 signal 처리 로직 개선

epoll (linux)

  • FD 관리 무한 , 앞에와 달리 FD 상태를 kernel이 관리하여 상태가 바뀐 것을 통지 해줌
    • fd_set을 검사하기 위해 루프를 돌 필요가 없어졌다!
    • 변화가 감지된 FD의 수가 아닌 FD 목록을 반환
  • Level-Triggered
    • 이벤트 발생한 이후 계속 버퍼에 저장해서 보관
  • Edge-Triggered
    • 이벤트 발생했을 때만 통보

Asynchronous Non-Blocking IO (AIO)

  • 굉장히 효율적인 통신을 구현할 수 있을 것 같다!! → 실제에서 잘 안보이는 이유
    • IO 작업을 위한 thread 유지, 관리 비용 + 확장성 떨어짐 + 완료 통지 방식에 대한 문제가 있다고 한다.

결론: 싱글 스레드임에도 처리 속도가 빠를 수 있는 이유

  • 비동기 IO
  • 프로그램이 파일에게 열도록 요청 → 커널이 엑세스 권한 부여 → 글로벌 파일 테이블에 항목 생성 → 소프트웨어에 해당 파일의 위치를 제공
  • 시스템에서 열려 있는 모든 파일에 대해 하나 이상의 File Descriptor가 존재하는데, 이를 관리해주는 전용 채널을 만드는 것이다.

네트워크 IO Multiplexing model

  • 이 과정은 I/O Multiplexing 방식에서, 다수의 소켓 중 하나 이상의 소켓이 읽기 가능한 상태가 되었을 때 이벤트를 받기 위한 전형적인 방식이다.
    select는 다수의 소켓에 대해 어떤 것이 데이터를 읽을 준비가 되었는지 확인하는 시스템 호출이고, 데이터가 준비되면 recvfrom 호출을 통해 커널에서 애플리케이션의 버퍼로 데이터를 복사해 온다.

  • 두 번의 블로킹이 발생하는데, 첫 번째는 select 호출에서 데이터가 준비될 때까지 대기하는 것, 두 번째는 recvfrom 호출에서 데이터를 복사하는 동안의 대기이다.

  • 이 과정을 통해 애플리케이션은 효율적으로 네트워크 I/O 작업을 수행할 수 있게 되며, 특히 다수의 소켓에 대한 비동기 작업을 수행할 때 유용하다고 한다.


  • 출처

https://ko.wikipedia.org/wiki/멀티플렉서#비용_절감

https://blog.naver.com/n_cloudplatform/222189669084

profile
do programming yourself
post-custom-banner

0개의 댓글