相关文章
第零回.序和属性
第一回.真的了解.NET CF吗?

第二回.初窥CF类型加载器

摘要
对可执行的应用程序,它的生命是从Load开始的,一个.NET 的程序,某种程度上可以说它的生命是从加载类型开始的。本文阐述了在.NET CF中的Type Loader的工作原理,并结合示例说明了如何让您的应用程序启动更快。
Keywords
.NET Compact Framework,Type Loader, JIT ,Generic,Dictionary

1.       设备不能承受之慢

等待是很痛苦的,让用户等待是不人道的。现在PC机上的程序也许感觉不是很明显,因为桌面计算机性能普遍较佳,而且一般的应用程序不会涉及海量数据的运算,日常的程序即使有性能上的某些缺陷,用户也不会明显的察觉到。

然而在CPU处理能力和内存均有限的移动设备上,计算机的工作能力就没有那么可观了。也许一个简单的程序就能让你的设备陷入肉眼就能识别的性能危机。设想一下用户怀着愉悦的心情在你的程序中选择了一个菜单项,但是他那台不怎么样的设备却需要反应数十秒,用户也只能望着屏幕中央不断旋转的光标兴叹了,这无疑对用户来说是一个打击,软件开发人员更是颜面无光。

好吧,下面我们就来看看下面一个简单的程序是如何折腾你的CLR的,虽然我刻意将它极端化了一点点J

   public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            int[] r = new int[6];
            int n = 0;

            安置一些计时的环节,并调用funcX()触发Type Loader#region 安置一些计时的环节,并调用funcX()触发Type Loader
            r[n++] = func0(Environment.TickCount);
            r[n++] = func1(Environment.TickCount);
            r[n++] = func2(Environment.TickCount);
            r[n++] = func3(Environment.TickCount);
            r[n++] = func4(Environment.TickCount);
            r[n++] = func5(Environment.TickCount);
            #endregion

            //将结果保存到本地文本文件中
            using (StreamWriter writer = new StreamWriter(@"\Temp\LoaderPerf.txt", false))
            {
                for (int i = 0; i < n; ++i) writer.WriteLine(r[i]);
            }
        }

        /**//// <summary>
        /// 返回一个整型值,单位为毫秒
        /// 指示从Type Load开始到方法开始执行的时间差
        /// </summary>
        /// <returns>初始化类型所耗费的时间(毫秒)</returns>
        public static int func0(int start)
        {
            int diff = Environment.TickCount - start;
            Maps0.func();
            return diff;
        }

        与func0一致的其他五个方法#region 与func0一致的其他五个方法

        public static int func1(int start)
        {
            int diff = Environment.TickCount - start;
            Maps1.func();
            return diff;
        }

        public static int func2(int start)
        {
            int diff = Environment.TickCount - start;
            Maps2.func();
            return diff;
        }

        public static int func3(int start)
        {
            int diff = Environment.TickCount - start;
            Maps3.func();
            return diff;
        }

        public static int func4(int start)
        {
            int diff = Environment.TickCount - start;
            Maps4.func();
            return diff;
        }

        public static int func5(int start)
        {
            int diff = Environment.TickCount - start;
            Maps5.func();
            return diff;
        }

        #endregion
    }

    /**//// <summary>
    /// 定义了一个有些夸张的类
    /// 它定义了5个枚举5个泛型字典并做了初始化
    /// </summary>
    public static class Maps0
    {
        public enum Enum1 { i1, }
        public enum Enum2 { i1, }
        public enum Enum3 { i1, }
        public enum Enum4 { i1, }
        public enum Enum5 { i1, }
        private static readonly Dictionary<Enum1, int> map1 = new Dictionary<Enum1, int>();
        private static readonly Dictionary<Enum2, int> map2 = new Dictionary<Enum2, int>();
        private static readonly Dictionary<Enum3, int> map3 = new Dictionary<Enum3, int>();
        private static readonly Dictionary<Enum4, int> map4 = new Dictionary<Enum4, int>();
        private static readonly Dictionary<Enum5, int> map5 = new Dictionary<Enum5, int>();
        public static void func() { }
    }

    与Maps0一样的其他五个类#region 与Maps0一样的其他五个类

    public static class Maps1
    {
        public enum Enum1 { i1, }
        public enum Enum2 { i1, }
        public enum Enum3 { i1, }
        public enum Enum4 { i1, }
        public enum Enum5 { i1, }
        private static readonly Dictionary<Enum1, int> map1 = new Dictionary<Enum1, int>();
        private static readonly Dictionary<Enum2, int> map2 = new Dictionary<Enum2, int>();
        private static readonly Dictionary<Enum3, int> map3 = new Dictionary<Enum3, int>();
        private static readonly Dictionary<Enum4, int> map4 = new Dictionary<Enum4, int>();
        private static readonly Dictionary<Enum5, int> map5 = new Dictionary<Enum5, int>();
        public static void func() { }
    }

    public static class Maps2
    {
        public enum Enum1 { i1, }
        public enum Enum2 { i1, }
        public enum Enum3 { i1, }
        public enum Enum4 { i1, }
        public enum Enum5 { i1, }
        private static readonly Dictionary<Enum1, int> map1 = new Dictionary<Enum1, int>();
        private static readonly Dictionary<Enum2, int> map2 = new Dictionary<Enum2, int>();
        private static readonly Dictionary<Enum3, int> map3 = new Dictionary<Enum3, int>();
        private static readonly Dictionary<Enum4, int> map4 = new Dictionary<Enum4, int>();
        private static readonly Dictionary<Enum5, int> map5 = new Dictionary<Enum5, int>();
        public static void func() { }
    }

    public static class Maps3
    {
        public enum Enum1 { i1, }
        public enum Enum2 { i1, }
        public enum Enum3 { i1, }
        public enum Enum4 { i1, }
        public enum Enum5 { i1, }
        private static readonly Dictionary<Enum1, int> map1 = new Dictionary<Enum1, int>();
        private static readonly Dictionary<Enum2, int> map2 = new Dictionary<Enum2, int>();
        private static readonly Dictionary<Enum3, int> map3 = new Dictionary<Enum3, int>();
        private static readonly Dictionary<Enum4, int> map4 = new Dictionary<Enum4, int>();
        private static readonly Dictionary<Enum5, int> map5 = new Dictionary<Enum5, int>();
        public static void func() { }
    }

    public static class Maps4
    {
        public enum Enum1 { i1, }
        public enum Enum2 { i1, }
        public enum Enum3 { i1, }
        public enum Enum4 { i1, }
        public enum Enum5 { i1, }
        private static readonly Dictionary<Enum1, int> map1 = new Dictionary<Enum1, int>();
        private static readonly Dictionary<Enum2, int> map2 = new Dictionary<Enum2, int>();
        private static readonly Dictionary<Enum3, int> map3 = new Dictionary<Enum3, int>();
        private static readonly Dictionary<Enum4, int> map4 = new Dictionary<Enum4, int>();
        private static readonly Dictionary<Enum5, int> map5 = new Dictionary<Enum5, int>();
        public static void func() { }
    }

    public static class Maps5
    {
        public enum Enum1 { i1, }
        public enum Enum2 { i1, }
        public enum Enum3 { i1, }
        public enum Enum4 { i1, }
        public enum Enum5 { i1, }
        private static readonly Dictionary<Enum1, int> map1 = new Dictionary<Enum1, int>();
        private static readonly Dictionary<Enum2, int> map2 = new Dictionary<Enum2, int>();
        private static readonly Dictionary<Enum3, int> map3 = new Dictionary<Enum3, int>();
        private static readonly Dictionary<Enum4, int> map4 = new Dictionary<Enum4, int>();
        private static readonly Dictionary<Enum5, int> map5 = new Dictionary<Enum5, int>();
        public static void func() { }
    }

