mongoose 多线程 HTTP Server 实战:per-thread 事件循环与连接迁移
简介本资源面向嵌入式开发与C语言网络编程学习者提供一份基于mongoose库实现多线程HTTP服务器的完整工程源码帮助理解高并发场景下线程池设计、线程同步、请求分发与错误处理等核心机制。压缩包共11个文件约221KB以cpp与h源码为主包含mongoose核心库文件、服务器主程序及VS2008工程配置另有可执行文件与说明文档便于直接编译运行与对照调试。目前已有3851人学习下载适合需要掌握mongoose 6.7版本多线程实现方式、并关注旧版代码升级迁移的开发者参考。通过阅读源码读者可快速搭建支持并发请求的轻量级Web服务理解线程池与互斥锁在实际项目中的落地方式为物联网设备或嵌入式系统的服务端开发提供可复用的工程模板与排错思路。1. 单文件也能扛住并发mongoose 多线程 HTTP Server 到底适合谁如果你手头有一个 C/C 项目需要塞进去一个 HTTP 服务端但又不想拖进 nginx、不想链接 OpenSSL、不想处理一堆 CMake 依赖那 mongoose 这个库大概率会被你翻出来。它最反直觉的地方在于整个 HTTP Server 的核心就是一个mongoose.c加一个mongoose.h把这两个文件丢进工程就能编译跨平台、零外部依赖还自带事件循环。但很多人第一次用它写服务端跑起来发现请求一多就排队CPU 多核完全吃不满——因为默认的mg_mgr_poll是单线程事件循环所有连接都在一个线程里处理。这篇要拆的就是怎么在 mongoose 之上做一个真正的多线程 HTTP Server。核心思路不复杂主线程只负责 accept把新连接分发给工作线程每个工作线程跑自己独立的mg_mgr事件循环。适合谁适合那些需要在嵌入式网关、边缘设备、内部工具后端里快速起一个 HTTP 接口又对二进制体积和依赖数量敏感的开发者。如果你已经在用 mongoose 但只跑过单线程 demo这篇的线程模型和踩坑记录能帮你少走一截弯路。2. 线程模型选型为什么不是简单加锁而是 per-thread event loop2.1 mongoose 的事件循环本质mongoose 的网络模型是围绕struct mg_mgr展开的。一个mg_mgr就是一个事件管理器内部维护着连接链表、定时器、以及底层 poll/epoll/kqueue 的封装。你调用mg_mgr_poll(mgr, timeout_ms)它就会阻塞等待网络事件然后回调你注册的fn处理函数。关键点在于一个mg_mgr实例不是线程安全的多个线程同时 poll 同一个 mgr 会导致连接链表被并发修改轻则丢事件重则直接崩溃。所以多线程方案的第一条铁律就是每个线程持有自己的mg_mgr互不共享。主线程acceptor有它自己的 mgr只用来监听和 accept每个工作线程也有自己的 mgr管理分配给它的那些连接。连接一旦从主线程的 mgr 上被 accept 出来就通过mg_mgr_*的迁移机制交给工作线程的 mgr。常见做法是固定线程数比如N sysconf(_SC_NPROCESSORS_ONLN)每个线程一个 mgr主线程 accept 后按轮询或最少连接数分发。不要动态创建线程mongoose 的连接迁移需要目标 mgr 已经在 poll 循环里跑着动态创建会引入同步窗口。2.2 连接迁移的两种实现路径mongoose 本身没有提供「把连接从 A mgr 移到 B mgr」的公开 API但它的内部结构允许你通过mg_wrapfd或者直接操作struct mg_connection的mgr字段来实现。更稳妥的做法是走文件描述符传递主线程 accept 到 fd 后不直接让 mongoose 接管而是把 fd 通过线程间队列发给工作线程工作线程用mg_wrapfd把这个 fd 包装成自己 mgr 上的连接。// 主线程accept 后把 fd 投递到工作线程队列 static void acceptor_fn(struct mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_ACCEPT) { int fd c-fd; // 从 mongoose 手里拿回 fd 的所有权 c-fd -1; c-is_closing 1; // 轮询选一个工作线程投递 fd int idx atomic_fetch_add(g_rr, 1) % g_worker_count; queue_push(g_workers[idx].fd_queue, fd); } }这段代码的逻辑是在MG_EV_ACCEPT事件里连接刚被 accept还没有任何数据读写。此时把c-fd置为 -1 并标记is_closingmongoose 会在本轮 poll 结束后关闭这个连接对象但不会关闭底层 fd因为 fd 已经被置 -1。然后把这个 fd 塞进对应工作线程的队列。工作线程在自己的 poll 循环里检查队列取出 fd 后用mg_wrapfd接管。参数说明g_rr是原子轮询计数器g_worker_count是工作线程数。队列需要是线程安全的可以用简单的互斥锁加链表也可以用无锁环形缓冲。注意c-fd -1之后不要再对这个连接做任何写操作它已经不属于你了。2.3 工作线程的 poll 循环与 fd 接管工作线程的主循环长这样// 工作线程poll 自己的 mgr同时检查 fd 队列 static void *worker_thread(void *arg) { struct worker *w (struct worker *)arg; struct mg_mgr mgr; mg_mgr_init(mgr); while (w-running) { // 先处理队列里新来的 fd int fd; while ((fd queue_try_pop(w-fd_queue)) 0) { struct mg_connection *c mg_wrapfd(mgr, fd, handler_fn, w); if (c NULL) { close(fd); // 包装失败直接关掉 } } // 再 poll 已有连接超时设小一点保证队列及时被检查 mg_mgr_poll(mgr, 10); } mg_mgr_free(mgr); return NULL; }这里有个参数需要权衡mg_mgr_poll的超时时间。设太大比如 1000ms新连接投递进来后要等最多 1 秒才被接管延迟不可接受设太小比如 1ms空转消耗 CPU。10ms 是一个常见折中实际项目里可以根据连接密度调整。如果队列用条件变量通知可以把超时设大但 mongoose 的 poll 不支持被外部事件唤醒所以还是得靠短超时轮询。mg_wrapfd的第三个参数是事件处理函数第四个是用户数据。包装出来的连接和正常 accept 的连接在使用上没有区别MG_EV_READ、MG_EV_WRITE、MG_EV_CLOSE都会正常触发。唯一要注意的是mg_wrapfd不负责设置非阻塞如果 fd 是阻塞模式需要自己调fcntl设成O_NONBLOCK。3. 从零搭一个可编译的多线程 Server文件结构与关键配置3.1 工程文件清单与编译参数这个方案不需要额外的构建系统一个 Makefile 就够。文件结构如下文件作用mongoose.c/mongoose.h从官方仓库获取的单文件库server.c主线程、工作线程、队列、业务 handlerMakefile编译脚本编译时需要注意几个宏CC gcc CFLAGS -O2 -Wall -pthread -DMG_ENABLE_THREADS1 LDFLAGS -pthread server: server.c mongoose.c $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS)MG_ENABLE_THREADS1会启用 mongoose 内部的线程安全机制主要是对mg_mgr的一些全局状态加锁。但注意这个宏并不会让单个 mgr 变成线程安全它只是让不同 mgr 在不同线程里跑的时候不会互相干扰。-pthread必须加在编译和链接两处否则原子操作和线程创建会出问题。3.2 业务 handler 的注册与路由工作线程接管连接后所有 HTTP 请求都会走到你注册的 handler。mongoose 的路由是基于事件回调的没有内置的 path 匹配需要自己写// 业务 handler处理 HTTP 请求 static void handler_fn(struct mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_HTTP_MSG) { struct mg_http_message *hm (struct mg_http_message *)ev_data; if (mg_match(hm-uri, mg_str(/api/status), NULL)) { mg_http_reply(c, 200, Content-Type: application/json\r\n, {\thread\:%d,\ok\:true}\n, ((struct worker *)c-fn_data)-id); } else if (mg_match(hm-uri, mg_str(/api/echo), NULL)) { mg_http_reply(c, 200, Content-Type: text/plain\r\n, %.*s\n, (int)hm-body.len, hm-body.buf); } else { mg_http_reply(c, 404, , not found\n); } } }mg_match是 mongoose 内置的简单模式匹配支持*通配。mg_http_reply会自动组装响应头和 body并设置Content-Length。注意c-fn_data在mg_wrapfd时被设成了 worker 指针所以 handler 里能拿到当前线程的 id方便调试和日志。参数说明hm-uri是请求路径hm-body是请求体。mg_http_reply的第二个参数是状态码第三个是额外的响应头每行以\r\n结尾第四个是格式化 body。如果 body 里有%字符记得用%.*s而不是%s避免格式化漏洞。3.3 优雅退出与资源回收多线程 server 最容易被忽略的是退出流程。主线程收到信号后需要通知所有工作线程停止然后依次 join。工作线程退出前要mg_mgr_free这会关闭所有连接并释放内存。但如果有连接正在处理请求直接 free 会导致对端收到 RST。常见做法是设一个running标志工作线程每轮 poll 检查一次。主线程收到 SIGINT 后把running置 0然后 join 所有线程。如果想让正在处理的请求完成可以在mg_mgr_free之前先遍历连接对每个连接调mg_http_reply发一个 503再等一小段时间。mongoose 没有提供「等待所有连接关闭」的 API所以这一步需要自己控制超时。// 主线程退出流程 static void on_signal(int sig) { g_running 0; for (int i 0; i g_worker_count; i) { g_workers[i].running 0; } } // 在 main 里 join 之后 for (int i 0; i g_worker_count; i) { pthread_join(g_workers[i].tid, NULL); }注意信号处理函数里只能做异步信号安全的操作置标志位是安全的但不要在里面调pthread_join或mg_mgr_free。4. 避坑与排查多线程 mongoose 最容易翻车的五个点4.1 现象请求偶发返回 404 或空响应日志里连接被莫名关闭原因主线程在MG_EV_ACCEPT里把c-fd置 -1 后没有正确设置is_closing或者工作线程的mg_wrapfd失败后没有关闭 fd导致 fd 泄漏。更隐蔽的情况是主线程的 mgr 和工作线程的 mgr 同时 poll 到了同一个 fd因为 fd 在迁移过程中被两边都持有产生竞争。解决确保c-fd -1和c-is_closing 1在同一轮事件里完成中间不要有任何可能让出 CPU 的操作。工作线程mg_wrapfd返回 NULL 时必须close(fd)。可以在迁移前后加日志打印 fd 编号和线程 id确认没有重复。4.2 现象CPU 占用 100%但 QPS 很低原因mg_mgr_poll的超时设得太小比如 0 或 1ms工作线程在空转。或者 fd 队列的检查逻辑放在了 poll 之后导致每轮 poll 都要等满超时才检查队列新连接延迟高线程却在忙等。解决超时设 10ms 左右并且把队列检查放在 poll 之前。如果连接数很少可以用mg_mgr_poll返回后判断是否有事件没有事件就稍微 sleep 一下。但 sleep 会引入延迟更好的做法是用eventfd或 pipe 唤醒 poll不过 mongoose 不直接支持需要改源码或接受 10ms 的延迟。4.3 现象压测时内存持续增长最终 OOM原因mg_wrapfd创建的连接在MG_EV_CLOSE时没有正确释放用户数据或者 fd 队列里的 fd 在 worker 退出时没有被清理。另一个常见原因是mg_http_reply的 body 太大mongoose 内部缓冲没有及时释放。解决在 handler 的MG_EV_CLOSE事件里释放自己 malloc 的内存。worker 退出时遍历 fd 队列把还没处理的 fd 全部 close。用 valgrind 或 ASan 跑一遍压测重点看mg_mgr_free之后还有没有 reachable 的内存块。4.4 现象跨线程调用mg_http_reply导致崩溃原因业务逻辑里可能从其他线程比如定时器线程、消息队列消费者直接拿到struct mg_connection *然后调mg_http_reply。这个连接属于某个工作线程的 mgr从其他线程写它的发送缓冲会破坏 mgr 的内部状态。解决所有对连接的写操作必须在拥有该连接的线程里做。如果其他线程需要推送数据把数据放到该线程的队列里由该线程在 poll 循环中取出并发送。mongoose 没有提供线程安全的跨线程发送 API这是设计上的取舍。4.5 现象编译报错undefined reference to pthread_create原因-pthread只加在了 CFLAGS 没加在 LDFLAGS或者用了-lpthread但顺序不对。在某些旧版 glibc 上-lpthread必须放在源文件之后。解决统一用-pthread编译和链接都加。如果用的是 CMake用find_package(Threads REQUIRED)然后target_link_libraries(server PRIVATE Threads::Threads)。不要手动写-lpthread除非你清楚链接顺序。5. 进阶技巧用连接数动态调线程、用 curl 验证线程亲和性5.1 动态线程数调整固定线程数在连接数波动大的场景下不够灵活。一个实用的技巧是主线程维护一个「待处理连接数」的原子计数当某个工作线程的队列长度超过阈值且总线程数小于上限时动态创建一个新工作线程当线程空闲超过一定时间且总数大于下限时通知该线程退出。实现上动态创建线程需要把新线程的 mgr 注册到一个全局数组里并且 acceptor 的分发逻辑要能感知到新线程。这里有一个坑新线程创建后它的 fd 队列是空的acceptor 需要等它初始化完成才能投递 fd。常见做法是用一个pthread_barrier或者原子标志位acceptor 检查标志位为 1 才把该线程纳入轮询。// 动态扩容检查是否需要新线程 static void maybe_spawn_worker(void) { int total atomic_load(g_worker_count); if (total MAX_WORKERS) return; int pending atomic_load(g_pending_conns); if (pending total * 100) { // 每个线程积压超过 100 连接 struct worker *w g_workers[total]; w-id total; w-running 1; queue_init(w-fd_queue); pthread_create(w-tid, NULL, worker_thread, w); atomic_fetch_add(g_worker_count, 1); } }参数说明MAX_WORKERS建议设为 CPU 核数的 2 倍再多线程切换开销会吃掉收益。pending total * 100这个阈值需要根据实际连接的生命周期调整短连接多的话阈值可以低一些。5.2 用 curl 验证线程亲和性多线程 server 上线前必须验证连接确实被分发到了不同线程。最简单的方法是在 handler 里返回当前线程 id然后用 curl 连续请求多次看返回的 id 是否变化。for i in $(seq 1 20); do curl -s http://127.0.0.1:8000/api/status | grep -o thread:[0-9]* done | sort | uniq -c如果所有请求都返回同一个 thread id说明分发逻辑没生效所有连接都落到了一个工作线程。可能的原因是 acceptor 的轮询计数器没有原子递增或者 fd 队列的 pop 逻辑有 bug 导致只有一个线程能取到 fd。另一个验证点是长连接。用curl的--http1.1和Connection: keep-alive发多个请求观察同一个连接上的多个请求是否始终由同一个线程处理。mongoose 的连接迁移是在 accept 时完成的后续同一连接的所有事件都会在同一个 mgr 上触发所以线程 id 应该保持不变。如果变了说明连接被错误地迁移了两次。5.3 一个我踩过的坑mg_wrapfd之后忘了设is_websocket如果你的 server 同时支持 WebSocket 和普通 HTTPmg_wrapfd包装出来的连接默认按 HTTP 处理。WebSocket 升级请求会走到MG_EV_HTTP_MSG但 mongoose 不会自动完成升级握手需要你在 handler 里判断mg_http_is_websocket并调mg_ws_upgrade。忘了这一步的话客户端会一直停在 101 之前表现为连接挂起。if (ev MG_EV_HTTP_MSG) { struct mg_http_message *hm (struct mg_http_message *)ev_data; if (mg_match(hm-uri, mg_str(/ws), NULL)) { mg_ws_upgrade(c, hm, NULL); // 必须显式升级 } }从那以后我每次用mg_wrapfd接管连接都会先确认业务里有没有 WebSocket有的话在 handler 第一层就做升级判断不然后面查起来就是黑匣子。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

