Rust로 비동기 프로그래밍을 하다 보면 tokio 라는 런타임(라이브러리)를 접하게 될 것이다. Tokio-rs를 사용하면서 알면 좋을만한 팁들을 정리해 보았다.

Bytes 라이브러리를 잘 사용하자

단순히 [u8;] 배열을 여기저기 옮겨다니면서 메모리 풀을 사용하는것도 좋지만, Bytes 라이브러리를 활용하는 것도 좋은 방법이다. 사실 왠만한 상황에서 bytes만큼 괜찮은 메모리 풀을 찾기도, 만들기도 쉽지 않을것이다.

BytesMut 활용

 IO 작업을 할 때 읽기/쓰기용 버퍼를 필연적으로 사용하게 된다. 이때 활용할 수 있는것이 BytesMut이다. BytesMut는 수정 가능한 (쓰기가 지원되는) 메모리 공간을 구현한다.

버전의 차이

Bytes 라이브러리가 0.4 버전일때는 미리 충분한 메모리 공간을 확보하지 않으면 데이터를 저장할 수 없었다. bytesMut::with_capacity(1024) 로 bytesMut을 생성했다면 딱 1024 바이트 까지만 저장 가능했던 것이다. 1024 바이트를 초과하여 데이터를 넣으면 패닉이 발생 했었다. (고정크기의 배열을 사용하는것과 동일했다.)

 이 점이 0.5 버전으로 올라오면서 메모리 공간이 부족하면 스스로 버퍼를 늘리도록 코드가 바뀌었다. bytesMut::with_capacity(1024) 로 bytesMut을 생성했었더라도 필요하면 자기 스스로 메모리 공간을 확장하도록 바뀐 것이다.

 0.4 버전에서는 메모리가 초과하면 프로그램이 아예 뻗어버리기에, 만에하나 생길 수 있는 문제를 고려하며 코드를 작성해야 했었다. 그러나 0.5버전 부터는 그런 영역을 bytesMut에 위임하며 보다 간단히 코드를 작성할 수 있게 되었다. 물론 미리 충분한 공간을 할당 받아서 메모리 재할당을 피하는것이 성능상 이점이 있을것이다.

 한편 메모리 크기에 대한 방어(체크)가 없으면 시스템 전체에 문제(Dos등)가 생길 수 있게 되었다. 간단한 TCP 서버를 만든다고 가정하자. 수신되는 데이터를 버퍼에 넣어야 할 것이다. 이 때 메모리 크기를 확인하지 않는다면 수신되는 모든 데이터를 메모리에 넣으려고 시도 할 것이다. TCP로 1GB의 데이터를 보내면 메모리가 1GB를 처묵처묵 할 것이다. 무한히 긴 데이터를 보내면? OOM 오류가 발생할 때 까지 스스로 메모리 공간을 확장하며 계속 메모리를 먹어치울 것이다. 이는 비단 프로그램뿐 아니라 시스템 전체에 악영향을 끼칠 수 있다.

얕은 복사 (Shallown Copy)

일반적인 메모리 버퍼 구현에서는 (특히 Rust에서는) 쓰레드를 넘나들거나 여러 지점에서 동시에 버퍼의 일부분을 접근 하려 할 때의 처리가 복잡했다. 매번 새로운 메모리 공간을 만들고 그곳으로 데이터를 복사해야 했다. C언어에서는 Heap을 바로 사용할 수 있었으나 Double Free등의 다양한 문제가 도사리고 있었다.

여기서 Bytes는 얕은 복사를 들고 나왔다. 실제 메모리 공간은 하나로 두고 포인터와 Reference Counter를 활용해서 메모리 공간을 안전하게 나눠쓸 수 있는 방법을 들고 나온것이다. 이것은  split(), split_off(), split_to() 함수를 이용하면 바로 사용 가능하다.

이때 split로 나눠진 bytesMut만큼 RC가 올라간다. RC는 할당받은 메모리 공간 전역에서 사용된다. 할당받은 메모리 공간내에서는 몇번을 split 하여도 해당 공간의 RC가 올라갈 뿐이다. 메모리 공간 해제는 해당 RC가 0이 되었을때 비로소 이루어진다. split한 일부분만 drop 된다고 끝이 아니다. split로 생겨난 모든 bytesMut을 제거해야 된다.

 단, BytesMut끼리 동일한 공간을 접근 할 수는 없다. BytesMut가 같은곳을 읽고 쓰게 된다면 의도치 않게 데이터가 훼손 될 수 있다. Rust언어에서 똑같은 변수에 두개 이상의 Mut 접근을 허용해 주지 않는것과 동일한 이유다. 같은 이유로 BytesMut을 Clone()하면 깊은 복사(deep Copy)가 발생한다. Mutable을 포기하면 동일한 공간을 바라볼 수 있다. freeze()를 통해 Bytes로 변환하면 가능하다.

Bytes 활용

 위의 BytesMut이 쓰기(수정)가 가능한 메모리 버퍼 구현이었다면, Bytes는 읽기만 가능한 메모리 버퍼 구현이다. 이름 뒤에 Mut이 빠진것을 보면 금세 알아차릴 수 있다. 읽기만 가능하다는 특성상 같은 데이터를 여러곳에서 돌려 쓸 수 있다. rust 언어에서 변수를 빌려줄때 &mut 은 하나밖에 못쓰지만 &은 여러곳에서 쓸 수 있다는것과 같은 맥락이다.

 Clone()이 작동하는 과정을 보면 이해가 편하다. Clone시 BytesMut는 모든 데이터를 복사해서 아예 새로운 메모리 공간을 만든다. BytesMut이 하나의 메모리 공간을 공유한다면, 같은 메모리 공간을 동시에 수정 할 수 있기 때문이다. 그러나 Bytes는 짜피 읽기 전용이기에 (데이터가 수정이 되지 않기에) 실제 메모리 공간은 그대로 둔다. 대신에 Reference Counter를 조작하므로서 메모리 접근을 관리한다. (윗 단인 BytesMut의 Shallown Copy 문단과 비슷하게 작동한다.)

sync::Channel 과의 조합

tokio로 TCP 서버를 구현하다 보면 channel을 이용해서 상호 소통을 해야할 경우가 생긴다. 그러다 보면 1:N으로 같은 데이터를 던져 줄 때가 꽤 많다. 이럴때 딱 쓰기 좋은것이 bytes다. 1만개의 channel에 태워보내도 bytes를 유지하기 위한 작은 공간을 제외하고는 실제 복사되는 데이터는 전혀 없다. bytes 구조체를 뜯어봐도 usize 크기의 변수 4개밖에 존재하지 않는다. (std::vec 도 구조 유지에만 이 정도를 쓴다. 구조체 + 실제 데이터를 고려하면 bytes가 나은것 같다.)