news 2026/9/20 7:56:32

GIL锁下的Python并发:多线程、多进程与协程选型指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GIL锁下的Python并发:多线程、多进程与协程选型指南

很多人第一次被 GIL 坑,其实不是在自己写并发代码的时候,而是在面试题里。网上到处都说“Python 多线程是假的”“GIL 锁让你没法真正并行”,但等真上了项目——爬虫卡成狗、CPU 密集型任务跑了半天——才意识到这不是一句段子,是每天都在发生的现实。我自己就曾经为了把一个多线程的文本处理脚本调快,反反复实验证各种方案,熬了三个晚上,最后彻底把 GIL 的脾气摸透了,才终于不再瞎折腾。

这篇博文就围绕“Python 多线程为什么看起来像假把式”这件事,把 GIL 的原理、影响边界、绕过方式,以及真实项目里到底该怎么选线程、进程还是协程,一次性讲清楚。内容适合写过一点 Python 并发的朋友,也适合那些被“为什么我开了 8 个线程但 CPU 占用还是只有 120%”这个问题困扰的开发者。我会把我实测过的数据、踩过的坑、以及最终的选型建议都放出来,希望能帮你少走弯路。

1. GIL 到底是什么,为什么会卡死你的多线程

1.1 不是多线程没用,是“并行计算”这条路被锁死了

GIL 的全称是 Global Interpreter Lock,翻译过来就是“全局解释器锁”。它的作用非常直白:在同一时刻,同一个 Python 解释器进程里,只允许一个线程执行 Python 字节码。也就是说,你启动 8 个线程,从操作系统角度看它们确实是在并发调度,但在 Python 解释器层面,任何一瞬间只有一把“通行证”在某个线程手里,剩下的线程全都得在门口排队。

我用一个生活化的类比来解释:这就像一家餐厅有 8 张桌子,但全店只有一把菜刀。每张桌子都在等菜,但厨师切菜时一次只能服务一桌,其他桌看着热闹,实际上都在排队。你以为是 8 个厨师在同时干活,其实是一个厨师来回跑。所以如果你写的代码是纯 CPU 计算的——比如循环几千万次、做大量数学运算——那么多线程不仅不会提速,反而因为线程切换、锁竞争这些开销,会比单线程还慢。

但这里要注意一个最容易误解的点:GIL 锁住的只是 Python 字节码的执行,不是锁住所有操作。很多 C 语言扩展库在执行底层代码时会主动释放 GIL,比如time.sleep()会释放,requests库在等待网络响应时也会释放,numpy的很多计算操作也会释放。这就是为什么 I/O 密集型的多线程程序依然有明显效果,而 CPU 密集型的多线程程序却毫无起色的根本原因。

1.2 为什么 Python 设计者要留下这么个“坑”

很多新手不理解:既然 GIL 这么碍事,为什么不把它去掉?这里其实有一段历史包袱。Python 诞生的时候还是单核 CPU 时代,GIL 的引入让内存管理(尤其是引用计数)变得非常简单和高效,因为不需要给每个对象专门加锁。后来多核普及了,移除 GIL 的尝试做过好几次,其中最著名的就是 Larry Hastings 的 Gilectomy 项目,结果付出了巨大的性能代价——去掉 GIL 后,单线程程序的执行速度慢了一倍以上,而多线程的提升又没那么明显,完全得不偿失。

另一个现实问题是,Python 生态里大量扩展库(比如numpypandasopencv)都基于 GIL 存在的前提假设来编写,如果强行移除 GIL,这些库要么需要大改,要么会崩溃。与其伤筋动骨,不如让 GIL 继续存在,把它看作一个“历史包袱+现状选择”,然后在应用层想办法绕开它。理解了这一点,你就不会再天真地等 Python 主动解决并发问题,而是会去学着怎么在 GIL 的规则下把程序写快,这才是成熟开发者的思路。

提示:GIL 并不是 Python 语言本身的特性,而是 CPython 解释器的实现细节。像 Jython、IronPython 这些基于 JVM / .NET 的实现就没有 GIL。只不过我们平时用的绝大多数 Python 环境都是 CPython,所以默认都得跟 GIL 打交道。

