От переводчика: Представляю вашему вниманию перевод статьи 2019 года. И хоть с момента публикации прошло уже больше двух лет, статья более чем актуальна и врядли утратит свое значение в ближайшие пару лет.

КДПВ
КДПВ

Опытные .NET-разработчики знают, что даже несмотря на наличие в .NET сборщика мусора (Garbage Collector, GC), утечки памяти все равно возникают с завидной регулярностью. Утечки возможны не из-за ошибок в сборщике мусора, а потому что даже в управляемом коде есть множество способов их появления.

Утечки памяти — довольно коварные сущности. Их можно долго не замечать, пока они медленно убивают приложение. При этом растет потребление памяти, создавая нагрузку на сборщик мусора и проблемы с производительностью. В конце концов приложение просто падает с исключением Out of memory.

В этой статье мы пройдемся по наиболее частым причинам возникновения утечек памяти в .NET-приложениях. Все примеры написаны на C#, но описанные проблемы и способы их решения справедливы и для других .NET-языков.

Что такое утечки памяти в .NET

Термин утечка памяти в средах со сборщиком мусора может вызывать некоторое недоумение. В конце концов, как может утекать память, если есть сборщик мусора, который следит за её своевременным освобождением?

На это есть 2 основные причины. Первая — это объекты, которые не используются в программе, но на которые еще сохранились ссылки. Из-за того, что в других участках кода на объекты есть ссылки, сборщик мусора не освобождает занятую ими память, так что они сохраняются навсегда, удерживая выделенную под них память. Так происходит, например, когда вы регистрируете обработчик события, но не удаляете его. Назовем такие утечки утечками управляемой памяти.

Вторая причина — неаккуратная работа с неуправляемой памятью, когда вы каким-либо способом выделяете неуправляемую память, но не освобождаете ее. На самом деле, это не так уж и сложно сделать в управляемом, даже работая с управляемым кодом. Сам .NET имеет множество классов, которые выделяют неуправляемую память. Почти всё, что использует потоки, графику, файловую систему или сетевые вызовы, под капотом работает с неуправляемой памятью. Вы можете легко выделить неуправляемую память и самостоятельно, например при помощи специальных классов (таких как Marshal) или при помощи P/Invoke.

Многие разделяют мнение, что утечки управляемой памяти это вовсе не утечки, ведь на них все еще есть ссылки и в теории, память все еще можно освободить. Это дискуссионный вопрос, но на мой взгляд, это все же утечки памяти. Они удерживают память, которая не может быть выделена другому экземпляру и в конечном итоге вызывают исключение Out of Memory. В этой статье я буду называть утечки и управляемой, и неуправляемой памяти просто утечками памяти.

Ниже приведено 8 наиболее часто встречающихся причин возникновения утечек. Первые 6 касаются утечек управляемой памяти, оставшиеся 2 — неуправляемой.

1. Обработчики событий

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

public class MyClass
{
	public MyClass(WiFiManager wiFiManager)
	{
		wiFiManager.WiFiSignalChanged += OnWiFiChanged;
	}
 
	private void OnWiFiChanged(object sender, WifiEventArgs e)
	{
    // делаем что-нибудь полезное
  }
}

Так, если wifiManager определен за пределами MyClass, то мы получили утечку памяти. wifiManager ссылается на экземпляр MyClass, который теперь никогда не будет удален сборщиком мусора.

События действительно очень опасны, и про это есть отдельная статья: 5 Техник избежать утечек памяти при использовании событий в C# .NET, о которых вам нужно знать.

Что можно сделать в этой ситуации? В вышеуказанной статье описано несколько хороших практик, позволяющих избежать утечек памяти. Не вдаваясь в подробности, вот некоторые из них:

  1. Всегда отписывайтесь от событий.

  2. Используйте паттерны слабых событий (Weak Event Pattern).

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

2. Захват членов класса в анонимных методах

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

Вот пример:

public class MyClass
{
	private JobQueue _jobQueue;
	private int _id;

	public MyClass(JobQueue jobQueue)
	{
		_jobQueue = jobQueue;
	}

	public void Foo()
	{
		_jobQueue.EnqueueJob(() =>
		{
			Logger.Log($"Executing job with ID {_id}");
			// Выполняем полезную работу
		});
	}
}

В этом примере член класса _id захвачен в анонимном методе и, как результат, экземпляр класса хранит ссылку на себя. Это означает, что пока _jobQueue существует и ссылается на анонимный делегат, он [_jobQueue] ссылается также и на экземпляр MyClass.

Решение проблемы здесь простое — использовать локальную переменную:

public class MyClass
{
	public MyClass(JobQueue jobQueue)
	{
		_jobQueue = jobQueue;
	}

	private JobQueue _jobQueue;
	private int _id;

