GC 는 한가할 때 도는 것이 아니다
Esc

    GC 는 한가할 때 도는 것이 아니다

    –C#최적화GC

    이 시리즈에서 “GC 가 언젠가 치운다” 는 말을 여러 번 했습니다. 박싱에서도, 확장 메서드에서도, LINQ 에서도 “쌓이면 GC 가 돈다” 로 끝냈습니다.

    그 언젠가가 언제인지 미뤄 뒀습니다. 마지막으로 그 이야기를 합니다.

    GC 는 한가할 때 도는 것이 아닙니다. new 를 했는데 자리가 없을 때 그 자리에서 돕니다.

    도는 시점은 우리가 못 고른다

    앞에서 힙을 봤습니다. new 를 하면 힙에 자리를 잡고, 아무도 안 가리키면 나중에 GC 가 치운다고 했습니다.

    그 나중이 언제인지 정하는 것은 이렇습니다.

    new 를 부릅니다. 힙에 자리가 남아 있으면 그냥 잡고 끝납니다. 아주 쌉니다. 자리가 없으면 그 순간 GC 가 돕니다. 도는 동안 우리 코드는 멈춰 있습니다.

    즉 GC 를 부르는 것은 할당입니다. 시간도 아니고 메모리 압박도 아니고 할당입니다. 그리고 할당은 우리 코드의 아무 줄에서나 일어날 수 있습니다.

    게임에서 이게 문제가 되는 이유가 여기 있습니다. 초에 예순 번 도는 루프 어딘가에서 할당이 임계치를 넘기면, 그 프레임 한가운데서 GC 가 돕니다. 평소 16밀리초에 끝나던 프레임이 갑자기 30밀리초가 됩니다. 화면이 한 번 걸립니다.

    평균은 멀쩡한데 가끔 튀는 것이 GC 때문인 경우가 많습니다.

    무엇을 치우고 무엇을 남기나

    GC 는 살아 있는 것부터 찾습니다. 버려진 것을 찾는 것이 아닙니다.

    앞에서 static 필드가 프로그램이 끝날 때까지 안 치워진다고 한 이유가 1번입니다. static 필드가 뿌리입니다. 그래서 static List 에 계속 넣기만 하면 그 안의 객체들도 전부 2번에서 살아 있는 것으로 표시됩니다.

    참조 세는 방식이 아닙니다. 서로 가리키는 두 객체가 있어도, 뿌리에서 손이 안 닿으면 둘 다 치워집니다. 순환 참조로 못 치우는 문제는 C# 에는 없습니다.

    세대로 나눠서 자주 도는 곳만 돈다

    객체를 하나 만들 때마다 힙 전체를 훑으면 너무 비쌉니다. 그래서 관찰 하나를 이용합니다.

    대부분의 객체는 아주 빨리 죽습니다. 메서드 안에서 만들어 쓰고 버려지는 것들입니다. 반대로 오래 산 것은 앞으로도 오래 삽니다.

    그래서 힙을 세 칸으로 나눕니다.

    무엇이 있나 얼마나 자주 도나
    0세대 방금 만든 것 자주. 아주 빠름
    1세대 0세대에서 한 번 살아남은 것 가끔
    2세대 오래 산 것 드물게. 대신 오래 걸림

    new 는 전부 0세대에 들어갑니다. 0세대가 차면 0세대만 훑습니다. 대개 밀리초 아래로 끝납니다. 거기서 살아남은 것은 1세대로 올라갑니다.

    문제는 2세대입니다. 힙 전체를 훑고 4번의 밀어 붙이기까지 하니 오래 걸립니다. 2세대가 도는 것은 드물지만, 돌면 프레임이 확실히 튑니다.

    그리고 큰 객체는 세대와 상관없이 따로 관리됩니다. 대략 85KB 를 넘는 것, 그러니까 큰 배열 같은 것입니다. 이쪽은 밀어 붙이기를 잘 안 해서 빈틈이 쌓입니다. 큰 배열을 계속 새로 만들면 여기가 지저분해집니다.

    여기까지는 .NET 런타임 이야기다

    한 가지 짚고 갑니다. 위의 세대와 밀어 붙이기는 .NET 런타임의 GC 가 그렇게 한다는 뜻입니다. C# 이라는 언어가 정한 것이 아닙니다.

    같은 C# 코드라도 어느 런타임 위에서 도느냐에 따라 GC 가 다르게 동작합니다. 예를 들어 유니티가 쓰는 GC 는 밀어 붙이기를 하지 않습니다. 살아남은 객체를 옮기지 않으니 빈틈이 그대로 남고, 그래서 힙이 조각조각 납니다.

    바뀌지 않는 것은 앞의 두 가지입니다. 할당이 GC 를 부른다는 것과 뿌리에서 손이 닿는 것만 살아남는다는 것입니다. 이 둘은 어디서든 같습니다. 그러니 대응도 같습니다. 할당을 줄이면 됩니다.

    그래서 무엇을 하면 되나

    GC 를 없앨 수는 없습니다. 덜 부르게 할 수 있을 뿐이고, 그 방법은 하나뿐입니다. 할당을 줄이는 것입니다.

    이 시리즈에서 나온 것들이 전부 여기로 모입니다.

    • 박싱을 안 한다. ArrayList 대신 List<int>, IComparable 대신 IComparable<T>
    • 문자열을 반복문에서 잇지 않는다. 값이 안 바뀌면 아예 안 만든다
    • 매 프레임 도는 자리에 LINQ 를 안 쓴다. 열거자와 람다 객체가 매번 생긴다
    • 리플렉션 결과를 들고 있는다. GetValue 는 박싱한다
    • struct 를 쓸 자리에 class 를 쓰지 않는다. 작고 많은 것은 힙에 안 가는 편이 낫다

    그리고 하나가 더 있습니다. 아예 다시 쓰는 것입니다.

    private readonly List<적> _버퍼 = new List<적>();
    
    void Update()
    {
        _버퍼.Clear();          // 새로 만들지 않고 비웁니다
        적찾기(_버퍼);
    }

    매 프레임 new List 를 하는 대신 하나를 만들어 두고 Clear() 로 비워 씁니다. 이 생각을 객체 전체로 확장한 것이 오브젝트 풀입니다. 총알이나 이펙트처럼 계속 만들고 버리는 것을 미리 만들어 두고 껐다 켜는 것입니다. 언제 이득이고 언제 손해인지는 그쪽에 따로 정리해 뒀습니다.

    하지 말아야 할 것

    GC.Collect() 를 직접 부르지 않습니다.

    GC.Collect();   // 대개 손해입니다

    강제로 부르면 2세대까지 전부 돕니다. 가장 비싼 것을 일부러 부르는 셈입니다. 그리고 아직 죽지 않은 객체를 억지로 1세대, 2세대로 올려 보내서 나중을 더 나쁘게 만듭니다.

    쓸 만한 자리가 아주 없지는 않습니다. 로딩 화면처럼 어차피 멈춰 있는 순간에 큰 것을 한꺼번에 버린 직후라면 여기서 한 번 치우는 편이 낫습니다. 그 밖에는 GC 가 알아서 하게 두는 편이 낫습니다.

    재 보지 않고 고치지 않습니다. 프레임이 튀는 이유는 GC 말고도 많습니다. 유니티라면 프로파일러의 GC Alloc 열을 먼저 봅니다. 프레임당 할당이 0 인데 GC 를 의심하는 것은 시간 낭비입니다.

    정리

    • GC 는 시간이 아니라 할당 때문에 돕니다. new 했는데 자리가 없으면 그 자리에서 돕니다
    • 그래서 게임에서는 프레임 한가운데서 돌고, 그 프레임이 튑니다
    • 버려진 것을 찾는 게 아니라 뿌리에서 손이 닿는 것을 찾습니다. 나머지는 지웁니다
    • static 필드가 뿌리라서, 거기 매달린 것은 프로그램이 끝날 때까지 안 치워집니다
    • 0세대는 싸고 2세대는 비쌉니다. 대부분의 객체가 금방 죽는다는 관찰을 이용한 것입니다
    • 줄이는 방법은 하나입니다. 할당을 줄이거나, 만든 것을 다시 쓰는 것입니다
    • GC.Collect() 를 직접 부르는 것은 대개 손해입니다

    그런데 GC 가 아는 것은 힙에 있는 것뿐입니다. 프로그램이 쓰는 것 중에는 힙에 없는 것도 있습니다. 파일이나 소켓처럼 운영체제가 빌려준 것들입니다.

    그런 것들은 GC 가 치워 주기를 기다리면 안 됩니다. 다 쓴 즉시 닫아야 합니다.