记一次 .NET 程序的性能优化实战(3)—— 深入 .NET 源码
记一次 .NET 程序的性能优化实战(3)—— 深入 .NET 源码
前言
前两篇文章 part1 和 part2 基本上理清了 IsSplitter()
运行缓慢的原因 —— 在函数内部使用了带 Compile
选项的正则表达式。
但是没想到在 IsSplitter()
内部使用不带 Compiled
选项的正则表达式,整个程序运行起来非常快,跟静态函数版本的运行速度不相上下。又有了如下疑问:
- 为什么使用不带
Compiled
选项实例化的Regex
速度会这么快? - 为什么把
Regex
变量从局部改成全局变量后运行速度有了极大提升?除了避免重复实例化,还有哪些提升? - 为什么
PerfView
收集到的采样数据,大部分发生在MatchCollections.Count
内部,极少发生在Regex
的构造函数内部?(使用带Compiled
选项的正则表达式的时候) Regex.IsMatch()
是如何使用缓存的?- 直接实例化的
Regex
对象会使用正则表达式引擎内部的缓存吗? - 正则表达式引擎内部根据什么缓存的?
- 什么时候会生成动态方法?生成的动态方法是在哪里调用的?
本文会继续使用 Perfview
抓取一些关键数据进行分析,有些疑问需要到 .NET
源码中寻找答案。在查看代码的过程中,发现有些逻辑单纯看源码不太容易理解,于是又调试跟踪了 .NET
中正则表达式相关源码。由于篇幅原因,本篇不会介绍如何下载 .NET
源码,如何调试 .NET
源码的方法。但是会单独写一篇简单的介绍文章 。
解惑
-
为什么使用不带
Compiled
选项实例化的Regex
速度会这么快?还是使用
PerfView
采集性能数据并分析,如下图:可以发现,
IsSplitter()
函数只在第一次被调用时发生了一次JIT
,后续调用耗时不到0.1ms
(图中最后一次调用耗时:4090.629-4090.597 = 0.032ms
)。使用带
Compiled
选项实例化的Regex
的IsSplitter()
函数,如下图:每次调用大概要消耗
11ms
(5616.375 - 5604.637 = 11.738 ms
)。至于为什么不带
Compiled
选项的正则表达式在调用过程中没有多余的JIT
,与疑问7一起到源码中找答案。 -
为什么把
Regex
变量从局部改成全局变量后运行速度有了极大提升?除了避免重复实例化,还有哪些提升?修改代码,把局部变量改成全局变量,编译。再次使用
PerfView
采集性能数据并分析,如下图:可以发现与使用不带
Compiled
选项的局部变量版本一样,只发生了一次JIT
。所以把局部变量改成全局变量后,除了避免了重复实例化的开销(很小),更重要的是避免了多余的JIT
操作。 -
为什么
PerfView
收集到的采样数据,大部分发生在MatchCollections.Count
内部,极少发生在Regex
的构造函数内部?(使用带Compiled
选项的正则表达式的时候)Regex
构造函数只被JIT
了一次,后面的调用都是在执行原生代码,执行速度非常快。而MatchCollections.Count
每次执行的时候都需要执行JIT
(每次都需要10ms
以 上),所以大部分数据在MatchCollections.Count
内部,是非常合理的。 -
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
到缓存中查找。如果没找到缓存就生成类型为 RegexCodes
的 code
(包含了字节码等信息),如果找到了缓存就使用缓存中的信息。 如果指定了 Compiled
选项(UseOptionC()
会返回 true
),并且 factory
是空(没使用缓存或者缓存中的 _factory
是空),就会执行 Compile()
函数,并把返回值保存到 factory
成员中。
至此,可以回答第 5 6
两个疑问了。
-
直接实例化的
Regex
对象会使用正则表达式引擎内部的缓存吗?会优先根据
option + culture + pattern
到缓存中查找,但是否更新缓存是由最后一个参数useCache
决定的,与是否指定Compiled
选项无关。 -
正则表达式引擎内部根据什么缓存的?
根据
option + culture + pattern
缓存。
疑问7 与由 疑问1 引申出来的 JIT
问题是一个问题。之所以会 JIT
,是因为有需要 JIT
的代码,如果不断有新的动态方法产生出来并执行,那么就需要不断地 JIT
。由于此问题涉及到的代码量比较大,逻辑比较复杂,需要深入 .NET
源码进行查看。为了更好的理解整个过程,我简单梳理了 IsSpitter()
函数中涉及到的关键类以及类之间的关系,整理成下图,供参考。
流程 & 类关系梳理
![](./images/class-relationship-and-call-sequence.png)
看完上图后,可以继续看剩下的 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, null, typeof(CompiledRegexRunner));
GenerateGo();
// 生成动态函数FindFirstChar
DynamicMethod firstCharMethod = DefineDynamicMethod("FindFirstChar" + regexnumString, typeof(bool), typeof(CompiledRegexRunner));
GenerateFindFirstChar();
// 生成动态函数InitTrackCount
DynamicMethod trackCountMethod = DefineDynamicMethod("InitTrackCount" + regexnumString, null, typeof(CompiledRegexRunner));
GenerateInitTrackCount();
return new CompiledRegexRunnerFactory(goMethod, firstCharMethod, trackCountMethod);
}
该函数非常清晰易懂,但却是非常关键的一个函数,会生成三个动态函数(也就是通过 PerfView
采集到的 FindFirstCharXXX
,GoXXX
,InitTrackCountXXX
),最后会构造一个类型为 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
问题了。
-
什么时候会生成动态方法?生成的动态方法是在哪里调用的?
在指定了
Compiled
标志的Regex
的构造函数内部会调用RegexCompiler.Compile()
函数,Compile()
函数又会调用RegexLWCGCompiler.FactoryInstanceFromCode()
,FactoryInstanceFromCode()
函数内部会分别调用GenerateFindFirstChar()
,GenerateGo()
,GenerateInitTrackCount()
生成对应的动态方法。在执行
MatchCollection.Count
的时候,会调用MatchCollection.GetMatch()
函数,GetMatch()
函数会调用对应RegexRunner
的Scan()
函数。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-constructor](./images/compiled-regex-constructor.png)
**不带 Compiled
选项的 Regex
**
构造函数与 Compiled
的基本一致,useCache
传递的也是 false
,不使用缓存。因为 UseOptionC()
返回的是 false
,所以不会执行 Compile()
函数。所以 factory
成员变量是 null
。
这里就不贴图了。
2. matches.Count
**带 Compiled
选项的 Regex
**
![MatchCollection-count-dynamic-FindFirstChar MatchCollection-count-dynamic-FindFirstChar](./images/MatchCollection-count-dynamic-FindFirstChar.png)
MatchCollection.Count
内部会调用 GetMatch()
函数,GetMatch()
函数会调用对应 RegexRunner
的 Scan()
函数(这里的 runner
类型是 CompiledRegexRunner
)。Scan()
内部会调用 FindFirstChar()
函数,而 CompiledRegexRunner
类型的 FindFirstChar()
函数内部调用的是设置好的动态方法。
**不带 Compiled
选项的 Regex
**
![MatchCollection-count-none-dynamic-FindFirstChar MatchCollection-count-none-dynamic-FindFirstChar](./images/MatchCollection-count-none-dynamic-FindFirstChar.png)
与带 Compiled
版本的调用栈基本一致,不一样的是这里 runner
的类型是 RegexInterpreter
,该类型的 FindFirstChar()
函数调用的代码不是动态生成的。
3. runner 赋值
当 runner
是 null
的时候,需要根据情况获取对应的 runner
。
**带 Compiled
选项的 Regex
**
factory
成员在 Regex
构造函数里通过 Compile()
赋过值,runner
会通过下图 1306
行的 factory.CreateInstance()
赋值。
**不带 Compiled
选项的 Regex
**
factory
成员没有被赋过值,因此是空的,runner
会通过下图 1308
行的 new RegexInterpreter()
赋值。
![runner runner](./images/runner.png)
总结
- 不要在循环内部创建编译型的正则表达式(带
Compiled
选项),会频繁导致JIT
的发生进而影响效率。 Regex.IsMatch()
也会创建 Regex 实例,但是最后一个参数bUseCache
是true
,表示使用缓存。Regex
构造函数的最后一个参数bUseCache
是true
的时候才会更新缓存。- 正则表达式引擎内部会根据
option + culture + pattern
查找缓存。