refactor: worker 子系统全面异步化,拆掉同步转异步的桥接层

拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。
worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给
另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成
协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。

改动(按子系统):
- music_downloader:downloader / _aria2 / _concurrency / cover / _http /
  __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改
  「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让
  已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经
  to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式
  读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已
  有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。
  删除 get_http_session / _SESSION_LOCAL。
  models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception:
  它是「续传起点失效、残片已清、从头重下」,既不是确定性失败
  (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立
  成类且在 except 链中先于二者捕获。
- music_api:podcast.py 全部函数协程化(直链解析内部改
  await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已
  清空,文件只剩空壳。
- services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。
  threading.Thread → loop.create_task(任务句柄字段 worker_thread →
  worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore,
  终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。
  删除 services/streaming.py(同步流垫片,唯一消费者已迁到
  core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、
  「入包」严格串行。
- web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是
  把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。
  web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。
  web/routers/download.py 的播客端点补上 3 处漏掉的 await
  (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象,
  靠 pyright 抓出来后修正。

留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、
mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、
PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/
往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject
逐条注明理由。

取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker
协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件
循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。

测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的
get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks
(顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...));
aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成
done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在
常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。

门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。

下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾;
B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅
sync_http 自用)。
This commit is contained in:
snowy-yang committed 2026-09-28 19:36:56 +08:00
1 parent 65adfc9089
commit 69a50c2c31
35 files changed
+1062 -1005

No files matched your search

+56 -25
View File
@@ -1,7 +1,17 @@
# -*- coding: utf-8 -*-
"""任务生命周期原语(启动 / 终态 / 文件收尾 / 结算)。
worker 自本轮起是协程:``_start_task`` 把 ``worker(*args)`` 包成 asyncio 任务挂到
应用事件循环上(原先起一条线程)。任务字典里的句柄随之由 ``worker_thread`` 换成
``worker_task``,监控循环对「worker 已结束但没人走终态结算」的兜底判断改为
``task.done()``——语义不变(settle 幂等,无双退)。
取消/暂停位仍是 ``threading.Event``:置位方可能是监控线程或请求线程,读取方是
worker 协程,只有"置位"与"轮询读取"两种操作,既不需要跨循环唤醒也不会被事件
循环卡住,比 asyncio.Event 更省心。
"""
import asyncio
import threading
import time
import traceback
@@ -21,20 +31,31 @@ from .api_service import (
api_service,
)
# 存活期的后台任务强引用——asyncio 只持弱引用,不留引用的话任务可能中途被 GC
_BACKGROUND_TASKS: set[asyncio.Task] = set()
def _start_task_thread(task_id: str, target, args=()) -> str | None:
def _log_worker_crash(handle: asyncio.Task) -> None:
"""worker 的异常逃逸到任务层时留痕。
正常情况下 worker 自带四路终态收敛,不会抛到这里;真抛出来说明收尾路径本身
出了问题,必须留下 traceback,否则任务永远停在 running 且无人知晓。
"""
if handle.cancelled():
return
exc = handle.exception()
if exc is not None:
api_service.logger.error(f"任务 worker 异常退出: {exc}", exc_info=exc)
def _start_task(task_id: str, worker, args: tuple = ()) -> str | None:
"""启动任务 worker(启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
任务句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 已结束
(无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。"""
try:
th = threading.Thread(target=target, args=args, daemon=True)
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['worker_thread'] = th
th.start()
return None
loop = asyncio.get_running_loop()
handle = loop.create_task(worker(*args), name=f'task:{task_id}')
except Exception as e:
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.pop(task_id, None)
@@ -43,14 +64,22 @@ def _start_task_thread(task_id: str, target, args=()) -> str | None:
auth_system.ledger_settle(task_id, 0, 'submit_failed')
except Exception:
pass
api_service.logger.error(f"任务线程启动失败: {task_id} {e}")
api_service.logger.error(f"任务启动失败: {task_id} {e}")
return f'任务启动失败:{e}'
_BACKGROUND_TASKS.add(handle)
handle.add_done_callback(_BACKGROUND_TASKS.discard)
handle.add_done_callback(_log_worker_crash)
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['worker_task'] = handle
return None
def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
async def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
"""任务进入终态的统一入口(记录 completed_at,保留期从完成时刻起算,
而不是从提交时刻起算)。
/F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算),
/F-E:置 error/canceled 终态时若 worker 已结束(无人再走正常结算),
代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。
终态先到先得——已终态(done/error/canceled)的任务
不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败
@@ -65,15 +94,15 @@ def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
t['msg'] = msg or t.get('msg', '')
t['completed_at'] = time.time()
if status in ('canceled', 'error'):
th = t.get('worker_thread')
if th is not None and not th.is_alive():
wt = t.get('worker_task')
if wt is not None and wt.done():
need_settle = True
# settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库
# settle 移到任务锁外——ledger_settle 内部要抢 SQLite 写锁
# (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆;
# settle 幂等,锁外调用无双退
# settle 幂等,锁外调用无双退。落库是同步阻塞调用,经 to_thread 卸载
if need_settle:
try:
auth_system.ledger_settle(task_id, 0, status)
await asyncio.to_thread(auth_system.ledger_settle, task_id, 0, status)
except Exception:
pass
@@ -88,11 +117,12 @@ def _task_files(task: dict[str, Any]) -> list[str]:
return files
def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
async def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
"""批量任务结算(走持久账本,按实际预扣-实际交付幂等退款;
重复调用/重启后调用都只会退一次)"""
try:
refund = auth_system.ledger_settle(task_id, ok_count, final_status)
refund = await asyncio.to_thread(
auth_system.ledger_settle, task_id, ok_count, final_status)
except Exception:
# 本函数多在异常处理器内被调用——结算再抛错(库忙超时)
# 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等)
@@ -104,11 +134,12 @@ def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'sett
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id) or {}
uid = int(t.get('billing_user_id') or 0)
u = auth_system.get_user_by_id(uid) if uid else None
u = await asyncio.to_thread(auth_system.get_user_by_id, uid) if uid else None
uname = u['username'] if u else 'system'
remaining = u['duration_seconds'] if u else '?'
auth_system.add_log('success', uname, 'system', '批量退费',
f'失败部分 {refund} 次已退回,剩余 {remaining} 次')
await asyncio.to_thread(
auth_system.add_log, 'success', uname, 'system', '批量退费',
f'失败部分 {refund} 次已退回,剩余 {remaining} 次')
except Exception:
pass
return refund
@@ -127,8 +158,8 @@ def _task_work_dir(task_id: str) -> Path:
return path
def _wait_if_paused(task: dict[str, Any], pause_ev: threading.Event,
cancel_ev: threading.Event, done: int) -> None:
async def _wait_if_paused(task: dict[str, Any], pause_ev: threading.Event,
cancel_ev: threading.Event, done: int) -> None:
"""断线暂停闸门:置 paused 态并等待恢复或取消,恢复后回到 running。
两个打包 worker(音乐歌单 / 播客整台)共用同一套暂停语义——此前各自持有
@@ -147,7 +178,7 @@ def _wait_if_paused(task: dict[str, Any], pause_ev: threading.Event,
task['done'] = done
task['msg'] = '已暂停(重新打开页面自动恢复)'
while not cancel_ev.is_set() and (pause_ev.is_set() or task.get('status') == 'paused'):
time.sleep(1)
await asyncio.sleep(1)
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
pause_ev.clear()