Repository: open-telemetry/opentelemetry-python

Issue:
When utilizing the Opentelemetry SDK to export traces, performance experienced a significant decline under multi-threading concurrency
**Describe your environment**
[Python Version] Python3.8
[OS] Windows 10
[platform] x86_64
[opentelemetry-python] 1.21.0


**Steps to reproduce**
1. init a simple provider
```
tracer = trace.get_tracer("name", "version", None, "url")

set_service_info("server_name", "server_version", "server_instance")
memory_exporter = InMemorySpanExporter()
trace_processor = SynchronousMultiSpanProcessor()
trace_processor.add_span_processor(
    span_processor=BatchSpanProcessor(
        span_exporter=memory_exporter,
        schedule_delay_millis=2000,
        max_queue_size=1000000,
        max_export_batch_size=400
    )
)
trace_provider = TracerProvider(resource=trace_resource(), active_span_processor=trace_processor)
set_tracer_provider(tracer_provider=trace_provider)
```
2. make a demo func
```
def func(threadName, a, b):
    with tracer.start_as_current_span("%s%d" % (threadName, 0)):
        for i1 in range(3):
            with tracer.start_as_current_span("%s%d" % (threadName, 0)):
                for j1 in range(10):
                    with tracer.start_as_current_span("%s%d%d" % (threadName, i1, j1)):
                        c = a + b

def test(threadName):
    start_time = time.time()
    for i in range(1000):
        func(threadName, 1, 2)
    end_time = time.time()
    print("%s %.2fs" % (threadName, end_time - start_time))
    return end_time - start_time
``` 
3. do test
```
t1 = threading.Thread(target=test, name="t1", args=("t1", ))
t2 = threading.Thread(target=test, name="t2", args=("t2", ))
t3 = threading.Thread(target=test, name="t3", args=("t3", ))
t4 = threading.Thread(target=test, name="t4", args=("t4", ))
t1.start()
t2.start()
t3.start()
t4.start()

t1.join()
t2.join()
t3.join()
t4.join()

time.sleep(1)

t = 0
t += test("t1")
t += test("t2")
t += test("t3")
t += test("t4")
print("total: %.2f" % t)
```

**What is the expected behavior?**
In both single-threaded and multi-threaded execution modes, the performance is comparable.

**What is the actual behavior?**
Performance experienced a significant decline under multi-threading concurrency

**Additional context**
https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-api/src/opentelemetry/context/__init__.py
```
def _load_runtime_context(func: _F) -> _F:
    """A decorator used to initialize the global RuntimeContext

    Returns:
        A wrapper of the decorated method.
    """

    @wraps(func)  # type: ignore[misc]
    def wrapper(  # type: ignore[misc]
        *args: typing.Tuple[typing.Any, typing.Any],
        **kwargs: typing.Dict[typing.Any, typing.Any],
    ) -> typing.Optional[typing.Any]:
        global _RUNTIME_CONTEXT  # pylint: disable=global-statement

        with _RUNTIME_CONTEXT_LOCK:
            if _RUNTIME_CONTEXT is None:
                # FIXME use a better implementation of a configuration manager
                # to avoid having to get configuration values straight from
                # environment variables
                default_context = "contextvars_context"

                configured_context = environ.get(
                    OTEL_PYTHON_CONTEXT, default_context
                )  # type: str
                try:

                    _RUNTIME_CONTEXT = next(  # type: ignore
                        iter(  # type: ignore
                            entry_points(  # type: ignore
                                group="opentelemetry_context",
                                name=configured_context,
                            )
                        )
                    ).load()()

                except Exception:  # pylint: disable=broad-except
                    logger.exception(
                        "Failed to load context: %s", configured_context
                    )
        return func(*args, **kwargs)  # type: ignore[misc]

    return typing.cast(_F, wrapper)  # type: ignore[misc]
```

> with _RUNTIME_CONTEXT_LOCK:

This thread lock is the reason caused performance declined.

The wrapper function will be called EACH TIME a span is created.

This lock is made for init _RUNTIME_CONTEXT object excatly ONCE.
But _RUNTIME_CONTEXT can not be None after it inited.

So in most cases, this func will do nothing under the lock.
Lock and unlock operation make the performance decline under multi-threading concurrency.

```
 if _RUNTIME_CONTEXT is None:
    with _RUNTIME_CONTEXT_LOCK:
        if _RUNTIME_CONTEXT is None:
            ...
```
If add a condition before lock operation, the performance will be comparable with single-threaded mode.


Discussion:

Resolving patch:
diff --git a/opentelemetry-api/src/opentelemetry/context/__init__.py b/opentelemetry-api/src/opentelemetry/context/__init__.py
--- a/opentelemetry-api/src/opentelemetry/context/__init__.py
+++ b/opentelemetry-api/src/opentelemetry/context/__init__.py
@@ -13,9 +13,7 @@
 # limitations under the License.
 
 import logging
-import threading
 import typing
-from functools import wraps
 from os import environ
 from uuid import uuid4
 
@@ -25,54 +23,50 @@
 from opentelemetry.util._importlib_metadata import entry_points
 
 logger = logging.getLogger(__name__)
-_RUNTIME_CONTEXT = None  # type: typing.Optional[_RuntimeContext]
-_RUNTIME_CONTEXT_LOCK = threading.Lock()
 
-_F = typing.TypeVar("_F", bound=typing.Callable[..., typing.Any])
 
-
-def _load_runtime_context(func: _F) -> _F:
-    """A decorator used to initialize the global RuntimeContext
+def _load_runtime_context() -> _RuntimeContext:
+    """Initialize the RuntimeContext
 
     Returns:
