파이썬의 버퍼 프로토콜과 러스트의 메모리·가변성 모델이 만날 때 발생하는 데이터 경쟁, 미정의 동작, 그리고 사운드함 사이의 긴장을 설명하고, pyo3의 접근법과 현재의 한계, 그리고 가능한 개선 방향을 논의한다.
2022년 10월 23일 일요일
내가 가장 싫어하는 버그 유형 중 하나는 두 개의 서로 다른 시스템이 상호작용할 때 결과가 나쁜 동작을 보이는데, 어느(혹은 둘 중 어느 것도!) 시스템이 잘못인지 말하기 어려운 경우다. 이 글은 그런 이야기 중 하나로, 파이썬의 버퍼 프로토콜과 러스트의 메모리 모델에 관한 것이다.
파이썬의 버퍼 프로토콜은 파이썬 객체가 그 뒤에 있는 메모리를 노출해 서로 다른 데이터 구조 간에 0-복사(0-copy) 상호운용성을 가능하게 하는 API 집합이다. 예를 들어, 이미지 파싱 라이브러리와 numpy 사이에서 메모리를 무손실로 공유하는 데 쓸 수 있다. 또한 다차원 배열이나 서로 다른 타입의 배열 같은 더 고급 상호운용성을 위한 다양한 메타데이터도 지원한다. 하지만 이 글의 나머지 부분에서는 단순화를 위해 이것을 uint8_t *와 길이만 있다고 가정하겠다.
파이썬 객체가 있고 그 버퍼를 얻고 싶다면, 파이썬에서는 memoryview, C에서는 PyObject_GetBuffer로 할 수 있다. 만약 클래스를 정의하고 버퍼를 노출하고 싶다면… 사실 파이썬만으로는 할 수 없고, C로 구현된 클래스만 버퍼 프로토콜을 구현할 수 있다. C에서 버퍼 프로토콜을 구현하려면 bf_getbuffer와 bf_releasebuffer 함수를 제공해야 하며, 각각 객체에서 버퍼를 얻을 때와 그 버퍼가 해제될 때 호출된다.
이제 조금 방향을 바꿔 데이터 레이스에 대해 이야기해 보자. 데이터 레이스는 동기화 없이 서로 다른 스레드에서 동일한 주소에 대한 쓰기와 읽기(또는 쓰기와 쓰기)가 일어날 때 발생하는 레이스 컨디션의 한 유형이다. 동기화는 락일 수도 있고 명시적인 원자적(atomic) 연산일 수도 있다. 데이터 레이스는 C에서 미정의 동작이다1. 미정의 동작은 “코드가 종종 당신이 원하는 대로 동작하겠지만, 컴파일러는 보안 취약점을 유발하는 것까지 포함해 무엇이든 마음대로 해도 된다”는 뜻의 라틴어다. 미정의 동작은 피해야 한다.
파이썬 버퍼 객체에서 데이터 레이스가 가능할까? 안타깝지만, 가능하다. 버퍼 프로토콜을 구현한 객체가 있다고 하고, 거기서 버퍼를 두 번 요청한다고 해 보자. 그러면 같은 메모리 위치를 가리키는 포인터 두 개를 얻게 된다. 이제 두 개의 스레드를 띄워 하나는 버퍼에서 읽고 다른 하나는 거기에 쓴다고 하자. 데이터 레이스가 생겼다.
아마 이렇게 생각할 수도 있다. “GIL이 이걸 막아주지 않나?” 순수 파이썬 코드만 상상한다면, 그렇다. GIL은 락이므로 접근이 동기화된다. 하지만 버퍼 프로토콜의 목표 중 하나는 C 확장이 버퍼를 처리하는 동안 GIL을 해제할 수 있게 하는 것이다. 따라서 우리 버퍼에 대한 읽기/쓰기는 GIL을 해제한 C 확장에서 일어날 수 있다. 이제 더는 동기화가 없다.
읽기와 쓰기가 동일한 C 확장에서 온다면, 그건 그 확장의 버그라고 말할 수도 있다. 하지만 만약 완전히 별개의 패키지에서 온다면(그게 바로 버퍼 프로토콜의 요점!)? 어느 쪽도 버그가 아니다. 버퍼에서 읽거나 쓰는 것 자체는 완전히 올바르다. 그렇다면 이런 두 작업을 병렬로 호출한 파이썬 코드가 버그라는 뜻이 된다. 그러나 파이썬 코드(설령 버그가 있는 파이썬 코드라도!)로 C 수준의 미정의 동작을 유발해서는 안 된다. 그게 파이썬 같은 고수준 언어를 쓰는 이유의 일부이기도 하다. 버퍼 프로토콜의 설계와 C의 데이터 레이스 미정의 동작 개념은 이 점에서 잘 맞지 않는 듯하다.
이제 러스트를 이야기해 보자. 러스트에서는 메모리에 연속한 객체와 그 길이를 슬라이스로 표현한다. 바이트 슬라이스는 &[u8]로, 가변 슬라이스는 &mut [u8]로 쓴다. 러스트는 간단하지만 강력한 규칙 하나를 구현한다. 참조는 가변이거나 공유일 수 있지만, 둘 다일 수는 없다(XOR). 즉, 어떤 메모리에 대한 가변 참조가 존재한다면 그 참조만 유일하게 존재해야 한다. 반대로, 어떤 메모리에 대한 참조가 여러 개 존재한다면 그것들은 모두 불변(immutable) 참조여야 한다. 이 규칙은 기본적인 함의를 갖는다. 예를 들어 &[u8]을 가지고 있다면, 누군가가 당신 몰래 그 메모리를 변경하고 있지 않다는 뜻이다. 가변 참조가 있을 수 없기 때문이다. 이는 C/C++의 const 개념(“이 참조로는 변경할 수 없지만, 다른 가변 참조는 존재할 수 있다”)과는 다르다.
러스트는 또한 C의 미정의 동작보다 더 강한 개념인 “사운드함(soundness)”을 도입한다. 대부분의 C 미정의 동작은 런타임에 무엇이 일어나는지로 정의된다. 사운드함은 함수가 실제로 어떻게 사용되었는지와 무관하게, 어떻게 사용될 수 있는지에 관한 것이다. 어떤 함수가 받는 인자의 모든 조합에 대해 미정의 동작을 유발할 수 없다면 그 함수는 사운드하다. 반대로, 안전 함수(즉 unsafe fn이 아닌 함수)인데도 미정의 동작을 유발할 수 있다면 그 함수는 언사운드(unsound)하다. 러스트 커뮤니티는 모든 언사운드 사례를 보안 이슈로 간주한다. 설사 실무에서 가능성이 낮더라도 말이다. 가변 XOR 공유 규칙을 위반하기 위해 unsafe를 사용하는 것은 미정의 동작이며, 따라서 언사운드하다.
파이썬 버퍼가 있고, 그 데이터를 러스트에서 표현하고자 한다면, 어떻게 해야 할까? 가장 자연스러운 답은 &[u8]일 것이다. 하지만 앞서 본 것처럼, 동시 쓰기의 가능성이 있는 상황에서는 이것이 언사운드하다. 마찬가지로 &mut [u8]도 받아들일 수 없다. 파이썬 버퍼 프로토콜은 한 번에 단 하나의 가변 버퍼만 내어준다는 보장을 제공하지 않기 때문이다. 중요한 점은, 러스트의 언사운드함 개념은 소스 코드 수준의 우려 사항이기 때문에, 설령 파이썬 코드가 실제로는 이런 방식으로 여러 버퍼를 만들지 않더라도 그 코드는 여전히 언사운드하다는 것이다.
pyo3는 CPython C-API에 바인딩하기 위한 인기 있는 러스트 라이브러리다. pyo3의 해결책은 내부 가변성(interior mutability)이다. 이는 러스트 코드에서 구조체가 공유 참조로도 안전하게 변경을 캡슐화하는 패턴이다. pyo3에서 파이썬 버퍼의 내용은 &[ReadOnlyCell<u8>]로 표현된다. 이는 안전하고 사운드하지만, 안타깝게도 상호운용성에서는 어려움을 겪는다.
문제는 어떤 바이트 시퀀스를 러스트 라이브러리에 넘겨 파싱(혹은 그 밖의 처리를)하고 싶다면, 그 라이브러리는 거의 확실히 &[u8]을 기대한다는 점이다. 그런데 &[ReadOnlyCell<u8>]을 할당과 복사 없이 안전하게 &[u8]로 바꾸는 방법은 없다. 그리고 물론, 파이썬 버퍼 프로토콜의 요점 자체가 이런 비효율을 피하는 데 있다.
따라서 유감스럽게도, 지금 당장은 효율성, 상호운용성, 그리고 사운드함 이 세 가지를 모두 동시에 얻는 방법이 없다.
현 상황이 이렇다면, 무엇을 하면 개선할 수 있을까?
내가 생각해 낼 수 있는 가장 단순한 답은 파이썬의 버퍼 프로토콜이 러스트의 가변 XOR 공유 의미론을 구현하는 것이다. 이런 의미론을 제공하면 C 코드에서의 미정의 동작 가능성도 함께 해소할 수 있다. 또한 버퍼 프로토콜 구현자가 이런 의미론을 제공함을 신호할 수 있는 플래그를 제공함으로써, 하위 호환적으로도 할 수 있다. 그렇게 되면 해당 버퍼는 &[u8]로 안전하게 표현될 수 있다. 사실 버퍼 프로토콜의 구현자는 오늘이라도 이런 의미론을 제공할 수 있다. 문제는 버퍼를 요청하는 코드가 그것이 지켜지고 있는지 알 방법이 없다는 점뿐이다.
어쩌면 이 문제를 해결하는 다른 방법들도 있을지 모른다! 이 문제를 어떻게 해결할 수 있을지에 대한 다른 분들의 생각을 듣고 싶다. 러스트로 작성된 파이썬 확장 모듈의 존재감이 더 커짐에 따라, 파이썬 버퍼를 러스트에서 효율적이고, 상호운용 가능하며, 사운드하게 다루는 방법을 찾는 것이 중요해질 것이다.