首页 > 其他分享 >STA模型、同步上下文和多线程、异步调度

STA模型、同步上下文和多线程、异步调度

时间:2024-10-20 11:21:31浏览次数:1  
标签:异步 同步 STA 上下文 线程 UI 多线程

写过任何桌面应用,尤其是WinForm的朋友们应该见过,Main函数上放置了一个[STAThread]的Attribute。而且几乎所有的桌面应用框架,都是由同一个UI线程来执行渲染操作的,这表现为从其他线程修改控件的值就会抛异常:

await Task.Run(() => control.Content = ""); // throws exception

大家一定都能猜出STA和UI线程一定有千丝万缕的联系,事实也的确如此(WinUI 3也是一个STA的框架)。

如何在其他线程修改UI

不论在什么框架中,只要使用了异步,就一定会有这种需求,因为异步获取到的数据就是为了显示的。

WPF中,若要从别的线程修改控件的属性,你需要使用DispatcherDispatcherPriority是可选参数):

control.Dispatcher.Invoke(DispatcherPriority.Normal, () => control.Content = "");

而WinUI 3中也有类似的操作,只是API名称有些变化(注意应使用DispatcherQueue而不是DispatcherDispatcherQueuePriority也是可选参数):

control.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, () => control.Content = "");

可为什么我们无法获取插入委托的完成时间或返回值呢?也许你的这个念头一闪而过,但还好我们用Dispatcher多是用来更新UI,所以完成时间和返回值都不是很重要,不过之后我们还是可以得到这个问题的答案。

STA模型下异步线程调度的区别

async void ButtonClicked(object sender, RoutedEventArgs e)
{
    var data = await FetchDataAsync();
    ((Button)sender).Content = data;
}

如果是只在控制台项目很熟悉异步的人,应该就会担心上面这段代码,如果不进行任何处理的话,给Content赋值这个操作会在执行异步的那个线程继续执行。然而如果运行这段代码会发现并没有出现问题,这是因为执行完异步后它会回到原来的线程(即UI线程),这就涉及到线程调度的区别了。

Debug.WriteLine(Environment.CurrentManagedThreadId);
await Task.Run(() => Debug.WriteLine(Environment.CurrentManagedThreadId));
Debug.WriteLine(Environment.CurrentManagedThreadId);

以上这段代码,在控制台项目和窗口项目(WPF、WinUI 3等)会有完全不同的两个结果:

  • 控制台项目中,输出结果形如1 2 2
  • 窗口项目中,输出结果形如1 2 1

可以得出结论,正是因为会回到原来的线程,所以在WinUI 3等框架中我们可以放心大胆地使用大量异步,而不用担心需要频繁调用DispatcherQueue导致代码变丑。

可它为什么可以回到原线程,而控制台项目不行?尝试在控制台项目Main函数上添加[STAThread],还是没有效果,说明[STAThread]不是决定性因素。

同步上下文

这就涉及到另外一个概念,同步上下文(Synchronization Context)。分别在两个项目中查看SynchronizationContext.Current可以发现,控制台项目的SynchronizationContext.Currentnull,而窗口项目则不是null

是的,同步上下文才是让运行完回到主线程的真正原因。

大致流程为:

  1. 程序开始运行,遇到了异步语句。
  2. Task首先捕获原来线程的调度器(TaskScheduler),如果没捕获到就用默认调度器。
  3. Task首先捕获原来线程的同步上下文。
  4. 将异步任务交给了某一线程执行。
  5. 异步任务执行完毕后,异步之后的语句会变成一个回调用于传递。
  6. 使用调度器执行回调。
  7. 默认调度器的行为:看捕获的同步上下文是否为空,若不为空就使用它运行该回调,为空则在线程池(ThreadPool)里运行回调。

我第一次了解以上的原理时,我想:也许可以实现一个自己的同步上下文?于是尝试使用以下的代码运行。

SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
Debug.WriteLine(Environment.CurrentManagedThreadId);
await Task.Run(() => Debug.WriteLine(Environment.CurrentManagedThreadId));
Debug.WriteLine(Environment.CurrentManagedThreadId);

可结果却还是失败了。我百思不得其解,于是查看WPF源码,终于得到了解答。WPF的同步上下文重写了默认的基类DispatcherSynchronizationContext,其中最重要的是它重写了Post方法,

  • 原来的SynchronizationContext中,Post方法使用ThreadPool来执行传入的回调。
  • 而WPF的重写中,用_dispatcher.BeginInvoke执行了传递进来的委托。这Dispatcher正是我们用来在别的线程修改UI时用的方法,这下逻辑终于闭环了。

async/await语法使得异步后的语句,虽然看起来是同步的,但实际上还是异步的回调(ContinueWith)。由默认调度器的逻辑,把回调语句交给了同步上下文来执行,而同步上下文又调用Dispatcher,回调最终回到了主线程执行。

