동기와 비동기, 동시성과 병렬성, 블로킹과 논블로킹. 검색하다보면 같이 자주 언급되는 단어들이며 비슷한 발음(동시-동기과와 비슷한 의미 덕분에 혼용되곤 한다. asyncio를 시작하기에 앞서 이러한 내용들을 우선 정리해보고자 한다.
동시성과 병렬성의 차이를 실생활에서 설명하는 예시로 레스토랑의 셰프를 들 수 있다. 레스토랑에서 오래 근무한 능숙한 셰프라면 파스타의 면을 삶으면서, “동시”에 파스타 소스를 끓일 수 있을 것이다. 이렇게 여러 일을 동시에 하는 경우를 “동시성”이라 표현한다. 컴퓨터의 경우라면 운영체제를 예로 들 수 있다. 우리는 유튜브로 노래를 들으면서 문서 작업을 할 수 있다. 이는 운영체제가 CPU의 단일 프로세스 코어에서 여러 프로그램을 아주 빠르게 번갈아가며 실행하기에 가능한 것이다. 즉, 운영체제는 프로그램이 마치 동시에 실행되는 것 같은 효과를 사용자에게 가져다준다.
병렬성이란 2명의 셰프가 각각 다른 요리를 하는 것을 뜻한다. 즉 1명의 셰프가 아니라 2명 혹은 그 이상의 셰프가 각각 자신만의 요리를 할 때 병렬성이라 표현한다. 그렇다고 해서 동시성의 개념이 물리적으로 CPU 1개 코어에서만 작동하는 개념은 아니다. 동시성은 제한된 자원에서 여러 작업을 한번에 실행시키는 논리적 개념이라 보는 것이 더 타당하다.
정리하면, “동시성”은 프로그램, 알고리즘 또는 문제의 속성 중 하나이며 병렬성은 이렇게 동시에 발생하는 문제를 해결하는 접근 방식의 하나이다.
다시 “동시성”에 대해 좀 더 생각해보자. 파스타 면을 삶으면서, 파스타 소스를 끓일 수 있는 이유는 무엇일까? 파스타 소스를 끓일 때는 아직 파스타 면이 필요하지 않기 때문이다. 또한, 파스타 면을 삶는 냄비와 파스타 소스를 끓이는 팬이 다르기 때문이다 즉, 동시성은 각각 따로 발생하는 2개의 이벤트가 서로 독립적이어서 영향을 끼치지 않을 때 가능하다.
왜 “독립적”인 특성이 중요할까. 각각의 이벤트(혹은 프로그램/알고리즘/해결하고자 하는 문제)가 서로 완전히 독립적이고, 각각의 컴포넌트로 분리할 수 있다면 이들의 처리 순서가 최종 결과에 영향을 주지 않게 된다. 즉, A→B→C와 같은 순차적 흐름이 필요없게 되므로, 이러한 문제를 병렬성을 적용할 수 있게 된다. (물론, 병렬 프로세싱이 필수적인 것은 아니다)
동시성을 달성하는 다양한 방법들이 존재한다. 그 기법들은 다음과 같다.
이번 시리즈에서 지속적으로 다룰 asyncio는 asynchronous IO(비동기 IO)의 약자로 파이썬에서 비동기 프로그래밍을 사용해 코드를 실행할 수 있도록 도와주는 라이브러리이다.
먼저, 동기(synchronous)와 비동기(asynchronous)에 대해서 알아보자.
동기 방식이라 함은, 작업 A가 작업 B에게 작업 요청을 하고, 작업을 끝낼 때까지 관심을 가지고 기다리는 방식을 뜻한다.
비동기 방식은 작업 A가 작업 B에게 작업 요청을 하고, 작업을 끝낼 때까지 관심을 버리고 기다리지 않는다. 앞서 동시성을 구현하는 방법으로 비동기가 가능하다 말했는데, 그 이유가 여기에 있다. 작업 A가 작업 B에게 작업 요청을 하고, 작업 A는 작업 B를 기다리지 않고 자신의 일을 한다. 즉, 작업 A와 작업 B가 동시에 실행되어 동시성을 달성할 수 있다.
비동기 방식으로 코드를 작성하면, 응답에 대해서 처리를 하는 코드를 따로 작성해야 한다. 관심이 없더라도, 작업 B가 작업을 끝냈는지 결국은 확인해야 한다. 이때, 콜백(Callback) 방식을 많이 활용한다
비동기 실행 모델에서 작업(태스크)은 스케줄러에 의해 선택되고 실행된다. 스케줄러는 인터리브 방식(interleaved manner)으로 태스크 큐에서 태스크를 선택하고 실행한다.
비동기 프로그램을 구현할 때는 항상 다음과 같은 질문을 염두에 두어야 한다.
예시를 들어보자. 작업 A가 작업 B에 고객이름과 데이터를 보내면, 작업 B는 해당 데이터를 고객에게 메일로 보낸다 가정하자. 작업 A는 작업 B가 메일을 성공적으로 보냈음을 반드시 알아야할까? 그럴 수도 있고, 아닐 수도 있다. 어떻게 설계하느냐에 따라 달라지는데, 만약 작업 B가 메일을 성공적으로 보냈는지 여부를 다른 콘솔 창 혹은 로그 파일에 기록하게끔 설계한다면, 작업 A는 굳이 메일 성공 여부를 확인할 필요가 없게 된다.
그렇다면, 블락과 논블락은 무엇일까?
블락 - 논블락은 기술적으로 함수 호출에서 제어권에 대한 이야기이다.
함수 A가 함수 B를 호출했을 때 프로세스 제어권이 함수 B로 넘어가는 경우 함수 B를 블로킹 함수라 한다.
반면, 함수 A에서 함수 B를 별도의 스레드로 생성하고, 특정 객체를 바로 리턴받는 경우 논블록이라 한다. 함수 A가 있는 스레드는 함수 B를 별도의 스레드로 만든 이후 자신의 일을 이어서 할 수 있기 때문에 블록킹되지 않는다. 별도의 스레드라고 표현했지만, 스레드가 아니더라도 논블록킹 함수 구현이 가능하다.
정리하면, 동기와 비동기는 작업 완료에 관심이 있느냐에 대한 작동 방식에 좀 더 초점이 있고, 블로킹과 논블로킹은 프로세스 제어권이 넘어가느냐에 대한 내용에 초점을 맞춘다.
asyncio가 필요한 이유에 대해서 살펴보자.
I/O 위주의 프로그램은 I/O 효율이 프로그램 속도에 중요한 영향을 미친다. 파일이나 네트워크 소켓에서 데이터를 읽을 때마다 프로그램은 잠시 실행을 멈추고 커널에 IO 연산 수행을 요청한다. 커널만이 하드웨어와 상호작용할 수 있고, 실제 읽기 연산의 주체가 커널이기 때문이다.
문제는 CPU와 IO 연산의 속도 차이에 있다. I/O 연산 대부분은 CPU보다 매우 느리게 작동한다. 그렇기에 프로그램이 네트워크 혹은 파일로부터 데이터가 도달하기를 기다리는 동안 CPU가 아무 일도 하지 않고 대기하는 일이 발생한다. 이렇게 I/O 도중 프로그램이 일시 정지된 상태를 I/O 대기(I/O wait)라 한다.
비동기 I/O는 커널이 I/O 연산을 수행하는 동안 다른 연산을 수행해 대기 시간을 줄이고, 자원을 최대로 활용하기 위해 필요하다.
그렇다면 비동기 I/O는 어떻게 동작할까? 비동기 I/O는 운영체제의 도움을 받아야 한다. 운영체제의 커널이 데이터 읽기 및 쓰기 요청을 하드웨어에 보내고, 하드웨어로부터 데이터가 준비 완료되면 신호를 보낸다. 프로그램은 데이터를 기다리는 대신, 다른 작업을 수행하다가 나중에 데이터가 준비되면 통지받는다.
이러한 메커니즘으로 이벤트 루프를 주로 사용한다. 이벤트 루프는 메시지 디스패처(message dispatcher)라고도 부르며, 프로그램의 이벤트나 메시지를 대기하다가 준비 완료되면 메시지를 디스패치(통지, 전송)한다.
프로그램이 I/O 대기에 들어가면 프로그램 실행을 멈추고 컨텍스트 스위칭이 발생해 커널이 I/O 관련 연산을 처리하기 시작한다. 컨텍스트 스위칭은 나중에 다시 프로그램을 재실행하기 위해 프로그램의 상태를 저장해야 한다.
이벤트 루프 또한 마찬가지이다. I/O 관련 작업을 비동기 방식으로 요청하고, 나중에 데이터가 준비되었을 때 통지받는다 생각하자. 통지받은 이후에는, 전달받은 데이터로 이후의 추가 작업을 진행해야 할 것이다. 그러기 위해서는 “어디”부터 다시 연산을 진행할 것인지와 전달받은 데이터를 “어떤” 함수(혹은 이벤트 핸들러)에 전달해 실행할 것인지를 관리해야 한다. 이벤트 루프는 이렇게 실행할 대상과 시점을 관리한다.
이벤트 루프를 사용하는 프로그래밍은 콜백과 퓨처(Future) 두 형태 중 하나로 구현이 가능하다. 콜백 방식은 파이썬 3 초반 버전 이전에 인기 있었다. 이후 등장한 asyncio 표준 라이브러리는 퓨처 메커니즘을 채택했다. 이후에 더 자세히 다루겠지만 퓨처는 미래에 얻을 결과에 대한 약속으로, 퓨처에 I/O 연산 결과 값이 채워지기를 기다리는 방식이다.