#endregion

以上代码在Windows Mobile 6 Professional的模拟器上运行时,得到的txt文件如下:

可以看到反应(加载类型的时间)基本在1秒左右,而且逐渐增长,原因下文中会做详细的解释,当然,这些数据和机器性能也是有关的。但是差别不会太大。

同样的程序在我的Samsung i718上测试结果为如下:

406
377
553
993
1678
2126.

虽然跟模拟器相比开始时效果要好一点,但还是一个数量级的

如果在你的应用程序加载几个类型就要花费CLR以秒为单位的时间,那用户估计要抓狂了。

2.       到底发生了什么?(What happened when JITing?)

前面只是举了一个略有夸张的例子,也许你还不明白它夸张在哪里,但它确实是有可能存在的,比如说我们的查询服务,比如通信数据中要对某些对象进行序列化处理,比如多机通信系统有时可能要用到的很多的Hashtable,Dictionary等等。下面我们就来看看,刚才那段代码在执行的时候,CF CLR发生了什么。

在代码执行到Maps0.func()的时候,对方法func()的调用会触发CF CLR的Type Loader的工作(这是JIT的工作方式,我也曾在这里提到)。为什么会在这里触发Type Loader呢?

因为之前的代码并没有访问到Maps0咯,如果Type Loader检测到该类型已经被构造出来,那么它将立即return,以免做重复的工作。

