前言
前两篇文章 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()
有很多重载版本,最后都会调用下面的版本:
1 2 3 | 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
有很多个构造函数,列举如下:
1 2 3 4 5 6 7 8 | 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
,表示不使用缓存。
这些构造函数最后都会调用下面的私有构造函数(代码有所精简调整):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | 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()
函数中涉及到的关键类以及类之间的关系,整理成下图,供参考。
流程 & 类关系梳理
看完上图后,可以继续看剩下的 JIT
问题了。因为大多数 JIT
都出现在 MatchCollection.Count
中,可以由此切入。
MatchCollection.Count
实现代码如下:
1 2 3 4 5 6 7 8 | public int Count {
get {
if (_done)
return _matches.Count;
GetMatch(infinite);
return _matches.Count;
}
}
|
Count
会调用 GetMatch()
函数,而 GetMatch()
函数会不断调用 _regex.Run()
函数。
_regex
是哪来的呢?在构造 MatchCollection
实例时传过来的。
MatchCollection
是由 Regex.Matches()
实例化的,代码如下(去掉了判空逻辑):
1 2 3 | 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()
具体实现代码如下(代码有精简):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | 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()
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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()
,相关代码如下(有调整):
1 2 3 4 | internal static RegexRunnerFactory Compile (RegexCode code, RegexOptions options) {
RegexLWCGCompiler c = new RegexLWCGCompiler();
return c.FactoryInstanceFromCode(code, options);
}
|
该函数直接调用了 RegexLWCGCompiler
类的 FactoryInstanceFromCode()
成员函数。相关代码如下(有删减):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 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()
关键代码如下(做了大量删减):
1 2 3 4 5 6 7 8 9 10 | 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
。这三个关键的函数实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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
构造函数与 Compiled
的基本一致,useCache
传递的也是 false
,不使用缓存。因为 UseOptionC()
返回的是 false
,所以不会执行 Compile()
函数。所以 factory
成员变量是 null
。
这里就不贴图了。
2. matches.Count
带 Compiled
选项的 Regex
MatchCollection.Count
内部会调用 GetMatch()
函数,GetMatch()
函数会调用对应 RegexRunner
的 Scan()
函数(这里的 runner
类型是 CompiledRegexRunner
)。Scan()
内部会调用 FindFirstChar()
函数,而 CompiledRegexRunner
类型的 FindFirstChar()
函数内部调用的是设置好的动态方法。
不带 Compiled
选项的 Regex
与带 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()
赋值。
总结
- 不要在循环内部创建编译型的正则表达式(带
Compiled
选项),会频繁导致 JIT
的发生进而影响效率。
Regex.IsMatch()
也会创建 Regex 实例,但是最后一个参数 bUseCache
是 true
,表示使用缓存。
Regex
构造函数的最后一个参数 bUseCache
是 true
的时候才会更新缓存。
- 正则表达式引擎内部会根据
option + culture + pattern
查找缓存。
参考资料
.NET源码
[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界