Python 3.13 free-threading实践:多进程到多线程迁移复盘
Python 3.13正式把free-threading无GIL作为实验特性放出来以后技术社区里讨论最多的就是一件事我的多进程代码是不是可以改成多线程了我第一次听到这个说法的时候也抱有同样的幻想毕竟多线程如果真能跑满多核那些为了绕过GIL而引入的进程池、IPC序列化、数据复制就可以统统扔掉了。抱着这个心态我花了两周时间把手头一个重CPU、重I/O混合的批处理项目从multiprocessing架构完整迁移到了free-threading模式下的多线程架构。结论是这是一次收益很高但代价也不小的重构——性能确实有明显提升但顺带暴露了很多以前被GIL掩盖的同步问题还踩了不少C扩展兼容性的坑。这篇文章就是这次实操的记录与复盘。我会从GIL机制讲清楚free-threading到底是什么再给出我自己做架构迁移时的判断标准、改造步骤、锁与数据结构的处理方案以及最终的实测数据。想要评估是否切换到free-threading模式或者正准备迁移手头项目的朋友可以作为一份参考手册来用。1. GIL与free-threading这次移除到底移除了什么1.1 从单行道收费站到多车道信号灯GIL机制回顾GIL的历史并不复杂。Python在设计初期为了让引用计数内存管理变得简单可靠直接给整个解释器加了一把大锁任何线程想要执行Python字节码都得先拿到这把锁。你可以想象一条单行道的收费站车线程很多但每次只能有一辆车通过收费口执行字节码其他车只能排队。对于I/O密集任务线程在等待I/O时会释放锁所以看起来还能并发但真正吃CPU的计算在GIL下就是实打实的串行。这里有个容易混淆的点GIL并不等于Python线程完全无法并发。在遇到I/O、sleep、C扩展释放GIL时其他线程是可以插队执行的。所以最典型的表现是——多线程写I/O很流畅多线程做计算一动不动。很多人在评估并发方案的时候会被多线程跑I/O好像挺快迷惑误以为自己的CPU密集任务也可以直接用线程解决实际一测才发现核心根本没跑满。1.2 PEP 703到底改了什么2023年PEP 703free-threading正式被接受Python 3.13作为实验性构建开始支持。之所以叫free-threading而不是移除GIL是因为它没有简单地把锁删掉而是引入了一套更细粒度的同步机制对象的内部锁、偏向引用计数、延迟引用计数等。GIL这把全局收费站被拆成了无数个路口信号灯不同线程可以同时在不同的路口通行但对于同一个对象的并发访问仍然有明确的规则约束。从使用者的角度最直接的感知就是在多核CPU上多个线程终于可以真正同时执行Python字节码了。第一次跑通的时候那种CPU所有核心都在跳动的感觉和以前多线程CPU占用率只有25%的对比还是很震撼的。1.3 如何拿到并验证free-threading环境我当时是在Linux服务器上操作的获取free-threading构建有三种常见途径用表格整理一下平台获取方式注意事项Windowspython.org 下载 3.13 安装包勾选 free-threaded 组件安装后会有 python3.13t.exe需要单独创建虚拟环境和普通3.13不共享site-packagesmacOSpython.org 安装器同样包含 free-threaded 变体如果之前装了brew版Python路径容易混注意which一下Linux用python-build-standalone提供的3.13t构建或从源码编译./configure --disable-gil --prefix/opt/python313t make -j源码编译需要额外安装依赖编一次大约20分钟装好后验证方式很简单直接看GIL是不是真的被禁用了python3.13t -c import sys; print(sys._is_gil_disabled())输出True就说明当前是free-threading模式。注意不要拿普通python3.13去跑普通版本这个函数返回False。这个检查也可以写进项目启动脚本里防止有人误用普通解释器跑需要并行性能的代码。2. 重构之前的架构盘点GIL时代哪些欠账该还了2.1 GIL时代三种并发方案的边界在动手改代码之前得先搞清楚自己的并发架构到底属于哪一类。GIL时代的并发方案大致就三种我直接列个对比表方案适用场景瓶颈与代价threading GIL网络请求、文件读写、数据库操作等I/O密集CPU密集时线程无法并行提升有限multiprocessingCPU密集计算进程间通信IPC序列化开销大数据复制启动慢asyncio高并发I/O、大量网络连接单线程事件循环CPU密集计算照样卡大部分真实项目其实是三者的混合体。我自己的项目就是一个典型外层用asyncio拉取数据中间用multiprocessing.Pool做CPU密集的文本清洗和匹配最终结果再汇总回主进程。这个结构在GIL时代完全合理但它的问题也很明显——每次分发给子进程的数据都要经过一次pickle序列化和反序列化处理完的结果又要再序列化传回来。项目里单条数据小但一天要处理几千万条IPC开销已经逐渐逼近计算开销本身。2.2 判断值得迁移的三个信号迁移是有成本的不是所有多进程代码都值得改。我自己的判断标准是三条CPU密集但IPC占比高。如果你的worker计算量不大但来回传数据占了大头free-threading多线程就能省掉序列化和进程切换收益非常明显。线程数量接近核数却仍跑不满CPU。GIL模式下CPU密集线程一旦超过1整体就是串行线程数再多也没用。换free-threading后同样的线程代码直接吃满多核。代码里有一堆围绕GIL设计的workaround。比如手动切分数据、复制大对象、维护进程池队列缓冲这些为了绕GIL写出来的复杂逻辑在free-threading下可以大幅简化。2.3 判断先别动的三个信号也有几个反方向信号遇到这些情况我建议先按兵不动项目是纯I/O密集。比如爬虫、API网关、实时推送asyncio已经是最优解free-threading带来的收益不大反而平添兼容性风险。计算核心在第三方扩展库内部。像numpy的矩阵运算、Pillow的图像处理、正则引擎的部分场景这些C扩展很多本来就会在计算时显式释放GIL多线程下已经能并行换free-threading收益极其有限。现有架构稳定且没有性能压力。重构永远有风险如果项目跑得好好的就为了跟上趋势去动底层并发模型不值得。3. 迁移实战从multiprocessing.Pool到ThreadPoolExecutor3.1 业务背景与迁移目标先交代一下我迁移的具体项目背景。这是一个批量日志清洗管道输入是几十万条JSON格式的业务日志每个worker负责处理一批数据做的事情包括JSON字段提取、正则匹配关键词、数量统计最后输出一份汇总报告。原架构是典型的multiprocessing.Pool map模式进程数等于CPU核数8核每批50条数据处理完一批再拉下一批。迁移的目标很简单去掉进程间序列化让多线程直接共享原始对象同时保证输出结果一致、崩溃率不增加。3.2 迁移前后的代码对照迁移前的代码大概长这样import json import re from multiprocessing import Pool PATTERN re.compile(r(error|warning|critical):\s*(.*)) def process_batch(batch): stats {error: 0, warning: 0, critical: 0} for item in batch: text item.get(message, ) for kind, desc in PATTERN.findall(text): stats[kind] 1 # 这里还做了大量文本分类和后续计算省略 return stats def main(): with open(logs.json, r, encodingutf-8) as f: logs json.load(f) batch_size 50 batches [logs[i:i batch_size] for i in range(0, len(logs), batch_size)] with Pool(processes8) as pool: results pool.map(process_batch, batches) # 汇总结果 total {error: 0, warning: 0, critical: 0} for r in results: for k in total: total[k] r[k] print(total)迁到free-threading之后核心改动很小import json import re import sys from concurrent.futures import ThreadPoolExecutor PATTERN re.compile(r(error|warning|critical):\s*(.*)) def process_batch(batch): stats {error: 0, warning: 0, critical: 0} for item in batch: text item.get(message, ) for kind, desc in PATTERN.findall(text): stats[kind] 1 return stats def main(): if not sys._is_gil_disabled(): raise SystemExit(请在free-threading模式下运行python3.13t) with open(logs.json, r, encodingutf-8) as f: logs json.load(f) batch_size 50 batches [logs[i:i batch_size] for i in range(0, len(logs), batch_size)] with ThreadPoolExecutor(max_workers8) as pool: results list(pool.map(process_batch, batches)) total {error: 0, warning: 0, critical: 0} for r in results: for k in total: total[k] r[k] print(total)表面上看核心改动就两个multiprocessing.Pool换成concurrent.futures.ThreadPoolExecutor、pool.map外面包一层list。但真正发生变化的是数据流——不再有进程间的pickle序列化process_batch里的batch就是主线程里的原始Python对象切片。对于上面这种纯计算、只读共享数据的场景多线程完全可以直接访问原始对象这是从代码层面直接看到的收益。3.3 迁移中最容易忽略的四个细节迁移后我踩了一堆坑把印象最深的四个写在这里fork和spawn的安全问题会消失但线程安全不会自动到来。多进程架构下每个worker有独立内存全局变量天然隔离。换成线程后所有全局对象变成共享需要全局搜索一遍所有读写共享状态的代码。正则和第三方模块的线程安全性。标准库的re模块在free-threading下本身是线程安全的但如果你用了自定义的C扩展去处理文本就要重新验证线程兼容。建议压测时多开几轮观察是否出现segfault。子进程的日志和信号处理不是白给的。多进程模式下子进程崩溃不会拖垮主进程换成多线程后某个线程的未捕获异常会直接中断整个进程。迁移时要给worker包一层异常捕获不能继续默认进程挂了还有主进程兜底。内存基线会涨。free-threading模式为了维护对象级别的并发安全每个对象会附带额外的锁元数据实测下来内存占用大约比普通模式高5%-15%大批量数据场景要留意。4. 锁与数据结构的深度改造GIL走了别把安全问题也带走4.1 GIL时代哪些安全其实是假象很多从GIL时代过来的开发者会有这样一个错觉只要不手动开锁多线程程序里单条语句都是原子操作所以不会出乱子。这个理解一半对一半错。先说对的地方list.append(x)、dict.get(key)这类单条字节码操作在GIL下确实是安全的因为在线程切换之前字节码已经执行完了。再说错的地方count 1包含了LOAD_GLOBAL、BINARY_OP、STORE_GLOBAL三条字节码GIL会在字节码间隔时切换线程。也就是说两个线程同时执行count 1时完全可能出现读到了同一个旧值分别加一后写回最终只加了一次的情况。这个问题在GIL时代就存在只是出现概率低、且大部分项目没有在高并发下暴露。换到free-threading后线程真正并行执行丢失更新的概率会呈数量级上升必须当作一等公民来处理。4.2 free-threading下的同步工具选择我自己的实践是三层递进从保守到激进第一层能用queue.Queue就不用自定义共享变量。任务分发和结果收集天然适合消息队列queue.Queue内部已经做了完整加锁生产者和消费者各写各的零心智负担。第二层必须共享状态时用threading.Lock显式包住临界区。比如汇总统计import threading _total {error: 0, warning: 0, critical: 0} _lock threading.Lock() def update_stats(stats): with _lock: for k in _total: _total[k] stats[k]别看不起这种土办法free-threading下它就是最可靠的方案。比任何天才级的无锁小技巧都靠谱。第三层数据本身做分区归约。与其在共享计数上锁来锁去不如让每个线程维护自己独立的统计字典最后一次性合并。这样既不需要锁又天然规避了并发写冲突——这是我在性能调优阶段发现的最有效手段。4.3 更深一层能不共享就不共享free-threading的核心价值是让线程真正并行但并行的前提是线程之间尽量不打架。如果一个程序80%的时间都在抢同一把锁那free-threading带来的并行收益就被抵消了大半。实战中我主要用三种手段减少共享读时共享、写时复制。原始数据比如那份几十万条的日志列表在任务分发后保持只读线程间共享没问题需要修改的字段在worker内部生成副本。分片处理。把大列表按线程数切成独立块每个线程只处理自己的切片互不交叉。这思路在multiprocessing时代是常规操作在free-threading下同样适用。合并阶段再汇总。每个线程返回自己的局部结果主线程在所有任务完成后统一合并。这样在线程执行期间完全没有跨线程的写冲突。我一直觉得无锁编程听上去很高级但在Python里更现实的路线是尽量别写要锁的代码。5. 依赖兼容性排查与C扩展的边界5.1 第三方库在free-threading下的兼容层级GIL被禁用后受影响最大的不是纯Python标准库而是带着C扩展的二进制库。原因在于GIL原本还给C扩展提供了一层不用考虑并发的保护。free-threading下C扩展要么自己在C层面处理并发要么明确标注线程安全。我实测时把依赖分了几个层级层级代表库实测结果完整兼容标准库、sqlite3、json、re、collections直接用无异常有条件兼容numpy、pandas、Cython编译的模块需要安装对应free-threading版本的wheel官方标准wheel默认不兼容待适配numba等JIT库、部分依赖私有C API的库会报错或崩溃需要等待上游支持完全阻塞一些老旧C扩展import即segfault只能换库或等重编译一个判断要点纯Python包在free-threading下反而没有太多问题因为解释器层面的对象锁已经替它们管好了基础安全。真正要花时间排查的是那些底层带二进制编译的库。5.2 兼容性排查的流水线依赖库是不是兼容我建议按下面四步走可以省很多时间检查构建python3.13t -c import sys; print(sys._is_gil_disabled())确保自己在free-threading环境。逐个导入写一个小脚本把项目用到的所有第三方库逐个import一遍出现异常就记下来。跑一轮冒烟测试重点测数据密集型操作比如numpy矩阵运算、pandas的DataFrame读写、正则的批量匹配。free-threading下数据竞争往往在压力下才暴露单次调用可能没问题。崩溃定位如果出现segfault优先怀疑C扩展可以把核心计算切换到纯Python实现试跑如果不再崩溃基本就是C扩展的问题。5.3 自建C扩展的适配方向如果你自己的项目里有C扩展适配的核心思路是不要默认GIL保护显式管理好对象的引用计数和锁。比如Cython代码里可以用with nogil:声明无GIL区域手写C扩展时需要注意跨线程借用Python对象时的引用计数原子加减问题必要的话引入解释器提供的线程锁接口。这块已经是比较深的水域大部分应用开发者不需要走到这一步但如果你的项目依赖自研扩展迁移前一定要预留这部分的时间。6. 性能实测与调优数据告诉我们改对了没有6.1 基准测试设计迁移完成后我做了很系统的性能对照测试。测试环境是一台8核16线程的Linux服务器Python版本是3.13.0tfree-threading构建和3.13.0GIL构建对比。测试任务设计了三组纯CPU密集用正则表达式对100万条日志文本做关键字匹配替换。混合负载CPU计算之外还要读写共享的统计表和输出文件。高频数据交换模拟worker之间需要反复传递中间结果的场景。单次执行的结果大致如下数字仅代表我当前这一版代码与测试环境的相对表现场景GIL单线程GIL多线程free-threading多线程多进程纯CPU密集30s31s9s10s混合负载28s26s11s17s高频交换22s23s14s超30s这里面最值得看的是多进程对比free-threading多线程纯CPU场景几乎持平混合负载和高频交换场景多线程版本优势非常明显主要赢在省掉了序列化和进程切换的开销。6.2 从数据看调优方向测试里也暴露了free-threading模式下三个比较明确的调优方向线程数不宜盲目拉满。我一开始设max_workers16结果并没有比8快反而因为线程调度和锁竞争变慢。对8核机器CPU密集任务设8到10个线程就够了。任务粒度要适中。批处理每批50条日志时线程间分配开销占比很高把批大小调到500条后吞吐量明显提升。太小会让线程频繁切换太大又会造成末尾空闲。留意GC开销。free-threading下跨线程引用的对象需要更频繁地协调引用计数垃圾回收的暂停时间在部分场景会变长。如果任务能分片隔离尽量不让对象在线程间频繁流转。6.3 个人实战体会如果让我重新选择一次我不会一上来就改代码。我会先在free-threading环境里跑一遍现有项目的真实负载基准把热点函数一个个标出来确认瓶颈确实在GIL上再投入重构。毕竟迁移一个并发架构最花时间的从来不是那几行代码而是后续排查线程安全、重建兼容性测试、重新调优的全套工程化工作。但反过来说如果你已经确认瓶颈在这里这笔投入的回报也很直接——我这次迁移后同规格任务从半小时级降到了十分钟以内整个团队再也不用靠堆机器解决性能问题了。

