关于具体的实现原理请看上篇简单的Coroutines实现[0]。
这篇主要介绍如何记录并打印协程的调用堆栈。
在实际使用中,我发现一个问题,就是当协程内部调用的函数发生错误时,输出的错误信息阅读起来十分吃力。
还是看上一篇的例子,假设在SubFunction1中发生了错误抛出异常,打印出来的错误信息中只有SubFunction1的信息,而不会包含调用它的SubFunction0和Run。
public class ThreadB : StThread
{
protected override IEnumerator Run()
{
yield return SubFunction0();
Console.WriteLine("[{0}]ThreadB wait for 1 seconds", msStopWatch.Elapsed.TotalSeconds);
yield return Wait(1.0f);
Console.WriteLine("[{0}]ThreadB wait for 2 seconds", msStopWatch.Elapsed.TotalSeconds);
yield return Wait(2.0f);
Console.WriteLine("[{0}]ThreadB wait done", msStopWatch.Elapsed.TotalSeconds);
}
IEnumerator SubFunction0()
{
yield return SubFunction1();
Console.WriteLine("[{0}]ThreadB SubFunction0 wait for 1 seconds", msStopWatch.Elapsed.TotalSeconds);
yield return Wait(1.0f);
Console.WriteLine("[{0}]ThreadB SubFunction0 wait for 2 seconds", msStopWatch.Elapsed.TotalSeconds);
yield return Wait(2.0f);
Console.WriteLine("[{0}]ThreadB SubFunction0 wait done", msStopWatch.Elapsed.TotalSeconds);
}
IEnumerator SubFunction1()
{
Console.WriteLine("[{0}]ThreadB SubFunction1 wait for 1 seconds", msStopWatch.Elapsed.TotalSeconds);
yield return null;
}
};
而实际上为了方便Debug,我们希望的调用信息应该是类似
at ThreadB.SubFunction1 [File.cs:linexxx]
at ThreadB.SubFunction0 [File.cs:linexxx]
at ThreadB.Run [File.cs:linexxx]
以便我们更好的判断是何处调用了这个产生错误的函数。
因此在执行协程函数时,我们需要捕获异常并重构调用堆栈信息再抛出新的异常,从而方便我们Debug。
实际上我们的mContextStack就是协程函数的调用栈,所以首先想到的是通过mContextStack中的信息来重构我们的调用堆栈。
在这篇c# yield关键字原理详解中详细介绍了yield的实质,我们可以知道yield return实际上就是一个状态机。
在yield return返回的IEnumerator值中,存有状态机的state的值(通过反射可以获取,注意state是private的),所以我首先想到的是通过state的值,找到代码文件中对应的yield return关键字的位置,再重建调用信息。
经过一些查找我最终在GitHub上找到了一个协程实现,其中就有如何通过IEnumerator获得调用的文件路径函数名以及类名的实现,具体可以参照 jeansimonet/Coroutines。
但是这里我们不采用这种办法,因为上述的实现需要使用一个第三的Mono库,个人不太喜欢使用非必要的第三方库,所以我们介绍另一种实现,那就是手动记录调用栈,即在每次yield return function()之前,把当前的位置信息保存起来,在需要时把它打印出来即可。完整的实现我已经放到我的Github上。
Stack mCallStackFrames;
我在StThread中添加了一个栈用来保存堆栈信息。理论上每个Context对应一个调用它的栈帧,但是由于初次调用的位置是在协程外部,我不打算记录这部分的信息,所以这里mCallStackFrames的数量总是等于mContextStack.Count-1,所以为了避免在使用过程中有人忘记记录调用栈,我在每次有新的Context入栈时,都会检查一次
protected void ContextStackPush(IEnumerator context)
{
mContextStack.Push(context);
#if DEBUG
Debug.AssertFormat(mContextStack.Count == mCallStackFrames.Count + 1,
"Forget TrackStack when yield return? Next Call is {0}", context.ToString());
#endif
}
同样的我们在出栈是也要把调用它的栈帧信息一同Pop掉,所以
protected IEnumerator ContextStackPop()
{
#if DEBUG
Debug.Assert(mContextStack.Count == mCallStackFrames.Count + 1, "Please use ContextStackPush and ContextStackPop.");
if(mContextStack.Count > 1)
{
mCallStackFrames.Pop();
}
#endif
return mContextStack.Pop();
}
在函数TraceStack,我们通过读取StackFrame中的信息,按照抛出异常是的CallStack的格式重新构建成我们想要的格式。这里需要注意的时我们无法通过method.Name获得原来函数的名字(因为编译器已经把它变成函数状态机了,原来的函数已经不复存在),所以我们要通过DeclaringType用正则表达式从中提取出我们想要的类名和函数名。另外这里唯一的不足是,我们无法获得函数的参数,但只要有调用位置信息我们就可以知道我们具体调用了哪个函数,所以我认为这是可以忍受的。
public void TraceStack(System.Diagnostics.StackFrame stackFrame)
{
var method = stackFrame.GetMethod();
string className = method.DeclaringType.Name;
string methodName = method.Name;
Regex regEx = new Regex(@"(.+)\+\<(.+)\>");
var match = regEx.Match(method.DeclaringType.ToString());
if(match.Success)
{
className = match.Groups[1].Value;
methodName = match.Groups[2].Value;
}
mCallStackBuilder.Length = 0;
mCallStackBuilder.AppendFormat
(
"at {0}.{1} () [0x0000] in {2}:{3}",
className,
methodName,
stackFrame.GetFileName(),
stackFrame.GetFileLineNumber()
);
mCallStackFrames.Push(mCallStackBuilder.ToString());
}
最后,在InternalRun中,我们捕获并抛出新的异常,调用栈的信息也会被传入到新异常中。StThreadException的实现比较简单,不详述。
protected virtual bool InternalRun()
{
RunningThread = this;
bool isDone = true;
while(mContextStack.Count > 0)
{
IEnumerator top = mContextStack.Peek();
#if DEBUG
try
{
#endif
while(isDone = !top.MoveNext())
{
//If one stack frame done, we will try to make it run
ContextStackPop();
if(mContextStack.Count > 0)
{
top = mContextStack.Peek();
}
else
{
break;
}
}
#if DEBUG
}
catch(Exception e)
{
throw new StThreadException(e, CallStack);
}
#endif
... ...
}
RunningThread = null;
return isDone;
}
实际上每次yield return前都要用TraceStack记录调用栈既不美观也不环保,我们能做的就是让调用看起来美观一点。
这里我提供我能想到的两种办法。
其一是通过重载操作符的方式来调用TraceStack,具体来说,例如在ThreadB中。我们先在StThread中重载">"操作符
public static IEnumerator operator >(StThread caller, IEnumerator nextCall)
{
#if DEBUG
if(nextCall != null)
{
StThread.RunningThread.TraceStack(new System.Diagnostics.StackFrame(1, true));
}
#endif
return nextCall;
}
public static IEnumerator operator <(StThread a, IEnumerator shift)
{
throw new NotSupportedException();
}
然后在ThreadB中,我们只需要在每次yield return之后加上this > 即可
public class ThreadB : StThread
{
protected override IEnumerator Run()
{
yield return this > SubFunction0();
}
IEnumerator SubFunction0()
{
yield return this > SubFunction1();
}
IEnumerator SubFunction1()
{
yield return null;
}
};
看上去要比直接调用美观一点。
另一种写法是通过扩展方法,
public static class StThreadTraceHelper
{
public static IEnumerator Run(this IEnumerator nextCall)
{
#if DEBUG
if(nextCall != null)
{
StThread.RunningThread.TraceStack(new System.Diagnostics.StackFrame(1, true));
}
#endif
return nextCall;
}
}
public class ThreadB : StThread
{
protected override IEnumerator Run()
{
yield return SubFunction0().Run();
}
IEnumerator SubFunction0()
{
yield return SubFunction1().Run();
}
IEnumerator SubFunction1()
{
yield return null;
}
};
当然如果你都不喜欢的话,推荐使用Mono库从Context中得到调用栈帧的信息。
https://github.com/ZealotAlie/StThread
https://github.com/jeansimonet/Coroutines