(블로그 이사하면서 옮겨진 글입니다)
이런 저런 방식으로 LINQ를 쓰다보니 '메모리를 여기서 과하게 사용하고, 결과적으로 쓸데없이 잦은 GC 호출을 만들게 되는 건 아닐까?'라는 생각이 들었다. LINQ동작에 대해서 거의 모르는 상태로 쓰고있었기 때문에, 간단하게 몇가지만 테스트를 해봤다.
결론은 어떻게 써야하는건지 저도 잘 모르겠습니다. LINQ구문으로 부르는 구문들을 포함해서 IEnumerable과 연관된 메소드는 너무나 많고, 우리가 그 메소드의 구현체를 모두 기억하기는 어려운 일입니다. 더군다나 그 구현체의 코드가 버전업마다 바뀌지 않는다는 보장도 없기때문에, 매번 업데이트마다 체크해야한다고 생각하면 더더욱 어려운 일이 됩니다. 이걸 잘 통제하면서 능숙하게 쓰는 방법은 도저히 답이 안보이지만, 그래도 일단 알아낸 것들은 기록해두고자 생각했습니다.
printMemoryUsage는 사용한 메모리를 출력하는 함수고, 우리가 주목해야할 정보는 difference입니다.(코드는 github에서 확인할 수 있습니다.)
printMemoryUsage("");
var enumerator = Enumerable.Range(0, 20 * 1024);
printMemoryUsage(nameof(enumerator));
var list = enumerator.ToList();
printMemoryUsage(nameof(list));
var orderedEnumerator = enumerator.OrderBy(x => x);
printMemoryUsage(nameof(orderedEnumerator));
var select = enumerator.Select(x => x);
printMemoryUsage(nameof(select));
var whereAndSelect = enumerator.Where(x => true).Select(x => x);
printMemoryUsage(nameof(whereAndSelect));
var orderedList = enumerator.OrderBy(x => x).ToList();
printMemoryUsage(nameof(orderedList));
:: difference: 21KB, memory: 21KB
enumerator:: difference: 10KB, memory: 31KB
list:: difference: 128KB, memory: 159KB
orderedEnumerator:: difference: 0KB, memory: 159KB
select:: difference: 0KB, memory: 159KB
whereAndSelect:: difference: 0KB, memory: 159KB
orderedList:: difference: 128KB, memory: 287KB
아마 예상한대로, ToList를 호출하는 경우들에 한해서 실제로 데이터들이 담긴 List 콜렉션을 만들기 때문에 실제로 그만큼의 메모리를 할당하고, 그 외의 경우에는 메모리 할당을 하지 않습니다.(IEnumerable에 대한 메모리가 할당될 수는 있겠으나, 이 경우에선 매우 작은 크기이기 때문에 무시했습니다.)
여기까지는 예상대로였지만, 아래 예시는 저한테는 조금 의외의 결과를 보여주었습니다.
foreach (var iter_enumerator in enumerator) {
printMemoryUsage(nameof(iter_enumerator));
break;
}
foreach (var iter_list in list) {
printMemoryUsage(nameof(iter_list));
break;
}
foreach (var iter_orderedEnumerator in orderedEnumerator) {
printMemoryUsage(nameof(iter_orderedEnumerator));
break;
}
foreach (var iter_select in select) {
printMemoryUsage(nameof(iter_select));
break;
}
foreach (var iter_whereAndSelect in whereAndSelect) {
printMemoryUsage(nameof(iter_whereAndSelect));
break;
}
foreach (var iter_orderedList in orderedList) {
printMemoryUsage(nameof(iter_orderedList));
break;
}
iter_enumerator:: difference: 0KB, memory: 287KB
iter_list:: difference: 0KB, memory: 287KB
iter_orderedEnumerator:: difference: 208KB, memory: 495KB
iter_select:: difference: 0KB, memory: 495KB
iter_whereAndSelect:: difference: 0KB, memory: 495KB
iter_orderedList:: difference: 0KB, memory: 495KB
List객체를 기준으로 보자면 Iteration은 단순히 콜렉션에서 순서대로 읽어오는 것이고, IEnumerable을 기준으로 보아도 순서대로 다음 객체를 읽어오는 것이니 당연히 메모리 할당이 크게 될 일이 없습니다. 실제로 한가지 케이스를 제외하고는 메모리 할당이 전혀 되지 않고 있습니다. 하지만 iter_orderedEnumerator
에서만 큰 메모리 할당이 이루어지고 있습니다. 왜 그런지 확인해보니, OrderBy
의 리턴값은 IEnumerable
이 아니라 IOrderedEnumerable
입니다. 생각해보면 정렬된 값을 미리 들고있을리는 없으니, 값을 읽으려는 순간에 메모리를 사용하여 정렬하고 그 값을 사용하는 게 당연하긴합니다. 하지만 다른 LINQ 오퍼레이션은 아무 생각없이 썼는데 여기서만 갑자기 다른 동작을 보여서 역시 당황스럽긴합니다. 정말로 중요한 부분이라면 C#쪽 소스코드가 어떻게 되어있는지를 한번 확인하고 넘어가는게 좋을 것 같기도 합니다.
List
의 생성자 코드를 한번 확인해봅시다. [__DynamicallyInvokable]
public List(IEnumerable<T> collection)
{
if (collection == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
}
ICollection<T> collection2 = collection as ICollection<T>;
if (collection2 != null)
{
int count = collection2.Count;
if (count == 0)
{
_items = _emptyArray;
return;
}
_items = new T[count];
collection2.CopyTo(_items, 0);
_size = count;
return;
}
_size = 0;
_items = _emptyArray;
foreach (T item in collection)
{
Add(item);
}
}
ICollection
이 아니면 foreach
루프를 돌면서 아이템을 하나씩 추가해주는데, 시간 효율적인 방법은 아닐 것 같습니다. 그런데 IEnumerable은 ICollection이 아닙니다. 특별한 해결책을 제시하려는 건 아니지만, 메모리 효율 뿐만 아니라 이런 곳에서 시간효율도 조금씩 손해를 보고있을 수 있으니 성능이 아주 중요한 프로그램이라면 생각보다 많은 곳에서 디테일을 신경쓸 필요가 있어보입니다.