GC 가 있는데도 메모리는 샌다
GC 가 있는 언어에서 메모리 누수라는 말은 좀 이상하게 들립니다. 알아서 치워 주는데 뭐가 샌다는 걸까요.
C 에서 말하는 누수와는 다른 것입니다. 거기서는 주소를 잃어버려서 못 치우는 것이었습니다. C# 에서는 그 반대입니다.
안 쓰는데 뿌리에서 손이 닿습니다. GC 는 손이 닿으면 살아 있는 것으로 봅니다.
GC 는 우리가 뭘 쓸 생각인지 모릅니다. 가리키는 줄이 하나라도 남아 있으면 치우지 않습니다. 그러니 새는 것은 GC 의 잘못이 아니라 우리가 안 놓은 것입니다.
가장 흔한 범인은 이벤트다
이게 압도적으로 많습니다. 그리고 방향을 거꾸로 알고 있으면 절대 못 찾습니다.
class 게임
{
public event Action 게임오버;
}
class 팝업
{
public 팝업(게임 g)
{
g.게임오버 += 보여주기;
}
void 보여주기() { ... }
}
팝업 이 게임 을 구독합니다. 그래서 “팝업이 게임을 붙잡는다” 고 생각하기 쉽습니다.
실제로는 반대입니다.
+= 로 넘긴 것은 그 메서드를 들고 있는 객체를 가리키는 대리자입니다.
그러니 게임 의 이벤트 목록이 팝업 을 가리킵니다.
오래 사는 쪽이 짧게 사는 쪽을 붙잡는 모양이면 무조건 샙니다.
게임 이 매니저나 static 이면 팝업 은 프로그램이 끝날 때까지 안 죽습니다.
팝업을 백 번 열고 닫으면 죽은 팝업이 백 개 쌓입니다. 그리고 게임오버가 나면 백 개가 전부 실행됩니다.
메모리보다 이쪽이 먼저 티가 납니다. 없앤 창이 반응하거나, 같은 소리가 여러 번 나거나, 이미 파괴된 객체를 만져서 터집니다.
해결은 붙인 만큼 떼는 것뿐입니다.
public void 닫기()
{
_게임.게임오버 -= 보여주기;
}
+= 를 적을 때 -= 를 적을 자리를 같이 정해 두는 것이 습관으로는 제일 낫습니다.
Dispose 안이든 OnDestroy 든, 짝이 되는 자리가 없으면 그 구독은 언젠가 샙니다.
두 번째: static 에 넣고 안 뺀다
static 은 뿌리입니다. 앞에서 본 그대로입니다.
static class 기록
{
public static List<로그> 전체 = new List<로그>();
}
여기 넣기만 하고 안 빼면 프로그램이 끝날 때까지 안 치워집니다.
그리고 로그 가 다른 객체를 가리키고 있으면 그것들도 전부 딸려서 삽니다.
캐시가 특히 이 모양이 되기 쉽습니다. 빠르게 하려고 만든 것이 제일 큰 짐이 됩니다. 넣는 코드를 쓸 때 빼는 조건을 같이 정합니다. 개수 상한이든 시간이든 하나는 있어야 합니다.
세 번째: 람다가 생각보다 많이 잡는다
class 화면
{
private byte[] _큰버퍼 = new byte[10_000_000];
public void 등록(타이머 t)
{
t.틱 += () => Console.WriteLine("한 번");
}
}
이 람다는 화면 의 아무것도 안 씁니다. 그런데 이렇게 적으면 안전하지 않습니다.
람다가 인스턴스 멤버를 하나라도 쓰면 컴파일러는 this 를 통째로 잡습니다.
this 를 잡으면 _큰버퍼 도 같이 잡힙니다. 십 메가가 타이머에 매달립니다.
위 코드는 아무것도 안 써서 괜찮지만, 나중에 누가 한 줄 고쳐서 필드를 하나 쓰면 그날부터 샙니다.
필요한 값만 지역 변수로 먼저 꺼내 두면 this 를 안 잡습니다.
public void 등록(타이머 t)
{
int n = _개수; // 필요한 것만 꺼내고
t.틱 += () => Console.WriteLine(n); // 화면 전체는 안 잡습니다
}
어떻게 찾나
늘 그렇듯 재는 것이 먼저입니다.
가장 싼 방법은 같은 동작을 반복하고 앞뒤를 비교하는 것입니다. 화면을 백 번 열고 닫은 뒤에도 메모리가 제자리로 안 돌아오면 그 화면이 새는 것입니다.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long 전 = GC.GetTotalMemory(true);
// 열고 닫기를 100번
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long 후 = GC.GetTotalMemory(true);
앞에서 GC.Collect() 를 부르지 말라고 했는데 여기는 예외입니다.
성능을 위해 부르는 것이 아니라 재려고 부르는 것이고, 재는 코드는 배포본에 안 들어갑니다.
소멸자까지 기다리려고 두 번 부릅니다.
숫자가 늘어난 것을 확인했으면 그다음은 프로파일러입니다. 스냅샷을 두 번 찍고 늘어난 객체를 보면, 대개 그 객체를 누가 붙잡고 있는지까지 보여 줍니다. 그 경로에 이벤트가 있으면 위의 첫 번째 경우입니다.
놓아 주고 싶은데 필요할 때는
캐시처럼 “있으면 쓰고 없으면 다시 만들면 되는” 것이 있습니다. 그럴 때는 붙잡지 않는 참조를 쓸 수 있습니다.
var 약한참조 = new WeakReference<큰데이터>(데이터);
if (약한참조.TryGetTarget(out var d))
쓴다(d);
else
다시만든다();
WeakReference 는 뿌리에서 손이 닿는 것으로 치지 않습니다.
그래서 GC 가 자리가 모자라면 그냥 치워 버립니다. 우리는 없어졌을 수도 있다고 가정하고 씁니다.
자주 쓸 물건은 아닙니다. 다시 만드는 것이 싸고, 없어도 프로그램이 멀쩡할 때만 맞습니다.
이벤트 누수를 이걸로 막으려고 하는 것보다 -= 를 제대로 적는 편이 훨씬 낫습니다.
정리
- C# 의 누수는 주소를 잃어버린 것이 아니라 안 쓰는데 손이 닿는 것입니다
- 제일 흔한 범인은 이벤트입니다. 발행자가 구독자를 붙잡습니다. 방향이 생각과 반대입니다
- 오래 사는 쪽이 짧게 사는 쪽을 구독하고 있으면 무조건 샙니다.
+=옆에-=자리를 정해 둡니다 static컬렉션은 뿌리라 넣기만 하면 영원히 삽니다. 빼는 조건을 같이 정합니다- 람다가 인스턴스 멤버를 하나라도 쓰면
this를 통째로 잡습니다. 필요한 값만 꺼내 씁니다 - 같은 동작을 반복하고 앞뒤 메모리를 비교하는 것이 제일 싼 확인법입니다
- 붙잡지 않는 참조가 필요하면
WeakReference가 있지만, 쓸 자리는 좁습니다