The below code results in a deadlock upon exiting main()
#include <stacktrace>
#include <iostream>
#include <thread>
#include <semaphore>
#include <chrono>
using namespace std::chrono_literals;
struct Singleton
{
Singleton()
{
worker = std::jthread{ [this] {
sema.acquire();
for (auto& e : trace) {
std::this_thread::sleep_for(50ms);
std::cout << e.description() << std::endl;
}
} };
}
std::binary_semaphore sema{ 0 };
std::stacktrace trace;
std::jthread worker;
};
std::stacktrace g()
{
return std::stacktrace::current();
}
std::stacktrace f()
{
return g();
}
Singleton& get()
{
static Singleton sing;
return sing;
}
int main(int argc, char** argv) {
get().trace = f();
get().sema.release();
std::this_thread::sleep_for(350ms);
return 0;
}
Specifically, calling description() seems to cause a deadlock in some CRT code trying to acquire a critical section.
I hypothesize that description() calls into some CRT code which depends on a global object in the CRT managed by a critical section. Either that object is destroyed upon exiting main before the jthread destructor is called, or upon exiting main the same critical section is being entered.
If this code is somehow undefined behavior, I would be grateful for someone to point out exactly what aspect of this usage is UB.
For context, this is a minimal reproduction of a problem existing in a much larger codebase, where a logging channel object containing a worker thread and lock-free queue is being managed as a singleton.
Edit: note the sleep_for calls are purely for illustrative purposes, they are not essential nor are they an attempt to fix a race condition. The code exhibits the same deadlocking behavior if they are removed.
I think this bug is due to that atexit functions, including local static object destructors are called under the same lock, that these functions are enumerated. This would end up in deadlock.
I don't think there are some words about this situation in the standard. It just doesn't mention any such limitations. I would have expected them mentioned around [support.start.term]/6 or [basic.start.term]/5. So I assume it is a bug in CRT.
A permanent clean fix in CRT would be to avoid calling user code from within a lock (both stacktrace machinery code and your singleton destructor code is user code in this regard). Either by unlocking before calling the user's code or using a lock-free list. But unfortunately, this would have severe performance impact, at least with a naive implementation (could like grab all registered functions at once under the lock, call them, repeat to see if there are more, this would still minimize the lock usage). Maybe that's why this wasn't fixed yet.
Suggest searching/reporting to https://developercommunity.visualstudio.com/ to see if it can be fixed.
As a workaround, I propose to use stacktrace machinery once before your singleton is constructed. It will then be initialized earlier, so will destroy later. Note that current is lightweight, and doesn't need global objects, you'll have to use some of string conversion functions.
struct Singleton
{
Singleton()
{
// Instantiate stacktrace machinery to prevent deadlock
(void) std::to_string(std::stacktrace::current());
worker = std::jthread{ [this] {
sema.acquire();
for (auto& e : trace) {
std::this_thread::sleep_for(50ms);
std::cout << e.description() << std::endl;
}
} };
}
std::binary_semaphore sema{ 0 };
std::stacktrace trace;
std::jthread worker;
};
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With