연속적으로 빠른 트랜잭션을 처리할 때, 객체 생성 속도와 GC 실행 주기 사이의 불균형으로 인해 메모리 및 CPU 사용량이 증가하는 상황이 발생할 수 있습니다. 이 글에서는 이러한 상황에서 객체 풀(Object Pool)의 사용 여부에 대한 아이디어를 검증한 결과를 공유하고자 합니다.
왼쪽이 오브젝트 풀을 적용한 결과이고, 오른쪽이 필요할 때마다 객체를 생성해서 사용한 결과입니다.
테스트 수행 시간에서 메모리 풀을 사용하는 쪽이 약간 더 빠른 것으로 측정되었습니다.
테스트에 사용된 오브젝트 풀이 CPU를 조금 더 사용하는 것으로 나왔으며, 수행시간에서는 큰 차이가 없습니다.
테스트 조건이 단순해서 이번 결과만으로 어떤 것이 좋다고 결론을 내리기는 어렵지만 일반적으로 크게 이점이 없어보입니다. 하지만 메모리 풀의 버퍼 크기 및 스레드 개수에 따라 성능 우위가 변할 수 있으며, 객체의 초기화 또는 해제 과정의 복잡성에 따라 객체 풀의 이점이 더욱 강조될 수 있습니다.
오브젝트 풀의 대상이 되는 객체의 클래스 코드는 다음처럼 단순합니다.
publicclassStep : IDisposable{privatebool _disposed = false;~Step(){Dispose(false);}publicvoidDispose(){Dispose(true);GC.SuppressFinalize(this);}protectedvirtualvoidDispose(bool disposing){if (_disposed) return;_disposed = true;if (disposing){// Dispose managed state (managed objects).}}public Step Parent { get; }publiclong TraceId { get; set; }public DateTimeOffset StartTime { get; }public TimeSpan Duration { get; set; }publicstring ResourceName { get; set; }}
아래는 Step 클래스를 IDisposable에서 상속받지 않고 더욱 단순하게 만들었을 때의 실행 시간 결과입니다.
오브젝트 풀의 수행 시간이 12배 정도 길어서 손해가 더욱 커진 것을 알 수 있습니다.
위의 테스트에 사용된 Step 클래스의 코드는 다음과 같습니다.
publicclassStep{public Step Parent { get; }publiclong TraceId { get; set; }public DateTimeOffset StartTime { get; }public TimeSpan Duration { get; set; }publicstring ResourceName { get; set; }}
멀티 스레드 환경에서 메모리 풀을 구현할 때 락을 사용하면 성능에 부정적인 영향을 미칠 수 있습니다. 따라서 락을 대신하여 Interlocked 연산을 사용하여 메모리 풀을 구현했습니다. 이를 위한 기본 알고리즘은 Full 상태가 없는 덮어쓰는 형식의 링 버퍼입니다. 이러면 현재 위치를 가리키는 인덱스를 증가시키기만 하면 되므로 Interlocked 연산을 사용하여 락 없이 스레드 안전한 자료 구조를 구현할 수 있습니다.
LazyRelease는 Release() 메서드만을 가지며, 이 메서드로 전달된 객체는 즉시 소멸하지 않고 링 버퍼의 크기만큼 유지됩니다. 그래서 이름을 뒤늦게 삭제된다는 의미로 LazyRelease라고 하였습니다.
publicclassLazyRelease{privateconstuint Capacity = 1 << 16; // 2^16privatereadonly T[] buffer = new T[Capacity];privateint tail = 0;public T Release(T value){int index = Interlocked.Increment(ref tail) & 0xFFFF;T oldItem = buffer[index];buffer[index] = value;return oldItem;}}
index가 증가하다 보면 index가 overflow되지 않도록 처음 위치로 순환시킬 필요가 있는데요. 아래의 코드처럼 변경하기 전의 값이 다른 스레드에 의해서 변화되지 않았는지 확인하면서 처리할 필요가 있습니다.
if (index > Capacity){Interlocked.CompareExchange(ref tail, index % Capacity, index);}
문제는 락만큼은 아니더라도 Interlocked도 상당히 CPU를 괴롭히는 명령어라는 점입니다. 그래서 LazyRelease 클래스에서는 overflow가 일어나더라도 문제가 없도록, Capacity를 index의 최댓값을 나누어 떨어트릴 수 있는 값을 사용하였습니다.
그리고 CPU를 더 괴롭히는 % 연산자 대신 & 연산자로 교체할 수 있게 되어 더욱 성능을 절약할 수 있게 되었습니다.
Interlocked Object Pool는 실제 오브젝트 풀을 구현한 클래스입니다. GetOrCreate() 메소드가 새로운 객체를 리턴해주는 기능을 제공합니다. 메모리 풀에서 가져올 수 있으면 해당 객체를 바로 리턴하지만, 실패하면 새로운 객체를 생성해서 리턴합니다.
메모리 풀에 오브젝트가 충분히 저장되어 있다고 해도 실패할 수 있는데요. 만약 이를 정확하게 처리하려면 결국 락을 걸어야 합니다. 락을 걸면 메모리 풀 사용의 이점보다 성능 이슈에 대한 비용이 더욱 커질 수 있어서 이렇게 실패를 허용하는 방식을 사용하게 된 것입니다.
publicclassInterlockedObjectPool{privateLazyRelease_buffer=newLazyRelease();public Step GetOrCreate(){StepnewObject=null;varold= _buffer.Release(null);if (old == null) newObject = newStep();else newObject = old;return newObject;}publicvoidRelease(Step step){_buffer.Release(step);}}