相关新闻

邯郸泓动数据服务有限公司可以做AI搜索品牌曝光吗

邯郸泓动数据服务有限公司可以做AI搜索品牌曝光吗

核心结论: 邯郸泓动数据服务有限公司(以下简称邯郸泓动)可提供AI搜索场景下的品牌曝光相关GEO优化服务,该服务围绕主流大模型平台内容优化、精准触达潜在用户等维度落地,具备对应行业案例与标准化操作逻辑支撑。AI搜索…

2026/10/11 0:58:11 阅读更多 →
电动车改大灯哪个品牌稳定?碧烽值得选吗

电动车改大灯哪个品牌稳定?碧烽值得选吗

改大灯这件事,很多人上来就问"哪个牌子好"。但牌子响不代表装到你车上就稳。大灯用得住用不住,说到底就四件事:散热压不压得住、密封防不防水、供电跟不跟得上、安装是不是专车专用。哪一项凑合了,用几个月就可能出问题…

2026/10/11 0:58:11 阅读更多 →
深圳货车限行,冷链城配怎么排路线和时间才能不迟到?

深圳货车限行,冷链城配怎么排路线和时间才能不迟到?

深圳货车限行,冷链城配怎么排路线和时间才能不迟到?在深圳做冷链城配,绕不开货车限行这件事。市区不少路段白天货车不能走,商场、餐饮门店又大多白天收货,时间窗口卡得很死,走错一条路可能要绕几公里&#…

