news 2026/9/7 16:34:45

基于匿名管道实现Linux进程池:原理与完整代码实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于匿名管道实现Linux进程池:原理与完整代码实践

做Linux服务端开发或者平时写一些工具,并发处理任务基本是躲不开的。这些年我试过很多方案,从线程池到消息队列都用过,但有一个很经典的组合我一直很推荐新手认真吃透:匿名管道加进程池。它不依赖任何第三方库,就是Linux系统最底层的pipe、fork、wait这些原语,却能解决一个很实际的问题——让一组预先创建好的子进程,稳定地接收父进程派发的任务,再通过管道把执行结果回传。

这篇文章我打算把基于匿名管道实现进程池这件事,从原理到完整可跑的代码,再到我实际踩过的坑,一次性讲清楚。适合刚学完进程间通信想找个综合练手项目的人,也适合做嵌入式或者小型服务端工具、不想引入重量级消息中间件的开发者参考。看完你能拿到一个可以直接编译运行的小型进程池,并且理解它为什么这么设计。

1. 为什么用匿名管道做进程池

1.1 进程池到底解决什么问题

先想一个场景:你的主程序不断接到一些耗时的子任务,比如压缩图片、转换格式、批量计算结果。最简单的实现是来一个任务就fork一个子进程去处理。但fork是有代价的,它要复制页表、文件描述符表、进程内核结构,高频创建销毁会让系统开销变大,而且子进程数量完全失控,执行一个耗时任务可能瞬间拖垮整台机器。

进程池的思路就是把“创建进程”这件事提前做完。启动时一次性fork出固定数量的子进程,之后来了任务就往池子里派,谁空闲谁执行,执行完把结果交回来。这样进程创建开销被摊到启动阶段,运行期的资源占用是可控的,并发数也有上限。

我之前做过一个批量文件处理工具,一开始就是边接边fork,结果一整天下来光进程创建销毁就占了CPU一大截。改成进程池之后,子进程常驻,任务切换开销小了很多,系统负载也稳定了。

1.2 先盘点一下进程间通信的候选方案

做进程池之前,得确定父子进程之间怎么传任务、怎么收结果。Linux下IPC方案不少,我列个常用的:

方案适合场景缺点进程池里好不好用
匿名管道父子进程间单向数据传输只能父子/祖孙用,半双工非常合适,配合fork天然无缝
FIFO命名管道无亲缘关系进程通信需要文件系统路径,清理麻烦可以用,但匿名管道就够了
System V消息队列消息型数据传递生命周期需要手动清理可用,但API老旧且语义偏重
共享内存大块数据高速读写需要自己处理同步互斥进程池传小任务有点大材小用
信号通知事件能传的信息量极少只适合做控制信号,不适合传任务
socketpair全双工字节流语义类似管道但更长其实也是好方案,后面扩展会提到

对进程池来说,父进程把任务字符串交给子进程,子进程把结果字符串交回来,本质就是两个方向上的字节流。匿名管道虽然半双工,但我们可以建两条,一条下行传任务,一条上行收结果,正好覆盖需求,而且它和fork的配合是所有IPC里最自然的。

1.3 匿名管道的适用边界

要泼一盆冷水:匿名管道不是万能的。它最大的限制是只能用于有亲缘关系的进程,因为管道描述符是fork时继承的,没有血缘关系的两个进程拿不到同一管道的两端。其次它传输的是无格式字节流,如果任务数据有复杂结构,你得自己设计协议做序列化。

但在“父进程+一批fork出来的子进程”这个模型里,这些限制都不算问题。我们需要的就是一条简单可靠的字节通道,不需要复杂的数据格式,也没有跨进程通信的需求。匿名管道在这种情况下简单、高效、稳定,而且不需要任何额外配置,完全符合项目需求。

2. 匿名管道的关键机制与API细节

2.1 pipe() 创建的到底是什么

管道在Linux里的本质是一段内核缓冲区,从写入端写进去的字节,会按先进先出的顺序从读取端读出来。调用pipe()后,你会拿到两个文件描述符,fd[0]是读端,fd[1]是写端,表面看是文件操作,实际读写的是内核里的环形缓冲区。

