我正在学习C ++,目前我正在摆弄以下代码:
class Bar;
struct Callback {
virtual void Continue(Bar&) = 0;
};
// ...
void Foo(Bar& _x, Callback& result)
{
// Do stuff with _x
if(/* some condition */) {
// TODO: Force unwind of stack
result.Continue(_x);
return;
}
// Do more stuff with _x
if(/* some other condition */) {
// TODO: Force unwind of stack
result.Continue(_x);
return;
}
// TODO: Force unwind of stack
Bar y; // allocate something on the stack
result.Continue(y);
}
我的主要想法是,我知道在调用每个网站result.Continue
时,函数Foo
也会返回。因此,可以在调用延续之前解开堆栈。
由于用户代码将以递归方式使用它,我担心此代码可能会导致堆栈溢出。据我所知,执行_x
时,参数result
和result.Continue
会保留在堆栈中,因为只有在Foo
返回时才会展开堆栈。
编辑:Continue
函数可能(可能会)调用Foo
方法:导致递归。只需尾调用优化Continue
而不是Foo
就可以导致堆栈溢出。
在返回Foo
之前,我可以做些什么来强制展开堆栈,将result
保留在临时(register
?)变量中,然后执行该继续?
答案 0 :(得分:5)
您可以使用我发现的解决此问题的设计。该设计假设一个事件驱动的程序(但你可以创建一个假的事件循环)。
为清楚起见,让我们忘记您的特定问题,而是关注两个对象之间的接口问题: sender 对象将数据包发送到 receiver 对象。发送方始终必须等待接收方完成处理任何数据包,然后再发送另一个数据包。接口由两个调用定义:
这些调用都没有返回任何内容。接收器始终通过调用Done()报告操作的完成。正如您所看到的,此接口在概念上与您所呈现的类似,并且遭受Send()和Done()之间相同的递归问题,可能导致堆栈溢出。
我的解决方案是在事件循环中引入作业队列。作业队列是等待分派的 LIFO队列(堆栈)事件。事件循环将队列顶部的作业视为最大优先级事件。换句话说,当事件循环必须决定调度哪个事件时,如果队列不为空,它将总是调度作业队列中的最高作业,而不是任何其他事件。
然后修改上述界面以使Send()和Done()调用排队。这意味着当发送方调用Send()时,所有发生的事情都是将作业推送到作业队列,并且当由事件循环调度时,此作业将调用接收方的Send()实际实现。 Done()以相同的方式工作 - 由接收者调用,它只是推送一个作业,当调度时,调用发送者的Done()实现。
了解队列设计如何提供三大好处。
它避免了堆栈溢出,因为Send()和Done()之间没有明确的递归。但是发送方仍然可以从其Done()回调中再次调用Send(),接收方可以直接从其Send()回调中调用Done()。
它模糊了立即完成的(I / O)操作与需要一些时间的操作之间的差异,即接收器必须等待某个系统级事件。例如,当使用非阻塞套接字时,接收器中Send()的实现调用send()系统调用,该调用或者管理发送内容,或者返回EAGAIN / EWOULDBLOCK,在这种情况下接收器要求事件循环通知当套接字可写时。当事件循环通知套接字是可写的时,它会重试send()系统调用,这可能会成功,在这种情况下,它会通过从此事件处理程序调用Done()来通知发送方操作已完成。无论发生哪种情况,从发送者的角度来看都是一样的 - 当发送操作完成时,立即或在一段时间后调用其Done()函数。
它使错误处理与实际I / O正交。可以通过让接收器调用Error()回调来实现错误处理,该回调以某种方式处理错误。了解发件人和收件人如何独立的可重复使用模块 对错误一无所知。如果出现错误(例如send()syscall失败并带有真正的错误代码,而不是EAGAIN / EWOULDBLOCK),发送者和接收者可以简单地从Error()回调中销毁,这可能是创建发送者的相同代码的一部分和接收者。
这些功能共同在事件驱动的程序中实现了优雅的flow-based programming。我在BadVPN软件项目中实现了队列设计和基于流程的编程,取得了巨大的成功。
最后,澄清了为什么作业队列应该是LIFO。 LIFO调度策略提供对作业调度顺序的粗粒度控制。例如,假设您正在调用某个对象的某个方法,并希望在执行此方法之后执行某些操作,并在之后执行所有已推送的作业,并递归调度。你所要做的就是在调用这个方法之前自己推动一项工作,然后从这份工作的事件处理程序中完成你的工作。
还有一个很好的属性,你总是可以通过出列作业取消这个推迟的工作。例如,如果此函数执行了某些操作(包括它推送的作业)导致错误并导致我们自己的对象被破坏,我们的析构函数可以使我们推送的作业出列,从而避免在作业执行和访问数据时发生的崩溃不再存在。
答案 1 :(得分:0)
在函数结束之前,您无法显式强制执行堆栈展开(在代码示例中销毁_x
和result
)。如果您的递归(您没有显示它)适合尾部调用优化,那么好的编译器将能够处理递归而无需创建新的堆栈帧。
答案 2 :(得分:0)
除非我误解,为什么不是这样的(导致stackoverflow的单个函数是一个设计缺陷imo,但如果你的原始Foo()中有很多本地人,那么调用DoFoo()可以缓解这个问题): / p>
class Bar;
struct Callback {
virtual void Continue(Bar&) = 0;
};
// ...
enum { use_x, use_y };
int DoFoo(Bar& _x)
{
// Do stuff with _x
if(/* some condition */) {
return use_x;
}
// Do more stuff with _x
if(/* some other condition */) {
return use_x;
}
return use_y;
}
void Foo(Bar& _x, Callback& result)
{
int result = DoFoo(_x);
if (result == use_x)
{
result.Continue(_x);
return;
}
Bar y; // allocate something on the stack
result.Continue(y);
}
答案 3 :(得分:0)
我找到了另一种方法,但这是针对Windows和Visual C ++的:
void* growstk(size_t sz, void (*ct)(void*))
{
void* p;
__asm
{
sub esp, [sz]
mov p, esp
}
ct(p);
__asm
{
add esp, [sz]
}
}
继续void (*ct)(void*)
将有权访问void* p;
堆栈分配的内存。每当延续返回时,通过将堆栈指针esp
恢复到通常的级别来释放内存。