代码改变世界

各种URL生成方式的性能对比(结论及分析)

2009-11-02 00:16  Jeffrey Zhao  阅读(...)  评论(... 编辑 收藏

上次我们设计了一个实验,比较三种不同URL生成方式的性能。您运行了吗?如果运行的话,有没有对结果进行一些的分析呢?现在我们就来详细观察及分析这次试验的结果,并给出我的分析。如果您有一些其他的看法,也请进行一些补充。

结论

我使用每种方式各生成1000次页面,并输出每生成100次的时候所耗费的时间。每种方式测试三次,并取平均值,结果如下:

从中我们可以得出结论,各种方式所消耗的时间大约是:

url generation benchmark

我们能够很轻易的推断出字符串拼接URL的方式最快,使用Lambda表达式生成URL最慢,但是您正确估计出它们之间的差距了吗?我没有,得到这个结果以后也让我吃了一惊,我也没想到Lambda表达式的性能会如此之差,而关键更在于……

这个性能可以令人接受吗?

上一篇文章里有朋友回复到“这点性能在实际应用程序中可以忽略不计”——这是真的吗?为了更好的进行判断,我们可以简单计算出这三种方式究竟每秒可以生成多少页面,结果如下:

pages per second

我们的测试样本基本上是模拟了链接数量处于“平均”水平的页面,并不过分——要知道真实应用中还会输出其他内容,以及做一些HTML编码之类的工作。从结果中我们可以看出,使用Lambda表达式每秒可以生成不到50次页面。自然,这是在我的性能比较一般的笔记本中“单核”的测试的结果,如果我们使用一台双核的机器来运行一个网站,它每秒大约可以生成100-150次页面。如果您的网站性能大约是50 requests/second请求的话(中到中上水平,不算太高),这大约意味着三分之一到二分之一的运算能力是交给单纯的页面生成。而对于一个性能要求较高的网站来说(100 r/q),页面生成所占用的系统资源则会更多。

因此我认为,目前使用Lambda表达式生成页面的性能并不能令人满意。要知道单纯的页面生成操作,按照普通观点来说应该是可以忽略不计的。例如直接拼接字符串的作法,单核每秒可以生成超过600次的页面,只占一点点运算资源。而使用Route的方式,每秒超过100次,也勉强可以令人接受——虽然我认为ASP.NET Routing的Route类性能还可以有更进一步的提高,有机会我会尝试一下。

性能分析

Lambda表达式生成URL的方式性能很难令人接受,但是它也有许多好处啊:静态检查,功能内聚,易于开发和测试。如果直接放弃这种做法实在令人心有不甘,那么我们又该怎么办呢?最简单的做法是减少生成链接的次数,这里可以用到MvcPatch中提供的页面片断缓存。使用这种方式可以将生成好的HTML缓存起来,在下一次请求时便可以直接输出,而不需要重新生成链接了,此时性能会比拼接字符串的方式更高。而且,页面片断缓存使用起来并不困难,对于一些不需要及时更新的内容,这个做法再合适不过了。

只不过,这个方式也只是权宜之计,治标不治本,而且总有一些内容是不适合使用片断缓存的。因此,我们还是要设法优化Lambda表达式生成URL的性能。为此,我们要分析,究竟是什么原因造成了性能问题。那么我们先来回味一下,生成“一个”链接需要经过哪些步骤:

  1. 构造一个表达式树。
  2. 对表达式树进行解析和运算,获得一个RouteValueDictionary。
  3. 使用Route配置,根据RouteValueDictionary生成URL。

为此,我们至少可以得出两个结论:

  • 需要为每个链接构造一个表达式树。
  • 使用Lambda表达式生成URL的做法,其实包含了使用Route生成URL的方式。

这意味着我们可以得到上面3个步骤所占的比例。于是乎,我们可以将ToPost等方法修改为如下形式在进行试验:

public static string ToPost(this UrlHelper helper, Blog blog, Post post)
{
    Expression<Action<BlogController>> expr = c => c.Post(blog, post);
    return helper.ToPostByRaw(blog, post);
}

经试验,修改后的做法所花时间大约是8.0正负0.3秒,由此减去ByRaw所消耗的时间后便是单纯用于构造Lambda表达式的时间。也就是说,仅仅是用来创建表达式树并很快地释放引用,也需要大约6.5秒的时间。看来在计算密集的情况下,生成表达式树的开销也是比较客观的,毕竟有闭包的存在,一个表达式树的节点总是比想象中要略多一些。此时,我们再将Lambda生成URL所用的总时间(22.6秒),减去创建表达式树的时间(6.5秒),再减去ByRoute所消耗的时间(9.4秒),可以得出第2个步骤所消耗的时间大约是6.7秒

现在,我们便大致得出了3个步骤分别消耗的时间:

  1. 构造表达式树:6.5秒,约占29%
  2. 解析和运算表达式树:6.7秒,约占30%
  3. 使用Route生成URL:9.4秒,约占41%

换句话说,除了第3个步骤属于无法回避的性能开销,其他两个步骤所消耗的时间大致相同。那么,您觉得从哪个地方开始优化比较合适呢?对我来说,则似乎有些为难。

生成Lambda表达式树其实只是创建了一些对象,它的性能其实很高,但是它还是占据了比较明显的开销。此外,解析和运算表达式树的开销和创建表达式树差不多,这意味着我们在这一块已经做的很不错了。事实上,第一次进行性能测试的时侯,我发现在解析和运算表达式树这边耗时特别长,而现在的结果已经是我进行了一些性能优化的结果(主要是避免了大量反射操作),节省了大约70%的时间(要知道原来生成1000次页面需要40-50秒)。在这个阶段,我还使用了FastLambda类库,否则这部分的时间消耗会有数量级的提升(20倍左右)。

所以我认为,性能已经很难有明显的提升了。除非我们可以避免为每个链接都生成一个表达式树,并且不对表达式树进行计算。这意味着我们需要做出根本性的,方向性地调整。

展望

在这里如何设计出又好用,又性能高的API着实让我头疼了一把。例如我想过,是否可以借鉴PostSharp的做法,修改编译后的程序集,把对UrlHelper.Action的调用修改为静态的数据操作(例如ByRoute的形式),这样就避免了表达式树的生成和解析。但是这也和PostSharp有所不同,PostSharp只要分析元数据即可,而它需要分析IL的逻辑,这要麻烦得多。或者,我们可以在运行时hook并替换掉ToPost的实现,这其实也就是前一种做法的“动态”版本——可惜的是,我不知道该如何进行。

最终我退而求其次,设计了这样一种做法。此时ToPost方法便会修改为:

// API签名
public static string Action(this UrlHelper helper, Expression template, params object[] args)

// 使用方式
private readonly static Expression<...> ToPostTemplate = (c, blog, post) => c.Post(blog, post);
public static string ToPost(this UrlHelper helper, Blog blog, Post post)
{
    return helper.Action(ToPostTemplate, blog, post);
}

由于准备了额外的“模板”,我们便省下了每次都生成表达式树的开销。在第一次调用Action方法时,会根据模板的内容生成与ByRoute静态实现差不多的操作,并根据模板对象进行缓存。这样,最后得到的性能便和ByRoute的做法没有多少区别了。只不过,这个做法需要生成独立的模板,从“美观度”上说实在比不上原来的做法。而且——这个“模板”对象的签名实在比较麻烦。

您对此有什么看法呢?我们不妨一起讨论一下如何做到“既美观,又高效”。如果您有更理想的做法也请告诉我。

相关文章