这里有个很关键的理解:管道不是一个存文件的路径,它没有名字,也没有目录项,它只存在于内核中。而fd本身是一个整数,指向进程打开文件表中的某一项。fork的时候,子进程会把父进程的文件描述符表完整复制一份,所以父进程手里的管道读写端,在子进程里也有对应的副本。

这就是为什么匿名管道能用于父子进程通信的根本原因——fork把两端都复制过去了,双方都能碰见同一个管道缓冲区。

2.2 数据流与阻塞规则

管道读写有几个行为规则,用之前必须刻在脑子里:

  • 读一个空管道会阻塞,直到有数据写入或所有写端被关闭。
  • 写一个满管道会阻塞,直到有空间可用。
  • 当某端没有进程持有写端时,读端调用read会返回0,相当于EOF。
  • 当没有进程持有读端时,写端调用write会触发SIGPIPE信号,进程默认被终止。

这四条规则组合起来,就是管道编程里80%的坑的来源。尤其最后一条,很多新手写管道父子通信,哪边该关的fd没关干净,结果要么是read永远阻塞等不到EOF,要么是write收到了SIGPIPE整个程序直接退出。

还有一个参数值得一提:PIPE_BUF。在Linux上默认是4096字节,当一次write的数据长度不超过PIPE_BUF时,写入是原子的,多个进程同时write不会互相交错;超过这个值,内核不保证原子性。进程池里多个子进程都会往结果管道里写,我正是依赖这个原子性来保证每条结果消息不会乱串。

2.3 fork之后fd的“复制陷阱”

管道创建之后,父进程和子进程都会持有同一组fd。假设你只建了一条管道,fork之后没有做任何关闭操作,那么父进程手里有读写两端,子进程手里也有读写两端。这会出现两个问题:

第一,父进程想从读端读数据,但它自己也握着写端,那么即使所有子进程都写完了,父进程的read也永远不会返回0,因为写端还没全部关闭,管道不会形成EOF。

第二,所有进程都握着两端,数据流向变得混乱,你不确定某份数据是自己写给自己了,还是写给了别人。

所以fork之后的第一件事,一定是按角色关闭不需要的端。父进程要读结果,就关闭结果管的写端,子进程要写结果,就关闭结果管的读端。缺一个fd没关,整个程序行为都会变得诡异。我实现进程池的时候,专门写了一个函数来做fd的清理,每次fork前后都确认一遍,踩过的坑多了自然知道这个步骤有多重要。

3. 进程池架构怎么设计

3.1 通道规划

我的设计是每个子进程独立拥有一条下行管道,用于接收父进程派发的任务;所有子进程共享一条上行管道,用于把执行结果汇报给父进程。

为什么下行管道要一人一条?因为这样父进程可以把任务精确地指派给某个空闲子进程,实现对worker的主动调度。如果你只建一条下行管道让所有子进程抢读,那调度语义就变了,变成“谁抢到谁干”,父进程无法控制任务分配策略,也没法做负载均衡。

那上行结果管道为什么又要共享?因为结果是无序的,谁先干完谁先写,父进程不需要指定从哪个子进程收结果,读回来自然就知道是哪个子进程干的。管道单次write不超过PIPE_BUF时是原子的,所以多个子进程同时写结果也不会串数据。

结构上就是:每个worker持有自己的task_fd写端(父进程持有)、task_fd读端(子进程持有),以及共享的result_fd读端(父进程持有)、result_fd写端(每个子进程各持一份)。

3.2 任务协议

管道是字节流,它不管你在里面传的是字符串、结构体还是二进制数据。进程池里最常见的做法,是用文本行作为一条任务的边界,一行一个任务。

我用的协议非常简单:

  • 父进程向子进程的task管道写入一行字符串,例如“sum 1 2 3”或者“sleep 3”,末尾带换行符。
  • 子进程从task管道逐行读取,解析后执行任务。
  • 子进程向result管道写入一行结果字符串,例如“worker 12345 done, result=6”,末尾也带换行符。