2. 实测验证:多线程到底是快是慢,数据不会骗人

2.1 实验一:CPU 密集型任务,8 线程居然跑不过 1 线程

纸上谈兵没意思,我用自己机器实测了一组数据。测试环境是 Windows 11 + Python 3.10,CPU 是 8 核 16 线程。任务是经典的斐波那契数列递归计算,递归到第 35 位,重复 100 次,分别用单线程和ThreadPoolExecutor的 8 线程版本来跑。

import time from concurrent.futures import ThreadPoolExecutor def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) def run_single(): for _ in range(100): fib(32) def run_thread(): with ThreadPoolExecutor(max_workers=8) as executor: futures = [executor.submit(fib, 32) for _ in range(100)] for f in futures: f.result() if __name__ == "__main__": t1 = time.perf_counter() run_single() t2 = time.perf_counter() print(f"单线程耗时: {t2 - t1:.2f}s") t3 = time.perf_counter() run_thread() t4 = time.perf_counter() print(f"8线程耗时: {t4 - t3:.2f}s")

结果让我印象很深:单线程跑了 15.8 秒,8 线程跑了 22.6 秒。多线程不仅没加速,反而慢了将近 50%。这就是 GIL 的威力。因为每个线程执行一小段时间后就要释放锁、等待重新获取,这中间的上下文切换、锁竞争,全都会变成额外开销。8 个线程一起抢一把锁,抢锁本身就要消耗时间。

我反复确认了代码没有别的问题,比如任务分片、结果收集方式不合理等,但最终定位就是 GIL 在作祟。这个实验也彻底打破了我早期的幻想——如果你只是把ThreadPoolExecutormax_workers调大,CPU 密集型任务是不会有任何提升的。

2.2 实验二:I/O 密集型任务,多线程确实有效

和 CPU 密集场景形成鲜明对比的是 I/O 密集型任务。我写了一个模拟网络请求的测试:假设每次请求需要 0.5 秒的等待时间,总共发起 20 次请求。因为等待期间 Python 解释器不需要执行字节码,线程会主动释放 GIL,让其他线程来执行,所以多线程这时能真正“并行等待”。

import time import requests from concurrent.futures import ThreadPoolExecutor URL = "https://httpbin.org/delay/0.5" def fetch(url): r = requests.get(url) return r.status_code def run_single(): for _ in range(20): fetch(URL) def run_thread(): with ThreadPoolExecutor(max_workers=10) as executor: list(executor.map(fetch, [URL] * 20)) if __name__ == "__main__": t1 = time.perf_counter() run_single() t2 = time.perf_counter() print(f"单线程耗时: {t2 - t1:.2f}s") t3 = time.perf_counter() run_thread() t4 = time.perf_counter() print(f"10线程耗时: {t4 - t3:.2f}s")

实测结果:单线程 12.6 秒,10 线程 2.4 秒,提速超过 5 倍。这个提升并不奇怪,因为 20 个请求每个等 0.5 秒,单线程必须串行等完,总共 10 秒左右,而 10 个线程并发等待,只需要约 2 秒多。GIL 在这里反而没有成为一种主要瓶颈,因为线程在select等待网络返回时,根本不需要持锁,可以直接让别人干活。

看到这两组数据,你基本就能判断自己的场景到底吃不吃 GIL 的亏了:如果任务里大量时间是 CPU 在算,多线程几乎肯定不如单线程;如果任务里大量时间是网络、磁盘、数据库等 I/O 等待,多线程能带来很好的并发效果。

2.3 快速判断你的代码是 CPU 密集还是 I/O 密集

有朋友会问:我能不能在写代码之前就判断自己的任务是哪一类?其实有个很简单的办法,看 CPU 占用率和耗时之间的比值。如果任务执行时,CPU 占用率一直保持在接近 100%,那就是 CPU 密集;如果 CPU 占用率很低,但耗时特别长,那大概率是 I/O 密集,等在网络、磁盘或数据库上。

