Sync와 Async를 구분하는 기준은 작업 순서
이다.
동기식 모델은 모든 작업들이 일련의 순서를 따르며 그 순서에 맞게 동작한다. 즉, A, B, C 순서대로 작업이 시작되었다면 A, B, C 순서대로 작업이 끝나야 한다. 설령 여러 작업이 동시에 처리되어 있다고 해도, 작업이 처리되는 모델의 순서가 보장된다면 이는 동기식 처리 모델이라고 할 수 있다.
많은 자료들이 동기식 처리 모델을 설명할 때, 작업이 실행되는 동안 다음 처리를 기다리는 것이 (Wait) Sync 모델이라고 하지만, 이는 잘 알려진 오해이다. 이 Wait Process 때문에 Blocking과 개념이 혼동이 생기는 경우가 흔하다. 동기식 처리 모델에서 알아두어야 할 점은 작업의 순서가 보장된다는 점 뿐이다.
동기식 처리 모델은 우리가 만드는 대부분의 프로세스의 로직이며 특히 Pipeline을 준수하는 Working Process에서 매우 훌륭한 모델이다.
반면 비동기식 모델은 작업의 순서가 보장되지 않는다.
말그대로 비동기(Asynchronous) 처리 모델로 A, B, C 순서로 작업이 시작되어도 A, B, C 순서로 작업이 끝난다고 보장할 수 없다.
비동기식 처리 모델이 이득을 보는 경우는 각 작업이 분리될 수 있으며, Latency가 큰 경우이다. 예를 들어 각 클라이언트 또는 작업별로 Latency가 발생하는 네트워크 처리나 File I/O 등의 훌륭한 적용 예시이다.
Blocking과 Non-Blocking을 구분하는 기준은 통지
이다.
Blocking이란 말그대로 작업의 멈춤, 대기(Wait)를 말한다. 즉, 작업을 시작하고 작업이 끝날때까지 대기하다가 즉석에서 완료 통지를 받는다.
이때 작업을 멈추는 동안 다른 작업이 끼어들 수 있는지 없는지는 다른 얘기인다. 이 부분이 중요하면서도 헷갈리는데, 많은 Blocking 방식의 사례에서 다른 작업의 Interrupt를 방지하기 때문에, Blocking은 곧 "순차처리"로 생각하는 오류가 생긴다. (이렇게 생각해버리면 Blocking과 Synchronous 모델은 같은 개념이 된다.)
단순히 생각해서 Blocking은 그저 작업을 수행하는 데 있어서 대기 시간을 갖는다는 의미일 뿐이다.
Non-Blocking이란 작업의 완료를 나중에 통지받는 개념이다. 작업의 시작 이후 완료시까지 대기하지 않고, 완료시킨다.
즉, 내부 동작에 무관하게 작업에 대한 완료를 처리받는 걸 말한다.
효과적인 작업 상태의 처리를 위해 Non-Blocking에서는 성공, 실패, 일부 성공(partial success)라는 3가지 패턴이 존재한다.
한 작업에 대해 대기 Queue와 무관하게 다른 작업을 처리하는 효율적인 알고리즘 처리시, 각 작업들의 완료 순서가 보장되지 않는 경우가 많이 때문에 Async와 헷갈릴 수 있지만 이들은 비교할 수 없는 개념이다.
많은 종류의 소프트웨어에서 동기 처리 방식이 Blocking이고, 비동기처리 방식이 Non-Blocking인 이유는 익숙한 구조이기 때문이다. 일련의 작업들에 대해 순차적으로 하나씩 처리하고 완료하는 방식은 매 작업의 수행마다 Blocking 하는 게 작업의 순서를 보장하기 쉬우며, 여러 작업들이 동시에 일어나는 구조에서는 한 작업을 수행하는 동시에 Non-Blocking으로 다른 작업을 받아와서 처리하는 구조가 효율적이다.
그렇기 때문에 같은 개념으로 혼동하기 쉽지만, 동기/비동기와 블로킹/논블로킹은 양립할 수 있는 개념이다.
I/O 작업은 유저레벨에서 직접 수행할 수 없다. 실제 I/O를 수행하는 것은 커널레벨에서만 가능하다. 따라서 유저 프로세스(또는 쓰레드)는 커널에게 I/O를 요청해야 한다. I/O에서 블로킹 형태의 작업은 유저 프로세스가 커널에게 I/O를 호출하는 함수를 호출하고, 커널이 작업을 완료하면 함수가 작업 결과를 반환한다.
I/O 작업이 진행되는 동안 유저 프로세스는 대상 파일의 디스크립터(Descriptor)가 준비되지 않으면 자신의 작업을 중단한 채 대기해야 한다. 커널 모드의 컨텍스트로 전환하는 시점에서 시스템 호출이 완료되며, 원래의 사용자 모드 컨텍스트로 돌아감으로써 대기상태가 해제된다. I/O 작업이 CPU 자원을 거의 쓰지 않기 때문에 이런 형태의 I/O는 리소스 낭비가 심하다.
만약 여러 클라이언트가 접속하는 서버를 블로킹방식으로 구현한다고 가정해보자. I/O 작업이 blocking 방식으로 구현되면 하나의 클라이언트가 I/O 작업을 진행하면 해당 프로세스(또는 쓰레드)가 진행하는 작업을 중지하게 된다. 따라서 다른 클라이언트의 작업에 영향을 미치지 않게 하기 위해서 클라이언트 별로 별도의 쓰레드를 만들어 연결시켜줘야 할 것이다. 그러면 쓰레드 수는 접속자 수가 많아질 수록 엄청나게 많아지기 된다. 쓰레드가 많으면 CPU의 컨텍스트 스위칭 횟수가 증가할 것이며, 이때 사용되는 컨텍스트 스위칭 비용 때문에, 실제 작업하는 양에 비하여 훨씬 비효율적으로 동작하게 될 것이다.
Blocking 방식의 비효율성을 극복하고자 만들어진 것이 Non-Blocking 방식이다. Non-Blocking은 I/O 작업을 진행하는 동안 유저 프로세스의 작업을 중단시키지 않는다. 유저 프로세스가 커널에게 I/O를 요청하는 함수를 호출하면, 함수는 I/O를 요청한 다음 진행상황과 상관 없이 바로 결과를 반환한다.
즉 호출 직후 프로그램으로부터 제어가 돌아옴으로써 시스템 호출 종료를 기다리지 않고 다음 처리로 넘어갈 수 있는 것이다. 일반적으로 O_NONBLOCK 플래그를 이용해 Non-Blocing 모드를 선언한다. 이때 프로세스는 블로킹 상태가 아니기 때문에 CPU를 다른 프로세스에서 사용함으로써 I/O 대기시간을 줄이거나 활용할 수 있다. 이때 발생하는 오류는 응용프로그램에서 처리하고 재시도하는 타이밍을 따로 정의할 필요가 있으며, 논-블로킹 I/O 소켓을 포함한 네트워크에 사용되는 I/O 모델에서는 디스크 I/O가 개입하지 않는다. 참고로, C10K 문제의 대책으로는 논-블로킹 I/O에 이벤트 루프 모델을 사용함으로써 단일 쓰레드에서 여러 요청을 처리할 수 있게 되었다.
C10K
인터넷이 발생하고 서비스가 거대화되면서, 서버 대당 처리할 수 있는 동시접속자수에 대한 한계가 제기 되었고, 이를 정의한 문제가 C10K(Connection 10,000) 문제이다. 즉, 서버에서 10,000개 이상의 소켓을 생성하고 처리를 할 수 있느냐에 대한 문제이다. 인터넷 전이나 초기같으면 동시에 하나의 서버에서 10,000개의 connection을 처리한 것은 아주 초대용량의 서비스였지만, 요즘 같은 SNS 시대나 게임만 해도 동접 수 만을 지원하는 시대에 동시에 많은 클라이언트를 처리할 수 있는 능력이 요구되었다. 메모리나 CPU가 아무리 높다 하더라도 많은 수의 소켓을 처리할 수 없다면, 동시에 많은 클라이언트를 처리할 수 없다는 문제이다. Unix의 I/O 방식이 이 문제의 도마 위에 올랐는데, 기존의 Unix System Call인 select() 함수를 이용하더라도 프로세스당 최대 2048개의 소켓 fd(file descriptor) 밖에 처리를 할 수 없었다. 이를 위해 개선안으로 나온 것이 비동기 I/O를 지원하는 API인데, Windows의 iocp와 같은 비동기시스템 호출이다.
유저 프로세스는 recvfrom 함수를 호출하여 커널에게 해당 소켓으로부터 데이터를 받아오고 싶다고 요청하고 있다. 커널은 이 요청에 대하여 상대방의 데이터를 전송받아서 recvBuffer에 저장하고, 유저에게 그 내용을 복사해줘야 한다. 상대방으로부터 데이터를 받는 중에 recvBuffer가 비어있다면 유터 프로세스가 커널에게 받아올 수 있는 정보는 없다. 따라서 recvfrom 함수는 아직 작업 진행중이라는 의미로 "EWOULDBLOCK"을 리턴한다. 이 결과를 받은 유저 프로세스는 다른 작업을 진행할 수 있다. 만약 recvBuffer에 유저가 받을 수 있는 데이터가 있다면, 버퍼로부터 데이터를 복사하여 받아온다. recvBuffer는 커널이 가지고 있는 메모리에 적재되어 있으므로 메모리간 복사가 일어나 I/O 보다 훨씬 빠른 속도로 데이터를 받아올 수 있다. 이때 recvfrom 함수는 빠른 속도로 읽을 수 있는 데이터를 복사해주고, 복사한 데이터의 길이와 함께 반환한다. 위의 모든 반환이 I/O의 진행시간과는 관계없이 빠르게 동작하기 때문에, 유저 프로세스는 자신의 작업을 오랜시간 동안 중지하지 않고도 I/O 처리를 수행할 수 있다.
socket 프로그래밍에서 해당 소켓의 I/O Blocking/Non-Blocking 모드를 결정할 수 있다.
//ioctlsocket 함수 : 소켓의 I/O 상태를 변경하는 함수
//리눅스에서는 ioctl 함수가 같은 기능을 지원한다.
ULONG isNonBlocking = 1;
ioctlsocket(socket, //Non-Blocking으로 변경할 소켓
FIONIO, //변경할 소켓의 입출력 모드
&isNonBlocking //넘기는 인자, 여기는 nonblocking 설정 값
);
두 번째 인자에 어떤 값을 넣느냐에 따라서 설정 값의 의미가 달라지긴 하지만, Non-Blocking으로 만드는데는 이 호출만으로 충분하다. 소켓은 초기 설정이 Blocking으로 되어있으므로 Non-Blocking 모드로 진행하기 위해서는 이 함수 호출이 필수적이다.
char str[BUF_SIZE] = {0,}; //받을 버퍼
unsigned int strLen = 0; //받고싶은 데이터의 길이
unsigned int length = 0; //지금까지 받은 데이터의 길이
unsigned int recvLen = 0; //이번에 받은 데이터의 길이
...
while(TRUE){
if ( recvLen = recv( sock, str+length, strLen, 0) < 0 ){
//recvfrom 호출하여 결과값을 받는다. 0보다 작으면 받을 수 있는 데이터가 없는것
if ( WSAGetLastError() == WSAEWOULDBLOCK ){
//WOULDBLOCK이라면 대기시킨다.
Sleep( 2000 ); /* Sleep for 2 milliseconds */
}
else{
printf("No data Error.\n");
break;
}
}
else {
length += recvLen;
if(length >= strLen)
//다받았으면 중지
break;
}
}
while문을 사용하여 Blocking 모델과 다를 바 없이 사용하고 있기 때문에 좋은 사용 방법이라고는 생각하지 않지만, 각 부분을 따로 놓고 생각하면 Non-Blocking 형식으로 I/O를 구현하는 방식들을 잘 보여주는 예시이다. 논블럭 recv 함수는 읽을 수 있는 데이터만 복사해서 가져오고 그 결과를 반환한다. 원하는 사이즈의 데이터를 받으려면 데이터를 축적하여 받아와야 할 것이다. 함수가 반환하는 값에 따라서 적절하게 동작하도록 코딩하면 원하는 결과를 얻을 수 있을 것이다.
그렇다면 이제 Blocking식 서버에서는 할 수 없었던 싱글 쓰레드 다중접속 서버를 만들 수 있다. accept된 소켓을 하나씩 클라이언트 세션으로 만들고 클라이언트 매니저가 연결된 클라이언트들을 관리한다. 클라이언트 매니저는 모든 클라이언트 세션들을 계속해서 순회하면서 recv를 호출하여 입력을 받는다. 그리고 받은 내용에 대한 처리를 해서 send로 응답해줄 수 잇다. 이때 수행되는 작업이 Non-Blocking이므로 따로 쓰레드를 만들지 않아도 충분히 잘 동작할 것이다. 이 구현방식의 문제는 클라이언트가 따로 입력을 하지 않아도 계속해서 recv로 확인을 해줘야한다는 점이다. 버퍼가 준비되었는지를 확인하는데 recv를 쓰는 것은 리소스를 남용하는 것이다. recv 말고 그냥 해당 소켓의 버퍼를 체크할 수 있는 방법이 있다면, 읽고 쓸 수 잇는 상태에 처한 소켓들을 가려낼 수 있다면 더 효과적인 방법으로 서버를 만들 수 있을 것이다.
이벤트 통지 모델은 Non-Blocking에서 제기된 문제를 해결하기 위해서 고안되었다. I/O 처리를 할 수 있는 소켓(혹은 파일 디스크립터)을 가려내서 가르쳐준다면, 다시 말해 입력 버퍼에 데이터가 수신되어서 데이터의 수신이 필요하거나, 출력 버퍼가 비어서 데이터의 전송이 가능한 상황을 알려준다면, 훨씬 단순하고 효과적으로 다중 I/O 모델을 처리할 수 있을 것이다. I/O 이벤트를 통지하는 방법은 크게 동기형 통지모델과 비동기형 통지모델로 나눌 수 있따.
동기(synchronous)와 비동기(asynchronous)는 서로 메시지를 주고 받는 상대방이 어떤 방식으로 통신을 하는가에 대한 개념이다. I/O 통지모델에서 대화하는 주체들은 커널과 프로세스
이다. 프로세스는 커널에게 I/O 처리를 요청하고, 커널은 프로세스에게 I/O 상황을 통지한다. 우선 I/O 요청은 방드시 동일하게 처리될 수 밖에 없는 부분이고, 결국에 커널이 프로세스에게 어떤 방식으로 통지하느냐에 따라 동기형이냐 비동기형이 결정될 것이다.
동기형 통지모델의 프로세스는 커널에게 지속적으로 현재 I/O 준비 상황을 체크한다. 즉 커널이 준비되었는지를 계속 확인하여 동기화하는 것이다. 따라서 동기형 통지모델에서 Notify를 적극적으로 진행하는 주체는 유저의 프로세스가 되며 커널은 수동적으로 유저 프로세스의 요청에 따라 현재의 상황을 보고한다.
이와 반대로 비동기형 통지모델은 일단 커널에게 I/O 작업을 맡기면 커널의 작업 진행사항에 대해서 프로세스가 인지할 필요가 없는 상황을 말한다. 유저의 프로세스가 I/O 동기화를 신경쓸 필요가 없기에 비동기형이라고 부를 수 있다. 따라서 비동기형 통지모델에서 Notify의 적극적인 주체는 커널이 되며, 유저 프로세스는 수동적인 입장에서 자신이 할 일을 하다가 통지가 오면 그 때 I/O 처리를 하게 된다.
I/O 다중화는 poll(), select(), epoll() 시스템 호출을 이용해 여러 파일 디스크립터를 하나의 프로세스로 관리한다. 이러한 시스템 호출은 파일 디스크립터 상태 변화를 모니터링 할 수 있다. poll, epoll, select의 차이는 다음과 같다.
select()는 지정한 소켓의 변화를 확인하고자 하는 함수로, 소켓에 변화가 생길 때까지 기다리다 어떤 소켓이 어떤 동작을 하면 동작한 소켓을 제외하고 나머지 소켓을 제거하고 해당 소켓에 대한 확인을 진행한다. 디스크립터 수에 제한되어 있어 적극적으로 사용되지는 않지만, 사용이 쉽고 지원 OS가 많아 이식성이 좋은 편이다.
poll은 select의 문제였던 디스크립터 수 제한이 없다. 처리 방식은 select와 비슷하며, 여러 개의 파일 디스크립터를 동시에 모니터링하다 한 개라도 읽을 수 있는 상태면 블로킹을 해제한다. 단, 디스크립터 수가 늘어날 수록 퍼포먼스가 떨어지는 단점이 잇지만, 디스크립터 수 제한이 없어 select보다 많이 사용된다. 단, 이식성이 나쁜 편이다.
epoll API는 Linux 커널 2.5.44에서 도입되었다. 파일 디스크립터 수에 제한이 없으며 상태 변화 모니터링도 크게 개선되었다. 구체적으로 파일 디스크립터 상태를 커널에서 감시하고 변화된 내용을 직접 확인할 수 있기 때문에 select, poll처럼 루프를 사용한 모니터링이 불가능하다. 때문에 효율적인 I/O를 구현할 수 있다.
참고