WPF 中為了 UI 的跨線程訪問,提供了 Dispatcher
線程模型。其 Invoke
方法,無論在哪個線程調用,都可以讓傳入的方法回到 UI 線程。
然而,如果你在 Lazy 上下文中使用了 Invoke
,那么當這個 Lazy<T>
跨線程並發時,極有可能導致死鎖。本文將具體說說這個例子。
一段死鎖的代碼
請先看一段非常簡單的 WPF 代碼:
private Lazy<Walterlv> _walterlvLazy = new Lazy<Walterlv>(() => new Walterlv());
private void OnLoaded(object sender, RoutedEventArgs e)
{
Task.Run(() =>
{
// 在后台線程通過 Lazy 獲取。
var backgroundWalterlv = _walterlvLazy.Value;
});
// 等待一個時間,這樣可以確保后台線程先訪問到 Lazy,並且在完成之前,UI 線程也能訪問到 Lazy。
Thread.Sleep(50);
// 在主線程通過 Lazy 獲取。
var walterlv = _walterlvLazy.Value;
}
而其中的 Walterlv
類的定義也是非常簡單的:
class Walterlv
{
public Walterlv()
{
// 等待一段時間,是為了給我么的測試程序一個准確的時機。
Thread.Sleep(100);
// Invoke 到主線程執行,里面什么都不做是為了證明絕不是里面代碼帶來的影響。
Application.Current.Dispatcher.Invoke(() =>
{
});
}
}
這里的 Application.Current.Dispatcher
並不一定必須是 Application.Current
,只要是兩個不同線程拿到的 Dispatcher
的實例是同一個,就會死鎖。
此死鎖的觸發條件
Lazy<T>
的線程安全參數設置為默認的,也就是LazyThreadSafetyMode.ExecutionAndPublication
;- 后台線程和主 UI 線程並發訪問這個
Lazy<T>
,且后台線程先於主 UI 線程訪問這個Lazy<T>
; Lazy<T>
內部的代碼包含主線程的Invoke
。
此死鎖的原因
- 后台線程訪問到 Lazy,於是 Lazy 內部獲得同步鎖;
- 主 UI 線程訪問到 Lazy,於是主 UI 線程等待同步鎖完成,並進入阻塞狀態(以至於不能處理消息循環);
- 后台線程的初始化調用到
Invoke
需要到 UI 線程完成指定的任務后才會返回,但 UI 線程此時阻塞不能處理消息循環,以至於無法完成Invoke
內的任務;
於是,后台線程在等待 UI 線程處理消息以便讓 Invoke
完成,而主 UI 線程由於進入 Lazy 的等待,於是不能完成 Invoke
中的任務;於是發生死鎖。
此死鎖的解決方法
Invoke
改為 InvokeAsync
便能解鎖。
這么做能解決的原因是:后台線程能夠及時返回,這樣 UI 線程便能夠繼續執行,包括執行 InvokeAsync
中傳入的任務。
實際上,以上可能是最好的解決辦法了。因為:
- 我們使用 Lazy 並且設置線程安全,一定是因為這個初始化過程會被多個線程訪問;
- 我們會在 Lazy 的初始化代碼中使用回到主線程的
Invoke
,也是因為我們預料到這份初始化代碼可能在后台線程執行。
所以,這段初始化代碼既然不可避免地會並發,那么就應該阻止並發造成的死鎖問題。也就是不要使用 Invoke
而是改用 InvokeAsync
。
如果需要使用 Invoke
的返回值,那么改為 InvokeAsync
之后,可以使用 await
異步等待返回值。
更多死鎖問題
死鎖問題:
- 使用 Task.Wait()?立刻死鎖(deadlock) - walterlv
- 不要使用 Dispatcher.Invoke,因為它可能在你的延遲初始化 Lazy 中導致死鎖 - walterlv
- 在有 UI 線程參與的同步鎖(如 AutoResetEvent)內部使用 await 可能導致死鎖
- .NET 中小心嵌套等待的 Task,它可能會耗盡你線程池的現有資源,出現類似死鎖的情況 - walterlv
解決方法: