Spaces:
Paused
Make blocking threadpool size configurable to lift image concurrency
Browse filesAll blocking endpoints (image gen, text, content filter) run via
run_in_threadpool on anyio's default thread limiter, capped at 40. That
40 was the hard ceiling on simultaneous in-flight image generations
regardless of how many accounts (each accounts x image_account_concurrency
slot) the pool had.
Add config.blocking_threadpool_size (env BLOCKING_THREADPOOL_SIZE or
config.json, floor 40 / ceil 2048) and raise the limiter in the app
lifespan so it takes effect inside the event loop. Image gen is
I/O-bound, so a few hundred threads cost little on modest hardware; the
real ceiling then becomes accounts x concurrency and upstream rate
limits rather than the runtime.
Bake BLOCKING_THREADPOOL_SIZE=128 into the Space image as the default;
override via a Space Variable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dockerfile +2 -1
- api/app.py +16 -0
- services/config.py +15 -0
|
@@ -22,7 +22,8 @@ ARG TARGETARCH
|
|
| 22 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 23 |
PYTHONUNBUFFERED=1 \
|
| 24 |
UV_LINK_MODE=copy \
|
| 25 |
-
HOME=/app
|
|
|
|
| 26 |
|
| 27 |
WORKDIR /app
|
| 28 |
|
|
|
|
| 22 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 23 |
PYTHONUNBUFFERED=1 \
|
| 24 |
UV_LINK_MODE=copy \
|
| 25 |
+
HOME=/app \
|
| 26 |
+
BLOCKING_THREADPOOL_SIZE=128
|
| 27 |
|
| 28 |
WORKDIR /app
|
| 29 |
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
from threading import Event
|
| 5 |
|
|
|
|
| 6 |
from fastapi import FastAPI, HTTPException
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.responses import FileResponse
|
|
@@ -12,6 +13,20 @@ from api import accounts, ai, image_tasks, openai_keys, register, system
|
|
| 12 |
from api.support import resolve_web_asset, start_limited_account_watcher
|
| 13 |
from services.backup_service import backup_service
|
| 14 |
from services.config import config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def create_app() -> FastAPI:
|
|
@@ -19,6 +34,7 @@ def create_app() -> FastAPI:
|
|
| 19 |
|
| 20 |
@asynccontextmanager
|
| 21 |
async def lifespan(_: FastAPI):
|
|
|
|
| 22 |
stop_event = Event()
|
| 23 |
thread = start_limited_account_watcher(stop_event)
|
| 24 |
backup_service.start()
|
|
|
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
from threading import Event
|
| 5 |
|
| 6 |
+
import anyio
|
| 7 |
from fastapi import FastAPI, HTTPException
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.responses import FileResponse
|
|
|
|
| 13 |
from api.support import resolve_web_asset, start_limited_account_watcher
|
| 14 |
from services.backup_service import backup_service
|
| 15 |
from services.config import config
|
| 16 |
+
from utils.log import logger
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _apply_blocking_threadpool_size() -> None:
|
| 20 |
+
# 抬高 anyio run_in_threadpool 线程池上限(默认 40),解除生图/文本接口的同时并发硬顶。
|
| 21 |
+
# 必须在事件循环内调用(limiter 绑定当前 async backend),故放在 lifespan 启动里。
|
| 22 |
+
size = config.blocking_threadpool_size
|
| 23 |
+
try:
|
| 24 |
+
limiter = anyio.to_thread.current_default_thread_limiter()
|
| 25 |
+
if limiter.total_tokens != size:
|
| 26 |
+
limiter.total_tokens = size
|
| 27 |
+
logger.info({"event": "blocking_threadpool_configured", "total_tokens": limiter.total_tokens})
|
| 28 |
+
except Exception as exc:
|
| 29 |
+
logger.warning({"event": "blocking_threadpool_config_failed", "error": str(exc)})
|
| 30 |
|
| 31 |
|
| 32 |
def create_app() -> FastAPI:
|
|
|
|
| 34 |
|
| 35 |
@asynccontextmanager
|
| 36 |
async def lifespan(_: FastAPI):
|
| 37 |
+
_apply_blocking_threadpool_size()
|
| 38 |
stop_event = Event()
|
| 39 |
thread = start_limited_account_watcher(stop_event)
|
| 40 |
backup_service.start()
|
|
@@ -224,6 +224,20 @@ class ConfigStore:
|
|
| 224 |
except (TypeError, ValueError):
|
| 225 |
return 3
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
@property
|
| 228 |
def auto_remove_invalid_accounts(self) -> bool:
|
| 229 |
value = self.data.get("auto_remove_invalid_accounts", False)
|
|
@@ -310,6 +324,7 @@ class ConfigStore:
|
|
| 310 |
data["image_retention_days"] = self.image_retention_days
|
| 311 |
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
|
| 312 |
data["image_account_concurrency"] = self.image_account_concurrency
|
|
|
|
| 313 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 314 |
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
| 315 |
data["log_levels"] = self.log_levels
|
|
|
|
| 224 |
except (TypeError, ValueError):
|
| 225 |
return 3
|
| 226 |
|
| 227 |
+
@property
|
| 228 |
+
def blocking_threadpool_size(self) -> int:
|
| 229 |
+
# 所有阻塞型接口(生图/文本/内容过滤)都跑在 anyio 的 run_in_threadpool 线程池里,
|
| 230 |
+
# 默认只有 40,是生图同时并发的硬顶(见 conversation.stream_image_outputs_with_pool)。
|
| 231 |
+
# 这里允许通过 env BLOCKING_THREADPOOL_SIZE 或 config.json 抬高;I/O 密集,2C16G 抬到
|
| 232 |
+
# 一两百只占很少内存,真正天花板会让位给 账号数×image_account_concurrency 与上游限流。
|
| 233 |
+
raw = os.getenv("BLOCKING_THREADPOOL_SIZE") or self.data.get("blocking_threadpool_size") or 40
|
| 234 |
+
try:
|
| 235 |
+
value = int(raw)
|
| 236 |
+
except (TypeError, ValueError):
|
| 237 |
+
value = 40
|
| 238 |
+
# 不低于 anyio 默认 40(避免误降并发),上限防手滑打爆内存。
|
| 239 |
+
return max(40, min(2048, value))
|
| 240 |
+
|
| 241 |
@property
|
| 242 |
def auto_remove_invalid_accounts(self) -> bool:
|
| 243 |
value = self.data.get("auto_remove_invalid_accounts", False)
|
|
|
|
| 324 |
data["image_retention_days"] = self.image_retention_days
|
| 325 |
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
|
| 326 |
data["image_account_concurrency"] = self.image_account_concurrency
|
| 327 |
+
data["blocking_threadpool_size"] = self.blocking_threadpool_size
|
| 328 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 329 |
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
| 330 |
data["log_levels"] = self.log_levels
|