[{"data":1,"prerenderedAt":2046},["ShallowReactive",2],{"post-26_06_09\u002Fmain":3},{"id":4,"title":5,"body":6,"cover":2033,"date":2034,"description":53,"draft":2035,"extension":2036,"meta":2037,"navigation":1338,"path":2038,"seo":2039,"stem":2040,"summary":2041,"tags":2042,"__hash__":2045},"posts\u002Fposts\u002F26_06_09\u002Fmain.md","以 QQ 机器人为梳理解异步编程",{"type":7,"value":8,"toc":1995},"minimark",[9,14,21,24,29,32,44,47,77,84,94,97,145,156,158,162,165,174,181,185,251,257,263,293,315,319,360,362,366,377,381,388,423,429,437,461,465,470,499,502,508,511,584,596,598,602,605,609,614,620,626,630,637,657,661,668,672,679,699,709,716,718,722,725,729,740,790,803,818,825,830,834,865,871,878,889,898,943,950,1016,1021,1025,1031,1037,1063,1074,1077,1174,1200,1204,1224,1254,1264,1299,1303,1310,1419,1422,1428,1438,1442,1448,1457,1460,1478,1480,1484,1487,1491,1496,1499,1570,1577,1600,1612,1615,1623,1658,1666,1714,1806,1822,1826,1832,1852,1855,1865,1871,1897,1902,1908,1911,1930,1932,1936,1984,1991],[10,11,13],"h1",{"id":12},"以-qq-机器人为例讲解异步编程","以 QQ 机器人为例讲解异步编程",[15,16,17],"blockquote",{},[18,19,20],"p",{},"从「异步是什么」到「事件循环怎么转」，再到一个真实框架（NcatBot）里的应用，最后用一个真实的定时任务 bug 复盘，把抽象的概念落到能跑的代码上。",[22,23],"hr",{},[25,26,28],"h2",{"id":27},"一为什么需要异步","一、为什么需要异步",[18,30,31],{},"先想一个最朴素的问题：一个 QQ 机器人在干什么？",[18,33,34,35,39,40,43],{},"它绝大部分时间在",[36,37,38],"strong",{},"等","——等 WebSocket 收到下一条消息、等 LLM API 返回、等沙箱里的命令跑完、等一张图片下载完。这些操作的共同点是 ",[36,41,42],{},"I\u002FO 密集","：CPU 几乎不工作，只是干等外部资源。",[18,45,46],{},"如果用同步阻塞的写法：",[48,49,54],"pre",{"className":50,"code":51,"language":52,"meta":53,"style":53},"language-python shiki shiki-themes github-dark github-light","msg = ws.recv()              # 阻塞，等消息（可能等几秒，也可能几分钟）\nreply = llm.chat(msg)        # 阻塞，等 LLM 返回（好几秒）\nws.send(reply)               # 阻塞，等发送完成\n","python","",[55,56,57,65,71],"code",{"__ignoreMap":53},[58,59,62],"span",{"class":60,"line":61},"line",1,[58,63,64],{},"msg = ws.recv()              # 阻塞，等消息（可能等几秒，也可能几分钟）\n",[58,66,68],{"class":60,"line":67},2,[58,69,70],{},"reply = llm.chat(msg)        # 阻塞，等 LLM 返回（好几秒）\n",[58,72,74],{"class":60,"line":73},3,[58,75,76],{},"ws.send(reply)               # 阻塞，等发送完成\n",[18,78,79,80,83],{},"在 ",[55,81,82],{},"ws.recv()"," 等消息的那几分钟里，整个程序什么都做不了——来了第二个群的消息也只能排队干瞪眼。要并发处理多个会话，传统办法是「一个连接一个线程」，但线程有真实的内存和上下文切换开销，几千个会话就会把机器拖垮。",[18,85,86,89,90,93],{},[36,87,88],{},"异步（async）的核心思想","：既然大部分时间都在等 I\u002FO，那就在「等」的时候让出 CPU，去干别的活，等 I\u002FO 好了再回来接着干。这样",[36,91,92],{},"单个线程","就能同时推进成百上千个「等待中」的任务。",[18,95,96],{},"关键区分：",[98,99,100,115],"table",{},[101,102,103],"thead",{},[104,105,106,109,112],"tr",{},[107,108],"th",{},[107,110,111],{},"含义",[107,113,114],{},"适合场景",[116,117,118,132],"tbody",{},[104,119,120,126,129],{},[121,122,123],"td",{},[36,124,125],{},"并发（concurrency）",[121,127,128],{},"多个任务交替推进，宏观上「同时」进行",[121,130,131],{},"I\u002FO 密集（机器人就是典型）",[104,133,134,139,142],{},[121,135,136],{},[36,137,138],{},"并行（parallelism）",[121,140,141],{},"多个任务在多核上真正同时执行",[121,143,144],{},"CPU 密集（大量计算）",[18,146,147,148,151,152,155],{},"Python 的 ",[55,149,150],{},"asyncio"," 提供的是",[36,153,154],{},"单线程并发","：不靠多核，靠在一个线程里飞快地切换任务来「假装同时」。对 I\u002FO 密集的机器人来说，这恰好是最划算的模型。",[22,157],{},[25,159,161],{"id":160},"二事件循环event-loop异步的心脏","二、事件循环（Event Loop）：异步的心脏",[18,163,164],{},"异步代码不会自己跑。背后必须有一个调度器在不停地做这件事：",[15,166,167],{},[18,168,169,170,173],{},"「谁的 I\u002FO 好了 \u002F 谁的定时器到点了 → 让谁继续往下执行；谁遇到 ",[55,171,172],{},"await"," 要等待了 → 把它挂起，切去跑别的。」",[18,175,176,177,180],{},"这个调度器就是 ",[36,178,179],{},"event loop（事件循环）","。",[182,183,184],"h3",{"id":184},"三个核心概念",[98,186,187,200],{},[101,188,189],{},[104,190,191,194,197],{},[107,192,193],{},"概念",[107,195,196],{},"是什么",[107,198,199],{},"类比",[116,201,202,218,238],{},[104,203,204,209,215],{},[121,205,206],{},[36,207,208],{},"coroutine（协程）",[121,210,211,214],{},[55,212,213],{},"async def"," 函数，调用后返回一个「待执行的计划」，本身不会自动运行",[121,216,217],{},"一份写好的菜谱",[104,219,220,225,235],{},[121,221,222],{},[36,223,224],{},"Task（任务）",[121,226,227,230,231,234],{},[55,228,229],{},"asyncio.create_task(coro)"," 把协程",[36,232,233],{},"注册到当前 loop","，loop 才会调度它",[121,236,237],{},"把菜谱交给厨师排进待办单",[104,239,240,245,248],{},[121,241,242],{},[36,243,244],{},"event loop",[121,246,247],{},"真正的调度者，运行期间不断切换、推进所有 Task",[121,249,250],{},"厨房里那个不停切换炉灶的厨师",[182,252,254,256],{"id":253},"await-到底发生了什么",[55,255,172],{}," 到底发生了什么",[18,258,259,262],{},[55,260,261],{},"await some_io()"," 这行代码的语义是：",[264,265,266,270,277,283,286],"ol",{},[267,268,269],"li",{},"「我要等这个 I\u002FO，现在还没好。」",[267,271,272,273,276],{},"把当前协程的执行",[36,274,275],{},"挂起","，记下它停在哪一行。",[267,278,279,280,180],{},"把控制权",[36,281,282],{},"交还给 event loop",[267,284,285],{},"loop 转去推进其他就绪的任务。",[267,287,288,289,292],{},"等这个 I\u002FO 完成，loop 再把这个协程从挂起处",[36,290,291],{},"恢复","执行。",[18,294,295,296,298,299,302,303,306,307,310,311,314],{},"这套「挂起—交还—恢复」就是单线程能并发的全部秘密。注意：",[55,297,172],{}," 让出的前提是它等的东西",[36,300,301],{},"本身是异步的","（如 ",[55,304,305],{},"asyncio.sleep","、异步网络库）。如果你在协程里写了同步阻塞的 ",[55,308,309],{},"time.sleep(5)","，loop 会被这一行",[36,312,313],{},"整个卡死"," 5 秒，所有其他任务一起停摆——这是异步编程最常见的坑。",[182,316,318],{"id":317},"三个必须记住的认知后面-bug-复盘的关键","三个必须记住的认知（后面 bug 复盘的关键）",[264,320,321,334,347],{},[267,322,323,180,326,329,330,333],{},[36,324,325],{},"协程不会自己跑",[55,327,328],{},"f()"," 只是创建协程对象，必须有一个",[36,331,332],{},"正在运行的 loop"," 去驱动。",[267,335,336,342,343,346],{},[36,337,338,341],{},[55,339,340],{},"create_task"," 绑定到「当前正在运行的那个 loop」","——调用那一刻 ",[55,344,345],{},"asyncio.get_running_loop()"," 返回的 loop。",[267,348,349,352,353,356,357,359],{},[36,350,351],{},"loop 一旦停止\u002F关闭，挂在它上面的所有 Task 都不再被调度","。哪怕协程是个 ",[55,354,355],{},"while True","，loop 不转，它就永远卡在某个 ",[55,358,172],{}," 上不动。",[22,361],{},[25,363,365],{"id":364},"三原理对比typescript-浏览器-与-python-的事件循环","三、原理对比：TypeScript \u002F 浏览器 与 Python 的事件循环",[18,367,368,369,372,373,376],{},"很多人是先在前端接触异步的。JS\u002FTS 和 Python 的 ",[55,370,371],{},"async\u002Fawait"," ",[36,374,375],{},"语法几乎一样","，但底层调度模型有一个值得一提的区别。",[182,378,380],{"id":379},"js-浏览器宏任务-微任务两个队列","JS \u002F 浏览器：宏任务 + 微任务两个队列",[18,382,383,384,387],{},"浏览器的事件循环把待执行的回调分成",[36,385,386],{},"两类队列","：",[389,390,391,404],"ul",{},[267,392,393,387,396,399,400,403],{},[36,394,395],{},"宏任务（macrotask）队列",[55,397,398],{},"setTimeout","、",[55,401,402],{},"setInterval","、I\u002FO、UI 渲染事件等。",[267,405,406,387,409,412,413,415,416,399,419,422],{},[36,407,408],{},"微任务（microtask）队列",[55,410,411],{},"Promise.then"," 回调、",[55,414,172],{}," 之后的续体、",[55,417,418],{},"queueMicrotask",[55,420,421],{},"MutationObserver"," 等。",[18,424,425,426],{},"调度规则是：",[36,427,428],{},"每执行完一个宏任务，就把当前微任务队列里的任务全部清空，然后才考虑渲染、再取下一个宏任务。",[48,430,435],{"className":431,"code":433,"language":434,"meta":53},[432],"language-text","取一个宏任务执行\n  └─ 执行过程中产生的微任务进微任务队列\n执行完这个宏任务\n  └─ 把微任务队列「一次性全部排空」（期间新产生的微任务也一起处理掉）\n  └─ （浏览器）此时可能进行一次渲染\n取下一个宏任务……\n","text",[55,436,433],{"__ignoreMap":53},[18,438,439,440,443,444,447,448,450,451,454,455,457,458,460],{},"为什么要分两个队列？很大程度上正是",[36,441,442],{},"为了浏览器渲染","。浏览器希望「一组逻辑上相关的、原子的更新」在同一帧内一起完成，不要被渲染打断到中间状态。微任务队列保证了：一个宏任务触发的所有连锁 Promise 回调，会在",[36,445,446],{},"下一次渲染之前一次性跑完","，从而把这些更新「绑定到一起」原子地呈现，避免画面闪烁或撕裂。",[55,449,172],{}," 的续体走的是微任务，所以它的优先级高于 ",[55,452,453],{},"setTimeout(0)"," 这种宏任务——这就是经典面试题「",[55,456,411],{}," 比 ",[55,459,398],{}," 先执行」的根源。",[182,462,464],{"id":463},"python-asyncio单一就绪队列-定时器堆","Python asyncio：单一就绪队列 + 定时器堆",[18,466,147,467,469],{},[55,468,150],{}," 没有「宏\u002F微任务」这种二分。它的模型更直接：",[389,471,472,479,493],{},[267,473,474,475,478],{},"一个 ",[36,476,477],{},"ready 队列","：所有当前就绪、可以立刻继续跑的回调\u002FTask。",[267,480,481,482,485,486,489,490,492],{},"一个按时间排序的",[36,483,484],{},"定时器结构","（最小堆）：",[55,487,488],{},"call_later"," \u002F ",[55,491,305],{}," 注册的「到点才就绪」的回调。",[267,494,474,495,498],{},[36,496,497],{},"selector","：通过操作系统的 I\u002FO 多路复用（epoll\u002Fkqueue\u002FIOCP）监听 fd 是否就绪。",[18,500,501],{},"每一轮循环（一次 \"tick\"）大致是：",[48,503,506],{"className":504,"code":505,"language":434,"meta":53},[432],"1. 算出最近的定时器还有多久到点 → 作为本轮 select 的超时时间\n2. select(timeout)：等 I\u002FO 事件，或等到最近的定时器到点\n3. 把「I\u002FO 就绪的回调」和「已到点的定时器回调」放进 ready 队列\n4. 把 ready 队列里当前的回调「逐个执行完」\n5. 回到第 1 步\n",[55,507,505],{"__ignoreMap":53},[18,509,510],{},"和 JS 的根本区别：",[98,512,513,526],{},[101,514,515],{},[104,516,517,520,523],{},[107,518,519],{},"维度",[107,521,522],{},"JS \u002F 浏览器",[107,524,525],{},"Python asyncio",[116,527,528,539,554,573],{},[104,529,530,533,536],{},[121,531,532],{},"队列结构",[121,534,535],{},"宏任务队列 + 微任务队列（二分）",[121,537,538],{},"单一 ready 队列 + 定时器堆",[104,540,541,544,551],{},[121,542,543],{},"设计动机",[121,545,546,547,550],{},"与",[36,548,549],{},"渲染","协调，把原子更新绑定在一帧内",[121,552,553],{},"纯后端调度，无渲染概念，模型更扁平",[104,555,556,561,570],{},[121,557,558,560],{},[55,559,172],{}," 续体优先级",[121,562,563,564,372,567,569],{},"走微任务，",[36,565,566],{},"高于",[55,568,398],{}," 等宏任务",[121,571,572],{},"就是普通就绪回调，进同一个 ready 队列",[104,574,575,578,581],{},[121,576,577],{},"「下一个 tick」语义",[121,579,580],{},"微任务排空 → （渲染）→ 下个宏任务",[121,582,583],{},"一轮 select + 执行 ready 回调",[18,585,586,589,590,592,593,595],{},[36,587,588],{},"JS 的两队列模型是被浏览器渲染需求「逼」出来的；Python 没有渲染负担，所以用了一个更扁平的单队列 + 定时器堆模型。"," 但两者对应用层开发者暴露的 ",[55,591,371],{}," 心智模型是高度一致的——遇到 ",[55,594,172],{}," 让出、I\u002FO 好了恢复。",[22,597],{},[25,599,601],{"id":600},"四异步的应用一个真实的-qq-机器人框架","四、异步的应用：一个真实的 QQ 机器人框架",[18,603,604],{},"来看异步在真实项目里怎么用。下面以我正在开发的基于 NcatBot 的 QQ AI Agent 框架（AEsirClaw）为例。",[182,606,608],{"id":607},"_41-整条链路都是异步的","4.1 整条链路都是异步的",[18,610,611,612,387],{},"一条 QQ 消息从进来到回复，几乎每一环都在 ",[55,613,172],{},[48,615,618],{"className":616,"code":617,"language":434,"meta":53},[432],"QQ 消息 ──→ WebSocket 收包（await recv）\n                │\n                ▼\n           事件分发 → 防抖调度（await sleep 等窗口）\n                │\n                ▼\n           组装上下文 → Agent Loop ◄──► LLM API（await chat）\n                │\n                ├── execute_task → Docker 沙箱（await 执行）\n                └── send_group_msg → QQ（await 发送 + await 打字延迟）\n",[55,619,617],{"__ignoreMap":53},[18,621,622,623,625],{},"每个 ",[55,624,172],{}," 都是一次「让出 CPU 去处理别的会话」的机会。正因如此，单线程就能同时服务多个群和私聊：A 群在等 LLM 返回时，loop 顺手就把 B 群刚到的消息推进了。",[182,627,629],{"id":628},"_42-异步让拟人化变得自然","4.2 异步让「拟人化」变得自然",[18,631,632,633,636],{},"机器人为了模拟真人打字节奏，会分段发送、每段之间停顿。同步写法里「停顿」意味着卡死整个程序；异步里它只是一次 ",[55,634,635],{},"await asyncio.sleep","，停顿期间 loop 照样服务别人：",[48,638,640],{"className":50,"code":639,"language":52,"meta":53,"style":53},"for seg in messages:\n    await send(seg)\n    await asyncio.sleep(字数 * delay + 随机波动)   # 停顿，但不阻塞其他会话\n",[55,641,642,647,652],{"__ignoreMap":53},[58,643,644],{"class":60,"line":61},[58,645,646],{},"for seg in messages:\n",[58,648,649],{"class":60,"line":67},[58,650,651],{},"    await send(seg)\n",[58,653,654],{"class":60,"line":73},[58,655,656],{},"    await asyncio.sleep(字数 * delay + 随机波动)   # 停顿，但不阻塞其他会话\n",[182,658,660],{"id":659},"_43-防抖用异步实现听完再说","4.3 防抖：用异步实现「听完再说」",[18,662,663,664,667],{},"人聊天习惯是连发好几条，机器人不该逐条机械回复。框架用一个异步防抖器：首条消息到达后启动一个 5 秒窗口（",[55,665,666],{},"await asyncio.sleep(5)","），窗口期间的新消息只刷新「候选」，窗口结束才取最新候选统一处理。整个等待逻辑就是一个挂在 loop 上的协程，等待期间完全不占用 CPU。",[182,669,671],{"id":670},"_44-框架如何托管-event-loop","4.4 框架如何托管 event loop",[18,673,674,675,678],{},"应用层开发者通常不自己写 ",[55,676,677],{},"asyncio.run","，而是框架替你把 loop 跑起来。NcatBot 的入口极简：",[48,680,682],{"className":50,"code":681,"language":52,"meta":53,"style":53},"from ncatbot.core import BotClient\nbot = BotClient()\nbot.run_frontend()   # 一切从这里开始\n",[55,683,684,689,694],{"__ignoreMap":53},[58,685,686],{"class":60,"line":61},[58,687,688],{},"from ncatbot.core import BotClient\n",[58,690,691],{"class":60,"line":67},[58,692,693],{},"bot = BotClient()\n",[58,695,696],{"class":60,"line":73},[58,697,698],{},"bot.run_frontend()   # 一切从这里开始\n",[18,700,701,704,705,708],{},[55,702,703],{},"run_frontend()"," 内部最终会进入一个长驻的事件循环，由它统一驱动所有异步任务。",[36,706,707],{},"理解「框架在哪里、用哪个 loop 跑你的代码」，是用好异步框架的关键","——下一节的 bug 正是栽在这上面。",[18,710,711,712,715],{},"这里先埋一个伏笔：NcatBot 在启动过程中其实会创建",[36,713,714],{},"不止一个"," event loop，它们各司其职、互相独立。记住这一点，往下看。",[22,717],{},[25,719,721],{"id":720},"五实践定时任务的实现与一次真实-bug-复盘","五、实践：定时任务的实现与一次真实 bug 复盘",[18,723,724],{},"需求很常见：让 QQ 机器人能自主设定定时任务（「每天早上 8 点拉一遍新闻」「每隔一小时报时」「10 分钟后提醒我」），到点后机器人主动在原会话里发言。",[182,726,728],{"id":727},"_51-调度器的设计","5.1 调度器的设计",[18,730,731,732,735,736,739],{},"核心是一个 ",[55,733,734],{},"TaskScheduler","：用 JSON 文件持久化任务，挂一个",[36,737,738],{},"常驻的异步扫描循环","，每隔 N 秒扫一遍，把到期的任务触发掉。扫描循环长这样：",[48,741,743],{"className":50,"code":742,"language":52,"meta":53,"style":53},"async def _scan_loop(self) -> None:\n    \"\"\"常驻循环：周期性扫描到期任务并触发。\"\"\"\n    while True:\n        try:\n            await asyncio.sleep(self.scan_interval)\n            await self._tick()                 # 扫描 + 触发到期任务\n        except asyncio.CancelledError:\n            break\n",[55,744,745,750,755,760,766,772,778,784],{"__ignoreMap":53},[58,746,747],{"class":60,"line":61},[58,748,749],{},"async def _scan_loop(self) -> None:\n",[58,751,752],{"class":60,"line":67},[58,753,754],{},"    \"\"\"常驻循环：周期性扫描到期任务并触发。\"\"\"\n",[58,756,757],{"class":60,"line":73},[58,758,759],{},"    while True:\n",[58,761,763],{"class":60,"line":762},4,[58,764,765],{},"        try:\n",[58,767,769],{"class":60,"line":768},5,[58,770,771],{},"            await asyncio.sleep(self.scan_interval)\n",[58,773,775],{"class":60,"line":774},6,[58,776,777],{},"            await self._tick()                 # 扫描 + 触发到期任务\n",[58,779,781],{"class":60,"line":780},7,[58,782,783],{},"        except asyncio.CancelledError:\n",[58,785,787],{"class":60,"line":786},8,[58,788,789],{},"            break\n",[18,791,792,793,795,796,798,799,802],{},"这是个标准的异步后台循环：",[55,794,355],{}," + ",[55,797,635],{},"。它必须被某个",[36,800,801],{},"持续运行的 loop"," 调度，才能一轮一轮地转下去。启动它的代码也很直觉：",[48,804,806],{"className":50,"code":805,"language":52,"meta":53,"style":53},"def start(self) -> None:\n    self._loop_task = asyncio.create_task(self._scan_loop())\n",[55,807,808,813],{"__ignoreMap":53},[58,809,810],{"class":60,"line":61},[58,811,812],{},"def start(self) -> None:\n",[58,814,815],{"class":60,"line":67},[58,816,817],{},"    self._loop_task = asyncio.create_task(self._scan_loop())\n",[18,819,820,821,824],{},"到点触发时，它调用一个回调，往会话里注入一条「",[58,822,823],{},"定时任务触发","」系统消息，再唤醒一次 Agent Loop，让机器人自己决定怎么发言。整个机制干净利落——逻辑测试全过，任务也能正常写进 JSON 文件。",[18,826,827],{},[36,828,829],{},"然后部署上去，到点了……什么都没发生。",[182,831,833],{"id":832},"_52-现象","5.2 现象",[389,835,836,847,854],{},[267,837,838,839,842,843,846],{},"任务确实被创建了：",[55,840,841],{},"schedules.json"," 里躺着两条 ",[55,844,845],{},"daily"," 任务。",[267,848,849,850,853],{},"时间早就过了，",[55,851,852],{},"next_run"," 时间戳算得也对。",[267,855,856,857,864],{},"但日志里",[36,858,859,860,863],{},"没有任何 ",[55,861,862],{},"[Scheduler] 触发任务"," 的记录","，连「扫描器已启动」之后的活动都看不到。",[18,866,867,868,180],{},"任务能存、时间能算，就是不触发。问题不在逻辑，而在",[36,869,870],{},"那个扫描循环根本没在转",[182,872,874,875,877],{"id":873},"_53-根因create_task-挂错了-loop","5.3 根因：",[55,876,340],{}," 挂错了 loop",[18,879,880,881,884,885,888],{},"我最初把 ",[55,882,883],{},"scheduler.start()"," 放在了插件的 ",[55,886,887],{},"on_load","（插件加载时的初始化钩子）里。看起来天经地义——加载时启动调度器，对吧？",[18,890,891,892,897],{},"错。关键在于框架",[36,893,894,895],{},"怎么调用 ",[55,896,887],{},"。扒开 NcatBot 的插件加载器：",[48,899,901],{"className":50,"code":900,"language":52,"meta":53,"style":53},"# ncatbot\u002Fplugin_system\u002Floader.py\ndef _run_init():\n    loop = asyncio.new_event_loop()              # ← 新建一个临时 loop\n    asyncio.set_event_loop(loop)\n    try:\n        loop.run_until_complete(plugin.__onload__())   # ← 跑你的 on_load\n    finally:\n        loop.close()                             # ← 跑完立刻关闭这个 loop！\n",[55,902,903,908,913,918,923,928,933,938],{"__ignoreMap":53},[58,904,905],{"class":60,"line":61},[58,906,907],{},"# ncatbot\u002Fplugin_system\u002Floader.py\n",[58,909,910],{"class":60,"line":67},[58,911,912],{},"def _run_init():\n",[58,914,915],{"class":60,"line":73},[58,916,917],{},"    loop = asyncio.new_event_loop()              # ← 新建一个临时 loop\n",[58,919,920],{"class":60,"line":762},[58,921,922],{},"    asyncio.set_event_loop(loop)\n",[58,924,925],{"class":60,"line":768},[58,926,927],{},"    try:\n",[58,929,930],{"class":60,"line":774},[58,931,932],{},"        loop.run_until_complete(plugin.__onload__())   # ← 跑你的 on_load\n",[58,934,935],{"class":60,"line":780},[58,936,937],{},"    finally:\n",[58,939,940],{"class":60,"line":786},[58,941,942],{},"        loop.close()                             # ← 跑完立刻关闭这个 loop！\n",[18,944,945,946,949],{},"而且这一切还被丢进",[36,947,948],{},"一个独立线程","里执行。问题链条于是浮现：",[264,951,952,961,975,991,1002],{},[267,953,954,956,957,960],{},[55,955,887],{}," 运行在这个",[36,958,959],{},"临时 loop","（Loop ①）上。",[267,962,963,966,967,970,971,974],{},[55,964,965],{},"start()"," 里的 ",[55,968,969],{},"asyncio.create_task(_scan_loop())"," 把扫描循环",[36,972,973],{},"绑定到了 Loop ①","（回忆第二节的认知 2：create_task 绑定当前正在跑的 loop）。",[267,976,977,979,980,983,984,987,988,180],{},[55,978,887],{}," 一返回，",[55,981,982],{},"run_until_complete"," 结束，紧接着 ",[55,985,986],{},"loop.close()"," —— ",[36,989,990],{},"Loop ① 死亡",[267,992,993,994,997,998,1001],{},"扫描循环卡在第一个 ",[55,995,996],{},"await asyncio.sleep(...)"," 上，",[36,999,1000],{},"再也没有 loop 去唤醒它","（认知 3）。",[267,1003,1004,1005,1008,1009,1012,1013,180],{},"而真正长驻的业务主 loop（Loop ②，由 ",[55,1006,1007],{},"run_frontend"," 内部的 ",[55,1010,1011],{},"asyncio.run(connect_websocket())"," 驱动）上，",[36,1014,1015],{},"压根没有这个扫描任务",[18,1017,1018],{},[36,1019,1020],{},"任务被挂在了一个用完即弃的 loop 上，那个 loop 关了之后，任务就成了无人调度的僵尸。",[182,1022,1024],{"id":1023},"_54-框架里到底有几个-loop","5.4 框架里到底有几个 loop",[18,1026,1027,1028,1030],{},"把 ",[55,1029,703],{}," 的链路摊开看，就能数清楚：",[48,1032,1035],{"className":1033,"code":1034,"language":434,"meta":53},[432],"run_frontend()\n  └─ start()\n       ├─ 加载插件\n       │    └─ 独立线程\n       │         └─ Loop ① = asyncio.new_event_loop()    ← 临时\n       │              └─ run_until_complete(on_load)       ← ❌ 我最初在这里 create_task\n       │         └─ loop.close()                           ← Loop ① 死亡，扫描循环冻结\n       │\n       └─ asyncio.run(connect_websocket())\n            └─ Loop ② = 长驻主循环                          ← ✅ 应该挂在这里\n                 ├─ while True: recv() → 分发 QQ 消息\n                 └─ 触发 STARTUP 事件 → _on_bot_ready       ← 这里 start() 才对\n",[55,1036,1034],{"__ignoreMap":53},[389,1038,1039,1054],{},[267,1040,1041,1044,1045,1047,1048,1050,1051,180],{},[36,1042,1043],{},"Loop ①（临时）","：只为跑一次性的 ",[55,1046,887],{}," 初始化，",[55,1049,982],{}," 完就 ",[55,1052,1053],{},"close",[267,1055,1056,1059,1060,1062],{},[36,1057,1058],{},"Loop ②（长驻）","：bot 的命脉，跑 ",[55,1061,355],{}," 收消息，所有 QQ 事件、启动回调都在它身上。",[182,1064,1066,1067,1070,1071,1073],{"id":1065},"_55-looprun_until_complete-vs-run_frontend-的本质区别","5.5 ",[55,1068,1069],{},"loop.run_until_complete"," vs ",[55,1072,703],{}," 的本质区别",[18,1075,1076],{},"这两者处在完全不同的抽象层级，正好对照理解：",[98,1078,1079,1094],{},[101,1080,1081],{},[104,1082,1083,1085,1090],{},[107,1084,519],{},[107,1086,1087],{},[55,1088,1089],{},"loop.run_until_complete(coro)",[107,1091,1092],{},[55,1093,703],{},[116,1095,1096,1115,1135,1149,1164],{},[104,1097,1098,1101,1108],{},[121,1099,1100],{},"本质",[121,1102,1103,1104,1107],{},"event loop 的",[36,1105,1106],{},"底层方法","：跑到指定协程完成就停",[121,1109,1110,1111,1114],{},"框架的",[36,1112,1113],{},"顶层入口","：启动整个 bot 并阻塞到退出",[104,1116,1117,1120,1126],{},[121,1118,1119],{},"生命周期",[121,1121,1122,1125],{},[36,1123,1124],{},"短暂","，跑完一个协程就返回（随后被 close）",[121,1127,1128,1131,1132,1134],{},[36,1129,1130],{},"长驻","，内部进 ",[55,1133,355],{}," 收消息，正常永不返回",[104,1136,1137,1140,1143],{},[121,1138,1139],{},"用的 loop",[121,1141,1142],{},"临时新建的 Loop ①，用完即弃",[121,1144,1145,1146,1148],{},"内部 ",[55,1147,677],{}," 独占运行的 Loop ②",[104,1150,1151,1154,1157],{},[121,1152,1153],{},"适合放什么",[121,1155,1156],{},"一次性、跑完就结束的初始化",[121,1158,1159,1160,1163],{},"需要",[36,1161,1162],{},"长期存活","的后台任务应挂在它的 loop 上",[104,1165,1166,1168,1171],{},[121,1167,199],{},[121,1169,1170],{},"临时点火烧开一壶水就熄火",[121,1172,1173],{},"常明的灶火，整晚不灭",[18,1175,1176,1177,1180,1181,1184,1185,1188,1189,1192,1193,1196,1197,1199],{},"补一个常被忽略的关系：",[55,1178,1179],{},"asyncio.run(coro)"," ≈ 「新建 loop + ",[55,1182,1183],{},"run_until_complete(coro)"," + 最后 ",[55,1186,1187],{},"close()","」。所以二者底层是同一类操作，区别只在跑的协程",[36,1190,1191],{},"会不会很快结束","——",[55,1194,1195],{},"connect_websocket"," 是死循环（loop 长驻），",[55,1198,887],{}," 很快结束（loop 短命）。",[182,1201,1203],{"id":1202},"_56-修复","5.6 修复",[18,1205,1027,1206,1208,1209,1211,1212,1215,1216,1219,1220,1223],{},[55,1207,965],{}," 从 ",[55,1210,887],{}," 挪到 ",[55,1213,1214],{},"_on_bot_ready","（",[55,1217,1218],{},"OFFICIAL_STARTUP_EVENT"," 的回调，",[36,1221,1222],{},"运行在长驻主 loop Loop ② 上","）：",[48,1225,1227],{"className":50,"code":1226,"language":52,"meta":53,"style":53},"async def _on_bot_ready(self, event):\n    \"\"\"Bot 连接成功后加载历史消息，并在主 loop 上启动定时任务调度器。\"\"\"\n    await self._load_recent_messages()\n    # 在 bot 主 event loop 上启动调度器（此回调即运行于该 loop）\n    self.scheduler.start()\n",[55,1228,1229,1234,1239,1244,1249],{"__ignoreMap":53},[58,1230,1231],{"class":60,"line":61},[58,1232,1233],{},"async def _on_bot_ready(self, event):\n",[58,1235,1236],{"class":60,"line":67},[58,1237,1238],{},"    \"\"\"Bot 连接成功后加载历史消息，并在主 loop 上启动定时任务调度器。\"\"\"\n",[58,1240,1241],{"class":60,"line":73},[58,1242,1243],{},"    await self._load_recent_messages()\n",[58,1245,1246],{"class":60,"line":762},[58,1247,1248],{},"    # 在 bot 主 event loop 上启动调度器（此回调即运行于该 loop）\n",[58,1250,1251],{"class":60,"line":768},[58,1252,1253],{},"    self.scheduler.start()\n",[18,1255,1256,1257,1259,1260,1263],{},"而 ",[55,1258,887],{}," 里只保留「构造对象、注册回调」这类",[36,1261,1262],{},"不依赖 loop 存活","的同步初始化，并留下注释警示后人：",[48,1265,1267],{"className":50,"code":1266,"language":52,"meta":53,"style":53},"# 注意：on_load 由框架在独立线程的临时 event loop 上执行\n# （loader.py: loop.run_until_complete(...) 后 loop.close()），\n# 因此这里只构造调度器，绝不能在此 start()——否则扫描协程会被挂到\n# 那个用完即弃的 loop 上，永远不会被调度。真正的启动放在 _on_bot_ready 中。\nself.scheduler = TaskScheduler(...)\nself.scheduler.set_callback(self._on_scheduled_trigger)\n",[55,1268,1269,1274,1279,1284,1289,1294],{"__ignoreMap":53},[58,1270,1271],{"class":60,"line":61},[58,1272,1273],{},"# 注意：on_load 由框架在独立线程的临时 event loop 上执行\n",[58,1275,1276],{"class":60,"line":67},[58,1277,1278],{},"# （loader.py: loop.run_until_complete(...) 后 loop.close()），\n",[58,1280,1281],{"class":60,"line":73},[58,1282,1283],{},"# 因此这里只构造调度器，绝不能在此 start()——否则扫描协程会被挂到\n",[58,1285,1286],{"class":60,"line":762},[58,1287,1288],{},"# 那个用完即弃的 loop 上，永远不会被调度。真正的启动放在 _on_bot_ready 中。\n",[58,1290,1291],{"class":60,"line":768},[58,1292,1293],{},"self.scheduler = TaskScheduler(...)\n",[58,1295,1296],{"class":60,"line":774},[58,1297,1298],{},"self.scheduler.set_callback(self._on_scheduled_trigger)\n",[182,1300,1302],{"id":1301},"_57-最小复现一眼看穿","5.7 最小复现：一眼看穿",[18,1304,1305,1306,1309],{},"为了把这个机制钉死，写一个不依赖框架的十几行 demo（项目里 ",[55,1307,1308],{},"test\u002Ftest_async.py","），对比两种 loop：",[48,1311,1313],{"className":50,"code":1312,"language":52,"meta":53,"style":53},"async def background_loop(tag):\n    while True:\n        await asyncio.sleep(0.2)\n        fired.append(tag)\n\n# 场景 A：临时 loop —— 等价于 on_load\ndef scenario_temp_loop():\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    loop.run_until_complete(fake_on_load())   # 内部 create_task(background_loop)\n    loop.close()                              # ← loop 关闭\n    time.sleep(1.0)                           # 等一秒，看后台循环跑没跑\n\n# 场景 B：长驻 loop —— 等价于主 loop\ndef scenario_long_running_loop():\n    async def main():\n        asyncio.create_task(background_loop(\"B\"))\n        await asyncio.sleep(1.0)              # loop 持续运转 1 秒\n    asyncio.run(main())\n",[55,1314,1315,1320,1324,1329,1334,1340,1345,1350,1355,1360,1366,1372,1378,1383,1389,1395,1401,1407,1413],{"__ignoreMap":53},[58,1316,1317],{"class":60,"line":61},[58,1318,1319],{},"async def background_loop(tag):\n",[58,1321,1322],{"class":60,"line":67},[58,1323,759],{},[58,1325,1326],{"class":60,"line":73},[58,1327,1328],{},"        await asyncio.sleep(0.2)\n",[58,1330,1331],{"class":60,"line":762},[58,1332,1333],{},"        fired.append(tag)\n",[58,1335,1336],{"class":60,"line":768},[58,1337,1339],{"emptyLinePlaceholder":1338},true,"\n",[58,1341,1342],{"class":60,"line":774},[58,1343,1344],{},"# 场景 A：临时 loop —— 等价于 on_load\n",[58,1346,1347],{"class":60,"line":780},[58,1348,1349],{},"def scenario_temp_loop():\n",[58,1351,1352],{"class":60,"line":786},[58,1353,1354],{},"    loop = asyncio.new_event_loop()\n",[58,1356,1358],{"class":60,"line":1357},9,[58,1359,922],{},[58,1361,1363],{"class":60,"line":1362},10,[58,1364,1365],{},"    loop.run_until_complete(fake_on_load())   # 内部 create_task(background_loop)\n",[58,1367,1369],{"class":60,"line":1368},11,[58,1370,1371],{},"    loop.close()                              # ← loop 关闭\n",[58,1373,1375],{"class":60,"line":1374},12,[58,1376,1377],{},"    time.sleep(1.0)                           # 等一秒，看后台循环跑没跑\n",[58,1379,1381],{"class":60,"line":1380},13,[58,1382,1339],{"emptyLinePlaceholder":1338},[58,1384,1386],{"class":60,"line":1385},14,[58,1387,1388],{},"# 场景 B：长驻 loop —— 等价于主 loop\n",[58,1390,1392],{"class":60,"line":1391},15,[58,1393,1394],{},"def scenario_long_running_loop():\n",[58,1396,1398],{"class":60,"line":1397},16,[58,1399,1400],{},"    async def main():\n",[58,1402,1404],{"class":60,"line":1403},17,[58,1405,1406],{},"        asyncio.create_task(background_loop(\"B\"))\n",[58,1408,1410],{"class":60,"line":1409},18,[58,1411,1412],{},"        await asyncio.sleep(1.0)              # loop 持续运转 1 秒\n",[58,1414,1416],{"class":60,"line":1415},19,[58,1417,1418],{},"    asyncio.run(main())\n",[18,1420,1421],{},"运行结果：",[48,1423,1426],{"className":1424,"code":1425,"language":434,"meta":53},[432],"【场景 A：临时 loop（用完即弃）】\n  >>> 结果：后台循环执行了 0 次（loop 已关闭，无人调度）\n\n【场景 B：长驻 loop】\n  [B-长驻loop] 后台循环执行了一次 ...   (×4)\n  >>> 结果：后台循环执行了 4 次（loop 持续运转，正常调度）\n",[55,1427,1425],{"__ignoreMap":53},[18,1429,1430,1437],{},[36,1431,1432,1433,1436],{},"完全相同的 ",[55,1434,1435],{},"create_task(background_loop)","，差别只在「它绑定的 loop 是否持续运行」。"," 场景 A 的 0 次，就是线上「定时任务永不触发」的精确缩影；场景 B 的 4 次，就是修复后扫描循环每 20 秒正常扫一遍的样子。",[182,1439,1441],{"id":1440},"_58-经验法则","5.8 经验法则",[18,1443,1444,1445,1447],{},"在「框架托管 event loop」的场景下，判断「后台任务该在哪 ",[55,1446,340],{},"」只需一条：",[15,1449,1450],{},[18,1451,1452],{},[36,1453,1454,1456],{},[55,1455,340],{}," 必须在「那个会长期活着的 loop」正在运行时调用。",[18,1458,1459],{},"具体到这类机器人框架：",[389,1461,1462,1472,1475],{},[267,1463,1464,1465,1467,1468,1471],{},"❌ 不要在 ",[55,1466,887],{},"\u002F",[55,1469,1470],{},"__init__"," 这类「初始化完就结束」的地方起长驻后台任务。",[267,1473,1474],{},"✅ 在「bot 就绪 \u002F 连接成功」的事件回调里起——那才是长驻主 loop。",[267,1476,1477],{},"快速自检：如果你的任务需要「一直跑」，但它所在的函数是「跑完就返回」的，那它大概率挂错了 loop。",[22,1479],{},[25,1481,1483],{"id":1482},"六延伸思考一个程序能有几个-event-loop","六、延伸思考：一个程序能有几个 event loop？",[18,1485,1486],{},"bug 复盘里反复出现「临时 loop」「长驻 loop」，自然引出三个递进的问题。",[182,1488,1490],{"id":1489},"_61-异步只能有一个-event-loop-吗","6.1 异步只能有一个 event loop 吗？",[18,1492,1493],{},[36,1494,1495],{},"不是。但有一条铁律：每个线程在同一时刻，最多只能有一个「正在运行」的 event loop。",[18,1497,1498],{},"「能创建几个」和「能同时运行几个」是两回事：",[98,1500,1501,1510],{},[101,1502,1503],{},[104,1504,1505,1507],{},[107,1506,519],{},[107,1508,1509],{},"答案",[116,1511,1512,1528,1543,1555],{},[104,1513,1514,1521],{},[121,1515,1516,1517,1520],{},"一个程序能",[36,1518,1519],{},"创建","几个 loop？",[121,1522,1523,1524,1527],{},"任意多个，",[55,1525,1526],{},"asyncio.new_event_loop()"," 想建几个建几个",[104,1529,1530,1536],{},[121,1531,1532,1533,1520],{},"一个线程能同时",[36,1534,1535],{},"运行",[121,1537,1538,1539,1542],{},"只能 1 个，loop 是阻塞式运行的，",[55,1540,1541],{},"loop.run_xxx()"," 没返回前线程被它占着",[104,1544,1545,1552],{},[121,1546,1547,1548,1551],{},"多个 loop 能在",[36,1549,1550],{},"不同线程","里同时跑吗？",[121,1553,1554],{},"可以，每个线程跑自己的 loop，互不干扰",[104,1556,1557,1564],{},[121,1558,1559,1560,1563],{},"loop 能",[36,1561,1562],{},"先后","在同一线程跑吗？",[121,1565,1566,1567,1569],{},"可以，跑完一个 ",[55,1568,1053],{}," 掉，再建一个跑——这正是 NcatBot 干的事",[18,1571,1572,1573,1576],{},"关键限制来自一个",[36,1574,1575],{},"线程局部","的概念「当前线程的 event loop」：",[389,1578,1579,1588],{},[267,1580,1581,1583,1584,1587],{},[55,1582,345],{}," 返回",[36,1585,1586],{},"此刻正在跑","的那个 loop。",[267,1589,1590,1592,1593,1596,1597,1599],{},[55,1591,229],{}," 把任务绑定到它返回的那个 loop——",[36,1594,1595],{},"这就是定时任务 bug 的命门","：你在哪个 loop 运行时调 ",[55,1598,340],{},"，任务就绑给哪个 loop。",[182,1601,1603,1604,1607,1608,1611],{"id":1602},"_62-new_event_loop-和-asynciorunmain-的区别","6.2 ",[55,1605,1606],{},"new_event_loop()"," 和 ",[55,1609,1610],{},"asyncio.run(main())"," 的区别",[18,1613,1614],{},"这俩不在一个层级——一个是「底层手动挡」，一个是「高层自动挡」。",[18,1616,1617,1622],{},[36,1618,1619,1621],{},[55,1620,1526],{},"：只「造」一个 loop","，不运行任何东西，生命周期全靠你：",[48,1624,1626],{"className":50,"code":1625,"language":52,"meta":53,"style":53},"loop = asyncio.new_event_loop()        # 1. 造\nasyncio.set_event_loop(loop)           # 2. 设为本线程当前 loop\ntry:\n    loop.run_until_complete(main())    # 3. 手动驱动\nfinally:\n    loop.close()                       # 4. 手动关（不关就泄漏）\n",[55,1627,1628,1633,1638,1643,1648,1653],{"__ignoreMap":53},[58,1629,1630],{"class":60,"line":61},[58,1631,1632],{},"loop = asyncio.new_event_loop()        # 1. 造\n",[58,1634,1635],{"class":60,"line":67},[58,1636,1637],{},"asyncio.set_event_loop(loop)           # 2. 设为本线程当前 loop\n",[58,1639,1640],{"class":60,"line":73},[58,1641,1642],{},"try:\n",[58,1644,1645],{"class":60,"line":762},[58,1646,1647],{},"    loop.run_until_complete(main())    # 3. 手动驱动\n",[58,1649,1650],{"class":60,"line":768},[58,1651,1652],{},"finally:\n",[58,1654,1655],{"class":60,"line":774},[58,1656,1657],{},"    loop.close()                       # 4. 手动关（不关就泄漏）\n",[18,1659,1660,1665],{},[36,1661,1662,1664],{},[55,1663,1610],{},"：造 + 跑 + 清理 + 关，一条龙","。等价于（简化）：",[48,1667,1669],{"className":50,"code":1668,"language":52,"meta":53,"style":53},"def run(main):\n    loop = asyncio.new_event_loop()                    # 造\n    try:\n        asyncio.set_event_loop(loop)\n        return loop.run_until_complete(main)           # 跑到完成\n    finally:\n        loop.run_until_complete(loop.shutdown_asyncgens())  # 清理残留 task \u002F async gen\n        asyncio.set_event_loop(None)\n        loop.close()                                   # 关\n",[55,1670,1671,1676,1681,1685,1690,1695,1699,1704,1709],{"__ignoreMap":53},[58,1672,1673],{"class":60,"line":61},[58,1674,1675],{},"def run(main):\n",[58,1677,1678],{"class":60,"line":67},[58,1679,1680],{},"    loop = asyncio.new_event_loop()                    # 造\n",[58,1682,1683],{"class":60,"line":73},[58,1684,927],{},[58,1686,1687],{"class":60,"line":762},[58,1688,1689],{},"        asyncio.set_event_loop(loop)\n",[58,1691,1692],{"class":60,"line":768},[58,1693,1694],{},"        return loop.run_until_complete(main)           # 跑到完成\n",[58,1696,1697],{"class":60,"line":774},[58,1698,937],{},[58,1700,1701],{"class":60,"line":780},[58,1702,1703],{},"        loop.run_until_complete(loop.shutdown_asyncgens())  # 清理残留 task \u002F async gen\n",[58,1705,1706],{"class":60,"line":786},[58,1707,1708],{},"        asyncio.set_event_loop(None)\n",[58,1710,1711],{"class":60,"line":1357},[58,1712,1713],{},"        loop.close()                                   # 关\n",[98,1715,1716,1730],{},[101,1717,1718],{},[104,1719,1720,1722,1726],{},[107,1721],{},[107,1723,1724],{},[55,1725,1606],{},[107,1727,1728],{},[55,1729,1610],{},[116,1731,1732,1743,1754,1767,1778,1795],{},[104,1733,1734,1737,1740],{},[121,1735,1736],{},"抽象层级",[121,1738,1739],{},"底层，手动挡",[121,1741,1742],{},"高层，自动挡",[104,1744,1745,1748,1751],{},[121,1746,1747],{},"做什么",[121,1749,1750],{},"只创建 loop 对象",[121,1752,1753],{},"创建 + 运行 + 清理 + 关闭",[104,1755,1756,1759,1764],{},[121,1757,1758],{},"谁负责 close",[121,1760,1761],{},[36,1762,1763],{},"你自己",[121,1765,1766],{},"自动",[104,1768,1769,1772,1775],{},[121,1770,1771],{},"能否复用 loop",[121,1773,1774],{},"能（建一次跑多次）",[121,1776,1777],{},"不能（每次新建新关）",[104,1779,1780,1783,1786],{},[121,1781,1782],{},"能否嵌套",[121,1784,1785],{},"—（你自己管）",[121,1787,1788,1791,1792],{},[36,1789,1790],{},"不能","，运行中的 loop 里再调会 ",[55,1793,1794],{},"RuntimeError",[104,1796,1797,1800,1803],{},[121,1798,1799],{},"典型用途",[121,1801,1802],{},"框架\u002F库内部精细控制、子线程建 loop",[121,1804,1805],{},"应用程序入口，跑一个顶层协程",[18,1807,1808,1809],{},"一句话：",[36,1810,1811,1813,1814,795,1817,795,1819,1821],{},[55,1812,677],{}," ≈ ",[55,1815,1816],{},"new_event_loop",[55,1818,982],{},[55,1820,1053],{}," 的安全封装。",[182,1823,1825],{"id":1824},"_63-机器人框架设计两个-loop是否合理","6.3 机器人框架设计「两个 loop」是否合理？",[18,1827,1828,1829,1831],{},"回看 NcatBot 的 ",[55,1830,965],{},"，它确实先后用了两个：",[48,1833,1835],{"className":50,"code":1834,"language":52,"meta":53,"style":53},"run_coroutine(self.plugin_loader.load_plugins)    # 临时 loop ①：插件加载，跑完即弃\n...\nasyncio.run(self.adapter.connect_websocket())     # 长驻 loop ②：业务主循环\n",[55,1836,1837,1842,1847],{"__ignoreMap":53},[58,1838,1839],{"class":60,"line":61},[58,1840,1841],{},"run_coroutine(self.plugin_loader.load_plugins)    # 临时 loop ①：插件加载，跑完即弃\n",[58,1843,1844],{"class":60,"line":67},[58,1845,1846],{},"...\n",[58,1848,1849],{"class":60,"line":73},[58,1850,1851],{},"asyncio.run(self.adapter.connect_websocket())     # 长驻 loop ②：业务主循环\n",[18,1853,1854],{},"我的判断分两层：",[18,1856,1857,1860,1861,1864],{},[36,1858,1859],{},"✅ 合理的部分","：生命周期不同的阶段用不同 loop 是正当的。「一次性初始化」和「长期运行」本就是两个阶段，只要两个 loop ",[36,1862,1863],{},"不同时运行、而是先后接力","，工程上完全站得住脚。",[18,1866,1867,1870],{},[36,1868,1869],{},"⚠️ 不优雅的部分","：NcatBot 这个具体实现是历史包袱，而非精心设计。两个味道：",[264,1872,1873,1886],{},[267,1874,1875,1881,1882,1885],{},[36,1876,1877,1880],{},[55,1878,1879],{},"run_coroutine"," 自己都标了「已废弃」","——其源码注释直言「NcatBot 已重构为纯异步架构，此函数使用 ",[55,1883,1884],{},"asyncio.run()"," 会阻塞线程，违背异步并发原则」。即作者自己都认为「插件加载另起一个线程 + 临时 loop」不该保留。",[267,1887,1888,387,1891,1893,1894,1896],{},[36,1889,1890],{},"正是这个设计埋了定时任务的坑",[55,1892,887],{}," 跑在临时 loop ① 上，",[55,1895,340],{}," 起的扫描循环随 ① 关闭而冻结，而长驻的是 ②。统一一个 loop，这坑根本不存在。",[18,1898,1899,387],{},[36,1900,1901],{},"更优雅的设计是「单一长驻 loop」",[48,1903,1906],{"className":1904,"code":1905,"language":434,"meta":53},[432],"asyncio.run(main())\n  └─ loop（唯一且长驻）\n       ├─ await 初始化（加载插件、连数据库……）   # 同一个 loop\n       ├─ create_task(后台任务们)                  # 绑定到这个长驻 loop ✅\n       └─ await 主循环（while True 收消息）         # 同一个 loop\n",[55,1907,1905],{"__ignoreMap":53},[18,1909,1910],{},"一个 loop 从头跑到尾，所有初始化、后台任务、业务循环都挂在它身上，就不会「挂错 loop」。",[18,1912,1913,1914,1917,1918,1921,1922,1925,1926,1929],{},"那「多 loop」什么时候才是真正的合理设计、而非妥协？——",[36,1915,1916],{},"多线程隔离","（阻塞\u002FCPU 密集活丢子线程跑独立 loop）、",[36,1919,1920],{},"库与宿主集成","（某库内部要跑自己的 loop，靠 ",[55,1923,1924],{},"run_coroutine_threadsafe"," 跨 loop 通信）、",[36,1927,1928],{},"多进程隔离","（每进程一个 loop，靠 IPC）。这些是目的明确的架构选择；而 NcatBot 的两个 loop 更像「重构没做完的残留」。",[22,1931],{},[25,1933,1935],{"id":1934},"七小结","七、小结",[389,1937,1938,1944,1950,1960,1967],{},[267,1939,1940,1943],{},[36,1941,1942],{},"异步","用「等 I\u002FO 时让出 CPU」换来单线程的高并发，天生契合 QQ 机器人这种 I\u002FO 密集场景。",[267,1945,1946,1949],{},[36,1947,1948],{},"事件循环","是异步的心脏；协程靠它驱动，Task 绑定它调度，loop 一停任务即冻结。",[267,1951,1952,1955,1956,1959],{},[36,1953,1954],{},"JS\u002FTS"," 因渲染需求采用宏\u002F微任务双队列，",[36,1957,1958],{},"Python"," 用更扁平的单就绪队列 + 定时器堆，但上层心智一致。",[267,1961,1962,1963,1966],{},"真实框架往往",[36,1964,1965],{},"托管多个 loop","，分清「临时初始化 loop」与「长驻业务 loop」，是把长驻后台任务挂对地方的前提——这次定时任务 bug 就是活生生的教训。",[267,1968,1969,1970,1973,1974,1976,1977,1980,1981,180],{},"一个程序",[36,1971,1972],{},"可以有多个 loop","，但同线程同一刻只跑一个；",[55,1975,677],{}," 是 ",[55,1978,1979],{},"new_event_loop + run + close"," 的安全封装；",[36,1982,1983],{},"「单一长驻 loop」通常是比多 loop 更省心的设计",[18,1985,1986,1987,1990],{},"异步不难，难的是搞清楚「",[36,1988,1989],{},"此刻我的代码，跑在哪个 loop 上，那个 loop 还活着吗","」。",[1992,1993,1994],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":53,"searchDepth":67,"depth":67,"links":1996},[1997,1998,2004,2008,2014,2026,2032],{"id":27,"depth":67,"text":28},{"id":160,"depth":67,"text":161,"children":1999},[2000,2001,2003],{"id":184,"depth":73,"text":184},{"id":253,"depth":73,"text":2002},"await 到底发生了什么",{"id":317,"depth":73,"text":318},{"id":364,"depth":67,"text":365,"children":2005},[2006,2007],{"id":379,"depth":73,"text":380},{"id":463,"depth":73,"text":464},{"id":600,"depth":67,"text":601,"children":2009},[2010,2011,2012,2013],{"id":607,"depth":73,"text":608},{"id":628,"depth":73,"text":629},{"id":659,"depth":73,"text":660},{"id":670,"depth":73,"text":671},{"id":720,"depth":67,"text":721,"children":2015},[2016,2017,2018,2020,2021,2023,2024,2025],{"id":727,"depth":73,"text":728},{"id":832,"depth":73,"text":833},{"id":873,"depth":73,"text":2019},"5.3 根因：create_task 挂错了 loop",{"id":1023,"depth":73,"text":1024},{"id":1065,"depth":73,"text":2022},"5.5 loop.run_until_complete vs run_frontend() 的本质区别",{"id":1202,"depth":73,"text":1203},{"id":1301,"depth":73,"text":1302},{"id":1440,"depth":73,"text":1441},{"id":1482,"depth":67,"text":1483,"children":2027},[2028,2029,2031],{"id":1489,"depth":73,"text":1490},{"id":1602,"depth":73,"text":2030},"6.2 new_event_loop() 和 asyncio.run(main()) 的区别",{"id":1824,"depth":73,"text":1825},{"id":1934,"depth":67,"text":1935},"\u002Fimages\u002Fposts\u002F26_06_09\u002Fcover.jpg","2026-06-09",false,"md",{},"\u002Fposts\u002F26_06_09\u002Fmain",{"title":5,"description":53},"posts\u002F26_06_09\u002Fmain","巩固对异步编程的理解，记录在AI Agent QQ机器人中定时任务的实现过程",[2043,2044],"notes","开发","nofOd1E3dwhXmWsl9CQr9zkrEOQWm3tQNJm5SsnS39o",1782672216593]