concurrency.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """长任务并发闸。
  2. 超限**直接拒绝(429),不排队**。这是刻意的取舍:LLM 长任务动辄跑几分钟,
  3. 排队意味着用户盯着一个不动的界面等一个不知道多久才开始的请求;而 429 带
  4. ``retry_after`` 能让前端明确告诉用户「稍后再试」,体验反而更可控。
  5. 两道上限同时生效:
  6. - 全局(``TFAGENTS_MAX_CONCURRENT_TASKS``)保护下游 provider 与自身资源
  7. - 每用户(``TFAGENTS_MAX_USER_TASKS``)防止单个用户占满全局额度
  8. 计数是**进程内**的,所以这道闸只在 ``workers=1`` 下有效;多 worker 时每个
  9. 进程各算各的,实际并发会翻倍。多进程部署需要把计数挪到 Redis/MySQL。
  10. """
  11. from __future__ import annotations
  12. import threading
  13. import time
  14. from typing import Any
  15. import config
  16. from obs import get_logger
  17. logger = get_logger(__name__)
  18. class TooManyTasksError(RuntimeError):
  19. """并发已满。携带建议的重试间隔,供 429 响应的 retry_after 使用。"""
  20. def __init__(self, message: str, scope: str, retry_after: int = 10) -> None:
  21. self.scope = scope
  22. self.retry_after = retry_after
  23. super().__init__(message)
  24. class TaskWallTimeoutError(RuntimeError):
  25. """任务超过墙钟上限。"""
  26. class TaskSlot:
  27. """一个已占用的并发位。
  28. 必须成对使用:``acquire_task_slot()`` 拿到后,在 finally 里 ``release()``。
  29. 重复 release 安全(幂等)——SSE 生成器有多条退出路径,很容易重复调用。
  30. """
  31. __slots__ = ("user_name", "acquired_at", "_released", "_lock")
  32. def __init__(self, user_name: str) -> None:
  33. self.user_name = user_name
  34. self.acquired_at = time.time()
  35. self._released = False
  36. self._lock = threading.Lock()
  37. @property
  38. def held_seconds(self) -> float:
  39. return time.time() - self.acquired_at
  40. @property
  41. def released(self) -> bool:
  42. return self._released
  43. def release(self) -> bool:
  44. """释放占位。返回 True 表示本次调用真正释放了(首次调用)。"""
  45. with self._lock:
  46. if self._released:
  47. return False
  48. self._released = True
  49. with _state_lock:
  50. _global_count[0] = max(_global_count[0] - 1, 0)
  51. user_count = _per_user_count.get(self.user_name, 0)
  52. if user_count <= 1:
  53. _per_user_count.pop(self.user_name, None)
  54. else:
  55. _per_user_count[self.user_name] = user_count - 1
  56. logger.info(
  57. "释放并发位 user=%s 持有时长=%.1fs 剩余全局=%d 剩余用户=%d",
  58. self.user_name, self.held_seconds, _global_count[0],
  59. _per_user_count.get(self.user_name, 0),
  60. )
  61. return True
  62. # 进程内计数。用 list/dict 而不是裸 int,避免到处写 global
  63. _state_lock = threading.Lock()
  64. _global_count = [0]
  65. _per_user_count: dict[str, int] = {}
  66. def acquire_task_slot(user_name: str) -> TaskSlot:
  67. """尝试占一个并发位;超限抛 :class:`TooManyTasksError`。"""
  68. user = user_name or "default"
  69. with _state_lock:
  70. if _global_count[0] >= config.MAX_CONCURRENT_TASKS:
  71. raise TooManyTasksError(
  72. f"服务当前并发任务已达上限({config.MAX_CONCURRENT_TASKS}),请稍后重试",
  73. scope="global",
  74. )
  75. current_user = _per_user_count.get(user, 0)
  76. if current_user >= config.MAX_USER_TASKS:
  77. raise TooManyTasksError(
  78. f"您的并发任务已达上限({config.MAX_USER_TASKS}),请等待当前任务结束后重试",
  79. scope="user",
  80. )
  81. _global_count[0] += 1
  82. _per_user_count[user] = current_user + 1
  83. logger.info(
  84. "占用并发位 user=%s 全局=%d/%d 用户=%d/%d",
  85. user, _global_count[0], config.MAX_CONCURRENT_TASKS,
  86. _per_user_count[user], config.MAX_USER_TASKS,
  87. )
  88. return TaskSlot(user)
  89. def stats() -> dict[str, Any]:
  90. """当前并发占用快照,供 /health 展示。"""
  91. with _state_lock:
  92. return {
  93. "global_active": _global_count[0],
  94. "global_limit": config.MAX_CONCURRENT_TASKS,
  95. "user_limit": config.MAX_USER_TASKS,
  96. "users": dict(_per_user_count),
  97. "wall_timeout_seconds": config.TASK_WALL_TIMEOUT,
  98. }
  99. def reset_for_tests() -> None:
  100. """仅供测试:清零计数。"""
  101. with _state_lock:
  102. _global_count[0] = 0
  103. _per_user_count.clear()