2019年1月30日 星期三

為何在使用 ReaderWriterLockSlim 與 非同步 await 關鍵字的時候,會造成系統掛掉

最近遇到一個問題,當我們建立一個 ASP.NET Core 的空白專案的時候,接著,在 Startup 類別中,建立一個靜態 ReaderWriterLockSlim 物件,而我們最終的目的是要能夠在 Configure 方法內,使用 app.Run 來寫入當時的時間到特定文字檔案內,用來記錄這個專案是何時啟用的,因此,將會使用底下的範例程式碼,當要把時間寫入到檔案內的時候,使用 ReaderWriterLockSlim.EnterWriteLock() 方法進行通知其他執行緒,現在這個執行緒需要開始進行鎖定,若同時也有其他的執行緒會用到 ReaderWriterLockSlim ,此時,該執行緒將會進入被 封鎖 Block 的狀態,剛剛那個執行緒呼叫了 ReaderWriterLockSlim.ExitWriteLock(),以便解除執行緒同步的狀態。
C Sharp / C#
public class Startup
{
    static ReaderWriterLockSlim lock1 = new ReaderWriterLockSlim();
    public void ConfigureServices(IServiceCollection services) { }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.Run(async (context) =>
        {
            lock1.EnterWriteLock();
            try
            {
                await File.AppendAllTextAsync("C:\\MyLog.log", DateTime.Now + "\n");
            }
            finally
            {
                lock1.ExitWriteLock();
            }
            await context.Response.WriteAsync("Hello World!");
        });
    }
}
看似一切都很美好,可是,當您啟動該專案之後,卻會得到了底下的錯誤訊息,讓人丈二金剛摸不著頭腦,明明就是按照文件說明的用法,卻是無法使用,這到底發生了甚麼問題呢?
C Sharp / C#
An unhandled exception occurred while processing the request.
UnauthorizedAccessException: Access to the path 'd:\MyLog.log' is denied.
System.IO.FileStream.ValidateFileHandle(SafeFileHandle fileHandle)

UnauthorizedAccessException: Access to the path 'd:\MyLog.log' is denied.
System.IO.FileStream.ValidateFileHandle(SafeFileHandle fileHandle)
System.IO.FileStream.CreateFileOpenHandle(FileMode mode, FileShare share, FileOptions options)
System.IO.FileStream..ctor(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize, FileOptions options)
System.IO.File.AsyncStreamWriter(string path, Encoding encoding, bool append)
System.IO.File.AppendAllTextAsync(string path, string contents, Encoding encoding, CancellationToken cancellationToken)
System.IO.File.AppendAllTextAsync(string path, string contents, CancellationToken cancellationToken)
WebApplication4.Startup+<>c+<<Configure>b__2_0>d.MoveNext() in Startup.cs
+
                    await File.AppendAllTextAsync("d:\\a.log", DateTime.Now + "\n");
Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
想要能夠解決這個問題,你需要明瞭 await 這個非同步關鍵字到底是怎麼運作的以及 執行緒仿射 Thread-affine 這兩個內容。
當您在程式內使用了 await 關鍵字,C# 編譯器將會把你的程式碼拆成兩個區塊,一個是在 await 關鍵字之前要等候的程式碼,也就是呼叫了一個非同步的運算之前的所有程式碼,這些程式碼將會在您呼叫 app.Run 這個委派方法當時的執行緒來執行,這個時候,我們也在這個執行緒內呼叫了 ReaderWriterLockSlim.EnterWriteLock() 方法,取得了一個執行緒同步的專屬使用權,其他的執行緒是無法進入到相同的程式碼區段的;另外一個就是當非同步方法執行完畢之後,在 await 關鍵字之後的所有程式碼,這裡將會拆出成為另外一個程式碼區塊。
由於這是個 Console 類型的應用程式,他的 SynchronizationContext 同步處理內容 並不存在,因此,在 await 之後的程式碼將會在另外一個執行緒中來運行(實際上,會從 ThreadPool 執行緒集區中,要求一個可用的執行緒來執行這些程式碼)。
但是,這樣為什麼會造成這個專案發生例外異常呢?這個時候,就需要來了解 執行緒仿射 Thread-affine,當想要使用 ReaderWriterLockSlim 類別作為執行緒同步問題的解決方案的時候, ReaderWriterLockSlim.EnterWriteLock() 與 ReaderWriterLockSlim.ExitWriteLock() 必須要同一個執行緒下來執行。在 UnauthorizedAccessException 說明文件中,可以看到這個例外異常是這個意思:當作業系統因為 I/O 錯誤或特定類型的安全性錯誤而拒絕存取時,所擲回的例外狀況。
因此,我們需要讓執行 await 關鍵字前後的程式區段都能夠在同一個執行緒之下,才能夠避免這樣的問題,這裡提出兩個解決方案,那就是在 app.Run 內的委派方法,不要使用 await 非同步的方式來執行,而是使用同步的方式來執行: File.AppendAllTextAsync(@"d:\\a.log", DateTime.Now + "\n").Wait();,如同底下的程式碼
C Sharp / C#
public class Startup
{
    static ReaderWriterLockSlim lock1 = new ReaderWriterLockSlim();
    public void ConfigureServices(IServiceCollection services) { }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.Run(async (context) =>
        {
            lock1.EnterWriteLock();
            try
            {
                 File.AppendAllTextAsync(@"d:\\a.log", DateTime.Now + "\n").Wait();
            }
            finally
            {
                lock1.ExitWriteLock();
            }
            await context.Response.WriteAsync("Hello World!");
        });
    }
}
第二種作法則是使用 Nito.AsyncEx 這個套件,裡面有提供非同步運算可以使用的 AsyncReaderWriterLock ,這樣當我們使用 await 進行非同步計算的時候,就不會產生問題了。
C Sharp / C#
public class Startup
{
    static AsyncReaderWriterLock lock1 = new AsyncReaderWriterLock();
    public void ConfigureServices(IServiceCollection services)
    {
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.Run(async (context) =>
        {
            using (await lock1.WriterLockAsync())
            {
                await File.AppendAllTextAsync(@"D:\Vulcan\Projects\ReaderWriterLockSlimAsyncTest\tmp.txt", DateTime.Now + "\n")
                  ;
            }
            await context.Response.WriteAsync("Hello World!");
        });
    }
}
Monitors (and locks) / Reader-writer locks / Mutex 也同樣的具有 執行緒仿射 Thread-affine 特性,使用上要特別注意


