Утечка памяти в приложении WPF с использованием ListView с ObservableCollection

У меня есть приложение WPF, использующее этот ListView:

<ListView ItemsSource = "{Binding Log.Entries}"
          VirtualizingPanel.IsVirtualizing = "True"
          VirtualizingPanel.IsVirtualizingWhenGrouping = "True">
    <i:Interaction.Behaviors>
        <afutility:AutoScrollBehavior/>
    </i:Interaction.Behaviors>
    <ListView.ItemTemplate>
        <DataTemplate>
            <WrapPanel>
                <TextBlock Foreground = "{Binding Path=LogLevel, Mode=OneTime, Converter = {StaticResource LogLevelToBrushConverter}}"
                           Text = "{Binding Path=RenderedContent, Mode=OneTime}"/>
            </WrapPanel>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

Log.Entries заканчивается экземпляром этого класса (сам список никогда не заменяется, это всегда один и тот же объект):

public class RingList<T> : IList<T>, INotifyCollectionChanged, INotifyPropertyChanged

Этот класс, по сути, представляет собой настраиваемый список, который ограничивает свое содержимое 100 элементами. Добавление элемента на полную мощность удаляет элемент из головы. Для каждого добавленного/удаленного элемента я вызываю CollectionChanged следующим образом:

// For added items
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, Count - 1));

// For removed items (only ever removes from the start of the ring)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, headItem, 0));

Элементы коллекции НЕ являются объектом, они представляют собой структуру, подобную этой:

public struct RecordedLogEntry : IEquatable<RecordedLogEntry> {
    public string RenderedContent { get; set; }
    public Level LogLevel { get; set; }

    // [...] Equals, GetHashCode, ToString, etc omitted for brevity, all standard
}

Мне известно, что привязка к объектам, отличным от INotifyPropertyChange, может вызвать утечку памяти (см.: Могут ли привязки вызывать утечку памяти в WPF?)

Вот почему я использовал Mode=OneTime, чтобы (надеюсь) избежать этого. Однако профилирование говорит на другом языке.

Это дамп памяти, сделанный во время выполнения после нескольких часов работы, что обычно приводит к тому, что системе не хватает памяти, если ее не обработать:

Вы можете ясно видеть:

  • 100 предметов в закрытой коллекции
  • Немного выше этого, 12 элементов, на которые ссылаются видимые в данный момент экземпляры ListViewItem (поскольку представление списка виртуализирует элементы, это примерно ожидаемое количество)
  • более 700 тыс. экземпляров, на которые ссылается сам ListView
  • В результате огромное количество данных, которые накапливались с течением времени

В проекте используется .NET 4.7.2 в Windows.

Как избежать этой утечки?

Изменить, не обращая внимания на это требование:

В идеале я не хочу отказываться от структуры, так как многие из этих элементов создаются в фоновом режиме (которые не все доходят до отображаемых 100 элементов), поэтому я хочу сохранить след записей журнала. небольшой.

Как справедливо заметил @Joe, это преждевременная оптимизация. Факт остается фактом: эти записи журнала предназначены не только для отображения пользовательского интерфейса, но и используются где-то еще.
Ни один из них не меняет контент на протяжении всей жизни, поэтому наличие реализации для уведомления об изменениях кажется нелогичным. Есть ли способ сделать привязку не заботящейся об обновлениях и выполнить реальную однократную привязку в этом случае использования, или это единственный вариант добавить класс-оболочку/скопировать данные в класс, который реализует INotifyPropertyChange только так происходит утечка памяти прочь?

Отвечает ли это на ваш вопрос? Привязка к списку вызывает утечку памяти

Orace 28.11.2022 18:06

Изменение вашего RecordedLogEntry со структуры на класс вряд ли сильно увеличит ваш след, если вообще что-то. Похоже на преждевременную оптимизацию. Каждый LogEntry уже содержит строку и перечисление, экономия размера, вероятно, будет ничтожной по сравнению с размером самой строки каждой записи. Я пытался исправить подобную проблему, я бы удалил все оптимизации (например, попробовал структуру вместо класса), исправил утечку и только потом подумал о возвращении к структуре.

Joe 28.11.2022 20:55

«Это ответ на ваш вопрос? Привязка к списку вызывает утечку памяти» Частично. Я знаю, что реализация INotifyPropertyChange для элементов (запись в журнале здесь), скорее всего, решит эту проблему - я бы не хотел этого делать, поскольку эта структура используется в другом месте.

founderio 28.11.2022 21:07