加载Maps0又会立即导致这个包含Maps0的模块(module)被标记为忙碌(busy)。

注意,这里说的是module而不是说这个App.exe,尽管此时的app的确是busying。

在加载Maps0的过程中,CF Loader会遇到Maps0所包含的某些静态类型字段如Dictionary< Maps0.Enum1, int>,这些类型同样是他从来没遇到的,于是,在JIT的时候这样的Load会递归的进行下去,直到涉及的类型都被Load完毕。

好,说到加载Dictionary< Maps0.Enum1, int>,这时,由于mscorlib.dll包含有Dictionary<T, U>的定义,现在mscorelib.dll同样会被打上一个忙碌中(busy)的标记。

接着,CF Loader会加载DictionaryEntry<Maps0.Enum1,int>,因为在Dictionary的内部会有一些数组结构,用来存储键值对,而每一个这样的键值对是一个DictionaryEntry,关于DictionaryEntry你可以在MSDN找到更详尽的解释。

而在加载DictionaryEntry<Maps0.Enum1,int>的时候,CLR又发现了类型Maps0.Enum1(一开始在Dictionary的时候就遇到了Maps0.Enum1),这时Loader将再次尝试去加载Maps0.Enum1。然而当Loader抛开mscorlib.dll(因为DictionaryEntry并不在mscorlib.dll中)而转向程序集app.exe它会发现,app.exe中那个包含Maps0的模块被标记为busy,所以它并不能访问,这其实是一个博弈的问题。所以上面的代码初看似乎除了冗余似乎没什么大问题,但实际上并不是一种好的实现方式。

而此时访问的Maps0.Enum1将被描述为处于一个“部分加载”(待结束) 的状态。

同样,包含对Maps0.Enum1引用的DictionaryEntry<Maps0.Enum1, int> 和Dictionary<Maps0.Enum1, int> 也会处于这样的部分加载的状态。由于Loader从对这些类型的加载中不成功地返回,最后它会扫描当前的module中的每一种类型,不管他们是返回的是完整加载还是“部分加载”状态。不过个人认为这里面有一定的可以优化的空间,虽然这样的扫描是必要的,因为不能保证返回的加载成功的类型运行时不会访问到“部分加载”的类型。

所有的加载到的类型扫描完毕后,Loader此时会尝试去完成扫描到的所有“部分加载”的类型。最终Loader 还是会回到起初对Maps0的调用的地方,并发现Maps0.Enum1处于“部分加载”的状态,这个时候才将Maps0的Enum1完整加载。

接下来,Loader会按照相同的方式去加载Maps0的其他成员。被Enum1“阻碍”的其他类型也都将被顺利加载,因为此时的Loader被告知Maps0.Enum类型已经加载过了。上面提到的这个问题就像一条拉链,如果每一个环节都拉紧了,最终还是只能从头把它拉开。

可是还没完呢,别放松得太早!这才一个Enum1呢,接着执行下去Loader会尝试加载Dictionary<Maps0.Enum2, int>,而这时候同样的问题会再次发生,因为在加载DictionaryEntry<Maps0.Enum2, int>的时候,Maps0.Enmu2之前已处于“部分加载状态”,然后以上的一切会重演。如此循环下去,到足够的次数Maps0类型终于被Load完毕了!汗,确实是非常纠结和缓慢的过程,每一次,当Type Loader尝试在App.exe中加载EnumX并以失败返回时,它都必须循环访问在mscorelib.dll和App.exe中每一个已加载的类型 (包括未加载完全的类型)。

现在你也许会感叹了,上面那段看似和谐的代码给你带来了多么痛苦的一次CLR之旅!Got twisted?!头晕了吗?

