记一次 .NET 程序的性能优化实战(3)—— 深入 .NET 源码

编程难

共 11125字,需浏览 23分钟

 ·

2022-01-10 03:28

前言

前两篇文章 part1part2 基本上理清了 IsSplitter() 运行缓慢的原因 —— 在函数内部使用了带 Compile 选项的正则表达式。

但是没想到在 IsSplitter() 内部使用不带 Compiled 选项的正则表达式,整个程序运行起来非常快,跟静态函数版本的运行速度不相上下。又有了如下疑问:

  1. 为什么使用不带 Compiled 选项实例化的 Regex 速度会这么快?
  2. 为什么把  Regex 变量从局部改成全局变量后运行速度有了极大提升?除了避免重复实例化,还有哪些提升?
  3. 为什么 PerfView 收集到的采样数据,大部分发生在 MatchCollections.Count 内部,极少发生在 Regex 的构造函数内部?(使用带 Compiled 选项的正则表达式的时候)
  4. Regex.IsMatch() 是如何使用缓存的?
  5. 直接实例化的 Regex 对象会使用正则表达式引擎内部的缓存吗?
  6. 正则表达式引擎内部根据什么缓存的?
  7. 什么时候会生成动态方法?生成的动态方法是在哪里调用的?

本文会继续使用 Perfview 抓取一些关键数据进行分析,有些疑问需要到 .NET 源码中寻找答案。在查看代码的过程中,发现有些逻辑单纯看源码不太容易理解,于是又调试跟踪了 .NET 中正则表达式相关源码。由于篇幅原因,本篇不会介绍如何下载 .NET 源码,如何调试 .NET 源码的方法。但是会单独写一篇简单的介绍文章 。

解惑

  1. 为什么使用不带 Compiled 选项实例化的 Regex 速度会这么快?

    还是使用 PerfView 采集性能数据并分析,如下图:

    可以发现, IsSplitter() 函数只在第一次被调用时发生了一次 JIT,后续调用耗时不到 0.1ms(图中最后一次调用耗时:4090.629-4090.597 = 0.032ms)。

    使用带 Compiled 选项实例化的 RegexIsSplitter() 函数,如下图:

    view-filter-event-with-etwlogger

    每次调用大概要消耗 11ms5616.375 - 5604.637 = 11.738 ms)。

    至于为什么不带 Compiled 选项的正则表达式在调用过程中没有多余的 JIT,与疑问7一起到源码中找答案。

  2. 为什么把  Regex 变量从局部改成全局变量后运行速度有了极大提升?除了避免重复实例化,还有哪些提升?

    修改代码,把局部变量改成全局变量,编译。再次使用 PerfView 采集性能数据并分析,如下图:

    可以发现与使用不带 Compiled 选项的局部变量版本一样,只发生了一次 JIT。所以把局部变量改成全局变量后,除了避免了重复实例化的开销(很小),更重要的是避免了多余的 JIT 操作。

  3. 为什么 PerfView 收集到的采样数据,大部分发生在 MatchCollections.Count 内部,极少发生在 Regex 的构造函数内部?(使用带 Compiled 选项的正则表达式的时候)

    Regex 构造函数只被 JIT 了一次,后面的调用都是在执行原生代码,执行速度非常快。而 MatchCollections.Count 每次执行的时候都需要执行 JIT(每次都需要 10ms 以 上),所以大部分数据在 MatchCollections.Count 内部,是非常合理的。

  4. Regex.IsMatch() 是如何使用缓存的?

    Regex.IsMatch() 有很多重载版本,最后都会调用下面的版本:

    static bool IsMatch(String input, String pattern, RegexOptions options, TimeSpan matchTimeout) {
      return new Regex(pattern, options, matchTimeout, true).IsMatch(input);
    }

    该函数会在内部构造一个临时的 Regex 对象,并且构造函数的最后一个参数 useCaChe 的值是 true,表示使用缓存。

疑问5疑问6 的答案在 Regex 的构造函数中,先看看 Regex 的构造函数。

Regex 构造函数

Regex 有很多个构造函数,列举如下:

public Regex(String pattern)
  : this(pattern, RegexOptions.None, DefaultMatchTimeout, false)
 {}
  
public Regex(String pattern, RegexOptions options)
  : this(pattern, options, DefaultMatchTimeout, false)
 {}
        
Regex(String pattern, RegexOptions options, TimeSpan matchTimeout)
  : this(pattern, options, matchTimeout, false) {}

注意: 以上构造函数的最后一个参数都是 false,表示不使用缓存。