	public void Foo()
	{
		var localId = _id;
		_jobQueue.EnqueueJob(() =>
		{
			Logger.Log($"Executing job with ID {localId}");
			// что-нибудь делаем
		});
	}
}

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

Примечание переводчика: если вам не совсем понятна природа возникновения утечки в данном случае, обратите внимание на этот комментарий.

3. Статические переменные

Некоторые разработчики считают, что использование статических переменных являются плохой практикой. Тем не менее, говоря об утечках памяти, о них нельзя не упомянуть.

Прежде, чем подойти к сути этого раздела, давайте немного поговорим о работе сборщика мусора в .NET. Основная идея состоит в том, что сборщик мусора проходит по всем корневым объектам (GC Roots, корни) и помечает их, как объекты, которые не будут для очищены при сборке. Затем сборщик мусора проходит по всем объектам, на которые ссылаются корни, и точно также помечает их. И так далее. В конце концов, сборщик мусора собирает всё оставшееся (отличная статья о сборщике мусора).

Что считается корневыми объектами?

  1. Cтек исполняющихся потоков.

  2. Статические переменные.

  3. Управляемые объекты, переданные COM-объектам через Interop.

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

public class MyClass
{
	static List<MyClass> _instances = new List<MyClass>();
	public MyClass()
	{
		_instances.Add(this);
	}
}

Если вы зачем-то напишите вышеприведенный код, любой экземпляр MyClass навсегда останется в памяти, тем самым вызвав утечку.

4. Кэширование

Разработчики любят кэширование. Действительно, зачем выполнять операцию дважды, если можно выполнить ее один раз и сохранить результат, не так ли?

Это правда, но если кэшировать бесконечно, то в конце концов вы исчерпаете всю доступную память. Посмотрите на этот пример:

public class ProfilePicExtractor
{
	private Dictionary<int, byte[]> PictureCache { get; set; } = 
		new Dictionary<int, byte[]>();

	public byte[] GetProfilePicByID(int id)
	{
		// По-хорошему, здесь нужно использовать механизм синхронизации,
		// но для упрощения примера мы это опустим
		if (!PictureCache.ContainsKey(id))
		{
			var picture = GetPictureFromDatabase(id);
			PictureCache[id] = picture;
		}
		return PictureCache[id];
	}

	private byte[] GetPictureFromDatabase(int id)
  {
		// ...
	}
}

Кэширование в этом примере помогает сократить дорогостоящие операции обращения к базе данных, но ценой является захламление памяти.

Для решения проблемы можно использовать следующие практики:

  1. Удалять из кэша данные, которые не используются какое-то время.

  2. Ограничить размер кэша.

  3. Использовать WeakReference для хранения кэшируемых объектов. WeakReference сборщику мусора самостоятельно очищать кэш, что в ряде случаев может оказаться не такой уж и плохой идеей. Сборщик мусора будет перемещать объекты, которые еще используются, в старшие поколения, чтобы держать их в памяти дольше. Это означает, что часто используемые объекты останутся в кэше дольше, тогда как неиспользуемые будут удалены сборщиком мусора без вашего явного участия.

5. Некорректная привязка данных в WPF

Привязка данных (Data Binding) в WPF тоже может стать причиной утечек памяти. Главное правило для предотвращения утечек — всегда использовать DependencyObject или INotifyPropertyChanged. Если вы этого не делаете, WPF создает т.н. сильную ссылку (strong reference) на объект, вызывая утечку памяти (более подробное объяснение).

Прммер:

<UserControl x:Class="WpfApp.MyControl"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
	<TextBlock Text="{Binding SomeText}"></TextBlock>
</UserControl>

Представленный ниже класс останется в памяти навсегда:

public class MyViewModel
{
	public string _someText = "memory leak";

	public string SomeText
	{
		get { return _someText; }
		set
		{
			_someText = value;
		}
	}
}

А вот этот класс уже не вызовет утечки:

public class MyViewModel : INotifyPropertyChanged
{
	public string _someText = "not a memory leak";

	public string SomeText
	{
		get { return _someText; }
		set
		{
			_someText = value;
			PropertyChanged?.Invoke(
				this,
				new PropertyChangedEventArgs(nameof (SomeText)));
		}
	}
}

На самом деле даже не важно, вызываете вы PropertyChanged или нет, главное, что класс реализует интерфейс INotifyPropertyChanged. Это говорит инфраструктуре WPF не создавать сильную ссылку.

Утечки памяти возникают только если используется режим привязки OneWay или TwoWay. Если привязка осуществляется в режиме OneTime или OneWayToSource, то проблемы не будет.

Утечки памяти в WPF также могут возникать, когда происходит привязка коллекций. Если коллекция не реализует INotifyCollectionChanged, вы получите утечку памяти. Вы можете избежать проблемы используя класс ObservableCollection, который этот интерфейс реализует.

6. Потоки, которые никогда не останавливаются

