EntityFramework Core上下文例項池原理分析

Jeffcky發表於2020-11-08

前言

無論是在我個人部落格還是著作中,對於上下文例項池都只是通過大量文字描述來講解其基本原理,而且也是淺嘗輒止,導致我們對其認識仍是一知半解,本文我們擺原始碼,從源頭開始分析。希望通過本文從原始碼的分析,我們大家都能瞭解到上注入下文和上下文例項池的區別在哪裡,什麼時候用上下文,什麼時候用上下文例項池

上下文例項池原理準備工作

上下文例項池和執行緒池原理從概念來上講一樣,都是可重用,但在原理實現上卻有本質區別。EF Core定義上下文例項池介面即IDbContextPool,將其介面實現抽象為:租賃(Rent)和歸還(Return)。如下:

public interface IDbContextPool
{
    DbContext Rent();
​
    bool Return([NotNull] DbContext context);
}

那麼租賃和歸還的機制是什麼呢?接下來我們從注入上下文例項池開始講解。當我們在Startup中注入上下文和上下文例項池時,其他引數配置我們暫且忽略,從使用上二者最大區別在於,上下文可自定義設定生命週期,預設為Scope,而上下文例項池可自定義最大池大小,預設為128。那麼問題來了,上下文例項池所管理的上下文的生命週期到底是什麼呢?我們一探原始碼究竟,引數細節判斷部分這裡忽略分析

private static void CheckContextConstructors<TContext>()
 where TContext : DbContext
{
    var declaredConstructors = typeof(TContext).GetTypeInfo().DeclaredConstructors.ToList();
    if (declaredConstructors.Count == 1
      && declaredConstructors[0].GetParameters().Length == 0)
    {
      throw new ArgumentException(CoreStrings.DbContextMissingConstructor(typeof(TContext).ShortDisplayName()));
 }
}

首先判斷上下文必須有建構函式,因存在隱式預設無參建構函式,所以繼續增強判斷,建構函式引數不能為0,否則丟擲異常

AddCoreServices<TContextImplementation>(
 serviceCollection,
 (sp, ob) =>
 {
      optionsAction(sp, ob);

      var extension = (ob.Options.FindExtension<CoreOptionsExtension>() ?? new CoreOptionsExtension())
        .WithMaxPoolSize(poolSize);
​
      ((IDbContextOptionsBuilderInfrastructure)ob).AddOrUpdateExtension(extension);
    },ServiceLifetime.Singleton );

其次,以單例形式注入DbContextOptions,因每個上下文無論例項化多少次,其DbContextOptions不會發生改變

serviceCollection.TryAddSingleton(
​   sp => new DbContextPool<TContextImplementation>(
      sp.GetService<DbContextOptions<TContextImplementation>>()));

然後,以單例形式注入上下文例項池介面實現,因為該例項中存在佇列機制來維護上下文,所有此類必然為單例,同時,該例項需要用到DbContextOptions,所以提前注入DbContextOptions

serviceCollection.AddScoped<DbContextPool<TContextImplementation>.Lease>();

緊接著,以生命週期為Scope注入Lease類,此類作為上下文例項池巢狀密封類存在,從單詞理解就是對上下文進行釋放(歸還)處理(接下來會講到)

serviceCollection.AddScoped(
   sp => (TContextService)sp.GetService<DbContextPool<TContextImplementation>.Lease>().Context);

最後,這裡就是上下文例項池所管理的上下文,其生命週期為Scope,不可更改

上下文例項池原理構造實現

首先給出上下文例項池中重要屬性,以免越往下看一臉懵

private const int DefaultPoolSize = 32;

private readonly ConcurrentQueue<TContext> _pool = new ConcurrentQueue<TContext>();

private readonly Func<TContext> _activator;

private int _maxSize;

private int _count;

private DbContextPoolConfigurationSnapshot _configurationSnapshot;

上述是對於注入上下文例項池所做的準備工作,接下來我們則來到上下文例項池具體實現

public DbContextPool([NotNull] DbContextOptions options)
{
    _maxSize = options.FindExtension<CoreOptionsExtension>()?.MaxPoolSize ?? DefaultPoolSize;

    options.Freeze();

    _activator = CreateActivator(options);

    if (_activator == null)
    {
      throw new InvalidOperationException(
        CoreStrings.PoolingContextCtorError(typeof(TContext).ShortDisplayName()));
    }
}

在其構造中,獲取自定義例項池最大大小,若未設定則以DefaultPoolSize為準,DefaultPoolSize定義為常量32,然後,防止例項化上下文後DbContextOptions配置發生更改,此時呼叫Freeze方法進行凍結,接下來則是例項化上下文,此時將其包裹在委託中,還未真正例項化,繼續看上述CreateActivator方法實現。

private static Func<TContext> CreateActivator(DbContextOptions options)
{
    var constructors
      = typeof(TContext).GetTypeInfo().DeclaredConstructors
        .Where(c => !c.IsStatic && c.IsPublic)
        .ToArray();

    if (constructors.Length == 1)
    {
      var parameters = constructors[0].GetParameters();

      if (parameters.Length == 1
        && (parameters[0].ParameterType == typeof(DbContextOptions)
          || parameters[0].ParameterType == typeof(DbContextOptions<TContext>)))
      {
        return
          Expression.Lambda<Func<TContext>>(
              Expression.New(constructors[0], Expression.Constant(options)))
            .Compile();
      }
    }

 return null;
}

