io_uring은 select/poll/epoll 같은 디스크립터 준비 이벤트 시스템의 대체물이 아니라, 커널에 비동기적으로 시스템 콜을 위임하는 일반 메커니즘이라는 점을 설명하고, 왜 이것이 중요한지와 동작 방식을 간단히 소개합니다.
2021년 6월 16일
몇 년 전부터 io_uring에 대해 간헐적으로 들어왔다. 이는 리눅스에서 매우 적은 오버헤드로 고성능 IO를 가능하게 하는 비교적 새로운 기술이다.
이야기될 때마다 보통 select, poll, epoll의 새로운 대안으로 소개되곤 했다. 그래서 나는 그것이 그들 다음 세대의 설비, 즉 파일이나 네트워크 소켓 등에서 어떤 일이 발생했는지 프로그램에 알려서 조치를 취하게 하는 또 하나의 설비라고 생각했다. 다시 말해, 점진적인 개선(아마도 오버헤드가 더 적을 뿐)일 뿐, 본질적으로는 기존과 크게 다르지 않다고 여겼다. 훌륭하긴 하지만 내겐 다소 지루한 주제였다. 개념은 잘 이해하지만, 요즘은 남이 만든 소프트웨어를 주로 운용하고 스스로는 거의 작성하지 않기 때문에 세부 사항에는 크게 관심을 두지 않았다.
그러다 지난주에 Brendan Gregg의 LISA21 발표 “Computing Performance: On The Horizon”를 보다가, 이 슬라이드가 눈에 들어왔다:

