흔하지 않은 코끼리 사냥하기

벌레 사냥은 재미있습니다. 그리고 때때로 손주들에게 지루하지 않게 들려줄 이야기("우리 때는 그래도 막대기와 돌로 벌레를 사냥했어" 등)를 가지고 살아서 도망치기도 합니다.
GDC 2014에는 트로피에 걸맞은 또 다른 사냥 사파리가 준비되어 있었습니다. Unity 5를 전 세계에 공개하기 5일 전, 우리는 새로운 64비트 에디터가 OSX에서 완전히 사용할 수 없을 정도로 무작위로 충돌하는 추악한 버그를 '발견'했습니다(놓치기 어려웠죠). 몇 분마다 무대에 올라 여러분의 버그 리포터가 얼마나 멋진지 보여주는 것만큼 좋은 것은 없습니다.
그래서 리바이와 조나단, 그리고 저는 손주들에게 들려주고 싶은 멋진 이야기들을 모두 내려놓고 스토킹에 나섰습니다. 그 당시에는 Mono가 런타임에 생성하는 네이티브 코드의 어딘가에서 충돌이 발생했다는 사실만 알고 있었습니다.
모든 프로그래머가 알다시피, 명확하지 않은 버그에 직면하면 증거를 수집하는 것부터 시작합니다. 버그의 행동 패턴에 대해 충분히 알게 되면 결국 버그를 잡을 수 있게 됩니다. 그리고 시간이 촉박해지면서 우리는 거의 모든 것을 촬영할 준비가 되어 있었습니다.
하지만 저희는 당황했습니다. 코끼리에게 이 버그는 놀라울 정도로 민첩하고 교활한 것으로 밝혀졌습니다.
김 씨는 윈도우에서도 메모리 디버거 브랜치를 통해 현저하게 유사한 현상을 발견했지만, 이는 OSX 10.9에서만 발생하는 것으로 보였습니다. 이전 버전의 OSX에서 Guard Malloc을 사용하도록 설정한 경우에도 상당히 비슷한 화면이 나타납니다. 그러나 호출 계층 구조의 임의의 깊이에서 임의의 스크립트 코드에서 충돌이 발생했기 때문에 무엇이 동일한 충돌이고 무엇이 그렇지 않은지 확실하게 말하기 어려웠습니다. 그리고 10회 연속으로 일관된 충돌이 발생하다가 다음 5회 동안은 완전히 달라질 수 있습니다.
그래서 킴과 제가 무릎 높이의 메모리와 허벅지 높이의 어셈블리 코드를 헤쳐나가는 동안, 리비는 모노의 비밀스러운 활동과 그렇지 않은 활동을 모두 추적하여 기가바이트의 로그와 할머니의 속도로 실행되는 편집기를 생성했습니다. 이를 통해 흥미로운 첫 번째 인사이트를 얻었습니다. 상황이 나빠지기 직전에 충돌한 메서드를 항상 컴파일하고 있었다는 사실입니다.
하지만 충돌이 발생한 이유는 무엇일까요? 즉각적인 원인은 잘못된 주소에서 코드를 실행하려고 했기 때문입니다. 어떻게 그렇게 되었나요? 모노의 신호 처리에서 제대로 재개되지 않는 버그가 있나요? 컴파일된 코드로 제대로 돌아가지 않는 Mono의 JIT 컴파일러 버그가 있나요? 다른 스레드가 메인 스레드의 스택 메모리를 손상시키나요? 요정과 그루밍족? (잠시 동안은 후자가 가장 가능성이 높아 보였습니다).
이틀간의 사냥이 끝난 후에도 코끼리는 여전히 건강하게 살아서 돌아다니고 있었습니다.
그래서 토요일 밤, 저는 노트북과 네 가지 색상의 펜, 그리고 유니티의 트레이드마크인 냉장고에 있는 맥주를 충분히 준비했습니다(아직 틈새에 꽂혀 있는 끔찍한 크리스마스 캔 맥주를 건드리지 않도록 조심하면서요). 그런 다음 디버거에서 네 가지 크래시가 멈출 때까지 Unity 인스턴스를 실행하고 "빨간색 크래시", "파란색 크래시", "녹색 크래시", "검은색 크래시"라고 표시한 다음 각각 다른 색상의 펜으로 메모하고 발견한 모든 것을 그리 예쁘지는 않지만 다이어그램으로 그렸습니다.
블루 크래시에 대한 제 메모는 다음과 같습니다:
그리고 그때 처음으로 발견한 것이 있었는데, 모든 경우에서 스택이 원래보다 16바이트 더 컸다는 것입니다!
그 후 다음 발견으로 이어졌습니다. 모든 충돌에서 여분의 16바이트를 살펴보니 충돌한 함수의 리턴 주소가 다시 나타났습니다. 추적을 통해 모든 경우에 이미 동일한 방법으로 일부 호출을 실행했음을 알 수 있었고, 처음에는 그 주소가 마지막으로 추적했던 호출의 주소라고 생각했습니다. 하지만 자세히 살펴보니 실제로는 메서드가 아직 컴파일되지 않은 호출의 반환 주소였습니다!
어떤 경우에는 마지막 추적된 메서드와 이 호출 사이에 아직 컴파일되지 않은 호출이 여러 번 있었기 때문에 잠시 당황했습니다. 하지만 자세히 살펴보면 우리가 항상 그 주위를 뛰어다녔다는 것을 알 수 있습니다.
그래서 우리가 분명히 반환해야 하는 함수를 살펴봤는데...
여기 있습니다(파란색으로 강조 표시됨): 우리는 엉뚱한 방향으로 뛰어들고 있었습니다!
여기서 Mono가 하는 일은 JIT 컴파일러에 대한 호출과 호출 후 명령어 스트림에 인코딩된 일부 데이터(컴파일할 메서드를 파악하기 위해 JIT 컴파일러에서 사용)만 포함하는 작은 "트램펄린" 함수를 만드는 것입니다. JIT 컴파일러가 작업을 완료하면 해당 트램폴린을 삭제하고 메서드 호출에 연결한 모든 흔적을 지웁니다.
그러나 여기에 표시되는 호출 명령어는 부호화된 32비트 오프셋을 사용하여 다음 명령어를 기준으로 점프하는 '니어 콜'이라고 하는 호출 명령어입니다.
부호화된 32비트 숫자는 위아래로 2GB밖에 되지 않는데, 여기서는 64비트를 실행하고 있기 때문에 힙 메모리 레이아웃이 버그 재현에 중요한 역할을 하는 이유를 갑자기 알게 되었습니다. 모노의 트램폴린이 JIT 컴파일러에서 2GB 이상 떨어져 있으면 오프셋이 32비트에 더 이상 맞지 않아 호출 명령을 내릴 때 잘릴 수 있다는 것이었습니다.
그 시점에서 조나단은 재빨리 올바른 수정 사항을 찾아냈고 일요일이 끝날 무렵에는 GDC에 맞춰 안정적으로 작동하는 빌드를 준비할 수 있었습니다.
여러분 모두 그 역사를 잘 알고 계실 겁니다. 유니티는 GDC 2014에서 Unity 5를 성공적으로 시연하여 극찬을 받았으며, 출시 후 빠르게 가장 사랑받는 소프트웨어로 자리 잡았습니다. 아, 그 부분은 아직 오지 않았는데...
그 출시 전에 수정해야 할 검은색과 파란색 충돌이 훨씬 더 많습니다 :).