玩转 .NET 线程调度模型 - Dispatcher

如果你熟悉 WPF 或者其他基于 XAML 的 UI 库,那么你一定知道 Dispatcher 与 DispatcherObject,它们是 .NET 线程模型的一部分,用于线程间调度。本篇文章将会描述 Dispatcher 的简单原理。

Dispatcher 与 DispatcherObject 通常被用于 UI 元素,这是由于 UI 元素需要在线程安全的环境下进行交互,而我们有通常有跨线程范围 UI 元素的需求,那么这个时候,Dispatcher 就显得特别有用,Dispatcher 能够让你将操作提交到 UI 线程运行。

关于 UI 线程的思考

在说明 Dispatcher 的原理之前,我们先来思考几个问题:

01.为什么 UI 元素能够进行交互?

简单一点的问,就是为什么一个 Button 被点击后能够响应 Click 事件,从而运行你的代码。你可能会答,由于点击 Button 触发了 Click 事件,而你侦听了这个事件。

那么更深层次呢?Button 类里面有处理鼠标的相关事件,从而触发了 Click 事件。

那么更深一点呢?UI 线程会接受所有输入,例如鼠标输入,将其传递给每个元素,所以会触发鼠标相关的事件,进而触发 Click 事件。

这里我们需要一点关于 UI 线程模型的概念,简单的来讲就是,UI 线程是运行在一个无限循环中,并维护一个消息队列,每次循环的开头都会获取队列的第一个消息,例如鼠标的按下消息,然后将这些消息提供给最外层 UI 元素,然后通过一系列的路由事件,传递给每个与该消息相关的 UI 元素,然后就会出现所谓的 MouseButtonDown 事件,然后配合 MouseButtonUp 事件就能组合成 Button 的 Click 事件,然后 UI 线程进入下一个消息循环,再次获取新的输入信息。

在这里我们只是简单的描述基本的 UI 线程模型,实际上的 UI 线程要做的事情还有很多,也并非这么简单,例如:在没有输入的情况下还需要去运行循环吗?如果有跨线程操作会怎么样?如何维护消息队列?稍微仔细想想,我们就能发现很多问题。

02.如何让 UI 失去响应?回想自己之前的编码经历,会导致 UI 失去相应一般无非是将 Internet 传输同步放到了 UI 线程,或者是把大量的运算放到了 UI 线程。想到这里这个问题的答案就很简单了,直接在某个控件的 Loaded 事件里面加上一个 While(ture); 就好了。

03.为什么死循环会导致 UI 失去响应?这个问题也很简单,看上面的线程模型的简图,UI 线程就干两件事,取消息,传给控件,然后处理,再循环,如果你在处理阶段出现了死循环,那么就无法到读下一条消息,就无法处理后面的消息,这直接导致了 UI 无法响应后面的任何消息,然后就会发生未响应。

Dispatcher 的简单原理

好的,理解了一些关于 UI 线程的东西,现在我们就能更好的理解关于线程调度与 Dispatcher 了。在 UI 线程里,谁负责提供消息循环呢?当然就是 Dispatcher 了,在有 Dispatcher 的线程上,线程是运行在 Dispatcher 提供的循环里,这个循环就是基本的线程消息循环。

我们在其他线程需要对 UI 元素进行更新,或者其他操作怎么办? 当然就是使用 Dispatcher 啦,一般我们是这么用的:

Control.Dispatcher.BeginInvoke(() =>  
{
    Control.Visibility = Visibility.Collapsed; // 更新 UI
});

也有同步版本

Control.Dispatcher.Invoke(() =>  
{
    Control.Visibility = Visibility.Collapsed; // 更新 UI
});

也有使用了 async/await 异步模型的版本

await Control.Dispatcher.InvokeAsync(() =>  
{
    Control.Visibility = Visibility.Collapsed; // 更新 UI
});

那么 Dispatcher 是如何进行线程调度,能够在任意线程访问 UI 元素呢?

简单的来讲,Dispatcher 除了维护消息队列,还维护了一个操作队列,我们在这里就简单的理解为 Queue<Action> ,当然事实上要比这复杂得多,因为这些操作还涉及到取消,进行状态,完成报告,优先度,等一系列的操作,并不是一个纯粹的 Action 队列。

每次使用 BeginInvoke 方法都会将这个 Action 放入这个队列,然后 Dispatcher 会在消息循环完成或者开始前,这主要取决于你为这个 Action 设定的优先度。

Dispatcher 在空闲时就会去执行队列里面的每个 Action,这样就保证了使用 BeginInvoke 方法就能在其他任何线程提交任务到 Dispatcher 管理的线程,这就是 Dispatcher 的调度作用。

那么同步版本 Invoke 又是什么原理呢?大体和 BeginInvoke 是一样的,不过 Invoke 方法是同步的,是指 Invoke 方法会阻塞当前线程,直到 Action 完成才会向下执行。下面的代码简单的描述了 Invoke 的工作原理:

AutoResetEvent sync = new AutoResetEvent(false); // 同步事件  
Dispatcher.BeginInvoke(new Action(() =>  
{
    Control.Visibility = Visibility.Collapsed;   // 更新 UI
    sync.Set();                                  // 停止等待
}));
sync.WaitOne();                                  // 阻塞当前线程  

async/await 异步版本也大致如此,不过是将等待操作放入 Task,使用 await 进行等待。

为线程使用 Dispatcher

如何为一个线程创建 Dispatcher 使该线程能够承载 UI 元素并且能够支持线程间调度?并不是只有 UI 线程才能有 Dispatcher,实际上所有 STA 线程都能支持 Dispatcher,下面的代码描述了如何新建一个 STA 线程并为其创建 Dispatcher。

Thread newDispatcherThread = new Thread(() =>  
{
    // 运行线程调度器,让线程进入
    Dispatcher.Run(); // <- 04

    // <- 08
    // Dispatcher.Run 后续的代码不会被运行,除非使用 Dispatcher.InvokeShutdown 方法关闭调度器才会执行到此处,一般用于清理线程资源

}); // <-01

// 只有 STA 线程才可以创建调度器
newDispatcherThread.SetApartmentState(ApartmentState.STA);  // <-02

// 开始运行线程
newDispatcherThread.Start(); // <-03

// 等待调度器创建完成
Thread.Sleep(1000);  // <- 04

var dispatcher = Dispatcher.FromThread(newDispatcherThread);  // <- 05  
dispatcher.Invoke(() =>  
{
    // 将 Action 提交到调度器任务队列

    // <- 07
});  // <- 06

// 关闭调度器
dispatcher.InvokeShutdown(); // <- 06  

上述代码的描述了调度器在线程交互中的作用,并且用序号标注了语句的运行顺序。

下篇文章我们将会描述如何创建多个 UI 线程,并让不同的窗口运行在不同的 UI 线程,以及如何在同一窗口承载不同 UI 线程的 UI 元素。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章