В этом посте описана одна из причин, по которой растёт расход памяти и возникают утечки, что при работе под Windows может приводить к исключениям OutOfMemoryException. Проблема может возникать после того, как приложение обновится с версии .NET 6 или ниже до .NET 7 или выше, но также встречается и в новых или необновлённых приложениях.
Мне не раз приходилось сталкиваться с данной конкретной проблемой, когда я работал в техподдержке. Поскольку я отвечаю в основном за веб-составляющую приложений, мне такие вещи встречались только в приложениях ASP.NET. Однако эта проблема характерна не только для ASP.NET Core и может произойти в любом приложении под .NET.
Она может возникать в .NET 6 и ниже, но чётче проявляется и лучше просматривается в .NET 7 и выше. Дело в том, что в этих версиях .NET иначе, чем прежде, обращается с блоками памяти, отводимыми под кучи для сборщиков мусора. Разница такова: в .NET 6 и ниже (а также в .NET Framework) используются сравнительно крупные сегменты, для каждой кучи — свои. А в .NET 7+ для этой цели применятся более мелкие регионы, доступные для повторного использования. Если вы хотите подробнее почитать о сегментах и регионах, посмотрите пост от Маони Стивенс, которая занимается архитектурой сборщика мусора в .NET: https://devblogs.microsoft.com/dotnet/put-a-dpad-on-that-gc/
Кроме того, по-видимому, именно такие утечки возникают только в Windows. Я прихожу к такому выводу, изучив релевантный исходный код .NET. Правда, не поленитесь пролистать эту статью даже в случае, если ваше приложение хостится на какой-нибудь другой платформе.
Также отмечу, что все ссылки на документацию и на исходный код, приведённые в этом посте, относятся к .NET 8 как минимальной версии .NET, поддерживаемой на момент подготовки оригинала этой статьи (не включая ASP.NET Core 2.3 или .NET Framework). Если ваше приложение написано под .NET 7 или старше, то большинство рассмотренных здесь концепций всё равно останется актуальным. Насколько мне известно, то же можно сказать и о .NET 9+.
Как можно заметить эту проблему
Если данная проблема затронет ваше приложение, то вы можете заметить один или несколько следующих симптомов:
Со временем нарастает расход памяти, даже, если трафик не меняется или остаётся лёгким.
Если расход памяти нарастает достаточно долго, это может приводить к
OutOfMemoryException
и ухудшению производительности.В статистике сборщика мусора прослеживается, что в поколении 2 много свободной памяти, и не видно, чтобы этот объём сокращался.
В дампах памяти содержится много закреплённых объектов
byte[]
и экземпляровFileSystemWatcher+AsyncReadState
.
Большинство из этих признаков проявится задолго до того, как в системе возникнет дефицит памяти. Поэтому очень пригодится умение заблаговременно их обнаруживать и избегать таких вмешательств, которые повлияют на пользователя. В данном случае помогут инструменты мониторинга и периодический анализ дампа.
Поскольку существует бесконечно много причин, приводящих к утечкам памяти в приложении, сразу перейдём к тому, как выглядит такая проблема в выведенном дампе. В качестве вывода рассмотрим команды от WinDbg
с расширением SOS от .NET. При этом, такой же вывод получим из интерфейса командной строки dotnet. В данном случае дамп получен из приложения, написанного на ASP.NET Core, работающего под .NET 8 в Windows+IIS.
Расследование
Начнём с вывода команды !gcheapstat из SOS (для наглядности я слегка почистил вывод, но он выглядит почти как исходный):
0:000> !gcheapstat
Heap Gen0 Gen1 Gen2 LOH POH
Heap0 40524872 38061216 485740568 0 0
Heap1 61045264 40133344 478243040 0 0
Heap2 15486520 39008632 479224200 0 53128
Heap3 49219288 35761584 478258096 0 0
Heap4 66295048 38873144 478597280 85776 81672
Heap5 15009984 40180176 488333256 0 1048
Heap6 42155696 38223640 470915848 0 8240
Heap7 90212936 38588136 479554176 98384 0
Total 379949608 308829872 3838866464 184160 144088
Free space:
Heap Gen0 Gen1 Gen2 LOH POH
Heap0 133544 32562168 454600200 0 0 SOH:86%
Heap1 193888 34698976 446975904 0 0 SOH:83%
Heap2 70128 33914136 447623832 0 0 SOH:90%
Heap3 161296 30709896 446524992 0 0 SOH:84%
Heap4 226584 33579616 447172344 32 0 SOH:82%
Heap5 56168 34728256 456538104 0 0 SOH:90%
Heap6 152896 33156456 440122928 0 0 SOH:85%
Heap7 313336 33419096 447947832 32 0 SOH:79%
Total 1307840 266768600 3587506136 64 0
Committed space:
Heap Gen0 Gen1 Gen2 LOH POH
Heap0 40570880 40505344 497610752 126976 4096
Heap1 61083648 40833024 489603072 4096 4096
Heap2 15536128 40177664 489631744 126976 69632
Heap3 49287168 36831232 490213376 4096 4096
Heap4 66326528 39550976 490278912 86016 135168
Heap5 15077376 41226240 500224000 4096 4096
Heap6 42209280 38801408 482791424 4096 69632
Heap7 90247168 39718912 489099264 102400 4096
Total 380338176 317644800 3929452544 458752 294912
Из тех ~4 ГБ, что были выделены в поколении Gen2, ~3,6 ГБ приходится на свободное пространство.
В выводе команды !eeheap -gc (из SOS) показаны все регионы (обратите внимание, насколько они мелкие по сравнению с сегментами, использовавшимися до .NET 7), а также общий размер кучи, используемой сборщиком мусора .NET:
(обратите внимание: в этом приложении используется 8 куч, но все они исключительно похожи друг на друга. Поэтому для краткости я избавился от куч 1-7, а также убрал большую часть записей в середине. Просто учитывайте, что в поколении Gen2 гораздо больше записей, чем в Gen0 и Gen1):
0:000> !eeheap -gc
========================================
Number of GC Heaps: 8
----------------------------------------
Heap 0 (0000026a598e5f80)
Small object heap
segment begin allocated committed allocated size committed size
generation 0:
02aa6dd82d48 026b7bc00028 026b7bffffc8 026b7c000000 0x3fffa0 (4194208) 0x400000 (4194304)
...
02aa6dd86c88 026b91c00028 026b91eab088 026b91eb1000 0x2ab060 (2797664) 0x2b1000 (2822144)
generation 1:
02aa6dd598c0 026a96000028 026a963f35c8 026a96400000 0x3f35a0 (4142496) 0x400000 (4194304)
...
02aa6dd82c90 026b7b800028 026b7ba962e0 026b7baa1000 0x2962b8 (2712248) 0x2a1000 (2756608)
generation 2:
02aa6dd51af8 026a6a400028 026a6a7ebf20 026a6a800000 0x3ebef8 (4112120) 0x400000 (4194304)
02aa6dd51bb0 026a6a800028 026a6abef2e0 026a6ac00000 0x3ef2b8 (4125368) 0x400000 (4194304)
[whole bunch of entries]
02aa6dd7f3c8 026b67c00028 026b67ff5f68 026b68000000 0x3f5f40 (4153152) 0x400000 (4194304)
02aa6dd7f818 026b69400028 026b697fa5b8 026b69800000 0x3fa590 (4171152) 0x400000 (4194304)
NonGC heap
segment begin allocated committed allocated size committed size
026a59072fb0 02aaef970008 02aaefa00f28 02aaefa10000 0x90f20 (593696) 0xa0000 (655360)
Large object heap
segment begin allocated committed allocated size committed size
02aa6dd52b80 026a70000028 026a70000028 026a7001f000 0x1f000 (126976)
Pinned object heap
segment begin allocated committed allocated size committed size
02aa6dd4ec40 026a5a000028 026a5a000028 026a5a001000 0x1000 (4096)
------------------------------
[cut]
------------------------------
GC Allocated Heap Size: Size: 0x10dec7650 (4528567888) bytes.
GC Committed Heap Size: Size: 0x113e69000 (4628844544) bytes.
Короче говоря, в Gen2 содержится огромное количество регионов/сегментов, каждый из которых получил 0x400000 (4 194 304) байт. На момент написания оригинала статьи, это стандартный исходный размер региона для кучи малых объектов (SOH). Некоторые из них могут по разным причинам немного отличаться размерами, но в совокупности получается, что в поколении 2 скапливается большой объём памяти.
Сделаем дамп одного из этих регионов/сегментов:
0:000> !dumpheap -segment 2aa6dd51af8
Address MT Size
026a6a400028 026a59a88160 129,240 Free
026a6a41f900 7ff9fa8e5d28 8,216
026a6a421918 7ff9fabcfdb8 40
026a6a421940 026a59a88160 92,552 Free
026a6a4382c8 7ff9fa8e5d28 8,216
026a6a43a2e0 7ff9fabcfdb8 40
026a6a43a308 026a59a88160 91,792 Free
026a6a450998 7ff9fa8e5d28 8,216
026a6a4529b0 7ff9fabcfdb8 40
026a6a4529d8 026a59a88160 75,648 Free
026a6a465158 7ff9fa8e5d28 8,216
026a6a467170 7ff9fabcfdb8 40
026a6a467198 026a59a88160 103,816 Free
026a6a480720 7ff9fa8e5d28 8,216
026a6a482738 7ff9fabcfdb8 40
026a6a482760 026a59a88160 117,904 Free
026a6a49f3f0 7ff9fa8e5d28 8,216
026a6a4a1408 7ff9fabcfdb8 40
026a6a4a1430 026a59a88160 92,504 Free
026a6a4b7d88 7ff9fa8e5d28 8,216
026a6a4b9da0 7ff9fabcfdb8 40
026a6a4b9dc8 026a59a88160 148,560 Free
026a6a4de218 7ff9fa8e5d28 8,216
026a6a4e0230 7ff9fabcfdb8 40
026a6a4e0258 026a59a88160 106,976 Free
026a6a4fa438 7ff9fa8e5d28 8,216
026a6a4fc450 7ff9fabcfdb8 40
026a6a4fc478 026a59a88160 79,408 Free
026a6a50faa8 7ff9fa8e5d28 8,216
026a6a511ac0 026a59a88160 161,488 Free
026a6a539190 7ff9fa8e5d28 8,216
026a6a53b1a8 7ff9fabcfdb8 40
026a6a53b1d0 026a59a88160 301,024 Free
026a6a5849b0 7ff9fa8e5d28 8,216
026a6a5869c8 026a59a88160 145,400 Free
026a6a5aa1c0 7ff9fa8e5d28 8,216
026a6a5ac1d8 7ff9fabcfdb8 40
026a6a5ac200 026a59a88160 99,216 Free
026a6a5c4590 7ff9fa8e5d28 8,216
026a6a5c65a8 7ff9fabcfdb8 40
026a6a5c65d0 026a59a88160 92,552 Free
026a6a5dcf58 7ff9fa8e5d28 8,216
026a6a5def70 7ff9fabcfdb8 40
026a6a5def98 026a59a88160 160,024 Free
026a6a6060b0 7ff9fa8e5d28 8,216
026a6a6080c8 7ff9fabcfdb8 40
026a6a6080f0 026a59a88160 92,544 Free
026a6a61ea70 7ff9fa8e5d28 8,216
026a6a620a88 7ff9fabcfdb8 40
026a6a620ab0 026a59a88160 81,576 Free
026a6a634958 7ff9fa8e5d28 8,216
026a6a636970 7ff9fabcfdb8 40
026a6a636998 026a59a88160 158,296 Free
026a6a65d3f0 7ff9fa8e5d28 8,216
026a6a65f408 7ff9fabcfdb8 40
026a6a65f430 026a59a88160 103,816 Free
026a6a6789b8 7ff9fa8e5d28 8,216
026a6a67a9d0 7ff9fabcfdb8 40
026a6a67a9f8 026a59a88160 89,176 Free
026a6a690650 7ff9fa8e5d28 8,216
026a6a692668 7ff9fabcfdb8 40
026a6a692690 026a59a88160 297,232 Free
026a6a6dafa0 7ff9fa8e5d28 8,216
026a6a6dcfb8 7ff9fabcfdb8 40
026a6a6dcfe0 026a59a88160 116,688 Free
026a6a6f97b0 7ff9fa8e5d28 8,216
026a6a6fb7c8 7ff9fabcfdb8 40
026a6a6fb7f0 026a59a88160 92,552 Free
026a6a712178 7ff9fa8e5d28 8,216
026a6a714190 7ff9fabcfdb8 40
026a6a7141b8 026a59a88160 149,184 Free
026a6a738878 7ff9fa8e5d28 8,216
026a6a73a890 7ff9fabcfdb8 40
026a6a73a8b8 026a59a88160 91,248 Free
026a6a750d28 7ff9fa8e5d28 8,216
026a6a752d40 7ff9fabcfdb8 40
026a6a752d68 026a59a88160 91,232 Free
026a6a7691c8 7ff9fa8e5d28 8,216
026a6a76b1e0 7ff9fabcfdb8 40
026a6a76b208 026a59a88160 92,544 Free
026a6a781b88 7ff9fa8e5d28 8,216
026a6a783ba0 7ff9fabcfdb8 40
026a6a783bc8 026a59a88160 170,760 Free
026a6a7ad6d0 7ff9fa8e5d28 8,216
026a6a7af6e8 7ff9fabcfdb8 40
026a6a7af710 026a59a88160 91,216 Free
026a6a7c5b60 7ff9fa8e5d28 8,216
026a6a7c7b78 7ff9fabcfdb8 40
026a6a7c7ba0 026a59a88160 140,096 Free
026a6a7e9ee0 7ff9fa8e5d28 8,216
026a6a7ebef8 7ff9fabcfdb8 40
Statistics:
MT Count TotalSize Class Name
7ff9fabcfdb8 29 1,160 System.Threading.ThreadPoolBoundHandle
7ff9fa8e5d28 31 254,696 System.Byte[]
026a59a88160 31 3,856,264 Free
Total 91 objects, 4,112,120 bytes
Обратите внимание: большая часть памяти здесь свободна (Free), а остальная в основном заполнена объектами Byte[]
по 8 КБ. Почему сборщик мусора не перемещает их и не освобождает занимаемое ими место? Потому что эти объекты System.Byte[]
закреплены, и на них указывают ссылки:
0:000> !gcroot 026a6a7e9ee0
HandleTable:
0000026a5966a150 (strong handle)
-> 026a930cba68 System.Threading.ThreadPoolBoundHandleOverlapped
-> 026a930cb9e8 System.IO.FileSystemWatcher+AsyncReadState
-> 026a6a7e9ee0 System.Byte[]
0000026a59669f10 (pinned handle)
-> 026a6a7e9ee0 System.Byte[]
Found 2 unique roots.
Они никуда не сдвинутся, так как закреплены. Поскольку между закреплёнными объектами Byte[]
немало свободного пространства, и это поколение Gen2, данное пространство, в сущности, нельзя использовать.
Почему нельзя? Поскольку память для кучи малых объектов выделяется только в Gen0. Следовательно, поскольку все эти регионы и свободная память находятся в Gen2, из них ничего не выделить.
Со временем выделяется и закрепляется всё больше таких объектов Byte[], всё больше и больше объектов далее продвигается в Gen2 (здесь мы предполагаем, что эти объекты дырявые и никуда не деваются) и, в сущности, они намертво выводятся из использования.
Здесь также обратите внимание на объект "
System.IO
.FileSystemWatcher+AsyncReadState"
, который ссылается на наш закреплённый Byte[]
.
Можно рассмотреть эту проблему и в ином ракурсе. Ниже приведён другой набор дампов, не с теми данными, что были рассмотрены выше. Но в данном случае проблема нагляднее. На этот раз я сосредоточился на выводе тех типов, имена которых содержат "FileSystemWatcher"
:
Первый дамп процесса:
0:000> !dumpheap -stat -type FileSystemWatcher
Statistics:
MT Count TotalSize Class Name
7fff3be68d20 1 24 System.IO.FileSystemWatcher+<>c
7fff3be363b8 2 48 System.IO.FileSystemWatcher+NormalizedFilterCollection
7fff3be36c98 2 48 System.IO.FileSystemWatcher+NormalizedFilterCollection+ImmutableStringList
7fff3be35600 2 240 System.IO.FileSystemWatcher
7fff3be623e8 9,569 229,656 System.WeakReference<System.IO.FileSystemWatcher>
7fff3be61718 9,569 612,416 System.IO.FileSystemWatcher+AsyncReadState
Второй дамп, сделанный чуть позже:
0:000> !dumpheap -stat -type FileSystemWatcher
Statistics:
MT Count TotalSize Class Name
7fff3be68d20 1 24 System.IO.FileSystemWatcher+<>c
7fff3be363b8 2 48 System.IO.FileSystemWatcher+NormalizedFilterCollection
7fff3be36c98 2 48 System.IO.FileSystemWatcher+NormalizedFilterCollection+ImmutableStringList
7fff3be35600 2 240 System.IO.FileSystemWatcher
7fff3be623e8 18,037 432,888 System.WeakReference<System.IO.FileSystemWatcher>
7fff3be61718 18,037 1,154,368 System.IO.FileSystemWatcher+AsyncReadState
Теперь общее количество этих объектов примерно удвоилось, но обратите внимание, что их общий размер не очень заметен (<2 МБ во втором дампе). Разумеется, добавится ещё и Byte[]
, ассоциированный с каждым объектом. Однако Byte[]
— очень типичный класс, используемый в .NET для различных операций, и зачастую он остаётся не слишком заметен, просто пропускается в выводе !dumpheap
.
Причина
Откуда же берутся все эти объекты? Во всех случаях, с которыми мне пока доводилось сталкиваться — из подобного кода, присутствующего в горячих или довольно часто используемых частях программы:
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json",
optional: true,
reloadOnChange: true)
.Build();
var someConfig = configuration["someConfig"];
Этот код будет сказываться на работе программы тем сильнее, чем чаще он выполняется. Проблема не явная, но вот что её провоцирует: reloadOnChange: true.
По умолчанию reloadOnChange==false, но в вышеприведённом коде она специально включена. Думаю, такой код используется в приложениях именно таким образом во многом потому, что как раз такой вариант приводится в качестве примера конфигурационного файла в документации по ASP.NET Core Configuration. Правда, эта проблема характерна не только для провайдера JSON, но, как и демонстрируют все примеры с файловыми провайдерами, она распространяется на ASP.NET Core Configuration File Providers. В общей документации по провайдерам .NET Configuration также просматривается что-то похожее для всех файловых провайдеров: .NET Configuration Providers. Все они на момент написания оригинала этой статьи демонстрировали reloadOnChange: true.
Всё это предназначено для использования только на этапе запуска приложения, причём при потреблении некого пользовательского конфигурационного файла, такого, какой ASP.NET сам по себе не потребляет автоматически (предполагается, что его значения, заданные по умолчанию, не менялись). На практике же некоторые ребята ошибочно используют этот код, например, с контроллером или компонентом промежуточного ПО для доступа к какому-то потребовавшемуся конфигурационному значению. При этом люди не знают, как этот компонент работает под капотом, а также не в курсе, что искомый конфигурационный файл уже загружен и отслеживается в конфигурационной системе приложения.
Решение
Для начала следует понять, так ли вам нужно приказывать .NET загружать запрошенный конфигурационный файл. Дело в том, что ASP.NET Core и так загружает (и отслеживает) для вас несколько таких файлов (опять же, предполагается, что вы в вашем приложении не меняли никаких значений по умолчанию). Обычное консольное приложение .NET также потребляет некоторые дополнительные конфигурационные файлы в случае, если использует реализацию GenericHost. В приложениях других типов могут быть собственные наборы такого рода. Лучше всего обращаться к конфигурации приложения при помощи специально предназначенных для этого механизмов — например, применить внедрение зависимостей.
Если приложение определённо должно поглощать и отслеживать пользовательский конфигурационный файл, добавьте его один раз и как можно раньше (в идеале на этапе запуска приложения), а впоследствии используйте только методы извлечения (retrieval methods). Эти методы для ASP.NET Core описаны здесь: Configuration in ASP.NET Core.
Вот общий документ по конфигурации в .NET: Configuration in .NET.
Если вам нужно по-быстрому временно исправить проблему, либо если в вашем приложении абсолютно необходимо в процессе нормального выполнения задач многократно динамически загружать конфигурационный файл, то не сомневайтесь и устанавливайте reloadOnChange
в false. Так код точно не пойдёт по пути мониторинга файла. Данный способ всё равно неэффективный и относительно медленный по сравнению с внедрением зависимостей или обычными методами извлечения. Дело в том, что .NET всё равно придётся открыть файл и разобрать его, чтобы предоставить запрашиваемую приложением информацию — и так при каждом вызове.
Дополнительная информация
Каков же здесь путь от reloadOnChange: true
до закреплённого буфера? Под капотом, когда для сборщика конфигурации уже вызван его Build()
, и при reloadOnChange==true
конфигурационный код выполняет определённую работу по отслеживанию изменений в указанном файле. В частности, последовательно делаются следующие вызовы:

Вверху расположен вызов AllocateBuffer(), где в куче поколения Gen0 выделяется 8-килобайтный буфер Byte[]. Это и есть тот самый 8216-байтный объект, который мы видели в выводе !dumpheap выше в этом посте.
Именно вызов StartRaisingEvents() выполняет здесь значительную часть служебной работы — получает буфер и прокладывает тот путь, проследовав по которому, буфер можно закрепить. Вот ссылка на новейший код для .NET 8 (LTS, 8.0.16), где делается эта операция:
Обратите внимание: данный конкретный метод находится в FileSystemWatcher.Win32.cs – дело в том, что в Windows, Linux и MacOS мониторинг обрабатывается везде по-своему. В Windows есть специальный вызов к Win32 API, выполняющий мониторинговую работу — это ReadDirectoryChangesW. Он вызывается отсюда.
Вот какова сигнатура этой функции на момент подготовки оригинала статьи:
BOOL ReadDirectoryChangesW(
[in] HANDLE hDirectory,
[out] LPVOID lpBuffer,
[in] DWORD nBufferLength,
[in] BOOL bWatchSubtree,
[in] DWORD dwNotifyFilter,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped,
[in, optional] LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
Перед нами буфер, выделяемый в управляемом коде с последующим закреплением. Адрес этого буфера передаётся в параметре lpBuffer, указанном выше. Если коротко, Windows будет заполнять этот буфер требуемыми уведомлениями об изменениях по мере того, как эти изменения будут происходить. Именно поэтому .NET и требуется его закрепить. Поскольку в MacOS и Linux это делается иначе, там закреплённый буфер не требуется.
Всё это происходит всякий раз при вызове IConfigurationBuilder.Build() с reloadOnChange==true. Поэтому со временем становится всё больше буферов, созданных и закреплённых таким образом. Всё это приводит к крайней фрагментации памяти, а также к тому, что целые регионы остаются в основном незаполненными. Они прозябают в Gen2 и, в сущности, ничего не делают.
Эта проблема не нова. Она существует уже несколько лет и неоднократно всплывала во многих местах. Вот несколько старых веток с её описанием на GitHub: