EntityFramework Core上下文实例池原理分析
前言
无论是在我个人博客还是著作中,对于上下文实例池都只是通过大量文字描述来讲解其基本原理,而且也是浅尝辄止,导致我们对其认识仍是一知半解,本文我们摆源码,从源头开始分析。希望通过本文从源码的分析,我们大家都能了解到上注入下文和上下文实例池的区别在哪里,什么时候用上下文,什么时候用上下文实例池
上下文实例池原理准备工作
上下文实例池和线程池原理从概念来上讲一样,都是可重用,但在原理实现上却有本质区别。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。若上下文实例池队列存在可租赁上下文,则取出,然后仅仅只是激活变更追踪响应属性,否则直接创建上下文实例。若归还上下文超出上下文实例池队列大小(自定义池大小),则直接释放余下上下文,当然也就不再受上下文实例池所管理。