簡言之,上下文建構函式和引數有且只能有一個,而且引數必須型別必須是DbContextOptions,最後通過lambda表示式構造上下文委託。通過上述分析,正常情況下,我們知道設計如此,上下文只能是顯式有參構造,而且引數必須只能有一個且必須是DbContextOptions,但有些情況下,我們在上下文構造中確實需要使用注入例項,豈不玩不了,若存在這種需求,這裡請參考之前文章(EntityFramework Core 3.x上下文建構函式可以注入例項呢?

上下文例項池原理本質實現

上下文例項池構造得到最大例項池大小以及構造上下文委託(並未真正使用),接下來則是對上下文進行租賃(Rent)和歸還(Return)處理

public virtual TContext Rent()
{
    if (_pool.TryDequeue(out var context))
    {
      Interlocked.Decrement(ref _count);

      ((IDbContextPoolable)context).Resurrect(_configurationSnapshot);

      return context;
    }

    context = _activator();

    ((IDbContextPoolable)context).SetPool(this);

    return context;
}

從上下文例項池中的佇列去獲取上下文,很顯然,首次沒有,於是就啟用上下文委託,例項化上下文,若存在則將_count減1,然後將上下文的狀態進行啟用或復活處理。_count屬性用來與獲取到的例項池大小maxSize進行比較(至於如何比較,接下來歸還用講到),然後為防併發執行緒中斷等機制,不能用簡單的_count--,必須保持其原子性,所以用Interlocked,不清楚這個用法,補補基礎。

public virtual bool Return([NotNull] TContext context)
{
    if (Interlocked.Increment(ref _count) <= _maxSize)
    {
      ((IDbContextPoolable)context).ResetState();

      _pool.Enqueue(context);

      return true;
    }

    Interlocked.Decrement(ref _count);

    return false;
}

當上下文釋放時(釋放時做什麼處理,下面會講),首先將上下文狀態重置,無非就是將上下文所跟蹤的模型(變更追蹤機制)進行關閉處理等等,這裡就不做深入探討,接下來則是將上下文歸還上下文到佇列中。我們結合租賃和歸還整體分析:設定池大小為32,若此時有33個請求,且處理時間較長,此時將直接租賃33個上下文,緊接著33個上下文陸續被釋放,此時開始將0-31歸還入佇列,當索引為32時,此時_count為33,無法入隊,怎麼搞?此時將來到注入的Lease類釋放處理

public TContext Context { get; private set; }

void IDisposable.Dispose()
{
    if (_contextPool != null)
    {
      if (!_contextPool.Return(Context))
      {
        ((IDbContextPoolable)Context).SetPool(null);
        Context.Dispose();
      }

      _contextPool = null;
      Context = null;
    }
}

若請求超出自定義池大小,且請求處理週期很長,那麼在釋放時,餘下上下文則不能歸還入佇列,直接釋放掉,同時上下文例項池將結束掉自身不再具備對該上下文的維護處理能力。我們再次回到租賃方法,當佇列中存在可用的上下文時,可以知道每次都重新例項化一個上下文和上下文例項池管理上下文的本質區別在於對Resurrect方法的處理。

((IDbContextPoolable)context).Resurrect(_configurationSnapshot);

我們再來看看該方法具體處理情況怎樣,是否存在什麼魔法從而有所影響效能的地方,我們在指定場景必須使用例項池呢?

void IDbContextPoolable.Resurrect(DbContextPoolConfigurationSnapshot configurationSnapshot)
{
    if (configurationSnapshot.AutoDetectChangesEnabled != null)
    {
      ChangeTracker.AutoDetectChangesEnabled = configurationSnapshot.AutoDetectChangesEnabled.Value;
      ChangeTracker.QueryTrackingBehavior = configurationSnapshot.QueryTrackingBehavior.Value;
      ChangeTracker.LazyLoadingEnabled = configurationSnapshot.LazyLoadingEnabled.Value;
      ChangeTracker.CascadeDeleteTiming = configurationSnapshot.CascadeDeleteTiming.Value;
      ChangeTracker.DeleteOrphansTiming = configurationSnapshot.DeleteOrphansTiming.Value;
    }
    else
    {
      ((IResettableService)_changeTracker)?.ResetState();
    }

    if (_database != null)
    {
      _database.AutoTransactionsEnabled
        = configurationSnapshot.AutoTransactionsEnabled == null
        || configurationSnapshot.AutoTransactionsEnabled.Value;
    }
}

哇,我們驚呆了,完全沒啥,都不用我們再解釋,只是簡單設定變更追蹤各個狀態屬性而已。毫無疑問,上下文例項確實可以重用上下文例項,若存在複雜的業務邏輯和吞吐量比較大的情況,使用上下文例項池很顯然效能優於上下文,除此之外,二者在使用本質上並不存在太大效能差異。因為基於我們上述分析,若直接使用上下文,每次構建上下文例項,並不需要花費什麼時間,同時,上下文例項池重用上下文後,也僅僅只是啟用變更追蹤屬性,也不需要耗費什麼時間。

 

這裡我們也可以看到,上下文例項池和執行緒池區別很大,執行緒池重用執行緒,但建立執行緒開銷可想而知,同時對於執行緒重用的機制也完全不一樣,據我所知,執行緒池具有多個佇列,對於執行緒池中的N個執行緒,有N+1個佇列,每個執行緒都有一個本地佇列和全域性佇列,至於選擇哪個執行緒任務進入哪個佇列看對應規則。

總結

分析至此,我們再對注入上下文和上下文例項池做一個完整的對比分析。上下文週期預設為Scope且可自定義,而上下文例項池所管理的上下文週期為Scope,無法再更改,上下文例項池預設大小為128,我們也可以重寫其對應方法,若不給定maxSize(可空),則預設池大小為32。若上下文例項池佇列存在可租賃上下文,則取出,然後僅僅只是啟用變更追蹤響應屬性,否則直接建立上下文例項。若歸還上下文超出上下文例項池佇列大小(自定義池大小),則直接釋放餘下上下文,當然也就不再受上下文例項池所管理。

相關文章