[BUG] Prevent lost condition-variable wakeups during OTLP file, periodic metric, and batch span processor shutdown - #4365
Conversation
…dic metric, and batch span processor shutdown.
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4365 +/- ##
==========================================
+ Coverage 82.63% 82.64% +0.02%
==========================================
Files 512 512
Lines 20138 20150 +12
==========================================
+ Hits 16639 16651 +12
Misses 3499 3499
🚀 New features to boost your workflow:
|
mateenali66
left a comment
There was a problem hiding this comment.
reader and file exporter fixes look right. per the overlap discussion on #4382: dropping the batch_span_processor.cc hunk here would let both PRs land without conflict, the other two fixes aren't covered there at all. also needs a CHANGELOG entry
| { | ||
| // Acquiring cv_m_ guarantees that the next time the worker thread checks the wait condition | ||
| // on cv_ (either from notify below or any other reason) it will see IsShutdown() return true. | ||
| std::lock_guard<std::mutex> cv_guard{cv_m_}; |
There was a problem hiding this comment.
fence is right, the wait predicate checks IsShutdown() under cv_m_. the force flush path below has the same store-then-notify shape (the "must not wait for ever" workaround), in scope here or follow-up?
There was a problem hiding this comment.
Since it's in the same vein, I've added the change to ForceFlush as well.
There was a problem hiding this comment.
ForceFlush change looks right, and no lock-order problem with it: this path takes force_flush_m_ then cv_m_ inside the predicate, and the worker's cv_m_ lock is scoped to the do-while body so it is released before CollectAndExportOnce runs.
One direction is still open though. This fixes the waker side (cv_). The completion side is unchanged: CollectAndExportOnce CASes force_flush_notified_sequence_ and calls force_flush_cv_.notify_all() holding nothing, so an OnForceFlush caller can evaluate the predicate, miss the notify, then wait. That is what the "must not wait for ever" chunked wait is compensating for, so the chunked loop has to stay as long as that store-then-notify is unguarded. Same bucket as the wait_for race you described, fine as a follow-up, just noting why that workaround can't come out yet.
| // Even though is_shutdown is atomic, the lock guarantees that either a change to | ||
| // is_shutdown will be observed, or background_thread_waker_cv will see the notification | ||
| // at shutdown. | ||
| if (concurrency_file->is_shutdown.load(std::memory_order_acquire)) |
There was a problem hiding this comment.
with the early check gone, shutdown is observed only after one more flush pass, intended? fine if it's deliberate flush-on-shutdown, just checking
There was a problem hiding this comment.
The check on is_shutdown occurs at almost the same time as previously, the only difference is that the check now occurs while holding concurrency_file->background_thread_waker_lock instead of prior to acquiring it. In both cases, if a flush is occurring while is_shutdown is set then the code will break prior to the next wait_for. Does that make sense or am I misunderstanding?
While looking into this I do notice there is an issue where a ForceFlush may not cause an additional flush and make take the full flush interval if file_->background_thread_waker_cv.notify_all(); happens just before the background thread blocks on concurrency_file->background_thread_waker_cv.wait_for(lk, flush_interval);. Fixing this would require a more involved change, so I'd prefer that to be separate. Some details on that: if you just add a variable to cause background_thread_waker_cv.wait_for to exit when it's set (e.g. is_force_background_thread_wake), the problem is that ForceFlush waits on background_thread_waiter_cv , so it could miss the notification from the background thread unless there is some additional synchronization (e.g., acquire the waiter lock before releasing the waker lock).
There was a problem hiding this comment.
Re-read the loop against the head commit, the only thing between the old check position and the new one is the BeforeWait() hook, so no extra flush pass. Agreed.
That hook is the one thing the move does change. On the shutdown path BeforeWait() now fires and AfterWait() never does, because the break is inside the lock scope. Before this PR the shutdown break came ahead of BeforeWait(), so the pair stayed balanced. thread_instrumentation.h documents the two as bracketing a blocking wait, so an app that flips thread state in BeforeWait and restores it in AfterWait leaves it set at thread exit. Hoisting the break out of the lock scope keeps both the fence and the pairing:
bool shutdown_requested = false;
{
std::unique_lock<std::mutex> lk(concurrency_file->background_thread_waker_lock);
shutdown_requested = concurrency_file->is_shutdown.load(std::memory_order_acquire);
if (!shutdown_requested)
{
concurrency_file->background_thread_waker_cv.wait_for(lk, flush_interval);
}
}then break after the AfterWait() block. Agreed the ForceFlush / wait_for race is a separate change.
There was a problem hiding this comment.
std::unique_lock<std::mutex> lk(concurrency_file->background_thread_waker_lock);
concurrency_file->background_thread_waker_cv.wait_for(lk, flush_interval, [concurrency_file]() {
return concurrency_file->is_shutdown.load(std::memory_order_acquire);
});Is these codes more clear? We also use conditional wait_for in other components.
There was a problem hiding this comment.
Good catch, I've moved the break to after AfterWait.
As far as using the conditional wait_for, I don't think it would be a lot clearer right now because is_shutdown has to kept outside the function, so the code would be more like
std::unique_lock<std::mutex> lk(concurrency_file->background_thread_waker_lock);
concurrency_file->background_thread_waker_cv.wait_for(lk, flush_interval, [concurrency_file, &is_shutdown]() {
return is_shutdown = concurrency_file->is_shutdown.load(std::memory_order_acquire);
});
but let me know if this is preferred.
| { | ||
| { | ||
| std::lock_guard<std::mutex> waker_guard{file_->background_thread_waker_lock}; | ||
| file_->is_shutdown.store(true, std::memory_order_release); |
There was a problem hiding this comment.
Could we apply this same guarded store plus notify in OtlpFileSystemBackend::Shutdown() too? When there is nothing pending, ForceFlush() returns before notifying the worker, so Shutdown() can return while the background thread remains parked until flush_interval. The destructor now handles this correctly, but callers that keep the client alive after Shutdown() still retain the delayed worker.
| if (worker_thread_.joinable()) | ||
| { | ||
| synchronization_data_->is_force_wakeup_background_worker.store(true, std::memory_order_release); | ||
| { |
There was a problem hiding this comment.
Could we drop the BatchSpanProcessor changes from this PR? #4382 contains the more complete fix for this path, including ForceFlush, completion notification, and regression coverage. Keeping the trace fix there avoids overlapping changes between the two PRs.
There was a problem hiding this comment.
Done, I've removed BatchSpanProcessor changes.
| if (worker_thread_.joinable()) | ||
| { | ||
| synchronization_data_->is_force_wakeup_background_worker.store(true, std::memory_order_release); | ||
| { |
There was a problem hiding this comment.
Done, I've removed BatchSpanProcessor changes.
| { | ||
| { | ||
| std::lock_guard<std::mutex> waker_guard{file_->background_thread_waker_lock}; | ||
| file_->is_shutdown.store(true, std::memory_order_release); |
| { | ||
| // Acquiring cv_m_ guarantees that the next time the worker thread checks the wait condition | ||
| // on cv_ (either from notify below or any other reason) it will see IsShutdown() return true. | ||
| std::lock_guard<std::mutex> cv_guard{cv_m_}; |
There was a problem hiding this comment.
Since it's in the same vein, I've added the change to ForceFlush as well.
| // Even though is_shutdown is atomic, the lock guarantees that either a change to | ||
| // is_shutdown will be observed, or background_thread_waker_cv will see the notification | ||
| // at shutdown. | ||
| if (concurrency_file->is_shutdown.load(std::memory_order_acquire)) |
There was a problem hiding this comment.
The check on is_shutdown occurs at almost the same time as previously, the only difference is that the check now occurs while holding concurrency_file->background_thread_waker_lock instead of prior to acquiring it. In both cases, if a flush is occurring while is_shutdown is set then the code will break prior to the next wait_for. Does that make sense or am I misunderstanding?
While looking into this I do notice there is an issue where a ForceFlush may not cause an additional flush and make take the full flush interval if file_->background_thread_waker_cv.notify_all(); happens just before the background thread blocks on concurrency_file->background_thread_waker_cv.wait_for(lk, flush_interval);. Fixing this would require a more involved change, so I'd prefer that to be separate. Some details on that: if you just add a variable to cause background_thread_waker_cv.wait_for to exit when it's set (e.g. is_force_background_thread_wake), the problem is that ForceFlush waits on background_thread_waiter_cv , so it could miss the notification from the background thread unless there is some additional synchronization (e.g., acquire the waiter lock before releasing the waker lock).
|
branch is conflicting against main now that #4382 has landed, needs a rebase before CI can give a clean signal here |
| if (concurrency_file->is_shutdown.load(std::memory_order_acquire)) | ||
| { | ||
| break; | ||
| } |
There was a problem hiding this comment.
In my understanding, When concurrency_file->is_shutdown is true, std::fflush should still need to be called one more time.So Shutdown will still flush all pending records.
There was a problem hiding this comment.
I think this is a preexisting issue, as previously is_shutdown did not cause an additional fflush, so I'd prefer to leave off fixing this.
There was a problem hiding this comment.
In the existing codes. Shutdown() will calls ForceFlush onece, which will wake up the background thread and calls std::fflush.
| if (concurrency_file->is_shutdown.load(std::memory_order_acquire)) | ||
| { | ||
| break; | ||
| } |
There was a problem hiding this comment.
I think this is a preexisting issue, as previously is_shutdown did not cause an additional fflush, so I'd prefer to leave off fixing this.
| // Even though is_shutdown is atomic, the lock guarantees that either a change to | ||
| // is_shutdown will be observed, or background_thread_waker_cv will see the notification | ||
| // at shutdown. | ||
| if (concurrency_file->is_shutdown.load(std::memory_order_acquire)) |
There was a problem hiding this comment.
Good catch, I've moved the break to after AfterWait.
As far as using the conditional wait_for, I don't think it would be a lot clearer right now because is_shutdown has to kept outside the function, so the code would be more like
std::unique_lock<std::mutex> lk(concurrency_file->background_thread_waker_lock);
concurrency_file->background_thread_waker_cv.wait_for(lk, flush_interval, [concurrency_file, &is_shutdown]() {
return is_shutdown = concurrency_file->is_shutdown.load(std::memory_order_acquire);
});
but let me know if this is preferred.
Changes
The issue in each changed spot is that there is a race between the "shutdown" call and the worker thread, such that the worker thread might not actually shut down. The worker will eventually shut down on the next configured periodic export, but if this could be configured to be relatively long (e.g. 1 minute). There's no workaround for this:
ForceFlushwill early-return after shutdown, so one can't useForceFlushto un-stuck a worker thread.Shutdownmore than once can lead to undefined behavior if it tries to join the worker thread from two different threads - at the very least, the return value ofjoinableis undefined, so a second thread trying to check if the worker is still joinable might seejoinableas false if the thread has been joined but is stuck.I did not update
CHANGELOG.mdsince the change seems small, but let me know if I should. I did not add unit tests as they would need to be invasive and complicated to reliably catch these. AFAIK there is no existing bug/issue documenting this.