«Изменение вашего RecordedLogEntry из структуры в класс [...] Каждый LogEntry уже содержит строку». Вы правы, это вряд ли окажет огромное влияние. Простое изменение класса, скорее всего, не решит эту проблему в любом случае, более вероятно, что реализация INotifyPropertyChanged решит ее (см. выше). Я отредактирую и включу это рассуждение в вопрос. Спасибо за ваш вклад!

founderio 28.11.2022 21:10

Я думаю, вам следует создать небольшой пустой проект, а затем попытаться воспроизвести наблюдаемую проблему, чтобы создать минимальный жизнеспособный пример. Затем опубликуйте этот код, чтобы мы могли его просмотреть. Высоки шансы, что вы сможете решить проблему самостоятельно, воспроизведя ее таким образом.

BionicCode 28.11.2022 22:35

Скриншот и фрагменты не помогают определить потенциальную утечку. Из того, что вы написали до сих пор, не похоже, что утечка связана с привязкой данных. Если есть утечка. Возможно, вы постоянно создаете новые копии своей структуры в куче из-за случайного бокса. Структуры обычно представляют собой память, выделенную в стеке. Также обратите внимание, что рекомендуется, чтобы структуры были неизменяемыми (ваши изменяемы).

BionicCode 28.11.2022 22:35

Вам также следует рассмотреть возможность создания одного события CollectionChanged вместо двух последовательных. Вы можете установить действие на NotifyCollectionChangedAction.Replace и установить свойство NotifyCollectionChangedEventArgs.OldStartingIndex на удаленный индекс, а NotifyCollectionChangedEventArgs.NewStartingIndex на индекс вставки. Затем установите NotifyCollectionChangedEventArgs.NewItems и NotifyCollectionChangedEventArgs.OldItems соответственно. Это может еще больше повысить производительность.

BionicCode 28.11.2022 22:42

Спасибо всем за ваш вклад. Я приму это во внимание для дальнейшего улучшения как кода, так и качества вопросов. К сожалению, проблема не была воспроизведена с минимальным примером, поэтому я должен предположить, что экземпляры сохраняются в другом месте.

founderio 29.11.2022 14:41
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
8
73
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

К сожалению, проблема не была воспроизведена с минимальным примером, поэтому я должен предположить, что экземпляры сохраняются в другом месте.

Минимальный пример, который обычно легко показывает утечку, если она исходит из привязок:

<Window x:Class = "ListViewLeakRepro.MainWindow"
        xmlns = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x = "http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d = "http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc = "http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable = "d"
        Title = "MainWindow" Height = "450" Width = "800">
    <Grid>
        <ListView Margin = "10,46,10,10"
                  ItemsSource = "{Binding Entries}"
                  VirtualizingPanel.IsVirtualizing = "True"
                  VirtualizingPanel.IsVirtualizingWhenGrouping = "True"
                  VirtualizingPanel.ScrollUnit = "Pixel">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <WrapPanel>
                        <TextBlock Text = "{Binding Path=RenderedContent, Mode=OneTime}"/>
                    </WrapPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <Button Content = "Start Indirect Log Spam" HorizontalAlignment = "Left" Margin = "10,10,0,0" VerticalAlignment = "Top" Click = "StartTimerIndirect"/>
        <Button Content = "Start Direct Log Spam" HorizontalAlignment = "Left" Margin = "143,10,0,0" VerticalAlignment = "Top" Click = "StartTimerDirect"/>
    </Grid>
</Window>

Код позади:

using System.Windows;

namespace ListViewLeakRepro {
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window {
        MainWindowModel Model { get; }

        public MainWindow() {
            InitializeComponent();

            Model = new MainWindowModel();
            DataContext = Model;
        }

        private void StartTimerIndirect(object sender, RoutedEventArgs e) {
            Model.StartTimerIndirect();
        }

        private void StartTimerDirect(object sender, RoutedEventArgs e) {
            Model.StartTimerDirect();
        }
    }
}

Модель:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;

namespace ListViewLeakRepro {
    public class MainWindowModel : INotifyPropertyChanged {
        public event PropertyChangedEventHandler PropertyChanged;

        public RingList<RecordedLogEntry> Entries { get; } = new RingList<RecordedLogEntry>(100);

        private static List<RecordedLogEntry> pollCache = new List<RecordedLogEntry>();
        private static List<RecordedLogEntry> pollCache2 = new List<RecordedLogEntry>();

        private DispatcherTimer timer;
        private DispatcherTimer timer2;
        private int logCounter = 0;

