비동기 IO 함수
IO 함수라고 하면 생소하게 느끼는 경우가 많다. IO 함수는 input / output 함수의 줄임말인데 우리가 흔히 봐왔던 함수로는 scanf와 printf가 있겠다. scanf
는 keyboard에서 글자를 읽어(input) 오는 것이고, printf
는 문자열을 화면으로 출력(print)하는 함수다.
이 뿐만 아니라 fprintf
, fscanf
등의 파일 입출력 관련의 함수 역시 io 함수들이고, 파일 관련 Windows API인 ReadFile
, WriteFile
, 네트워크 통신을 위한 WSARecv
, WSASend
, 기타 등등 입출력 장치와 통신하기 위한 함수들은 io 함수라고 보면 된다.
blocking
IO 함수들은 기본적으로 blocking 모드로 동작하게 되어있다. blocking 모드는 프로그램의 수행을 멈추는 것으로 간단한 예를 보면 다음과 같다.
while (true) {
// 테트리스 블럭을 내린다
// 벽돌 간 충돌 검사를 한다
// 사용자의 키 입력을 받는다.
scanf ("%c", &key);
// 키 입력에 대한 처리를 한다.
혹시나 테트리스를 짜면서 위와 같은 코드를 짜본 경험이 있는 사람들은 금방 이해가 될 것이다. 완벽하다고 생각했던 위 코드는 제대로 동작하지 않는다. 키를 누르고 enter key를 누르기 전까지는 테트리스 블럭이 다음 줄로 내려가지 않기 때문이다. 또한 재밌는 것은, 키를 여러번 누르고 enter key를 눌러 input stream을 flush해주면 블럭이 아까 키 눌렀던만큼 쉬지 않고 한 순간에 움직이는 것을 볼 수 있다.
이렇게 수행 흐름을 중단시키는 것을 blocking 함수라고 한다. 학부 때 기초 프로그래밍을 하면서 다루게 되는 io 함수는 scanf
나 printf
, fscanf
나 fprintf
인데 여기서 blocking이라는 것을 느낄 만한 것은 scanf
정도이고, 그나마도 당연히 흐름이 멈춰야만 하는 프로그램에서 scanf
를 쓰는 것이기 때문에 blocking이라는 개념이 친숙하지 않을 수 있다.
하지만 조금 더 나아가서 게임을 만들어보려한다던가(테트리스 등), 아니면 네트워크 프로그래밍을 하면서 read
, send
함수를 쓰려고 하다보면 해당 함수를 호출했을 때 흐름이 막히는 특성으로 인해 당황하는 경우가 있다.
보통은 blocking 함수로 프로그램의 흐름이 멈추게 되어 생각했던대로 동작하지 않는 것을 막기 위해 다른 흐름을 만드는 방법을 시도한다. 즉 multi thread를 사용하는 것이고 몇가지 경우에서 이 시도는 꽤 괜찮아 보인다. 하지만 이 글에서는 비동기 IO 함수라는 것이 어떤 것이고 그게 왜 필요하고, 왜 더 효율적인가를 알아보고자 한다.
(물론 테트리스 게임 등을 만들 때는 대부분 입력을 위한 thread를 따로 만드는 것이 아니라 GetAsyncKeyState
와 같은 비동기 API 를 쓰게 된다.)
scanf 대탐험
먼저 blocking 함수의 동작 원리에 대해 간단히 살펴보자. 이를 위해 간단한 선행지식 하나가 필요하다. 우리가 쓰고 있는 운영체제라는 물건은 하드웨어의 자원을 관리하고 다른 응용 프로그램이 이를 직접 접근하지 못하게 막고, 쓰고 싶을 때는 운영체제에 요청하면, 본인이 직접 접근해서 일을 처리하고, 그 결과를 응용 프로그램에게 돌려준다는 것이다.
키보드, 네트워크, 하드디스크, 모니터, 마우스 등은 모두 하드웨어이다. 우리가 이러한 기기로부터 정보를 얻거나 출력하려면 당연히 운영체제에게 허락을 받아야한다. 그것을 허용해주기 위해 운영체제가 제공해주는 것이 API인 것이다. 본 문서는 Windows 대상이니까 명확하게 Windows API라고 하자.
이렇게 분리한 이유 중 하나는 응용 프로그램이 하드웨어를 직접 마구 접근하여 시스템에 손상을 가하는 것으로부터 보호하기 위함이다. 운영체제는 여러 개의 프로그램을 동시에 안전하게 돌려줘야할 의무가 있는데, 어떤 악의적인 프로그램이 다른 프로그램을 모두 손상시키거나, 아니면 하드웨어를 독점하면 안되기 때문이다.
CPU 는 한 순간에 하나의 명령만 수행하기 때문에 운영체제의 코드와 응용 프로그램의 코드를 수행하는 시점에는 구분할 수가 없다. 따라서 코드가 메모리에 존재하는 영역(segment) 자체에 레벨을 설정하여 권한을 갖고(privileged) 수행할 수 있는 영역과 그렇지 않은 영역을 구분한다. 운영체제는 응용 프로그램을 실행할 때 그 코드를 권한을 갖지 못한 영역(user)에 올리기 때문에 그 코드는 하드웨어에 직접 접근하지 못하고, 운영체제에게 요청하여 원하는 값을 얻어갈 수 있도록 하는 것이다.
- https://duartes.org/gustavo/blog/post/cpu-rings-privilege-and-protection
- https://en.wikipedia.org/wiki/Privilege_level
- https://en.wikipedia.org/wiki/Ring_(computer_security)
또한 이러한 ring level이 변경되기 위해서, 즉 user 영역에서 실행되던 코드가 privileged 영역의 kernel 코드가 실행되도록 권한이 변경되어야 하는데, 그러기 위해서 kernel mode로 진입하는 switch 과정이 일어나야한다. 이 과정이 mode switching 과정인데 이 글에서 자세히 다루기에는 양이 너무 많고, 아무튼 복잡하고 부담이 큰 작업이라는 정도로만 이야기를 해보자. 이 때문에 io 함수를 작은 버퍼 단위로 자주 호출하는 것이나, 짧은 lock 을 사용하는 시점에 kernel에서 관리해주는 lock 작업인 semaphore를 쓰는 것이 효율이 좋지 않다는 이야기가 나오는 것이다. (자주 mode switching 가 발생하기 때문에 그렇다)
길고도 지루한 기반 지식은 다 마련되었다. 이제 scanf
함수가 어떤 방식으로 이루어지는지 간략한 개요를 보자.
- 응용 프로그램은 사용자의 입력을 알고 싶기에
scanf
함수를 호출한다. scanf
함수는 CRT(c runtime) 내의 입력 버퍼(inputstream buffer)에 값이 있는지 확인한다.- 입력 값이 없기 때문에 keyboard 버퍼로부터 값을 읽어야겠다. kernel에게 부탁해보자. 이제 kernel 모드로 진입한다. (mode switching)
- keyboard 버퍼를 확인해본다. 입력이 없다면 프로세스를 재운다(대기시킨다). 응용 프로그램을 wait queue로 빼서 scheduling 대상에서 제외한다. 이 지점에서 응용 프로그램은 그 수행을 멈추고 blocking이 된다.
- 사용자가 keyboard를 쳤다! 일단 kernel 버퍼에 keyboard 버퍼 값을 복사해온다.
- 사용자의 메모리가 swap out 되었다면 복구해주고, 거기에 kernel 버퍼의 값을 복사해준다.
- kernel mode를 벗어난다. 이제 user 모드로 진입한다. (mode switching)
scanf
함수는 kernel이 반환한 결과를 본다. enter key가 없다!- 다시 kernel에게 keyboard 버퍼 값을 읽어달라고 요청한다. 이제 kernel 모드로 진입한다. (mode switching) 그리고 4번부터 반복
scanf
함수는 드디어 enter key를 찾게 되었다. 이제 응용 프로그램으로 반환해야겠다.- 서식 문자열(formatted string, %c 같은 것)에 맞춰서 입력 버퍼에 있는 값을 반환해준다.
- 응용 프로그램은 오랜 인고의 시간을 거쳐
scanf
함수 수행을 마치고 다음 줄을 실행한다
장황한 scanf
수행 로직이 나왔다. 물론 12번 이후에 또 scanf
를 요청하면 입력 버퍼(inputstream)에 데이터가 있으니 바로 수행이 이루어질 것이다.
이 때문에 테트리스를 scanf
기반으로 작성했을 때 키를 여러 번 누르고 enter key를 누르면 모아놨던 동작이 scanf
에 의해 개방되면서 여러 움직임이 순식간에 일어나게 되는 것이다.
아무튼 핵심은,
- 응용 프로그램은 IO 작업을 위해 kernel에게 요청한다
- kernel은 IO 작업이 완료될 때까지 응용 프로그램의 수행을 중단시킨다 (blocking)
- kernel은 IO 작업이 완료된 후 응용 프로그램을 깨우고(wakeup)
- 그 결과를 응용 프로그램의 메모리에 복사(copy) 해준다.
- 그리고 IO 함수를 끝내고 수행 흐름을 응용 프로그램에게 돌려준다 (완료 통지 completion notification)
4번은 좀 나중에 할 이야기이다. 왜냐하면 IO 의 효율을 이야기할 때 메모리 복사는 빠질 수 없는 주제이기 때문이다. 아무튼 2번과 5번에 대한 이야기를 해보자
다시 blocking
kernel 함수는 응용 프로그램의 IO 요청이 있을 때 IO 작업이 완료될 때까지 응용 프로그램의 수행을 중단시킨다. 그리고 IO 작업이 완료되면, 다시 흐름을 재개시킨다. 즉, IO 함수의 완료 통지를 함수의 반환을 통해 하기 때문에 수행 흐름을 멈췄다가 재개하는, blocking 방식을 사용하는 것이다. 왜냐하면 그 방법이 운영체제 입장에서 훨씬 효율적이기 때문이다.
운영체제는 여러 프로그램을 동시에 실행시켜줘야하는 의무가 있다. 프로그램은 작업 성향에 따라서 CPU-bound 작업과 IO-bound 작업으로 나뉘는데, 데이터를 갖고 단순한 계산을 반복하는 작업의 경우 CPU만 소모하는 작업일 것이고, 대화상자와 같은 응용 프로그램은 사용자와의 interaction이 중요하니까 IO-bound 작업이 될 것이다.
후자의 경우 어차피 사용자의 입력이 없으면 더 이상 수행할 게 없으니까 사용자의 입력이 들어올 때까지, 즉 응용 프로그램이 kernel에 입력 받아달라고 요청하면 kernel은 그 입력이 들어올 때까지 해당 응용 프로그램을 재워(sleep)버리는 것이다. 그리고 CPU 소모하는 작업들만 열심히 작업할 수 있게 처리(scheduling) 해주다가 IO 작업 완료가 되면, 그 응용 프로그램을 깨워(wakeup)서 결과를 통보(함수 반환)해주는 것이다.
그런데 게임 같은 프로그램 때문에 일이 틀어졌다. 이것들은 IO 작업도 많이 쓰고 CPU도 많이 쓴다. 게다가 IO 개수도 한 두개가 아니다. 네트워크도 쓰고 키보드, 마우스도 쓰고, 화면으로 출력도 하고, 파일도 쓴다.
이런 프로그램일 경우에는 IO 함수 요청했는데 데이터가 없으니까 프로그램 멈추면 안된다. IO가 없으면 없는거고 게임 로직은 계속 수행해야 한다. 1인용 테트리스야 enter key 눌러야만 벽돌이 움직입니다 이러면 실소로 끝나지만, 실시간 MMORPG에서 유저가 행동을 해야만 AI가 동작한다는 것은 말이 되지 않기 때문이다.
이런 이유로 인해서 수행 흐름을 막지 않는 IO 함수가 필요해졌다. 데이터가 들어올 때까지 기다리는 것이 아니라 데이터가 없으면 그냥 -1반환해버리고 함수가 끝나는 것이다.
while (true) {
// 테트리스 블럭을 내린다
// 벽돌 간 충돌 검사를 한다
// 사용자의 키 입력을 받는다.
if (nb_scanf ("%c", &key) != -1) {
// 키 입력에 대한 처리를 한다.
저 nb_scanf
함수는 마법의 함수이어서 키보드 입력이 없으면 -1을 반환하고, 그렇지 않으면 그 결과값을 key 변수로 돌려받는다. 이와 같이 짜면 테트리스가 우리가 생각했던 대로 돌아간다. 그래서 우리는 scanf
를 쓰지 않고 conio.h의 getch
함수를 쓰게 되는 것이다.
그런데 뭔가 찜찜하다. 확실히 non blocking 함수를 쓰면 흐름이 중단되지 않으니까 전체 로직 수행 흐름에 영향을 안 주니까 따로 thread 를 안 써도 좋다. 그런데 그럼 값이 있든 없든 매번 물어봐야하니까 낭비 아닌가?
낭비라고 생각할 수 있다. 저 수행 방식은 non-blocking이기는 하지만 비동기(asynchronous)는 아니다. non-blocking으로 돌아가지만 동기(synchronous)적으로 계속 물어보는 방법이다. 로직을 처리하는 구문과 IO를 처리하는 흐름이 하나되어 돌아간다. 그렇다면 비동기적으로 돌아가는 것은 어떤 것일까?
asynchronous
while (true) {
// 테트리스 블럭을 내린다
// 벽돌 간 충돌 검사를 한다
// 사용자의 키 입력을 받는다.
async_read (stdin, async_callback);
}
void async_callback (char *key) {
// 키 입력에 대한 처리를 한다.
async_read
함수는 마법같은 함수로 호출할 당시에는 아무 일도 일어나지 않는다. 하지만 kernel에서 IO 작업이 완료되면 그 통지를 인자로 넘긴 callback 함수를 통해 호출해주는 것이다.
어찌보면 interrupt handler 같은 방식으로 동작한다. 이 때 async_callback
함수는 어떤 흐름에 의해 실행될지 모른다. 단일 thread 프로그램이라면 테트리스 logic을 처리하고 있다가 중간에 갑자기 async_callback
함수가 수행될 것이고, 여러 thread라면 logic이 수행되면서 동시에 callback 이 수행될 수 있을 것이다.
아무튼 저런 복잡한 사정은 무시하더라도 여기에 문제가 있다. 테트리스 logic을 처리하는 while 문의 scope와 키보드의 입력을 처리하는 async_callback
함수는 완전히 다른 scope이다. 따라서 변수를 공유해주기 위해 전역변수를 써야한다든가 하는 여러가지 귀찮은 문제가 발생하게 된다.
더 큰 문제가 있다. 저 while 문에서 async_read
가 지속적으로 호출되는데, 정녕 이것은 괜찮단 말인가? 그렇지 않다!
운영체제는 async_read
작업 요청을 자신의 작업 queue 같은 곳에 착실히 쌓아놨다가 나중에 더 이상 async_read
를 요청하지 않아도 키보드 입력이 들어오면 아까 요청한 작업이 남아서 계속 callback 이 호출될 수 있겠다. 즉, gameover 가 떴는데 아직 처리되지 않은 키보드 입력이 계속 처리될 수 있다는 것이다.
더 무서운 이야기는, thread scheduling은 우리가 직접 제어하는게 아니라서 문제가 발생할 수 있다는 것이다. 사용자가 키 입력을 했다(KEY1), 그리고 또 입력을 했다(KEY2). 이 둘은 순서에 의해(order) IO 완료 통지가 날아온다. 즉 callback 함수는 KEY1, KEY2 순으로 호출이 된다. 그런데 문제는 KEY1 의 callback 과 KEY2 의 callback이 다른 thread에서 수행되어버린다면(전자를 Thread 1, 후자를 Thread 2라고 해보자) 분명 Thread 1이 KEY1에 대한 callback을 먼저 수행하기 시작했는데, 운 나쁘게도 자신의 시간을 모두 소모(time out)해버리고 다른 thread가 수행되도록 switching 이 되어버린 것이다. 이 때다 싶은 Thread 2는 자신이 Thread 1보다 늦게 KEY 2의 callback이 호출되었음에도 불구하고 Thread 1이 잠든 틈을 타서 자신이 먼저 수행을 끝낼 수도 있다는 것이다.
이러한 순서 문제(out of order)에 의해서 문제가 크게 발생할 수 있다. 나는 분명히 왼쪽 키(KEY1)를 누른 다음에 회전 키(KEY2)를 눌렀는데, 이게 위의 문제에 의해서 회전 키(KEY2)가 먼저 동작하고 왼쪽 키(KEY1) 이 나중에 동작해버리면 사용자는 당황할 것이다.
순서 문제
내용이 너무 길다. 잠깐 정리해보자. 동기화된 프로그래밍은 로직을 작성하기 쉽지만 매번 물어보는 부담이 있기 때문에 효율을 위해 blocking을 사용한다. 경우에 따라 non blocking 함수를 사용해야할 경우에 그 부담을 해소하기 위해 비동기 IO 함수를 사용한다. 비동기 IO 함수는 다 좋은데 여러번 호출할 경우 완료 통지 후 처리의 순서 보장이 안될 가능성이 있다. 이를 해결하기 위한 가장 좋은 방법은 무엇일까?
네트워크의 경우 주고받는 데이터 자체에 모종의 정보를 기록해서 순서를 맞춰주는 방법이 있는데 이건 너무 복잡하다. 대충 쉬운 방법은 바로 하나의 callback 요청이 끝난 다음에 다음 async IO 요청을 한다인 것이다. 다음과 같다.
while (true) {
if (isIOCompletion)
async_read (stdin, async_callback);
// 생략
}
void async_callback (char *key) {
isIOCompletion = false;
// 생략
isIOCompletion = true;
}
좋은 방법인 것 같다. 그런데 flag를 사용할 경우 발생하는 문제가 있다. 여러 thread에서 저 flag 값이 바로 반영되지 않아서 생길 수 있는 문제인데 이에 대해서는 다음의 글을 보자. volatile-interlocked-operation
보다 좋은 방법은 없을까?
async_read (stdin, async_callback);
while (true) {
// 로직 처리
}
void async_callback (char *key) {
// 생략
async_read (stdin, async_callback);
}
명쾌하다! 처음에 딱 한번 read 요청을 한다. 그러면 keyboard 입력이 들어오면 async_callback
함수가 수행될 것이다. 그리고 그 함수의 모든 수행이 끝나면 다시 async_read
함수를 통해 다음 입력을 읽어올 수 있도록 요청하는 것이다.
이러한 방법을 통해 비동기적 IO 요청에 대해 순서 보장을 고려해줄 수 있다.
마무리
다시 요약해보자. IO의 효율을 위해 비동기 IO를 사용했다. 그 이유는 흐름이 막히는(blocking) 것을 방지하기 위함이고, 또 무의미하게 IO 완료 여부를 확인(non-blocking 에서 무한 루프를 통한 질의)를 막기 위함이다.
하지만 효율적으로 동작하기는 해도, 비동기적으로 처리하는 것은 callback 함수가 언제 호출되어야할지 모르기 때문에 여전히 로직 작성에 어려움이 있다. callback이 수행되는 thread에서 lock을 사용할 경우 dead lock에도 주의를 해주어야하고, 선행 수행 의존성이라도 있으면 이걸 제어하기 위해 엄청난 흐름 제어용 lock을 사용해야하기 때문이다.
그리고 결정적으로 하나의 IO가 아닌 여러 IO를 감시해야할 때 비동기 IO가 얼마나 더 효용성이 있는지에 대한 언급이 이 글에는 없다. 오로지 테트리스를 예제로 동기 IO와 비동기 IO에 대해서 비교를 하는 글이니까.
따라서 다음 글에서는 그 방향대로 이야기를 진행해볼까 한다. 비동기 IO의 문제점을 어떻게 해결하고, 효율적으로 여러 IO를 어떻게 처리할 수 있는지에 대해서 말이다.