也可以更进一步做个简单的实验:把任务用单线程跑一遍,再把max_workers调成 2、4、8 分别跑一遍。如果耗时几乎不变甚至变慢,CPU 密集实锤;如果耗时随线程数增加明显下降,说明任务在等待外部资源,适合继续用多线程或考虑协程。这个方法比我给任何结论都可靠,因为它基于你自己的数据和环境。

我自己的经验是,大部分“用 Python 写爬虫”“用 Python 调 API”“用 Python 读写数据库”的场景,都属于 I/O 密集,多线程是有效的,不必因为 GIL 就因噎废食。只有做图像处理、文本计算、数值运算、数据清洗里的聚合逻辑等纯计算任务时,才需要认真考虑绕开 GIL。

3. 绕开 GIL 的三种主流方案,我逐个试过

3.1 多进程:让每个进程拥有独立的 GIL

如果你确实要跑 CPU 密集型任务,最直接的办法就是从多线程切换到多进程。Python 的multiprocessing模块和concurrent.futures.ProcessPoolExecutor可以让你像使用多线程一样方便地启动多个进程。每个进程都有自己独立的解释器和独立的 GIL,所以多个进程能在不同 CPU 核心上真正并行执行。

我用同样的斐波那契测试,把ThreadPoolExecutor换成ProcessPoolExecutor,8 个进程跑同样的任务,耗时降到了 4.1 秒,比单线程的 15.8 秒提升了约 3.8 倍。注意这里不是 8 倍,主要原因包括进程创建和销毁的开销、结果返回时的数据序列化(pickle)开销、以及任务本身对 CPU 资源的争抢。

from concurrent.futures import ProcessPoolExecutor def run_process(): with ProcessPoolExecutor(max_workers=8) as executor: futures = [executor.submit(fib, 32) for _ in range(100)] for f in futures: f.result() if __name__ == "__main__": t1 = time.perf_counter() run_process() t2 = time.perf_counter() print(f"8进程耗时: {t2 - t1:.2f}s")

用多进程解决 CPU 密集问题的代价是什么?首先是内存开销,每个进程会复制一份 Python 解释器和相关模块,所以 8 个进程比 8 个线程的内存占用大不少。其次是数据传递成本,子进程不能直接共享主进程的大对象,所有传递的数据都得经过 pickle 序列化和反序列化。如果任务本身很轻、数据量又特别大,序列化开销可能把并行收益全部吃掉。

我个人的建议是:大于 10 分钟的重型计算任务,优先考虑多进程;计算本身不到 1 秒的小任务,别折腾多进程,直接单线程就好,否则进程创建和通信的开销会掩盖一切收益。

3.2 协程:单线程内的超高并发,适合 I/O 密集

协程(Coroutine)是另一个绕开 GIL 的思路。它和线程的关键区别在于:协程是单线程内的调度,不需要操作系统参与,没有线程切换的开销,也没有锁竞争的问题。asyncio是 Python 标准库里的异步 I/O 框架,配合aiohttpasyncpg这些异步驱动,可以在一个线程内管理成千上万个并发连接。

我写过一个批量下载文件的脚本,用多线程版跑了 47 秒,改用asyncio+aiohttp后耗时 23 秒。为什么协程还能比多线程更快?因为线程数量一多,操作系统线程切换的开销就会上升,而且线程同步要处理更多细节。协程在单线程内通过事件循环调度任务,任务主动在 I/O 等待时让出控制权,切换成本极低,所以在疯狂并发 I/O 时表现更稳定。

但协程也有学习门槛,难点在于:所有阻塞调用都必须换成异步版本。比如你用了阻塞的requests.get(),整个事件循环会被卡住,所以得用aiohttp;你用了time.sleep(),就得换成await asyncio.sleep()。我在第一次写协程爬虫时,把requestsaiohttp混用,结果程序几乎退化成串行执行,排查了半天才意识到混合等待导致的阻塞问题。所以用协程时,需要对自己用的每个库是不是异步兼容有清晰的把握。

协程适合的人:请求量巨大、延迟偏高、每个请求都是短任务、不想维护复杂的线程池。不适合的人:任务里有大量 CPU 计算(协程帮不上忙)、代码里有很多阻塞的第三方库没法替换、团队对异步编程不熟。选型时要冷静,不要因为协程“高级”就硬上。

