解決ASP.NET Core在Task中使用IServiceProvider的問題

前言

    問題的起因是在幫同事解決遇到的一個問題,他的本意是在EF Core中為了解決避免多個執行緒使用同一個DbContext實例的問題。但是由於對Microsoft.Extensions.DependencyInjection體系的深度不是很了解,結果遇到了新的問題,當時整得我也有點蒙了,所以當時也沒解決,而且當時快下班了,就想著第二天再解決。在地鐵上,經過我一系列的思維跳躍,終於想到了問題的原因,第二天也順利的解決了這個問題。雖然我前面說了EFCore,但是本質和EFCore沒有關係,只是湊巧。解決了之後覺得這個問題是個易錯題,覺得挺有意思的,便趁機記錄一下。

問題演示

接下來我們還原一下當時的場景,以下程式碼只是作為演示,無任何具體含義,只是為了讓操作顯得更清晰一下,接下來就貼一下當時的場景程式碼

[Route("api/[controller]/[action]")]
[ApiController]
public class InformationController : ControllerBase
{
    private readonly LibraryContext _libraryContext;
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<InformationController> _logger;

    public InformationController(LibraryContext libraryContext, 
        IServiceProvider serviceProvider,
        ILogger<InformationController> logger)
    {
        _libraryContext = libraryContext;
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    [HttpGet]
    public string GetFirst()
    {
        var caseInfo = _libraryContext.Informations.Where(i => i.IsDelete == 0).FirstOrDefault();
        //這裡直接使用了Task方式
        Task.Run(() => {
            try
            {
                //Task里創建了新的IServiceScope
                using var scope = _serviceProvider.CreateScope();
                //通過IServiceScope創建具體實例
                LibraryContext dbContext = scope.ServiceProvider.GetService<LibraryContext>();
                var list = dbContext!.Informations.Where(i => i.IsDelete == 0).Take(100).ToList();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message, ex);
            }
        });
        return caseInfo.Title;
    }
}

再次強調一下,上述程式碼純粹是為了讓演示更清晰,無任何業務含義,不喜勿噴。咱們首先看一下這段程式碼表現出來的意思,就是在ASP.NET Core的項目里,在Task.Run里使用IServiceProvider去創建Scope的場景。如果對ASP.NET Core Controller生命周期和IServiceProvider不夠了解的話,會很容易遇到這個問題,且不知道是什麼原因。上述這段程式碼會偶現一個錯誤

Cannot access a disposed object.
Object name: 'IServiceProvider'.

這裡為什麼說是偶現呢?因為會不會出現異常完全取決於Task.Run里的程式碼是在當前請求輸出之前執行完成還是之後完成。說到這裡相信有一部分同學已經猜到了程式碼報錯的原因了。問題的本質很簡單,是因為IServiceProvider被釋放掉了。我們知道默認情況下ASP.NET Core為每次請求處理會創建單獨的IServiceScope,這會關乎到聲明周期為Scope對象的聲明周期。所以如果Task.Run里的邏輯在請求輸出之前執行完成,那麼程式碼運行沒任何問題。如果是在請求完成之後完成再執行CreateScope操作,那必然會報錯。因為Task.Run里的邏輯何時被執行,這個是由系統CPU調度本身決定的,特別是CPU比較繁忙的時候,這種異常會變得更加頻繁。

這個問題不僅僅是在Task.Run這種場景里,類似的本質就是在一個IServiceScope里創建一個新的子Scope作用域的時候,這個時候需要注意的是父級的IServiceProvider釋放問題,如果父級的IServiceProvider已經被釋放了,那麼基於這個Provider再去創建Scope則會出現異常。但是這個問題在結合Task或者多執行緒的時候,更容易出現問題。

解決問題

既然我們知道了它為何會出現異常,那麼解決起來也就順理成章了。那就是保證當前請求執行完成之前,最好保證Task.Run里的邏輯也要執行完成,所以我們上述的程式碼會變成這樣

