當前位置:
首頁 > 最新 > 深入研究EF Core AddDbContext 引起的內存泄露的原因

深入研究EF Core AddDbContext 引起的內存泄露的原因

前兩天逛園子,看到 @Jeffcky 發的這篇文章《EntityFramework Core依賴注入上下文方式不同造成內存泄漏了解一下》。

一開始只是粗略的掃了一遍沒仔細看,只是覺得是多次CreateScope後獲取實例造成的DbContext無法復用。

因為AddDbContext默認的生命周期是Scoped的,每次都創建一個新的Scope並從它的ServiceProvider屬性中獲取的依賴注入實例是不能共享的。

但我來我仔細看了幾遍文章和下面的評論,也在本地建了項目實際測試了,確實如文章所說的那樣。

於是乎,我就來了興趣,就去EF Core的源代碼中找到AddDbContext()的內部實現並把測試項目進行了如下改造:

一、Main方法內部:

var services = new ServiceCollection();

//方式一 AddDbContext註冊方式,會引起內存泄露

//services.AddDbContext(options => options.UseSqlServer("connectionString"));

//方式二 使用AddScoped模擬AddDbContext註冊方式,new EFCoreDbContext()時參數由DI提供,會引起內存泄露

services.AddMemoryCache(); // 手動高亮點1

Action optionsAction = o => o.UseSqlServer("connectionString");

Action optionsAction2 = (sp, o) => optionsAction.Invoke(o);

services.TryAdd(new ServiceDescriptor(typeof(DbContextOptions),

p => DbContextOptionsFactory(p, optionsAction2),

ServiceLifetime.Scoped));

services.Add(new ServiceDescriptor(typeof(DbContextOptions),

p => p.GetRequiredService>(),

ServiceLifetime.Scoped));

services.AddScoped(s => new EFCoreDbContext(s.GetRequiredService>()));

//方式三 直接使用AddScoped,new EFCoreDbContext()時參數自己提供。不會引起內存泄露

//var options = new DbContextOptionsBuilder()

// .UseSqlServer("connectionString")

// .Options;

//services.AddScoped(s => new EFCoreDbContext(options));

//為了排除干擾,去掉靜態ServiceLocator

//ServiceLocator.Init(services);

//for (int i = 0; i

//{

// var test = new TestUserCase();

// test.InvokeMethod();

//}

//去掉靜態ServiceLocator後的代碼

var rootServiceProvider = services.BuildServiceProvider(); // 這一句放在循環外就可避免內存泄露,挪到循環內就會內存泄露