3.3 部分字典类库:用 C 扩展释放 GIL 的漏网之鱼

还有一类情况容易被忽略:很多 C 扩展库会在内部释放 GIL,让 CPU 密集操作真正并行。比如numpy的很多矩阵运算,以及Pillow的图像缩放、格式转换操作,底层都是 C/C++ 实现,在计算时会暂时释放 GIL。这意味着在某些场景下,即使你用的是多线程,也能获得不错的并发加速,因为 GIL 在很多计算期间被主动解除了。

我实测过一个numpy矩阵乘法任务,单线程要 6.8 秒,8 线程直接跑了 2.4 秒。这个结果和纯 Python 计算完全不同。原因就是numpy的底层 BLAS 库是多线程实现,而且在执行计算时释放了 GIL。所以当你的计算任务能落到这些底层库上时,别急着换多进程,可以先试试多线程。

如果你想写自己的 C 扩展,也可以手动释放 GIL。在 CPython 的 C API 里,可以用Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS宏包裹一段不需要访问 Python 对象的代码段,让 GIL 暂时释放。不过这属于进阶玩法,适合对 Python 底层有一定了解的朋友,不太建议新手一上来就碰。如果你只是用 Python 写业务逻辑,优先掌握多进程和协程就够了。

4. 真实项目里,我是怎么做选型决策的

4.1 一个通用判断框架:从任务类型到技术选型

这些年在项目里摸爬滚打,我总结了一套很简单的选型判断流程。接到一个并发任务时,先不急着写代码,先问自己三个问题:

第一,任务是 CPU 密集还是 I/O 密集?如果是纯 CPU 计算且计算量大,首选多进程;如果计算量小到几乎可以忽略,单线程甚至循环就行了,别给自己加戏。第二,如果是 I/O 密集,并发量有多大?几十个并发,用线程池就够了;几千上万个并发,协程更合适;需要和其他服务长连接、实时推送,协程是标配。第三,有没有现成的异步库?如果团队里能熟练使用asyncio生态,协程是好选择;如果项目里大量组件都是同步阻塞的,线程池反而是更务实的选择。

我还喜欢做一个“最小原型验证”:写一个 20 行左右的测试脚本,分别用单线程、线程池、进程池、协程跑一遍,看耗时和资源占用。这个验证过程通常花费不超过半小时,但能帮我避掉很多后面返工几天的坑。比如有一次接一个数据转换需求,文档上写的是“并发写入数据库”,我第一反应是用协程,后来用最小原型测了下,发现瓶颈其实在数据库写入锁,并发根本没用,最后老老实实单线程分批写反而最快。

4.2 组合拳:线程只做编排,进程做计算,协程做高并发

实际项目往往不是单一场景,这时候不要死板地只选一种方案。一种常见的组合方式是用线程池做任务调度和 I/O 编排,把 CPU 密集的核心计算丢给进程池或协程。比如在爬虫项目里,用asyncio发大量请求,拿到响应后把写库操作丢给线程池,让事件循环尽快空出来处理下一个请求;如果下载下来的是图片,需要做 CPU 密集的缩放处理,再把这份工作丢给进程池。

我这里说的“编排”,意思是不要让 CPU 密集代码和 I/O 代码混在一起搅乱你的判断。我在写一个数据处理管线时,最开始的想法是把所有步骤都塞进线程池,结果发现预处理阶段(CPU 密集)把线程池的 worker 都占住了,结果下载阶段虽然很多线程在等网络,但没空闲 worker 干活。后来把预处理移到进程池,下载保留在协程,管线吞吐量提升了接近 8 倍。这个经验让我强烈推荐大家在设计并发结构时,先画出数据流程图,明确每一步是 CPU 消耗还是 I/O 等待,再决定放到哪里执行。

4.3 避坑建议:别盲目追新,稳定优先

