2011년 06월 07일
Lua에서 사용자데이터(userdata)의 소멸 시기와 약참조(weak) 테이블
Lua 언어를 사용하여 스크립트 시스템을 구축하고, 객체지향을 흉내내던 중 메모리 관리때문에 발생한 문제에 대한 포스팅.
-----
내가 지금 회사에 와 처음 합류한 프로젝트에서는 Lua를 스크립팅 언어로서 사용하고 있었다.
그 때 당시 7개월 남짓 그 프로젝트에 있으며 스크립트 시스템쪽은 거의 손을 대지 않았는데, 요즘 무슨 바람이 불어서인지 그때의 Lua시스템을 현재 프로젝트에 흉내내어 적용하고 있는 중이다, 좀 더 버전업 된 C++ 연동 모듈을 받아서(thanx to rica).
그냥 단순한 사용만으로는 성이 차지 않아, C++의 객체를 그대로 Lua에서 사용하는 방법을 쓰고 있다. Lua에는 사용자데이터(userdata)라는 타입을 지원하여 임의로 C++의 메모리 인스턴스를 그대로 Lua에서 사용할 수 있게 해줄 뿐더러, 테이블/함수 매핑을 통해 마치 객체에서 메소드를 호출하는 듯한 형태로도 사용할 수 있다(Programming in Lua 참조). 거기에 메모리 수거(garbage collection) 시 행동을 메타테이블에 정의해두어 마치 Lua 내에서 소멸자와 같은 행동을 정의시킬 수도 있다!!!
여기까진 만능이다. 하지만 C++의 객체를 Lua에서 그대로 가져와 사용하기에는 두 가지의 문제가 있다. 하나는 C++측에서의 소멸을 Lua에서 제어하는 경우와 Lua측에서의 소멸을 C++쪽에서 관리해야 하는 경우, 두 경우를 모두 처리하는 범용적인 시스템을 설계하기가 매우 어렵다.
지금 사용하는 시스템은 C++쪽에서의 소멸 시점을 Lua쪽에서 제어하는 방식이 구현되어 있다. Lua에 넘기려는 모든 C++ 객체 인스턴스는 객체가 참조계수를 관리하는(intrusive) 스마트 포인터 객체여야 하고, Lua에 넘어오는 순간 참조계수가 1 증가한다. Lua의 사용자 데이터 인스턴스에는 해당 객체를 가리키는 포인터만 기억하며, Lua에서 메모리 수거 되는 순간(메타테이블의 __gc 함수 호출) 참조계수를 1 감소시킨다.
이 경우 문제가 되는 건 가만 놔두면 Lua의 VM은 아주 오랫동안 해당 객체에 메모리수거를 하지 않을 수도 있다는 것. 만약 그 객체의 소멸자에서 중요한 로직을 처리한다거나 하면 메모리 누수에 준하는 매우 심각한 상황이 발생할 수도 있다! 물론 수동으로 lua_gc나 garbagecollection을 호출한다거나, 아니면 가장 빠듯한 설정으로 메모리 수거 설정을 정할 수도 있지만 모두 다 일반적인 케이스에 대해 성능 저하를 담보로 한다.
그렇다면 C++에서 소멸이 될 수 있는 시점에 Lua쪽에서 강제로 삭제시키는 방법은 어떨까? 마침 약참조(weak reference) 테이블과 섞어쓰면 매우 그럴 듯한 해결책이 나올 수 있을 것 같았다. 마침 관리의 편의를 위해 이 시스템에서는 객체의 포인터값(경량 사용자 데이터)을 key로 하고 실제 사용자 데이터 값을 value로 하는 약참조 테이블을 사용하고 있었던 차였다!
그리하여 주변과 논의하여(thanx to ehooi) 강제 파괴라 명명한 방법을 적용해보았는데,
하지만.. 아주 간헐적으로 C++에서 소멸을 위한 준비작업이 진행되었음에도 Lua에서 참조계수를 놓지 않는 문제가 다시 발생하였다! 대체 원인이 뭐지 하고 내가 작성한 코드를 계속 살펴보고 도입 당시 받아온 Lua VM 연동 모듈을 아무리 살펴보아도 문제를 찾을 수 없었는데, 의외로 문제는 Lua의 메모리 수거 메커니즘에 있었다. 그것은 다름 아닌
Lua의 사용자데이터는 더 이상 참조가 없고 모든 약참조 테이블에서 제거되었지만 아직 실제 수거 동작(__gc 호출)은 진행되지 않은 시점이 존재한다
는 것(오늘 포스팅의 핵심이다). Lua의 메모리 수거는 여러 스텝으로 나뉘어 있으며 사용자가 명시적으로 완전한(full) 메모리 수거를 지시한다거나 매우 빠듯한 메모리 수거 정책을 쓰지 않는 한, 아주 천천히 메모리 사용량 증가에 따라 단계적으로 수거를 진행한다. 이 때, 어떤 사용자데이터가 더 이상 Lua 내에서 더 이상 직접 참조(약참조 제외)하는 곳이 없어 메모리 수거 대상이 되었을 때 약참조테이블들에서 지워지는 게 먼저 진행되고, 실제로 __gc 함수가 호출되고 메모리가 반환되는 시점은 한참 뒤 마지막 단계에서 수행되는 것. Lua 5.1.4 기준으로 사용자데이터가 약참조 테이블에서 지워지는 건 propagate 단계이고, 각 사용자데이터에 __gc 함수가 호출되는 것은 finalize단계이다. 따라서 그 중간 단계에서 더 이상 진행하지 않고 멈춰있다면 위의 방법도 소용이 없는 것이었다.
결국은.. 원래 의도했던 깔끔하고 일반적인 방법을 포기하고 일정부분 특정 사용 패턴에 맞추는 방법으로 재구상 중이다. 예를 들어 아예 Lua의 메모리 수거 패턴에 맡겨버릴 수 있는, 우리가 소멸시점을 굳이 알지 않아도 되는 종류와 직접 소멸시점을 다 알고 Lua에 통보할 수 있는 경우를 나누어 따로 관리한다던가 - 하는 방법.
아무튼 결론은