“더 빠른 시스템 콜” 때문이었는지, 아니면 다이어그램 때문이었는지 모르겠지만, io_uring에는 내가 알던 것보다 더 많은 것이 있다는 느낌이 들었다. 정확히 뭔지는 몰랐지만, 직접 써봐야겠다고 생각하기에 충분했다.
이걸 새로운 종류의 디스크립터 준비(레디니스) 기능이라고 생각하고, 그냥 멍청한 다중 사용자 텔넷 채팅 서버를 하나 써보려 했다. 이런 류의 프로그램은 한때 내게 기본 과제였다. 오래전 이야기지만, MUD 같은 채팅 서버를 작성하면서 C, 유닉스, 네트워크 프로그래밍을 처음 배웠다. 그런데 너무 오래전 일이다 보니, 흥미로운 정보 대부분이 머릿속에서 빠져나가 있었다.
그래서 yoctochat을 구상했다. 가능한 한 가장 단순한 채팅 서버의 윤곽을 잡고, 각기 다른 디스크립터 준비 메커니즘으로 여러 번 구현하는 식이다. 먼저 select 버전을 만들고, 이어서 poll 버전을 만들었다. epoll 버전은 한 번도 써본 적이 없어서 조금 더 오래 걸렸지만, 본질적으로는 같은 계열이라 구조는 거의 같았다.
그렇게 정리를 마치고, 이런 프로그램들이 무엇인지 분명히 이해한 상태에서 며칠 전 yc_uring.c를 시작했다. 몇 번의 시행착오 끝에 감을 잡기 시작했고 형태가 잡혀갔다. 잠시 집안일을 하며 내가 만들고 있는 프로그램의 형태를 곱씹다가 문득 깨달았다:
io_uring은 이벤트 시스템이 전혀 아니다. io_uring은 사실 범용 비동기 시스템 콜 메커니즘이다.
이제야 왜 이렇게들 떠들썩한지 알겠다!
고전적인 유닉스 IO 시스템 콜(예: read())은 모두 동기적이고, 블로킹이다. 이를 호출하면, 요청한 일이 일어날 때까지 프로그램은 잠든다. read()의 경우 그건 “데이터가 도착했다”는 뜻이다.
명백한 문제는, 동시에 둘 이상의 대상으로 read()를 하고 싶을 땐 어떻게 하느냐다. 하나를 읽느라 블로킹된 동안 다른 대상에서 무언가가 일어나면 어떻게 하나? 영영 기다릴 수도 있다!
알람을 걸어 호출을 중단한 뒤 다음 대상으로 넘어가는 식의 어색한 해법도 있고, “논블로킹” 모드를 쓰는 방법도 있지만, 둘 다 문제를 안고 있다. 확실히 먹히는 방법은 디스크립터 준비 메커니즘을 사용하는 것이다. “이 여러 대상 가운데 읽을 게 생기면 깨워주고, 어느 것들인지 알려달라”고 요청하는 식이다. 그 신호가 오면 보고된 대상들을 순회하며 각각 read()를 호출한다. 그러면 모두 읽을 게 대기 중이므로 블로킹되지 않는다.
select()는 디스크립터 준비에 대한 원조 유닉스 설비다. 적은 수의 대상에 대해서는 잘 동작하지만, 규모가 커지면 한계가 있다. 이를 보완하려고 poll()이 만들어졌지만, 자체적인 문제도 더했다. 그 다음으로 대부분의 유닉스 계열은 각자 개선된 시스템을 내놓았는데, 특히 리눅스의 epoll과 FreeBSD의 kqueue가 대표적이다. 이들은 수년에 걸쳐 확장되어 오늘날 사실상 기본 선택지가 되었다. 그래도 개념은 예전 방식과 같다. 관심 있는 여러 대상 중 어디에서든 일이 발생하면 프로세스를 깨워 달라고 시스템에 요청하고, 그다음 프로세스가 각 대상에 대해 동작을 수행하는 것이다.
하지만 io_uring은 다른 접근을 한다. 원래 문제로 돌아가 새 시각으로 바라보며 이렇게 말한다. 커널이 “준비가 됐다”고 알려줘서 우리가 동작을 취하는 대신, 우리가 커널에 “취하고 싶은 동작”을 알려주면, 조건이 맞는 순간 커널이 그 동작을 수행해 주면 어떨까?
어차피 우리가 원하는 건 거의 항상 이것이다. 전통적인 모델에선 사용자 프로그램이 커널에 “준비되면 알려달라”고 호출한 뒤, 곧장 다시 커널을 호출해 실제 동작을 수행하게 한다. 사이에 사용자 프로그램이 하는 일은 없다. 그렇다면 그 두 호출을 결합해 커널로 제출할 수 있다면, 사용자 프로그램은 아예 개입할 필요가 없다.
동작도 꽤 단순하다. 호출을 반으로 쪼갤 뿐이다. 요청을 내면, 어느 순간 완료되고 결과를 돌려받는다. 요청과 응답은 인코딩 방식이 다르다(실제 함수 호출이 아니라, 제출하는 메모리 버퍼에 호출 이름과 인자가 담긴다). 하지만 효과는 같다. 커널이 프로그램을 대신해서 무언가를 수행하도록 요청하는 것이다.
전체는 두 개의 큐로 동작한다. 서브미션 큐(submission queue)에는 서비스 요청을 넣고, 컴플리션 큐(completion queue)에서는 완료된 요청과 그 결과를 확인한다.
그럼 “링 버퍼(ring buffer)”나 “uring”은 뭐냐고? 사실 구현 세부사항에 가깝다. 내가 그동안 헷갈렸던 이유도 여기 있을 것이다. 설명마다 다들 링 버퍼에 흥분하다 보니 그게 정말 중요한 부분인 줄 알았다. 하지만 실제 느낌은 원격 API를 호출하는 것에 훨씬 가깝다.
그리고 이것이 단지 시스템 콜을 수행하는 또 다른 방식이기 때문에, 전통적으로 비동기가 어려웠던 파일 IO를 포함해 거의 모든 것에 적용된다. 그래서 많은 프로그램을 훨씬 단순하게 만들 잠재력이 있다.
이걸 이해하고 나니 매우 인상적이었고, 내가 쓰는 더 많은 프로그램에서 이를 보게 되길 기대하고 있다. 다만 문서가 좀 더 잘 정리되어 있었으면 한다. 얼마나 많은 사람이 나처럼 ‘다른 무엇’이라고 오해해서 아직 살펴보지 않았을지 궁금하다. 누가 알겠는가!