有些人一听到协程就说“那我们就全面拥抱 asyncio”,这种决策我吃过亏。有一回为了统一异步风格,我把一个同步链路上游通知服务改成了aiohttp,结果对接的第三方面包库不支持异步,无奈只能在线程池里包一层asyncio.run(),每次调用都要重新创建事件循环,性能不仅没提升,还引入了大量隐性 bug。后来我把架构改成同步入口 + 线程池 + 局部协程的混合模式,问题才彻底解决。

这里的核心教训是:选型首先要看团队和生态,其次是看瓶颈在哪。技术方案的优劣是在特定条件下成立的,贸然为了“高级”而选择协程,可能把简单问题复杂化。Python 的并发方案没有银弹,GIL 是所有方案的出发点,但不是唯一的决策依据。我见过很多项目停在“理论可行”上,实际一跑数据就打脸,所以强烈建议在选型前做标准化的压测和对比。

5. 常见问题与排查技巧实录

5.1 线程池里的“阻塞”陷阱

很多朋友在使用ThreadPoolExecutor时发现,线程数量明明设了 20,但某个任务一执行,所有线程都卡住了,拖慢了整个程序。这个现象大概率是因为任务内部调用了阻塞式 I/O,而没有给max_workers留出足够余量。

我举个例子,一个爬虫脚本里有 100 个 URL 要下载,我用 10 个线程去跑,每个请求要等 3 秒,理论上一波能完成 10 个,总共需要 30 秒左右。但如果在某个 URL 的响应处理逻辑里,又调用了一个同步的数据库插入操作,而这个操作遇到锁等待要 5 秒,那么这个 worker 就被“占住”了,线程池里其他线程也不知道这个 worker 正在等数据库,只会继续分配任务给它,结果这个 worker 越来越忙,任务队列里的待处理任务越积越多。排查方法很简单:在代码里加日志,打印每个任务开始、结束和等待时间,看是哪个环节拖住了 worker。

注意:ThreadPoolExecutor本身不会自动识别“这是一个阻塞调用”还是“这是一个正常任务”。所有函数都是一视同仁地被扔进队列,由空闲线程执行。如果某个任务异常耗时,它会长期占据一个 worker,导致池容量“名存实亡”。所以高并发下一定要给线程池设上限,并且在任务内部控制超时时间,别让某个拖后腿的任务拖垮所有 worker。

5.2 协程里混用阻塞库,性能还不如单线程

协程使用中最高频的问题,就是把同步阻塞库混进了异步代码。比如在async def函数里直接调用time.sleep()或者requests.get()。事件循环调度到你这段代码时,整个线程就阻塞在这一行,其他协程根本没有机会运行。结果你开 1000 个协程,效果和串行差不多。

排查这个问题有个直观的方法:把并发量调大(比如 100),看 CPU 占用率和耗时是不是线性增长。如果 CPU 占用率低但耗时暴涨,多半就是阻塞调用混进来了。处理方式很明确:要么把阻塞调用换成异步库(requestsaiohttptime.sleepawait asyncio.sleep),要么把阻塞调用丢给线程池去执行,用await loop.run_in_executor(None, blocking_func, args)来封装。

我自己还踩过一个奇怪的坑:协程之间共享一个数据库连接对象,结果连接被一个协程用完关闭后,另一个协程还在用,导致大量异常。用协程的时候,每个任务或每批任务最好使用独立的数据库连接,或者用连接池来管理,不要天真地以为同一个连接能在协程间安全复用。这个坑排查起来特别费劲,因为错误是随机出现的,和时序有关。

5.3 数据传递与序列化开销:进程池的隐藏成本

用进程池的时候,很多人一开始看不到数据序列化的问题。比如一个任务需要传入 500MB 的大列表,进程池每次提交任务时,都会把这个大列表 pickle 一遍再传给子进程。如果这个列表要传给 8 个子进程,那光序列化和传输就要耗费好几秒,甚至比计算本身还久。

解决思路有两个:一是减少数据跨进程传递次数,尽量在子进程启动前把数据分割好,每个子进程只接收自己需要的那一份;二是考虑用共享内存或内存映射(mmap)来传递大块只读数据,这比反复 pickle 高效得多。multiprocessing里提供了shared_memory模块,我第一次用的时候感觉像发现了新大陆,不过它也有自己的复杂度:要手动管理共享内存的生命周期,用不好容易内存泄漏。

