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

2022-08-10 12:07:27

前言

    問題的起因是在幫同事解決遇到的一個問題,他的本意是在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方法中[點選檢視原始碼