另外,我们可以从输出的结果中看到,从Maps0到MapsN,所耗费的时间是越来越长的,这又是什么原因呢?其实仔细分析我们可以知道,由于加载的类型逐渐增多,当每一次遍历他们,并获取其加载状况的时候,所需要的代价(资源,时间)也会越来越多,这个是很好理解的。

最后,再来回顾一下上面描述的问题,可以发现,主要的耗费在于那个隐藏的博弈的怪圈,简单来说,事情就是这样的: 类型 A在模块A.dll中,但它引用了一个类型B,但是这个类型B又引用了另外一个类型A2,有趣的是这个A2却正好位于已谢绝访问的A模块中,而且A2并没有被完全加载。问题就在这里,所以必须让A2先加载完毕,但是这之前Loader却要扫描所有已加载的类型,现在应该很清楚了吧。头还晕的同志可以喝杯茶再多看两遍。呵呵。虽然这里的泛型字典有点特殊,但是它很好的反映了问题,接下来,来看看改如何优化这个程序。

3.       较好的解决方案

针对这个问题,我们该如何提高程序的性能呢?

这里有两种方式:

1)我们可以通过转移的方式打破这种循环依赖的结构。把Maps类型或者Enum类型放到另外的程序集中去。

2)我们可以通过某些手段让这所有的Enum类型都先加载,后面的Maps的内容涉及EnumX的就不再需要重复加载了。

下面来来看看这两种方案具体是如何实现的

第一种方案是从编程原则上面说的,这很好理解,我们在编写程序的时候应当使所有涉及的引用是“前向”的,也就是说,尽量避免这种往“回”的引用出现,以免造成环形引用。这有点像那个Philosopher的例子,只不过那个例子会造成Deadlock,而这里的lock最终会由CLR费力的解开。而这都是应该尽量避免的。

第二种方案也比较好理解,但是实现起来可能会比较繁琐,

也许你会设想,要是在调用Maps0.fun()之前先new一个Enum1的实例不就好了吗?但这是不可能实现的,成员类的调用还是会首先导致父类的加载。而这还是回到了之前出现的那个问题上了。

我们需要作的应该是把EnmuX类型均移到Maps0类型之外,并放到另一个类型中去,比如class EnumsInMaps0,或者就让他们在最外层的class内也行。然后去让这个EnumsInmaps中的Enums先实例化,如下:

//我把Maps0,Maps1,Maps2中的Enum那出来,放到EnumInMaps中:

   class EnumInMaps
    {
        public enum Maps0Enum1 { i1, }
        public enum Maps0Enum2 { i1, }
        public enum Maps0Enum3 { i1, }
        public enum Maps0Enum4 { i1, }
        public enum Maps0Enum5 { i1, }

        public enum Maps1Enum1 { i1, }
        public enum Maps1Enum2 { i1, }
        public enum Maps1Enum3 { i1, }
        public enum Maps1Enum4 { i1, }
        public enum Maps1Enum5 { i1, }

        public enum Maps2Enum1 { i1, }
        public enum Maps2Enum2 { i1, }
        public enum Maps2Enum3 { i1, }
        public enum Maps2Enum4 { i1, }
        public enum Maps2Enum5 { i1, }

        public void LoadMessage()
        {
            // MessageBox.Show("Enums Loaded!");
        }
    }

注意,这里也许单纯的new还不行,即使是你为这个EnumsInMaps创建了一个实例,仍然不能保证它的每个成员都被JIT了。我们姑且踏实一点,这里我不妨这样:

         public static void PreLoadEnums()
        {
            EnumInMaps.Maps0Enum1.i1.ToString();
            EnumInMaps.Maps0Enum2.i1.ToString();
            EnumInMaps.Maps0Enum3.i1.ToString();
            EnumInMaps.Maps0Enum4.i1.ToString();
            EnumInMaps.Maps0Enum5.i1.ToString();

            EnumInMaps.Maps1Enum1.i1.ToString();
            EnumInMaps.Maps1Enum2.i1.ToString();
            EnumInMaps.Maps1Enum3.i1.ToString();
            EnumInMaps.Maps1Enum4.i1.ToString();
            EnumInMaps.Maps1Enum5.i1.ToString();

            EnumInMaps.Maps2Enum1.i1.ToString();
            EnumInMaps.Maps2Enum2.i1.ToString();
            EnumInMaps.Maps2Enum3.i1.ToString();
            EnumInMaps.Maps2Enum4.i1.ToString();
            EnumInMaps.Maps2Enum5.i1.ToString();
        }

