码农高天
非阻塞子线程若缺乏类级别锁或高阶原子动作封装,多个自动化任务抢占物理鼠标将直接引发状态错乱与程序瘫痪。
AI-assisted summary of 【Code Review】格式,异常处理和多线程的风险 — original source. Check the original for full context.
Source published:
Key ideas
- 码农高天通过对开源麻将机器人 MahjongCopilot 中百余行中间件代码的 review,指出项目中普遍存在未严格遵守 PEP 8 规范的问题,呼吁开发者借助 Flake8 或 Black 等自动化 Linter 统一代码风格。 (0:00)
- 代码中重复调用正在运行的任务时仅静默 return,高天指出这属于缺乏防御意识的设计,应根据业务语义明确是返回布尔值、抛出异常还是使用断言阻止系统状态错乱。 (5:00)
- 多线程驱动单个鼠标操作带来了严重的原子性缺失与竞态条件风险,并发交叉执行或中途被打断(如拖拽中未触发 mouse_up)会导致系统状态彻底崩溃。 (10:00)
- 高天给出了两种规避并发风险的解决方案:在工程层面对共享鼠标引入类级别线程锁,或在架构层将“拖拽”等复合交互整体提升为不可分割的原子动作。 (10:00)
Detailed notes
## Background 本期视频由博主“码农高天”于 2025 年 4 月 3 日发布在 YouTube 平台上。视频内容是一次针对开源麻将机器人(MahjongCopilot)中鼠标自动化操作中间件的 Code Review,旨在通过一段百余行的 Python 核心代码,剖析其中隐藏的规范与工程细节。
## TL;DR 视频聚焦于自动化任务类 `AutomationTask`,指出代码中存在大量违背 PEP 8 规范的空格排版与不一致的字符串格式化问题,建议引入自动化 Linter 工具予以规范。随后,码农高天分析了命名语义混淆与静默 return 带来的调用端感知缺失,发散探讨了防御性异常处理的设计选择。最后,视频深入剖析了该类在多线程并发场景下的严重安全隐患,揭示了任务交叉抢占、延时破坏上下文以及复合操作中途打断所引发的竞态条件,并给出了引入类级别共享锁与提升原子动作抽象粒度的改进方案。
## Atmosphere & style 整期视频呈现出资深工程师特有的务实、严谨与循循善诱。码农高天语气平实自然,从微观的代码排版细节切入,层层递进到复杂的多线程并发架构,讲解极具逻辑张力。在指出多线程竞态条件等“工程梦魇”时,通过生动的场景推演将抽象的并发 Bug 具象化,带给观众一种在真实排障现场抽丝剥茧般的专业感与启发感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=0s) 码农高天通过对开源麻将机器人 MahjongCopilot 中百余行中间件代码的 review,指出项目中普遍存在未严格遵守 PEP 8 规范的问题,呼吁开发者借助 Flake8 或 Black 等自动化 Linter 统一代码风格。 - [05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 代码中重复调用正在运行的任务时仅静默 return,高天指出这属于缺乏防御意识的设计,应根据业务语义明确是返回布尔值、抛出异常还是使用断言阻止系统状态错乱。 - [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 多线程驱动单个鼠标操作带来了严重的原子性缺失与竞态条件风险,并发交叉执行或中途被打断(如拖拽中未触发 mouse_up)会导致系统状态彻底崩溃。 - [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 高天给出了两种规避并发风险的解决方案:在工程层面对共享鼠标引入类级别线程锁,或在架构层将“拖拽”等复合交互整体提升为不可分割的原子动作。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=0s) Code Review 强调代码规模控制,动辄几千行的完整仓库难以在短视频内深入,限定在 100 至 300 行的核心模块最适合细致审查。 - [00:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=0s) AutomationTask 在项目中扮演中间件角色,通过开辟独立线程来执行耗时的连续动作,避免阻塞主程序逻辑。 - [00:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=0s) PEP 8 对类型注解、二元运算符与默认参数的空格有着精细规则,开发者光靠肉眼与记忆极易出现前后矛盾,必须配置自动化 Linter(如 Flake8、Black)保证代码一致性。 - [05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 函数参数名为 game_browser 却在内部赋值给 self.executor,无明确映射的命名转换会平白增加阅读者的认知负担。 - [05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 使用 dataclass 定义 ActionStep 及其子类是值得肯定的做法,省去了繁琐的手写构造函数,便于通过基类统一操作类型提示。 - [05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 任务重复调用时直接隐式 return 会掩盖问题,外部调用方无法感知执行状态;应仔细评估是否该 raise 异常、返回状态布尔值或使用 assert 强校验。 - [05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 在同一文件极近的上下文中混用旧式 % 格式化与 f-string 属于不规范编码,新开发的代码应统一采用现代的 f-string 语法。 - [05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 将鼠标按下与弹起之间的延时拆到 Python 线程中 sleep 虽然解放了浏览器主线程,但缺乏注释且暴露了线程安全窗口。 - [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 系统只对应一个物理鼠标,当多个自动化任务在独立线程中并发调度时,极易发生移动与点击操作交错抢占的竞态条件。 - [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 当支持随时被取消或因外部状态变更中途退出时,拖拽等复合动作若被打断,极易遗漏 mouse_up 导致鼠标永久处于按下卡死状态。 - [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 从工程实现角度解决并发冲突,最直接的做法是引入类级别(Class-level)线程锁,强制全局同一时间仅允许一个任务驱动鼠标。 - [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 从架构设计角度解决原子性问题,应重新定义 ActionStep 的粒度,将易受打断的复合行为(如拖拽)自身封装为单次不可拆分的原子操作。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=0s) 项目背景介绍与 PEP 8 代码格式规范
码农高天在视频开篇提到了本次 Code Review 的征集背景。此前在动态里虽然有若干小伙伴报名,但活动明确要求提交一段 100 到 300 行的 Python 代码,因为一期视频的时长有限,主播自己也不可能去深度阅读动辄几千行的大型仓库。不少人直接扔来一个 GitHub 项目地址就让主播去 review,这种过大的项目显然无法处理。最终选中的是一位朋友的代码,也是仅有的几位明确标注了审查区域的提交者之一。该项目是一个相对复杂的麻将机器人(MahjongCopilot),包含了读取信息、算法计算以及底层操作等模块。
本次具体 review 的核心仅有 `game/automation.py` 里的一个类——`AutomationTask`。该类的核心接口是 `start_action_steps`,外部向其传入一系列动作步骤(`action_steps`,如移动鼠标、点击、按下等操作)和一个可选的游戏状态(`game_state`),以此来操控浏览器实现自动化控制。这个类本质上扮演了一个中间件的角色:外部将一系列想要完成的操作交给它,它再去控制浏览器。而它存在的最大意义是维护了一个独立的线程去驱动浏览器,因为一连串操作(如点击后等待几秒再拖动)耗时较长,开发者不希望这些耗时动作阻塞主程序,因此将它们分发到子线程中执行。
```mermaid flowchart LR accTitle: AutomationTask 架构简图 accDescr: 主程序向 AutomationTask 传递动作和状态,AutomationTask 在子线程中调用 GameBrowser 控制浏览器。 subgraph MainProgram["主程序"] Steps["action_steps<br/>(移动/点击等)"] State["game_state<br/>(游戏状态)"] end subgraph Middleware["AutomationTask (中间件)"] Worker["独立线程 Worker"] end subgraph BrowserLayer["浏览器控制"] Executor["GameBrowser (executor)"] end
Steps --> Worker State --> Worker Worker -->|"无阻塞操作"| Executor ```
针对这段代码,码农高天提出的第一个核心建议是引入自动化 Linter 工具。仅仅在短短一个类之中,就存在很多不符合 Python 官方 PEP 8 规范的写法: - **函数参数类型注解**:PEP 8 推荐在参数注解的冒号后面保留一个空格,而冒号前面不加空格;原代码中冒号前后均无空格。 - **参数默认值的等号空格**:当参数带有类型提示(type annotation)时,默认值的赋值等号两边应该各有一个空格;如果参数没有类型提示,等号两边则不加空格。 - **变量类型注解**:与参数规则一致,冒号后面应有空格,赋值等号前后也应保留空格。 - **函数调用的多行参数格式**:原代码用三行传递三个参数,但三行排版格式严重不一致。在函数调用时,关键字参数赋值的等号两端按标准不应加空格。 - **二元运算符(Binary Operation)**:在一般的除法等二元操作中,如果语句仅有一个运算符,其左右两侧都应加空格。 - **多余缩进与空行字符**:在 243 行等逻辑空行上出现了两个缩进(8 个空格),规范的空行上不应包含任何不可见空格字符。
码农高天直言,人类记不住这么多排版细则是很正常的,“记不住是很正常的事情啊,就是因为记不住你才会发现他写的这个程序里面经常有前后不统一的地方”,前面觉得这样写,后面又在 `game_state` 处加上了空格。为了避免这类局部前后矛盾,必须借助 Flake8、Black 或 Pylint 等自动化 Linter 工具来统一代码风格,让项目显得整洁专业。
[05:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=300s) 命名语义、错误状态处理与字符串格式化的一致性
梳理完排版后,码农高天转向具体的功能实现。在类的初始化函数 `__init__` 中传入了三个参数:`game_browser`、`name` 以及 `description`。其中 `game_browser` 是浏览器的抽象层,提供移动鼠标、点击等底层控制,在此处可以被视为一个黑盒。但在赋值时,原作者将 `game_browser` 赋值给了实例属性 `self.executor`。码农高天指出这种命名转换存在歧义:在同一个类中没有必要搞出两套没有明确映射关系的命名体系,“如果你认为它一定是一个 browser,写 browser 就好了对吧;如果你认为他是一个 executor,那这个 argument 就是 executor”。统一命名能显著降低读者的认知负担。此外,类中还初始化了 `_stop_event`、`_thread` 以及记录上次操作时间的变量。
随后代码进入核心入口函数 `start_action_steps`。在动作定义上,原作者定义了一个基础类 `ActionStep` 作为 dataclass,表示一个原子动作(atomic action),并在此基础上派生出鼠标移动、单击、按下、弹起等子类。码农高天肯定了这种做法:使用 dataclass 避免了冗长手写 `__init__` 的模板代码,且让类型提示可以通过基类统合所有原子动作。
进入函数体后,代码首先校验任务是否已经在运行:通过检查线程是否存在且活跃(`is_running`)。如果线程已经在运行,函数直接简单地 `return`。码农高天在此强调,这是一个关键的“设计选择”(design choice): - 如果只是默默地 return,外部调用方根本无法感知分配下去的操作到底是执行了还是没执行。 - 开发者必须思考:这种情况出现是否属于程序异常?是不是需要抛出异常(`raise Exception`)?调用者是否至少需要拿到一个布尔值(`True`/`False`)来确认执行状态? - 如果按正常业务逻辑,该任务在生命周期内只能被调用一次,那么重复调用是否意味着程序状态彻底错乱,进而应该直接使用 `assert` 断言?
这种思维正是面对异常与边界条件时所必需的发散思考。
```mermaid flowchart TD accTitle: start_action_steps 与 Task 执行流程 accDescr: 展示 start_action_steps 的判断分支以及后台 Task 内部的循环检测与 run_step 调用。 Start(["调用 start_action_steps"]) --> CheckRunning{"is_running?"} CheckRunning --"True"--> SilentReturn["直接 return (隐式吞掉调用)"] CheckRunning --"False"--> SpawnThread["启动子线程运行 Task 闭包"]
subgraph TaskLoop["Task 线程内部执行循环"] RecordStep["记录当前 op_step"] --> ForStep["遍历 action_steps"] ForStep --> CheckStop{"_stop_event is set?"} CheckStop --"Yes"--> LogStop["Log 并退出 Task"] CheckStop --"No"--> CheckState{"game_state 改变且未忽略?"} CheckState --"Yes"--> LogAbort["终止并退出 Task"] CheckState --"No"--> RunStep["调用 run_step 执行动作"] RunStep --> ForStep end
SpawnThread --> TaskLoop ```
随后,函数开启一个子线程并执行闭包 `task`。在 `task` 开头,作者读取了代表游戏状态计数器的 `op_step`,但随后定义了一个仅使用了一次的局部变量 `message` 并传给日志输出。码农高天指出这完全可以合并成一行,尤其令人费解的是在极近的代码上下文里,前面的 log 拼接使用的是 `%` 格式化,紧接着下一处 log 却使用了 f-string。码农高天明确指出:“如此近的局部不统一是不应该出现的啊。除非是这段代码已经被维护了 20 年了,在没有 f-string 的时候用的其他的东西……如果你这个代码是最近才开发的,你就统一都用 f-string 就可以了。”
在主循环执行每个 `action_step` 时,代码进行了两层守护: 1. 检查外界是否调用公有方法触发了 `_stop_event`,若触发则记录 log 并直接退出; 2. 若传入了 `game_state`,则检查游戏状态是否发生变化;如果状态发生变更且该操作未标记为忽略状态变更(`ignore_step_change`),说明环境已变,先前的操作序列失效,任务同样直接中断 return。
最后进入 `run_step`,针对不同操作类型调用 `executor` 的动作。这里包含一个细节:在处理单击(`click`)时,作者没有使用底层的 `mouse_click`,而是把点击过程中的 delay 延时操作放到了 Python 线程中,而不是让浏览器自身去延时。码农高天认为此处应该添加注释说明原由,并推测作者可能是为了避免浏览器主线程被 sleep 阻塞导致其它页面动作卡死。
[10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 多线程并发隐患、竞态条件与操作原子性设计
代码虽然行数极少且逻辑直接,但码农高天指出了其中隐藏的最致命架构隐患:**多线程并发下的竞态条件(Race Condition)与原子性(Atomicity)缺失**。
既然任务被放进单独的线程中运行,这意味着系统在理论上可能同时启动多个自动化任务。码农高天用具体场景阐述了并发时的灾难性表现: - **场景一:多任务交叉抢占**。假设 Task A 的逻辑是“将鼠标移到点 A 并单击”,Task B 的逻辑是“将鼠标移到点 B 并单击”。由于线程调度切换,极可能出现 Task A 刚把鼠标移到 A 点,CPU 控制权就被 Task B 抢走;Task B 将鼠标移到了 B 点执行单击,随后控制权交回 Task A,Task A 紧接着在当前位置(即 B 点)补上了一次单击。最终的宏观表现变成了“鼠标先移到 A、再移到 B、在 B 连点两次”,操作顺序彻底崩溃。 - **场景二:拆分 Delay 导致的上下文破坏**。前文提到作者为了解放浏览器线程,把 `mouse_down` 和 `mouse_up` 之间的 delay 放到了 Python 子线程中 `time.sleep`。但在 Python 线程休眠期间,其他线程极有可能趁虚而入,插入移动或其它点击,导致鼠标弹起时的坐标和环境根本不是按下时的状态。 - **场景三:被打断的复合动作**。若外界需要模拟一个“拖拽”效果(`mouse_down` -> 移动 -> `mouse_up`),而任务支持随时被外界 cancel 或由于 `game_state` 改变而中途 return,那么一旦在拖拽途中被打断,`mouse_up` 将永远得不到执行。物理鼠标状态将一直停留在“按下”状态,导致后续所有麻将机器人的逻辑彻底瘫痪。
码农高天总结道:“我提到的这些问题根据应用的不同,它发生的可能性可能会不同……但是理论上你只要有两个 automation task 有可能会同时运行,就会出现这个问题。而这个问题的背后就是多线程的梦魇——原子性问题,就是你究竟需要什么东西是 atomic 的。”
针对这种多线程风险,码农高天给出了两个层次的解决思路: - **工程实现方案(线程锁)**:最直接有效的手段是在任务层加锁。虽然让浏览器线程保持非阻塞,但通过在 `AutomationTask` 类级别引入一个共享锁(Class-level Lock),强制全局同一时刻只能有一个任务在驱动鼠标。“因为我其实很难想象在什么情况下你会需要两个 task 同时工作,你只有一个鼠标嘛,我很难想象两个 task 同时运行是有可能会正确的。” - **架构设计方案(提升原子动作抽象粒度)**:重新审视 `ActionStep` 的定义。如果底层没有锁来进行强力保护,就不能只提供单纯的“按下”、“弹起”这类极小颗粒度动作,而是应该从设计层面上将“拖拽”等不可分割的高级行为本身定义为一个原子动作(Atomic Action),确保其在执行过程中绝对不被中途打断。
在视频的尾声,码农高天表示虽然没有通读整个项目的全部源码,但仅从这百余行代码中就能提炼出这些在工程实践中极易踩坑的关键问题。最后他感谢了 FittingCode 的赞助支持,并提醒观众不要忘记“一键三连”。
## Notable quotes > [10:00](https://www.youtube.com/watch?v=01_4T_UI6q8&t=600s) 你只有一个鼠标嘛,我很难想象两个 task 同时运行是有可能会正确的。 — 码农高天
## Mentioned 码农高天, MahjongCopilot, AutomationTask, ActionStep, PEP 8, Flake8, Black, Pylint, FittingCode
## Video description 这次的code reivew我们看了一个自动化鼠标操作的中间件。很简单的一段代码,但是里面依然有不少你可能不知道的细节。
本次代码:https://github.com/latorc/MahjongCopilot/blob/main/game/automation.py#L192
[Source](https://www.youtube.com/watch?v=01_4T_UI6q8)