goroutine의 multiplexing 이해 과정

shinychan95·2021년 6월 26일
1

go-with-Go

목록 보기
3/4
post-thumbnail

💡 Go 언어 이야기 책에서 정확히 이해되지 않던 문장

goroutine은 일부 커널 스레드로 멀티플렉싱되어 사용되므로 C 언어에서 스레드를 매번 생성하는 것보다 효율적이다.

 

참고 자료

네트워크 내 multiplexing 개념

Multiplexing/Demultiplexing 개념은 네트워크 수업 시간에 transport layer의 기능으로써 처음 배웠다. 그때 배운 multiplexing은 host-to-host communication을 가능하도록 하는 기술이다. 하나의 서버 내에 여러 프로세스(호스트)가 동작하고 있지만, transport layer는 서버에 한 개만 존재하므로 헤더 내 구분을 위한 정보를 포함하여 encapsulate가 필요하다.

따라서 정의해보면,

  • multiplexing - 각 호스트마다 다른 socket을 가지고 있는 상황에서 헤더 정보가 포함된 데이터를 segments로 encapsulating한 뒤, segments를 적절한 network layer에 전달하는 것을 말한다.
  • demultiplexing - transport layer의 segments를 특정 socket에 전달하는 것을 의미한다.

factorio thumbnail 그림 출처 - Computer Networking, A Top-Down Approach, 7th edition

하지만, 위에서 언급된 Go의 multiplexing 기술은 좀더 스레드에 관한 I/O Multiplexing 개념이다.

 

I/O Multiplexing

I/O Multiplexing 은 일반적으로 한 thread 에서 여러 non-blocking socket(I/O streams)을 검사하여, 활성화된(사용 가능한) socket의 데이터를 처리하는 것을 의미합니다.

  • 하지만, 한 thread에서 처리할 수 있는 socket은 한정되어 있다. 왜냐하면 하나의 스레드에 부하가 집중되기 때문이다. 또한 멀티코어 프로세서 환경이라면 나머지 코어를 낭비하게 되는 것이다.
  • 그렇다고 연결마다 thread를 만드는 것도 call stack과 context switching 비용 등의 치명적인 단점이 존재한다.

즉, 적절한 thread pool을 관리하여 thread를 만드는 비용을 줄이고, 적절한 thread들로 다수의 socket들의 요청을 처리하는 것이다.

factorio thumbnail 그림 출처 - https://www.programmersought.com/article/37161499412

여기서 Non-blocking socket이란 데이터를 전송하는 경우에는 전송이 완료되지 않더라도 무조건 반환하고, kernel이 전송 작업을 수행하게 된다. 그리고 데이터를 수신하는 경우에는 지속적으로 시스템 콜을 커널에게 보내 데이터가 준비되었을 때 해당 데이터를 가져오게 된다. 데이터가 buffer 내 없을 경우에는 대기하는 것이 아닌 바로 데이터가 없다는 결과값을 바로 반환받게 된다.

factorio thumbnail

위 그림은 I/O multiplexing을 설명하는 그림이다.

 

동기, 비동기, Blocking, Non-blocking

factorio thumbnail 그림 출처 - https://velog.io/@wonhee010

위 그림을 살펴보면, 앞서 설명한 I/O multiplexing은 Async-Blocking에 해당한다. 왜냐하면 select()가 수행되는 동안 blocking 되기 때문이다. 하지만 socket의 관점에서는 non-blocking socket으로 작동한다.

 

select and poll in Unix

select는 싱글 스레드로 다중 I/O를 처리하는 multiplexing 통지 모델의 대표적인 방법이다.

아래 코드는 네트워크 수업 시간에 multiplexing 관련하여 select를 통해 구현한 예시이다.

