-
Notifications
You must be signed in to change notification settings - Fork 617
[SDK] Fix lost wakeup in BatchSpanProcessor shutdown/force-flush notify #4382
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
d8786fb
d2345a4
e9ffb1d
305e961
217e5e1
32da17e
d152beb
d09c6d3
a28b45a
37d532e
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 |
|---|---|---|
|
|
@@ -98,7 +98,8 @@ void BatchSpanProcessor::OnEnd(std::unique_ptr<Recordable> &&span) noexcept | |
| size_t buffer_size = buffer_.size(); | ||
| if (buffer_size >= max_queue_size_ / 2 || buffer_size >= max_export_batch_size_) | ||
| { | ||
| // signal the worker thread | ||
| // Notified without lock to reduce contention for span end. If this notify is lost, | ||
| // the worker thread may wait until next schedule or until the next notify attempt. | ||
| synchronization_data_->cv.notify_all(); | ||
| } | ||
| } | ||
|
|
@@ -127,6 +128,7 @@ bool BatchSpanProcessor::ForceFlush(std::chrono::microseconds timeout) noexcept | |
| if (synchronization_data_->force_flush_pending_sequence.load(std::memory_order_acquire) > | ||
| synchronization_data_->force_flush_notified_sequence.load(std::memory_order_acquire)) | ||
| { | ||
| std::lock_guard<std::mutex> cv_lock(synchronization_data_->cv_m); | ||
| synchronization_data_->is_force_wakeup_background_worker.store(true, | ||
| std::memory_order_release); | ||
| synchronization_data_->cv.notify_all(); | ||
|
|
@@ -188,18 +190,24 @@ void BatchSpanProcessor::DoBackgroundWork() | |
| } | ||
| #endif /* ENABLE_THREAD_INSTRUMENTATION_PREVIEW */ | ||
|
|
||
| // Wait for `timeout` milliseconds | ||
| std::unique_lock<std::mutex> lk(synchronization_data_->cv_m); | ||
| synchronization_data_->cv.wait_for(lk, timeout, [this] { | ||
| if (synchronization_data_->is_force_wakeup_background_worker.load(std::memory_order_acquire)) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| return !buffer_.empty(); | ||
| }); | ||
| synchronization_data_->is_force_wakeup_background_worker.store(false, | ||
| std::memory_order_release); | ||
| // This scope is important! `cv_m` must be released before acquiring `force_flush_cv_m`. | ||
| // Since `Export()` calls `NotifyCompletion()` which takes `force_flush_cv_m`, | ||
| // holding `cv_m` while calling `Export()` can lead to a ABBA deadlock. | ||
| { | ||
|
marcalff marked this conversation as resolved.
|
||
| // Wait for `timeout` milliseconds. | ||
| std::unique_lock<std::mutex> lk(synchronization_data_->cv_m); | ||
| synchronization_data_->cv.wait_for(lk, timeout, [this] { | ||
| if (synchronization_data_->is_force_wakeup_background_worker.load( | ||
| std::memory_order_acquire)) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| return !buffer_.empty(); | ||
| }); | ||
| synchronization_data_->is_force_wakeup_background_worker.store(false, | ||
| std::memory_order_release); | ||
| } | ||
|
|
||
| #ifdef ENABLE_THREAD_INSTRUMENTATION_PREVIEW | ||
| if (worker_thread_instrumentation_ != nullptr) | ||
|
|
@@ -309,6 +317,7 @@ void BatchSpanProcessor::NotifyCompletion( | |
| exporter->ForceFlush(timeout); | ||
| } | ||
|
|
||
| std::lock_guard<std::mutex> lock(synchronization_data->force_flush_cv_m); | ||
|
Member
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. with this closed the chunked wait in ForceFlush (the "must not wait for ever" workaround) is no longer needed for correctness, follow-up to simplify?
Contributor
Author
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. Yes, it would be nice to simplify this part. I'll create an issue for it in case someone wants to take it up as I am a bit low on bandwidth in the upcoming weeks. |
||
| std::uint64_t notified_sequence = | ||
| synchronization_data->force_flush_notified_sequence.load(std::memory_order_acquire); | ||
| while (notify_force_flush > notified_sequence) | ||
|
|
@@ -376,8 +385,12 @@ bool BatchSpanProcessor::InternalShutdown(std::chrono::microseconds timeout) noe | |
|
|
||
| if (worker_thread_.joinable()) | ||
| { | ||
| synchronization_data_->is_force_wakeup_background_worker.store(true, std::memory_order_release); | ||
| synchronization_data_->cv.notify_all(); | ||
| { | ||
| std::lock_guard<std::mutex> cv_lock(synchronization_data_->cv_m); | ||
| synchronization_data_->is_force_wakeup_background_worker.store(true, | ||
| std::memory_order_release); | ||
| synchronization_data_->cv.notify_all(); | ||
| } | ||
| worker_thread_.join(); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| // Copyright The OpenTelemetry Authors | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| #include <gtest/gtest.h> | ||
| #include <atomic> | ||
| #include <chrono> | ||
| #include <cstdlib> | ||
| #include <future> | ||
| #include <iostream> | ||
| #include <memory> | ||
| #include <string> | ||
| #include <utility> | ||
|
|
||
| #include "opentelemetry/nostd/span.h" | ||
| #include "opentelemetry/sdk/common/exporter_utils.h" | ||
| #include "opentelemetry/sdk/trace/batch_span_processor.h" | ||
| #include "opentelemetry/sdk/trace/batch_span_processor_options.h" | ||
| #include "opentelemetry/sdk/trace/exporter.h" | ||
| #include "opentelemetry/sdk/trace/recordable.h" | ||
| #include "opentelemetry/sdk/trace/span_data.h" | ||
| #include "opentelemetry/version.h" | ||
|
|
||
| OPENTELEMETRY_BEGIN_NAMESPACE | ||
|
|
||
| namespace | ||
| { | ||
|
|
||
| class CountingSpanExporter final : public sdk::trace::SpanExporter | ||
| { | ||
| public: | ||
| explicit CountingSpanExporter(std::shared_ptr<std::atomic<std::size_t>> exported_count) noexcept | ||
| : exported_count_(std::move(exported_count)) | ||
| {} | ||
|
|
||
| std::unique_ptr<sdk::trace::Recordable> MakeRecordable() noexcept override | ||
| { | ||
| return std::unique_ptr<sdk::trace::Recordable>(new sdk::trace::SpanData); | ||
| } | ||
|
|
||
| sdk::common::ExportResult Export( | ||
| const nostd::span<std::unique_ptr<sdk::trace::Recordable>> &recordables) noexcept override | ||
| { | ||
| exported_count_->fetch_add(recordables.size(), std::memory_order_relaxed); | ||
| return sdk::common::ExportResult::kSuccess; | ||
| } | ||
|
|
||
| bool ForceFlush(std::chrono::microseconds /*timeout*/) noexcept override { return true; } | ||
|
|
||
| bool Shutdown(std::chrono::microseconds /*timeout*/) noexcept override { return true; } | ||
|
|
||
| private: | ||
| std::shared_ptr<std::atomic<std::size_t>> exported_count_; | ||
| }; | ||
|
|
||
| // A lost wakeup results in the worker being parked for the entire schedule delay, | ||
| // so the watchdog only has to separate "instant" from "parked for the entire delay" | ||
| // while being generous enough to avoid false positives on slow CI runners. | ||
| constexpr std::chrono::minutes kParkScheduleDelay{10}; | ||
| constexpr std::chrono::minutes kWakeupWatchdog{1}; | ||
|
|
||
| // Runs `operation` on another thread and aborts the binary if it does not return in time. | ||
| template <typename Operation> | ||
| bool CallWithWatchdog(const char *operation_name, | ||
| const char *stall_hint, | ||
| int round, | ||
| const Operation &operation) | ||
| { | ||
| auto result = std::async(std::launch::async, operation); | ||
| if (result.wait_for(kWakeupWatchdog) == std::future_status::timeout) | ||
| { | ||
| std::cerr << operation_name << " did not return within " << kWakeupWatchdog.count() | ||
| << "m at round " << round << ". " << stall_hint << '\n'; | ||
| std::abort(); | ||
| } | ||
| return result.get(); | ||
| } | ||
|
|
||
| template <typename Operation> | ||
| void RunWorkerParkRace(const char *operation_name, const char *stall_hint, Operation operation) | ||
| { | ||
| constexpr int kRounds = 2000; | ||
|
denizariyan marked this conversation as resolved.
|
||
| constexpr int kSpinSweep = 50; | ||
|
|
||
| for (int round = 0; round < kRounds; ++round) | ||
| { | ||
| auto exported_count = std::make_shared<std::atomic<std::size_t>>(0); | ||
|
|
||
| sdk::trace::BatchSpanProcessorOptions options; | ||
| options.schedule_delay_millis = kParkScheduleDelay; | ||
| options.max_queue_size = 4096; | ||
| options.max_export_batch_size = 512; | ||
|
|
||
| auto processor = std::make_shared<sdk::trace::BatchSpanProcessor>( | ||
| std::make_unique<CountingSpanExporter>(exported_count), options); | ||
|
|
||
| // Vary the offset across a sweep so that over the whole set we have a better chance of hitting | ||
| // the race window. | ||
| int spin_iterations = round * kSpinSweep; | ||
| volatile int spin_sink = 0; | ||
| for (int s = 0; s < spin_iterations; ++s) | ||
| { | ||
| // busy-spin a scheduling-independent increasing amount to sweep the race offset | ||
| int next = spin_sink; | ||
| spin_sink = next + 1; | ||
| } | ||
| processor->OnEnd(processor->MakeRecordable()); | ||
|
|
||
| EXPECT_TRUE(CallWithWatchdog(operation_name, stall_hint, round, | ||
| [operation, processor] { return operation(*processor); })); | ||
| EXPECT_EQ(exported_count->load(std::memory_order_relaxed), 1u); | ||
|
|
||
| // Shutdown() already joined the worker; ForceFlush() left it running. Join it either way | ||
| // before the next round. | ||
| EXPECT_TRUE(CallWithWatchdog("teardown Shutdown()", | ||
| "possible lost shutdown wakeup stall during worker join()", round, | ||
| [processor] { return processor->Shutdown(); })); | ||
| } | ||
| } | ||
|
|
||
| // Catch a lost cv wakeup during Shutdown(). A lost wakeup parks the worker for the whole schedule | ||
| // delay, so the untimed join() inside Shutdown() blocks for that long. | ||
| TEST(BatchSpanProcessorStress, ShutdownRacesWorkerPark) | ||
| { | ||
| RunWorkerParkRace("ShutdownRacesWorkerPark: Shutdown()", | ||
| "possible lost shutdown wakeup stall during worker join()", | ||
| [](sdk::trace::BatchSpanProcessor &processor) { return processor.Shutdown(); }); | ||
| } | ||
|
|
||
| // Catch a lost cv wakeup during ForceFlush(). A lost wakeup parks the worker for the whole | ||
| // schedule delay before it services the flush, so ForceFlush() blocks for that long. | ||
| TEST(BatchSpanProcessorStress, ForceFlushRacesWorkerPark) | ||
| { | ||
| RunWorkerParkRace( | ||
| "ForceFlushRacesWorkerPark: ForceFlush()", "possible lost force-flush wakeup", | ||
| [](sdk::trace::BatchSpanProcessor &processor) { return processor.ForceFlush(); }); | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| OPENTELEMETRY_END_NAMESPACE | ||
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.
this takes
cv_mwhile holdingforce_flush_cv_m. safe today because the worker releasescv_mbeforeNotifyCompletion, but that ordering is load-bearing now, worth a comment pinning itThere 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.
I think we shouldn't lock any other mutex in ForceFlush.
synchronization_data_->cv.wait_for(lk, ..., it will locksynchronization_data_->cv_mand cause deadlock here. And the wakeup notification can not be sent.timeout.Uh oh!
There was an error while loading. Please reload this page.
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.
Good catch! Both of these exist in the current
mainbut not with this PR.On
main,DoBackgroundWork()declaresstd::unique_lock<std::mutex> lk(cv_m)at loop-body scope, socv_mstays held acrossExport()->NotifyCompletion(). If I added thecv_macquisition toForceFlush()alone, you would get exactly what you describe:NotifyCompletion()takesforce_flush_cv_mwhile the worker holdscv_m,ForceFlush()takescv_mwhile holdingforce_flush_cv_m, and that is an ABBA deadlock.But this PR also scopes that wait here: https://github.com/open-telemetry/opentelemetry-cpp/pull/4382/changes#diff-6f1f4caf95893b12ea6cb9203fdb5a4f383eb6f2be79ae113254986ff4d42463R192-R206
{ std::unique_lock<std::mutex> lk(synchronization_data_->cv_m); synchronization_data_->cv.wait_for(lk, timeout, [this] { ... }); synchronization_data_->is_force_wakeup_background_worker.store(false, std::memory_order_release); }With
cv_mreleased beforeExport(), the only nesting left isforce_flush_cv_m->cv_minForceFlush(), and the worker never holdscv_mwhen it wantsforce_flush_cv_m. So no cycle as far as I can see.Similarly, with the scoping, the added blocking is bounded by the worker's
cv_mhold time, which doesn't include the drain/export operations themselves, just atomic load&store +buffer_.empty()check. I think this is acceptable, WDYT?I think now that we can guarantee there would be no lost wakeups, we could re-shape this operation to make it easier to follow and harder to break (maybe by hoisting the wakeup out of the predicate) but I would rather keep the restructuring out of this bugfix PR.
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.
Sorry, I may be missing something here. The main branch never tries to lock
synchronization_data_->cv_minForceFlush, so there is no ABBA deadlock between the thread callingForceFlushand the background thread.Limiting the scope of the wait in the background thread does not solve this problem either: the deadlock only occurs while the background thread is waiting on
synchronization_data_->cv, which still preventsForceFlushfrom callingsynchronization_data_->cv.notify_all()to wake it up.In some scenarios, the timeout and
schedule_delay_millis_are set to large values, and waiting that long is not acceptable — for example, when gracefully shutting down an application.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.
You are right about
main, my wording was sloppy, sorry.mainhas neither lock, so there is no ABBA there. What I meant is that adding thecv_macquisition toForceFlush()on top ofmainwithout the rest of the changes in this PR would create an issue, becausemainholdscv_macrossExport()->NotifyCompletion(), so the scoping change has to be part of the same PR. That is a statement about why both hunks are here, not about a bug inmain.On the second point though, I think (please let me know if I misunderstood what you meant) your concern rests on
wait_forinDoBackgroundWorkholding the mutex while parked, and it does not.cv.wait_for(lk, timeout, pred)atomically unlockslkand blocks, and only reacquires it when it is woken or times out. So while the background thread is parked,cv_mis unlocked andForceFlush()acquires it immediately. The mutex is held only while the predicate is being evaluated and whilewait_forreturns, which here is one atomic load&store plusbuffer_.empty()so that is how long the added acquisition can block for.The stress test in this PR also proves it,
ForceFlushRacesWorkerParkruns 2000 rounds withschedule_delay_millisset to 10 minutes and a 1 minute watchdog that aborts the binary ifForceFlush()does not return. If takingcv_mdeadlocked against a parked worker, the very first round would hang. It completes in around 1 second on Linux, macOS and Windows CI. Remove the lock and the same test fails, which is the lost wakeup this PR is fixing. I have also ran this test for over 1k rounds on my local machine without issues.Your third point is the reason I would like to keep the lock rather than drop it. A large
schedule_delay_millis_leading to a long wait when the notification is missed is the problem this PR aims to solve. Without the lock the store and thenotify_all()can land after the worker has evaluated its predicate but before it parks, the notification is lost, and the worker then sleeps the full schedule delay. With a 10 minute delay that is a 10 minuteForceFlush(), and the same applies toInternalShutdown()on the graceful shutdown path. Takingcv_mis what makes the wakeup guaranteed instead of best effort.Please let me know if this clarifies it or if I misunderstood your 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.
In my understanding, this is not an issue.
wait_for(), aka pthread_cond_wait(), internally releases the mutex while waiting for the condition, and re acquire the mutex once the condition is signaled, so the mutex is -- not -- held for the entire wait duration.
Acquiring the mutex lock before pthread_cond_signal / broacast is what makes delivering signals reliable.