В этом посте описана одна из причин, по которой растёт расход памяти и возникают утечки, что при работе под 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), где делается эта операция:

https://github.com/dotnet/runtime/blob/v8.0.16/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemWatcher.Win32.cs#L52.

Обратите внимание: данный конкретный метод находится в 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:

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