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

变更项:\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
import shutil
from typing import Optional, Tuple
import threading
from concurrent.futures import Future as ConcurrentFuture
import psutil
from playwright.async_api import async_playwright
@@ -625,6 +627,83 @@ class _PersistentBrowser:
_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:
@@ -642,13 +721,17 @@ async def warmup_md2img_browser(timeout_seconds: int = 45) -> bool:
2. 不执行实际业务截图,仅确保常驻浏览器已可用。
"""
try:
current_loop_id = id(asyncio.get_running_loop())
logger.info(f"[md2img] 开始浏览器预热: loop={current_loop_id}, timeout={int(timeout_seconds)}s")
manager = _get_browser_manager()
await asyncio.wait_for(manager.ensure_browser(), timeout=max(10, int(timeout_seconds)))
browser = manager._browser
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] 开始浏览器预热: caller_loop={id(asyncio.get_running_loop())}, timeout={int(timeout_seconds)}s")
async def _warmup_impl():
manager = _get_browser_manager()
await asyncio.wait_for(manager.ensure_browser(), timeout=max(10, int(timeout_seconds)))
browser = manager._browser
browser_pid = getattr(getattr(browser, "process", None), "pid", None) if browser else None
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
except Exception as e:
logger.error(f"[md2img] 浏览器预热失败: {e}")
@@ -665,8 +748,18 @@ def warmup_md2img_browser_sync(timeout_seconds: int = 45) -> bool:
async def html_to_image(html_content, output_image):
manager = _get_browser_manager()
await manager.screenshot(html_content, output_image)
"""将 HTML 渲染为图片。
说明:
1. 实际截图逻辑固定在 md2img 专用事件循环执行;
2. 调用方无论来自哪个线程/loop都只会复用同一套常驻浏览器。
"""
async def _html_to_image_impl():
manager = _get_browser_manager()
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):