-
Notifications
You must be signed in to change notification settings - Fork 622
[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 4 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,7 @@ 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 | ||
| // Best effort wakeup for worker thread. | ||
| synchronization_data_->cv.notify_all(); | ||
| } | ||
| } | ||
|
|
@@ -127,6 +127,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); | ||
|
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. this takes
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. I think we shouldn't lock any other mutex in ForceFlush.
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. Good catch! Both of these exist in the current
On 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
Similarly, with the scoping, the added blocking is bounded by the worker's 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.
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. Sorry, I may be missing something here. The main branch never tries to lock 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 In some scenarios, the timeout and
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. You are right about On the second point though, I think (please let me know if I misunderstood what you meant) your concern rests on The stress test in this PR also proves it, Your third point is the reason I would like to keep the lock rather than drop it. A large Please let me know if this clarifies it or if I misunderstood your concern.
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.
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. |
||
| synchronization_data_->is_force_wakeup_background_worker.store(true, | ||
| std::memory_order_release); | ||
| synchronization_data_->cv.notify_all(); | ||
|
|
@@ -188,18 +189,21 @@ 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); | ||
| // Wait for `timeout` milliseconds. | ||
| { | ||
|
marcalff marked this conversation as resolved.
|
||
| 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 +313,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 +381,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 window is still open since
buffer_isn't undercv_m, worst case the worker parks for the full schedule delay. worth stating that bound here?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.
Yes, that is what I meant with Best effort wakeup there, I'll extend the comment with the worst case outcome to make it clear.
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.
Extended in 217e5e1