Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ Increment the:
* [CONFIGURATION] file configuration - yaml schema 1.1.0
[#4340](https://github.com/open-telemetry/opentelemetry-cpp/pull/4340)

* [SDK] Fix lost-wakeups in BatchSpanProcessor to prevent stalls during
shutdown and force flush.
[#4382](https://github.com/open-telemetry/opentelemetry-cpp/pull/4382)

Breaking changes:

* [CONFIGURATION] SDK default component builder libraries and example
Expand Down
43 changes: 28 additions & 15 deletions sdk/src/trace/batch_span_processor.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this takes cv_m while holding force_flush_cv_m. safe today because the worker releases cv_m before NotifyCompletion, but that ordering is load-bearing now, worth a comment pinning it

Copy link
Copy Markdown
Member

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.

  1. When background is waiting when call synchronization_data_->cv.wait_for(lk, ..., it will lock synchronization_data_->cv_m and cause deadlock here. And the wakeup notification can not be sent.
  2. This will make ForceFlush block for more time than timeout.

@denizariyan denizariyan Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

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 main but not with this PR.

  1. When background is waiting when call synchronization_data_->cv.wait_for(lk, ..., it will lock synchronization_data_->cv_m and cause deadlock here. And the wakeup notification can not be sent.

On main, DoBackgroundWork() declares std::unique_lock<std::mutex> lk(cv_m) at loop-body scope, so cv_m stays held across Export() -> NotifyCompletion(). If I added the cv_m acquisition to ForceFlush() alone, you would get exactly what you describe: NotifyCompletion() takes force_flush_cv_m while the worker holds cv_m, ForceFlush() takes cv_m while holding force_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_m released before Export(), the only nesting left is force_flush_cv_m -> cv_m in ForceFlush(), and the worker never holds cv_m when it wants force_flush_cv_m. So no cycle as far as I can see.

  1. This will make ForceFlush block for more time than timeout.

Similarly, with the scoping, the added blocking is bounded by the worker's cv_m hold 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.

Copy link
Copy Markdown
Member

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_m in ForceFlush, so there is no ABBA deadlock between the thread calling ForceFlush and 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 prevents ForceFlush from calling synchronization_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.

Copy link
Copy Markdown
Contributor Author

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. main has neither lock, so there is no ABBA there. What I meant is that adding the cv_m acquisition to ForceFlush() on top of main without the rest of the changes in this PR would create an issue, because main holds cv_m across Export() -> 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 in main.

On the second point though, I think (please let me know if I misunderstood what you meant) your concern rests on wait_for in DoBackgroundWork holding the mutex while parked, and it does not. cv.wait_for(lk, timeout, pred) atomically unlocks lk and blocks, and only reacquires it when it is woken or times out. So while the background thread is parked, cv_m is unlocked and ForceFlush() acquires it immediately. The mutex is held only while the predicate is being evaluated and while wait_for returns, which here is one atomic load&store plus buffer_.empty() so that is how long the added acquisition can block for.

The stress test in this PR also proves it, ForceFlushRacesWorkerPark runs 2000 rounds with schedule_delay_millis set to 10 minutes and a 1 minute watchdog that aborts the binary if ForceFlush() does not return. If taking cv_m deadlocked 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 the notify_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 minute ForceFlush(), and the same applies to InternalShutdown() on the graceful shutdown path. Taking cv_m is what makes the wakeup guaranteed instead of best effort.

Please let me know if this clarifies it or if I misunderstood your concern.

Copy link
Copy Markdown
Member

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.

1. When background is waiting when call `synchronization_data_->cv.wait_for(lk, ...`, it will lock `synchronization_data_->cv_m` and cause deadlock here. And the wakeup notification can not be sent.

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();
Expand Down Expand Up @@ -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.
{
Comment thread
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)
Expand Down Expand Up @@ -309,6 +317,7 @@ void BatchSpanProcessor::NotifyCompletion(
exporter->ForceFlush(timeout);
}

std::lock_guard<std::mutex> lock(synchronization_data->force_flush_cv_m);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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();
}

Expand Down
13 changes: 13 additions & 0 deletions sdk/test/trace/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ cc_test(
],
)

cc_test(
name = "batch_span_processor_test_stress",
srcs = glob(["*_test_stress.cc"]),
tags = [
"test",
"trace",
],
deps = [
"//sdk/src/trace",
"@com_google_googletest//:gtest_main",
],
)

cc_test(
name = "tracer_test",
srcs = [
Expand Down
1 change: 1 addition & 0 deletions sdk/test/trace/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ foreach(
trace_id_ratio_sampler_test
composable_sampler_test
batch_span_processor_test
batch_span_processor_test_stress
tracer_config_test)
add_executable(${testname} "${testname}.cc")
target_link_libraries(
Expand Down
140 changes: 140 additions & 0 deletions sdk/test/trace/batch_span_processor_test_stress.cc
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;
Comment thread
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
Loading