• 揭秘 ClownFish 比手写代码还快的原因


    在上篇博客中,我介绍了我的最新版本的通用数据访问层:ClownFish 。

    在那篇博客的回复评论中,有些人感觉比较好奇:为什么ClownFish能比手工代码的执行速度更快?
    不过,也有人不相信,那些人认为反射肯定的速度肯定比不上手写代码。
    显然后者完全是在瞎猜,凭自己的感觉在猜。

    今天的博客不打算再介绍ClownFish在功能上有什么优点,只是想回答上篇博客中那些感兴趣的人,
    解答他们的疑惑:为什么ClownFish能比手工代码的执行速度更快。

    我认为ClownFish拥有更快速度的主要有以下原因:
    1. 运行时不使用反射。
    2. 没有从名称到序号的查找过程。
    3. 尽量使用专用版本的读取方法。
    4. 为每个实体类型生成一个专用的加载器,减少各种查找开销。
    除此之外,在高并发的支持上也做过一些优化。

    运行时不使用反射

    许多人都知道:反射性能较差。

    有些人甚至以此为由不使用反射,还有些人在寻找优化反射性能的方法。 其实反射不管如何优化,都会比不使用反射的代码慢。 所以,ClownFish在运行时,根本就不使用反射。 看到这里,或许有些人又疑惑了:不使用反射如何处理各种类型的加载?

    通常说来,数据访问层【应该】是可以加载未知类型的数据实体对象,不可能是专用的手工代码,因此,使用反射好像是必然的选择。 我想,几乎所有人在设计实体对象加载时,都会这样做。 ClownFish的前辈版本也是这样设计的。

    我喜欢使用反射,因为它的功能实在是太强大了,我花了很多时间研究反射的优化方法。
    当然了,我也试过一些较为成熟的方法:Emit,可惜最终的性能还是不够理想。

    我想我应该解释一下Emit的性能不够理想的原因了,要不然,还会有人不相信。 虽然Emit是目前最为有效的解决反射性能的方法,但它【在使用的时候】有以下缺点:
    1. 生成的委托类型都是object,需要拆箱与装箱。
    2. 生成的委托比较零散,需要一个字典容器来保存,在使用时会有字典的查找开销。
    3. 这种方法推荐是按需生成委托的方式,如果保存容器设计不当,在读写时会有大量的锁开销,影响并发。

    既然反射方案有这么多的麻烦以及性能又不好,那么不使用它不就行了吗?
    或许有些人认为:这不是又回到原点了嘛,数据访问层又不知道它要加载什么类型的实体对象!

    其实,我们并没有回到原点,而是来到一个岔路口上,摆有面前有二条路:
    1. 众人皆知的反射方案。
    2. 动态代码生成并编译的方案。

    微软在推出ASP.NET时,一直强调:ASP.NET的程序是经过编译的,因此比ASP要快。
    在研究ASP.NET过程中,我正好从它那里发现一种可以在某些场景替代反射的方案: 那就是:动态代码生成并编译的方案。

    我们可以回想一下:我们设计的ASPX页面中,那些字符标签,它们是如何运行的,它们并不是C#代码呢。 如果你还想不明拍白的话,可以这样去想:为什么我写了一个控制台程序,需要在编译后,才能运行(可执行文件), 而ASP.NET程序呢? 我们不需要编译啊,写完代码就可以运行!

    在我以前的博客ASP.NET页面优化,性能提升8倍的方法中, 我分析过ASP.NET编译之后的页面代码是什么样的。 那篇博客的一些示例代码大致介绍了ASP.NET在编译页面时所做的处理过程,其实也就是:根据我们的代码生成了另一些可运行的代码。ClownFish也借鉴了这种思想,会根据数据实体类型生成对应的【实体加载器】。 实体加载器是什么,我后面会介绍,这里要说的是:实体加载器中没有任何反射调用,它所包含了经过特别优化处理的代码, 那些代码比较复杂,但是在性能方面是却是最优的(我认为我已优化到极致了),它们虽然也算是通用代码,然而却比通常的手工版代码要快。

    优化反射性能的终极方法

    前面我提到了【动态代码生成并编译的方案】,我认为这种方案是目前为止最有效的优化反射性能的方法,尤其适合需要大最使用反射的场合中代替反射。

    这个方法的实现方式是:在运行时生成可运行的代码,然后调用编译器编译,最后运行编译后的程序集。

    由于最后运行的是编译后的程序集,因此,没有任何性能上的损失,而且,在如果动态生成的代码的执行性能比较优秀, 那么,完全就有可能比手工版本的代码还要高效。

    这个方法需要二个阶段:
    1. 代码生成:对于ClownFish来说,它会生成一份【类似于】手工版本的代码(代码优化方面的事情后面再说)。
    2. 编译代码:调用C#编译器,在运行时编译前面生成的代码,得到一个程序集。

    在上面的二个步骤中,第二步是比较简单的,因为 .net framework 对编译器也提供了封装类型, 为我们在运行时动态编译代码提供了方便的功能。

    比如:ClownFish会在运行时根据实体类型动态生成一些C#代码,那么我们可以通过这下面的代码得到C#编译器的包装类型实例:

    CodeDomProvider.CreateProvider("CSharp")
    

    上面这行代码将根据【当前运行时 CLR版本】匹配的C#编译器提供程序。
    如果要获取特定版本的C#编译器提供程序,可以使用下面的代码:

    Dictionary<string, string> dict = new Dictionary<string,string>();
    dict["CompilerVersion"] = "v3.5";
    dict["WarnAsError"] = "false";
    
    CSharpCodeProvider csProvider = new CSharpCodeProvider(dict);
    

    在得到一个CodeDomProvider的实例后,只要调用CompileAssemblyFromSource方法就可以得到编译后的程序集,例如:

    CompilerResults cr = csProvider.CompileAssemblyFromSource(cp, codes);
    
    return cr.CompiledAssembly;
    

    因此,剩下的任务是看你如何在运行时生成代码了,这是个细致的体力活,应该是没有太多的技术难度的。

    由于ClownFish使用了这种动态生成技术,所以ClownFish的性能不比手工代码差。 然而,ClownFish还能在生成代码时做更多的优化,最终的性能可以超越手工代码。 下面来看一下ClownFish在代码优化方面的改进。

    没有从名称到序号的查找过程

    我想很多人应该知道DbDataReader提供了二种获取数据的索引器:

    public abstract object this[int ordinal] { get; }
    
    public abstract object this[string name] { get; }
    

    这二个索引器的具体执行过程,我们可以查看SqlDataReader的实现:

    public override object this[int i]
    {
        get
        {
            return this.GetValue(i);
        }
    }
    public override object this[string name]
    {
        get
        {
            return this.GetValue(this.GetOrdinal(name));
        }
    }
    

    从代码中我们可以明显地看出:其实都是对GetValue方法的调用,然而,后者会多一个GetOrdinal(name)调用, 它用于从列名到索引序号的转换,GetOrdinal又是如何实现的呢。在这里我不想贴出那一堆的代码,太长了。 所以,根据这个差别,我们可以知道调用后者也是较慢的,因为每个取值过程都有一次【从名称到序号的查找过程】。

    虽然知道有这个差别,但是,我们在直接从DbDataReader中取值时,还只好选择字符串为参数的那个索引器, 因为我们不能假设数据库返回结果中数据列的顺序。 可以想像一下:如果我们要加载一个数据实体列表,它包含30条记录,每个数据实体又包含10个数据成员, 那么,这种查找过程将重复300次! 因此,它对性能的损耗是不可忽视的。

    ClownFish的加载方式则不同,它一定会调用整数序号的那个索引器版本, 因此,完全没有这些性能开销。

    我曾听过我身边的朋友谈过他们的想法:我也可以在手工代码中,加入一个Dictionary<string, int>对象来保存这种索引关系, 然后,在每次取值时,从Dictionary获取索引序号,然后再去调用DbDataReader的整数序号的那个索引器。

    如果您也是这样想的,那么,我该打断一下您的思路了:从Dictionary查找难道不花时间吗?

    所以,采用Dictionary保存名称到序号的思路肯定不是最优的。
    ClownFish的这部分实现中,是完全没有这种查找开销的。

    那么,如何才能做到完全没有查找开销呢?
    我想很多人应该会关心这个问题。
    其实答案并不复杂,首先,我们确实需要一张索引表,它保存了从名称到序号的映射关系。
    如果您认为Dictionary是实现这种数据关系理想的方法,那么也可以。

    下面我来告诉你此时的解决方法:
    我们直接根据Dictionary做foreach迭代,每次是不是都可以同时拿到一对 name与index?
    有了index,是不是可以直接调用GetValue,取得数据结果,
    另外,由于已经得到了名称,是不是可以知道该写到实体对象的哪个数据成员上去?
    所以,采用这种方法,完全可以避开【从名称到序号的查找过程】,将性能的影响减少到最低。

    当然了,这只是一个思路,在实现时需要根据这个思路去设计代码生成器。

    类似的,DataRow也支持这样二种索引器,优化的方法是一样的。

    尽量使用专用版本的读取方法

    DbDataReader,DataRow在读取数据时,不仅仅只提供索引器这一种方法,还提供了一些专用方法:

    public abstract bool GetBoolean(int ordinal);
    public abstract byte GetByte(int ordinal);
    public abstract char GetChar(int ordinal);
    public abstract string GetDataTypeName(int ordinal);
    public abstract DateTime GetDateTime(int ordinal);
    public abstract decimal GetDecimal(int ordinal);
    public abstract float GetFloat(int ordinal);
    public abstract Guid GetGuid(int ordinal);
    public abstract short GetInt16(int ordinal);
    public abstract int GetInt32(int ordinal);
    public abstract long GetInt64(int ordinal);
    

    由于这些方法都是针对特定类型做过优化,因此使用它们可以获得更好的性能,还能减少装箱折箱的次数。
    平时我们确实也不曾调用它们,因为调用它们需要一个序号。
    但是现在不一样了,在前面的小节中我已经解决了这个问题, 所以,ClownFish在生成代码时,会选择最合适的方法来调用。

    那么,ClownFish又是如何该知道调用哪个方法呢?
    答案是:根据实体类型的数据成员的类型来决定,ClownFish会通过反射方法来获取实体类型的定义, 自然可以知道它包含了多少数据成员以及它们的数据类型。

    ClownFish的实体加载器

    前面二个小节,我介绍了ClownFish在生成代码时会采用的一些优化措施, 那么,这些生成的代码又是如何组织的呢?

    ClownFish会分别为每个实体类型生成一个实体加载器,内部称为ModelLoader,它包含这些方法:

    public TModel GetItemFromReader(DbDataReader reader)
    public List<TModel> GetListFromReader(DbDataReader reader, int capacity)
    public TModel GetItemFromTable(DataTable table)
    public List<TModel> GetListFromTable(DataTable table, int capacity)
    
    public object GetValue(object obj, string name)
    public void SetValue(object obj, string name, object value)
    

    前4个是用于加载数据实体(或列表)的,后二个是用于访问实体数据成员的(避开反射调用)。

    从这些加载实体的方法可以看出,最复杂情况:加载列表是在加载器内部完成的。 由于使用动态生成的代码,因此,中间代码没有object委托的那种装箱折箱对性能的损耗, 对性能的提升也是有帮助的。

    ClownFish提倡使用参数化的SQL语句,调用多个参数的SQL语句或者存储过程时, 可以传递一个自定义类型的对象。在运行时,表现为需要读取这些参数对象中的数据成员。 为了避免使用反射,ClownFish直接生成了GetValue, SetValue方法,从而完全避开了反射调用。

    例如,GetValue方法其实是一段很简单的代码,但它可以避开反射调用:

    public static object GetValue(object obj, string name)
    {
        Product item = (Product)obj;
        switch( name ) {
            case "ProductID":
                return item.ProductID;
            case "ProductName":
                return item.ProductName;
    
            default:
                throw new ArgumentOutOfRangeException("name", 
                        string.Format("Property or field {0} not found.", name));
        }
    }
    

    说明:这些代码是ClownFish在运行时生成的,并不需要我们去实现。

    本文前面部分说了Emit也不是最优秀的方案,下面我来通过一个例子再次解释我的理由。 还是前面那个例子:“如果我们要加载一个数据实体列表,它包含30条记录,每个数据实体又包含10个数据成员。” 由于Emit比较复杂,所以现在能找到的包装方法通常只能实现针对一个属性(或字段)生成一个访问委托。 委托生成好了,我们保存在哪里? 要选择一个容器吧,我想几乎绝大多人会选择Dictionary<T, K>的实例中。 悲剧也就从这里开始了!

    你可以计算一下,在将数据库的结果转成实体列表时,需要查找Dictionary多少次,是不是300次?
    前面在好不容易优化了一个300次查找,现在又要来一个300次查找!

    ClownFish的实现中,根本没有这么多的查找过程,因为动态生成的代码可以直接调用, 且包含了循环部分的代码。因此,要比其它反射方案要高效很多。

    对并发的优化

    Emit与Dictionary的悲剧故事还未结束。

    由于Dictionary不是线程安全的类型,所以读写都需要上锁。 然而很多人又非常懒,喜欢直接使用lock, 这样又会导致在高并发时性能低下的问题。 虽然可以采用ReaderWriterLock来 缓解 这个问题,但是我并没有选择它,因为它有点麻烦,而且读写之间仍然会有阻塞。

    ClownFish的内部实现中,也需要缓存一些类型描述信息, 我选择了Hashtable。因为我看到MSDN中对Hashtable有这样一段描述:

    Hashtable 是线程安全的,可由多个读取器线程和一个写入线程使用。多线程使用时,如果只有一个线程执行写入(更新)操作,则它是线程安全的,从而允许进行无锁定的读取(若编写器序列化为 Hashtable)。

    根据这个特性,我可以直接读取Hashtable,仅当Hashtable不存在结果时,才去lock一下, 因此,真正出现锁的时候极少。 如果采用在初始化时编译实体类型,那么就完全没有锁的影响了。

    另一方面,由于ClownFish会为每种实体类型生成一个加载器, 因此,也大大降低了对缓存容器的查找次数,将锁定的需求减少到最低水平。

    小结:ClownFish对并发的优化主要采用了专用加载器与Hashtable的方法, 这二种方法的结合使用,可以最大限度减少锁的使用,提高并发吞吐量。

    ClownFish的实体编译方法

    为了能让ClownFish在运行时拥有最优秀的性能,ClownFish选择了动态生成代码并编译的方法, 而且,为了避免在生成代码、编译代码期间对调用线程的阻塞影响, ClownFish采用了后台线程的方式来处理,如果要加载的实体类型的加载器没有编译完成, 会按照老版本的方式直接采用反射的方式来执行,一旦加载器编译完成,则后续调用将会使用加载器。

    所以,这里又涉及到加载器的生成及和编译时机问题。

    在这个编译时机问题上,我更关注的是性能影响,所以设计了编译模式的概念来解决。
    而且,为了保证ClownFish能满足各种使用场景,ClownFish提供了三种编译方法:

    public static class BuildManager
    {
        // 自动编译模式,此模式会自动收集待编译的数据实体类型。
        public static void StartAutoCompile(Func<bool> func)
        public static void StartAutoCompile(Func<bool> func, int timerPeriod)
    
    
        // 手工提交编译模式,此模式要求手工提交需要编译的数据实体类型。
        public static void CompileModelTypesSync(Type[] types, bool throwOnFailure)
        public static void CompileModelTypesAsync(Type[] types)
    }
    

    【手工提交编译模式】适合在程序初始化时调用,它又分为【同步】提交和【异步】提交二种方式。
    如果所有的数据实体类型有一定特征,那么可以考虑这种方式。调用方法:

    Type[] models = BuildManager.FindModelTypesFromCurrentApplication(
                                t => t.FullName.StartsWith("Test.Models."));
    BuildManager.CompileModelTypesSync(models, true);
    

    或者调用异步版本:

    Type[] models = BuildManager.FindModelTypesFromCurrentApplication(
                                t => t.FullName.StartsWith("Test.Models."));
    BuildManager.CompileModelTypesAsync(models);
    

    在示例代码中,由于所有的数据实体类型都定义在 Test.Models 命名空间中, 所以只需二行代码就可以为所有的实体类型生成加载器供运行时调用。

    【自动编译模式】的启动也很简单:

    // 启动自动编译数据实体加载器的工作模式。
    // 编译的触发条件:请求实体加载器超过2000次,或者,等待编译的类型数量超过100次
    BuildManager.StartAutoCompile(() => BuildManager.RequestCount > 2000 || BuildManager.WaitTypesCount > 100);
    
    // 启动自动编译数据实体加载器的工作模式。每10秒【固定】启动一个编译过程。
    // 注意:StartAutoCompile只能调用一次,第二次调用时,会引发异常。
    //BuildManager.StartAutoCompile(() => true, 10000);
    

    说明:
    1. 手工提交编译的方法可以多次调用,每次调用都可能会生成一个程序集。
    2. 手工提交编译模式与自动编译模式可以混用。
    3. 自动编译模式的方法只能调用一次。

    好了,还是继续讨论性能问题。
    在程序初始化时,如果采用【手工提交编译模式】,显然在运行时会有最出色的性能。

    如果您的项目中,数据实体类型定义比较混乱,那么可以选择【自动编译模式】。 当ClownFish发现某个数据实体类型的加载器没有编译生成时, 会记住这个类型,然后在某个时刻一起编译这些缺少加载器的实体类型。

    注意,ClownFish并没有选择那种【按需生成】的方法, 我认为启动代码生成器与编译器是件昂贵的操作,会占用较多的工作线程时间, 尤其是需要频繁启动时,成本会比较高,所以,我选择了后台线程与批量处理的方式。

    在【自动编译模式】下,ClownFish会被一个Timer触发,然后检查是否需要启动代码生成代码编译的任务, 如果确实有类型需要编译,才会真正启动任务。所以,这样的设计会减少动态生成的程序集数量, 也会减少启动编译器所带来的工作负担,毕竟一个项目中,实体类型的数量可能会很多,批量编译能节省很多资源。

    好了,【揭秘 ClownFish 比手写代码还快的原因】的文字部分到此为止, 希望本文能给那些还在开发维护数据访问层的朋友一些参考, 也希望告诉那些正在研究改进反射性能的朋友:优化大量反射的终极方法应该是动态代码生成技术。

     
    分类: Ado.netClownFish
  • 相关阅读:
    解决电脑启动桌面图标变乱
    多表查询
    windows安装rabbitmq踩坑实录
    springboot单元测试
    联想锁屏壁纸所在路径
    ArrayList数组扩容方式(基于jdk1.8)
    SpringCloud + Consul服务注册中心 + gateway网关
    springboot 文件上传及java使用post请求模拟文件上传
    kotlin + springboot启用elasticsearch搜索
    kotlin + springboot整合mybatis操作mysql数据库及单元测试
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/2646850.html
Copyright © 2020-2023  润新知