统一转图为单运行时常驻浏览器

变更项:\n1. 新增 md2img 专用运行时(独立线程 + 单事件循环),确保浏览器生命周期只在一个 loop 内维护。\n2. 新增运行时任务投递与异步等待封装,支持任意调用方线程/loop 统一提交截图任务。\n3. 调整浏览器预热逻辑:预热改为在 md2img 专用运行时执行,避免预热与业务截图分属不同 loop。\n4. 调整 html_to_image:统一在专用运行时内完成截图,彻底规避跨事件循环复用导致的重建。\n5. 增强中文注释与运行日志,便于定位 runtime loop 与预热状态。
This commit is contained in:
liuwei
2026-04-17 10:00:25 +08:00
parent 3b9bd02b5f
commit 5a84c60b2c

View File

@@ -3,6 +3,8 @@ import time
from pathlib import Path from pathlib import Path
import shutil import shutil
from typing import Optional, Tuple from typing import Optional, Tuple
import threading
from concurrent.futures import Future as ConcurrentFuture
import psutil import psutil
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
@@ -625,6 +627,83 @@ class _PersistentBrowser:
_BROWSER_MANAGER: Optional[_PersistentBrowser] = None _BROWSER_MANAGER: Optional[_PersistentBrowser] = None
_MD2IMG_RUNTIME = None
class _Md2ImgRuntime:
"""Markdown 转图专用运行时。
设计目的:
1. 在独立线程中维护唯一事件循环,所有浏览器操作都在这个 loop 执行;
2. 彻底避免“预热在 A loop、截图在 B loop”的跨 loop 复用问题;
3. 为消息处理与定时任务提供统一稳定的浏览器执行上下文。
"""
def __init__(self):
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._lock = threading.Lock()
self._ready = threading.Event()
@property
def loop(self) -> Optional[asyncio.AbstractEventLoop]:
return self._loop
def _thread_main(self):
"""运行时线程入口:创建并常驻事件循环。"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._loop = loop
self._ready.set()
logger.info(f"[md2img] 专用运行时已启动: thread={threading.current_thread().name}, loop={id(loop)}")
loop.run_forever()
def ensure_started(self):
"""确保运行时已启动(幂等)。"""
if self._thread and self._thread.is_alive() and self._loop and self._loop.is_running():
return
with self._lock:
if self._thread and self._thread.is_alive() and self._loop and self._loop.is_running():
return
self._ready.clear()
self._thread = threading.Thread(target=self._thread_main, name="md2img-runtime", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=10):
raise RuntimeError("md2img 专用运行时启动超时")
def submit(self, coro) -> ConcurrentFuture:
"""向专用运行时提交协程任务。"""
self.ensure_started()
if not self._loop:
raise RuntimeError("md2img 运行时事件循环未就绪")
return asyncio.run_coroutine_threadsafe(coro, self._loop)
def _get_md2img_runtime() -> _Md2ImgRuntime:
global _MD2IMG_RUNTIME
if _MD2IMG_RUNTIME is None:
_MD2IMG_RUNTIME = _Md2ImgRuntime()
return _MD2IMG_RUNTIME
async def _run_in_md2img_runtime(coro, timeout_seconds: Optional[int] = None):
"""在 md2img 专用事件循环中执行协程,并在当前调用方 loop 中异步等待结果。"""
runtime = _get_md2img_runtime()
runtime.ensure_started()
target_loop = runtime.loop
current_loop = asyncio.get_running_loop()
# 若当前已在专用 loop 内,直接执行,避免不必要的线程跳转。
if target_loop is current_loop:
if timeout_seconds is not None:
return await asyncio.wait_for(coro, timeout=max(1, int(timeout_seconds)))
return await coro
future = runtime.submit(coro)
awaitable_future = asyncio.wrap_future(future)
if timeout_seconds is not None:
return await asyncio.wait_for(awaitable_future, timeout=max(1, int(timeout_seconds)))
return awaitable_future
def _get_browser_manager() -> _PersistentBrowser: def _get_browser_manager() -> _PersistentBrowser:
@@ -642,13 +721,17 @@ async def warmup_md2img_browser(timeout_seconds: int = 45) -> bool:
2. 不执行实际业务截图,仅确保常驻浏览器已可用。 2. 不执行实际业务截图,仅确保常驻浏览器已可用。
""" """
try: try:
current_loop_id = id(asyncio.get_running_loop()) logger.info(f"[md2img] 开始浏览器预热: caller_loop={id(asyncio.get_running_loop())}, timeout={int(timeout_seconds)}s")
logger.info(f"[md2img] 开始浏览器预热: loop={current_loop_id}, timeout={int(timeout_seconds)}s")
async def _warmup_impl():
manager = _get_browser_manager() manager = _get_browser_manager()
await asyncio.wait_for(manager.ensure_browser(), timeout=max(10, int(timeout_seconds))) await asyncio.wait_for(manager.ensure_browser(), timeout=max(10, int(timeout_seconds)))
browser = manager._browser browser = manager._browser
browser_pid = getattr(getattr(browser, "process", None), "pid", None) if browser else None browser_pid = getattr(getattr(browser, "process", None), "pid", None) if browser else None
logger.info(f"[md2img] 浏览器预热完成: loop={current_loop_id}, pid={browser_pid}") logger.info(f"[md2img] 浏览器预热完成: runtime_loop={id(asyncio.get_running_loop())}, pid={browser_pid}")
return True
await _run_in_md2img_runtime(_warmup_impl(), timeout_seconds=max(10, int(timeout_seconds) + 5))
return True return True
except Exception as e: except Exception as e:
logger.error(f"[md2img] 浏览器预热失败: {e}") logger.error(f"[md2img] 浏览器预热失败: {e}")
@@ -665,9 +748,19 @@ def warmup_md2img_browser_sync(timeout_seconds: int = 45) -> bool:
async def html_to_image(html_content, output_image): async def html_to_image(html_content, output_image):
"""将 HTML 渲染为图片。
说明:
1. 实际截图逻辑固定在 md2img 专用事件循环执行;
2. 调用方无论来自哪个线程/loop都只会复用同一套常驻浏览器。
"""
async def _html_to_image_impl():
manager = _get_browser_manager() manager = _get_browser_manager()
await manager.screenshot(html_content, output_image) await manager.screenshot(html_content, output_image)
await _run_in_md2img_runtime(_html_to_image_impl())
async def _await_with_progress(coro, timeout_seconds: int, stage_name: str, progress_interval_seconds: int = 10): async def _await_with_progress(coro, timeout_seconds: int, stage_name: str, progress_interval_seconds: int = 10):
"""等待协程并周期输出进度,避免长时间无日志看起来像假死。""" """等待协程并周期输出进度,避免长时间无日志看起来像假死。"""