Мы уже говорили о том, как работает сборщик мусора и о корневых объектах. Я упоминал, что стек потока считается корневым объектом. Стек потока включает все локальные переменные, а также члены стеков вызовов.

Если вы зачем-то создали бесконечный поток, который ничего не делает и ссылается на объекты, то возникнет утечка памяти. Один из примеров того, как это может легко случиться — неправильное использование класса Timer. Посмотрите на этот код:

public class MyClass
{
	public MyClass()
	{
		Timer timer = new Timer(HandleTick);
		timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
	}

	private void HandleTick(object state)
	{
		// Что-нибудь делаем
	}
}

Если вы не остановите таймер, он будет бесконечно выполняться в отдельном потоке, удерживая ссылку на MyClass и предотвращая его удаление сборщиком мусора.

7. Не освобожденная неуправляемая память

До сих пор мы говорили только об управляемой памяти, которая освобождается сборщиком мусора. Совсем другое дело — неуправляемая память. Вместо того, чтобы просто избегать ссылок на ненужные объекты, в этом случае вам необходимо явно освобождать память.

Вот простой пример:

public class SomeClass
{
	private IntPtr _buffer;

	public SomeClass()
	{
		_buffer = Marshal.AllocHGlobal(1000);
	}

	// Делаем что-нибудь, но не освобождаем память
}

В этом примере мы использовали Marshal.AllocHGlobal, чтобы выделить участок неуправляемой памяти (см. документацию в MSDN). Если явно не освободить память при помощи Marshal.FreeHGlobal, она будет считаться выделенной в куче процесса, вызывая утечку памяти, даже после удаления SomeClass сборщиком мусора.

Для предотвращения подобных проблем вы можете добавить в свой класс метод Dispose, в котором очищать неуправляемые ресурсы. Например:

public class SomeClass : IDisposable
{
	private IntPtr _buffer;

	public SomeClass()
	{
		_buffer = Marshal.AllocHGlobal(1000);
		// Делаем что-нибудь, но не освобождаем память
	}

	public void Dispose()
	{
		Marshal.FreeHGlobal(_buffer);
	}
}

Утечки неуправляемой памяти даже хуже, чем утечки управляемой памяти в связи с фрагментацией. Сборщик мусора умеет дефрагментировать управляемую память, помещая неудаленные объекты рядом, чтобы освободить место для новых данных. В свою очередь, неуправляемая память навсегда привязывается к месту, в котором она выделена.

8. Не вызванный метод Dispose

В последнем примере мы добавили метод Dispose для освобождения неуправляемых ресурсов, когда они больше не нужны. Это прекрасно, но что случится, если кто-нибудь использует класс, но не вызовет метод Dispose?

Что вы можете сделать, так это использовать конструкцию using языка C#:

using (var instance = new MyClass())
{
	// ... 
}

Конструкция из примера работает на классах, реализующих интерфейс IDisposable и при компиляции автоматически преобразуется в следующий код:

MyClass instance = new MyClass();
try
{
	// ...
}
finally
{
	if (instance != null)
	{
		((IDisposable)instance).Dispose();
	}
}

Это довольно удобно, потому что если будет выброшено исключение, метод Dispose все равно будет вызван.

Для достижения наибольшей надежности MSDN предлагает паттерн реализации Dispose. Вот пример его использования:

public class MyClass : IDisposable
{
	private IntPtr _bufferPtr;
	public int BUFFER_SIZE = 1024 * 1024; // 1 MB
	private bool _disposed = false;

	public MyClass()
	{
		_bufferPtr =  Marshal.AllocHGlobal(BUFFER_SIZE);
	}

	protected virtual void Dispose(bool disposing)
	{
		if (_disposed)
			return;

		if (disposing)
		{
			// Очищаем используемые управляемые объекты
		}

		// Очищаем неуправляемые объекты
		Marshal.FreeHGlobal(_bufferPtr);
		_disposed = true;
	}

	public void Dispose()
	{
		Dispose(true);
		GC.SuppressFinalize(this);
	}

	~MyClass()
	{
		Dispose(false);
	}
}

Использование этого паттерна позволяет гарантировать, что даже если метод Dispose не был вызван явно, то он все равно будет вызван финализатором, когда сборщик мусора решит удалить объект. Если же Dispose вызывался вручную, финализатор для объекта отключается и вызван не будет. Отмена финализатора очень важна, так как его вызов обходится достаточно дорого и может вызывать проблемы с производительностью.

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

Заключение

Безусловно, разработчику очень важно понимать, как возникают утечки памяти, но это лишь часть общей картины. Не менее важно научиться распознавать, локализовывать и устранять утечки памяти в приложениях. Больше информации по теме вы можете найти в статье: Найти, исправить и избежать утечек памяти в C# .NET: 8 лучших практик.

Надеюсь, что статья и перевод были для вас полезными. Удачного программирования.