文本行协议的优点是肉眼可读,出问题直接抓取管道数据就能排查;缺点是数据表达效率低,传大块二进制不合适。但对进程池这种任务分发场景,文本行已经够用,而且调试体验极好。

3.3 任务分发策略

父进程拿到一个任务,要做的事情是:遍历worker数组,找一个busy标记为0的子进程,把任务写进它的task管道,然后把该worker标记为busy。

如果所有worker都忙,父进程的策略取决于你的业务需求。可以做阻塞等待:父进程去读结果管道,回收其中一个worker的结果,然后继续找空闲worker。也可以做排队缓冲:把任务先存在内存队列里,等有worker空闲了再派发。我下面给的demo用的是阻塞等待,因为逻辑最简单直观,而排队缓冲只需要在外面套一层FIFO,有兴趣可以自己改。

分发策略如果复杂一点,还可以用最小负载、轮询、哈希等算法,但我建议一开始别搞花活,先老老实实遍历找空闲,跑通了再优化。

3.4 子进程生命周期

子进程不是干完一个任务就退出,它是一个常驻循环:读任务、执行、回报结果、再读下一个任务,直到收到退出指令。

我设计的退出指令是一个固定的字符串“exit”。父进程需要关闭进程池时,会向所有子进程的task管道写入exit,然后关闭所有task写端,接着读取结果管道直到EOF,最后用waitpid回收所有子进程。

这里有个细节:子进程读到task管道返回0(也就是所有写端都被关闭),也应该主动退出循环,这是一种兜底机制。万一哪次任务派发逻辑出了问题,没有发exit,但父进程已经关闭了写端,子进程至少不会永远卡在读管道上,它是可以自己感知到EOF并退出的。

4. 完整实现与代码解析

4.1 整体结构

我把代码拆成几个部分:worker管理结构、worker创建、任务分发、结果回收、进程池关闭。整个代码不依赖任何第三方库,gcc直接编译就能跑。

数据结构上,一个worker记录三样东西:子进程pid、父进程写入任务用的task_fd、当前是否busy。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #include <errno.h> #define MAX_WORKERS 8 #define MAX_TASK_LEN 1024 #define MAX_RESULT_LEN 1024 #define EXIT_CMD "exit" typedef struct { pid_t pid; int task_fd; /* 父进程持有,用于向该worker派发任务 */ int busy; /* 1表示正在执行任务 */ } worker_t; static worker_t workers[MAX_WORKERS]; static int worker_count = 0; static int result_fd = -1;

4.2 子进程主循环

子进程的逻辑很简单:用自己的task_fd读任务,执行任务,把结果写到共享的result_fd,读完返还,继续下一轮。

static void worker_loop(int task_fd, int result_fd) { FILE *fp = fdopen(task_fd, "r"); char line[MAX_TASK_LEN]; char result[MAX_RESULT_LEN]; if (fp == NULL) { _exit(1); } while (fgets(line, sizeof(line), fp) != NULL) { line[strcspn(line, "\n")] = '\0'; if (strcmp(line, EXIT_CMD) == 0) { break; } if (strncmp(line, "sum", 3) == 0) { long total = 0; char *p = line + 3; char *tok = strtok(p, " "); while (tok != NULL) { total += atol(tok); tok = strtok(NULL, " "); } snprintf(result, sizeof(result), "worker %d done, result=%ld\n", (int)getpid(), total); } else if (strncmp(line, "sleep", 5) == 0) { int sec = atoi(line + 5); sleep(sec); snprintf(result, sizeof(result), "worker %d sleep %ds done\n", (int)getpid(), sec); } else { snprintf(result, sizeof(result), "worker %d unknown task: %s\n", (int)getpid(), line); } if (write(result_fd, result, strlen(result)) < 0) { perror("write result"); break; } } fclose(fp); close(result_fd); _exit(0); }

这里有两个细节值得注意。第一个,我用fdopen把task_fd包成FILE*,是为了方便用fgets做逐行读取,不用自己维护半包缓冲。但fdopen之后,这个FILE*会接管fd,所以退出时用的是fclose而不是close。