这些构造函数最后都会调用下面的私有构造函数(代码有所精简调整):

private Regex(String pattern, RegexOptions options, TimeSpan matchTimeout, bool useCache)
{
  string cultureKey = null;
  if ((options & RegexOptions.CultureInvariant) != 0)
      cultureKey = CultureInfo.InvariantCulture.ToString(); // "English (United States)"
  else
      cultureKey = CultureInfo.CurrentCulture.ToString();
  
  // 构造缓存用到的 key,包含 options,culture 和 pattern
  String key = ((int) options).ToString(NumberFormatInfo.InvariantInfo) + ":" + cultureKey + ":" + pattern;
  CachedCodeEntry cached = LookupCachedAndUpdate(key);

  this.pattern = pattern;
  this.roptions = options;

  if (cached == null) {
      // 如果没找到缓存就生成类型为 RegexCodes 的 code,包含了字节码等信息
      RegexTree tree = RegexParser.Parse(pattern, roptions);
      code = RegexWriter.Write(tree);
      // 如果指定了 useCache 参数就缓存起来,下次就能在缓存中找到了
      if (useCache)
          cached = CacheCode(key);
  } else {
      // 如果找到了缓存就使用缓存中的信息
      code       = cached._code;
      factory    = cached._factory;
      runnerref  = cached._runnerref;
  }

  // 如果指定了 Compiled 选项,并且 factory 是空(没使用缓存,或者缓存中的 _factory 是空)
  if (UseOptionC() && factory == null) {
      // 根据 code 和 roptions 生成 factory
      factory = Compile(code, roptions);
  
      // 需要缓存就缓存起来
      if (useCache && cached != null)
          cached.AddCompiled(factory);
  }
}

注意:bool useCache 标记的构造函数是私有的,也就是说不能直接使用此构造函数实例化 Regex

首先会根据 option + culture + pattern 到缓存中查找。如果没找到缓存就生成类型为 RegexCodescode(包含了字节码等信息),如果找到了缓存就使用缓存中的信息。如果指定了 Compiled 选项(UseOptionC() 会返回 true),并且 factory 是空(没使用缓存或者缓存中的 _factory 是空),就会执行 Compile() 函数,并把返回值保存到 factory 成员中。

至此,可以回答第 5 6 两个疑问了。

  1. 直接实例化的 Regex 对象会使用正则表达式引擎内部的缓存吗?

    会优先根据 option + culture + pattern 到缓存中查找,但是否更新缓存是由最后一个参数 useCache 决定的,与是否指定 Compiled 选项无关。

  2. 正则表达式引擎内部根据什么缓存的?

    根据 option + culture + pattern 缓存。

疑问7 与由 疑问1 引申出来的 JIT 问题是一个问题。之所以会 JIT,是因为有需要 JIT 的代码,如果不断有新的动态方法产生出来并执行,那么就需要不断地 JIT。由于此问题涉及到的代码量比较大,逻辑比较复杂,需要深入 .NET 源码进行查看。为了更好的理解整个过程,我简单梳理了 IsSpitter() 函数中涉及到的关键类以及类之间的关系,整理成下图,供参考。

流程 & 类关系梳理

看完上图后,可以继续看剩下的 JIT 问题了。因为大多数 JIT 都出现在 MatchCollection.Count 中,可以由此切入。

MatchCollection.Count

实现代码如下:

public int Count {
  get {
    if (_done)
      return _matches.Count;
    GetMatch(infinite);
    return _matches.Count;
  }
}

Count 会调用 GetMatch() 函数,而 GetMatch() 函数会不断调用 _regex.Run() 函数。

_regex 是哪来的呢?在构造 MatchCollection 实例时传过来的。

MatchCollection 是由 Regex.Matches() 实例化的,代码如下(去掉了判空逻辑):

public MatchCollection Matches(String input, int startat) {
  return new MatchCollection(this, input, 0, input.Length, startat);
}

该函数会实例化一个 MatchCollection 对象,并把当前 Regex 实例作为第一个参数传给 MatchCollection 的构造函数。该参数会被保存到 MatchCollection 实例的 _regex 成员中。

接下来继续查看 Regex.Run 函数的实现。

Regex.Run()

具体实现代码如下(代码有精简):

