본문

테크
Interlocked Object Pool 검증 결과

작성일 2023년 11월 09일
main

연속적으로 빠른 트랜잭션을 처리할 때, 객체 생성 속도와 GC 실행 주기 사이의 불균형으로 인해 메모리 및 CPU 사용량이 증가하는 상황이 발생할 수 있습니다. 이 글에서는 이러한 상황에서 객체 풀(Object Pool)의 사용 여부에 대한 아이디어를 검증한 결과를 공유하고자 합니다.

테스트 결과 및 평가

CPU와 메모리 사용량 측정 결과

왼쪽이 오브젝트 풀을 적용한 결과이고, 오른쪽이 필요할 때마다 객체를 생성해서 사용한 결과입니다.

object_pool_result
  • 메모리 사용량은 눈에 띄는 차이가 보이지는 않았습니다.
  • 오브젝트 풀을 사용하지 않으면 GC가 실행될 때마다 객체가 반환되면서 메모리 사용량 변화가 조금 큰 것으로 측정되었습니다.
  • CPU 사용량은 오브젝트 풀을 사용하는 쪽이 높았으며 사용량 변화도 컸습니다.

테스트 수행 시간 측정 결과

테스트 수행 시간에서 메모리 풀을 사용하는 쪽이 약간 더 빠른 것으로 측정되었습니다.

object_pool_result

평가

테스트에 사용된 오브젝트 풀이 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배 정도 길어서 손해가 더욱 커진 것을 알 수 있습니다.

object_pool_result

위의 테스트에 사용된 Step 클래스의 코드는 다음과 같습니다.

   publicclassStep{public Step Parent { get; }publiclong TraceId { get; set; }public DateTimeOffset StartTime { get; }public TimeSpan Duration { get; set; }publicstring ResourceName { get; set; }}
   

메모리 풀의 코드 살펴보기

LazyRelease class

멀티 스레드 환경에서 메모리 풀을 구현할 때 락을 사용하면 성능에 부정적인 영향을 미칠 수 있습니다. 따라서 락을 대신하여 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

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);}}
   
GC 메모리 사용량이 궁금하다면?
와탭 서버 모니터링 알아보기
류종택[email protected]
Development TeamAPM Agent Developer

지금 바로
와탭을 경험해 보세요.