Комментарии (44)


  1. byme
    15.11.2021 10:42

    del


  1. DistortNeo
    15.11.2021 11:30
    +2

    Использовать WeakReference для хранения кэшируемых объектов.

    То есть использовать Dictionary<int, WeakReference<byte[]>>? Но ведь сами элементы словаря из никуда не денутся. Если словарь не чистить принудительно от мёртвых ссылок, то он тоже будет разрастаться.


    Например, в моём случае ключ был довольно сложным: длинная строчка (query fragment от URI-запроса), без удаления ненужных элементов словарь разрастался до миллионов элементов и отъедал нехило так памяти.


    При этом делать периодическую чистку было накладно, т.к. внезапно начать проверять миллион ссылок во время обработки запроса — это серьёзный лаг в обработке запросов. Вместо этого я написал свой велосипед WeakReferenceDictionary: словарь + связанный список. При каждом обращении к словарю я делал одну итерацию чистки: брал первый элемент из списка и, если он был жив, то отправлял его в конец, а если мёртв, то удалялся ключ из словаря.


    1. zhaparoff
      15.11.2021 11:41
      +1

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


      1. DistortNeo
        15.11.2021 12:09
        +2

        Т.е. вы написали свой велосипед для кэширования.

        Именно так. Велосипед получился простой и компактный: 81 строка, и это вместе с пустыми строчками и скобками.


        Не проще ли было использовать стандартный MemoryCache из библиотеки, который умеет ресайз и экспайрить элементы

        Нет, не проще. Мне не нужен expiration ни по времени, ни по занимаемой памяти, а только по факту существования ссылки на кэшируемый объект. Стандартный MemoryCache в это не умеет, надо все равно реализовывать CacheEntryChangeMonitor и делать проверку ручками.


        Так что нет. Лучше написать простой велосипед, чем пытаться адаптировать под себя монстра.


        1. zhaparoff
          15.11.2021 12:35
          +1

          А что со скоростью и потокобезопасностью вашего велосипеда? Он покрыт тестами? Вы уверены что в вашей реализации нет утечек, собственно? Что будет если оно будет жить месяц без рестарта? А если в него закинуть 100 ГБ данных? А если туда приедет 10 миллиардов уникальных элементов?

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


          1. DistortNeo
            15.11.2021 16:52
            +1

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


            1. Infarh
              19.11.2021 18:14

              А посмотреть можно?


              1. DistortNeo
                19.11.2021 19:44

                Ну смотрите:


                WeakReferenceDictionary
                    sealed class WeakReferenceDictionary<TKey, TValue>
                        where TKey : notnull
                        where TValue : class
                    {
                        readonly Dictionary<TKey, Entry> data = new();
                        readonly LinkedList<Entry> list = new();
                
                        public bool TryGetValue(TKey key, [MaybeNullWhen(returnValue: false)] out TValue value)
                        {
                            PerformCollectIteration();
                
                            if (!data.TryGetValue(key, out var entry))
                            {
                                value = default;
                                return false;
                            }
                
                            var obj = entry.Reference.Target;
                            if (obj != null)
                            {
                                value = (TValue)obj;
                                return true;
                            }
                
                            data.Remove(key);
                            list.Remove(entry);
                            value = default;
                            return false;
                        }
                
                        public void Add(TKey key, TValue value)
                        {
                            PerformCollectIteration();
                
                            var entry = new Entry(key, new WeakReference(value));
                            data.Add(key, entry);
                            entry.Node = list.AddLast(entry);
                        }
                
                        public void Remove(TKey key)
                        {
                            PerformCollectIteration();
                
                            if (data.TryGetValue(key, out var entry))
                            {
                                data.Remove(key);
                                list.Remove(entry.Node!);
                            }
                        }                
                
                        void PerformCollectIteration()
                        {
                            var entry = list.First?.Value;
                            if (entry == null)
                                return;
                
                            list.RemoveFirst();
                
                            if (entry.Reference.IsAlive)
                            {
                                entry.Node = list.AddLast(entry);
                            }
                            else
                            {
                                data.Remove(entry.Key);
                            }
                        }
                
                        sealed class Entry
                        {
                            public readonly TKey Key;
                            public readonly WeakReference Reference;
                            public LinkedListNode<Entry>? Node;
                
                            public Entry(TKey key, WeakReference reference)
                            {
                                Key = key;
                                Reference = reference;
                            }
                        }
                    }

                Потокобезопасность — как у Dictionary.


                1. zhaparoff
                  21.11.2021 17:46

                  Ну т.е. ее нет. Плюс к этому удвоенные требования по памяти... Очень "разумно" для кеша. Как раз то, о чем я и говорил.


                  1. DistortNeo
                    21.11.2021 18:05
                    +1

                    Ну т.е. ее нет

                    Потому что для моей задачи это не нужно (блокировка используется во внешнем коде). Смысл переусложнять код?


                    Плюс к этому удвоенные требования по памяти…

                    Опять же пишете ерунду. В моём случае память уходит на хранение ключей и значений, которые могут быть весьма большими. От того, что ссылки на ключи и значения будут храниться дважды, потребление памяти вырастет процентов так на 5-10.


                    Зато для моей задачи критична стабильность отклика. Периодическое пробегание по всему словарю ради чистки — это очень плохой сценарий.


                    Конечно, в моей реализации тоже есть недостаток: худшее время вставки в Dictionary составляет O(N). Но это случается не так часто, как чистка.


                    Очень "разумно" для кеша. Как раз то, о чем я и говорил.

                    Спасибо, я вас понял. Вы почему-то топите исключительно за универсальные решения. Я же написал, почему MemoryCache меня не устраивает. У вас будут ещё советы? Или исключительно критика?


                    1. zhaparoff
                      22.11.2021 01:15

                      Ок, давайте по порядку.

                      Смысл переусложнять код?

                      Смысл в том, что в вашей реализации с внешним локом, любая операция (даже чтение) будет блокировать ВСЮ коллекцию. В случае же с ConcurrentDictionary<T> (который, собственно, и используется в дефолтной реализации MemoryCache) для вставки/удаления блокируется только один конкретный бакет, в котором в случае с хорошей хеш-функцией живет один единственный элемент. И даже для них в некоторых случаях используются атомарные операции вместо полноценного лока. Я уже молчу про операцию чтения, которая там вообще неблокирующая.

                      От того, что ссылки на ключи и значения будут храниться дважды, потребление памяти вырастет процентов так на 5-10.

                      Согласен, тут я некорректно выразился. Я скорее имел в виду число аллокаций. Что при интенсивной нагрузке и довольно долгом (по меркам GC) времени жизни объектов в кеше с большой долей вероятности приведет к их скоплению во 2 поколении и его фрагментации. Но тут я не могу утверждать на 100% - надо делать профилирование. Я уверен, что разработчики, имплементировавшие MemoryCache, делалали ее, причем для разных workload сценариев. Делали ли его вы?

                      Зато для моей задачи критична стабильность отклика.

                      Поэтому и не стоит зря аллоцировать лишнюю память. Благо GC сейчас очень многое прощает, не зря его полировали годами, но все же перенапрягать его не стоит.

                      Конечно, в моей реализации тоже есть недостаток

                      Да, вот он: list.Remove(entry);

                      Вот его сложность - O(n), в случае же с Dictionary (и Concurrent в том числе), в общем случае это O(1). И только лишь в случае полного вырождения O(n).

                      Но это случается не так часто, как чистка.

                      Чистка в MemoryCache производится в отдельном фоновом потоке, причем с определенным каденсом, а не при каждом чихе, так что никаких фризов быть не должно. Если вы конечно не под одноядерный/однопоточный чип пишете...

                      Вы почему-то топите исключительно за универсальные решения

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

                      Мой совет - подумайте как перенести блокировки внутрь реализации. Перепишите с использованием ConcurrentDictionary. Кстати, на Хабре есть очень годная статья про него https://habr.com/ru/company/skbkontur/blog/348508/. Еще для этого придется выкинуть LinkedList, но может оно и к лучшему, зачем он тут вообще, все равно вы каждый раз ищете entry в словаре? Проверять протухла ли WeakReference можно и при обращении к ключу. Если боитесть что stale records будут жрать память - добавте асинхронную проверку/очистку фоновом потоке. Только тогда получится что вы написали свою урощенную версию MemoryCache.

                      Чтобы сэкономить время, можете сходить по ссылке https://github.com/aspnet/Caching/blob/master/src/Microsoft.Extensions.Caching.Memory/MemoryCache.cs и посмотреть как оно все рализовано. Не знаю, можно ли переиспользовать код... Думаю да, с определенными оговорками - там вроде Apache 2.0

                      Или просто взять готовую реализацию MemoryCache.

                      Я же написал, почему MemoryCache меня не устраивает.

                      Мне не нужен expiration ни по времени, ни по занимаемой памяти, а только по факту существования ссылки на кэшируемый объект. 

                      Ваш критерий это и есть "по занимаемой памяти", только в случае с MemoryCache он сам заботится о том чтобы не распухнуть, и трекает время последнего обращения, чтобы очистить самые редко используемые записи, так что с ним вам и WeakReferences не особо будут нужны. Главное не создавать записи с опцией NeverRemove, и все будет хорошо.


                      1. DistortNeo
                        22.11.2021 10:21

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

                        Код с неблокирующими операциями будет намного более сложным. А оно мне надо? Преждевременные оптимизации — зло.


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


                        Делали ли его вы?

                        Нет, в этом не было необходимости. Это код не является узким местом.


                        Так же, многие из них оптимизированы с учетом нюансов работы CLR, коих ни вы, ни я, ни многие другие разработчики "со стороны" всех не знают.

                        Я вот смотрю код и там не видно никаких оптимизаций с учётом нюансов работы CLR. Код как код, который писали самые обычные программисты.


                        Вот его сложность — O(n),

                        С чего вдруг? Это же LinkedList, а не List.


                        Если боитесть что stale records будут жрать память — добавте асинхронную проверку/очистку фоновом потоке. Только тогда получится что вы написали свою урощенную версию MemoryCache.

                        Ну вот, а у меня эта проверка сделана без фоновых потоков.


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

                        А мне не нужно ни отслеживание последнего обращения, ни трекинга занимаемой памяти, потому что в кэше хранятся не копии, а ссылки на объекты, которые используются где-то ещё. То есть расход памяти в случае использования кэша — это хранение самого словаря, а это ничто по сравнению с самими объектами.


                        Дополнительный расход памяти появляется только в случае, если объект перестаёт использоваться, и ссылка на него остаётся исключительно в кэше. Поэтому и используется WeakReference, чтобы трекать такие объекты.


                        P.S. В целом, спасибо, навели на мысль, чтобы сравнить производительность велосипеда с библиотечным решением для типичных юзкейсов в проекте.


                      1. zhaparoff
                        22.11.2021 11:49
                        +1

                        С чего вдруг? Это же LinkedList, а не List.

                        Потому что https://github.com/microsoft/referencesource/blob/5697c29004a34d80acdaf5742d7e699022c64ecd/System/compmod/system/collections/generic/linkedlist.cs#L256

                        В вашей реализации TryGet используется именно он для удаления протухших ссылок.

                        Я не буду дальше с вами дискутировать, вижу мои доводы для вас не особо убедительны + я не в курсе всех особенностей вашей задачи.

                        Могу лишь предложить погонять ваше решение в условиях high memory pressure. Позамерять cache hit rate в различных сценариях. Собрать GC метрики при типичной нагрузке.


                      1. DistortNeo
                        22.11.2021 12:09

                        Да, действительно косяк. Должно быть entry.Node, как в другом месте.


                        Видимо, не вылезло, потому что количество одновременно живых объектов не очень большое, и в профилировщике это вообще никак не отображалось.


                      1. DistortNeo
                        22.11.2021 13:05

                        Upd: полез фиксить, оказалось, я вам старый код привёл из неактуальной ветки.


                        Current
                            sealed class WeakReferenceDictionary<TKey, TValue>
                                where TKey : IEquatable<TKey>
                                where TValue : class
                            {
                                readonly Dictionary<TKey, Entry> data = new();
                                readonly Queue<Entry> list = new();
                                readonly object sync = new();
                        
                                public bool TryGetValue(TKey key, [MaybeNullWhen(returnValue: false)] out TValue value)
                                {
                                    lock (sync)
                                    {
                                        PerformCollectIteration();
                        
                                        if (!data.TryGetValue(key, out var entry))
                                        {
                                            value = default;
                                            return false;
                                        }
                        
                                        var obj = entry.Reference.Target;
                                        if (obj != null)
                                        {
                                            value = (TValue) obj;
                                            return true;
                                        }
                        
                                        data.Remove(key);
                                        entry.Removed = true;
                        
                                        value = default;
                                        return false;
                                    }
                                }
                        
                                public void Add(TKey key, TValue value)
                                {
                                    lock (sync)
                                    {
                                        PerformCollectIteration();
                        
                                        var entry = new Entry(key, new WeakReference(value));
                                        data.Add(key, entry);
                                        list.Enqueue(entry);
                                    }
                                }
                        
                                public void Remove(TKey key)
                                {
                                    lock (sync)
                                    {
                                        if (data.TryGetValue(key, out var entry))
                                        {
                                            data.Remove(key);
                                            entry.Removed = true;
                                        }
                        
                                        PerformCollectIteration();
                                    }
                                }                
                        
                                void PerformCollectIteration()
                                {
                                    if (!list.TryDequeue(out var entry))
                                        return;
                        
                                    if (entry.Removed)
                                        return;
                        
                                    if (entry.Reference.IsAlive)
                                    {
                                        list.Enqueue(entry);
                                    }
                                    else
                                    {
                                        data.Remove(entry.Key);
                                    }
                                }
                        
                                sealed class Entry
                                {
                                    public readonly TKey Key;
                                    public readonly WeakReference Reference;
                                    public bool Removed;
                        
                                    public Entry(TKey key, WeakReference reference)
                                    {
                                        Key = key;
                                        Reference = reference;
                                    }
                                }
                            }


    1. shai_hulud
      16.11.2021 01:24

      И тут на сцену выходит эфемерон.


      1. DistortNeo
        16.11.2021 11:40

        Проблема в том, что в C# его нет. Напрямую подписаться на финализацию объекта нельзя.


        Есть решение в виде ConditionalWeakTable, к кэшируемому объекту прикрепить прокладку с финализатором, и в финализаторе реализовать логику удаления элемента из кэша.


        Я этот вариант пробовал. Получалось дико неэффективно как по скорости, так и по памяти. Финализаторы в .NET — довольно тяжёлый механизм, и лучше его избегать.


  1. mkvmaks
    15.11.2021 13:17
    +1

    Как раз с самым 1-м пунктом сам столкнулся, долго не мог понять,Ю почему у меня приложение само схлопывается (((. Помогли ребята на форумах.


  1. pankraty
    15.11.2021 20:47
    +1

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

    1. Простой паттерн - отписаться от события, изменить коллекцию и подписаться обратно. Но если подписка/отписка сделана через анонимные методы, то отписка не делает ничего, а подписка - добавляет ещё один делегат. Пишу схематично, с телефона

    x.OnChange -= () => DoSomething() ;
    Uplate(x) ;
    x.OnChange += () => DoSomething() ;

    Причины понятны, анонимный делегат каждый раз создаётся новый, поэтому отписка не происходит. Но пропустить такое довольно легко.

    1. Window.ShowDialog() имеет задокументированную, но легко пропускаемую особенность: когда вызван без параметров, то родительским элементом для нового окна станет главное окно приложения. И когда в системе открывается большое количество диалоговых окон (в том числе, одно из другого, а из него ещё одно и т. д), со сложными моделями и со множеством взаимосвязей, то память кушается очень активно, а освобождается только при закрытии главного окна. Как решали, точно не помню - то ли явным образом вызывали диспоуз, то ли передавали параметр в ShowDialog, чтобы дочерние диалоги диспозилизь при закрытии родительского.

    2. Ещё в системе были WPF элементы, встроенные в WinForm элементы (посредством ElementHost), а в паре мест было наоборот - WinForm-овский WebBrowser встраивался в WPF окно. И на WinForm всё привыкли, что диспоуз родителя диспозит всех детей, а на WPF всё привыкли, что Dispose не требуется, поэтому в ElementHost даже не предусмотрен диспоуз вложенной вьюшки, даже если она реализует IDisposable. Кто ж знал, что туда внутрь вкрутят браузер, который обязательно надо задиспоузить? Пришлось какие-то костыли прикручивать.

    Вообще, конечно, яркий проект был. На thedailywft.com штук 8 статей по его мотивам.


  1. SadOcean
    15.11.2021 20:56

    Второй пример не плох, но может не решить проблем полностью.
    Что значит "захватит локальную переменную" - как Я понимаю, компилятор создаст анонимный класс, в котором id станет полем, а лямбда с замыканием - методом.
    Да, ссылка на "тяжелый" MyClass не останется висеть, но останется висеть ссылка на легкий класс с id, это тоже может быть неприятно.


    1. Kolonist Автор
      15.11.2021 21:12

      Насколько я понимаю, проблема второго примера там в том, что в _jobQueue есть ссылка на экземпляр MyClass, что приводит к появлению цикличной ссылки и предотвращает утилизацию сборщиком мусора обоих объектов.

      Если же мы будем использовать локальную переменную, которая в этом случае передается по значению, то сборщику мусора уже ничего не помешает удалить _jobQueue, так как здесь он уже не ссылается на экземпляр MyClass. При этом не важно, что именно представляет из себя делегат, который хранится в _jobQueue, главное, что он не ссылается на экземпляр MyClass.


      1. lair
        15.11.2021 22:11

        Насколько я знаю, GC в .NET нет никакого дела до цикличных ссылок, он прекрасно их выпиливает.


        1. Kolonist Автор
          15.11.2021 22:37

          Да, Вы правы. Получается, здесь дело не просто в циклической ссылке, а в замыкании, ссылка на которое будет сохранена в статическом поле.


          1. lair
            15.11.2021 22:40

            Почему в статическом-то? Все, что сделает замыкание в данном случае — это создаст еще один метод в классе MyClass. Откуда статическое поле?


            1. Kolonist Автор
              15.11.2021 22:50

              Тогда из-за чего утечка?


              1. lair
                15.11.2021 23:01

                Так я ее в этом примере и не вижу. Это у автора статьи надо спрашивать.


                1. mk2
                  15.11.2021 23:29

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


                  И что? Он держит ссылка на экземпляр работы, пока работа не будет выполнена, потом этот экземпляр отпустит. Это нормальное ожидаемое поведение, а не утечка.

                  Ну и с этим экземпляром работы захватит ещё и экземпляр MyClass, чего программист почему-то мог не ожидать. Я согласен, что пример не очень хороший, но утечка по такому принципу действительно может быть.


                  1. lair
                    15.11.2021 23:31

                    И что? Он держит ссылка на экземпляр работы, пока работа не будет выполнена, потом этот экземпляр отпустит. Это нормальное ожидаемое поведение, а не утечка.


                    Ну и с этим экземпляром работы захватит ещё и экземпляр MyClass, чего программист почему-то мог не ожидать. Я согласен, что пример не очень хороший, но утечка по такому принципу действительно может быть.

                    В том-то и дело, что это может быть ожидаемое поведение. Нельзя сходу называть это утечкой. Банальный вопрос: нас интересует значение на момент захвата или на момент выполнения?


                    Так что это не "не очень хороший", а весьма плохой пример.


              1. SadOcean
                16.11.2021 16:00

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

                А циклические ссылки GC умеет дропать, если они между мертвыми объектами.


            1. Kolonist Автор
              15.11.2021 23:39

              Кстати, сейчас проверил генерируемый IL, и да, никакого static. Хотя встречал об этом не один раз на просторах Интернета.


              1. SadOcean
                16.11.2021 16:02

                Я не уверен насчет случая лямбд без замыканий (их в теории можно сделать и статичными методами без ссылок на объекты), но в остальных слуаях вроде в лямбде хранится ссылка на метод и на объект - владелец метода.
                В случае замыкания - это синтетический объект, созданный из захваченных переменных.


                1. lair
                  16.11.2021 17:17

                  Лямбды без замыканий (обычно) превращаются в статические методы.


  1. cstrike
    17.11.2021 00:23
    +1

    Пример с таймером тоже плохой. Там будет точно наоборот, таймер будет собран сборщиком, когда этого не ожидаешь. Это если билдить в релиз, если в дебаг, то все как вы сказали.


    1. Kolonist Автор
      17.11.2021 01:00

      Разве? В чем тогда смысл таймера, если он останавливается сам, когда ему вздумается?


      1. cstrike
        17.11.2021 01:34
        +1

        Ни "когда вздумается", а когда на него нет ссылок. Причем локальная ссылка еще не гарантия от сборки мусора, в релизе JITer оптимизирует локальные ссылки и позволяет собирать объекты еще до выхода из скоупа


        1. Kolonist Автор
          17.11.2021 07:41

          Действительно, спасибо за замечание.

          Видимо, статьи этого автора нужно переводить с большой осторожностью, перепроверяя на MSDN.


    1. DistortNeo
      17.11.2021 01:14

      С чего вдруг? Посмотрите исходники:
      https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/shared/System/Threading/Timer.cs
      Пока таймер может тикать, он сидит в TimerQueue.


      1. cstrike
        17.11.2021 01:35
        +1

        As long as you are using a Timer, you must keep a reference to it. As with any managed object, a Timer is subject to garbage collection when there are no references to it. The fact that a Timer is still active does not prevent it from being collected.

        https://docs.microsoft.com/en-us/dotnet/api/system.threading.timer?view=net-5.0#remarks


        1. DistortNeo
          17.11.2021 02:00

          Да, действительно так. Проглядел класс TimerHolder с финализатором, единственное назначение которого — убивать сам таймер.


    1. cstrike
      17.11.2021 10:19
      +3

      Update:

      Этот код ведет себя поразному в .Net Framework и .Net Core:

      using System;
      using System.Threading;
      
      namespace ConsoleApp1
      {
          class Program
          {
              static void Main(string[] args)
              {
                  var timer = new Timer(OnTimer, null, 0, 1000);
                  Console.ReadLine();
              }
              static void OnTimer(object state)
              {
                  Console.WriteLine(DateTime.Now.TimeOfDay);
                  GC.Collect();
              }
          }
      }

      Если билдить в Release, то в .Net Framework таймер сработает лишь 1 раз, а в .Net Core будет продолжать работать пока не остановишь.


  1. geoser
    17.11.2021 09:57

    Забыли загрузку сборок и их динамическую генерацию. В моем опыте был случай, когда память текла из-за создания экземпляров типа Regex с опцией Compiled. С этой опцией создается динамическая сборка, которая никогда не выгружается.


  1. geoser
    17.11.2021 10:01

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


    1. Kolonist Автор
      18.11.2021 21:36

      Самое интересное, что выше есть комментарий от человека, который сам с этой утечкой сталкивался.


      1. geoser
        20.11.2021 03:38
        +1

        Она может быть, а может не быть. Согласен, что подписываться на событие объекта, временем жизни которого мы не управляем, не лучшая практика. Этот пункт как раз об этом: объект вставляет ссылку на себя в объект, который приходит извне, тем самым ставя себя в зависимость от времени жизни этого объекта. Если ваш объект транзиентный, а подписывается на синглтон, например, то будет утечка.

        Но в общем случае это не обязательно утечка: если мы знаем время жизни объекта, на который мы подписываемся, и понимаем отношения этого объекта с подписчиком, то вполне можно не отписываться, и оставить это на откуп GC.