internal Match Run(bool quick, int prevlen, String input, int beginning, int length, int startat) {
    Match match;
    // 使用缓存的时候,可能从缓存中拿到一个有效的 runner,其它情况下都是 null。
    RegexRunner runner = (RegexRunner)runnerref.Get();

    // 不使用缓存的时候 runner是 null
    if (runner == null) {
        // 如果 factory 不为空就通过 factory 创建一个 runner。
        // 使用了 Compiled 标志创建的 Regex 实例的 factory 不为空
        if (factory != null)
            runner = factory.CreateInstance();
        else
            runner = new RegexInterpreter(code, UseOptionInvariant() ? CultureInfo.InvariantCulture : CultureInfo.CurrentCulture);
    }

    try {
        // 调用 RegexRunner.Scan 扫描匹配项。
        match = runner.Scan(this, input, beginning, beginning + length, startat, prevlen, quick, internalMatchTimeout);
    } finally {
        runnerref.Release(runner);
    }

    return match;
}

逻辑还是非常清晰的,先找到或者创建(通过 factory.CreateInstance() 或者直接 new)一个类型为 RegexRunner 实例 runner,然后调用 runner->Scan() 进行匹配。

对于使用 Compiled 选项创建的 Regex,其 factory 成员变量会在 Regex 构造函数中赋值,对应的语句是 factory = Compile(code, roptions); ,类型是 CompiledRegexRunnerFactory

我们先来看看 CompiledRegexRunnerFactory.CreateInstance() 的实现。

CompiledRegexRunnerFactory.CreateInstance()

代码如下:

protected internal override RegexRunner CreateInstance() {
  CompiledRegexRunner runner = new CompiledRegexRunner();

  new ReflectionPermission(PermissionState.Unrestricted).Assert();
  // 设置关键的动态函数,这三个函数是在 `RegexLWCGCompiler`
  // 类的 `FactoryInstanceFromCode()` 中生成的。
  runner.SetDelegates(
    (NoParamDelegate) goMethod.CreateDelegate(typeof(NoParamDelegate)),
    (FindFirstCharDelegate) findFirstCharMethod.CreateDelegate(typeof(FindFirstCharDelegate)),
    (NoParamDelegate) initTrackCountMethod.CreateDelegate(typeof(NoParamDelegate))
  );

  return runner;
}

该函数返回的是 CompiledRegexRunner 类型的 runner。在返回之前会先调用 runner.SetDelegates 为对应的关键函数(Go, FindFirstChar, InitTrackCount)赋值。参数中的 goMethod, findFirstCharMethod, initTrackCountMethod 是在哪里赋值的呢?在 Regex.Compile() 函数中赋值的。

Regex.Compile()

Regex.Compile() 会直接转调 RegexCompiler 的静态函数 Compile(),相关代码如下(有调整):

internal static RegexRunnerFactory Compile(RegexCode code, RegexOptions options) {
  RegexLWCGCompiler c = new RegexLWCGCompiler();
  return c.FactoryInstanceFromCode(code, options);
}

该函数直接调用了 RegexLWCGCompiler 类的 FactoryInstanceFromCode() 成员函数。相关代码如下(有删减):

internal RegexRunnerFactory FactoryInstanceFromCode(RegexCode code, RegexOptions options) {

  // 获取唯一标识符,也就是FindFirstChar后面的数字
  int regexnum = Interlocked.Increment(ref _regexCount);
  string regexnumString = regexnum.ToString(CultureInfo.InvariantCulture);

  // 生成动态函数Go
  DynamicMethod goMethod = DefineDynamicMethod("Go" + regexnumString, nulltypeof(CompiledRegexRunner));
  GenerateGo();

  // 生成动态函数FindFirstChar
  DynamicMethod firstCharMethod = DefineDynamicMethod("FindFirstChar" + regexnumString, typeof(bool), typeof(CompiledRegexRunner));
  GenerateFindFirstChar();
  
  // 生成动态函数InitTrackCount  
  DynamicMethod trackCountMethod = DefineDynamicMethod("InitTrackCount" + regexnumString, nulltypeof(CompiledRegexRunner));
  GenerateInitTrackCount();

  return new CompiledRegexRunnerFactory(goMethod, firstCharMethod, trackCountMethod);
}

该函数非常清晰易懂,但却是非常关键的一个函数,会生成三个动态函数(也就是通过 PerfView 采集到的 FindFirstCharXXXGoXXXInitTrackCountXXX),最后会构造一个类型为 CompiledRegexRunnerFactory 的实例,并把生成的动态函数作为参数传递给 CompiledRegexRunnerFactory 的构造函数。

至此,已经找到生成动态函数的地方了。动态函数是什么时候被调用的呢?在 runner.Scan() 函数中被调用的。

RegexRunner.Scan()

关键代码如下(做了大量删减):