-        A wrapper of the decorated method.
+        An instance of RuntimeContext.
     """
 
-    @wraps(func)  # type: ignore[misc]
-    def wrapper(
-        *args: typing.Tuple[typing.Any, typing.Any],
-        **kwargs: typing.Dict[typing.Any, typing.Any],
-    ) -> typing.Optional[typing.Any]:
-        global _RUNTIME_CONTEXT  # pylint: disable=global-statement
-
-        with _RUNTIME_CONTEXT_LOCK:
-            if _RUNTIME_CONTEXT is None:
-                # FIXME use a better implementation of a configuration manager
-                # to avoid having to get configuration values straight from
-                # environment variables
-                default_context = "contextvars_context"
-
-                configured_context = environ.get(
-                    OTEL_PYTHON_CONTEXT, default_context
-                )  # type: str
-                try:
-
-                    _RUNTIME_CONTEXT = next(  # type: ignore
-                        iter(  # type: ignore
-                            entry_points(  # type: ignore
-                                group="opentelemetry_context",
-                                name=configured_context,
-                            )
-                        )
-                    ).load()()
-
-                except Exception:  # pylint: disable=broad-except
-                    logger.exception(
-                        "Failed to load context: %s", configured_context
-                    )
-        return func(*args, **kwargs)  # type: ignore[misc]
-
-    return typing.cast(_F, wrapper)  # type: ignore[misc]
+    # FIXME use a better implementation of a configuration manager
+    # to avoid having to get configuration values straight from
+    # environment variables
+    default_context = "contextvars_context"
+
+    configured_context = environ.get(
+        OTEL_PYTHON_CONTEXT, default_context
+    )  # type: str
+
+    try:
+        return next(  # type: ignore
+            iter(  # type: ignore
+                entry_points(  # type: ignore
+                    group="opentelemetry_context",
+                    name=configured_context,
+                )
+            )
+        ).load()()
+    except Exception:  # pylint: disable=broad-except
+        logger.exception(
+            "Failed to load context: %s, fallback to %s",
+            configured_context,
+            default_context,
+        )
+        return next(  # type: ignore
+            iter(  # type: ignore
+                entry_points(  # type: ignore
+                    group="opentelemetry_context",
+                    name=default_context,
+                )
+            )
+        ).load()()
+
+
+_RUNTIME_CONTEXT = _load_runtime_context()
 
 
 def create_key(keyname: str) -> str:
@@ -125,7 +119,6 @@ def set_value(
     return Context(new_values)
 
 
-@_load_runtime_context  # type: ignore
 def get_current() -> Context:
     """To access the context associated with program execution,
     the Context API provides a function which takes no arguments
@@ -134,10 +127,9 @@ def get_current() -> Context:
     Returns:
         The current `Context` object.
     """
-    return _RUNTIME_CONTEXT.get_current()  # type:ignore
+    return _RUNTIME_CONTEXT.get_current()
 
 
-@_load_runtime_context  # type: ignore
 def attach(context: Context) -> object:
     """Associates a Context with the caller's current execution unit. Returns
     a token that can be used to restore the previous Context.
@@ -148,10 +140,9 @@ def attach(context: Context) -> object:
     Returns:
         A token that can be used with `detach` to reset the context.
     """
-    return _RUNTIME_CONTEXT.attach(context)  # type:ignore
+    return _RUNTIME_CONTEXT.attach(context)
 
 
-@_load_runtime_context  # type: ignore
 def detach(token: object) -> None:
     """Resets the Context associated with the caller's current execution unit
     to the value it had before attaching a specified Context.
@@ -160,7 +151,7 @@ def detach(token: object) -> None:
         token: The Token that was returned by a previous call to attach a Context.
     """
     try:
-        _RUNTIME_CONTEXT.detach(token)  # type: ignore
+        _RUNTIME_CONTEXT.detach(token)
     except Exception:  # pylint: disable=broad-except
         logger.exception("Failed to detach context")
 


Test patch:
diff --git a/opentelemetry-api/tests/context/test_context.py b/opentelemetry-api/tests/context/test_context.py
--- a/opentelemetry-api/tests/context/test_context.py
+++ b/opentelemetry-api/tests/context/test_context.py
@@ -13,9 +13,12 @@
 # limitations under the License.
 
 import unittest
+from unittest.mock import patch
 
 from opentelemetry import context
 from opentelemetry.context.context import Context
+from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext
+from opentelemetry.environment_variables import OTEL_PYTHON_CONTEXT
 
 
 def _do_work() -> str:
@@ -74,3 +77,19 @@ def test_set_current(self):
 
         context.detach(token)
         self.assertEqual("yyy", context.get_value("a"))
+
+
+class TestInitContext(unittest.TestCase):
+    def test_load_runtime_context_default(self):
+        ctx = context._load_runtime_context()  # pylint: disable=W0212
+        self.assertIsInstance(ctx, ContextVarsRuntimeContext)
+
+    @patch.dict("os.environ", {OTEL_PYTHON_CONTEXT: "contextvars_context"})
+    def test_load_runtime_context(self):  # type: ignore[misc]
+        ctx = context._load_runtime_context()  # pylint: disable=W0212
+        self.assertIsInstance(ctx, ContextVarsRuntimeContext)
+
+    @patch.dict("os.environ", {OTEL_PYTHON_CONTEXT: "foo"})
+    def test_load_runtime_context_fallback(self):  # type: ignore[misc]
+        ctx = context._load_runtime_context()  # pylint: disable=W0212
+        self.assertIsInstance(ctx, ContextVarsRuntimeContext)