Linux系统编程:用C系统调用实现迷你Shell与管道重定向
发布时间:2026/9/30 4:09:15 作者:尧图编辑部 阅读量:1,286

1. 实验整体设计与思路拆解Linux编程技术应用这个实验放在操作系统课程的靠后位置不是随便排的。前面十几个实验多半在做用户管理、权限、磁盘、进程查看这类用命令的活到了第十二个实验题目里的编程两个字才是重点——它要求你从命令的使用者变成系统调用的调用者。说白了就是从敲ls变成自己写一个东西去调用fork、exec、pipe、wait这一套把操作系统到底怎么把一个程序跑起来这件事亲手复现一遍。我理解这个实验的落点是用 C 语言写一个简易的命令解释器mini shell能读用户输入、能切分参数、能创建子进程执行外部命令、能支持管道连接多条命令、能处理重定向最好再顺手接一下CtrlC信号。做完这一套进程创建、程序替换、进程间通信、信号处理、文件描述符管理这五块内容就全串起来了。它适合已经学过 C 语言基础、知道指针和数组怎么回事、对fork只在 PPT 上见过一面的同学。也适合工作几年、想回头把底子补扎实的人因为这个实验用的全是面试和真实工程里反复出现的东西。我当年第一次做的时候卡在管道那一晚上没睡好后来才明白问题不在代码在于脑子里没有进程和文件描述符的物理图像。这篇就把那条路径完整摊开包括我踩过的坑。1.1 为什么这个实验要从 Shell 脚本过渡到 C 系统调用教材和多数实验指导书会安排两段先用 bash 脚本做点自动化再用 C 语言写系统调用。很多同学觉得脚本部分是凑数的其实不是。脚本那一段是在帮你建立命令是怎么被拼起来的这个直觉——变量、管道、重定向、条件判断这些概念你在脚本里用得越顺写 C 版本时就越清楚自己要实现哪些功能只不过换成了系统调用的手工版。真正的分水岭在于控制权。bash 脚本里你写ls | grep txtshell 自己就把两个进程建好了你什么都不用管。到了 C 版本|这个字符对你来说就是一串字节你得自己pipe()建管道、fork()建两个子进程、把管道两端dup2到标准输入输出上再execvp把两个程序塞进去。这个从用工具到造工具的跨越就是这个实验最核心的训练价值。我个人的建议是不要跳过脚本部分但也不要在脚本上花太多时间。脚本只要能写一个带for循环和管道的小例子就够了比如统计某目录下所有.c文件的总行数。剩下的精力全部投到 C 版本上因为 C 版本里的每一个系统调用都能对应到课本上进程管理进程通信文件系统这些章节的考点。面试问到fork 之后父子进程共享什么、复制什么你能答出来靠的就是这段代码的肌肉记忆。还有一个现实原因C 版本能让你真正看见错误。脚本写错了bash 给你一句模棱两可的报错C 版本里execvp失败你perror一下errno直接告诉你是权限不够还是路径不对。这种对错误的掌控感是往后做系统层开发的基本功。1.2 环境与工具链的选型考量环境上没什么好纠结的任意一个主流发行版都行。我个人在虚拟机里跑得最多好处是快照随便打sudo rm -rf之类的操作出事了直接回滚心理负担小。用 WSL 也可以编译和运行系统调用的体验和原生基本一致只是涉及到某些低层调用比如ptrace相关偶尔有差异做这个实验碰不到。编译器用gcc就够了关键是编译选项。我在这个实验里固定用这么一套gcc -Wall -Wextra -g -O0 minishell.c -o minishell-Wall -Wextra是必须的这类系统调用代码最容易犯的错就是漏检查返回值、size_t和int混用、字符串常量写进可写内存。开了警告能把一大半低级 bug 拦在编译期。-g是为了后面用gdb和valgrind调试。-O0关掉优化避免调试时变量被优化掉、单步跳得莫名其妙。调试工具我列几个真正常用的strace -f ./minishell能打印出所有子进程的系统调用管道为什么不通、dup2有没有生效一看便知ps -o pid,ppid,stat,cmd --forest看进程树检测僵尸进程特别方便valgrind --leak-checkfull查内存和文件描述符泄漏。还有一些小工具比如lsof -p pid看某个进程打开了哪些 fd排查管道死锁时救过我好几次。注意调试系统调用代码时不要一上来就printf满天飞。printf本身带缓冲区在fork前后混用会让输出重复、错乱反而干扰判断。优先用strace从外部观察实在不行再用write(2, ...)直接写标准错误它不带缓冲行为可预测。2. 核心知识点拆解与关键细节这部分是实验的骨头。我把要用的系统调用按功能分成四组每一组都讲清楚它到底做了什么、为什么是这个签名、常见误用在哪。2.1 进程控制三件套fork、exec、waitfork()是整个实验的支点。它的语义用一句话说调用一次返回两次。父进程里返回子进程的 PID子进程里返回 0出错返回 -1。理解它的关键在于复制这个词——子进程拿到的是父进程地址空间的一份副本代码段共享只读数据段、堆、栈、文件描述符表都是独立的拷贝。但注意这个拷贝是写时复制COW实现的不是一fork就把内存实打实复制一份而是等你写的时候才复制那一页。有个坑几乎人人都踩父子进程共享文件偏移量。fork之后父子进程的文件描述符指向同一个打开文件表项所以一方read了 100 字节另一方再read就是从 101 开始。这个问题在管道里表现得最明显也是很多输出丢了一半的根源。exec家族不是单个函数而是一组execl、execv、execle、execve、execlp、execvp。区别在两个维度——参数是列表形式l还是数组形式v是否用PATH搜索p是否带自定义环境变量e。实验里最常用的是execvp第一个参数是命令名第二个是以NULL结尾的参数数组。它成功之后不会返回因为它已经把当前进程的映像整个换掉了只有失败才返回 -1这时候必须perror加_exit否则子进程会继续跑父进程的代码产生诡异结果。wait和waitpid负责收尸。子进程结束后如果父进程不wait它就会变成僵尸占着一个进程表项不放。waitpid(pid, status, 0)是更精细的版本可以指定等哪个子进程。status不是退出码本身要用WIFEXITED(status)判断是否正常退出再用WEXITSTATUS(status)取出真正的退出码。这两个宏用错的概率很高我见过不止一个人直接把status当退出码打印结果得到 256 之类的怪值。2.2 管道与重定向的底层逻辑管道pipe(int fd[2])返回两个文件描述符fd[0]是读端fd[1]是写端数据只能从写端流向读端单向。内核里它其实是一块环形缓冲区默认 64KB不同版本有差异。写满会阻塞读空也会阻塞这个阻塞特性既是管道的优点也是死锁的来源。ls | grep txt在系统调用层面是这样搭起来的先pipe拿到两端fork出第一个子进程把它dup2(pfd[1], STDOUT_FILENO)让它的标准输出接到写端execvp(ls)再fork第二个子进程dup2(pfd[0], STDIN_FILENO)execvp(grep)。关键是父进程要把这两个 fd 都关掉否则管道的两端永远有引用读端不会读到 EOFgrep会一直卡着。dup2这个名字里的 2 很关键它做的是重定向和dup的复制到最小可用 fd不同。dup2(oldfd, newfd)会把newfd关掉如果开着然后让它指向oldfd指向的那个打开文件表项。顺序上有个易错点dup2之后如果想保留原来的newfd语义得确保newfd不被提前关掉。标准写法是dup2(oldfd, newfd); close(oldfd);但oldfd和newfd相等时要特判否则会把自己关掉。重定向本质上也是dup2open出目标文件的 fddup2到 0 或 1然后execvp。用的是O_APPEND标志其他和一样。我第一次实现重定向时忘了处理里目标文件权限直接open用了默认的 0666结果受umask影响变成 0644也没出问题但严格说应该显式传0644避免环境差异。2.3 信号处理与进程间同步信号这块实验通常只要求处理SIGINT也就是CtrlC。默认行为是终止当前前台进程组。但如果你在用自己写的 shell 跑一个前台命令CtrlC会把 shell 自己也干掉这显然不对——真实的 shell 是把信号转发给前台进程组。标准做法是在 shell 里忽略SIGINT和SIGTTOUSIGTTOU是因为 shell 要操作终端然后在启动每个前台作业时把它放进一个新的进程组用setpgid再让 shell 忽略SIGINT子进程收到CtrlC时自己处理。最简洁的实现是子进程exec前恢复SIGINT为默认行为父进程保持忽略同时用tcsetpgrp把前台进程组切成子进程的组。这个实验如果不用作业控制可以退一步父进程注册SIGINT处理器收到后把信号转发给记录下来的前台子进程 PID。这里有个细节必须说清楚信号处理函数里能安全调用的函数非常有限只有async-signal-safe的那一批printf、malloc都不在其中。如果你在 handler 里printf理论上可能死锁。正确做法是在 handler 里只设置一个volatile sig_atomic_t标志主循环里检查这个标志再处理。这个点写在报告里是加分项因为它体现你真的读过signal(7)。2.4 文件 I/O 与系统调用的直接使用实验要求编程技术应用所以要刻意用系统调用而不是标准库。区别在于open返回的是文件描述符fopen返回的是FILE*后者是 libc 在 fd 上包了一层缓冲。混用两者会出问题比如对同一个文件又read又fread缓冲区不一致就会丢数据。文件描述符的三个标准值要记牢0 是标准输入1 是标准输出2 是标准错误。dup2的目标就是这三个。还有一个技巧open一个文件拿到的 fd 总是当前未用的最小整数所以如果你先close(0)再open新文件就会自动占据 0 号槽位——这就是dup2出现之前的老式重定向写法。知道这个原理dup2的存在意义就很好理解了它不想让你必须去猜最小可用 fd 是几。参数选择上O_CREAT必须搭配第三个权限参数O_TRUNC用于清空文件O_APPEND用于追加。这三个标志组合错了就会出现追加变成覆盖或覆盖变成追加的问题。我在实验报告里专门列过一张表对着填参数比凭记忆快。3. 实操过程从零搭一个迷你命令解释器说完了原理接下来是能直接抄的部分。我按功能递进的顺序写每一步都能独立编译运行你可以边写边测出问题马上定位。3.1 目录结构与编译脚本准备我不会把这个实验做成一个大文件而是分成几个部分单文件也行但至少把主循环、解析、执行、管道这几个函数分区用注释隔开。目录就三个东西linuxlab12/ ├── minishell.c ├── Makefile └── test/ 放一些测试用的输入文件和脚本Makefile我不写花哨的三条规则够用CC gcc CFLAGS -Wall -Wextra -g -O0 minishell: minishell.c $(CC) $(CFLAGS) $ -o $ clean: rm -f minishell .PHONY: clean注意Makefile里命令行必须以 Tab 开头不可以用空格。这是我见过新手最常翻车的地方一报missing separator基本就是这个原因。3.2 骨架读取一行、切分参数主循环就三件事打印提示符、读一行、执行。读一行我用getline它会自动malloc比fgets省心不需要猜缓冲区多大。#include stdio.h #include stdlib.h #include string.h #define MAXARGS 64 int main(void) { char *line NULL; size_t cap 0; ssize_t n; char *argv[MAXARGS]; while (1) { printf(msh ); fflush(stdout); n getline(line, cap, stdin); if (n -1) break; /* CtrlD 退出 */ int argc parse_line(line, argv); if (argc 0) continue; /* 空行 */ if (strcmp(argv[0], exit) 0) break; execute(argv, argc); } free(line); return 0; }fflush(stdout)这行容易被忽略。提示符没有换行符标准输出又是行缓冲的——连到终端时行缓冲会在遇到换行时刷但提示符后没有换行所以可能卡在缓冲区里不显示。特别是你把输出重定向到文件时不加fflush提示符压根就不会出现。切分函数要考虑引号否则echo hello world会被切成两个参数int parse_line(char *line, char **argv) { int argc 0; char *p line; while (*p argc MAXARGS - 1) { while (*p || *p \t || *p \n) p; if (*p \0) break; if (*p || *p \) { char q *p; argv[argc] p; while (*p *p ! q) p; if (*p) *p \0; } else { argv[argc] p; while (*p *p ! *p ! \t *p ! \n) p; if (*p) *p \0; } } argv[argc] NULL; return argc; }这个解析器是就地修改的——它把分隔符替换成\0argv里存的是指向原缓冲区内部的指针。好处是不用额外分配内存坏处是原字符串被破坏了。对我们来说原字符串没用了所以无所谓。但如果你的执行函数需要反复读这一行比如打印日志就得先strdup一份。3.3 执行外部命令fork 加 execvp执行一个不带管道和重定向的简单命令标准套路是这样#include unistd.h #include sys/wait.h extern char **environ; static void execute(char **argv, int argc) { pid_t pid fork(); if (pid 0) { perror(fork); return; } if (pid 0) { /* 子进程恢复默认信号替换映像 */ signal(SIGINT, SIG_DFL); execvp(argv[0], argv); perror(execvp); _exit(127); } /* 父进程等它结束 */ int status; while (waitpid(pid, status, 0) 0) ; if (WIFEXITED(status) WEXITSTATUS(status) ! 0) fprintf(stderr, [exit %d]\n, WEXITSTATUS(status)); }有几个地方值得展开。第一子进程exec失败必须用_exit而不是exit。exit会刷新 stdio 缓冲区而这个缓冲区是从父进程继承来的里面可能有父进程还没输出的内容一刷就会重复打印。_exit直接走系统调用跳过所有 atexit 和缓冲区处理行为干净。第二waitpid外面套while是为了处理被信号中断的情况。waitpid可能返回 -1 并置errno为EINTR重新等一次就好。不加这层循环在某些条件下你会以为子进程没收到实际上它还在跑。第三[exit %d]这一行是我自己加的用来显示上一个命令的退出码调试时特别有用能快速判断execvp到底成没成功。真实 shell 里对应的是$?。3.4 加上管道递归执行流水线管道是这部分的核心难点。我采用的方案是递归找到第一个|把左边执行掉并让它的输出接到管道写端然后把右边剩余部分和管道读端递归处理。static int run_pipeline(char **argv, int n, int in_fd) { if (n 0) return 0; /* 找到从当前位置起的第一个 | */ int idx 0; while (idx n strcmp(argv[idx], |) ! 0) idx; char *cmd[MAXARGS]; int c 0; for (int i 0; i idx; i) cmd[c] argv[i]; cmd[c] NULL; if (c 0) return -1; int has_pipe (idx n); int pfd[2] {-1, -1}; if (has_pipe pipe(pfd) 0) { perror(pipe); return -1; } pid_t pid fork(); if (pid 0) { perror(fork); return -1; } if (pid 0) { if (in_fd ! STDIN_FILENO) { dup2(in_fd, STDIN_FILENO); close(in_fd); } if (has_pipe) { close(pfd[0]); /* 子进程不用读端 */ dup2(pfd[1], STDOUT_FILENO); close(pfd[1]); } execvp(cmd[0], cmd); perror(execvp); _exit(127); } if (in_fd ! STDIN_FILENO) close(in_fd); if (!has_pipe) { int status; waitpid(pid, status, 0); return WEXITSTATUS(status); } close(pfd[1]); /* 父进程必须关写端 */ return run_pipeline(argv idx 1, n - idx - 1, pfd[0]); }这段代码里有两处close是生死攸关的。一处是子进程如果只写不读要把pfd[0]关掉否则读端引用还在下游读不到 EOF。另一处是父进程执行完 fork 后必须关掉pfd[1]父进程若一直持有写端下游grep就永远等不到文件结束。我当年就是漏了父进程那个close(pfd[1])现象是ls | grep c能出结果但命令不返回得手动CtrlC。当时盯着strace看了半小时才反应过来是写端没关管道没到 EOF。这个坑我建议你自己手动复现一次把close(pfd[1])注释掉跑一遍感受一下进程卡在那里不退出的状态记忆会比看十遍书都深。还有个细节是参数数组的传递。递归调用里用的是argv idx 1n - idx - 1是剩余个数。因为函数只读前n个元素不依赖argv[n]是否为NULL所以不用重新构造一个以NULL结尾的数组。这一点和execvp的要求不同execvp一定要NULL结尾所以cmd那里必须补cmd[c] NULL。3.5 重定向与信号处理的收尾重定向解析我放在parse_line之后单独做一遍扫描因为它和管道的位置无关先处理掉可以简化后面的逻辑/* 扫描 argv处理 和 返回处理后的 argc 调用者负责在子进程里 dup2 */ struct redir { char *in_file; char *out_file; int append; }; static int scan_redir(char **argv, int argc, struct redir *r) { int w 0; r-in_file r-out_file NULL; r-append 0; for (int i 0; i argc; i) { if (strcmp(argv[i], ) 0 i 1 argc) { r-in_file argv[i]; } else if (strcmp(argv[i], ) 0 i 1 argc) { r-out_file argv[i]; r-append 0; } else if (strcmp(argv[i], ) 0 i 1 argc) { r-out_file argv[i]; r-append 1; } else { argv[w] argv[i]; } } argv[w] NULL; return w; }然后在子进程exec之前应用static void apply_redir(struct redir *r) { if (r-in_file) { int fd open(r-in_file, O_RDONLY); if (fd 0) { perror(open in); _exit(1); } dup2(fd, STDIN_FILENO); close(fd); } if (r-out_file) { int flags O_WRONLY | O_CREAT | (r-append ? O_APPEND : O_TRUNC); int fd open(r-out_file, flags, 0644); if (fd 0) { perror(open out); _exit(1); } dup2(fd, STDOUT_FILENO); close(fd); } }信号处理我采用的是最简方案shell 主进程忽略SIGINT这样CtrlC不会杀掉 shell子进程exec前恢复默认前台命令就能被正常中断。实现就一行signal(SIGINT, SIG_IGN); /* 在 main 开头 */ signal(SIGTTOU, SIG_IGN);子进程里那行signal(SIGINT, SIG_DFL)前面已经写了。这个方案不完美因为CtrlC会同时发给 shell 和子进程它们在同一前台进程组但因为 shell 忽略了实际效果就是只中断了子进程足够用。真要做得跟 bash 一样需要setpgid加tcsetpgrp做完整的作业控制那是另一个实验的量级了。4. 常见问题与排查技巧实录这部分是我认为整篇文章最值钱的地方。文档不会告诉你这些因为它们都是运行期才暴露的。4.1 僵尸进程与孤儿进程的识别现象连续跑几十条命令后ps aux | grep minishell里冒出一堆defunct状态的进程。原因几乎一定是waitpid没调用成功或者调用了但被别的地方抢了先。判断方法很简单看进程树的 PPIDps -eo pid,ppid,stat,cmd --forestSTAT列是Z就是僵尸。僵尸本身不耗 CPU但它占进程表项数量多了会耗尽 PID。排查方向是检查每个fork之后是不是所有分支都走到了wait。特别注意管道递归里父进程 fork 完第一个子进程后如果递归下去了那第一个子进程的收尸就要在递归返回后做很容易漏。孤儿进程是另一回事父进程先退出子进程被 init 收养PPID 变成 1。如果你写的 shell 在管道还没结束时就退出了管道里剩下的子进程就成孤儿了它们会继续跑完然后变成僵尸被 init 收走一般不影响但会让人困惑为什么命令输出还在一行行冒出来。提示排查这类问题的顺序是先看ps --forest确认进程关系再看strace -f -e tracewait4,clone确认谁在等谁。不要一上来就改代码先搞清楚进程的真实状态。4.2 编译与链接期的典型报错报错信息真实原因处理办法implicit declaration of function fork忘了包含unistd.h补头文件别用隐式声明硬扛undefined reference to execvp同样是头文件缺失导致链接找不到检查是否用了-stdc99却没开_POSIX_C_SOURCEwarning: assignment makes integer from pointer把char*赋给了int变量检查返回值和参数类型是否写混error: O_RDONLY undeclared缺fcntl.h补上warning: control reaches end of non-void function函数有分支没返回值每个出口都要return别靠运气第二行那个比较隐蔽。gcc默认的gnu99模式下_POSIX_C_SOURCE是打开的execvp能找到但如果你为了严格按 C 标准写了-stdc99POSIX 的东西就全被藏起来了。解决办法是在文件最顶上、包含任何头文件之前加#define _POSIX_C_SOURCE 200809L这个宏必须在第一行写在#include stdio.h之后就没用了。4.3 管道不通与文件描述符泄漏管道问题我归成三类。第一类是命令能出结果但不返回前面说过是写端没关。第二类是多段管道只剩最后一段输出通常是把dup2的顺序搞反了或者close了不该关的 fd。第三类是输出乱序这多半是父子进程共享了标准输出缓冲区解决办法就是子进程里fflush(NULL)或者直接用_exit。文件描述符泄漏的检查用lsof最快lsof -p $(pgrep -n minishell) | wc -l连续跑十条命令再看一次如果 fd 数量一直涨就是有地方open或pipe之后没关。这类泄漏不像内存泄漏那么致命但一个长跑的 shell 会慢慢把 fd 用光最后open直接失败。我自己的检查清单是每个pipe的两端在父进程和子进程各自应该关哪个写代码时就在旁边注释标出来比事后排查省事。另外valgrind也能抓 fd 泄漏valgrind --track-fdsyes --leak-checkfull ./minishell退出时会打印每个 fd 的状态未关闭的会标出来。我强烈建议每写完一个功能就跑一次早发现早轻松。4.4 常见问题速查表现象可能原因快速验证修复execvp报No such file命令不在PATH或参数数组没NULL结尾手动which一下命令用绝对路径调试检查argv[argc]NULL子进程输出重复两遍用了exit而非_exit看输出是否恰好成对出现改_exit或fork前fflush(NULL)CtrlC把 shell 也干掉shell 没忽略SIGINT中断后看主循环是否还在主进程signal(SIGINT, SIG_IGN)重定向文件是空的open失败但没检查返回值perror看错误码检查目录写权限和O_CREAT是否漏了提示符不显示stdout 行缓冲重定向输出到文件复现提示符后加fflush(stdout)读取到上一次的输入getline复用缓冲区观察命令是否错乱每次循环开头清空line[0]或重新getline这张表我贴在自己的实验报告最后答辩时老师专门问了最后一行那个读取到上一次输入的情况因为getline的实现细节里有个容易忽略的点它只在需要时才扩大缓冲区但不会自动清空内容。如果你手动改过line的内容下一次可能读到残留。排查时用strace -e traceread看实际读到的字节数最直接。5. 实验的验证手法与延伸方向代码写完不等于实验做完验证和延伸是拉开分数的地方。5.1 用 strace 和 ps 观察真实行为我习惯在验收前跑这么一条命令strace -f -e traceclone,execve,pipe,dup2,wait4 ./minishell然后在 shell 里输入ls | grep c out.txt。你会看到clonefork的底层实现、pipe、两次dup2、两次execve以及最后的wait4。这个输出跟你的代码逻辑一一对应只要顺序对不上说明理解有偏差。比如你看到execve之后紧跟exit_group那就是execve失败了。用这种方式做自检比在代码里加一百行printf都有效。ps --forest适合看进程树。执行sleep 30 | cat的时候另开一个终端跑ps -eo pid,ppid,pgid,stat,cmd --forest你能看到三个进程shell、sleep、cat还有它们各自的 PGID。如果sleep和cat是同一个 PGID说明你用了setpgid把作业放进了同一个组——这是作业控制做对了的标志。5.2 内存与 fd 的收尾检查功能都通了之后跑一遍valgrind重点看两块definitely lost和File descriptor部分。getline分配的那块内存在循环结束时如果没free会报 lost这个要修。fd 部分只要不是still open at exit超过 3 个0、1、2 是正常的基本就没问题。还有一个更狠的检查方式是压力测试连续跑两千条简单命令for i in $(seq 1 2000); do echo true ; done | ./minishell跑完看进程还在不在、内存有没有疯长。真 shell 面对这种情况是稳如泰山的你的版本如果跑崩了多半是某个循环里的malloc没释放或者waitpid漏了导致僵尸堆积。这个测试我第一次做的时候是挂了的原因是解析函数对超长输入没有边界控制argv数组越界写坏了栈。加上argc MAXARGS - 1的判断之后就稳了。延伸方向给两个。一是加内建命令用cd和pwd练手注意cd必须由 shell 自己实现不能 fork因为它要改的是 shell 自己的当前工作目录改了子进程的没用。二是加后台执行那就得引入作业表、SIGCHLD处理器和作业号管理工作量翻倍但做完之后你对作业控制的理解会上一个台阶。最后分享一点我个人在这个实验里的体会。真正让我把系统调用这块吃透的不是我写对了多少代码而是我故意写错了几次。把close删掉一次看管道怎么卡住把exit换成保留看输出怎么重复把wait去掉看僵尸怎么堆起来。每制造一次错误脑子里那张进程和文件描述符的图就清晰一分。后来面试被问到父子进程共享哪些资源我能一条条说清楚哪些是拷贝、哪些是共享、为什么靠的就是当年在虚拟机里反复把它跑崩又救回来的那些夜晚。这个实验的代码量不大但它要求你在脑子里同时维护多个进程的状态这种思维方式一旦建立起来往后再看epoll、协程、容器这些概念你会发现底层其实是同一套东西的变体。