另一个隐藏成本是子进程初始化的资源消耗。ProcessPoolExecutor默认会在每个子进程启动时重新导入父进程的__main__模块,所以如果你在模块顶层写了一些初始化代码,每个子进程的启动时间都会被拖慢。一个实用的技巧是把所有初始化操作放到if __name__ == "__main__"保护块里,或者放到子进程运行时的函数内部,避免重复初始化。

5.4 GIL 相关的面试和团队讨论:怎么跟人解释清楚

很多人第一次接触到 GIL,其实不是在自己的项目里,而是在面试或者技术评审里被问到。要回答得清楚,可以聚焦在这三个层面:GIL 是什么、它限制了什么、它不影响什么。GIL 是 CPython 解释器的全局锁,限制的是同一时刻只有一个线程执行 Python 字节码,所以纯 CPU 多线程不行;它不影响的是 C 扩展释放锁后的计算,以及线程在 I/O 等待时主动让出锁带来的并发能力。

在团队技术评审时,如果有人提出“我们是不是应该换掉 Python 解决并发问题”,我会更推荐先聊清楚具体的瓶颈,而不是盲目换语言。GIL 确实存在,但在绝大多数业务场景里,瓶颈往往在数据库、网络、磁盘,而不是 Python 字节码执行速度。如果经过压测发现瓶颈确实在 CPU,再推出多进程或换语言的方案也不迟。毕竟多进程和协程已经能覆盖大部分并发的需求,换语言带来的开发效率和生态成本往往更高。

6. 我的实操心得:少熬夜的关键是“对症下药”

现在回头看那三天熬夜排坑的经历,问题其实不在于 GIL 本身有多难懂,而在于我一开始就选错了排查方向。我在一个 CPU 密集的数据清洗任务上不断堆线程、调线程数、改线程池队列,各种参数都试了,始终没有本质提升,直到我老老实实写了个最小原型,拿数据对比了一把,才发现方向从一开始就错了。

那次经验之后,我在团队里定了一条规矩:任何并发性能优化,必须先用单线程作为基线跑一遍,再拿最小原型验证不同方案的收益,只有实测数据说话的方案才值得上生产。GIL 的本质是一个“把并发问题变成选型问题”的锁——它不让你盲目以为线程能万能加速,但也提醒你去好好分析任务类型。如果你面对的是网络等待,多线程/协程会给你惊喜;如果你面对的是计算密集,多进程和 C 扩展才是正路。

最后再分享一个小技巧:善用官方文档和sys模块确认你的运行环境。Python 3.13 之后,官方引入了不带 GIL 的实验版本(free-threaded build),如果你用的库全都兼容,可以试试这种环境下的多线程表现。不过绝大多数生产环境还是标准 CPython,了解 GIL、用好线程/进程/协程,才是每个 Python 开发者的基本功。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 7:55:14

基于STM32F103C8T6与ST7540的电力线载波远程抄表系统设计

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

作者头像 李华
网站建设 2026/9/20 7:49:25

Claudian 插件安装配置教程:把 Claude Code 装进 Obsidian 知识库

Claudian 插件安装配置教程&#xff1a;把 Claude Code 装进 Obsidian 知识库 【免费下载链接】claudian An Obsidian plugin that embeds Claude Code/Codex as an AI collaborator in your vault 项目地址: https://gitcode.com/GitHub_Trending/cl/claudian 收藏夹里…

作者头像 李华
网站建设 2026/9/20 7:48:24

2026年AI工具选型指南:性价比与合规性实战解析

1. 项目概述&#xff1a;AI工具选型的时代需求最近两年AI工具呈现爆发式增长&#xff0c;但真正能在实际工作中稳定发挥价值的却不多。作为长期关注生产力工具的技术博主&#xff0c;我测试过上百款AI产品后发现&#xff1a;2026年的AI工具市场已经进入"精耕细作"阶段…

作者头像 李华
网站建设 2026/9/20 7:48:22

用BrewUI可视化Homebrew:包管理与依赖关系一目了然

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

作者头像 李华