用react-native-reanimated-dnd打造水平排序列表:滑动重排交互完整教程

用react-native-reanimated-dnd打造水平排序列表:滑动重排交互完整教程

【免费下载链接】react-native-reanimated-dnd A powerful, performant drag-and-drop library for React Native built on Reanimated 4 and Gesture Handler. Smooth 60fps animations, sortable lists & grids, collision detection, and full TypeScript support. 项目…

2026/10/15 15:13:58 阅读更多 →
Kubernetes网络本质:从Pod访问Service看三层模型协同

Kubernetes网络本质:从Pod访问Service看三层模型协同

1. 项目概述:从一个最朴素的请求出发,理解Kubernetes网络的本质“在pod中访问另外一个服务”——这句话看起来平平无奇,甚至有点像刚学完Docker后顺手写的curl命令。但当你把它放进Kubernetes环境里,它就成了一面照妖镜&#xff0…

2026/10/15 15:13:58 阅读更多 →
Flutter颜色系统全解析与最佳实践

Flutter颜色系统全解析与最佳实践

1. 为什么需要系统学习Flutter颜色在移动应用开发中,颜色管理看似简单实则暗藏玄机。我见过太多开发者因为颜色处理不当导致的应用被拒案例——某金融应用因为夜间模式颜色对比度不足被App Store驳回,某电商应用因为主题色在不同设备上显示不一致遭到用户…

