健壮的 C++:安全网
在程序本应中止时使其继续运行
引言
有些程序需要在发生严重问题(例如使用无效指针)后仍能继续运行。服务器、其他多用户系统以及实时游戏就是一些例子。本文介绍了如何编写健壮的 C++ 软件,使其在通常会中止的情况下不会退出。它还讨论了如何在已发布给用户的软件中,在发生严重问题时捕获有助于调试的信息。
背景
假设读者熟悉 C++ 异常。但是,异常并不是健壮软件需要处理的唯一问题。它还必须处理 POSIX 信号,当发生严重问题时,操作系统会引发这些信号。头文件 <csignal>
为 C/C++ 定义了以下 POSIX 信号子集:
SIGINT
:中断(通常在输入 Ctrl-C 时)SIGILL
:非法指令(可能是堆栈损坏影响了指令指针)SIGFPE
:浮点异常(包括除以零)SIGSEGV
:段错误(使用无效指针)SIGTERM
:强制终止(通常在输入kill
命令时)SIGABRT
:异常终止(当 C++ 运行时环境调用abort
时)
就像异常通过 catch
语句捕获一样,信号通过信号处理程序捕获。每个线程都可以为其想要处理的每个信号注册一个信号处理程序。信号只是一个传递给信号处理程序的 int
参数。
Using the Code
本文中的代码取自 Robust Services Core (RSC)。如果您是第一次阅读有关 RSC 某个方面的文章,请花几分钟时间阅读这篇 序言。
编译器选项。本文介绍的方法需要以下编译器选项。它们在下载的 CMake 文件中设置,但如果您要根据本文开发代码,则需要在自己的项目中设置它们:
- Windows (MSVC 或 clang):/EHa
- Linux (gcc):-fnon-call-exceptions
类概述
使用 RSC 开发的应用程序继承自 Thread
类来实现其线程。本文介绍的所有内容都随之免费提供。本节还介绍了与其他 Thread
类协作的其他类。
线程
需要持续可用的软件必须捕获所有异常。单线程应用程序可以在 main
函数中执行此操作。但 RSC 支持多线程,因此它在 Thread
基类中执行此操作,所有其他线程都继承自该基类。Thread
类有一个循环,该循环在一个 try
块中调用应用程序,后面跟着一系列 catch
块,用于处理应用程序未捕获的任何异常。
SysThread
这是本机线程的包装器,由 Thread
的构造函数创建。部分实现是平台特定的。
Daemon
创建 Thread
时,它可以注册一个 Daemon
以在线程被强制退出后重新创建该线程,这通常发生在线程引发过多异常时。
异常
直接使用 <exception>
不适合需要调试已发布软件中问题的系统。因此,RSC 定义了一个虚拟 Exception
类,所有异常都从中派生。此类的主要职责是在发生异常时捕获运行线程的堆栈。这样,导致异常的整个函数调用链将可用于辅助调试。这比 std::exception::what
返回的 const
char*
更有用,后者会说“invalid string position
”之类的话,指明了问题但没有说明问题发生的位置,甚至可能没有唯一地说明问题检测到的位置。
SysStackTrace
SysStackTrace
实际上是一个包装了一系列函数的命名空间。最有趣的功能是实际捕获线程堆栈的函数。Exception
的构造函数调用此函数,Debug::SwLog
函数(用于生成调试日志以记录一个意外但未实际导致异常的问题)也调用此函数。所有 SysStackTrace
函数都是平台特定的。
SignalException
当发生 POSIX 信号时,RSC 会将其作为 C++ 异常抛出,以便可以按常规方式处理,即通过展开堆栈并删除局部对象。SignalException
派生自 Exception
,用于此目的。它仅记录发生的信号,并依赖其基类来捕获堆栈。
PosixSignal
RSC 中支持的每个信号都必须创建一个 PosixSignal
实例,其中包括其名称(例如 "SIGSEGV"
)、数值(11
)、解释("Invalid Memory Reference"
)以及其他属性。POSIX 标准定义的各种信号(包括 <csignal>
中的信号)的 PosixSignal
实例被实现为简单类 SysSignals
的私有成员。然后,通过 SysSignals::CreateNativeSignals
实例化目标平台上支持的信号子集。
将信号转换为 SignalException
被证明是从严重错误中恢复的有用方法。因此,RSC 在 NbSignals.h 中为内部使用定义了信号。每个内部信号都关联一个 PosixSignal
实例。
// The following signals are proprietary and are used to throw a
// SignalException outside the signal handler.
//
constexpr signal_t SIGNIL = 0; // nil signal (non-error)
constexpr signal_t SIGWRITE = 121; // write to protected memory
constexpr signal_t SIGCLOSE = 122; // exit thread (non-error)
constexpr signal_t SIGYIELD = 123; // ran unpreemptably too long
constexpr signal_t SIGSTACK1 = 124; // stack overflow: attempt recovery
constexpr signal_t SIGSTACK2 = 125; // stack overflow: exit thread
constexpr signal_t SIGPURGE = 126; // thread killed or suicided
constexpr signal_t SIGDELETED = 127; // thread unexpectedly deleted
演练
创建线程
现在来看细节。让我们从创建 Thread
开始。子类可以添加自己的线程特定数据——这意味着不需要 thread_local
——但我们感兴趣的是 Thread
的构造函数。
Thread::Thread(Faction faction, Daemon* daemon) :
daemon_(daemon),
faction_(faction)
{
// Thread uses the PIMPL idiom, with much of its data in priv_.
//
priv_.reset(new ThreadPriv);
// Create a new thread. StackUsageLimit is in words, so convert
// it to bytes.
//
auto prio = FactionToPriority(faction_);
systhrd_.reset(new SysThread(this, prio,
ThreadAdmin::StackUsageLimit() << BYTES_PER_WORD_LOG2));
Singleton<ThreadRegistry>::Instance()->Created(systhrd_.get(), this);
if(daemon_ != nullptr) daemon_->ThreadCreated(this);
}
此构造函数创建一个 SysThread
实例,该实例又创建一个本机线程。传递给 SysThread
构造函数的参数是线程的属性:
- 正在构造的
Thread
对象(this
) - 其入口函数(对于所有
Thread
子类,为EnterThread
;它接收this
作为参数) - 其优先级(RSC 基于线程的
Faction
,这与本文无关) - 其堆栈大小,由配置参数
ThreadAdmin::StackUsageLimit
定义
然后将新线程添加到 ThreadRegistry
,该注册表跟踪所有活动线程。
这是 SysThread
的构造函数。
SysThread::SysThread(Thread* client, Priority prio, size_t size) :
nid_(NIL_ID),
nthread_(0),
priority_(Priority_N),
signal_(SIGNIL)
{
Create(client, size);
SetPriority(prio);
}
这会调用两个平台特定的函数(如果您有兴趣了解详细信息,请参阅 SysThread.win.cpp)。
Create
创建本机线程。其平台特定的句柄保存在nthread_
中,其线程编号保存在nid_
中。SetPriority
设置线程的优先级。
进入线程
EnterThread
是所有 Thread
子类的入口函数。
static unsigned int EnterThread(void* arg)
{
Debug::ft("NodeBase.EnterThread");
// Our argument is a pointer to a Thread.
//
auto thread = static_cast<Thread*>(arg);
return thread->Start();
}
这会调用以下代码,该代码在调用特定于线程的代码之前设置安全网:
main_t Thread::Start()
{
auto started = false;
while(true)
{
try
{
if(!started)
{
// Immediately register to catch POSIX signals.
//
RegisterForSignals();
// Indicate that we're ready to run. This blocks until we're
// scheduled in. At that point, resume execution.
//
Ready();
Resume(Thread_Start);
started = true;
}
// Perform any environment-specific initialization (and recovery,
// if reentering the thread). Exit the thread if this fails.
//
auto rc = systhrd_->Start();
if(rc != 0) return Exit(rc);
switch(priv_->traps_)
{
case 0:
break;
case 1:
{
// The thread just trapped. Invoke its virtual Recover function
// in case it needs to clean up unfinished work before resuming
// execution. (The full version of this code is more complex
// because it handles the case where Recover traps.)
//
priv_->traps_ = 0;
Recover();
break;
}
default:
//
// TrapHandler (which appears later) should have prevented us
// from getting here. Exit the thread.
//
return Exit(priv_->signal_);
}
// Invoke the thread's entry function. If this returns,
// the thread exited voluntarily.
//
Enter();
return Exit(SIGNIL);
}
// Catch all exceptions. TrapHandler returns one of
// o Continue, to resume execution at the top of this loop
// o Release, to exit the thread after deleting it
// o Return, to exit the thread immediately
//
catch(SignalException& sex)
{
switch(TrapHandler(&sex, &sex, sex.GetSignal(), sex.Stack()))
{
case Continue: continue;
case Release: return Exit(sex.GetSignal());
default: return AbnormalExit(sex.GetSignal());
}
}
catch(Exception& ex)
{
switch(TrapHandler(&ex, &ex, SIGNIL, ex.Stack()))
{
case Continue: continue;
case Release: return Exit(SIGNIL);
default: return AbnormalExit(SIGNIL);
}
}
catch(std::exception& e)
{
switch(TrapHandler(nullptr, &e, SIGNIL, nullptr))
{
case Continue: continue;
case Release: return Exit(SIGNIL);
default: return AbnormalExit(SIGNIL);
}
}
catch(...)
{
switch(TrapHandler(nullptr, nullptr, SIGNIL, nullptr))
{
case Continue: continue;
case Release: return Exit(SIGNIL);
default: return AbnormalExit(SIGNIL);
}
}
}
}
首次进入时,此代码调用 RegisterForSignals
,该函数将 SignalHandler
注册到底层平台本地的每个信号。这是通过调用 signal
(在 <csignal>
中)完成的,每个线程都必须在首次进入以及每次接收到信号后,为它想要处理的每个信号调用此函数。这可确保线程能够接收 POSIX 信号以便恢复,而不是允许程序中止。
void Thread::RegisterForSignals()
{
auto& signals = Singleton<PosixSignalRegistry>::Instance()->Signals();
for(auto s = signals.First(); s != nullptr; signals.Next(s))
{
if(s->Attrs().test(PosixSignal::Native))
{
signal(s->Value(), SignalHandler);
}
}
}
稍后我们将介绍 SignalHandler
。为了完成本节,我们需要查看 Start
,这是 EnterThread
调用过的。
每次循环时,Start
都首先调用 SysThread::Start
,这允许本机线程在安全运行之前执行任何必需的工作。这是特定于平台的代码,在 Windows 上的外观如下:
signal_t SysThread::Start()
{
// This is also invoked when recovering from a trap, so see if a stack
// overflow occurred. Some of these are irrecoverable, in which case
// returning SIGSTACK2 causes the thread to exit.
//
if(status_.test(StackOverflowed))
{
if(_resetstkoflw() == 0)
{
return SIGSTACK2;
}
status_.reset(StackOverflowed);
}
// The translator for Windows structured exceptions must be installed
// on a per-thread basis.
//
_set_se_translator((_se_translator_function) SE_Handler);
return 0;
}
这部分的前半部分处理线程堆栈溢出,这可能是特别棘手的问题。后半部分安装了一个特定于 Windows 的处理程序。Windows 通常不引发 POSIX 信号,而是具有所谓的“结构化异常”。因此,我们提供了 SE_Handler
,它将特定于 Windows 的异常转换为 POSIX 信号,可以使用我们的 SignalException
来引发。代码稍后会出现。
退出线程
通常通过调用 Exit
来退出线程;当其 Enter
函数返回或因异常而被强制退出时,会发生这种情况。只有当 Thread
对象在仍在运行时被删除时,Exit
才会被绕过。在这种情况下,TrapHandler
返回 Return
,这会导致线程立即退出,因为不再有需要删除的对象。
当 Thread
对象被删除时,其 Daemon
(如果存在)会收到通知,以便重新创建线程。RSC 还跟踪互斥体所有权,因此它会释放线程拥有的任何互斥体。大多数操作系统都会执行此操作,但 RSC 会生成日志以突出显示这种情况。跟踪互斥体所有权还可以调试死锁,只要 CLI 线程不参与死锁。
main_t Thread::Exit(signal_t sig)
{
delete this;
return sig;
}
Thread::~Thread()
{
// Other than in very rare situations, the usual path is
// to schedule the next thread (via Suspend) and delete
// this thread's resources.
//
Suspend();
ReleaseResources();
}
void Thread::ReleaseResources()
{
// If the thread has a daemon, tell it that the thread is
// exiting. Remove the thread from the registry and free
// its native thread.
//
Singleton<ThreadRegistry>::Extant()->Erase(this);
if(dameon_ != nullptr) daemon_->ThreadDeleted(this);
systhrd_.reset();
}
接收 Windows 结构化异常
如前所述,我们注册了 SE_Handler
来将每个 Windows 异常映射到 POSIX 信号。
// Converts a Windows structured exception to a POSIX signal.
//
void SE_Handler(uint32_t errval, const _EXCEPTION_POINTERS* ex)
{
signal_t sig = 0;
switch(errval) // errval:
{
case DBG_CONTROL_C: // 0x40010005
sig = SIGINT;
break;
case DBG_CONTROL_BREAK: // 0x40010008
sig = SIGBREAK;
break;
case STATUS_ACCESS_VIOLATION: // 0xC0000005
//
// The following returns SIGWRITE instead of SIGSEGV if the exception
// occurred when writing to a legal address that was write-protected.
//
sig = AccessViolationType(ex);
break;
case STATUS_DATATYPE_MISALIGNMENT: // 0x80000002
case STATUS_IN_PAGE_ERROR: // 0xC0000006
case STATUS_INVALID_HANDLE: // 0xC0000008
case STATUS_NO_MEMORY: // 0xC0000017
sig = SIGSEGV;
break;
case STATUS_ILLEGAL_INSTRUCTION: // 0xC000001D
sig = SIGILL;
break;
case STATUS_NONCONTINUABLE_EXCEPTION: // 0xC0000025
sig = SIGTERM;
break;
case STATUS_INVALID_DISPOSITION: // 0xC0000026
case STATUS_ARRAY_BOUNDS_EXCEEDED: // 0xC000008C
sig = SIGSEGV;
break;
case STATUS_FLOAT_DENORMAL_OPERAND: // 0xC000008D
case STATUS_FLOAT_DIVIDE_BY_ZERO: // 0xC000008E
case STATUS_FLOAT_INEXACT_RESULT: // 0xC000008F
case STATUS_FLOAT_INVALID_OPERATION: // 0xC0000090
case STATUS_FLOAT_OVERFLOW: // 0xC0000091
case STATUS_FLOAT_STACK_CHECK: // 0xC0000092
case STATUS_FLOAT_UNDERFLOW: // 0xC0000093
case STATUS_INTEGER_DIVIDE_BY_ZERO: // 0xC0000094
case STATUS_INTEGER_OVERFLOW: // 0xC0000095
sig = SIGFPE;
_fpreset();
break;
case STATUS_PRIVILEGED_INSTRUCTION: // 0xC0000096
sig = SIGILL;
break;
case STATUS_STACK_OVERFLOW: // 0xC00000FD
//
// A stack overflow in Windows now raises the exception
// System.StackOverflowException, which cannot be caught.
// Stack checking in Thread should therefore be enabled.
//
sig = SIGSTACK1;
break;
default:
sig = SIGTERM;
}
// Handle SIG. This usually throws an exception; in any case, it will
// not return here. If it does return, there is no specific provision
// for reraising a structured exception, so simply return and assume
// that Windows will handle it, probably brutally.
//
Thread::HandleSignal(sig, errval);
}
接收 POSIX 信号
我们注册了 SignalHandler
来接收 POSIX 信号。即使在 Windows 上,使用其结构化异常,在调用 raise
(在 <csignal>
中)之后也会到达此代码。
void Thread::SignalHandler(signal_t sig)
{
// Re-register for signals before handling the signal.
//
RegisterForSignals();
if(HandleSignal(sig, 0)) return;
// Either trap recovery is off or we received a signal that could not be
// associated with a thread. Restore the default handler for the signal
// and reraise it (to enter the debugger, for example).
//
signal(sig, nullptr);
raise(sig);
}
将 POSIX 信号转换为 SignalException
既然我们已经获得了由 SignalHandler
接收或由 SE_Handler
从 Windows 结构化异常转换而来的 POSIX 信号,我们就可以将其转换为 SignalException
。
bool Thread::HandleSignal(signal_t sig, uint32_t code)
{
auto thr = RunningThread(std::nothrow);
if(thr != nullptr)
{
// Turn the signal into a standard C++ exception so that it can
// be caught and recovery action initiated.
//
throw SignalException(sig, code);
}
// The running thread could not be identified. A break signal (e.g.
// on ctrl-C) is sometimes delivered on an unregistered thread. If
// the RTC timeout is not being enforced and the locked thread has
// run too long, trap it; otherwise, assume that the purpose of the
// ctrl-C is to trap the CLI thread so that it will abort its work.
//
auto reg = Singleton<PosixSignalRegistry>::Instance();
if(reg->Attrs(sig).test(PosixSignal::Break))
{
if(!ThreadAdmin::TrapOnRtcTimeout())
{
thr = LockedThread();
if((thr != nullptr) && (SteadyTime::Now() < thr->priv_->currEnd_))
{
thr = nullptr;
}
}
if(thr == nullptr) thr = Singleton<CliThread>::Extant();
if(thr == nullptr) return false;
thr->Raise(sig);
return true;
}
return false;
}
throw
语句之后的代码需要一些解释。中断信号(SIGINT
、SIGBREAK
),当用户输入 Ctrl-C 或 Ctrl-Break 时生成,通常在未知线程上到达。假设用户希望中止耗时过长或更糟的是陷入无限循环的工作是合理的。
但是应该中止什么工作呢?这里必须指出,RSC 强烈鼓励使用协作式调度,即线程以不可抢占的方式(“锁定”)运行,并在完成一个逻辑工作单元后让出。RSC 只允许一个不可抢占的线程同时运行,并且它还强制执行此类线程执行的超时。如果线程在超时之前没有让出,它将收到内部信号 SIGYIELD
,导致 SignalException
被抛出。在开发过程中,有时禁用此超时很有用。因此,在尝试确定哪个线程正在执行用户希望中止的工作时,第一个候选者是正在运行不可抢占线程的那个。但是,只有在使用 SIGYIELD
被禁用并且线程已经运行超过超时时间时,才会中断此线程。
如果中断不可抢占线程似乎不合适,则假定应该中断 CliThread
。此线程负责解析和执行通过控制台输入的命令。因此,除非 CliThread
出于某种不明原因不存在,否则它将接收到 SIGYIELD
。
如果现在已确定要中断的线程,则调用 Thread::Raise
将信号传递给该线程。
信号发送给另一个线程
发送信号给另一个线程存在问题。<csignal>
中的 raise
函数只向当前运行的线程发送信号。Windows 似乎也没有提供可用于此目的的函数。那么该怎么办?
在 RSC 中,大多数函数做的第一件事就是调用 Debug::ft
来标识当前正在执行的函数。这些调用已从本文的代码中删除,但现在有必要提及它们。Debug::ft
的原始(且仍然存在)目的是支持函数跟踪工具,这就是为什么大多数非平凡函数会调用它的原因。此跟踪工具的输出稍后会看到。Debug::ft
的普及还允许它被用于其他目的。由于线程可能频繁调用它,它可以检查线程是否等待信号。如果是,则会发出信号!它还可以检查线程是否有堆栈溢出的风险,在这种情况下也会发出信号!(这比允许堆栈溢出要好。如 SE_Handler
中所述,Windows 甚至不再允许拦截堆栈溢出异常。)
这是将信号传递给另一个线程的代码。
void Thread::Raise(signal_t sig)
{
Debug::ft(Thread_Raise);
auto reg = Singleton<PosixSignalRegistry>::Instance();
auto ps1 = reg->Find(sig);
// If this is the running thread, throw the signal immediately. If the
// running thread can't be found, don't assert: the signal handler can
// invoke this when a signal occurs on an unknown thread.
//
auto thr = RunningThread(std::nothrow);
if(thr == this)
{
throw SignalException(sig, 0);
}
// If the signal will force the thread to exit, try to unblock it.
// Unblocking usually involves deallocating resources, so force the
// thread to sleep if it wakes up during Unblock().
//
if(ps1->Attrs().test(PosixSignal::Exit))
{
if(priv_->action_ == RunThread)
{
priv_->action_ = SleepThread;
Unblock();
priv_->action_ = ExitThread;
}
}
SetSignal(sig);
if(!ps1->Attrs().test(PosixSignal::Delayed)) SetTrap(true);
if(ps1->Attrs().test(PosixSignal::Interrupt)) Interrupt(Signalled);
}
考虑到目标线程可以通过 Debug::ft
支持的检查自行抛出 SignalException
,Raise
执行以下操作:
- 调用
SetSignal
以记录与线程关联的信号。 - 调用
Unblock
(一个虚函数)以在信号强制线程退出时解除线程阻塞。 - 调用
SetTrap
,如果信号应尽快传递,而不是等到线程下次让出(这会设置通过Debug::ft
检查的标志)。 - 调用
Interrupt
以唤醒线程,如果信号应立即传递而不是等到线程恢复执行。
在上面的列表中,是否调用最后三个函数中的每一个取决于可以在信号的 PosixSignal
实例中设置的各种属性。
异常发生时捕获线程堆栈
SignalException
派生自 Exception
(它派生自 std::exception
)。尽管 Exception
是一个虚类,但所有 RSC 异常都从中派生,因为它的构造函数通过调用 SysStackTrace::Display
来捕获运行线程的堆栈。
Exception::Exception(bool stack, fn_depth depth) : stack_(nullptr)
{
// When capturing the stack, exclude this constructor and those of
// our subclasses.
//
if(stack)
{
stack_.reset(new std::ostringstream);
if(stack_ == nullptr) return;
*stack_ << std::boolalpha << std::nouppercase;
SysStackTrace::Display(*stack_, depth + 1);
}
}
SignalException
仅记录信号和调试代码,然后指示 Exception
捕获堆栈。
SignalException::SignalException(signal_t sig, debug32_t errval) :
Exception(true, 1),
signal_(sig),
errval_(errval)
{
}
捕获线程堆栈是平台特定的。请参阅 SysStackTrace.win.cpp 获取 Windows 目标。以下是其在 RSC 日志中的输出示例,用于映射到 SIGSEGV
的 Windows 结构化异常。“Function Traceback
”之后的部分是堆栈跟踪。
THR902 Jun-27-2022 15:16:16.123 on Reigi {3}
in NodeTools.RecoveryThread (tid=20, nid=0x4eb8): trap number 2
type=Signal
signal : 11 (SIGSEGV: Illegal Memory Access)
errval : 0xc0000005
Function Traceback:
NodeBase.Exception.Exception @ Exception.cpp + 53[28]
NodeBase.SignalException.SignalException @ SignalException.cpp + 38[12]
NodeBase.Thread.HandleSignal @ Thread.cpp + 1892[27]
NodeBase.SE_Handler @ SysThread.win.cpp + 147[0]
_NLG_Return2 @ <unknown file> (err=487)
_NLG_Return2 @ <unknown file> (err=487)
_NLG_Return2 @ <unknown file> (err=487)
_NLG_Return2 @ <unknown file> (err=487)
_CxxFrameHandler4 @ <unknown file> (err=487)
__GSHandlerCheck_EH4 @ gshandlereh4.cpp + 86[0]
_chkstk @ <unknown file> (err=487)
RtlRestoreContext @ <unknown file> (err=487)
KiUserExceptionDispatcher @ <unknown file> (err=487)
NodeBase.Thread.CauseTrap @ Thread.cpp + 1264[5]
NodeTools.RecoveryThread.UseBadPointer @ NtIncrement.cpp + 3405[0]
NodeTools.RecoveryThread.Enter @ NtIncrement.cpp + 3304[0]
NodeBase.Thread.Start @ Thread.cpp + 3124[0]
NodeBase.EnterThread @ SysThread.win.cpp + 159[0]
recalloc @ <unknown file> (err=487)
BaseThreadInitThunk @ <unknown file> (err=487)
RtlUserThreadStart @ <unknown file> (err=487)
在已发布的软件中,用户可以收集这些日志并发送给您。更好的是,您的软件可以包含代码通过 Internet 自动将它们发送给您。这些日志中的每一个都突出了一个需要修复的 bug。
从异常中恢复
上面的日志是由 TrapHandler
生成的,该函数很久以前被提及为 Thread::Start
在捕获到异常时调用的函数。
Thread::TrapAction Thread::TrapHandler(const Exception* ex,
const std::exception* e, signal_t sig, const std::ostringstream* stack)
{
try
{
// If this thread object was deleted, exit immediately.
//
if(sig == SIGDELETED)
{
return Return;
}
if(Singleton<Threads>::Instance()->GetState() != Constructed)
{
return Return;
}
// The first time in, save the signal. After that, we're dealing
// with a trap during trap recovery:
// o On the second trap, log it and force the thread to exit.
// o On the third trap, force the thread to exit.
// o On the fourth trap, exit without even deleting the thread.
// This will leak its memory, which is better than what seems
// to be an infinite loop.
//
auto retrapped = false;
switch(++priv_->traps_)
{
case 1:
SetSignal(sig);
break;
case 2:
retrapped = true;
break;
case 3:
return Release;
default:
return Return;
}
// Record a stack overflow against the native thread wrapper
// for use by SysThread::Start.
//
if((sig == SIGSTACK1) && (systhrd_ != nullptr))
{
systhrd_->status_.set(SysThread::StackOverflowed);
}
auto exceeded = LogTrap(ex, e, sig, stack);
// Force the thread to exit if
// o it has trapped too many times
// o it trapped during trap recovery
// o this is a final signal
//
auto sigAttrs = Singleton<PosixSignalRegistry>::Instance()->Attrs(sig);
if(exceeded | retrapped | sigAttrs.test(PosixSignal::Final))
{
return Release;
}
// Resume execution at the top of Start.
//
return Continue;
}
// The following catch an exception during trap recovery (a nested
// exception) and invoke this function recursively to handle it.
//
catch(SignalException& sex)
{
switch(TrapHandler(&sex, &sex, sex.GetSignal(), sex.Stack()))
{
case Continue:
case Release:
return Release;
default:
return Return;
}
}
catch(Exception& ex)
{
switch(TrapHandler(&ex, &ex, SIGNIL, ex.Stack()))
{
case Continue:
case Release:
return Release;
default:
return Return;
}
}
catch(std::exception& e)
{
switch(TrapHandler(nullptr, &e, SIGNIL, nullptr))
{
case Continue:
case Release:
return Release;
default:
return Return;
}
}
catch(...)
{
switch(TrapHandler(nullptr, nullptr, SIGNIL, nullptr))
{
case Continue:
case Release:
return Release;
default:
return Return;
}
}
}
重新创建线程
如果一个线程捕获异常的次数过多,它将被强制退出。但是,如果该线程起到了重要作用,就需要有一种方法来重新创建它。
在创建线程中,我们看到线程在创建时可以注册一个 Daemon
。而在退出线程中,当线程退出时 Daemon::ThreadDeleted
会收到通知。此函数不是虚函数,但对于每个 Daemon
都是相同的。
void Daemon::ThreadDeleted(Thread* thread)
{
// This does not immediately recreate the deleted thread. We only create
// threads when invoked by InitThread, which is not the case here. So we
// must ask InitThread to invoke us. During a restart, however, threads
// often exit, so there is no point doing this, and InitThread will soon
// invoke our Startup function so that we can create threads.
//
auto item = Find(thread);
if(item != threads_.end())
{
threads_.erase(item);
if(Restart::GetStage() != Running) return;
Singleton<InitThread>::Instance()->Interrupt(InitThread::Recreate);
}
}
当 InitThread
运行时,它会在发现线程因需要重新创建线程而被中断时调用以下函数。
void InitThread::RecreateThreads()
{
// Invoke daemons with missing threads.
//
auto& daemons = Singleton<DaemonRegistry>::Instance()->Daemons();
for(auto d = daemons.First(); d != nullptr; daemons.Next(d))
{
if(d->Threads().size() < d->TargetSize())
{
d->CreateThreads();
}
}
// This is reset after the above so that if a trap occurs, we will
// again try to recreate threads when reentered.
//
Reset(Recreate);
}
以下函数最终调用虚函数 Daemon::CreateThread
。
void Daemon::CreateThreads()
{
switch(traps_) // initialized to 0 when creating a Daemon
{
case 0:
break;
case 1:
// CreateThread trapped. Give the subclass a chance to
// repair any data before invoking CreateThread again.
//
++traps_;
Recover();
--traps_;
break;
default:
// Either Recover trapped or CreateThread trapped again.
// Raise an alarm.
//
RaiseAlarm(GetAlarmLevel());
return;
}
// Try to create new threads to replace those that exited.
// Incrementing traps_, and clearing it on success, allows
// us to detect traps.
//
while(threads_.size() < size_)
{
++traps_;
auto thread = CreateThread();
traps_ = 0;
if(thread == nullptr)
{
RaiseAlarm(GetAlarmLevel());
return;
};
threads_.insert(thread);
ThreadAdmin::Incr(ThreadAdmin::Recreations);
}
RaiseAlarm(NoAlarm);
}
代码在行动中的跟踪
RSC 有29 个测试,重点在于对该软件进行压力测试。每个测试都会执行一些恶意操作,以查看软件是否能在不退出的情况下进行处理。在这些测试期间,函数跟踪工具会启用,以便 Debug::ft
记录所有函数调用。对于与上述日志相关的 SIGSEGV
测试,跟踪工具的输出如此。当工具打开时,代码速度会减慢约 4 倍。当工具关闭时,调用 Debug::ft
的开销非常小。
析构函数使用无效指针
最近添加的一个测试在具体 Thread
子类的析构函数中使用了一个无效指针。此测试本应很早添加;它特别好,因为析构函数中的异常通常会导致程序中止。尽管 RSC 在使用 Microsoft C++ 编译器编译时能够正常运行,但发生的情况很有趣。结构化异常(Windows 的 SIGSEGV
等效项)会被拦截并作为 C++ 异常抛出。但此异常不会立即被捕获。处理删除的 C++ 运行时代码会捕获异常本身并继续调用析构函数链的工作。这值得称赞,因为它允许基类 Thread
释放其资源。只有之后,C++ 运行时才会重新抛出异常,该异常最终被 Thread::Start
中的安全网捕获。我们现在面临着一个不寻常的情况:一个成员函数在其对象被删除之后运行。因为 Thread::TrapHandler
不是虚函数,所以它会成功调用。当它发现线程已被删除时,它会返回并退出线程。
关注点
坦诚地说,C++ 标准不支持在响应 POSIX 信号时抛出异常。事实上,在 C++ 环境中,信号处理程序几乎可以做任何事情,这都是未定义行为!未定义行为的列表在此处,与信号处理相关的行为编号为 128 到 135。同一网站上提供的详细编码标准对此信号提出了以下建议:
- SIG31-C。请勿在信号处理程序中访问共享对象。
- SIG34-C。请勿从可中断的信号处理程序中调用
signal()
。 - SIG35-C。请勿从计算异常信号处理程序返回。
幸运的是,其中大部分是理论上的,而不是实践上的。大多数与信号处理相关的行为是未定义行为的主要原因是不同平台支持信号的方式不同。许多导致未定义行为的风险源于极少发生的竞争条件1。尽管如此,如果您的软件必须健壮,您能做什么?冒着未定义行为的风险总比让程序退出要好。
同样的理由,即无法依赖底层平台如何执行某项操作,并不能为标准采纳 noexcept
辩护。如果确实可能在响应信号时抛出异常,任何 noexcept
函数都无法这样做。即使是一个简单的非虚“getter”,它只返回一个成员的值,现在也存在风险。如果调用这样的函数时 this
指针无效,它将增加该指针的偏移量并尝试读取内存。爆炸!一个表面上微不足道的 noexcept
函数,由于自身的原因,现在会在信号处理程序抛出异常以从 SIGSEGV
中恢复时导致调用 abort
。
调用 abort
并不是世界末日,更不用说您的程序了,因为您的信号处理程序可以将 SIGABRT
转换为异常。但是现在我们处理的是 abort
还是异常?如果异常不被“允许”怎么办,是因为它发生在析构函数中还是 noexcept
函数中?(举手,各位有没有在析构函数中见过什么糟糕的事情发生?)
当调用 abort
时,C++ 标准规定,在堆栈展开方式与抛出异常时是否相同,这取决于实现。也就是说,局部对象可能不会被删除。因此,如果堆栈上的函数拥有局部 unique_ptr
中的某些内容,它就会泄漏。如果它将一个互斥体包装在一个局部对象中,而该对象的析构函数在函数返回时释放互斥体,那么结果可能会更糟。当然,这假设您的程序能够幸存。如果不能,那么它实际上无关紧要。
除非您的软件非常完美,否则它偶尔会引起 abort
,而您的 C++ 编译器最好允许这在所有情况下都转换为展开堆栈的异常。归根结底,无论是平台还是编译器,都将使您能够交付健壮的 C++ 软件,或者几乎不可能交付。
总而言之,以下是一些 C++ 标准应该强制执行的事情,才能认真对待健壮性:
- 信号处理程序在接收到信号时必须能够抛出异常。
- 如果信号处理程序响应
SIGABRT
抛出异常,则必须展开堆栈。 std::exception
的构造函数必须提供一种捕获调试信息(例如线程堆栈)的方法,然后才能展开堆栈。
好消息是,平台和编译器供应商通常能够交付健壮的软件,尽管标准未能强制规定。
注释
1 在类 UNIX 环境中,除了本文讨论的信号之外,有时还会使用其他信号作为线程间通信的原始形式。这极大地增加了这些竞争条件的风险,在此不推荐。
历史
- 2020 年 9 月 3 日:添加关于重新创建线程的部分
- 2020 年 8 月 11 日:添加关于线程退出时发生情况的详细信息
- 2020 年 5 月 27 日:描述析构函数中发生异常时的情况
- 2019 年 8 月 28 日:初始版本