码农高天
开发者盲目堆砌async与await却缺少并发任务与统一事件循环,所有并发收益实质仅由多线程提供,反让主程序架构徒增无意义的复杂度。
AI-assisted summary of 【Code Review】把异步写成同步?async可未必就异步了! — original source. Check the original for full context.
Source published:
Key ideas
- 开源项目 bilipod 虽通篇使用 async/await 语法,但因在数据初始化与下载过程中从未并发创建第二个协程任务,其 I/O 等待时无其他任务可调度,架构本质上完全退化成了单线同步串行。 (5:00)
- 项目的包结构与导入方式存在冲突,采用标准 src 目录却在内部全部使用绝对导入,导致其无法作为一个独立的 Python package 被外部引入,只能直接运行 main.py 入口文件。 (0:00)
- 调度器线程在触发任务时会临时创建独立的 Event Loop,与主线程的主事件循环彻底割裂,导致协程级上下文切换机制完全失效,实质退化为线程调度与跨线程事件通知。 (10:00)
- 盲目滥用 async/await 并未带来并发 I/O 性能收益,反而因混用线程与异步原语让整体工程架构变得极其晦涩且难以维护。 (10:00)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 11 月 23 日发布。视频内容是一期代码审查(Code Review),针对知识星球用户投稿的开源项目 bilipod 展开深入分析,旨在探讨项目中虽然使用了 async/await 关键字,但为何在实际运行中并没有真正实现异步并发的问题。
## TL;DR 高天在审查 bilipod(一个将 B站视频转为播客订阅源的工具)代码时,指出了其包结构引用混乱、交互与日志混淆以及数据库插入缺乏幂等性等设计缺陷。更核心的问题在于,项目在数据初始化和网络 I/O 下载过程中仅有单任务线性推进,看似写满协程语法实则完全同步串行。最后,作者在独立线程中使用 schedule 调度器并频繁创建独立的 Event Loop,导致主线程与后台任务之间的事件循环完全割裂,协程上下文切换彻底失效,使整个异步架构形同虚设。
## Atmosphere & style 视频风格专业、犀利且充满技术极客的幽默感。高天的讲解直击痛点,通过层层剖析代码逻辑,在看似合理的 async 语法中抽丝剥茧,精准挑出“假异步”与架构混乱的硬伤。整体观看体验既像是一场严肃严谨的工程把脉,又带着生动风趣的即兴吐槽,给盲目套用异步框架的开发者敲响了警钟。
## Bulletin - [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 开源项目 bilipod 虽通篇使用 async/await 语法,但因在数据初始化与下载过程中从未并发创建第二个协程任务,其 I/O 等待时无其他任务可调度,架构本质上完全退化成了单线同步串行。 - [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) 项目的包结构与导入方式存在冲突,采用标准 src 目录却在内部全部使用绝对导入,导致其无法作为一个独立的 Python package 被外部引入,只能直接运行 main.py 入口文件。 - [10:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=600s) 调度器线程在触发任务时会临时创建独立的 Event Loop,与主线程的主事件循环彻底割裂,导致协程级上下文切换机制完全失效,实质退化为线程调度与跨线程事件通知。 - [10:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=600s) 盲目滥用 async/await 并未带来并发 I/O 性能收益,反而因混用线程与异步原语让整体工程架构变得极其晦涩且难以维护。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) bilipod 是一个抓取 B站 视频或播放列表并转制为播客订阅源的工具,采用 Python 开发并依赖 TinyDB 与 HTTPX。 - [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) 包内部模块依赖若采用绝对导入而非相对导入,将破坏包的独立分发与复用能力,限制其仅能从入口脚本启动。 - [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) 用户交互展示与系统日志具有本质区别,Banner 等交互内容不应绑定在可随时被关闭或调整级别的 Logger 上输出。 - [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) 文件与数据库备份逻辑应严格校验物理文件是否存在,仅判断父文件夹是否存在容易引发逻辑断层或潜在异常。 - [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 在循环体起始处盲目添加 sleep 无法在并发协程场景下实现有效的请求限流(Rate Limit),属于反直觉的错误模式。 - [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 数据转换逻辑应进行合理封装,避免将过多的临时对象与拼装步骤暴露在业务循环中,降低数据传递耦合。 - [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 持久化操作缺乏幂等性设计,对既有数据直接执行 insert 而非 upsert 会导致数据库脏数据与主键冲突风险。 - [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 协程并发的核心价值在于多任务在 I/O 等待时交替执行,没有多任务并发调度时,async/await 与纯同步阻塞无异。 - [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 视频抓取与下载过程中缺乏过滤机制,未跳过已入库内容,且线性逐个下载彻底放弃了异步网络 I/O 的性能优势。 - [10:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=600s) 协程上下文切换依赖所有任务共享同一个事件循环,跨线程临时创建独立的 Event Loop 会割裂协程协作链路。 - [10:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=600s) 第三方同步调度库(如 schedule)与 asyncio 混合使用时,若仅靠线程包裹加单发 Event Loop,会使主线程事件循环退化为空转等待。 - [10:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=600s) 如果最终的并发仅靠多线程实现,且主流程无并发协程,则主程序完全没有必要引入复杂的异步架构。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) 项目背景、包结构与架构初始化分析
本期视频继续进行代码审查(Code Review),项目来自知识星球用户的投稿,名为 **bilipod**。该项目的核心功能是抓取哔哩哔哩(Bilibili)上的用户视频或播放列表(playlist),并将其转制成播客(podcast)订阅源。用户在配置文件 `config.yaml` 中定义各项参数,例如在 `feeds` 字段下指定每个播客的 ID、对应 B站的用户或播放列表来源以及相关页面设置。程序读取配置后,负责下载视频、转音频并生成播客内容。
```mermaid flowchart TD accTitle: Bilipod 启动与初始化流程 accDescr: 展示程序入口 main.py 到各组件启动与初始化的过程
subgraph Entry["程序入口与配置"] A["config.yaml 配置"] --> B["main.py (asyncio.run)"] B --> C["初始化数据库 TinyDB"] end
subgraph InitStage["初始化与任务分发"] C --> D["data_initialize (爬取与下载)"] D --> E["创建异步任务 update_episodes"] E --> F["配置 schedule 定时任务"] end
subgraph Background["后台服务"] B -.-> G["独立线程: Web Server"] F -.-> H["独立线程: run_scheduler"] end ```
在审阅项目的物理与逻辑组织方式时,高天首先指出了包结构(package structure)与导入模式之间的严重矛盾:
- **目录结构与导入方式的冲突**:项目采用了标准的 `src/` 目录组织形式,入口文件位于 `src/bilipod/main.py`,包根目录下也放置了 `__init__.py`。然而,代码内部在引用本地模块(local modules)时全部采用了绝对引用(如 `import bilipod.xxx`)。高天指出:“这种引用方法只有在你的入口程序刚好在这个 Source bilipod 里面的时候才能工作,而我们看他很多其他的库函数也是使用的这种绝对引用,也就是说他整个 package 没有办法作为一个真正的 package 被使用,他只能运行那个 main.py 文件才能跑得起来。”如果要将其作为标准包发布或导入,内部模块之间的依赖必须规范使用相对引用(relative import)。 - **交互输出(Interaction)与日志(Logging)的混淆**:在 `main.py` 中,程序在读取 `argparse` 参数后启动,并在开头打印了一个巨大的字符画 Banner,而这个 Banner 是通过 Logger 输出的。高天强调,记录日志与同用户交互在语义上存在本质差别:“显示给用户的东西应该有没有 log 都显示,他是跟用户互动的必需品,而 log 是可以开启、可以关闭、可以设置 level、可以写到另一个文件的。” - **数据库路径与备份逻辑断裂**:在通过 TinyDB 初始化数据库时,代码判断若父文件夹不存在则创建,若存在则使用 `shutil` 将数据库文件复制备份。然而如果父文件夹存在但数据库文件本身不存在,调用复制逻辑便存在隐患甚至抛出异常。高天认为此处存在阅读上的逻辑断链,即使作者有特殊意图,也至少应补充注释说明。 - **并发与后台任务分派**:程序使用 `asyncio.run(main())` 启动主协程,随后用一个独立线程启动 Web 服务对外提供文件访问;接着调用 `data_initialize` 解析抓取源并落库;紧接着用 `asyncio.create_task` 启动 `update_episodes` 任务;最后为每个播客通过 `schedule` 注册定时更新任务,并额外开启一个常驻后台线程以支撑调度器循环。
---
[05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) data_initialize 逻辑剖析与伪异步陷阱
深入到 `data_initialize` 函数内部,代码开始根据用户配置的 `feeds` 遍历处理播客数据,但这里的控制流暴露了许多代码设计与异步机制理解上的硬伤:
- **反直觉且无意义的 Sleep**:在提取每个 Feed 的配置时,循环体内第一步直接执行了 `await asyncio.sleep(0.5)`。高天吐槽道:“从理论上说你是不是应该先干活再 sleep,你不能先休息再干活对吧。”更关键的是,在标准协程并发架构中,单纯的 `sleep(0.5)` 无法起到防止爬虫被目标站点限流(Rate Limit)的作用,因为这半秒窗口期内其他协程任务依然会持续发起请求。 - **过多中间量导致接口抽象失效**:循环中先构造了一个空的 `Pod` 对象,通过其 UID 异步调用 `get_pod_info` 发起网络请求获取元数据,再拿返回的信息去更新该 `Pod`。高天认为这种写法将过多的中间量和中间步骤暴露在业务循环中,导致代码极度难读:“我个人的感觉是这个 for loop 里应该只有这么一行东西,就我的目的是从一个 feed 拿到一个 pod,就这么简单……这样你就能避免在几个数据结构之间来回来去的传数据。” - **数据库插入缺乏幂等保护**:获取到 `Pod` 数据后,程序直接无条件 `insert` 进 TinyDB 的数据表中。由于数据库文件可能由以往运行遗留,无脑插入必然产生脏数据与主键冲突。此处应该使用具有“存在即更新,不存在即插入”语义的 `upsert` 机制。此外,后续提取视频列表(`get_episode_list`)并调用 HTTPX 下载视频时,同样缺少过滤机制,导致重复抓取已有内容。 - **“伪异步”的串行执行真相**:整个 `data_initialize` 过程充斥着 `async` 与 `await`,但根本没有利用协程的真正优势。高天尖锐地指出了这一架构缺陷:
> “大家有没有意识到他刚才完全没有用上异步这件事?他写了很多 async await 对吧,但是我们说协程异步的核心是什么?是多任务并行对吧。当我们等待一个任务,当这个任务正在做 I/O 的时候,要有另外的任务可以做,这才叫异步,才叫协程对吧。而到目前为止,他并没有建立第二个任务……也就是说在他 initialize 的过程中,从头到尾都只有一个协程任务在运行。当他进行 I/O 操作的时候,并没有第二个任务可以补上,这一段看似异步的代码实际上是完全同步的。”
无论是查询播客详情还是使用 HTTPX 下载音视频流,代码全都是线性单线推进,前一个视频下载完成前绝不开始下一个,完全丧失了并发 I/O 的性能收益。
---
[10:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=600s) 调度机制缺陷与线程/协程事件循环割裂
随后审查转向整个工程中唯一调用了 `asyncio.create_task` 的组件——`update_episodes`。按常理,后台更新任务应当与定时调度配合实现高并发事件驱动,但其底层的设计再次陷入了逻辑混乱:
```mermaid flowchart TD accTitle: 线程与事件循环割裂模型 accDescr: 说明主线程与调度器线程运行在不同事件循环中,协程间无法实现控制权切换
subgraph MainThread["主线程 (Main Event Loop)"] direction TB MTask1["update_episodes (任务: while True 等待 Event)"] MTask2["阻塞等待任务 (仅维持事件循环不退出)"] end
subgraph SchedThread["调度器独立线程 (run_scheduler)"] direction TB S1["schedule 定时扫描轮询"] --> S2["run_async 回调触发"] S2 --> S3["临时新建并启动独立的 Event Loop"] S3 --> S4["运行 update_pod 协程"] S4 --> S5["执行 update_event.set()"] end
S5 -.->|"跨线程触发 Event (无协程切换)"| MTask1 ```
- **Update Episodes 的死锁等待**:`update_episodes` 的主体逻辑是一个 `while True` 死循环,其内部唯一的动作是 `await event.wait()`,等待某个全局事件被唤醒后去更新并下载单集信息。而触发该事件的 `set()` 方法则放在 `update_pod` 中。 - **调度器运行于新线程与新事件循环**:定时任务使用了第三方库 `schedule`,运行在独立的后台辅助线程(`run_scheduler`)中。调度器到点触发任务时,调用了自定义的 `run_async` 辅助函数。然而该函数每次执行,都会在调度器线程中**新建一个完全独立的 Event Loop** 来跑 `update_pod`。 - **协程上下文切换彻底失效**:高天解释道,协程级的上下文切换(Context Switch)或控制权让渡(Control Yielding),前提是所有协程运行在**同一个**事件循环中。由于 `update_pod` 是在新线程中的新事件循环里单发单关,而主线程的 `update_episodes` 则挂在主事件循环上,两者之间完全无法发生协程级切换,仅能依赖线程级调度与同步原语通知。此时主线程事件循环里唯一挂载的任务,就只剩下一个处于纯等待状态的挂起协程。 - **总结与反思**:高天对该架构作出了最终定性:“他写的这个协程程序是一个同步程序,他几乎没有任何协程级别的异步。他确实做到了一些异步,但是是通过线程来完成的。也就是说,他其实完全没有必要把主程序搞得这么复杂,他主程序可以搞成完全同步的,和现在效果是一样的。”如果开发者在不理解底层调度机制的情况下盲目滥用 `async`/`await`,非但无法压榨硬件与网络潜力,反而会让整个软件架构退化得晦涩难懂、难以维护。
## Notable quotes > [05:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=300s) 当我们等待一个任务,当这个任务正在做 I/O 的时候,要有另外的任务可以做,这才叫异步,才叫协程对吧。 — 高天
> [00:00](https://www.youtube.com/watch?v=makiSlq3MLs&t=0s) 显示给用户的东西应该有没有 log 都显示,他是跟用户互动的必需品,而 log 是可以开启、可以关闭、可以设置 level、可以写到另一个文件的。 — 高天
## Mentioned bilipod, 哔哩哔哩, 高天, TinyDB, HTTPX, asyncio, schedule, Python
## Video description Code Review是提升代码水平最快的方式之一,这期视频我们review一个比较复杂的项目的一部分。这个代码使用了异步的架构,然而出现了async就一定异步成功了么?
本次代码:https://github.com/sunrisewestern/bilipod
[Source](https://www.youtube.com/watch?v=makiSlq3MLs)