第二个,每次往result管道写结果前,我都先把结果格式化到一个栈缓冲里,然后一次write写完。这样做的原因是保证单次write长度远小于PIPE_BUF,避免多条结果在管道里互相交错。

4.3 创建worker

创建worker的关键点是:先建管道,再fork,然后在父子进程中各关掉不需要的一端。顺序不能乱。

static int create_worker(int idx) { int task_pipe[2]; pid_t pid; if (pipe(task_pipe) < 0) { perror("pipe task"); return -1; } pid = fork(); if (pid < 0) { perror("fork"); close(task_pipe[0]); close(task_pipe[1]); return -1; } if (pid == 0) { /* 子进程:关闭task写端,只保留读端 */ close(task_pipe[1]); /* 关闭result读端,保留result写端 */ close(result_fd); worker_loop(task_pipe[0], result_fd); _exit(0); } /* 父进程:关闭task读端,只保留写端 */ close(task_pipe[0]); workers[idx].pid = pid; workers[idx].task_fd = task_pipe[1]; workers[idx].busy = 0; return 0; }

result管道要在创建worker之前,由父进程单独创建。我把它放在init_pool里:

static int init_pool(int count) { int result_pipe[2]; if (count <= 0 || count > MAX_WORKERS) { fprintf(stderr, "worker count must be 1..%d\n", MAX_WORKERS); return -1; } /* 先忽略SIGPIPE,避免写管道时因为对端关闭而直接退出 */ signal(SIGPIPE, SIG_IGN); if (pipe(result_pipe) < 0) { perror("pipe result"); return -1; } /* 父进程保留result读端,关闭写端 */ result_fd = result_pipe[0]; close(result_pipe[1]); worker_count = count; for (int i = 0; i < count; i++) { if (create_worker(i) < 0) { return -1; } } return 0; }

看到没有,result管道的写端是在每个子进程里继承并保留的。父进程创建完result管道后就立即关闭了自己的写端,所以管道的EOF语义是:当所有子进程都关闭/退出了写端,父进程在result_fd上read才会返回0。

4.4 任务分发与结果回收

dispatch_task负责找空闲worker并写入任务。如果所有worker都忙,父进程会先去阻塞读取一条结果,把对应的worker释放出来,再尝试继续分发。这种方式牺牲了一些并发调度灵活性,但代码最简单,容易理解。

static void reap_result(void) { char buf[MAX_RESULT_LEN]; ssize_t n = read(result_fd, buf, sizeof(buf) - 1); if (n <= 0) { return; } buf[n] = '\0'; /* 结果格式是 worker <pid> ...,解析出pid */ int pid_val = 0; if (sscanf(buf, "worker %d", &pid_val) == 1) { for (int i = 0; i < worker_count; i++) { if (workers[i].pid == pid_val) { workers[i].busy = 0; break; } } } printf("%s", buf); fflush(stdout); } static int dispatch_task(const char *cmd) { int idx = -1; for (int i = 0; i < worker_count; i++) { if (!workers[i].busy) { idx = i; break; } } if (idx < 0) { /* 没有空闲worker,先收一个结果再试 */ reap_result(); for (int i = 0; i < worker_count; i++) { if (!workers[i].busy) { idx = i; break; } } } if (idx < 0) { fprintf(stderr, "no free worker\n"); return -1; } char buf[MAX_TASK_LEN]; snprintf(buf, sizeof(buf), "%s\n", cmd); if (write(workers[idx].task_fd, buf, strlen(buf)) < 0) { perror("write task"); return -1; } workers[idx].busy = 1; return 0; }

关闭进程池的代码也必须仔细写。先发exit给所有worker,再关掉所有task写端,然后读结果管道直到EOF,最后waitpid回收所有子进程。

