WPF 应用启动过程同时启动多个 UI 线程且访问 ContentPresenter 可能让多个 UI 线程互等

在应用启动过程里,除了主 UI 线程之外,如果还多启动了新的 UI 线程,且此新的 UI 线程碰到 ContentPresenter 类型,那么将可能存在让新的 UI 线程和主 UI 线程互等。这是多线程安全问题,不是很好复现,即使采用 demo 的代码,也需要几千次运行才能在某些配置比较差的机器上遇到新的 UI 线程和主 UI 线程互等,应用启动失败。本文来告诉大家复现的步骤,以及原因,和解决方法

复现步骤

只需要在主 UI 线程里,加载的资源里面包含 ContentPresenter 类型的初始化。然后在主 UI 线程执行 App 时,同时启动另一个 UI 线程,让另一个 UI 线程碰到 ContentPresenter 类型。碰到 ContentPresenter 类型,让 ContentPresenter 类型的静态构造函数能被执行,代码如下

先在 App.xaml 定义资源,定义的资源刚好碰到 ContentPresenter 类型


            <Setter Property="HorizontalAlignment" Value="Left" />
            <Setter Property="VerticalAlignment" Value="Center" />
            <Setter Property="Width" Value="100" />
            <Setter Property="Height" Value="100" />
            <Setter Property="Margin" Value="10,10,10,10" />
            <Setter Property="Template" >
                <Setter.Value>
                    <ControlTemplate>
                        <ContentPresenter Content="123"></ContentPresenter>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>

大家都知道,在 WPF 里的 XAML 将会被构建为 BAML 文件,在启动过程里面加载 BAML 将需要调用到 WPF 底层,将 BAML 展开内存。如上代码将需要创建 ContentPresenter 对象

在 App.xaml.cs 里,在 App 构造函数再启动另一个 UI 线程,在新 UI 线程里面访问 ContentPresenter 类型的 ContentProperty 属性,这是一个静态属性,在类型在程序集第一次碰到,将会调用类型的静态构造函数

public partial class App : Application
{
    public App()
    {
        RunNewUIThread();
    }