int main(int argc, char *argv[]) {
    // Declare variables.
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    struct timeval timeout;

    // Declare fd_set variables.
    fd_set reads, cpy_reads;

    socklen_t adr_sz;
    int fd_max, str_len, fd_num, i;
    char buf[BUF_SIZE];
    // If there was no argument when executed, exit the program.
    if (argc != 2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    // Make socket for TCP connection.
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    // Bind the port.
    if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");

    // Listen the connection setup request from clients.
    if (listen(serv_sock, 10) == -1)
        error_handling("listen() error");

    // Initialize the fd_set reads to have zero bits for all file descriptors
    FD_ZERO(&reads);

    // Add serv_sock into reads fd_set variable
    FD_SET(serv_sock, &reads);

    fd_max = serv_sock;
    printf("fd_max: %d\n", fd_max);

    while(1) {
        cpy_reads = reads;
        timeout.tv_sec = 15;

        // The return values of select()
        //            -1: Exception occurred
        //             0: Timeout
        // Larger than 1: The number of file descriptors where an event occurred
        if ((fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1) {
            printf("An exception occurred!\n");
            break;
        }

        if (fd_num == 0) {
            printf("Time out!\n");
            break;
        }

        for (i = 0; i < fd_max + 1; i++) {
            // Check whether i th fd had an event
            if (FD_ISSET(i, &cpy_reads)) {

                // When an event occurred within serv_sock
                // Connection request from a client!
                if (i == serv_sock) {
                    adr_sz = sizeof(clnt_adr);
                    clnt_sock= accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);

                    // Add clnt_sock to the proper fd_set variable
                    FD_SET(clnt_sock, &reads);

                    // if clnt_sock is greater than fd_max, make fd_max as clnt_sock
                    if (fd_max < clnt_sock) fd_max = clnt_sock;
                    
                    printf("Connected Client: %d \n", clnt_sock);
                }

                // When an event occured within a socket connected to a client.
                else {
                    str_len = read(i, buf, BUF_SIZE);

                    // Close Request
                    if (str_len == 0) {
                        // remove file descriptor i from fd_set
                        FD_CLR(i, &reads);

                        // close the i th file descriptor
                        close(i);

                        printf("Closed Client: %d\n", i);
                    }
                    // Get a file from the client.
                    else {
                        printf("File name to be saved: %s\n", buf);

                        // Get file data from the client, and save the file
                        write_file(i, buf);
                        printf("%s is received and saved\n", buf);
                    }
                }
            }
        }
    }

    close(serv_sock);
    return 0;
}
  • FD_SET은 하나의 FD(파일 디스크립터)의 상태를 하나의 비트로 표현한다. 파일 디스크립터의 번호는 고유하기 때문에, 파일 디스크립터의 번호를 인덱스로하여 해당 비트가 어떤 값을 가지고 있느냐에 따라서 준비상황을 통지 받을 수 있는 것이다.
  • select는 Read/Write/Error 3가지 I/O에 대한 통지를 받는다. 또한 select에 timeout을 설정하여 대기시간을 설정할 수 있다.

 

epoll in Linux

epoll은 select의 단점을 보완하여 리눅스환경에서 사용할 수 있도록 만든 I/O 통지 기법이다.

전체 파일 디스크립터에 대한 반복문을 사용하지 않고, 커널에게 정보를 요청하는 함수(select 같은)를 호출할 때마다 전체 관찰 대상에 대한 정보를 넘기지도 않는다.

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

블로그에서 가져온 실제 코드,

int main(int argc, char * argv[]) {
  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 & close socket 처리 
      }
    }
  }

  closesocket(server_socket);
  close(epoll_fd);
  return 0;

}

 

질문에 대한 답

goroutine은 일부 커널 스레드로 멀티플렉싱되어 사용되므로 C 언어에서 스레드를 매번 생성하는 것보다 효율적이다.

→ 스레드 풀을 관리하면서, 해당 스레드들에 goroutine이 multiplexing을 이용해 할당되는 방식이다.

고루틴은 어떻게 동작하는가?

위 글에서는 goroutine에 대한 자세한 동작 과정을 설명하는데, 아래 세 가지 이유로 스레드와의 차이점을 언급한다.

  • 메모리 소비 → 2KB(goroutine) < 1MB(thread)
  • 설치와 철거 비용
  • Context switching 비용
    • thread - 모든 레지스터들을 save/restore(16개의 범용 레지스터, PC, SP, segment 레지스터, 16개의 XMM 레지스터, FP coprocessor state, 16개의 AVX 레지스터, 모든 MSR들 등)
    • goroutine - 오직 3개의 레지스터만이 save/restore(PC, Stack Pointer, DX)

 

추가로 goroutine의 스케줄러 관련된 자세한 내용을 다룬 블로그도 참고하면 도움이 된다.

Goroutines vs Threads

🙋🏻‍♂️

profile
개발자로 일하는 김찬영입니다.

0개의 댓글