        private static readonly object lockObject = new object();

        public void StartTimerIndirect() {
            if (timer != null) {
                return;
            }
            timer = new DispatcherTimer(TimeSpan.FromMilliseconds(10), DispatcherPriority.Background, SpamLogIndirect, Application.Current.Dispatcher);
            timer2 = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background, RefreshLogDisplay, Application.Current.Dispatcher);
        }

        public void StartTimerDirect() {
            if (timer != null) {
                return;
            }
            timer = new DispatcherTimer(TimeSpan.FromMilliseconds(10), DispatcherPriority.Background, SpamLogDirect, Application.Current.Dispatcher);
        }

        private void SpamLogIndirect(object sender, EventArgs e) {
            // Add some invisible junk data to have results earlier
            byte[] junkData = new byte[50 * 1024 * 1024];

            lock (lockObject) {
                pollCache.Add(new RecordedLogEntry($"Entry {++logCounter}", junkData));
            }
        }

        private void SpamLogDirect(object sender, EventArgs e) {
            // Add some invisible junk data to have results earlier
            byte[] junkData = new byte[50 * 1024 * 1024];

            lock (lockObject) {
                Entries.Add(new RecordedLogEntry($"Entry {++logCounter}", junkData));
            }
        }

        private void RefreshLogDisplay(object sender, EventArgs e) {
            lock (lockObject) {
                // Swap background buffer
                (pollCache, pollCache2) = (pollCache2, pollCache);
            }

            foreach (RecordedLogEntry item in pollCache2) {
                Entries.Add(item);
            }

            pollCache2.Clear();
        }
    }
}

Класс RingList:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;

namespace ListViewLeakRepro {
    /// <summary>
    /// A collection with a fixed buffer size which is reused when full.
    /// Only the most recent items are retained, the oldest are overwritten,
    /// always keeping a constant amount of items in the collection without reallocating memory.
    /// </summary>
    /// <typeparam name = "T"></typeparam>
    public class RingList<T> : IList<T>, INotifyCollectionChanged, INotifyPropertyChanged {
        private T[] buffer;
        private int head;
        private int tail;
        private bool firstRevolution;

        public event NotifyCollectionChangedEventHandler CollectionChanged;
        public event PropertyChangedEventHandler PropertyChanged;

        public RingList(int capacity) {
            buffer = new T[capacity];
            firstRevolution = true;
        }

        public int Capacity {
            get {
                return buffer.Length;
            }

            set {
                T[] oldBuffer = buffer;
                int oldHead = head;
                // int oldTail = tail;

                int oldLength = Count;

                buffer = new T[value];
                head = 0;
                tail = 0;

                for (int i = 0; i < oldLength; i++) {
                    Add(oldBuffer[(oldHead + i) % oldBuffer.Length]);
                }
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Capacity)));
                CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
            }
        }

        public int IndexOf(T item) {
            for (int i = 0; i < Count; i++) {
                if (Equals(this[i], item)) {
                    return i;
                }
            }
            return -1;
        }

        public void Insert(int index, T item) => throw new NotImplementedException();

        public void RemoveAt(int index) => throw new NotImplementedException();

        public bool Remove(T item) => throw new NotImplementedException();

        public T this[int index] {
            get {
                if (index < 0 || index >= Count) {
                    throw new IndexOutOfRangeException();
                }
                int actualIndex = (index + head) % Capacity;
                return buffer[actualIndex];
            }
            set {
                if (index < 0 || index > Count) {
                    throw new IndexOutOfRangeException();
                }
                int actualIndex = (index + head) % Capacity;
                T previous = buffer[actualIndex];
                buffer[actualIndex] = value;
                CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, previous, index));
            }
        }

        public void Add(T item) {
            if (Count == Capacity) {
                RemoveHead();
            }

            buffer[tail] = item;
            tail++;
            if (tail == Capacity) {
                tail = 0;
                head = 0;
                firstRevolution = false;
            }

            CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, Count - 1));
        }

        private void RemoveHead() {
            if (Count == 0) {
                throw new InvalidOperationException("Cannot remove from an empty collection");
            }
            T headItem = buffer[head];
            head++;
            if (head == Capacity) {
                head = 0;
            }
            CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, headItem, 0));
        }

        public void Clear() {
            buffer = new T[buffer.Length];
            head = 0;
            tail = 0;
            firstRevolution = true;
            CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
        }

        public bool Contains(T item) => IndexOf(item) >= 0;

        public void CopyTo(T[] array, int arrayIndex) => throw new NotImplementedException();

        public void CopyTo(List<T> collection) {
            if (collection == null) {
                throw new ArgumentNullException(nameof(collection));
            }

            for (int i = 0; i < Count; i++) {
                collection.Add(this[i]);
            }
        }

        public int Count {
            get {
                if (tail == head) {
                    if (firstRevolution) {
                        return 0;
                    } else {
                        return Capacity;
                    }
                } else if (tail < head) {
                    return Capacity - head + tail;
                } else {
                    return tail - head;
                }
            }
        }

        public bool IsReadOnly => false;

        public IEnumerator<T> GetEnumerator() {
            int position = 0; // state
            while (position < Count) {
                yield return this[position];
                position++;
            }
        }

        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    }
}

