Skip to content

Commit 4b4f1ee

Browse files
authored
opentelemetry-sdk: reduce lock contention in attributes (#5298)
* opentelemetry-sdk: reduce lock contention in attributes There are a few improvements here: - set_attributes was double locking (once in set_attributes and once in __setitem__. This change updates the call to a private _setitem_locked for set_attributes once the lock has been obtained - __iter__ doesn't use a lock if the BoundedAttributes's instantiated as immutable - _worker_awaken was being set even when it's already set, adding a check in emit to avoid unnecessary calls Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> * move code out of hotpath Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> * lint Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> * changelog Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> * apply feedback, fix lint Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> * ruff format Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> --------- Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com>
1 parent 4b5b5ef commit 4b4f1ee

5 files changed

Lines changed: 106 additions & 20 deletions

File tree

.changelog/5298.changed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
opentelemetry-sdk: reduce lock contention in attributes

opentelemetry-api/src/opentelemetry/attributes/__init__.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -277,29 +277,49 @@ def __getitem__(self, key: str) -> types.AnyValue:
277277
def __setitem__(self, key: str, value: types.AnyValue) -> None:
278278
if getattr(self, "_immutable", False): # type: ignore
279279
raise TypeError
280-
with self._lock:
281-
if self.maxlen is not None and self.maxlen == 0:
280+
if self.maxlen is not None and self.maxlen == 0:
281+
with self._lock:
282282
self.dropped += 1
283+
return
284+
if self._extended_attributes:
285+
value = _clean_extended_attribute(key, value, self.max_value_len)
286+
else:
287+
value = _clean_attribute(key, value, self.max_value_len) # type: ignore
288+
if value is None:
283289
return
290+
with self._lock:
291+
self._setitem_locked(key, value)
284292

293+
def _set_items(self, attributes: "types._ExtendedAttributes") -> None:
294+
if getattr(self, "_immutable", False): # type: ignore
295+
raise TypeError
296+
if self.maxlen is not None and self.maxlen == 0:
297+
with self._lock:
298+
self.dropped += len(attributes)
299+
return
300+
cleaned = []
301+
for key, value in attributes.items():
285302
if self._extended_attributes:
286-
value = _clean_extended_attribute(
287-
key, value, self.max_value_len
288-
)
303+
cv = _clean_extended_attribute(key, value, self.max_value_len)
289304
else:
290-
value = _clean_attribute(key, value, self.max_value_len) # type: ignore
291-
if value is None:
292-
return
293-
294-
if key in self._dict:
295-
del self._dict[key]
296-
elif self.maxlen is not None and len(self._dict) == self.maxlen:
297-
if not isinstance(self._dict, OrderedDict):
298-
self._dict = OrderedDict(self._dict)
299-
self._dict.popitem(last=False) # type: ignore
300-
self.dropped += 1
305+
cv = _clean_attribute(key, value, self.max_value_len) # type: ignore
306+
if cv is None:
307+
continue
308+
cleaned.append((key, cv))
309+
with self._lock:
310+
for key, cv in cleaned:
311+
self._setitem_locked(key, cv)
312+
313+
def _setitem_locked(self, key: str, value: types.AnyValue) -> None:
314+
if key in self._dict:
315+
del self._dict[key]
316+
elif self.maxlen is not None and len(self._dict) == self.maxlen:
317+
if not isinstance(self._dict, OrderedDict):
318+
self._dict = OrderedDict(self._dict)
319+
self._dict.popitem(last=False) # type: ignore
320+
self.dropped += 1
301321

302-
self._dict[key] = value # type: ignore
322+
self._dict[key] = value # type: ignore
303323

304324
def __delitem__(self, key: str) -> None:
305325
if getattr(self, "_immutable", False): # type: ignore
@@ -308,6 +328,8 @@ def __delitem__(self, key: str) -> None:
308328
del self._dict[key]
309329

310330
def __iter__(self):
331+
if self._immutable:
332+
return iter(self._dict)
311333
with self._lock:
312334
return iter(list(self._dict))
313335

opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright The OpenTelemetry Authors
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import threading
45
import tracemalloc
56
from functools import lru_cache
67

@@ -17,6 +18,11 @@
1718
_TracerConfig,
1819
sampling,
1920
)
21+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
22+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
23+
InMemorySpanExporter,
24+
)
25+
from opentelemetry.sdk.util import BoundedList
2026
from opentelemetry.sdk.util.instrumentation import _scope_name_matches_glob
2127
from opentelemetry.trace import SpanContext, TraceFlags
2228