[HttpGet]
public async Task<string> GetFirst()
{
    var caseInfo = _libraryContext.Informations.Where(i => i.IsDelete == 0).FirstOrDefault();
    //這裡使用了await Task方式
    await Task.Run(() => {
        try
        {
            //Task里創建了新的IServiceScope
            using var scope = _serviceProvider.CreateScope();
            //通過IServiceScope創建具體實例
            LibraryContext dbContext = scope.ServiceProvider.GetService<LibraryContext>();
            var list = dbContext!.Informations.Where(i => i.IsDelete == 0).Take(100).ToList();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex.Message, ex);
        }
    });
    return caseInfo.Title;
}

試一下,發現確實能解決問題,因為等待Task完成能保證Task里的邏輯能在請求執行完成之前完成。但是,很多時候我們並不需要等待Task執行完成,因為我們就是希望它在後台執行緒去執行這些操作,而不需要阻塞執行。

上面我們提到了本質是解決在IServiceScope創建子Scope時遇到的問題,因為這裡注入進來的IServiceProvider本身是Scope的,只在當前請求內有效,所以基於IServiceProvider去創建IServiceScope要考慮到當前IServiceProvider是否釋放。那麼我們就得打破這個枷鎖,我們要想辦法在根容器中去創建新的IServiceScope。這一點我大微軟自然是考慮到了,在Microsoft.Extensions.DependencyInjection體系中提供了IServiceScopeFactory這個根容器的作用域,基於根容器創建的IServiceScope可以得到平行與當前請求作用域的獨立的作用域,而不受當前請求的影響。改造上面的程式碼用以下形式

[Route("api/[controller]/[action]")]
[ApiController]
public class InformationController : ControllerBase
{
    private readonly LibraryContext _libraryContext;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<InformationController> _logger;

    public InformationController(LibraryContext libraryContext, 
        IServiceScopeFactory scopeFactory,
        ILogger<InformationController> logger)
    {
        _libraryContext = libraryContext;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    [HttpGet]
    public string GetFirst()
    {
        var caseInfo = _libraryContext.Informations.Where(i => i.IsDelete == 0).FirstOrDefault();
        //這裡直接使用了Task方式
        Task.Run(() => {
            try
            {
                //Task里創建了新的IServiceScope
                using var scope = _scopeFactory.CreateScope();
                //通過IServiceScope創建具體實例
                LibraryContext dbContext = scope.ServiceProvider.GetService<LibraryContext>();
                var list = dbContext!.Informations.Where(i => i.IsDelete == 0).Take(100).ToList();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message, ex);
            }
        });
        return caseInfo.Title;
    }
}

如果你是調試起來的話你可以看到IServiceScopeFactory的具體實例是Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope類型的,它裡面包含了一個IsRootScope屬性,通過這個屬性我們可以知道當前容器作用域是否是根容器作用域。當使用IServiceProvider實例的時候IsRootScopefalse,當使用IServiceScopeFactory實例的時候IsRootScopetrue。使用CreateScope創建IServiceScope實例的時候,注意用完了需要釋放,否則可能會導致TransientScope類型的實例得不到釋放。在之前的文章咱們曾提到過TransientScope類型的實例都是在當前容器作用域釋放的時候釋放的,這個需要注意一下。

問題探究

上面我們了解到了在每次請求的時候使用IServiceProvider和使用IServiceScopeFactory的時候他們作用域的實例來源是不一樣的。IServiceScopeFactory來自根容器,IServiceProvider則是來自當前請求的Scope。順著這個思路我們可以看一下他們兩個究竟是如何的不相同。這個問題還得從構建Controller實例的時候,注入到Controller中的實例作用域的問題。

請求中的IServiceProvider

在之前的文章<ASP.NET Core Controller與IOC的羈絆>我們知道,Controller是每次請求都會創建新的實例,我們再次拿出來這段核心的程式碼來看一下,在DefaultControllerActivator類的Create方法中[點擊查看源碼👈]

internal class DefaultControllerActivator : IControllerActivator
{
    private readonly ITypeActivatorCache _typeActivatorCache;
    public DefaultControllerActivator(ITypeActivatorCache typeActivatorCache)
    {
        _typeActivatorCache = typeActivatorCache;
    }