Match Scan(Regex regex, String text, int textbeg, int textend, int textstart, int prevlen, bool quick, TimeSpan timeout) {
  for (; ; ) {
    if (FindFirstChar()) {
      Go();

      if (runmatch._matchcount [0] > 0)
        return TidyMatch(quick);
    }
  }
}                       

可以看到,Scan() 函数内部会调用 FindFirstChar()Go(),而且只有当 FindFirstChar() 返回 true 的时候,才会调用 Go()。这两个函数是虚函数,具体的子类会重写。对于 Compiled 类型的正则表达式,对应的 runner 类型是 CompiledRegexRunner。这三个关键的函数实现如下:

internal sealed class CompiledRegexRunner : RegexRunner {
  NoParamDelegate goMethod;
  FindFirstCharDelegate findFirstCharMethod;
  NoParamDelegate initTrackCountMethod;
        
  protected override void Go() {
    goMethod(this);
  }

  protected override bool FindFirstChar() {
    return findFirstCharMethod(this);
  }

  protected override void InitTrackCount() {
    initTrackCountMethod(this);
  }
}

现在可以回答疑问7疑问1 引申出来的 JIT 问题了。

  1. 什么时候会生成动态方法?生成的动态方法是在哪里调用的?

    在指定了 Compiled 标志的 Regex 的构造函数内部会调用 RegexCompiler.Compile() 函数,Compile() 函数又会调用 RegexLWCGCompiler.FactoryInstanceFromCode()FactoryInstanceFromCode() 函数内部会分别调用 GenerateFindFirstChar(), GenerateGo(), GenerateInitTrackCount() 生成对应的动态方法。

    在执行 MatchCollection.Count 的时候,会调用 MatchCollection.GetMatch() 函数,GetMatch() 函数会调用对应 RegexRunnerScan() 函数。Scan() 函数会调用 RegexRunner.FindFirstChar(),而 CompiledRegexRunner 类型中的 FindFirstChar() 函数调用的是设置好的动态函数。

Compiled 与 非 Compiled 对比

1. 构造函数

Compiled 选项的 Regex 

useCache 传递的是 false,表示不使用缓存。因为指定了 RegexOptions.Compiled 选项, Regex 的构造函数内部会调用 RegexCompiler.Compile() 函数,Compile() 函数又会调用 RegexLWCGCompiler.FactoryInstanceFromCode()FactoryInstanceFromCode() 函数内部会分别调用 GenerateFindFirstChar(), GenerateGo(), GenerateInitTrackCount() 生成对应的动态方法,然后返回 CompiledRegexRunnerFactory 类型的实例。如下图:

compiled-regex-constructor

不带 Compiled 选项的 Regex 

构造函数与 Compiled 的基本一致,useCache 传递的也是 false,不使用缓存。因为 UseOptionC() 返回的是 false,所以不会执行 Compile() 函数。所以 factory 成员变量是 null

这里就不贴图了。

2. matches.Count

Compiled 选项的 Regex 

MatchCollection-count-dynamic-FindFirstChar

MatchCollection.Count 内部会调用 GetMatch() 函数,GetMatch() 函数会调用对应 RegexRunnerScan() 函数(这里的 runner 类型是 CompiledRegexRunner)。Scan() 内部会调用 FindFirstChar() 函数,而 CompiledRegexRunner 类型的 FindFirstChar() 函数内部调用的是设置好的动态方法。

不带 Compiled 选项的 Regex 

MatchCollection-count-none-dynamic-FindFirstChar

与带 Compiled 版本的调用栈基本一致,不一样的是这里 runner 的类型是 RegexInterpreter,该类型的 FindFirstChar() 函数调用的代码不是动态生成的。

3. runner 赋值

runnernull 的时候,需要根据情况获取对应的 runner

Compiled 选项的 Regex 

factory 成员在 Regex 构造函数里通过 Compile() 赋过值,runner 会通过下图 1306 行的 factory.CreateInstance() 赋值。

不带 Compiled 选项的 Regex 

factory 成员没有被赋过值,因此是空的,runner 会通过下图 1308 行的 new RegexInterpreter() 赋值。

runner

总结

  • 不要在循环内部创建编译型的正则表达式(带 Compiled 选项),会频繁导致 JIT 的发生进而影响效率。
  • Regex.IsMatch() 也会创建 Regex 实例,但是最后一个参数 bUseCachetrue,表示使用缓存。
  • Regex 构造函数的最后一个参数 bUseCachetrue 的时候才会更新缓存。
  • 正则表达式引擎内部会根据 option + culture + pattern 查找缓存。

参考资料

.NET源码    https://referencesource.microsoft.com/


浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报