운영체제의 지원만 더 좋았다면 훨씬 더 유용했을, 연속된 가상 메모리 매핑을 이용한 마법의 링 버퍼 기법을 설명합니다.
이것은 제대로 된 OS 지원만 있었다면 훨씬 더 유용했을 귀엽고 작은 요령이다. 유용한 경우의 수는 꽤 적지만, 그래도 글로 남길 만한 가치는 충분하다.
여기서는 여러분이 링 버퍼가 무엇인지, 그리고 보통 어떻게 구현되는지 알고 있다고 가정하겠다. 나는 예전에 이 주제에 대해 글을 쓴 적이 있다. 그 글에서는 채워진 상태를 세는 여러 방법과, 그것이 불변식에 무엇을 의미하는지를 중심으로 다뤘다. 링 버퍼는 정말 훌륭한 자료구조이지만, 한 가지 문제는 링 버퍼와 직접 맞닿는 모든 것이 이 사실을 알고 있어야 하며, 올바르게 래핑을 처리해야 한다는 점이다. 인터페이스를 신중하게 설계하면 이것은 꽤 투명하게 처리할 수 있다. 내가 “Buffer-centric IO”에서 설명한 기법을 사용하면, 생산자는 데이터에 대한 소비자의 관점을 충분히 제어할 수 있어서 래핑을 완전히 투명하게 만들 수 있다. 하지만 이것은 IO 시스템 내부에서 복사를 피하는 멋진 방법이긴 해도, 클라이언트 코드까지 내려 보내기에는 너무 다루기 번거롭다. 많은 코드는 메모리 안에 데이터가 완전히 선형으로 놓여 있다고 가정하고 작성되므로, 그런 코드를 링 버퍼와 함께 사용하려면 먼저 데이터를 메모리 안의 선형 블록으로 복사해 내야 한다.
내가 “마법의 링 버퍼”라고 부르는 것을 쓰지 않는다면 말이다.
기저 개념은 아주 단순하다. 메모리 안에서 링의 동일한 복사본 여러 개를 서로 바로 옆에 배치해 링을 “풀어버리는” 것이다. 이론적으로는 임의 개수의 복사본을 둘 수 있지만, 실제로는 거의 모든 실용적인 용도에서 두 개면 충분하므로 여기서는 그것을 사용하겠다.
물론 여러 복사본의 동기화를 유지하는 일은 성가시고 올바르게 구현하기도 까다로우며, 사실상 모든 메모리 접근을 두 번씩 수행하는 것은 좋지 않은 생각이다. 다행히도 우리는 실제로 데이터의 물리적 복사본 두 개를 유지할 필요가 없다. 우리가 정말로 필요한 것은 같은 물리적 데이터가 서로 다른 두 개의 별도 메모리 위치에 존재하는 것이다. 이 위치들은 논리적이거나 가상적인 위치이며, 가상 메모리(페이징) 하드웨어는 이제 어디에나 있다. 페이징을 사용하면 몇 가지 외부 제약이 생긴다. 버퍼 크기는 프로세서의 페이지 크기의 배수여야 하고(가상 메모리가 더 거친 단위로 관리된다면 그보다 더 커야 할 수도 있다), 몇몇 정렬 제약도 만족해야 한다. 이런 제약을 받아들일 수 있다면, 우리가 진짜로 필요한 것은 사용자 공간에서 사용할 수 있는 호출을 이용해 같은 물리 메모리를 우리 주소 공간에 여러 번 매핑하도록 OS를 설득하는 방법뿐이다.
적절한 수단은 메모리 매핑 파일로 밝혀진다. 현대적인 OS는 일반적으로 익명 mmap을 지원하는데, 이것은 실제로 어떤 파일에도 뒷받침되지 않는 “메모리 매핑 파일”이다. 물론 경우에 따라 스왑 파일이 쓰일 수는 있다. 그리고 적절한 주문을 사용하면, 이런 OS는 실제로 같은 물리 메모리 영역을 연속된 가상 주소 범위 안에 여러 번 매핑하게 만들 수 있다. 바로 우리가 필요한 것이다!
나는 이 아이디어의 기본 구현을 Windows용 C++로 작성했다. 코드는 여기에서 볼 수 있다. 구현은 조금 아슬아슬한데(주석 참조), Windows가 메모리 매핑을 위해 메모리 영역을 예약하도록 허용하지 않기 때문이다. 내가 알기로는 이것은 할당에 대해서만 가능하다. 그래서 매핑할 수 있는 주소를 얻기 위해 약간의 요란한 절차가 필요하다. 우리가 찾아낸 메모리 범위를 해제한 뒤 우리 자신의 매핑을 완료하기 전 사이에 다른 스레드가 그 범위를 할당해 버릴 수 있으므로, 여러 번 재시도해야 할 수도 있다.
여러 Unix 계열에서는 같은 기본 원리를 시도할 수 있지만, 어떤 경우에는 실제로 백킹 파일을 만들어야 할 수도 있다. 순수하게 POSIX 기능에만 의존한다면 그것 없이 하는 방법은 보이지 않는다. Linux에서는 익명 공유 mmap 뒤에 remap_file_pages를 사용해서 이를 구현할 수 있어야 한다. 어느 Unix 계열을 사용하든 경쟁 조건 없이 구현할 수 있으므로, 그 부분은 훨씬 더 낫다. 물론 이 경우에는 백킹 파일이 없는 편이 정말 더 좋고, 많아야 RAM 디스크 위의 백킹 파일 정도가 적절하다. 이 일 때문에 디스크 IO를 일으키고 싶지는 않을 테니 말이다.
코드에는 이것이 실제로 동작하는 모습을 보여 주는 작은 예제도 들어 있다.
업데이트: 이제 댓글에는 MacOS X에서 같은 아이디어를 구현하는 방법을 설명하는 두 개의 글 링크도 있으며, 밝혀진 바로는 Wikipedia에 앞서 설명한 것과 같은 경쟁 조건 없는 POSIX 변형의 동작하는 코드도 있다.
이런 종류의 것이 유용한 경우는 몇 가지 있다. 서론에서 언급했듯이 그중 여러 경우가 IO와 관련되어 있다. 하지만 내가 처음 이걸 정말 원했던 경우는 스레드 간 통신이었다. 상황은 한 스레드가 가변 크기 명령을 생성하고 다른 스레드가 그것을 소비하며, 그 사이에 SPSC 큐가 놓여 있는 구조였다. 마법의 링 버퍼가 없으면 이것은 엄청난 번거로움이었다. 래핑은 이론적으로 명령 한가운데 어디에서든 일어날 수 있었고, 어쨌든 어떤 워드 경계에서든 발생할 수 있었다. 그래서 이 경우를 감지하고, “실제” 명령이 래핑되었을 때마다 링 버퍼에서 앞으로 건너뛰기 위한 특수 명령을 삽입해야 했다. 마법의 링 버퍼를 쓰면 모든 로직과 특수 경우가 그냥 사라지고, 낭비되는 메모리의 일부도 함께 사라진다. 엄청난 차이는 아니지만, 분명히 아주 만족스럽다.