들어가며
안녕하세요. MSE2(Messaging Server Engineering 2)에서 인증 도메인을 개발하고 있는 김종민입니다. LINE에서는 서버 개발에 비동기 서버사이드 프레임워크인 Armeria를 적극 사용하고 있습니다. Armeria와 같은 비동기 서버를 처음 사용해 서버 애플리케이션을 개발하다 보면 간혹 서버의 응답 속도가 느려지거나 서비스가 응답 불능 상태가 되는 문제를 겪게 됩니다. 이는 비동기 서버의 이벤트 루프를 블록하기 때문에 발생하는 문제일 가능성이 높습니다.
사실 위와 같은 문제는 Armeria뿐만 아니라 이벤트 루프를 바탕으로 하는 라이브러리나 프레임워크를 이용해 애플리케이션을 개발하는 경우에는 언제 어디서나 발생할 수 있는 문제입니다. 저도 처음 위와 같은 문제를 겪고 이벤트 루프가 무엇인지 열심히 찾아가며 공부했던 기억이 있는데요. 그 과정이 쉽지만은 않았습니다. 그래서 제가 공부했던 내용을 바탕으로 이벤트 루프가 무엇이고 블록하는 것이 왜 문제가 되는 것인지, 저와 비슷한 문제를 겪고 있는 엔지니어들에게 조금이나마 도움이 되었으면 하는 바람으로 이 글을 작성하게 되었습니다. 글은 세 편에 걸쳐 아래와 같은 순서로 진행할 예정입니다.
- 비동기 서버에서 이벤트 루프를 블록하면 안 되는 이유 1부 - 멀티플렉싱 기반의 다중 접속 서버로 가기까지
- 비동기 서버에서 이벤트 루프를 블록하면 안 되는 이유 2부 - Java NIO와 멀티플렉싱 기반의 다중 접속 서버
- 비동기 서버에서 이벤트 루프를 블록하면 안 되는 이유 3부 - Reactor 패턴과 이벤트 루프
1부는 다음과 같은 순서로 진행합니다.
이벤트 루프란
이벤트 루프는 동시성(concurrency)을 제공하기 위한 프로그래밍 모델 중 하나로, 특정 이벤트가 발생할 때까지 대기하다가 이벤트가 발생하면 디스패치해 처리하는 방식으로 작동합니다. 이벤트 루프는 여러 라이브러리에서 구현되어 중심적인 역할을 담당하고 있습니다. 중요하고 핵심적인 역할을 맡고 있는 만큼 이벤트 루프를 잘 이해하지 못하고 사용하면 앞서 말씀드린 것처럼 정상적인 서비스를 수행할 수 없는 문제가 발생하기도 합니다.
이벤트 루프를 블록하면 안 되는 이유를 다루는 이번 시리즈에서는 이벤트 루프 스레드를 블록하면 어떤 문제가 발생하는지 알아보는 것을 시작으로 이벤트 루프를 왜 블록하면 안 되는지 이해할 수 있도록 이벤트 루프가 어떤 구조로 만들어졌는지 소개하겠습니다.
Armeria의 이벤트 루프 스레드를 블록하면 발생하는 문제
Armeria는 gRPC, Thrift, Kotlin, Retrofit, Reactive Streams, Spring Boot 등 여러 기술을 쉽게 활용해 마이크로 서비스를 만들 수 있도록 해주는 프레임워크입니다. Armeria 역시 내부에서 이벤트 루프를 사용하고 있습니다. Armeria의 이벤트 루프를 블록하면 어떤 문제가 발생할까요? 간단한 예시를 통해서 알아보겠습니다.
Armeria는 들어오는 요청이나 처리 후 내보내는 응답에 공통 로직을 적용할 수 있도록 decorator를 제공하고 있습니다. Decorator를 이용하면 들어온 요청이 인증된 클라이언트로부터 온 것인지 확인하는 인증(authentication) 로직도 다음과 같이 손쉽게 구현할 수 있습니다(예시 코드는 Kotlin으로 작성했습니다).
AuthenticationDecorator - blocking operation in decorator
class AuthenticationDecorator(
private val delegate: HttpService
) : SimpleDecoratingHttpService(delegate) {
private val authClient = WebClient.of("https://api.auth.linecorp.com:8080/")
override fun serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse {
if (!authenticate(req)) {
return HttpResponse.of(HttpStatus.UNAUTHORIZED)
}
// 인증된 요청만 서비스 로직을 수행할 수 있다.
return delegate.serve(ctx, req)
}
private fun authenticate(req: HttpRequest): Boolean {
val result = authClient.get("...").aggregate().join() // 주의! Blocking operation 발생.
return result.status().isSuccess
}
}
Decorator에서는 블로킹 I/O를 통해 외부 API를 호출해서 해당 요청이 올바른지 검증하는 로직을 수행하고 있습니다. 위 코드는 아무런 문제 없어 보입니다. 하지만 애플리케이션을 실행해서 확인해 보면 몇 건의 요청 처리 후 API 응답 속도가 느려지거나 응답 불능 상태가 되는 것을 확인할 수 있습니다.
그 이유는 이벤트 루프 스레드가 위 블로킹 I/O를 이용한 외부 API 호출 때문에 블록 상태가 되면서 이후 API 요청들을 제대로 처리할 수 없게 되고, 이에 따라 정상적인 서비스를 제공할 수 없게 되기 때문입니다. 아래 그림에서 'armeria-eventloop-nio-4-1' 스레드가 중간중간 블록되는 것을 확인할 수 있습니다.
Armeria 내부에서도 블로킹 메서드를 사용하면 다음과 같은 로그를 통해 '서버의 성능이 크게 저하되고 복구할 수 없는 데드락(deadlock) 상태가 발생할 수도 있음'을 경고합니다.
14:30:49.626 [armeria-eventloop-nio-4-1] WARN com.linecorp.armeria.common.util.EventLoopCheckingFuture - Calling a blocking method on CompletableFuture from an event loop or non-blocking thread. You should never do this as this will usually result in significantly reduced performance of the server, generally crippling its ability to handle high load, or even result in deadlock which cannot be recovered from. Use ServiceRequestContext.blockingExecutor to run this logic instead or switch to using asynchronous methods like thenApply. If you really believe it is fine to block the event loop like this, you can disable this log message by specifying the -Dcom.linecorp.armeria.reportBlockedEventLoop=false JVM option.
java.lang.IllegalStateException: Blocking event loop, don't do this.
at com.linecorp.armeria.common.util.EventLoopCheckingFuture.maybeLogIfOnEventLoop(EventLoopCheckingFuture.java:101)
at com.linecorp.armeria.common.util.EventLoopCheckingFuture.join(EventLoopCheckingFuture.java:86)
at com.exam.armeria.AuthenticationDecorator.authenticate(AuthenticationDecorator.kt:25)
at com.exam.armeria.AuthenticationDecorator.serve(AuthenticationDecorator.kt:17)
....
....
Armeria의 이벤트 루프는 무엇이고 어떤 역할을 하기에 블록 상태가 되면 위와 같은 심각한 문제가 발생하는 것일까요? 이에 대한 답을 얻기 위해 우리는 먼저 이벤트 루프가 어떻게 작동하는지 그리고 어떤 역할을 하는지 알아야 합니다. 그래서 우선 멀티플렉싱이 무엇인지부터 알아보겠습니다. Armeria의 이벤트 루프는 I/O 멀티플렉싱을 기반으로 하기 때문입니다.
멀티플렉싱 기반의 다중 접속 서버로 가기까지
기술은 한정된 자원을 이용해서 더 높은 성능을 낼 수 있는 방향으로 발전해 왔습니다. 소켓(Socket)을 통한 네트워크 I/O 작업도 '더 효율적으로 자원을 사용'하도록 변화해 왔습니다.
소켓을 통한 네트워크 I/O
소켓은 네트워크에서 서버와 클라이언트, 두 개의 프로세스가 특정 포트를 통해 양방향 통신이 가능하도록 만들어 주는 추상화된 장치입니다. 메모리의 사용자 공간에 존재하는 프로세스(서버, 클라이언트)는 커널 공간에 생성된 소켓을 통해 데이터를 송수신할 수 있습니다.
소켓은 지역(로컬) IP 주소와 포트 번호, 상대방의 IP 주소와 포트 번호, 그리고 수신 버퍼와 송신 버퍼가 존재합니다. 서버와 클라이언트 소켓이 서로 연결된 후 데이터가 들어오면 수신 버퍼에 수신 데이터가 쓰이고, 반대로 데이터를 내보낼 때는 송신 버퍼에 데이터가 쓰입니다.
C언어를 이용해 작성한 에코 서버와 클라이언트
C언어를 이용해 Linux에서 간단하게 소켓을 이용한 에코(echo) 서버와 클라이언트를 만들어 보겠습니다. 한 줄 한 줄의 코드를 전부 이해하기보다는 주석을 참고해 서버와 클라이언트에서 어떤 순서로 소켓이 생성되고 통신이 이뤄지는지에 중점을 두고 살펴보겠습니다.
참고. 멀티플렉싱 방식으로의 발전이 어떻게 진행되어 왔는지 C언어에서 제공하는 여러 시스템 콜을 기반으로 설명드리고자 Java가 아닌 C언어를 사용해 예시 코드를 작성했습니다. Java를 이용한 방식은 2부에서 공유드립니다.
Linux에는 'Everything is a File'이라는 말이 있습니다. Linux에서는 소켓도 하나의 파일(file), 더 정확히는 파일 디스크립터(file descriptor)로 생성해 관리합니다. 그러므로 저수준(low-level) 파일 입출력 함수를 기반으로 소켓 기반 데이터 송수신이 가능합니다.
참고. 파일 디스크립터(file descriptor)는 다음과 같은 특징이 있습니다.
- - 운영체제가 만든 파일을 구분하기 위한 일종의 숫자이다.
- - 저수준 파일 입출력 함수는 입출력을 목적으로 파일 디스크립터를 요구한다.
- - 저수준 파일 입출력 함수에 소켓의 파일 디스크립터를 전달하면, 소켓을 대상으로 입출력을 진행한다.
서버 - echo_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[]) {
// 파일 디스크립터를 위한 변수
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, i;
struct sockaddr_in serv_adr;
struct sockaddr_in clnt_adr;
socklen_t clnt_adr_sz;
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
// 1. 소켓 하나를 생성한다.
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error");
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]));
// 2. 소켓에 IP와 포트 번호를 할당한다.
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 3. 서버 소켓(리스닝 소켓)을 통해 클라이언트의 접속 요청을 대기한다.
// 5개의 수신 대기열(큐)을 생성한다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
clnt_adr_sz=sizeof(clnt_adr);
for (i=0; i<5; i++) {
// 4. 클라이언트 접속 요청을 수락한다(클라이언트와 연결된 새로운 소켓이 생성된다).
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if (clnt_sock == -1)
error_handling("accept() error");
else
printf("Connected client %d \n", i+1);
// 5. 클라이언트와 연결된 소켓을 통해 데이터를 송수신한다.
while((str_len=read(clnt_sock, message, BUF_SIZE)) != 0)
write(clnt_sock, message, str_len);
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
클라이언트 - echo_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[]) {
// 파일 디스크립터를 위한 변수
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
if (argc != 3) {
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
// 1. 소켓 하나를 생성한다.
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
// 2. 소켓을 이용해 서버의 서버 소켓(리스닝 소켓)에 연결을 요청한다.
if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");
else
puts("Connected...........");
while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if (!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
break;
// 3. 연결된 소켓을 통해 서버로부터 데이터를 송수신한다.
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE-1);
message[str_len] = 0;
printf("Message from server: %s", message);
}
close(sock);
return 0;
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
컴파일 후 결과를 확인해 보면 다음과 같습니다. 클라이언트에서 서버로 전달한 메시지가 응답으로 잘 되돌아오는 것을 확인할 수 있습니다.
고찰
코드로 먼저 살펴봤던 서버와 클라이언트 소켓 생성과 연결 과정은 다음과 같습니다.
서버 |
|
클라이언트 |
|
앞서 살펴본 예시 코드는 하나의 클라이언트가 연결할 때는 문제가 없지만 다수의 클라이언트가 연결하는 경우에는 문제가 발생합니다. 처음 연결한 클라이언트가 연결을 종료하기 전까지는 다른 클라이언트의 연결은 listen 큐에 들어가 대기해야 하기 때문입니다. 따라서 다수의 요청을 처리할 수 없다는 문제가 있습니다.
이 문제를 해결하려면 둘 이상의 클라이언트가 동시에 접속해 서버의 서비스를 제공받을 수 있도록 '다중 접속 서버'를 구현해야 하고, 다중 접속 서버는 다음과 같이 여러 가지 방법으로 구현할 수 있습니다.
- 멀티프로세싱(multiprocessing) 기반 서버: '프로세스를 다수 생성'하는 방식으로 서비스를 제공한다.
- 멀티스레딩(multithreading) 기반 서버: '스레드를 다수 생성'하는 방식으로 서비스를 제공한다.
- 멀티플렉싱(multiplexing) 기반 서버: '입출력 대상을 묶어서 관리'하는 방식으로 서비스를 제공한다.
멀티프로세싱 기반 다중 접속 서버
멀티프로세싱 기반의 다중 접속 서버는 다수의 프로세스를 생성하는 방식으로 서비스를 제공합니다.
- 부모 프로세스는 리스닝 소켓으로 accept 함수를 호출해서 연결 요청을 수락한다.
- 이때 얻는 소켓의 파일 디스크립터(클라이언트와 연결된 연결 소켓)를 자식 프로세스를 생성해 넘겨준다.
- 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.
핵심은 하나의 연결이 생성될 때마다 프로세스를 생성해서 해당 클라이언트에 대해 서비스를 제공하는 것입니다.
echo_multi_process_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 30
void error_handling(char *message);
void read_childproc(int sig);
int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
pid_t pid;
struct sigaction act;
socklen_t adr_sz;
int str_len, state;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
// 1. 소켓 하나를 생성한다.
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]));
// 2. 소켓에 IP와 포트 번호를 할당한다.
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 3. 생성한 소켓을 서버 소켓(리스닝 소켓)으로 등록한다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
while(1) {
adr_sz = sizeof(clnt_adr);
// 4. 부모 프로세스는 리스닝 소켓으로 accept 함수를 호출해서 연결 요청을 수락한다.
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1)
continue;
else
puts("new client connected...");
// 5. 이때 얻는 소켓의 파일 디스크립터(클라이언트와 연결된 연결 소켓)를 자식 프로세스를 생성해 넘겨준다.
pid = fork();
if (pid == -1) {
close(clnt_sock);
continue;
}
if (pid == 0) {
close(serv_sock);
// 6. 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);
close(clnt_sock);
puts("client disconnected...");
return 0;
}
else
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void read_childproc(int sig) {
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("removed proc id: %d \n", pid);
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
결과를 확인해 보면 별문제 없이 다수의 클라이언트가 서버에 연결됐고 전달한 메시지가 응답으로 잘 되돌아오는 것을 확인할 수 있습니다. 이와 같이 각 클라이언트 요청마다 별도의 프로세스를 생성함으로써 다중 접속 문제를 해결했습니다.
고찰
장점 |
|
단점 |
|
위에서 열거한 단점들은 각 클라이언트의 요청마다 프로세스가 아닌 스레드를 생성함으로써 해결할 수 있습니다. 그럼 다음으로 멀티프로세싱 기반의 다중 접속 서버의 단점을 개선할 수 있는 멀티스레딩 기반의 다중 접속 서버에 대해 알아보겠습니다.
멀티스레딩 기반의 다중 접속 서버
멀티스레딩 기반의 다중 접속 서버는 다수의 스레드를 생성하는 방식으로 서비스를 제공합니다.
- 메인 스레드는 리스닝 소켓으로 accept 함수를 호출해서 연결 요청을 수락한다.
- 이때 얻는 소켓의 파일 디스크립터(클라이언트와 연결된 연결 소켓)를 별도 워커 스레드를 생성해 넘겨준다.
- 워커 스레드는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.
핵심은 연결이 하나 생성될 때마다 프로세스가 아닌 스레드를 생성해서 해당 클라이언트에게 서비스를 제공하는 것입니다.
echo_multi_thread_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>
#define BUF_SIZE 30
void * handle_clnt(void * arg);
void error_handling(char * msg);
int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
pthread_t t_id;
socklen_t adr_sz;
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
// 1. 소켓 하나를 생성한다.
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]));
// 2. 소켓에 IP와 포트 번호를 할당한다.
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 3. 생성한 socket을 server socket(listen socket)으로 등록한다.
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
while(1) {
adr_sz = sizeof(clnt_adr);
// 4. 메인 스레드는 리스닝 소켓으로 accept 함수를 호출해서 연결 요청을 수락한다.
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1)
continue;
puts("new client connected...");
// 5. 클라이언트와 연결된 소켓의 파일 디스크립터를 워커 스레드를 생성해 넘겨준다.
pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
pthread_detach(t_id);
}
close(serv_sock);
return 0;
}
void * handle_clnt(void * arg) {
int clnt_sock=*((int*)arg);
int str_len=0, i;
char buf[BUF_SIZE];
// 6. 워커 스레드는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);
close(clnt_sock);
return NULL;
}
void error_handling(char * msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
각 클라이언트 요청마다 별도 스레드를 생성함으로써 문제를 해결했습니다.
고찰
장점 |
|
단점 |
|
각 클라이언트 요청마다 별도의 스레드를 생성함으로써 프로세스를 생성하던 방법보다 리소스 비용을 줄일 수 있었고, 스레드가 서로 공유하는 메모리를 가질 수 있는 환경이 됐습니다. 여기서 더 나아가 I/O 멀티플렉싱(multiplexing) 기법을 사용한다면, 각 클라이언트마다 별도 스레드를 이용하는 게 아니라, 하나의 스레드에서 다수의 클라이언트에 연결된 소켓(파일 디스크립터)을 관리하면서 소켓에 이벤트(read/write)가 발생할 때만 해당 이벤트를 처리하도록 구현함으로써 더 적은 리소스를 사용하도록 개선할 수 있습니다.
멀티플렉싱 기반의 다중 접속 서버
'입출력 다중화'란 하나의 프로세스 혹은 스레드에서 입력과 출력을 모두 다룰 수 있는 기술을 말합니다. 커널(kernel)에서는 하나의 스레드가 여러 개의 소켓(파일)을 핸들링할 수 있는 select, poll, epoll, io_uring과 같은 시스템 콜(system call)을 제공하고 있습니다. 그럼에도 지금까지 하나의 프로세스나 스레드에서 하나의 클라이언트에 대한 입출력만 처리할 수 있었던 이유는, 입출력 함수가 블록되면 입출력 데이터가 준비될 때까지 무한정 블록돼 여러 클라이언트의 입출력을 처리할 수 없었기 때문입니다.
I/O 멀티플렉싱 기법을 사용하면, 비록 입출력 다중화에서도 입출력 함수 자체는 여전히 블록하는 것으로 작동하지만, 입출력 함수를 호출하기 전에 어떤 파일에서 입출력이 준비됐는지를 확인할 수 있습니다.
이와 같은 블로킹 I/O가 무엇인지 이해하기 위해 먼저 짚고 넘어가야 할 두 가지 사항이 있습니다.
- 애플리케이션에서 I/O 작업을 할 때, 스레드는 데이터가 사용할 수 있는 상태로 준비될 때까지 대기합니다. 예를 들어 소켓을 통해 read를 수행하는 경우 데이터가 네트워크를 통해 도착할 때까지 기다립니다. 패킷이 네트워크를 통해 도착하면 커널 내 버퍼에 복사됩니다.
- 커널 내 버퍼에 복사된 데이터를 애플리케이션에서 사용하기 위해서는 커널 공간(kernel space)에서 사용자 공간(user space)으로 복사해야 합니다. 애플리케이션은 사용자 모드에서 사용자 공간에만 접근할 수 있기 때문입니다.
블로킹 I/O 모델
read
함수는 커널 공간에 데이터가 도착하길 기다리는 것부터 시작하기 때문에, 프로세스(스레드)가 하나의 소켓에 대해 read
함수를 호출하면, 데이터가 네트워크를 통해 커널 공간에 도착해 사용자 공간의 프로세스 버퍼에 복사될 때까지 시스템 콜이 반환되지 않습니다.
I/O 멀티플렉싱 모델
멀티플렉싱 모델에서는 select
함수를 호출해서 여러 개의 소켓 중 read
함수 호출이 가능한 소켓이 생길 때까지 대기합니다. select
의 결과로 read
함수를 호출할 수 있는 소켓의 목록이 반환되면, 해당 소켓들에 대해 read
함수를 호출합니다.
블로킹 I/O 모델은 하나의 스레드에서 하나의 소켓에 대해 read
함수를 호출해 데이터가 커널 공간에 도착했는지 확인하고 현재 읽을 수 있는 데이터가 없는 경우 블록돼 대기했다면, 멀티플렉싱 I/O 모델은 여러 소켓을 동시에 확인하며 그중 하나 이상의 사용 가능한 소켓이 준비될 때까지 대기합니다.
select
select
방식은 이벤트(입력, 출력, 에러)별로 감시할 파일들을 fd_set
이라는 파일 상태 테이블(파일 디스크립터 비트 배열)에 등록하고, 등록된 파일(파일 디스크립터)에 이벤트가 발생하면 fd_set
을 확인하는 방식으로 작동합니다.
예를 들어 위와 같이 6개의 파일을 다뤄야 할 때, 6개의 파일에 대해 입출력 데이터가 준비될 때까지 이벤트를 기다리는 파일 상태 테이블을 준비합니다. 그 후 6개의 파일 중 입출력이 준비된 파일에 대한 이벤트가 발생하면 이벤트가 발생한 파일 디스크립터의 수를 반환합니다. 이후 이벤트가 준비된 파일에 대해 입출력을 수행하는데, 이미 데이터가 준비된 파일에 대해 입출력을 수행하기 때문에 무한정 대기해야 하는 블록이 발생하지 않을 것이라는 게 보장됩니다.
참고.
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)
- -
nfds
: 검사 대상 파일 디스크립터의 수- -
readfs
: 읽기 이벤트를 검사할 파일 디스크립터 목록- -
writefds
: 쓰기 이벤트를 검사할 파일 디스크립터 목록- -
exceptfds
: 예외 이벤트를 검사할 파일 디스크립터 목록- -
timeout
: 이벤트를 기다릴 시간 제한- - 반환 값: 이벤트가 발생한 파일의 갯수
여기서 반환값이 이벤트가 발생한 파일의 디스크립터 목록이 아닌 파일의 갯수라는 것을 주의해야 한다.
echo_select_server.c
#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>
#define BUF_SIZE 100
void error_handling(char *buf);
int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
struct timeval timeout;
// 파일 상태 테이블 선언
fd_set reads, cpy_reads;
socklen_t adr_sz;
int fd_max, str_len, fd_num, i;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
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]));
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
FD_ZERO(&reads); // fd_set 테이블을 초기화한다.
FD_SET(serv_sock, &reads); // 서버 소켓(리스닝 소켓)의 이벤트 검사를 위해 fd_set 테이블에 추가한다.
fd_max = serv_sock;
while(1) {
cpy_reads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
// result
// -1: 오류 발생
// 0: 타임 아웃
// 1 이상: 등록된 파일 디스크립터에 해당 이벤트가 발생하면 이벤트가 발생한 파일 디스크립터의 수를 반환한다.
if ((fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1)
break;
if (fd_num == 0)
continue;
for (i=0; i<fd_max+1; i++) {
if (FD_ISSET(i, &cpy_reads)) { // fd_set 테이블을 검사한다.
// 서버 소켓(리스닝 소켓)에 이벤트(연결 요청) 발생
if (i == serv_sock) {
adr_sz = sizeof(clnt_adr);
clnt_sock= accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
FD_SET(clnt_sock, &reads); // fd_set 테이블에 클라이언트 소켓 디스크립터를 추가한다.
if (fd_max < clnt_sock)
fd_max = clnt_sock;
printf("connected client: %d \n", clnt_sock);
}
// 클라이언트와 연결된 소켓에 이벤트 발생
else {
str_len = read(i, buf, BUF_SIZE);
if (str_len == 0) { // close request!
FD_CLR(i, &reads); // fd_set 테이블에서 파일 디스크립터를 삭제한다.
close(i);
printf("closed client: %d \n", i);
} else {
write(i, buf, str_len); // 가독성을 위해 write에 대한 블로킹 여부 확인 처리는 생략한다.
}
}
}
}
}
close(serv_sock);
return 0;
}
void error_handling(char *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
각 클라이언트 요청마다 별도 프로세스나 스레드를 할당해서 처리하는 게 아니라 하나의 프로세스(스레드)에서 여러 입출력을 관리함으로써 문제를 해결했습니다.
고찰
장점 |
|
단점 |
|
참고. POSIX(Portable Operating System Interface)는 이식 가능 운영 체제 인터페이스의 약자로, 서로 다른 UNIX OS의 공통 API를 정리해 이식성이 높은 유닉스 응용 프로그램을 개발하기 위한 목적으로 IEEE가 책정한 애플리케이션 인터페이스 규격이다.
poll
poll
도 select
와 마찬가지로 멀티플렉싱을 구현하는 시스템 콜입니다. poll
이 여러 개의 파일을 다루는 방법은 select
와 같습니다. 파일 디스크립터의 이벤트를 기다리다가 이벤트가 발생하면, poll
에서의 블록이 해제되고 어떤 파일 디스크립터에 이벤트가 발생했는지 검사하는 방식입니다. poll
의 작동 원리는 select
와 비슷하므로 생략하고, select
와 비교한 차이점에 대해서만 간단히 정리하겠습니다.
장점 |
|
단점 |
|
epoll
epoll
은 select
와 poll
의 단점을 해결할 수 있는 멀티플렉싱을 지원합니다. 커널에 관찰 대상에 대한 정보를 한 번만 전달하고, 관찰 대상의 범위나 내용에 변경이 있을 때에만 변경 사항을 알려줍니다. 비슷한 역할을 하는 시스템 콜로 Windows에는 IOCP, FreeBSD에서는 Kqueue가 있습니다.
epoll
역시 작동 원리를 설명하는 대신 select
와 poll
과 비교한 차이점에 대해서만 알아보겠습니다.
장점 |
|
단점 |
|
마치며
1부에서는 이벤트 루프를 이해하기 위해 먼저 멀티플렉싱 기반의 다중 접속 서버를 구현하는 방법에 대해 알아보았습니다. select
와 poll
, epoll
과 같은 여러 가지 방법을 공유드리기 위해서 C언어를 이용해서 살펴봤는데요. 2부에서는 Java를 이용해서 어떻게 멀티플렉싱 기반의 다중 접속 서버를 구현할 수 있는지 알아보겠습니다.