2026/10/15 15:13:58 阅读更多 →

最新新闻

粮油方向论文降重:三类做法的实际体验对比

粮油方向论文降重:三类做法的实际体验对比

粮油方向的论文,重复率高的地方很固定:国标检测方法的步骤描述、原料特性的通用表述、工艺原理的铺垫段。这三类占了重复的大头。要降得干净,得先分清哪些能改、哪些碰不得。 一、先划"不能改"的线 这一步必须在动手前做&#xf…

2026/10/15 16:47:54 阅读更多 →
从零构建月球着陆模拟器:物理模拟与状态机实战

从零构建月球着陆模拟器:物理模拟与状态机实战

最近我把一个存了很久的想法落地了:做一个纯粹的月球着陆模拟器,项目代号就叫 Apollo-11。名字不复杂,但它把我的目标说得很清楚——让玩家用有限的燃料,手动控制一艘着陆器,在起伏的月面上找到一块平地,减…

2026/10/15 16:47:54 阅读更多 →
ESP32修复USB4000光谱仪变砖:EEPROM固件恢复实战

ESP32修复USB4000光谱仪变砖:EEPROM固件恢复实战

1. 项目背景与核心需求拆解1.1 一台“变砖”的光谱仪到底意味着什么手里有一台USB4000光谱仪,插上电脑之后系统毫无反应,设备管理器里连未知设备都不弹出来,这种状态在硬件圈子里通常被叫做“变砖”。光谱仪这类精密光学仪器,内部…