static void shutdown_pool(void) { char buf[MAX_RESULT_LEN]; for (int i = 0; i < worker_count; i++) { if (workers[i].task_fd >= 0) { if (write(workers[i].task_fd, EXIT_CMD "\n", strlen(EXIT_CMD) + 1) < 0) { perror("write exit cmd"); } } } for (int i = 0; i < worker_count; i++) { close(workers[i].task_fd); workers[i].task_fd = -1; } /* 把所有子进程退出前写的剩余结果全部读完 */ while (read(result_fd, buf, sizeof(buf)) > 0) { /* 这里可以继续输出,但我直接丢弃,只起到排空管道的作用 */ } close(result_fd); for (int i = 0; i < worker_count; i++) { waitpid(workers[i].pid, NULL, 0); } }

main函数用来串起整个流程,支持命令行交互式派发任务:

int main(int argc, char *argv[]) { int count = 3; char line[MAX_TASK_LEN]; if (argc > 1) { count = atoi(argv[1]); } if (init_pool(count) < 0) { return 1; } printf("init %d workers, input task line, or 'exit' to quit\n", count); while (fgets(line, sizeof(line), stdin) != NULL) { line[strcspn(line, "\n")] = '\0'; if (strcmp(line, "exit") == 0) { break; } if (strcmp(line, "status") == 0) { for (int i = 0; i < worker_count; i++) { printf("worker[%d] pid=%d busy=%d\n", i, workers[i].pid, workers[i].busy); } continue; } if (line[0] != '\0') { dispatch_task(line); } } shutdown_pool(); return 0; }

整个代码用gcc直接编译就行:

gcc -Wall -o procpool procpool.c

跑起来之后,你可以输入“sum 1 2 3”,会看到某个worker返回结果;输入三个“sleep 5”,在worker数量为3时会同时执行,如果连续输入四个“sleep 5”,第四个任务会等前面某一个完成后再被派发,这就是进程池的并发控制效果。

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

5.1 任务发不出去,子进程全卡死

我最早调试时遇到过一个经典问题:往子进程的task管道写完任务,子进程却没有任何响应。排查了很久发现,原因是父进程在fork之后没有关闭task_pipe[0]的读端,而子进程也没有关闭自己的读端之外的其他fd。

更隐蔽的是result管道。如果子进程不关闭result读端,就算父进程已经关了result写端,父子进程全部维护着管道,管道永远不会出现EOF,父进程想通过read返回0来判断子进程全部退出,也是等不到结果的。

经验:fork之后,立刻在子进程分支里关掉所有不该有的fd,父进程分支里也立刻关掉所有不该有的fd。每个管道两端的清理都在fork后马上做,不要拖。

5.2 父进程突然被SIGPIPE干掉

在进程池运行过程中,如果某个子进程异常退出,而父进程恰好往它的task管道写任务,就会触发SIGPIPE信号,默认行为是终止整个进程。父进程一死,整个进程池也跟着崩了。

解决方法是两件事一起做:init_pool里signal(SIGPIPE, SIG_IGN)忽略信号;然后在每次write之后检查返回值,如果返回-1,结合errno判断是EPIPE还是其他错误,做出对应处理。忽略信号只是为了不让程序突然暴毙,真正健壮的逻辑仍然要在write返回值里体现。

5.3 读到的结果是半截的,或者多条结果粘在一起

管道是字节流,不保证一次read拿到的数据正好是一条完整消息。如果你在父进程里用read一次只读固定长度,很可能把两条结果拆开,或者把一条结果加上下一条结果的前半截一起读出来。

我的处理办法是:结果统一以换行符结尾,但父进程的reap_result里为了简单只是read了一段就解析,严谨的做法是维护一个按行缓冲的读取器,把数据累积起来,遇到换行符才解析一行。如果你把这个池子接到真实项目里,建议把结果读取改为逐行读取,和子进程写端格式保持一致。

5.4 僵尸进程一堆

创建了子进程,退出后父进程没有及时回收,子进程就变成僵尸进程。进程池是常驻模型,子进程不能干完一个任务就退出,但shutdown_pool时如果waitpid没写全,同样会留僵尸。

根本原因还是对生命周期不够重视。只要fork了子进程,就必须有一个对应的waitpid或SIGCHLD处理逻辑。我习惯在shutdown的时候把所有子进程的pid遍历一遍,逐个waitpid,确保一个都不漏。