Dispatcher就是一个事件循环,同步(顺序)地不断执行着外部传入的事件,所以根本不知道传入的任务什么时候才会执行。这也是为什么无法在之前提到的DispatcherQueue.TryEnqueue中获知完成时间或获取返回值。

ConfigureAwait(bool)的作用

听到将回调送回主线程执行时,也许有人会想到这个方法,听起来作用差不多:

Task.ConfigureAwait(bool continueOnCapturedContext)

没错,这个方法就是为了抑制调用同步上下文的行为的。既然是抑制调用同步上下文,那么如果没有同步上下文(SynchronizationContext.Currentnull),ConfigureAwait自然不起作用。

而当在有同步上下文的窗口项目中,.ConfigureAwait(false)就发挥它的作用了:

Debug.WriteLine(Environment.CurrentManagedThreadId);
await Task.Run(() => Debug.WriteLine(Environment.CurrentManagedThreadId)).ConfigureAwait(false);
Debug.WriteLine(Environment.CurrentManagedThreadId);

结果居然出现了形如1 2 2的,在控制台项目才会出现的结果,这正是ConfigureAwait的作用:抑制调用SynchronizationContext,回调会继续在原来异步的线程上执行。

既然回到主线程就可以直接操作UI了,这么好用为什么要抑制呢?

自动回到主线程的缺点

性能降低

有一定基础的程序员都知道,进程切换开销太大,所以出现了线程;线程切换开销也是很大,于是又出现了协程(异步)。所以我们要避免频繁切换线程。

自动回到主线程的模型中,切换时会将线程相关的上下文送到另一个线程以供执行,执行结束后又将结果的上下文送回原线程,这些操作耗费了大量开销。但仔细想想,第二步送回的操作有时是完全没有必要的,例如如果异步后不需要更新UI,就不需要回到主线程。

这样,我们得到了一个优化的思路:对无需修改UI的分支使用.ConfigureAwait(false)

线程死锁

大家在作为新手自己探索桌面应用的时期,一定或多或少遇到过异步转同步的需求。但自己按照网上的说法调用.GetAwaiter().GetResult().Wait().Result后,居然把UI卡死了。这正是同步上下文导致的。

以下代码可以在WinUI 3中复刻卡死UI:

Task.Run(() => Thread.Sleep(100))
    .ContinueWith(_ => { }, TaskScheduler.FromCurrentSynchronizationContext())
    .GetAwaiter().GetResult();

而在控制台项目中,我们先设置一个不会回到主线程的同步上下文(不然TaskScheduler.FromCurrentSynchronizationContext()方法会找不到同步上下文而抛异常),然后再执行相同的代码:

SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
Task.Run(() => Thread.Sleep(100))
    .ContinueWith(_ => { }, TaskScheduler.FromCurrentSynchronizationContext())
    .GetAwaiter().GetResult();

结果并没有卡死。这是为什么呢?

其实有了之前的知识,我们很容易推出结论:

  1. 主线程遇到异步转同步的耗时操作后,会挂起等待操作完成。
  2. 操作已经完成,开始执行回调,但主线程仍然在等待该语句完成。
  3. 回调的TaskScheduler要求调用同步上下文(即主线程)来运行回调任务,而此时主线程还在等待该操作执行完毕返回。

综上,两个线程互相等待的死锁形成了。但是这个缺点,并不是让我们不要使用自动回到主线程的同步上下文,而是遇到这种同步上下文时,异步转同步的操作一定要谨慎操作。

当STA遇到同步上下文

刚才的文章,仿佛是在说STA和同步上下文是两个不相关的东西,只是单线程渲染UI的需求让它们捆绑起来出现。事实确实如此,但如果不相关的两者遇到了会发生什么呢?

我们先来看STA的定义:

STA(Single Thread Apartment,单线程套间)是一种线程模型,
用在程序的入口方法上,来指定当前线程的ApartmentState是STA,用在其他方法上不产生影响。
这个属性只在COM(Component Object Model,组件对象模型)Interop有用,如果全部是托管代码则无用。
其它的还有MTA(Multi Thread Apartment,多线程套间)、NTA(Neutral Threaded Apartment,中立线程套间)。

虽然有点摸不着头脑,但原来[STAThread]是为了COM交互服务的。WinUI 3作为一个源码是WinRT(COM)实现的框架,UI部分自然要大量和COM交互,自然UI线程和STA线程是同一个线程。

那么一个问题出现了,假如我在异步转同步的方法体内执行COM交互,是不是也会导致死锁?

答案是肯定的,但并不是所有的COM交互都会导致死锁:

在COM中,除了线程有三种套间类型,COM对象也有五种套间模型,并在注册表里面可以通过ThreadingModel属性指定对象所期望的套间类型,它们的对应关系分别是:

  • Main(默认):主STA(第一个创建的STA)
  • Apartment:STA
  • Both:STA或MTA
  • Free:MTA
  • Neutural:NTA