2026/10/15 16:47:54 阅读更多 →
905 nm激光测距模块UART驱动与ROS 2集成实战

905 nm激光测距模块UART驱动与ROS 2集成实战

1. 从一束不可见光说起:905 nm 测距模块到底在解决什么问题如果你玩过扫地机器人、做过无人机定高、或者给机械臂加过避障,大概率绕不开一个东西:激光测距模块。市面上常见的方案有超声波、红外三角测距、ToF 摄像头,还有一类就是…

2026/10/15 16:47:54 阅读更多 →
C# WinForms接口调试工具:HttpClient+SQLite工程化实践

C# WinForms接口调试工具:HttpClient+SQLite工程化实践

简介:这是一款基于C#开发的轻量级Windows桌面接口测试工具,面向.NET初学者、Web API开发者及测试人员,用于快速验证HTTP接口功能与响应行为。工具采用WinForm框架构建图形界面,原生支持GET、POST、PUT、DELETE四种标准请求方法&am…

2026/10/15 16:47:54 阅读更多 →
Transformer为什么强?解析全局建模、并行计算与多模态扩展优势

Transformer为什么强?解析全局建模、并行计算与多模态扩展优势

最近总有人问我同一个问题:Transformer 到底比其他模型强在哪。我做了几年序列建模相关的工作,文本分类、机器翻译、多模态对齐这些任务都反复折腾过,每次换架构、调参、对比实验做下来,对“强”字的理解其实和刚接触时不太一样。…

2026/10/15 16:46:53 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 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/15 0:14:36 阅读更多 →
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/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练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/15 9:49:14 阅读更多 →