    public static void RunNewUIThread()
    {
        Thread thread = new(Run);
        thread.SetApartmentState(ApartmentState.STA);
        thread.Start();

        void Run()
        {
            var currentDispatcher =
            System.Windows.Threading.Dispatcher.CurrentDispatcher;
            currentDispatcher.InvokeAsync(() =>
            {
                TouchContentPresenter();
            });
            System.Windows.Threading.Dispatcher.Run();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void TouchContentPresenter()
    {
        // Just call the .cctor in ContentPresenter.

        var property = ContentPresenter.ContentProperty;
        CaptureObject(property);
    }

    private static void CaptureObject(object obj)
    {
        Debug.WriteLine(obj);
    }
}

以上的代码为了不在 Release 下被优化,于是写了 TouchContentPresenter 和 CaptureObject 两个方法。类型的静态构造函数是在类型被碰到之前,放在 TouchContentPresenter 方法里面,可以让代码在准备调用 TouchContentPresenter 方法时才尝试执行 ContentPresenter 的静态构造函数。同时加上 MethodImplOptions.NoInlining 让代码不会被内联

再加上 CaptureObject 方法,强行捕获参数,从而让获取属性的代码不会被优化

复现的代码放在 https://github.com/lindexi/lindexi_gd/tree/de8bdfbf4715c7200631913cecd24749c98228a3/BerehenachearbairGarciwereyer 上,拉下来之后,构建运行,大概运行几千次,预计是可以复现

在复现时,可以看到线程 Id 为 22436 的主 UI 线程在等待 ContentPresenter 的静态构造函数完成,如下图

WPF 应用启动过程同时启动多个 UI 线程且访问 ContentPresenter 可能让多个 UI 线程互等

这是因为在 .NET 里面,一个类型的静态构造函数,只能由一个线程执行,不会存在多线程同时执行静态构造函数。如果有某个线程在执行静态构造函数,那么其他的线程将需要等待静态构造函数执行完成才能继续碰类型。也就是相当于静态构造函数进入时加了锁,需要在执行完成之后才会释放锁,其他的线程都在等待静态构造函数的锁,也就是等待静态构造函数执行完

在线程 Id 为 16100 的新 UI 线程,执行到 ContentPresenter 的静态构造函数,然而静态构造函数在等待一个被主 UI 线程拿到的锁,静态构造函数无法执行完成

WPF 应用启动过程同时启动多个 UI 线程且访问 ContentPresenter 可能让多个 UI 线程互等

原理

核心原因是一个不良设计导致的,在 ContentPresenter 的静态构造函数里面,干的活太多了。其中就包括调用了 CreateTextBlockFactory 等方法,如下代码

        static ContentPresenter()
        {
            DataTemplate template;
            FrameworkElementFactory text;
            Binding binding;

            // Default template for strings when hosted in ContentPresener with RecognizesAccessKey=true
            template = new DataTemplate();
            text = CreateAccessTextFactory();
            text.SetValue(AccessText.TextProperty, new TemplateBindingExtension(ContentProperty));
            template.VisualTree = text;
            template.Seal();
            s_AccessTextTemplate = template;

            // Default template for strings
            template = new DataTemplate();
            text = CreateTextBlockFactory();
            text.SetValue(TextBlock.TextProperty, new TemplateBindingExtension(ContentProperty));
            template.VisualTree = text;
            template.Seal();
            s_StringTemplate = template;

            // 忽略其他代码
        }

        internal static FrameworkElementFactory CreateAccessTextFactory()
        {
            FrameworkElementFactory text = new FrameworkElementFactory(typeof(AccessText));

            return text;
        }

在 CreateAccessTextFactory 创建的 FrameworkElementFactory 对象的构造函数代码如下,在构造函数将会给 FrameworkElementFactory.Type 属性赋值

        public FrameworkElementFactory(Type type, string name)
        {
            Type = type;
            Name = name;
        }

然而 FrameworkElementFactory.Type 属性是比较复杂的,在赋值积分啊里面将会调用到 XamlReader.BamlSharedSchemaContext.GetKnownXamlType 方法

        public Type Type
        {
            get { return _type; }
            set
            {
                // 忽略其他代码

                // If this is a KnownType in the BamlSchemaContext, then there is a faster way to create
                // an instance of that type than using Activator.CreateInstance.  So in that case
                // save the delegate for later creation.

                WpfKnownType knownType = null;
                if (_type != null)
                {
                    knownType = XamlReader.BamlSharedSchemaContext.GetKnownXamlType(_type) as WpfKnownType;
                }
                _knownTypeFactory = (knownType != null) ? knownType.DefaultConstructor : null;
            }
        }

GetKnownXamlType 里面将需要等待 _syncObject 对象的锁。然而 XamlReader.BamlSharedSchemaContext 是一个静态属性,这就意味着在使用此属性,无论是主 UI 线程还是新 UI 线程都拿到相同的 WpfSharedBamlSchemaContext 类型对象,也就是说调用到 WpfSharedBamlSchemaContext 的其他方法时,等待的是相同的一个 _syncObject 对象

        internal XamlType GetKnownXamlType(Type type)
        {
            XamlType xamlType;

            lock (_syncObject)
            {
                // 忽略其他代码

            }
            return xamlType;
        }

如果是在 新 UI 线程先碰到 ContentPresenter 类型,那么 ContentPresenter 的静态构造函数将在 新 UI 线程执行。执行的静态构造函数将会等待 WpfSharedBamlSchemaContext_syncObject 对象的锁。如果刚好主 UI 线程正在展开 Baml 需要使用 Create_BamlProperty_ContentPresenter_ContentSource 方法,那么在此方法进入时,将因为碰到了 ContentPresenter 类型,需要等待 ContentPresenter 的静态构造函数执行完成

        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
        private WpfKnownMember Create_BamlProperty_ContentPresenter_ContentSource()
        {
            Type type = typeof(System.Windows.Controls.ContentPresenter);
            DependencyProperty  dp = System.Windows.Controls.ContentPresenter.ContentSourceProperty;
            var bamlMember = new WpfKnownMember( this,  // Schema Context
                            this.GetXamlType(typeof(System.Windows.Controls.ContentPresenter)), // DeclaringType
                            "ContentSource", // Name
                             dp, // DependencyProperty
                            false, // IsReadOnly
                            false // IsAttachable
                                     );
            bamlMember.TypeConverterType = typeof(System.ComponentModel.StringConverter);
            bamlMember.Freeze();
            return bamlMember;
        }

在进入 Create_BamlProperty_ContentPresenter_ContentSource 方法之前,其实主 UI 线程已获取了 _syncObject 对象的锁。也就是说 ContentPresenter 的静态构造函数必须等待主 UI 线程释放锁才能完成,然而主 UI 线程必须等待 ContentPresenter 的静态构造函数执行完成才能释放锁

于是就构成了两个线程相互等待。在主 UI 线程进入 Create_BamlProperty_ContentPresenter_ContentSource 方法,需要等待 ContentPresenter 的静态构造函数执行完成,才能释放主 UI 线程的锁,让 ContentPresenter 的静态构造继续执行。执行在新 UI 线程的 ContentPresenter 的静态构造函数在等待主 UI 线程释放锁才能执行完成。主 UI 线程在等待新 UI 线程的静态构造函数执行完成。新 UI 线程在等待主 UI 线程等待静态构造函数执行完成之后释放的锁

两个 UI 线程进入摸鱼,应用就起不来

看到以上的原理,在实际的应用里面,想要遇到这个坑还是很难。因为 ContentPresenter 的静态构造函数只会执行一次,谁能说一定不在主 UI 线程执行?而且即使在新 UI 线程执行,那也不一定刚好在进入静态构造函数,主 UI 线程也需要用到 ContentPresenter 的相关属性。这个是需要刚好的,如果在主 UI 线程需要用到 ContentPresenter 的相关属性比较前,就在新 UI 线程进入 ContentPresenter 的静态构造函数,那将因为在新 UI 线程能等到锁而成功执行完成 ContentPresenter 的静态构造函数。如果在主 UI 线程碰到 ContentPresenter 的相关属性时,那么此时的 ContentPresenter 的静态构造函数就由主 UI 线程执行,也没有任何问题。只有在主 UI 线程拿到了锁,在准备碰到 ContentPresenter 的上一个方法时,也就是 WpfSharedBamlSchemaContext.CreateKnownMember 方法,此时的主 UI 线程已拿到锁,在新 UI 线程进入 ContentPresenter 的静态构造函数,如此才能让两个线程相互等待

解决方法

了解了原理,解决方法就十分简单了,只需要不让 ContentPresenter 的静态构造方法被新的 UI 线程调度执行即可。在新的 UI 线程执行之前,先碰一下 ContentPresenter 类型即可,例如获取此类型的某个属性之类,如以下代码

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void TouchContentPresenter()
    {
        // Just call the .cctor in ContentPresenter.

        var property = ContentPresenter.ContentProperty;
        CaptureObject(property);
    }

    private static void CaptureObject(object obj)
    {
        Debug.WriteLine(obj);
    }

在开启新的 UI 线程之前,先调用一下 TouchContentPresenter 方法即可。由于碰到了类型里面的某个属性,无论是否静态,都会先调用对应的类型的静态构造函数,静态构造函数只会被调用一次,因此即可解决线程安全问题

另一个解决方法是不要尝试在应用启动的过程里面开启多个 UI 线程。在应用启动完成之后,再开启,就基本不会遇到此问题

这个问题已报告给 WPF 官方,详细请看 Multi UI thread visit the ContentPresenter at application startup may deadlock · Issue #6609 · dotnet/wpf

我认为这也是一个设计缺陷,稍微熟悉 .NET 的开发者都知道,在静态构造函数里面碰锁是很危险的。因为静态构造函数的调用是不确定的,取决于第一次碰到此类型的代码进入之前。因此静态构造函数里面的碰锁的时机将是不可预期的。再加上静态构造函数只能被调用一次,这就让其他多线程碰到此类型,都需要等待静态构造函数执行完成。由于静态构造函数的调用是不可预期的,多线程里只有一个线程能进入静态构造函数,其他线程需要等待,于是此等待就相当于一个锁,如果在静态构造函数里面会碰到另一个锁,那就相当于有两个锁。有两个锁加上不可预期的调用,那这个逻辑很好构成相互等待

Original: https://www.cnblogs.com/lindexi/p/16733255.html
Author: lindexi
Title: WPF 应用启动过程同时启动多个 UI 线程且访问 ContentPresenter 可能让多个 UI 线程互等

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/577740/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

  • Question03-查询平均成绩大于等于60分的同学的学生编号和学生姓名和平均成绩

    * SELECT stu.SID, stu.Sname, CAST(AVG(sc.score) AS DECIMAL(18,2)) avg_score FROM Student s…

    Linux 2023年6月7日
    091
  • 程序员要知道的22个学习网站

    点击标题即可直达链接网址 GitHub是一个面向开源及私有软件项目的托管以及在线软件开发平台,用于存储、跟踪和协作软件项目,开发者能够上传自己的代码文件,并与其他开发者在开源项目上…

    Linux 2023年6月6日
    095
  • php-redis 总结

    php-redis代码库和文档地址:https://github.com/phpredis/phpredis/#readme string 字符串类型: list 列表类型(也是链…

    Linux 2023年5月28日
    0105
  • Redis分布式锁实战

    背景 目前开发过程中,按照公司规范,需要依赖框架中的缓存组件。不得不说,做组件的大牛对CRUD操作的封装,连接池、缓存路由、缓存安全性的管控都处理的无可挑剔。但是有一个小问题,该组…

    Linux 2023年5月28日
    094
  • 正则表达

    常用表达式 单字符:. : 除换行以外所有字符[] :[aoe] [a-w] 匹配集合中任意一个字符\d :数字 [0-9]\D : 非数字\w :数字、字母、下划线、中文\W :…

    Linux 2023年6月13日
    088
  • 随便侃侃博客挖坑的事

    很多都没有写博客了,说实在的,Markdown的语法都忘的差不多了。 今年看着停留在提醒上的写博客计划,然后又想了想要写的东西,太多了,都需要花点时间去总结,感觉静不下心来,真的无…

    Linux 2023年6月6日
    0102
  • git使用命令行保留原分支迁移代码仓库

    有些时候我们需要对git仓库中的项目进行一些迁移,如从a账号迁移到b账号下,从github平台迁移到内部的gitlab平台等。一般平台会自带 migrate 或者 import 的…

    Linux 2023年6月7日
    0106
  • linux系统(centos)下kvm虚拟化用命令行给虚拟机添加硬盘

    linux系统(centos)下kvm虚拟化用命令行给虚拟机添加硬盘 背景 公司有用单台服务器使用kvm装虚拟机,利用webvirtmgr进行界面管理。当虚拟机创建时固定硬盘后,不…

    Linux 2023年6月8日
    0107
  • CTF竞赛权威指南(PWN篇)下载地址

    博客网址:www.shicoder.top微信:18223081347欢迎加群聊天 :452380935 这里给大家提供《CTF竞赛权威指南(PWN篇)》的下载地址(不是网上的64…

    Linux 2023年6月13日
    097
  • 智能指针

    RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内 存、文件句柄、网络连接、互斥量等等)的简单技术。…

    Linux 2023年6月13日
    077
  • CAPL学习笔记

    CAPL是CANOE自带的一种编程语言,要和CANOE中的一个节点绑定在一起。它的文件后缀是.can。 两种添加方式:1. 在simulation setup中增加一个网络节点,配…

    Linux 2023年6月13日
    085
  • 【Ubuntu】如何将Ubuntu软件源切换到国内源?

    为什么切换软件源? 当初次部署Ubuntu镜像时,会发现更新软件时速度非常慢,因为Ubuntu的软件都来自与国外,所下载或更新软件时的速度非常慢,此时就可以选择切换到国内的软件源来…

    Linux 2023年6月13日
    0100
  • Git 代码提交和下载

    1、新建一个目录,存放下载下来的项目; 2、进入刚刚新建的文件夹,点击鼠标右键,选择”Git Bash Here” 3、进行基础配置,作为 Git 的基础配…

    Linux 2023年6月13日
    0122
  • Tomcat启动乱码

    1、找到安装的tomcat的conf目录2、找到logging.properties配置文件3、在文件中找到 java.util.logging.ConsoleHandler.en…

    Linux 2023年6月7日
    0106
  • Centos 7 升级内核

    【背景说明】 在公司进行部署产品时,发公司内部的服务内核资源并不能满足于产品部署条件,于是我和内核就进行了一场风花雪月般的交互,在操作前,本人小白一枚,就在浩瀚的互联网海洋中搜索升…

    Linux 2023年5月27日
    0107
  • 阿里云函数-小米运动

    简介 是否支持多账号:是消息推送平台: server酱 Qmsg酱 PUSHPLUS 原代码(2022.07.12更新)- 不稳定 由于官方接口偶偶失效(每两三月可能失效1-2天(…

    Linux 2023年6月7日
    0101
亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球