基于匿名管道实现Linux进程池:从原理到踩坑实践
如果你写过Linux下的多进程服务一定遇到过这种尴尬父进程要派活给子进程方案想了一圈——共享内存要加锁还要处理脏读消息队列得额外引库socketpair又感觉像杀鸡用了牛刀。其实一个pipe()调用就能解决大部分分发问题也就是标题里说的基于匿名管道实现的进程池。匿名管道这东西平时学的时候觉得简单无非就是pipe()返回两个fd一个读一个写fork之后父子进程各留一端。可真要拿它搭一套进程池框架会发现坑不少管道缓冲区的写入原子性、子进程崩溃时的EPIPE、多路分发时的负载均衡、进程退出的僵尸回收……每一处都有讲究。我前阵子从零搭过一版基于匿名管道的进程池不是为了在生产环境替换什么重型框架纯粹是想把这块的原理彻底吃透。这篇文章就把整套设计思路、关键代码、踩坑经过原原本本写出来适合正在学Linux多进程编程的同学也适合想自己造轮子的后端开发做参考。1. 为什么是匿名管道先看任务分发的几种常见姿势1.1 一个典型的分发场景先明确一下进程池要解决的问题。假设父进程是一个下载调度器需要把一批URL分给若干个子进程去并发下载下载完再把结果汇总回来。最直观的做法是请求来了现fork一个进程处理完就退出但高频场景下频繁创建销毁进程的开销非常大——fork要复制页表、初始化task_struct进程要经历完整的加载和释放周期吞吐根本扛不住。所以就有了进程池启动时一次性创建N个固定数量的子进程它们常驻内存等待任务父进程把任务分发下去子进程处理完再进入等待状态。这里面的核心问题就是父进程与子进程之间怎么通信。1.2 各方案横向对比我梳理过Linux下几种IPC方式在这个场景里的表现方案优点缺点适合场景共享内存 互斥锁吞吐高数据零拷贝可达编程复杂度高锁竞争调试困难脏读风险需要传输大量数据的场景System V / POSIX消息队列API语义清晰自带缓冲需要链接librt消息长度受限系统级资源管理麻烦简单点对点消息传递有名管道FIFO可跨进程不依赖父子关系需要文件系统路径多个读端写端时命名混乱两个独立进程间的通信socketpair全双工语义和socket一致对简单单向分发来说功能冗余需要双向交互的框架匿名管道创建成本极低fork天然继承阻塞语义天然适合等待任务单向传输只能父子/兄弟进程用父进程单向派发任务的场景单论父进程派活、子进程接活这件事匿名管道是最省事的不需要文件系统路径不依赖额外库pipe()加fork()就齐活了。而且管道自带缓冲区和阻塞语义子进程没准备好读取时父进程的写操作会自然挂起这就是天然的背压机制。1.3 我最终选型的理由我用匿名管道还有一个重要原因它支持多路复用。每个工作进程拥有一条独立管道父进程可以把所有管道的读端注册到poll()或epoll()上谁空闲就派活给谁彻底避免某个子进程累死、其他子进程闲死的负载不均问题。这个能力后面专门有一节细讲。2. 进程池的骨架管道布置与数据流向2.1 先搞清楚单条管道怎么在父子之间流转匿名管道的创建和继承很容易但有一个点非常重要单向性。管道数据只能从写端流向读端想在父子进程间双向通信必须创建两条管道。最基本的代码骨架如下int fds[2]; if (pipe(fds) -1) { perror(pipe); exit(EXIT_FAILURE); } pid_t pid fork(); if (pid 0) { // 子进程关闭写端只保留读端 close(fds[1]); read(fds[0], buf, sizeof(buf)); // 等待父进程写入 close(fds[0]); exit(0); } else { // 父进程关闭读端只保留写端 close(fds[0]); write(fds[1], task, sizeof(task)); close(fds[1]); waitpid(pid, NULL, 0); }这里的每个close()都不是多余的。你想想管道本身的读写端在内核里各自对应一个文件对象fork()之后父子进程各持有一份fd副本。如果不把子进程用不到的写端关掉那子进程自己手里还攥着写端的引用即使父进程关闭写端内核里管道写端的引用计数仍然不为零父进程那边read()永远等不到EOF。这个fd引用计数不归零导致意外阻塞的坑我在第3节详细展开。2.2 一父多子的管道布局进程池要管N个子进程就需要N条管道。常见的布局是父进程维护两个数组task_pipes: 父进程写 - 子进程读 reply_pipes: 子进程写 - 父进程读 // 如果需要回执每个子进程对应一对管道。结构体可以这样定义typedef struct { pid_t pid; // 子进程PID int task_fd; // 父进程写端派发任务 int reply_fd; // 父进程读端接收结果/就绪信号 int busy; // 标记是否繁忙 } worker_t;创建流程是父进程先pipe()创建任务管道fork()子进程子进程保留读端fds[0]关闭写端fds[1]父进程保留写端fds[1]关闭读端fds[0]按需再创建回执管道方向反过来有一说一这里的关闭操作一开始很容易搞混。我自己的经验是每次pipe之后立刻想清楚四条fd分别归谁用注释标在代码里后面调试会省很多事。2.3 任务负载的编码方式管道是字节流没有消息边界所以任务怎么编码是个关键设计。我见过有人直接把结构体塞进去写这样做短消息没问题但碰到跨平台或者结构体内有指针、字节对齐差异时读端解析就会出乱子。比较稳妥的方案有两种方案A长度前缀法typedef struct { uint32_t len; // 负载长度 uint8_t data[]; // 变长负载 } task_msg_t; // 写端 uint32_t len task_size; write(task_fd, len, sizeof(len)); write(task_fd, task_data, len);方案B固定分隔符法用约定好的分隔符比如\n切分任务适合文本协议。但任务是二进制数据时选这个方案会给自己找麻烦。我做下载调度器时负载就是一个包含URL、重试次数、超时参数的紧凑结构体直接用方案A定长头部加变长数据解析逻辑简单可靠。这里给个参考实现ssize_t write_task(int fd, const void *data, size_t len) { uint32_t nlen htonl(len); // 网络字节序避免大小端问题 ssize_t ret write(fd, nlen, sizeof(nlen)); if (ret ! sizeof(nlen)) return -1; return write_all(fd, data, len); // 循环写保证全部写入 }htonl转换看着多余但进程池以后可能要跨机器通信提前把字节序问题处理好未来扩展就少踩一个坑。3. 匿名管道读写最容易踩的四个坑这一节是这篇文章含金量最高的部分全是我实际调试时碰到的问题每个都值得单独记一笔。3.1 坑一缓冲区满导致的写阻塞管道的写操作有个特点内核缓冲区写满后再write()会阻塞。缓冲区默认大小在Linux上是65536字节64KB这个数值可以通过fcntl查询int sz fcntl(fd, F_GETPIPE_SZ); // 输出通常为 65536我调试时遇到的现象是父进程一次性给多个子进程派发大量任务写满了某个子进程管道的缓冲区父进程阻塞在那个子进程的write()上导致后面的任务全部卡住。表面看是父进程卡死根因却是子进程消费太慢。这个问题有两种解法解法1拆分大任务。单次写入控制在一个合理范围内比如8KB避免瞬间灌满缓冲区。解法2把写端设为非阻塞缓冲区满时返回EAGAIN把任务暂存到父进程侧的任务队列里等子进程消费掉一部分再补发。我后来选了解法2配合poll()统一管理逻辑上更接近生产级的进程池。3.2 坑二子进程退出后触发SIGPIPE父进程被静默杀死这个坑最隐蔽。子进程异常退出崩溃或被人为kill后父进程再往管道写端write()内核立马向父进程发送SIGPIPE信号。这个信号的默认行为是终止进程——对父进程直接挂了而且往往没有任何错误日志排查起来像见鬼。一旦出现SIGPIPE默认行为没有打印任何信息这个线索就该立刻反应过来。预防办法是启动时忽略或捕获它signal(SIGPIPE, SIG_IGN);这样write()会返回-1errno被设为EPIPE你就能在业务代码里优雅处理某个worker挂了这件事了。3.3 坑三read()返回0≠数据结束read()返回0表示读到EOF也就是对方关闭了写端fd。但管道关闭写端和没有数据可读是两码事。很多人写循环读管道时代码是while (read(fd, buf, sizeof(buf)) 0) { handle(buf); } // 走到这说明对方关闭了写端这里有个隐患管道没有数据时read()会阻塞而不是返回0。只有当写端fd全部关闭后read()才会返回0。父进程如果长时间不派发任务子进程就会一直阻塞在read()上这不是问题但如果逻辑上写错了关闭时机子进程会提前拿到EOF空转退出。我踩过的具体场景是进程池启动时一次性fork了8个子进程但只派发了4个任务另外4个子进程在read()上阻塞等待。由于父进程代码里有个bug在派发完第一批任务后误关了所有管道写端导致剩余子进程全部读到EOF退出池子瞬间只剩一半工人。这就是典型的关错关闭时机。3.4 坑四多个写者并发写同一条管道如果多个线程或进程往同一条管道写数据write()的原子性就要特别注意。Linux管道对于不超过PIPE_BUF通常为4096字节的写入是原子的超过则可能交错。进程池场景下多条管道各自独立天然避免了多写者问题。但如果你偷懒让多个父进程线程共享一个写端fd去派发任务超过4KB的负载就可能出现数据交错。我的建议是每条管道只有唯一的写者和唯一的读者不要在管道上做任何共享写入。4. 任务分发策略从轮询到真正意义上的负载均衡4.1 最朴素的轮询分发管道布置好了任务分发策略是个绕不开的问题。最朴素的想法是轮流派发int next 0; for (i 0; i tasks; i) { write(workers[next].task_fd, task, len); next (next 1) % worker_count; }这种轮询在任务耗时均匀时效果还行但现实里任务耗时不可能均匀——有的URL 2毫秒就下载完有的要卡2秒。轮询模式下分配到2秒耗时任务的子进程还在埋头处理下个任务又来了全部堆在同一进程头上其他子进程反而闲着吞吐就废了。4.2 用poll监听空闲就绪信号做动态分发我的做法是每个子进程在处理完一个任务之后往自己的回执管道写一个字节随便什么值比如R表示我空闲了。父进程这边用poll()统一监听所有回执管道的读端哪个有数据就说明哪个子进程空闲立刻给对应任务管道派发新任务。核心伪代码struct pollfd fds[N]; for (i 0; i N; i) { fds[i].fd workers[i].reply_fd; fds[i].events POLLIN; } while (1) { int ready poll(fds, N, -1); for (i 0; i N; i) { if (fds[i].revents POLLIN) { char c; read(workers[i].reply_fd, c, 1); // 消费就绪信号 dispatch(worker[i]); // 派发下一个任务 } } }这套机制的效果是动态的哪个子进程干完活了就立刻补上新任务谁都不闲着也不存在任务在某个进程上排队。我第一次跑通这个模型时吞吐量相比轮询提升了接近40%而且不需要任何动态调整进程池大小的复杂算法纯靠调度策略优化就吃满了CPU。4.3 假如任务需要返回结果怎么办前面说的是单向派发任务如果子进程处理完需要把结果回传给父进程回执管道那一个字节就不够用了。两种做法长度前缀法回传子进程先写结果长度再写结果内容父进程按长度读取。只回传完成事件结果数据放到共享内存或临时文件回执管道只发送完成信号父进程收到后再去对应位置取数据。我在下载调度器里用的是第二种任务结果直接写进一个预分配的内存映射文件区域回执管道只传索引。这样管道数据量极小吞吐非常稳定。5. 子进程的退出与回收进程池的生命周期管理5.1 正常关闭的流程进程池不能无限跑收到退出信号时得优雅地释放所有子进程。正常的关闭顺序是父进程向所有任务管道写一个退出指令或直接关闭写端子进程read()读到EOF或退出指令后清理资源并调用exit(0)父进程用waitpid()回收所有子进程这里有个细节如果父进程直接close()所有任务管道的写端read()返回0的作用是让子进程知道不会再有新任务来了这是最简洁的退出方式。注意要在所有子进程都完成各自任务后再关闭写端否则会坑三里说的一样子进程提前退场。5.2 SIGCHLD信号处理与僵尸进程子进程退出后如果没人回收会变成僵尸进程。僵尸进程虽然不占CPU但残留task_struct进程表资源迟早被耗光。常规做法是捕获SIGCHLDvoid sigchld_handler(int sig) { int saved_errno errno; while (waitpid(-1, NULL, WNOHANG) 0); errno saved_errno; }WNOHANG表示非阻塞轮询回收一次回调里把所有已退出的子进程全部收割。这里**保存和恢复errno**是个小细节中断处理函数中改变errno会影响正在执行的系统调用判断新手很容易忽略。5.3 子进程崩溃后的自动重启生产级进程池要能自愈。父进程某个子进程异常退出后在SIGCHLD处理函数里能拿到退出的PID但注意SIGCHLD信号不会告诉你哪个进程退了你只能靠waitpid()的返回值反查。我的做法是在handler里遍历workers[]数组比对pid与waitpid()返回值找到对应的worker后立刻fork()新进程替换。这里有个优先级问题waitpid()要优先于重新分配任务的逻辑否则新任务派发到一个已死进程的管道写端瞬间触发EPIPE。6. 性能实测与优化方向6.1 一次简单的压测数据我用一套参数做过简单基准测试环境Linux 5.154核8线程CPU进程池固定4个子进程任务内存中模拟耗时运算时间戳计算加字符串处理对比对象线程池pthread与进程池匿名管道结果很有意思吞吐量上进程池大概能到线程池的85%左右——毕竟每次数据传递多了两次内核缓冲区的拷贝、还有进程上下文切换的开销。但进程池的隔离性带来的稳定性在某些场景下比那15%的性能缺口更值钱。比如任务里有一段不可信的库代码崩了只影响一个子进程重建一个就行线程池一个段错误直接带走整个进程。管道的吞吐能力本身并不是瓶颈。Linux管道的实际吞吐量能到几百MB/s甚至更高你如果只是用管道传任务描述和完成信号数据量撑死几KB真正的开销在进程间上下文切换。6.2 优化方向改成socketpair实现全双工匿名管道最大的限制是单向。你如果既要派发任务、又要回收结果、还想传递流式数据可以每个worker用一对socketpair(AF_UNIX, SOCK_STREAM, 0, sv)替代两条管道。socketpair是全双工的一套fd就能双向读写API和管道几乎一样。理论性能上和管道差距很小。好处是不用管两条管道各自的缓冲区状态代码更清爽。坏处是语义上比管道复杂那么一点——你得把一条双向通道自己设计好协议边界不像两条单向管道那样方向强制清晰。6.3 优化方向epoll接管所有fd进程池规模一大比如上百个workerpoll()每次全量扫描fd数组就会有点吃力。换成epoll是标准解法int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; for (i 0; i N; i) { ev.data.fd workers[i].reply_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, workers[i].reply_fd, ev); }用epoll_wait()替代poll()活跃fd再多也只用处理就绪的那几个。实测下来worker数超过50后epoll事件回调的次数明显比poll全量扫描少CPU占用自然降下来。6.4 更进一步管道只做信号数据走共享内存如果你对性能非常较真可以把架构改成共享内存存数据、管道传控制。任务数据放入一块预分配共享内存管道只传任务序号或者起始偏移量子进程凭序号去共享内存取数据。这样管道流量极小数据本身没有系统调用拷贝开销吞吐能上一个大台阶。代价是编程复杂度明显上升要处理共享内存的分配、释放和并发安全还要防止任务处理过程中数据被覆盖。我个人的判断是在数据量需要超过100MB/s的场景才值得付出这个复杂度一般的任务分发用纯管道就足够了。写在最后的一点体会我把这套进程池跑起来之后最大的感受是匿名管道就像本分老实的管道工人你把任务交给他他按顺序送达从不偷工减料但也不会主动帮你多干别的。它的简单既是优点也是边界——适合做清晰、单向、低延迟的任务分发不适合当万能通信总线。如果你刚接触这块建议不要一上来就套epoll加共享内存的复杂架构。先用两条匿名管道跑通一个父进程对四个子进程的任务分发再逐步加入负载均衡、崩溃重启、多路复用每一步的坑都亲自踩一遍。这个循环走下来你对Linux进程模型的体感会跟看书完全不一样。后面等这套框架再成熟一些我可能会在里面尝试加入任务优先级和动态扩缩容到时候再开一篇专门分享。

相关新闻

轻量级服务器管理面板1Panel:安装、反向代理与HTTPS配置指南

轻量级服务器管理面板1Panel:安装、反向代理与HTTPS配置指南

说实话,我是在一台 2 核 2G 的小机器上,才真正体会到“轻量级服务器管理面板”这句话的分量。早年间用过不少面板,装完光常驻内存就吃掉四五百兆,再跑一两个业务进程,机器恨不得天天向你证明什么叫资源焦虑。后来换了 …

2026/10/9 3:17:03 阅读更多 →
ChatGLM大模型微调实战:单卡LoRA从环境配置到部署避坑指南

ChatGLM大模型微调实战:单卡LoRA从环境配置到部署避坑指南

简介:面向ChatGLM系列大模型微调需求的实践资源包,聚焦AI大模型应用与自然语言处理场景,适合正在学习或落地大模型微调的开发者、算法工程师与科研人员。压缩包内共148个文件,以58个Python脚本、36个Jupyter Notebook为核心&#…

2026/10/9 3:17:03 阅读更多 →
opensrc 0.7.2 Windows x64 下载:定位依赖包对应版本的源码

opensrc 0.7.2 Windows x64 下载:定位依赖包对应版本的源码

opensrc 0.7.2:查依赖实现时先对齐源码版本 下载入口:opensrc 0.7.2 Windows x64 EXE 先经过草料提示页,点击“继续访问”进入夸克分享,文件名为 opensrc-win32-x64.exe。也可在官方 v0.7.2 发行页下载对应资产。 为什么需要专…

2026/10/9 3:16:02 阅读更多 →

最新新闻

基于Deepseek Harness的防幻觉电源设计Agent实践

基于Deepseek Harness的防幻觉电源设计Agent实践

我一直在做电源相关的硬件设计,这两年深度用大模型辅助设计之后,发现一个很尴尬的问题:模型给出的方案,听起来头头是道,但落到具体元器件参数、环路补偿、热计算上,经常一本正经地编数据。有一回我让模型推…

2026/10/9 4:21:45 阅读更多 →
Java Arrays工具类全解:从sort排序到parallelPrefix,一文吃透数组操作

Java Arrays工具类全解:从sort排序到parallelPrefix,一文吃透数组操作

凡是写过Java的人,基本都绕不过数组。不管是学习阶段做的图书管理系统,还是工作里处理批量数据、做算法题、写接口返回结构,数组都是最底层、最常用的一块积木。但很多人在实际开发里对数组的处理方式其实非常原始:复制一个数组要…

2026/10/9 4:21:45 阅读更多 →
110kV线路三段式相间距离保护整定计算与Simulink仿真实践

110kV线路三段式相间距离保护整定计算与Simulink仿真实践

电气工程专业的学生或者刚入行的继保新人,大概率都接过这样一张任务书:110kV线路三段式相间距离保护,要求Simulink仿真,外加一份详细研究报告。这题目看着经典,但真要动手,会发现卡壳的点全藏在细节里——整…

2026/10/9 4:21:45 阅读更多 →
无需开发板:STM32驱动DS18B20与OLED仿真实战

无需开发板:STM32驱动DS18B20与OLED仿真实战

/* 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 4:21:45 阅读更多 →
PS5手柄驱动与固件解析技术指南

PS5手柄驱动与固件解析技术指南

我无法根据当前输入生成符合要求的博文。原因如下:项目标题“AnyPS5”缺乏明确指向性,未说明其性质(是工具、项目、社区、改装方案、模拟器相关?);项目正文为空,无任何功能描述、技术背景或使用…

2026/10/9 4:21:45 阅读更多 →
信息学奥赛买笔问题:贪心策略与分支结构详解

信息学奥赛买笔问题:贪心策略与分支结构详解

2059:【例3.11】买笔,这道题我相信学过信息学奥赛的同学都不陌生。它是《信息学奥赛一本通》第三章选择结构部分的经典例题,题目讲的是买笔这件事——钢笔5元一支、铅笔2元一支、圆珠笔1元一支,要求三种笔都至少买一支&#xff0c…

2026/10/9 4:20:44 阅读更多 →

日新闻

Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南

Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南

Java时间API这个话题,隔三差五就会在群里被翻出来讨论一次。上周还有个同事线上处理一个订单超时问题,排查到最后发现是ZonedDateTime序列化后时区丢了,用户在下单当天晚上看到的时间整整差了8个小时。这类问题几乎每个做Java开发的人都遇到过…

2026/10/9 0:00:49 阅读更多 →
EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错

EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错

前几个月我手头有好几台机器需要互相访问:办公室台式机、家里 NAS、还有一台云主机。如果只是偶尔传个文件倒还好,问题是工作场景经常要在几处环境之间来回切换,每次都先登录跳板机再层层代理,实在折腾。我先后试过端口映射、自建…

2026/10/9 0:00:49 阅读更多 →
AI Agent工程实战:从七要素到七个决策点的系统设计指南

AI Agent工程实战:从七要素到七个决策点的系统设计指南

AI Agent 这个词在过去一年里被反复提及,但真正动手搭过一套能跑起来的 Agent 系统的人都知道,从"知道它是什么"到"让它稳定干活"之间隔着一整套工程决策。我前后参与过几个 Agent 项目的落地,从最初用现成框架拼装&…

2026/10/9 0:01:50 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

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

2026/10/8 15:26:32 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

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

2026/10/8 15:26:40 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

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

2026/10/8 10:10:36 阅读更多 →

月新闻

我发现了一个新思路:用 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/8 21:13:17 阅读更多 →
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/8 15:26:17 阅读更多 →
黑夜航拍船只数据集训练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/7 13:34:55 阅读更多 →