直接在Enums上调用方法,这总可以了吧,好,现在我们就可以让enumX顺利加载了,也可以随意实例化了,不用担心什么循环依赖。你也不必总是保留一个实例,类型一旦被加载,在这个应用程序的生命周期内它都会被标记为已加载,而且生成的本地代码也会被缓存(如果没有内存问题的话)。所以即便没有了实例,这个类依然存在着。

现在,我们需要把对这些enumX的处理交给一个函数去做。当然,这个函数应当是较早调用的,至少要比加载Maps或者他的成员而调用它们的时候更早些。

注意:为了便于在同一份代码中对比,这里只对Maps0,Maps1,Maps2进行了修改,后面三个类依旧保持原样

执行结果如下:

在我的Samsung i718上面测试结果如下:

4
4
42
845
1358
1797

可以看到,经过修改的Maps0,Maps1,Maps2的Load时间加起来不过几十毫秒,较之前面的代码,速度提高了百倍左右,看来我们修改的效果是明显的。

4.       类型加载的几句你必须知道的废话 

再多说几句关于类型构造器的话吧。

首先,编写类型时,要时刻想到CLR在加载它的时候会有哪些行为,你的代码逻辑是否会导致交叉引用而对CLR造成前面提到的困惑。

另外,构造时要注意CLR的“自动化”行为。要弄清需要的是静态构造器还是实例构造器,默认的CLR是否会调用无参构造器,是否每个构造器都导致了其他成员的初始化等等。这里面仍然有着可优化空间。

最后,关于前两天有网友问到的,Windows Mobile上的程序是否有必要检查其版本唯一性?也就是说,是否需要自己在程序中保证当前运行的只有一份自己的程序的实例。

这个问题要分情况考虑:

如果你的程序是纯本地代码编写(without CLR),那么跟在WinCE下无异,你需要在你的程序检查当前的运行的程序(当然,方法很多,比如CreateEvent捕获异常等等,这里不做详细介绍)。

如果你的程序是托管的(with CLR Supports),那么你设备上的.NET Compact Framework CLR会帮你做这个维护,保证你的应用程序不会出现多份。CLR此时的工作如下模式:

首先,CLR会找到你的程序入口点,在尝试加载你的应用程序之前它会检查程序集的信息,看要加载的应用程序是否在当前已请求Singleton的程序清单上,如果没有则证明是首次执行程序,然后再加载该应用程序到CLR中,然后请求Singleton保护。

简单说来如下所示:

AppStart();

CheckSingletonMutex();

LoadAppIntoCLR();

AcquireSingletonMutex();

但是,当你启动的间隔极短,在Check Singleton还没完成的时候,还是有可能出现多个你的应用程序实例同时存在的情况。所以说,作为正式的产品,这样的检查还是有必要的。

点此处下载代码示例

总结

程序的性能总是在我们不经意间浪费掉了。PC机的开发也许感觉还不是太明显,作为移动设备,性能问题却十分要命。

类型的加载是JIT的,托管应用程序是CLR掌管的一个运行实例,JIT是CLR的必杀技,CLR是.NET的灵魂。作为移动设备的程序员,在享受CF CLR带来的种种便利的同时,也应该为CLR想想,尽量减轻它额外的负担,让你的应用程序享受裸奔一样的快感!

Regards

Reference:
MSDN 
Jeffrey Richter CLR via C# Second Edition
.NET Compact Framework 社区

---

©Freesc Huang
  黄季冬<fox23>@HUST
   2008/3/1