    public object Create(ControllerContext controllerContext)
    {
        //省略一系列判斷程式碼

        var serviceProvider = controllerContext.HttpContext.RequestServices;
        //這裡傳遞的IServiceProvider本質就是來自HttpContext.RequestServices
        return _typeActivatorCache.CreateInstance<object>(serviceProvider, controllerTypeInfo.AsType());
    }
}

通過這個方法我們可以看到創建Controller實例時,如果存在構造依賴,本質則是通過HttpContext.RequestServices實例創建出來的,而它本身就是IServiceProvider的實例,ITypeActivatorCache實例中則存在真正創建Controller實例的邏輯,具體可以查看TypeActivatorCache類的實現[點擊查看源碼👈]

internal class TypeActivatorCache : ITypeActivatorCache
{
    private readonly Func<Type, ObjectFactory> _createFactory =
        (type) => ActivatorUtilities.CreateFactory(type, Type.EmptyTypes);
    private readonly ConcurrentDictionary<Type, ObjectFactory> _typeActivatorCache =
            new ConcurrentDictionary<Type, ObjectFactory>();

    public TInstance CreateInstance<TInstance>(
        IServiceProvider serviceProvider,
        Type implementationType)
    {
        //省略一系列判斷程式碼

        var createFactory = _typeActivatorCache.GetOrAdd(implementationType, _createFactory);
        //創建Controller的時候,需要的依賴實例都是來自IServiceProvider
        return (TInstance)createFactory(serviceProvider, arguments: null);
    }
}

其實在這裡我們就可以得到一個結論,我們在當前請求默認通過構造注入的IServiceProvider的實例其實就是HttpContext.RequestServices,也就是針對當前請求的作用域有效,同樣的是來自當前作用域的Scope周期的對象實例也是在當前請求結束就會釋放。驗證這個很簡單可以寫個demo來演示一下

[Route("api/[controller]/[action]")]
[ApiController]
public class InformationController : ControllerBase
{
    private readonly IServiceProvider _serviceProvider;

    public InformationController(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    [HttpGet]
    public bool[] JudgeScope([FromServices]IServiceProvider scopeProvider)
    {
        //比較構造注入的和在HttpContext獲取的
        bool isEqualOne = _serviceProvider == HttpContext.RequestServices;
        //比較通過Action綁定的和在HttpContext獲取的
        bool isEqualTwo = scopeProvider == HttpContext.RequestServices;
        return new[] { isEqualOne, isEqualTwo };
    }
}

毫無疑問,默認情況下isEqualOne和isEqualTwo的結構都是true,這也驗證了我們上面的結論。因此在當前請求默認注入IServiceProvider實例的時候,都是來自HttpContext.RequestServices的實例。

請求中的IServiceProvider和IServiceScopeFactory

上面我們看到了在當前請求中獲取IServiceProvider實例本身就是Scope的,而且在當前請求中通過各種注入方式獲取到的實例都是相同的。那麼接下來我們就可以繼續跟蹤,本質的HttpContext.RequestServices的IServiceProvider到底來自什麼地方呢?我們找到HttpContext默認的實現類DefaultHttpContext中關於RequestServices屬性的定義[點擊查看源碼👈]

//接受
public IServiceScopeFactory ServiceScopeFactory { get; set; } = default!;
//數據來自RequestServicesFeature
private static readonly Func<DefaultHttpContext, IServiceProvidersFeature> _newServiceProvidersFeature = context => new RequestServicesFeature(context, context.ServiceScopeFactory);
//快取來自_newServiceProvidersFeature
private IServiceProvidersFeature ServiceProvidersFeature =>
            _features.Fetch(ref _features.Cache.ServiceProviders, this, _newServiceProvidersFeature)!;
//數據來自ServiceProvidersFeature的RequestServices
public override IServiceProvider RequestServices
{
    get { return ServiceProvidersFeature.RequestServices; }
    set { ServiceProvidersFeature.RequestServices = value; }
}

通過上面的源碼我們可以看到HttpContext.RequestServices的數據最終來自RequestServicesFeature類的RequestServices屬性,我們可以直接找到RequestServicesFeature類的定義[點擊查看源碼👈]

public class RequestServicesFeature : IServiceProvidersFeature, IDisposable, IAsyncDisposable
{
    private readonly IServiceScopeFactory? _scopeFactory;
    private IServiceProvider? _requestServices;
    private IServiceScope? _scope;
    private bool _requestServicesSet;
    private readonly HttpContext _context;