for (int i = 0; i

{

using (var test = new TestUserCase(rootServiceProvider))

{

test.InvokeMethod();

}

}

二、上一步中引用的DbContextOptionsFactory方法,放到Main方法後面即可

private static DbContextOptions DbContextOptionsFactory(IServiceProvider applicationServiceProvider,

Action optionsAction)

where TContext : DbContext

{

var builder = new DbContextOptionsBuilder(

new DbContextOptions(new Dictionary()));

builder.UseApplicationServiceProvider(applicationServiceProvider); // 手動高亮點2

optionsAction?.Invoke(applicationServiceProvider, builder);

return builder.Options;

}

三、EFCoreDbContext也做一些更改,不需要重寫OnConfiguring方法,構造方法參數類型改為DbContextOptions

public class EFCoreDbContext : DbContext

{

public EFCoreDbContext(DbContextOptions options) : base(options)

{

}

public DbSet TestA { get; set; }

}

經過上面幾步改造以後,不使用AddDbContext()也可重現使用AddDbContext()時的內存泄露。

我們來對比一下用AddDbContext和AddScoped(這裡的AddScoped指的是原先的AddScoped方式,並非我們改造過的AddScoped)有什麼不同。可以很容易的找到兩個可疑的地方:

也就是我在上面代碼中我添加了 //手動高亮 字樣的那兩行代碼。

跟據命名我們大致可以猜到這兩行代碼的作用,用於內存中緩存和將當前使用的ServiceProvider設置為ApplicationServiceProvider(該Application不是指的整個應用程序,而是EF Core Application)。

經測試,這兩行代碼去掉任意一行都不會引起內存泄露。

而 UseApplicationServiceProvider 是EF Core2.0才引入的(見官方API文檔),

這也印證了 @geek_power 在文章下面留言中說的「這個問題只在EF Core2.0中才有」。

他的原話是「我測試過,Asp.net core並沒有這個問題,EF6.x和EF core1.0也沒這個問題,只有.net core console + EF core2.0會出現內存泄露。

經過測試是Microsoft.Extensions.DependencyInjection1.0升級到Microsoft.Extensions.DependencyInjection2.0造成的,只在console出現。」

這句話中的Asp.net core沒有這個問題是有誤導的,經測試,這個問題在ASP.NET Core中照樣是有的,只不過平時大家在使用ASP.NET Core使用DI時一般都是直接獲取IServiceProvider的實例,而不會直接用到ServiceCollection,更不會循環多次調用BuildServiceProvider。

就算在ASP.NET Core內部使用了ServiceCollection,一般也是用戶自己新創建的,和Startup.ConfigureServices(IServiceCollection services)內的services沒有關係。

而EF Core的註冊到一般也是註冊到services的,所以用戶自己創建的ServiceCollection也就和EF Core扯不上關係,更不會引起EF Core內存泄露了。

關於,ASP.NET Core中復現內存泄露,我後面會給出測試代碼。

另外,為了排除干擾,我把原測試中的在靜態中傳遞ServiceCollection或ServiceProvider的ServiceLocator去掉,改為在new TestUserCase()直接傳參。

改動後的代碼上面已經給出,但有一句要特別注意一下,就是

var rootServiceProvider = services.BuildServiceProvider();

這句,這行代碼如果放到循環外就不會內存泄露,移到循環內就會內存泄露。

到此,我們找到三個可導致內存泄露的地方

循環內多次調用BuildServiceProvider();

services.AddMemoryCache()

builder.UseApplicationServiceProvider(applicationServiceProvider);

這三個項,只要其它任何一項不滿足,都不會出現內存泄露。換句話說就是,這三個條件必須全部滿足才會導致內存泄露。

那麼我們可以得到一個初步的猜想。

內存泄露是由內存緩存引起的,緩存使用的key與當前使用的ServiceProvider有關,而多次調用BuildServiceProvider()後生成的ServiceProvider又不同的,從而導致一直在添加新的緩存而從來沒有從緩存中獲取過。

要怎麼證實呢?

於是我做了以下操作:

首先,我排出了所有資源沒釋放的原因,對所有創建的對象進行了顯示的資源回收,沒任何效果(這些步驟可有可無,不影響測試結果)。

然後,排除了資料庫連接沒有關閉的原因,使用SQL Server Profiler查看資料庫連接情況,每次都是正常關閉的。

再然後,使用jetbrains dotMemory查看內存佔用,發現增加的內存的確都是緩存數據並且得到一個重要的線索,Microsoft.EntityFrameworkCore.Internal.ServiceProviderCache.

先前在研究UseApplicationServiceProvider的時,閱讀EF Core的源代碼見過這貨。

其中有這樣一段代碼

//EF Core內部生成緩存key的代碼//代碼位置:Microsoft.EntityFrameworkCore.Internal.ServiceProviderCache//所在方法:IServiceProvider GetOrAdd(IDbContextOptions options, bool providerRequired)varkey =options.Extensions .OrderBy(e=>e.GetType().Name) .Aggregate(0L, (t, e) => (t *397) ^ ((long)e.GetType().GetHashCode() *397) ^ e.GetServiceProviderHashCode());

可以看到方法簽名的其中一個參數是IDbContextOptions類型,而且key也是用它計算的。

那是不是我們可以在自己的代碼中模擬生成一個key呢?

於是我在EFCoreDbContext的構造方法內添加了如下代碼

varkey =options.Extensions .OrderBy(e=>e.GetType().Name) .Aggregate(0L, (t, e) => (t *397) ^ ((long)e.GetType().GetHashCode() *397) ^e.GetServiceProviderHashCode()); Console.WriteLine($"EF Core當前DbContextOptions實例生成的緩存key為:");

果然,得到的結果是:內存泄露時每次列印到的key值都不一樣,沒有內存泄露時列印出來的都一樣(測試代碼快速切換內存泄露/沒有內存泄露方法,將前面提到的 var rootServiceProvider = services.BuildServiceProvider() 這句移動到循環內/外即可)。

詳細信息如下圖:

內存泄露時(BuildServiceProvider語句位於循環內)

第一次

第二次

第三次

沒有內存泄露時(BuildServiceProvider語句位於循環外)

不過,這只是一個最終的key計算方案,並不能看到具體是那裡不同導致的生成的key值不一樣的。

所以繼續改造代碼,一步步的跟蹤這個key生成的每一步,並列印出來。細節就不一一表述了,直接給出完整EFCoreDbContext代碼:

public class EFCoreDbContext : DbContext

{

public EFCoreDbContext(DbContextOptions options) : base(options)

{

//模擬生成EF Core 緩存key

var key = options.Extensions

.OrderBy(e => e.GetType().Name)

.Aggregate(0L, (t, e) => (t * 397) ^ ((long)e.GetType().GetHashCode() * 397) ^ e.GetServiceProviderHashCode());

Console.WriteLine($"EF Core當前DbContextOptions實例生成的緩存key為:");

//列印一下影響生成緩存key值的對象名、HashCore、自定義的ServiceProviderHashCode

var oExtensions = options.Extensions.OrderBy(e => e.GetType().Name);

Console.WriteLine($"列印引起key變化的IDbContextOptionsExtension實例列表");

foreach (var item in oExtensions)

{

Console.WriteLine($"item name: HashCode: ServiceProviderHashCore:");

}

//從上一步列印結果來看,oExtensions內包含兩個對象,SqlServerOptionsExtension和CoreOptionsExtension

//SqlServerOptionsExtension的HashCode和ServiceProviderHashCode每次都一樣,不是變化因素,不再跟蹤

//CoreOptionsExtension 用來表示由EF Core 管理的選項,而不是由資料庫提供商或擴展管理的選項。

//前面提到過的 builder.UseApplicationServiceProvider(applicationServiceProvider);

//就是把當前使用的 ServiceProvider 賦值到 CoreOptionsExtension .ApplicationServiceProvider

var coreOptionsExtension = options.FindExtension();

if (coreOptionsExtension != null)

{

var x = coreOptionsExtension;

Console.WriteLine($"
列印CoreOptionsExtension的一些HashCode
" +

$"GetServiceProviderHashCode:
" +

$"HashCode:
" +

$"ApplicationServiceProvider HashCode:
" +

$"InternalServiceProvider HashCode:");

//模擬GetServiceProviderHashCode的生成過程

var memoryCache = x.MemoryCache ?? x.ApplicationServiceProvider?.GetService();

var loggerFactory = x.LoggerFactory ?? x.ApplicationServiceProvider?.GetService();

var isSensitiveDataLoggingEnabled = x.IsSensitiveDataLoggingEnabled;

var warningsConfiguration = x.WarningsConfiguration;

var hashCode = loggerFactory?.GetHashCode() ?? 0L;

hashCode = (hashCode * 397) ^ (memoryCache?.GetHashCode() ?? 0L);

hashCode = (hashCode * 397) ^ isSensitiveDataLoggingEnabled.GetHashCode();

hashCode = (hashCode * 397) ^ warningsConfiguration.GetServiceProviderHashCode();

if (x.ReplacedServices != null)

{

hashCode = x.ReplacedServices.Aggregate(hashCode, (t, e) => (t * 397) ^ e.Value.GetHashCode());

}

Console.WriteLine($"
模擬生成GetServiceProviderHashCode:");

if (x.GetServiceProviderHashCode() == hashCode)

{

Console.WriteLine($"模擬生成的GetServiceProviderHashCode和GetServiceProviderHashCode()獲取的一致");

}

//列印GetServiceProviderHashCode的生成步驟,對比差異

Console.WriteLine($"
影響GetServiceProviderHashCode值的因素");

Console.WriteLine($"loggerFactory:");

Console.WriteLine($"memoryCache:");

Console.WriteLine($"isSensitiveDataLoggingEnabled:");

Console.WriteLine($"warningsConfiguration:");

}

}

public DbSet TestA { get; set; }

}

再次運行項目,截圖如下:

內存泄露時(BuildServiceProvider語句位於循環內)

第一次

第二次

第三次

第四次

這不是考眼力看圖找不同,就不難為大家了,我做些標註,丑是丑了點,但能說明問題就好。

圖中列印的信息,由上到下越來越具體,那麼反過來就是最下面的標註為1的(藍色框內)的部分變化引用標註2的整體HashCore變化,再引起3變化,最終引起4生成的緩存key變化。

很意外,導致生成的key不同的原因居然是日誌和內存緩存。也就是說是由每次從ApplicationServiceProvider獲取的日誌和內存緩存對象都不同引起的。

而CoreOptionsExtension.ApplicationServiceProvider的值就是在builder.UseApplicationServiceProvider(applicationServiceProvider)時設置給它的,也是我們獲取EFCoreDbContext實例的那個ServiceProvider。

沒有內存泄露時(BuildServiceProvider語句位於循環外)

可以看到,雖然也有一些變化的地方,但變動的值沒有參與計算key,只有上圖我圈的部分才參與了key生成,所以緩存可以得到重用。

到現在,我們可以得到最終的結論,導致內存泄露的原因是:

在循環內部多次調用BuildServiceProvider(),導致EF Core內部CoreOptionsExtension.ApplicationServiceProvider在循環時每次取得IMemoryCache和ILoggerFactory的實例都不同。

而這兩個對象的HashCode值是參與了EF Core緩存key生成的,所以導致每次生成的key都不一樣,緩存數據沒法得到復用。

為什麼多次調用BuildServiceProvider()會導致每次獲取的IMemoryCache和ILoggerFactory實例會不相同呢?

原因也簡單,IMemoryCache和ILoggerFactory默認註冊的都是Singleton(參考文檔和源碼)。

不是說註冊為Singleton的類型在任何地方取出來都是同一個實例了,它也是有前提條件的,那就是:只有在同一個Root ServiceProvider下取得的實例才是唯一的

如果多次調用BuildServiceProvider()創建了多個Root ServiceProvider,那麼從不同的Root ServiceProvider中取得的實例是不同的。

這也可解釋了另外一個問題,「好像這一切都只發生在控制台應用程序中,ASP.NET Core不管怎麼玩都沒有問題」。

經過測試,在ASP.NET Core中這樣寫也會有問題的。

只是因為在ASP.NET Core中我們一般很少直接用到IServiceCollection,大多數時候都是直接通過構造方法注入IServiceProvider的,更不會多次調用services.BuildServiceProvider();

並且默認情況下也只有在Startup.ConfigureServices(IServiceCollection services)才會用到它,而我們幾乎不會把除註冊服務外的其他代碼寫到這的。。

關於@geek_power 《Microsoft.Extensions.DependencyInjection不同版本導致EF出現內存泄露》提到的問題,我表示懷疑。

他文章中方案二提到:在EF6 + Microsoft.Extensions.DependencyInjection1.0 或 EF Core1.0 + Microsoft.Extensions.DependencyInjection1.0 中即使只調用了一次BuildServiceProvider()也會出現內存泄露。

而且我使用 EF Core1.0 + Microsoft.Extensions.DependencyInjection1.0 實際測試過,沒發現有任何問題。EF6 + Microsoft.Extensions.DependencyInjection1.0就沒測了。

一個小問題:生成緩存key中的 397 是什麼?

stackoverflow上有人提過這個問題,大該意思是它是一個「恰到好處」的素數,夠用也不至於太大,使用素數的原因是因為這樣生成的HashCode重複率低。

https://stackoverflow.com/questions/102742/why-is-397-used-for-resharper-gethashcode-override

補充:

EF Core內部對這種錯誤的使用方法是有警告提示的,大家看我測試代碼開啟了控制台日誌列印,是因為我看到這ServiceProviderCache內有有這幾行代碼,我想列印出來看看提示內容是什麼。

循環20次以上就可看到這樣的提示。

為英文不好的同學獻上google翻譯(google真TM機智,把microsoft.com翻譯成google.com)

原文地址: https://www.cnblogs.com/weapon/p/9121143.html


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 dotNET跨平台 的精彩文章:

為什麼 web 開發人員需要遷移到.NET Core,並使用 ASP.NET Core MVC 構建 web和API
傲嬌碼農的自我修養

TAG:dotNET跨平台 |