Windows Mobile 进阶系列.第二回.初窥.NET CF类型加载器相关推荐

  1. Windows Mobile 进阶系列.第一回.真的了解.NET CF吗?

    第一回. 真的了解.NET Compact Framework吗? 作为系列文章的开篇,有必要先详细了解一下基于CE.NET的.NET Compact Framework(以后简称.NET CF),本 ...

  2. Windows Mobile 开发系列文章收藏 - Windows Mobile 6.x

    收集整理一些Windows Mobile 6.x开发相关文章, 文章及相关代码大部分搜集自网络,版权属于原作者! 智能手机      手机词汇      研发手机基本流程 WAP协议分析(1)     ...

  3. Windows内核加载器概念学习

    最近看ReactOS源码分析相关,看到内核加载器概念相关的:原文如下: ReactOS源码分析--内核加载器(一) 计算机BIOS读取硬盘第一个扇区的数据到内存0x7C00位置,将控制权交给主引导记录 ...

  4. jQuery Mobile 手动显示ajax加载器,提示加载中...

    在使用jQuery Mobile开发时,有时候我们需要在请求ajax期间,显示加载提示框(例如:一个旋转图片+一个提示:加载中...).这个时候,我们可以手动显示jQuery Mobile的加载器,大 ...

  5. 在Windows端安装kafka 提示错误: 找不到或无法加载主类 的解决方案

    在Windows端安装kafka 提示错误: 找不到或无法加载主类 的解决方案 参考文章: (1)在Windows端安装kafka 提示错误: 找不到或无法加载主类 的解决方案 (2)https:// ...

  6. 【Youtobe trydjango】Django2.2教程和React实战系列八【渲染数据库数据与模板加载顺序探究】

    [Youtobe trydjango]Django2.2教程和React实战系列八[渲染数据库数据与模板加载顺序探究] 1. 准备数据 2. 渲染数据库数据到模板 3. 如何在app里加载django ...

  7. 网络安全进阶篇之免杀(十四章-5) Golang加载器CS免杀国内主流杀软

    文章目录 一. 概念 1.1 360 安全卫士和 360 杀毒 1.2 Golang 二.前期准备 2.1 下载地址 三.具体过程 3.1 使用CS生成payload 3.2 免杀过程 3.3 测试 ...

  8. Windows Mobile 系列文章索引---不断整理中(2009-07-08)

    Windows Mobile 高级编程系列 ØWindows Mobile 进阶系列.第零回.序 ØWindows Mobile 进阶系列.第一回.真的了解.NET CF吗? ØWindows Mob ...

  9. Windows Mobile 开发系列文章收藏 - 讨论篇

    关注Windows Mobile 应用开发, 探讨移动应用未来发展方向, 未来的手机又会是一个什么样子呢?  Windows Mobile 未来会发展成何种高度? 这些方面都值得我们去思考关注, 想了 ...

  10. 学习Windows Mobile开发系列笔记(win32基本程序框架)

    一直对Windows Mobile开发很有兴趣.去年已经做过一个这方面的项目了,虽然自己看了很多资料,但是没有系统的学习过.现在应该还只是个入门者吧. 现在想系统的学习一番,我决定把Windows M ...

最新文章

  1. HA: SHERLOCK 靶机渗透取证
  2. 周志华,李航来智源大会了!
  3. 深度强化元学习教程---元学习概述
  4. 如何在Ubuntu 16.04安装R
  5. EditText 空指针问题
  6. url override and HttpSession implements session
  7. .Net Core项目 Encoding不全问题
  8. iPhone Instruments工具使用_检测内存泄露(转)
  9. python基础教程自学网-Python基础系统管理学习手册视频教程
  10. AtCoder Grand Contest 028题解
  11. 第一讲:使用html5——canvas绘制奥运五环
  12. python语音识别_Python语音识别终极指南
  13. html+css+js实现小游戏flybird(完整版)
  14. 在线出境游竞品分析报告:携程、途牛和马蜂窝
  15. c语言程序训练营,C语言编程强化训练营
  16. 群星闪耀 视觉科技史上引领我们前进的不朽瞬间
  17. 金启孮:普通话其实是满州人的蹩脚汉语
  18. 推荐10款自动化测试工具
  19. d使用ldc生成wasm
  20. TOJ 3778.Sheldon's Friendship II

热门文章

  1. VBA金融建模——期权定价
  2. 选对了裤长,胜过任何一件高级定制
  3. lda指令是什么意思_lda指令什么意思
  4. 眼睛到底是冷敷好还是热敷好?敷眼睛是个技术活!
  5. http web服务器
  6. 如何解决上传到github上的图片显示不出来的问题
  7. iOS集成twitter分享
  8. CAD打开慢,卡在99%
  9. 经典书籍《宽客人生》阅读心得
  10. 查看服务器显卡GPU型号