    public RequestServicesFeature(HttpContext context, IServiceScopeFactory? scopeFactory)
    {
        _context = context;
        _scopeFactory = scopeFactory;
    }

    public IServiceProvider RequestServices
    {
        get
        {
            if (!_requestServicesSet && _scopeFactory != null)
            {
                //釋放掉之前沒釋放掉的RequestServicesFeature實例
                _context.Response.RegisterForDisposeAsync(this);
                //通過IServiceScopeFactory創建Scope
                _scope = _scopeFactory.CreateScope();
                //RequestServices來自IServiceScopeFactory的CreateScope實例
                _requestServices = _scope.ServiceProvider;
                //填充已經設置了RequestServices的標識
                _requestServicesSet = true;
            }
            return _requestServices!;
        }
        set
        {
            _requestServices = value;
            _requestServicesSet = true;
        }
    }

    //釋放的真實邏輯
    public ValueTask DisposeAsync()
    {
        switch (_scope)
        {
            case IAsyncDisposable asyncDisposable:
                var vt = asyncDisposable.DisposeAsync();
                if (!vt.IsCompletedSuccessfully)
                {
                    return Awaited(this, vt);
                }
                
                vt.GetAwaiter().GetResult();
                break;
            case IDisposable disposable:
                disposable.Dispose();
                break;
        }
        //釋放時重置相關屬性
        _scope = null;
        _requestServices = null;

        return default;

        static async ValueTask Awaited(RequestServicesFeature servicesFeature, ValueTask vt)
        {
            await vt;
            servicesFeature._scope = null;
            servicesFeature._requestServices = null;
        }
    }
    //IDisposable的Dispose的方法,通過using可隱式調用
    public void Dispose()
    {
        DisposeAsync().AsTask().GetAwaiter().GetResult();
    }
}

通過上面的兩段源碼,我們得到了許多關於IServiceProvider和IServiceScopeFactory的相關資訊。

  • DefaultHttpContext的RequestServices值來自於RequestServicesFeature實例的RequestServices屬性
  • RequestServicesFeature的RequestServices屬性的值通過IServiceScopeFactory通過CreateScope創建的
  • 構建RequestServicesFeature的IServiceScopeFactory值來自於DefaultHttpContext的ServiceScopeFactory屬性

那麼接下來我們直接可以找到DefaultHttpContext的ServiceScopeFactory屬性是誰給它賦的值,我們找到創建HttpContext的地方,在DefaultHttpContextFactory的Create方法里[點擊查看源碼👈]

public class DefaultHttpContextFactory : IHttpContextFactory
{
    private readonly IHttpContextAccessor? _httpContextAccessor;
    private readonly FormOptions _formOptions;
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public DefaultHttpContextFactory(IServiceProvider serviceProvider)
    {
        _httpContextAccessor = serviceProvider.GetService<IHttpContextAccessor>();
        _formOptions = serviceProvider.GetRequiredService<IOptions<FormOptions>>().Value;
        //通過IServiceProvider的GetRequiredService直接獲取IServiceScopeFactory
        _serviceScopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
    }

    //創建HttpContext實例的方法
    public HttpContext Create(IFeatureCollection featureCollection)
    {
        if (featureCollection is null)
        {
            throw new ArgumentNullException(nameof(featureCollection));
        }

        var httpContext = new DefaultHttpContext(featureCollection);
        Initialize(httpContext);
        return httpContext;
    }

