Repository: litestar-org/litestar

Issue:
Litestar consumes high amount of CPU
### Description

Running litestar `litestar app run` with uvicorn consumes in an idling state on my machines 40-45% CPU.

Is this normal or is there a way to reduce the idling CPU consumption?

Attached a picture of cProfile. `python -m cProfile -o litestar.pstats ./.venv/bin/litestar run`

![litestar_profile](https://github.com/litestar-org/litestar/assets/1131885/ab23a525-f85e-479e-8a5d-981fe68c46e3)

### URL to code causing the issue

_No response_

### MCVE

```python
UVICORN_LOOP=uvloop litestar run                            
Using Litestar app from env: 'app.asgi:create_app'
Starting server process ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Litestar version             │ 2.2.1                                                                                                        │
│ Debug mode                   │ Disabled                                                                                                     │
│ Python Debugger on exception │ Disabled                                                                                                     │
│ CORS                         │ Enabled                                                                                                      │
│ CSRF                         │ Disabled                                                                                                     │
│ OpenAPI                      │ Enabled path=/schema                                                                                         │
│ Compression                  │ Disabled                                                                                                     │
│ Template engine              │ ViteTemplateEngine                                                                                           │
│ Static files                 │ path=/static dirs=.../src/app/domain/web/public html_mode=Enabled │
│ Middlewares                  │ JWTCookieAuthenticationMiddleware, SentryLitestarASGIMiddleware, middleware_factory                          │
└──────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
2023-10-23T19:12:38.780745Z [info     ] Started server process [4257]  [uvicorn.error] 
2023-10-23T19:12:38.781923Z [info     ] Waiting for application startup. [uvicorn.error] 
2023-10-23T19:12:38.789916Z [info     ] Application startup complete.  [uvicorn.error] 
2023-10-23T19:12:38.792650Z [info     ] Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) [uvicorn.error] 
^C2023-10-23T19:15:04.770399Z [info     ] Shutting down                  [uvicorn.error] 
^C2023-10-23T19:15:04.910332Z [info     ] Finished server process [4257] [uvicorn.error] 
^C2023-10-23T19:15:04.974529Z [info     ] ASGI 'lifespan' protocol appears unsupported. [uvicorn.error] 
```
```


### Steps to reproduce

```bash
- Run `litestar run`
- Look process ressources
```


### Screenshots

![litestar_profile](https://github.com/litestar-org/litestar/assets/1131885/ab23a525-f85e-479e-8a5d-981fe68c46e3)


### Logs

_No response_

### Litestar Version

2.2.1
python3.11



### Platform

- [ ] Linux
- [X] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh Litestar dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/2503">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2503/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2503/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
I cannot reproduce this with a basic application. Can you provide a reproducible example? It’s really hard to debug this without actually know what your app looks like, but I’m fairly certain that there’s some kind of busy loop involved you’re not aware of.

Resolving patch:
diff --git a/litestar/channels/backends/memory.py b/litestar/channels/backends/memory.py
--- a/litestar/channels/backends/memory.py
+++ b/litestar/channels/backends/memory.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import asyncio
 from asyncio import Queue
 from collections import defaultdict, deque
 from typing import Any, AsyncGenerator, Iterable
@@ -65,9 +64,6 @@ async def unsubscribe(self, channels: Iterable[str]) -> None:
     async def stream_events(self) -> AsyncGenerator[tuple[str, Any], None]:
         """Return a generator, iterating over events of subscribed channels as they become available"""
         while self._queue:
-            if not len(self._channels):
-                await asyncio.sleep(0)
-                continue
             yield await self._queue.get()
             self._queue.task_done()
 
diff --git a/litestar/channels/backends/redis.py b/litestar/channels/backends/redis.py
--- a/litestar/channels/backends/redis.py
+++ b/litestar/channels/backends/redis.py
@@ -23,6 +23,35 @@
 _XADD_EXPIRE_SCRIPT = (_resource_path / "_redis_xadd_expire.lua").read_text()
 
 
+class _LazyEvent:
+    """A lazy proxy to asyncio.Event that only creates the event once it's accessed.
+
+    It ensures that the Event is created within a running event loop. If it's not, there can be an issue where a future
+    within the event itself is attached to a different loop.
+
+    This happens in our tests and could also happen when a user creates an instance of the backend outside an event loop
+    in their application.
+    """
+
+    def __init__(self) -> None:
+        self.__event: asyncio.Event | None = None
+
+    @property
+    def _event(self) -> asyncio.Event:
+        if self.__event is None:
+            self.__event = asyncio.Event()
+        return self.__event
+
+    def set(self) -> None:
+        self._event.set()
+
+    def clear(self) -> None:
+        self._event.clear()
+
+    async def wait(self) -> None:
+        await self._event.wait()
+
+
 class RedisChannelsBackend(ChannelsBackend, ABC):
     def __init__(self, *, redis: Redis, key_prefix: str, stream_sleep_no_subscriptions: int) -> None:
         """Base redis channels backend.
@@ -35,7 +64,7 @@ def __init__(self, *, redis: Redis, key_prefix: str, stream_sleep_no_subscriptio
         """
         self._redis = redis
         self._key_prefix = key_prefix
-        self._stream_sleep_no_subscriptions = stream_sleep_no_subscriptions / 1000
+        self._stream_sleep_no_subscriptions = stream_sleep_no_subscriptions
 
     def _make_key(self, channel: str) -> str:
         return f"{self._key_prefix}_{channel.upper()}"
@@ -60,6 +89,7 @@ def __init__(
         )
         self.__pub_sub: PubSub | None = None
         self._publish_script = self._redis.register_script(_PUBSUB_PUBLISH_SCRIPT)
+        self._has_subscribed = _LazyEvent()
 
     @property
     def _pub_sub(self) -> PubSub:
@@ -77,10 +107,13 @@ async def on_shutdown(self) -> None:
     async def subscribe(self, channels: Iterable[str]) -> None:
         """Subscribe to ``channels``, and enable publishing to them"""
         await self._pub_sub.subscribe(*channels)
+        self._has_subscribed.set()
 
     async def unsubscribe(self, channels: Iterable[str]) -> None:
         """Stop listening for events on ``channels``"""
         await self._pub_sub.unsubscribe(*channels)
+        if not self._pub_sub.subscribed:
+            self._has_subscribed.clear()
 
     async def publish(self, data: bytes, channels: Iterable[str]) -> None:
         """Publish ``data`` to ``channels``
@@ -98,11 +131,10 @@ async def stream_events(self) -> AsyncGenerator[tuple[str, Any], None]:
         """
 
         while True:
-            if not self._pub_sub.subscribed:
-                await asyncio.sleep(self._stream_sleep_no_subscriptions)  # no subscriptions found so we sleep a bit
-                continue
-
-            message = await self._pub_sub.get_message(ignore_subscribe_messages=True, timeout=None)  # type: ignore[arg-type]
+            await self._has_subscribed.wait()
+            message = await self._pub_sub.get_message(
+                ignore_subscribe_messages=True, timeout=self._stream_sleep_no_subscriptions
+            )
             if message is None:
                 continue
 
@@ -149,6 +181,7 @@ def __init__(
         self._stream_ttl = stream_ttl if isinstance(stream_ttl, int) else int(stream_ttl.total_seconds() * 1000)
         self._flush_all_streams_script = self._redis.register_script(_FLUSHALL_STREAMS_SCRIPT)
         self._publish_script = self._redis.register_script(_XADD_EXPIRE_SCRIPT)
+        self._has_subscribed_channels = _LazyEvent()
 
     async def on_startup(self) -> None:
         """Called on application startup"""
@@ -159,10 +192,13 @@ async def on_shutdown(self) -> None:
     async def subscribe(self, channels: Iterable[str]) -> None:
         """Subscribe to ``channels``"""
         self._subscribed_channels.update(channels)
+        self._has_subscribed_channels.set()
 
     async def unsubscribe(self, channels: Iterable[str]) -> None:
         """Unsubscribe from ``channels``"""
         self._subscribed_channels -= set(channels)
+        if not len(self._subscribed_channels):
+            self._has_subscribed_channels.clear()
 
     async def publish(self, data: bytes, channels: Iterable[str]) -> None:
         """Publish ``data`` to ``channels``.
@@ -182,6 +218,11 @@ async def publish(self, data: bytes, channels: Iterable[str]) -> None:
             ],
         )
 
+    async def _get_subscribed_channels(self) -> set[str]:
+        """Get subscribed channels. If no channels are currently subscribed, wait"""
+        await self._has_subscribed_channels.wait()
+        return self._subscribed_channels
+
     async def stream_events(self) -> AsyncGenerator[tuple[str, Any], None]:
         """Return a generator, iterating over events of subscribed channels as they become available.
 
@@ -190,13 +231,14 @@ async def stream_events(self) -> AsyncGenerator[tuple[str, Any], None]:
         """
         stream_ids: dict[str, bytes] = {}
         while True:
-            stream_keys = [self._make_key(c) for c in self._subscribed_channels]
+            # We wait for subscribed channels, because we can't pass an empty dict to
+            # xread and block for subscribers
+            stream_keys = [self._make_key(c) for c in await self._get_subscribed_channels()]
             if not stream_keys:
-                await asyncio.sleep(self._stream_sleep_no_subscriptions)  # no subscriptions found so we sleep a bit
                 continue
 
             data: list[tuple[bytes, list[tuple[bytes, dict[bytes, bytes]]]]] = await self._redis.xread(
-                {key: stream_ids.get(key, 0) for key in stream_keys}, block=1
+                {key: stream_ids.get(key, 0) for key in stream_keys}, block=self._stream_sleep_no_subscriptions
             )
 
             if not data:


Test patch:
diff --git a/tests/unit/test_channels/test_backends.py b/tests/unit/test_channels/test_backends.py
--- a/tests/unit/test_channels/test_backends.py
+++ b/tests/unit/test_channels/test_backends.py
@@ -74,6 +74,10 @@ async def test_pub_sub_shutdown_leftover_messages(channels_backend_instance: Cha
     await asyncio.wait_for(channels_backend_instance.on_shutdown(), timeout=0.1)
 
 
+async def test_unsubscribe_without_subscription(channels_backend: ChannelsBackend) -> None:
+    await channels_backend.unsubscribe(["foo"])
+
+
 @pytest.mark.parametrize("history_limit,expected_history_length", [(None, 10), (1, 1), (5, 5), (10, 10)])
 async def test_get_history(
     channels_backend: ChannelsBackend, history_limit: int | None, expected_history_length: int