-
Notifications
You must be signed in to change notification settings - Fork 13
refactor: simplify TLS key init with pthread_once and fix fork leak #523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,20 +18,21 @@ | |
| #include "tsc_clock.hpp" | ||
|
|
||
| #include <algorithm> | ||
| #include <atomic> | ||
| #include <cassert> | ||
| #include <chrono> | ||
| #include <cstdint> | ||
| #include <cstdio> | ||
| #include <cstdlib> | ||
| #include <cstring> | ||
| #include <new> | ||
| #include <pthread.h> | ||
| #include <unistd.h> | ||
|
|
||
| namespace ddprof { | ||
|
|
||
| AllocationTracker *AllocationTracker::_instance; | ||
| std::atomic<pthread_key_t> AllocationTracker::_tl_state_key{ | ||
| AllocationTracker::kInvalidKey}; | ||
| pthread_once_t AllocationTracker::_tl_key_once = PTHREAD_ONCE_INIT; | ||
| pthread_key_t AllocationTracker::_tl_state_key; | ||
|
|
||
| namespace { | ||
|
|
||
|
|
@@ -50,51 +51,49 @@ DDPROF_NOINLINE auto sleep_and_retry_reserve(MPSCRingBufferWriter &writer, | |
| } | ||
| return Buffer{}; | ||
| } | ||
| // Abort with a diagnostic if a pthread call returns an error. | ||
| // pthread functions return the error code directly (they do not set errno), | ||
| // so perror() would print the wrong message. | ||
| #define PTHREAD_CHECK(call) \ | ||
| do { \ | ||
| int err_ = (call); \ | ||
| if (err_ != 0) { \ | ||
| fprintf(stderr, "ddprof: %s failed: %s\n", #call, strerror(err_)); \ | ||
| std::abort(); \ | ||
| } \ | ||
| } while (0) | ||
|
|
||
| } // namespace | ||
|
|
||
| void AllocationTracker::delete_tl_state(void *tl_state) { | ||
| delete static_cast<TrackerThreadLocalState *>(tl_state); | ||
| } | ||
|
|
||
| void AllocationTracker::create_key() { | ||
| PTHREAD_CHECK(pthread_key_create(&_tl_state_key, delete_tl_state)); | ||
| } | ||
|
|
||
| void AllocationTracker::ensure_key_initialized() { | ||
| if (_tl_state_key.load(std::memory_order_acquire) != kInvalidKey) { | ||
| return; | ||
| } | ||
| pthread_key_t new_key; | ||
| if (pthread_key_create(&new_key, delete_tl_state) != 0) { | ||
| return; | ||
| } | ||
| pthread_key_t expected = kInvalidKey; | ||
| if (!_tl_state_key.compare_exchange_strong(expected, new_key, | ||
| std::memory_order_release)) { | ||
| // Another thread beat us, discard our key | ||
| pthread_key_delete(new_key); | ||
| } | ||
| PTHREAD_CHECK(pthread_once(&_tl_key_once, create_key)); | ||
| } | ||
|
|
||
| TrackerThreadLocalState *AllocationTracker::get_tl_state() { | ||
| const pthread_key_t key = _tl_state_key.load(std::memory_order_relaxed); | ||
| if (key == kInvalidKey) { | ||
| return nullptr; | ||
| } | ||
| return static_cast<TrackerThreadLocalState *>(pthread_getspecific(key)); | ||
| return static_cast<TrackerThreadLocalState *>( | ||
| pthread_getspecific(_tl_state_key)); | ||
| } | ||
|
|
||
| TrackerThreadLocalState *AllocationTracker::init_tl_state() { | ||
| // acquire pairs with the release in ensure_key_initialized(): guarantees | ||
| // that if we see a valid key the pthread key is fully published and we won't | ||
| // silently return nullptr and drop the thread's initial allocations. | ||
| const pthread_key_t key = _tl_state_key.load(std::memory_order_acquire); | ||
| if (key == kInvalidKey) { | ||
| return nullptr; | ||
| } | ||
| // Free any previously set state (e.g. inherited across fork). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there is a fork leak, but we can not fully address it here... I could still add this to my PR, it does not do any harm (though I doubt we should be hitting this path):
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this could be a real issue with the pthread approach, thanks for raising |
||
| delete static_cast<TrackerThreadLocalState *>( | ||
| pthread_getspecific(_tl_state_key)); | ||
|
|
||
| auto *state = new (std::nothrow) TrackerThreadLocalState{}; | ||
| if (!state) { | ||
| return nullptr; | ||
| } | ||
| state->tid = ddprof::gettid(); | ||
| state->stack_bounds = retrieve_stack_bounds(); | ||
| pthread_setspecific(key, state); | ||
| PTHREAD_CHECK(pthread_setspecific(_tl_state_key, state)); | ||
| return state; | ||
| } | ||
|
|
||
|
|
@@ -200,11 +199,9 @@ void AllocationTracker::free() { | |
| pevent_munmap_event(&_pevent); | ||
|
|
||
| // The pthread key (_tl_state_key) is intentionally not deleted here. | ||
| // pthread_key_delete would race with concurrent get_tl_state() calls that | ||
| // already loaded the key value but haven't called pthread_getspecific yet. | ||
| // The cost is one leaked key per dlclose/reload cycle, which is acceptable: | ||
| // POSIX guarantees at least PTHREAD_KEYS_MAX (128) keys per process, and | ||
| // library reload is not a supported use case. | ||
| // pthread_key_delete would race with concurrent get_tl_state() calls. | ||
| // The cost is one leaked key per dlclose/reload cycle, acceptable given | ||
| // POSIX guarantees at least PTHREAD_KEYS_MAX (128) keys per process. | ||
|
|
||
| // Do not destroy the object: | ||
| // there is an inherent race condition between checking | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A user pointed to a crash that could involve pthread once.
I was not able to reproduce it locally.
I also do not fully trust the differences between musl and glibc for pthread once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as I can see (with Claude assist), neither glibc nor musl's pthread_once can crash from correct usage.
Both implementations:
Could it be a sequence of double initialization maybe ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: The only scenario where musl could deadlock is if we fork-during-init: If fork() happens while pthread_once is executing create_key(), the child process deadlocks on musl. We could mitigate it by calling ensure_key_initialized() from the library constructor if this is a concern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Humm, scenario added:
Fork safety
ddprof is statically linked with musl and loaded into glibc processes. musl's pthread_once has no fork-generation mechanism: if fork() happens while pthread_once is in-progress, the child deadlocks because musl's futex wait will never be woken (the initializing thread doesn't exist in the child, and musl's fork handlers aren't registered when musl isn't the host libc).
To eliminate this race, ensure_key_initialized() is called from the library constructor (ProfilerAutoStart), before any thread can call fork(). This guarantees pthread_once has completed by the time threads exist.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for thinking carefully about this. At the moment, I still think keeping the atomic is simpler.
Just to fix slightly what is above: we try not to rely on libc implementation for the library part (what is injected into the process). We build against musl, but deploy and inject into binaries that depend on glibc. So the library does not pull libc as an explicit dependency.
We looked at the ABI for pthread_once and in theory it should be OK, but I still think relying on the atomic is better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
libddprof is not linked statically with musl (I don't think that's possible for a shared library to statically link libc): libddprof does not depend directly on a libc but makes the assumption that one will be loaded before libddprof is itself loaded. Hence when libddprof is loaded on a glibc host it will use glibc functions, and no musl code will ever be executed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not call
pthread_key_createinallocation_tracking_init?At this point symbol interposition is not yet active, so there should be no concurrent calls to
init_tl_state/get_tl_state. No need for pthread_once / atomic.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that the tl_state of existing threads (other than the one executing
allocation_tracking_init) will never get initialized, sinceinit_tl_stateis called upon thread creation (we had many issues with lazily creating the thread local state).