只有只接受STA的对象才会要求回到主线程进行交互,也就是因类型(对象)而异。由于COM资料少难度大,我也不是很清楚相关知识,只好建议大家不要在异步转同步操作中使用COM相关API。

参考文献

  1. ConfigureAwait FAQ
  2. COM和套间(Apartments) 1 - 基本知识

标签:异步,同步,STA,上下文,线程,UI,多线程
From: https://www.cnblogs.com/pokersang/p/18487040

相关文章

  • JDBC:Statement和PreparedStatement的区别分析
    StatementStatement用于执行静态的SQL查询,通常在SQL语句不会频繁变化的情况下使用。特点不支持参数化查询:SQL语句直接嵌入在代码中,在语句中添加参数较为麻烦。存在SQL注入风险:由于直接拼接字符串,容易受到SQL注入攻击。性能较低:每次执行SQL语句时,数据库都需要......
  • 异步MQ:后发先至
    目录一、消息排序与识别1、消息添加时间戳或序列号2、识别重复消息二、状态管理与补偿机制1、维护处理状态2、建立补偿机制三、监控与告警1、实时监控消息处理顺序2、告警与通知在异步处理过程中,当出现消息“后发先至”的情况时,消费者可以采取以下措施来处理:一、......
  • 适用于 Windows 11/10/8/7/Vista/XP 的最佳免费分区软件
    无论您使用的是SSD、机械磁盘还是任何类型的RAID阵列,硬盘驱动器都是Windows计算机中不可或缺的组件。在将文件保存到全新磁盘之前,您应该初始化它,创建分区并使用文件系统格式化。在运行计算机一段时间后,您需要收缩、扩展、转换、复制磁盘分区等。可靠的磁盘分区工具可以帮......
  • 论文阅读:Vision Mamba- Efficient Visual Representation Learning with Bidirectiona
    文章介绍本文由华中科技大学、地平线、智源人工智能研究院等机构合作;提出了一种带有双向Mamba块(Vim)的新通用视觉baseline,它用位置嵌入标记图像序列,并用双向状态空间模型压缩视觉表示。问题引入在处理图像和视频等视觉数据方面,基于纯SSM的通用baseline尚未得到探索;Visu......
  • GPTs及Assistant API快速开发AI应用实战
    前言随着人工智能技术的飞速发展,GPTs(如GPT-3、GPT-4等)和OpenAI的AssistantAPI已经成为构建智能应用的重要工具。这些技术不仅提供了强大的自然语言处理能力,还大大简化了AI应用的开发流程。本文将通过几个实战项目,展示如何利用GPTs和AssistantAPI快速开发AI应用。第二......
  • Task01:课程简介、安装Installation
    标题:PyCharm安装流程详解摘要:本文详细介绍了在不同操作系统下安装PyCharm的步骤,包括软件的下载、安装过程中的各项设置以及可能遇到的问题和解决方法,旨在为Python开发者提供一个全面且清晰的PyCharm安装指南。一、引言PyCharm是一款由JetBrains开发的功能强大......
  • NewStarCTF-WP合集
    梦开始的地方第一~二周misc-decompress将所有压缩文件放在一个目录,使用Bandizip解压.001,然后使用md5计算器计算内部内容,即可获得flagmisc-用溯流仪见证伏特台首先进入所给链接找到威胁盟报告,发现由于b站原因导致视频不清晰,于是下载央视频后搜索该新闻,再读出信息powerj7km......
  • Stable Diffusion之提示词指南
    负提示词负向提示词:简单说就是告诉AI你想不要绘制什么,不要在画面中出现的内容。可以看到在WebUI页面中负提示词也是和正提示词一样,有一个输入框,一般我们不输入也是可以的。使用负面提示词是引导图像的另一种好方法,这里放的不是你想要的东西,而是你不想要的东西。它们不......
  • cpp:指针转化(百度AI:static_cast/dynamic_cast/const_cast/reinterpret_cast)
    cpp:指针转化(百度AI:static_cast/dynamic_cast/const_cast/reinterpret_cast)    一、c++指针转化概述: 在C++中,指针转换主要包括静态转换、动态转换、常量转换和重新解释转换四种类型。‌ ‌1、 静态转换(static_cast)‌: -- 用于基本数据类型之间的转换,如将int转换......
  • Ubuntu 24.04使用virtualBox启动虚拟机提示Kernel driver not installed的解决办法
    1.Ubuntu安装virtualBoxvirtualBox官方下载对应ubuntu24.04系统的deb安装包进入到下载文件所在目录使用如下apt命令安装下载好的deb安装包sudoaptinstall-f./virtualBox*2.启动虚拟机提示“Kerneldrivernotinstalled”由于我装的是双系统,ubuntu挂载了windows下使......