Предметы коллекции:

namespace ListViewLeakRepro {
    public class RecordedLogEntry {
        public RecordedLogEntry(string renderedContent, byte[] junkData) {
            RenderedContent = renderedContent;
            JunkData = junkData;
        }

        public string RenderedContent { get; set; }
        public byte[] JunkData { get; set; }
    }
}


Анализ с приведенным выше кодом воспроизведения

Запуск симуляции с использованием «Прямого» спама логов, т. е. прямой записи в Entries, НЕ вызывает утечку.

Он потребляет много памяти, но GC может его очистить:

Запуск симуляции с использованием «косвенного» спама журнала, т.е. сбора записей в буфере, который вращается каждые 500 мс, по-видимому, вызывает утечку памяти. GC не очищает его сразу:

Но в конце концов это происходит, просто это занимает больше времени:

Вывод: не утечка в привязке, скорее неограниченный рост в одном из буферов или в каком-то другом месте, поддерживающем жизнь экземпляра.

Примечание: переход от класса к структуре не оказывает существенного влияния на ситуацию (может изменить общее потребление, но не вызывает и не решает проблему).

Если вы проверяете память с помощью профилировщика, вы обычно также можете проверить граф объектов. Он покажет вам корни GC и деревья ссылок в целом. Когда вы найдете свои экземпляры в дереве, вы можете легко увидеть все ссылки на этот экземпляр. Это поможет вам понять, какие экземпляры влияют на их время жизни. Вы можете использовать профилировщик Visual Studio. Просто сделайте снимок и начните осмотр. Вы также можете загрузить пробную версию dotMemory от JetBrain, очень мощного и простого в использовании профилировщика памяти. Проверка ссылок также позволяет перейти к соответствующим строкам кода.

BionicCode 30.11.2022 00:47

Если вы безуспешно проверили очевидный критический код, вы всегда должны использовать профилировщик. Это твой единственный шанс. Как если бы вы использовали отладчик для выявления ошибки.

BionicCode 30.11.2022 00:50

«по-видимому, вызывает утечку памяти. GC не очищает ее сразу» — это не утечка, поскольку освобождается выделенная память. Утечка существует, когда GC никогда не может собирать ссылки. Также будьте осторожны со своими статическими членами. Экземпляры, на которые они ссылаются (и ссылки этих ссылок — полное дерево ссылок, начинающееся с корня статического экземпляра), никогда не будут пригодны для сбора. События — еще один печально известный источник утечек: всегда отписывайтесь. Удаляйте IDisposable и внедряйте IDisposable там, где это необходимо.

BionicCode 30.11.2022 01:03

Вы можете видеть на моих скриншотах выше, что я на самом деле использую профилировщик дампа памяти. Единственные активные ссылки на мои структуры данных исходят от ListView->EffectiveValueEntry[]->MS.Internal.WeakDictionary-‌​>WeakObjectHashtable‌​->Hastable+bucket[]. Это никогда не происходит при работе в отладчике с снимками памяти.

founderio 30.11.2022 14:57

«Это не утечка, так как выделенная память освобождается». Отсюда и "кажется". Тот факт, что их очистка занимает так много времени, потребляя более 20 ГБ памяти, прежде чем они будут освобождены, безусловно, выглядит как утечка. Пока больше не будет.

founderio 30.11.2022 15:01

«Также будьте осторожны со своими статическими элементами». Вот почему я сейчас уменьшаю их мощность при вращении. На потребление памяти не повлияло. НО: С тех пор вся система больше не падала. Таким образом, может быть, что среда выполнения теперь должным образом освобождает память и возвращает ее системе по мере необходимости, чего раньше не могло быть из-за того, что эти две статики растут и никогда не уменьшают емкость. Я просто не знаю, как это доказать.

founderio 30.11.2022 15:02

Другие вопросы по теме