💡 Go 언어 이야기
책에서 정확히 이해되지 않던 문장
goroutine
은 일부 커널 스레드로 멀티플렉싱되어 사용되므로 C 언어에서 스레드를 매번 생성하는 것보다 효율적이다.
Multiplexing/Demultiplexing 개념은 네트워크 수업 시간에 transport layer의 기능으로써 처음 배웠다. 그때 배운 multiplexing은 host-to-host communication을 가능하도록 하는 기술이다. 하나의 서버 내에 여러 프로세스(호스트)가 동작하고 있지만, transport layer는 서버에 한 개만 존재하므로 헤더 내 구분을 위한 정보를 포함하여 encapsulate가 필요하다.
따라서 정의해보면,
그림 출처 - Computer Networking, A Top-Down Approach, 7th edition
하지만, 위에서 언급된 Go의 multiplexing 기술은 좀더 스레드에 관한 I/O Multiplexing 개념이다.
I/O Multiplexing 은 일반적으로 한 thread 에서 여러 non-blocking socket(I/O streams)을 검사하여, 활성화된(사용 가능한) socket의 데이터를 처리하는 것을 의미합니다.
즉, 적절한 thread pool을 관리하여 thread를 만드는 비용을 줄이고, 적절한 thread들로 다수의 socket들의 요청을 처리하는 것이다.
그림 출처 - https://www.programmersought.com/article/37161499412
여기서 Non-blocking socket이란 데이터를 전송하는 경우에는 전송이 완료되지 않더라도 무조건 반환하고, kernel이 전송 작업을 수행하게 된다. 그리고 데이터를 수신하는 경우에는 지속적으로 시스템 콜을 커널에게 보내 데이터가 준비되었을 때 해당 데이터를 가져오게 된다. 데이터가 buffer 내 없을 경우에는 대기하는 것이 아닌 바로 데이터가 없다는 결과값을 바로 반환받게 된다.
위 그림은 I/O multiplexing을 설명하는 그림이다.
그림 출처 - https://velog.io/@wonhee010
위 그림을 살펴보면, 앞서 설명한 I/O multiplexing은 Async-Blocking에 해당한다. 왜냐하면 select()가 수행되는 동안 blocking 되기 때문이다. 하지만 socket의 관점에서는 non-blocking socket으로 작동한다.
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;
}
epoll은 select의 단점을 보완하여 리눅스환경에서 사용할 수 있도록 만든 I/O 통지 기법이다.
전체 파일 디스크립터에 대한 반복문을 사용하지 않고, 커널에게 정보를 요청하는 함수(select 같은)를 호출할 때마다 전체 관찰 대상에 대한 정보를 넘기지도 않는다.
블로그에서 가져온 실제 코드,
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
에 대한 자세한 동작 과정을 설명하는데, 아래 세 가지 이유로 스레드와의 차이점을 언급한다.
goroutine
) < 1MB(thread)goroutine
- 오직 3개의 레지스터만이 save/restore(PC, Stack Pointer, DX)
추가로 goroutine의 스케줄러 관련된 자세한 내용을 다룬 블로그도 참고하면 도움이 된다.
🙋🏻♂️