5.5 排查工具怎么看

管道问题不好直观观察,我的调试三板斧:

  • 用strace看系统调用,重点看pipe、fork、read、write的调用顺序和返回值。
  • 在关键fd操作后打印日志,包括“close result read end in child”这种操作。
  • 查看/proc/ /fd目录,能看到每个进程打开了哪些fd,判断有没有哪个管道端没关干净。
ls -l /proc/<pid>/fd

这个方法极其好用,一眼看出某个进程里是不是多了一个不该存在的管道fd。

6. 实测效果、扩展方向和个人体会

6.1 一个小压测

我自己跑了一个简单压测:创建3个worker,连续派发10个“sleep 1”任务,串行执行需要10秒,进程池并行只要3秒多,说明任务均衡分配在了3个worker上。再用“sum 1 2 3 4 5”批量派发几十次,所有结果都能正确回收,没有出现数据交错或者丢失。

管道本身的吞吐能力对于文本任务来说完全够用,不需要额外优化。如果你的任务数据特别大,比如单条几十MB,那管道缓冲区加拷贝的开销就会很明显,到时候再考虑换共享内存。

6.2 可以往哪些方向扩展

这个进程池骨架扩展空间很大。把下行管道和上行管道换成socketpair,可以实现全双工通信,父进程可以随时主动通知子进程做更多控制。把父进程的任务读写改成非阻塞加poll,就能做到单线程同时管理数十个worker。在worker执行逻辑里加入定时器,可以支持任务超时取消。如果你希望任务能排队,则在dispatch_task外面再加一个FIFO。

还有一个很实际的扩展:把任务格式从纯文本改成“长度+二进制数据”,或者用JSON行协议,就能承载更复杂的业务数据。

6.3 最后说点实际体会

我在实际项目中用过很多并发模型,但每次遇到需要紧急实现一个批量处理工具的场景,第一个想到的还是管道加进程池这套组合。它不需要安装依赖,不依赖外部服务,一个C文件编译出来就能跑,排查问题时strace抓一把syscall就能看清楚所有行为。反而是后来用的一些花哨框架,出了问题很难定位。

要说这个模型最大的局限,那就是它太朴素了,没有现成的任务超时、重试、故障迁移这些能力,都需要自己写。但恰恰因为朴素,它的每一行逻辑你都能掌控。如果你还在学习Linux进程间通信,我的建议是不要急着上消息队列中间件,先亲手用匿名管道实现一个进程池,进程调度、fd生命周期、阻塞语义这些核心概念,都会在这一百多行代码里变得异常清晰。

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

AI编程助手自动打补丁:从生成代码到自我修复的演进与实战

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

作者头像 李华
网站建设 2026/9/7 16:32:28

猫抓 cat-catch 上手指南:资源嗅探三步存下网页视频

猫抓 cat-catch 上手指南&#xff1a;资源嗅探三步存下网页视频 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 在线课程页面里的视频&#xff0c;…

作者头像 李华
网站建设 2026/9/7 16:31:04

从零搭建AI Agent工具链:手写Python智能体实现ReAct与Function Calling

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

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

AI低成本软件产品开发全流程实战指南

做这个“AI低成本软件产品开发全流程”项目之前&#xff0c;我一直有个很深的感受&#xff1a;很多人一提AI开发产品&#xff0c;要么觉得必须有大模型训练团队&#xff0c;要么觉得要烧很多钱囤显卡&#xff0c;其实这两条都是被网上各种高大上案例带偏了。真正的低成本路线&a…

作者头像 李华
网站建设 2026/9/7 16:30:01

试用claude.ai账号hold on; 以及传统文化的AI+应用

anthropic&#xff0c;好在把每次处理好的conversation数据以json格式发给我&#xff0c;自己转换一下还能用。 —— 为什么我把 六爻卜卦的 skill 用到 法律事务预测上&#xff1f;——因为&#xff0c;那个skill 真的是律师写的 这律师还写了 河洛理数 预测用的skill让我们看…

作者头像 李华