    private DefaultHttpContext Initialize(DefaultHttpContext httpContext)
    {
        //IHttpContextAccessor也是在這裡賦的值
        if (_httpContextAccessor != null)
        {
            _httpContextAccessor.HttpContext = httpContext;
        }

        httpContext.FormOptions = _formOptions;
        //DefaultHttpContext的ServiceScopeFactory屬性值來自注入的IServiceProvider
        httpContext.ServiceScopeFactory = _serviceScopeFactory;

        return httpContext;
    }
}

這裡我們可以看到IServiceScopeFactory的實例來自於通過DefaultHttpContextFactory注入的IServiceProvider實例,這裡獲取IServiceScopeFactory的地方並沒有CreateScope,所以這裡的IServiceScopeFactoryIServiceProvider中的實例都是來自根容器。這個我們還可以通過註冊DefaultHttpContextFactory地方看到
[點擊查看源碼👈]

services.TryAddSingleton<IHttpContextFactory, DefaultHttpContextFactory>();

通過這裡可以看到DefaultHttpContextFactory註冊的是單例模式,註冊它的地方則是在IHostBuilderConfigureServices方法里。關於每次請求的創建流程,不是本文的重點,但是為了讓大家對本文講解的IServiceScopeFactoryIServiceProvider來源更清楚,咱們可以大致的描述一下

  • GenericWebHostService類實現自IHostedService,在StartAsync方法中啟動了IServer實例,默認則是啟動的Kestrel。
  • IServer啟動的方法StartAsync中會傳遞HostingApplication實例,構建HostingApplication實例的時候則會依賴IHttpContextFactory實例,而IHttpContextFactory實例則是在構建GenericWebHostService服務的時候注入進來的。
  • 當每次請求ASP.NET Core服務的時候會調用HostingApplicationCreateContext方法,該方法中則會創建HttpContext實例,每次請求結束後則調用該類的DisposeContext釋放HttpContext實例。

說了這麼多其實就是為了方便讓大家得到一個關係,即在每次請求中獲取的IServiceProvider實例來自HttpContext.RequestServices實例,HttpContext.RequestServices實例來自IServiceScopeFactory來自CreateScope方法創建的實例,而IServiceScopeFactory實例則是來自根容器,且DefaultHttpContextFactory的生命周期則和當前ASP.NET Core保持一致。

後續插曲

就在解決這個問題後不久,有一次不經意間翻閱微軟的官方文檔,發現官方文檔有提到相關的問題,而且也是結合efcore來講的。標題是《Do not capture services injected into the controllers on background threads》翻譯成中文大概就是不要在後台執行緒上捕獲注入控制器的服務,說的正是這個問題,微軟給我們的建議是

  • 注入一個IServiceScopeFactory以便在後台工作項中創建一個範圍。
  • IServiceScopeFactory是一個單例對象。
  • 在後台執行緒中創建一個新的依賴注入範圍。
  • 不引用控制器中的任何東西。
  • 不從傳入請求中捕獲DbContext。

得到的結論和我們在本文描述的基本上是差不多的,而且微軟也很貼心的給我們提供了相關示例

[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        using (var scope = serviceScopeFactory.CreateScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();
            context.Contoso.Add(new Contoso());
            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

原來還是自己的坑自己最了解,也不得不說微軟現在的文檔確實挺詳細的,同時也提醒我們有空還是得多翻一翻文檔避免踩坑。

總結

    本文主要是通過幫助同事解決問題而得到的靈感,覺得挺有意思的,希望能幫助更多的人了解這個問題,且能避免這個問題。我們應該深刻理解ASP.NET Core處理每次請求則都會創建一個Scope,這會影響當前請求獲取的IServiceProvider實例,和通過IServiceProvider創建的生命周期為Scope的實例。如果把握不住,則可以理解為當前請求直接注入的服務,和當前服務直接注入的IServiceProvider實例。如果想獲取根容器的實例則可以通過獲取IServiceScopeFactory實例獲取,最後請注意IServiceScope的釋放問題。
    曾幾何時,特別喜歡去解決遇到的問題,特別喜歡那種解決問題沉浸其中的過程。解決了問題,了解到為什麼會讓自己感覺很通透,也更深刻,不經意間的也擴展了自己的認知邊界。這個過程得到的經驗是一種通識,是一種意識。而思維和意識則是我們適應這個不斷在變化時代的底層邏輯。

👇歡迎掃碼關注我的公眾號👇