2026/10/11 0:57:09 阅读更多 →

最新新闻

做了10年计划排产,最后靠这三张表把排产管住了!

做了10年计划排产,最后靠这三张表把排产管住了!

很多计划员最怕的,不是订单多,而是计划永远赶不上变化。早上刚排好的计划,中午销售插单;下午采购说关键料没到;车间临时停机;老板又追着问订单为什么还没交。计划员只能不停改表、调设备、挪订单、发通知。…

2026/10/11 1:49:40 阅读更多 →
10年仓库管理经验:管、存、发、盘一文搞定!

10年仓库管理经验:管、存、发、盘一文搞定!

仓库最怕的不是货多,也不是人少,而是每天都在救火。 采购催入库,生产催领料,销售催发货,财务月底催对账,老板一问库存准不准,仓库主管只能翻表、找单、问人。 更麻烦的是,很多问题表…

2026/10/11 1:49:40 阅读更多 →
2小时,我搭了一套采购订单跟踪系统:下单、交期、到货、欠料一屏看清

2小时,我搭了一套采购订单跟踪系统:下单、交期、到货、欠料一屏看清

上午生产催料,下午仓库问货到没到,晚上老板又在群里追供应商交期。 采购说已经催了,供应商说下周到,仓库说只收到一部分,生产说明天就要用。 最后所有人一起翻聊天记录、查Excel、找邮件,忙了一圈&#xff…

2026/10/11 1:49:40 阅读更多 →
100个AI实验03:省时不等于省钱

100个AI实验03:省时不等于省钱

AI(人工智能)帮员工省下30%的工时,不等于替公司省下30%的工资。我拿一家公司“每月省27000元”的模拟方案测试AI:如果工资照发、订单没增加,这家公司第一年不是多赚钱,而是多支出84000元。 这不是说AI没用…

2026/10/11 1:49:40 阅读更多 →
OKR模板选错?4类组织约束决定落地成败

OKR模板选错?4类组织约束决定落地成败

简介:本资源为《20种OKR模板案例大全》PDF手册,面向企业管理者、HR、部门负责人及目标管理实践者,解决OKR落地难、缺乏岗位适配范例、难以分层设计目标与关键结果等实际问题。手册覆盖公司整体及市场、销售、人事、研发、产品、客户成功、客服…

2026/10/11 1:49:39 阅读更多 →
云上OpenClaw蜜罐新玩法:2H4G服务器极速部署TaoToken实战指南

云上OpenClaw蜜罐新玩法:2H4G服务器极速部署TaoToken实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 1:48:39 阅读更多 →

日新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 5:23:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 10:38:42 阅读更多 →