2019年1月16日 星期三

TPL TAP 1 透過 Task.Status 取得正在執行中的工作狀態


TPL TAP 1 透過 Task.Status 取得正在執行中的工作狀態

當需要進行 Task 非同步的計算,可以選擇這些方法:建立一個 Task 物件 ,呼叫該物件的 Task.Start 方法、或者使用 TaskFactory.StartNew 來取得一個 Task 物件,又或者可以使用 Task.Run 方法來啟動一個 Task 非同步工作。
在這篇文章的範例中,則是使用 Task.Run 靜態方法,建立一個委派非同步工作,不過,在這個委派方法中,則是只有一行的程式碼,那就是拋出一個例外異常;由於在 Task 工作內所發生的例外異常,會被 Task 物件捕捉起來,並不會造成該應用程式異常結束,不過,在設計這樣的應用程式的時候,該如何知道這個工作發生了例外異常呢?在 Task 物件內,可以透過 Task.Status 這個屬性來得知該工作的執行結果或者當時的狀態。
其中 Task.Status 共會有這些列舉值 : Canceled , Created , Faulted , RanToCompletion , Running , WaitingForActivation , WaitingForChildrenToComplete , WaitingToRun
在底下的範例程式碼中,在主執行緒下會先使用 Task.Run 建立一個非同步委派工作,接著,主執行緒會休息 800ms ,最後,會顯示這個工作物件的狀態與最後結果的相關屬性值,分別是:IsCompleted 表示這個工作已經正常執行完成、 IsCanceled 這個工作取消了、最後是 IsFaulted 表示這個工作在執行上產生了例外異常。
C Sharp / C#
var fooTask = Task.Run( () =>
{
    throw new Exception("發生了例外異常");
});
Thread.Sleep(800);
Console.WriteLine($"Status : {fooTask.Status}");
Console.WriteLine($"IsCompleted : fooTask.IsCompleted}");
Console.WriteLine($"IsCanceled : fooTask.IsCanceled}");
Console.WriteLine($"IsFaulted : {fooTask.IsFaulted}");
var exceptionStatusX = (fooTask.Exception == null) ?"沒有 AggregateException 物件" : "有 ggregateException 物件";
Console.WriteLine($"Exception : {exceptionStatusX}")
Console.WriteLine("Press any key for continuing...")
Console.ReadKey();
現在,透過 Visual Studio 2017 的除錯組態進行這個範例程式碼除錯與執行, 可以,竟然偶而會看到底下的輸出結果,也就是看到該工作的狀態為 WaitingForActivation,想要不看這樣不正常的結果,因此,當要執行這個範例程式碼,請不要使用 Visual Studio 2017 來進行執行,因為,這樣會有可能得到 fooTask 物件的 Status 狀態值為 WaitingForActivation,所以,請在命令提示字元視窗下來執行。
Console
Status : WaitingForActivation
IsCompleted : False
IsCanceled : False
IsFaulted : False
Exception : 沒有 AggregateException 物件
Press any key for continuing...
當把這個範例程式碼,直接透過 命令提示字元視窗下來執行,此時,就會看到了所期望的結果,這個非同步委派工作最後的工作狀態應該是 Faulted,並且 Exception 這個屬性是有物件的。
Console
Status : Faulted
IsCompleted : True
IsCanceled : False
IsFaulted : True
Exception : 有 AggregateException 物件
Press any key for continuing...