-----
내가 지금 회사에 와 처음 합류한 프로젝트에서는 Lua를 스크립팅 언어로서 사용하고 있었다.
그 때 당시 7개월 남짓 그 프로젝트에 있으며 스크립트 시스템쪽은 거의 손을 대지 않았는데, 요즘 무슨 바람이 불어서인지 그때의 Lua시스템을 현재 프로젝트에 흉내내어 적용하고 있는 중이다, 좀 더 버전업 된 C++ 연동 모듈을 받아서(thanx to rica).
그냥 단순한 사용만으로는 성이 차지 않아, C++의 객체를 그대로 Lua에서 사용하는 방법을 쓰고 있다. Lua에는 사용자데이터(userdata)라는 타입을 지원하여 임의로 C++의 메모리 인스턴스를 그대로 Lua에서 사용할 수 있게 해줄 뿐더러, 테이블/함수 매핑을 통해 마치 객체에서 메소드를 호출하는 듯한 형태로도 사용할 수 있다(Programming in Lua 참조). 거기에 메모리 수거(garbage collection) 시 행동을 메타테이블에 정의해두어 마치 Lua 내에서 소멸자와 같은 행동을 정의시킬 수도 있다!!!
여기까진 만능이다. 하지만 C++의 객체를 Lua에서 그대로 가져와 사용하기에는 두 가지의 문제가 있다. 하나는 C++측에서의 소멸을 Lua에서 제어하는 경우와 Lua측에서의 소멸을 C++쪽에서 관리해야 하는 경우, 두 경우를 모두 처리하는 범용적인 시스템을 설계하기가 매우 어렵다.
지금 사용하는 시스템은 C++쪽에서의 소멸 시점을 Lua쪽에서 제어하는 방식이 구현되어 있다. Lua에 넘기려는 모든 C++ 객체 인스턴스는 객체가 참조계수를 관리하는(intrusive) 스마트 포인터 객체여야 하고, Lua에 넘어오는 순간 참조계수가 1 증가한다. Lua의 사용자 데이터 인스턴스에는 해당 객체를 가리키는 포인터만 기억하며, Lua에서 메모리 수거 되는 순간(메타테이블의 __gc 함수 호출) 참조계수를 1 감소시킨다.
이 경우 문제가 되는 건 가만 놔두면 Lua의 VM은 아주 오랫동안 해당 객체에 메모리수거를 하지 않을 수도 있다는 것. 만약 그 객체의 소멸자에서 중요한 로직을 처리한다거나 하면 메모리 누수에 준하는 매우 심각한 상황이 발생할 수도 있다! 물론 수동으로 lua_gc나 garbagecollection을 호출한다거나, 아니면 가장 빠듯한 설정으로 메모리 수거 설정을 정할 수도 있지만 모두 다 일반적인 케이스에 대해 성능 저하를 담보로 한다.
그렇다면 C++에서 소멸이 될 수 있는 시점에 Lua쪽에서 강제로 삭제시키는 방법은 어떨까? 마침 약참조(weak reference) 테이블과 섞어쓰면 매우 그럴 듯한 해결책이 나올 수 있을 것 같았다. 마침 관리의 편의를 위해 이 시스템에서는 객체의 포인터값(경량 사용자 데이터)을 key로 하고 실제 사용자 데이터 값을 value로 하는 약참조 테이블을 사용하고 있었던 차였다!
그리하여 주변과 논의하여(thanx to ehooi) 강제 파괴라 명명한 방법을 적용해보았는데,
..라는 작업이었고, 간단한 테스트 결과 순조롭게 작업이 완료되었다.
"C++에서 소멸이 되는 시점에 Lua 모듈에 그것을 통보하면 Lua에서는 이 약참조 테이블을 뒤져 사용자데이터를 꺼내고, 마치 스마트포인터에 NULL값을 대입하듯이 포인터값을 지워주고 참조계수를 감소시켜주고, Lua에서는 해당 사용자데이터의 메타테이블을 제거해주면 설령 메모리 수거가 늦게 되어도 문제 없겠지. luserdata -> userdata 약참조 테이블에서는 명시적으로 제거해주자"
하지만.. 아주 간헐적으로 C++에서 소멸을 위한 준비작업이 진행되었음에도 Lua에서 참조계수를 놓지 않는 문제가 다시 발생하였다! 대체 원인이 뭐지 하고 내가 작성한 코드를 계속 살펴보고 도입 당시 받아온 Lua VM 연동 모듈을 아무리 살펴보아도 문제를 찾을 수 없었는데, 의외로 문제는 Lua의 메모리 수거 메커니즘에 있었다. 그것은 다름 아닌
Lua의 사용자데이터는 더 이상 참조가 없고 모든 약참조 테이블에서 제거되었지만 아직 실제 수거 동작(__gc 호출)은 진행되지 않은 시점이 존재한다
는 것(오늘 포스팅의 핵심이다). Lua의 메모리 수거는 여러 스텝으로 나뉘어 있으며 사용자가 명시적으로 완전한(full) 메모리 수거를 지시한다거나 매우 빠듯한 메모리 수거 정책을 쓰지 않는 한, 아주 천천히 메모리 사용량 증가에 따라 단계적으로 수거를 진행한다. 이 때, 어떤 사용자데이터가 더 이상 Lua 내에서 더 이상 직접 참조(약참조 제외)하는 곳이 없어 메모리 수거 대상이 되었을 때 약참조테이블들에서 지워지는 게 먼저 진행되고, 실제로 __gc 함수가 호출되고 메모리가 반환되는 시점은 한참 뒤 마지막 단계에서 수행되는 것. Lua 5.1.4 기준으로 사용자데이터가 약참조 테이블에서 지워지는 건 propagate 단계이고, 각 사용자데이터에 __gc 함수가 호출되는 것은 finalize단계이다. 따라서 그 중간 단계에서 더 이상 진행하지 않고 멈춰있다면 위의 방법도 소용이 없는 것이었다.
결국은.. 원래 의도했던 깔끔하고 일반적인 방법을 포기하고 일정부분 특정 사용 패턴에 맞추는 방법으로 재구상 중이다. 예를 들어 아예 Lua의 메모리 수거 패턴에 맡겨버릴 수 있는, 우리가 소멸시점을 굳이 알지 않아도 되는 종류와 직접 소멸시점을 다 알고 Lua에 통보할 수 있는 경우를 나누어 따로 관리한다던가 - 하는 방법.
아무튼 결론은

(이미지 출처 : 츄리닝, 국중록/이상신)
# by | 2011/06/07 23:29 | Programming | 트랙백 | 덧글(3)