@@ -239,3 +245,57 @@ def benchmark_iter():
239245
list(attrs)
240246

241247
benchmark(benchmark_iter)
248+
249+
250+
@pytest.mark.parametrize("num_items", [1, 10, 50, 128])
251+
def test_bounded_list_iterator(benchmark, num_items):
252+
blist = BoundedList.from_seq(num_items, range(num_items))
253+
254+
peaks = []
255+
for _ in range(200):
256+
tracemalloc.start()
257+
list(blist)
258+
_, peak = tracemalloc.get_traced_memory()
259+
tracemalloc.stop()
260+
peaks.append(peak)
261+
benchmark.extra_info["mean_alloc_bytes"] = sum(peaks) / len(peaks)
262+
263+
def benchmark_iter():
264+
list(blist)
265+
266+
benchmark(benchmark_iter)
267+
268+
269+
# Total span work is fixed at TOTAL_SPANS regardless of thread count so that
270+
# ideal (GIL-free) parallelism would produce a flat wall-clock time across
271+
# thread counts. Any increase instead reveals GIL + lock contention.
272+
_TOTAL_SPANS = 128
273+
_ATTRS_PER_SPAN = {f"key{i}": f"value{i}" for i in range(50)}
274+
275+
276+
@pytest.mark.parametrize("num_threads", [1, 2, 4, 8])
277+
def test_gil_contention_batch_processor(benchmark, num_threads):
278+
exporter = InMemorySpanExporter()
279+
provider = TracerProvider(sampler=sampling.DEFAULT_ON)
280+
# max_export_batch_size=16 ensures the export threshold is crossed
281+
# during the benchmark so _worker_awaken.set() contention is exercised.
282+
provider.add_span_processor(
283+
BatchSpanProcessor(exporter, max_export_batch_size=16)
284+
)
285+
tracer = provider.get_tracer("bench")
286+
spans_per_thread = _TOTAL_SPANS // num_threads
287+
288+
def worker():
289+
for _ in range(spans_per_thread):
290+
span = tracer.start_span("s")
291+
span.set_attributes(_ATTRS_PER_SPAN)
292+
span.end()
293+
294+
def benchmark_fn():
295+
threads = [threading.Thread(target=worker) for _ in range(num_threads)]
296+
for thread in threads:
297+
thread.start()
298+
for thread in threads:
299+
thread.join()
300+
301+
benchmark(benchmark_fn)

opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,10 @@ def emit(self, data: Telemetry) -> None:
206206
self._metrics.drop_items(1)
207207
# This will drop a log from the right side if the queue is at _max_queue_size.
208208
self._queue.appendleft(data)
209-
if len(self._queue) >= self._max_export_batch_size:
209+
if (
210+
len(self._queue) >= self._max_export_batch_size
211+
and not self._worker_awaken.is_set()
212+
):
210213
self._worker_awaken.set()
211214

212215
def shutdown(self, timeout_millis: int = 30000):

opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -891,8 +891,7 @@ def set_attributes(
891891
logger.warning("Setting attribute on ended span.")
892892
return
893893

894-
for key, value in attributes.items():
895-
self._attributes[key] = value
894+
self._attributes._set_items(attributes) # pylint: disable=protected-access
896895

897896
def set_attribute(self, key: str, value: types.AttributeValue) -> None:
898897
with self._lock:
@@ -990,6 +989,7 @@ def end(self, end_time: int | None = None) -> None:
990989
return
991990

992991
self._end_time = end_time if end_time is not None else time_ns()
992+
self._attributes._immutable = True # pylint: disable=protected-access
993993

994994
if self._record_end_metrics:
995995
self._record_end_metrics()

0 commit comments

Comments
 (0)