ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

操作系统进程详解:从fork/exec到上下文切换

操作系统进程详解:从fork/exec到上下文切换 很多人学《操作系统导论》英文简称 OSTEP最容易出现的困境是书买回来了或者打开了网页看完了第一章的对话信心满满结果进入第四章就开始发懵。我自己的经历也差不多所以这次重读的时候换了一个思路——每一章都当成一个具体问题来拆读完立刻写代码验证。第一期笔记就落在第四章抽象进程。进程这个词听起来很亲切任务管理器里天天见聊天软件可能挂着十几个进程浏览器每个标签页一个进程Linux 里敲ps能刷出几屏。可真要回答进程是什么、操作系统为什么要用进程这个概念把硬件细节藏起来、fork/exec/wait 这套 API 是怎么配合的很多人就开始含糊了。这篇文章的目标很直接让零基础的人也能把第四章吃透并且能在自己的电脑上亲手复现每一个关键结论。不需要先修多少前置知识能写最简单的 C 程序、能在终端敲命令就够了。1. 操作系统为什么要造进程这个抽象来骗我们1.1 设想一个没有进程的世界先做一个思想实验。假设没有操作系统帮忙你写一个 C 程序里面就一行printf(hello\n)。程序要运行就要被加载进内存CPU 要取指令、执行、写回结果。听起来不复杂对吧但问题是你的程序只是电脑里几十个程序之一。如果每个程序都直接操控 CPU、内存和设备会发生什么想象一个会议室里同时来了五个团队每个团队都想用唯一的白板。如果没有管理员团队 A 写到一半团队 B 冲进来把白板擦了写自己的团队 A 回头一看内容没了直接崩溃。计算机里的情况比这更残酷CPU 只有一个先忽略多核内存是共享的你程序里的变量地址、函数调用栈随时可能被别的程序覆盖。没有隔离没有管理大家互相踩踏谁也别想稳定运行。所以操作系统的第一个核心工作就是虚拟化。它把物理资源CPU、内存、磁盘变成一个个看起来独立、连续、可控的假象分配给每个程序。CPU 虚拟化的假象是每个程序都觉得自己独占了一个 CPU想怎么跑就怎么跑。内存虚拟化的假象是每个程序都觉得自己有一段连续且私有的地址空间从 0 到很大随便用。进程就是这个假象的最小承载单位。1.2 时分共享把独占CPU的戏演好CPU 虚拟化靠的是时分共享time sharing。也就是 CPU 先把时间片分给进程 A跑几毫秒然后切到进程 B跑几毫秒再切回 A。因为切换足够快比如每秒切换几十上百次用户根本感觉不到每个进程都像在独享 CPU。这里有个很关键的理解操作系统不是在并行运行多个进程而是在并发地交错运行。就像一个人同时烧三壶水其实他是一次又一次地换着壶看火候而不是同时握着三把水壶。时分共享的逻辑很简单但实现起来有个致命问题怎么保证切换之后还能切回来程序还能从原来的状态继续跑这就引出了进程这个抽象最核心的内容——机器状态machine state。你切换一个进程之前必须把它的全部运行现场记录下来下次恢复时原样还原。记录现场这件事就是整个第四章后半部分要讲的机制。1.3 为什么这套笔记从进程开始而不是从内存或文件系统《操作系统导论》这本书的编排很有意思它把操作系统的三大核心抽象放在最前面进程CPU 的虚拟化、地址空间内存的虚拟化、文件持久存储的虚拟化。进程排第一因为它是理解其他所有抽象的前提没有进程就没有调度、没有同步、没有内存管理更不会有你电脑上那些多任务并行的体验。而且进程这个抽象特别适合从零开始学它不需要你预先理解太多硬件细节只需要掌握几个核心概念——机器状态、API、状态机、数据结构。这些概念能直接映射到现实世界的现象为什么任务管理器里一个软件有好几个进程、为什么有时进程卡死、为什么杀进程经常能解决一半的电脑问题。理解了进程你就拿到了打开整本操作系统教材的第一把钥匙。2. 一个进程的体检报告地址空间、寄存器、PC 一个都不能少2.1 程序和进程菜谱与做菜过程的区别很多教材上来就抛定义进程是运行中的程序。这句话太容易滑过去了。我更喜欢用做饭类比程序是菜谱躺在磁盘上是个静态文件进程是照着菜谱做菜的过程它占着灶台CPU、用着锅碗瓢盆内存、随时记录做到哪一步了程序计数器。菜谱可以被无数人同时参考但每一份做菜的过程是独立的一摊事。所以你在 Linux 里ls -l看到的那些可执行文件只是程序当你在终端敲下./helloshell 帮它创建出一个进程这时候它才是进程。同一个程序可以同时有多个进程在跑比如你开三个终端每个终端跑同一个编译好的程序那就是三个进程它们的内存、寄存器都是相互独立的。2.2 机器状态三件套地址空间、寄存器、PC要完整描述一个进程此刻在干什么操作系统必须记录以下这些东西OSTEP 管它们叫机器状态第一块是地址空间address space。进程能访问的内存的集合包括代码段、数据段、堆、栈。代码段是编译好的指令数据段是全局变量堆是malloc动态分配的内存栈是函数调用用的。进程以为这段空间是自己独占的连续领地实际上物理内存里它们可能东一块西一块全靠操作系统和硬件配合做映射。第四章先不深挖地址空间的实现你只要明白每个进程都有一份看起来自己的内存布局。第二块是寄存器registers。CPU 内部那一小撮高速存储单元比如通用寄存器、栈指针SP、帧指针FP。寄存器就是 CPU 工作的草稿纸算到一半的中间结果全在上面。进程切换时必须保存所有寄存器的值否则切回来的时候草稿纸被别的进程写乱了程序当场精神错乱。第三块是程序计数器Program CounterPC。它记录下一条要执行的指令在地址空间里的位置。有了 PC进程才知道自己做到哪一步了。没保存 PC切换回来就不知道从哪里继续执行等于做菜做到一半忘了步骤。除此之外还有 I/O 相关的状态这个进程打开了哪些文件、占着哪些设备。这些一般不叫机器状态但操作系统同样得记账。2.3 命令行的实证用 ps 看到进程的体检数据光看书不动手概念永远是概念。你现在就可以打开 Linux 终端看一遍ps -ef-e表示显示所有进程-f表示完整格式。输出里你会看到 PID进程号、PPID父进程号、CPU 占用、运行时间还有 COMMAND命令行。PID 就是进程的身份证号内核就是靠它区分不同进程的。再细一点ps -p 1 -o pid,ppid,comm,args这是看 PID 为 1 的进程在绝大多数现代 Linux 发行版上是systemd或init它是内核启动后的第一个进程负责拉起整个用户态的世界。你在ps -ef里看到的所有进程追根溯源几乎都能追到它。想看某个进程开了哪些文件用lsof -p PID想看它的内存映射去/proc/PID/maps翻。/proc这个虚拟文件系统把进程的内核数据结构几乎全暴露出来了。你可以先瞄一眼不用全看懂有个直觉就够操作系统内部每个进程都存着一大堆这样的体检数据。3. fork、exec、wait 三件套创建、运行、等待的底层剧本3.1 fork 的返回两次到底是怎么回事进程是怎么创建的在 Unix/Linux 世界里核心系统调用只有一个fork()。OSTEP 用了好几个小例子来演示我建议你亲手敲一遍。先保存一个最简单的版本#include stdio.h #include unistd.h int main() { printf(before fork, pid %d\n, (int)getpid()); fork(); printf(after fork, pid %d\n, (int)getpid()); return 0; }编译运行gcc fork_demo.c -o fork_demo ./fork_demo你会发现after fork这行输出了两次。这就是 fork 最反直觉的地方fork()调用一次却返回两次。原因在于fork()会创建当前进程的一个几乎完整的副本子进程然后两个进程从fork()返回的地方继续往下跑。返回值是区分两者的关键在父进程中fork()返回子进程的 PID一个正整数。在子进程中fork()返回0。如果创建失败返回负数。所以规范的写法是#include stdio.h #include unistd.h #include stdlib.h int main() { pid_t rc fork(); if (rc 0) { fprintf(stderr, fork failed\n); exit(1); } else if (rc 0) { printf(I am child, pid %d\n, (int)getpid()); } else { printf(I am parent of %d, my pid %d\n, (int)rc, (int)getpid()); } return 0; }这里我踩过的第一个坑是子进程并不是从main开头重新执行而是从fork()调用之后的指令继续执行。原因是 fork 复制了整个进程的地址空间和寄存器状态子进程的 PC 和父进程一样都停在 fork 返回点。所以你别在 fork 前面放太多初始化逻辑然后期望子进程重跑一遍它不会跑。另外注意两个进程的执行顺序是不确定的。你多跑几次上面这个程序会发现有时先打印父亲有时先打印孩子。谁先谁后取决于操作系统的调度器这就是第四章结尾预告的调度问题。如果你希望有确定顺序就得靠 wait 出手。3.2 wait父进程的等待礼仪一个进程创建了子进程之后通常要等它干完活。wait()就是干这个的。看这个例子#include stdio.h #include unistd.h #include sys/wait.h #include stdlib.h int main() { pid_t rc fork(); if (rc 0) { fprintf(stderr, fork failed\n); exit(1); } else if (rc 0) { printf(child (pid %d) is sleeping 2s\n, (int)getpid()); sleep(2); } else { pid_t wc wait(NULL); printf(parent (pid %d) waited, child %d finished\n, (int)getpid(), wc); } return 0; }父进程调用wait(NULL)后会阻塞在那里直到某个子进程结束然后返回那个子进程的 PID。这里有个知识点顺带就出来了僵尸进程zombie。子进程已经退出但父进程还没调用 wait 回收它这时候子进程就处于僵尸状态——进程的代码和大部分内存已经释放但内核里进程表项还在留着给父进程查询退出状态。你可以在程序里加一个sleep(10)在退出前然后开另一个终端用ps aux | grep zombie_demo能看到状态标记为Z的进程。如果父进程一直不 wait僵尸会一直挂着所以写代码时记得及时 wait。3.3 exec把当前进程换芯fork 复制出来的子进程和父进程跑的是同一份代码。但实际使用中我们往往想在子进程里运行一个完全不同的程序比如 shell 里执行ls。这一步靠exec()系列函数完成。#include stdio.h #include unistd.h #include stdlib.h #include string.h #include sys/wait.h int main() { pid_t rc fork(); if (rc 0) { fprintf(stderr, fork failed\n); exit(1); } else if (rc 0) { char *args[3]; args[0] strdup(ls); args[1] strdup(-l); args[2] NULL; execvp(args[0], args); printf(this line never runs\n); } else { wait(NULL); printf(parent done\n); } return 0; }execvp做的事情是用ls这个程序完全替换当前进程的地址空间、寄存器、PC。也就是说子进程从 fork 出来的那一刻起身上还带着父进程的壳但 exec 一执行壳里的内容被整个换掉变成ls的程序。这就是为什么printf(this line never runs\n)永远不会执行——不是被跳过而是执行到这里时进程已经变成ls了后面这段代码已经不存在于地址空间里。所以要记住 exec 的语义它如果成功了就不返回了。如果 exec 之后的代码还能执行那说明 exec 失败了。这也是很多教材里强调的exec 只在失败时返回的意思。3.4 组合拳shell 到底是怎么启动一条命令的把三件套组合起来你就能理解 shell 的工作原理了。终端里敲ls -lshell 大致做了三件事fork()创建一个子进程子进程一开始就是 shell 的复制品。子进程调用execvp(ls, args)把自己变成ls进程。父进程shell调用wait()等待子进程跑完然后打印新的提示符。这样既保证了ls是独立进程有自己的 PID、自己的环境又保证了 shell 本身不会被ls的代码覆盖——因为 exec 的是子进程不是 shell 自己。这套设计是 Unix 几十年的经典智慧。为什么不用一个创建并立即运行的复合 API因为解耦。fork 负责复制exec 负责替换wait 负责同步三个原语各管一件事你可以自由组合出任意流程。比如你想在子进程里重定向输出可以先 fork在子进程里用open打开文件、dup2把标准输出指向文件然后再 exec这套流程完全不用改动 exec 本身灵活性极高。4. 进程的一生运行、就绪、阻塞状态如何切换4.1 为什么必须引入状态这个概念你打开任务管理器可以看到每个进程旁边有个状态运行中、挂起、正在运行……这些不是给你看着玩的而是操作系统调度器的核心输入。一个进程从创建到退出会经历多个状态OSTEP 第四章给了一个简化的状态机。核心有这几个状态状态含义你可以在任务管理器里对应的观察现象运行Running进程正在 CPU 上执行指令CPU 占用率高的进程就绪Ready进程可以运行但暂时没轮到 CPU短暂等待被调度的进程阻塞Blocked进程在等待某件事比如磁盘 I/O、网络包、锁完成此时不占 CPU无响应但 CPU 占用为 0 的进程初始Initial进程正在被创建刚启动的进程最终Final进程已退出等待父进程回收僵尸态状态栏里的 Z 进程4.2 状态之间的转换规律状态转换有几个规则初学的时候容易搞混我说一下自己理解的方式就绪 → 运行调度器从就绪队列里挑了一个进程把 CPU 交给它。这个动作叫被调度。运行 → 就绪这个进程的时间片用完了比如每 10ms 切换一次或者它被更高优先级的进程抢占CPU 被收回但人家并没有出错只是排队等下个时间片。运行 → 阻塞进程主动发起了一个需要等待的操作典型就是讀磁盘或等待网络数据。这时候 CPU 可以腾出来给别的进程用否则 CPU 闲着太浪费。阻塞 → 就绪等待的事件完成了比如磁盘数据到了进程重新变得可运行但还不能立刻跑得排队等调度器发 CPU。初始 → 就绪/运行fork 完成后进程加入系统等调度器给它第一次机会。这里最容易踩的认知误区是阻塞 ≠ 就绪。阻塞中的进程即使在疯狂等待事件它也不会消耗 CPU 时间而就绪进程虽然没在跑但它随时可以被调度器选中进入运行。很多人在排查程序卡住时会发现 CPU 占用是 0大概率就是进程进入了阻塞状态在等什么外部条件。4.3 用一个 I/O 例子看状态机实战假设你运行一个程序它先计算 1 秒纯 CPU 密集然后读一个大文件 2 秒I/O 等待再计算 1 秒。在示意图里它的状态是这样走的Running算 1 秒→ Blocked读文件 2 秒→ Ready读完等调度→ Running再算 1 秒→ Final。关键洞察是在它 Blocked 的那 2 秒里CPU 没有闲着。调度器会立刻让其他就绪进程顶上。比如你一边下载文件网络 I/O阻塞一边编译代码CPU 密集下载进程的阻塞时间正好让给编译进程用。这就是多进程让电脑同时干很多事的真相——不是你有多核而是阻塞期的空隙被充分利用了。这也是为什么 I/O 密集型程序和 CPU 密集型程序对操作系统的要求完全不同。I/O 密集程序大部分时间在阻塞等待CPU 密集程序则想占满每个时间片。调度器未来要针对这两种情况设计不同的策略第四章完全没讲因为那是后续章节调度的核心矛盾。现在你只需要建立起状态是调度决策的基础材料这个概念。4.4 状态机里藏着的两个问题盯着这个状态机看一会儿你会发现问题第一谁来管理就绪队列一堆进程处于 Ready凭什么 A 先上而不是 B 先上这是调度策略policy问题比如先来先服务、最短任务优先、时间片轮转等都是后面的章节。第二状态切换时怎么保证现场不丢失从 Running 切到 ReadyCPU 上的寄存器里全是这个进程的中间结果谁负责保存恢复这是机制mechanism问题也就是下一章要讲的上下文切换。OSTEP 特意把机制和策略分开这是理解操作系统的顶级心法机制是怎么做到策略是做什么选择。第四章主要讲机制——进程的抽象和上下文切换调度策略在第五章之后才会登场。你别急着往调度算法里钻先把状态机搞透。5. 操作系统的账本进程控制块与上下文切换是怎么协同的5.1 进程控制块里到底记了哪些账操作系统能同时管理成百上千个进程靠的是一个核心数据结构进程控制块Process Control BlockPCB。在 Linux 里这个结构叫task_struct你会经常在内核代码和相关资料里看到它。PCB 是每个进程的档案袋里面大致装这几类信息类别具体内容用途进程标识PID、PPID、用户 ID、组 ID区分进程、决定权限机器状态寄存器上下文PC、SP、通用寄存器、浮点寄存器上下文切换时保存/恢复现场内存信息地址空间布局、页表指针管理进程的内存映射I/O 信息打开的文件描述符列表、设备占用管理文件与设备访问调度信息优先级、状态、时间片剩余、CPU 使用统计供调度器做决策审计信息启动时间、CPU 时间累计统计与监控你可以用/proc/PID/status看到很多 PCB 里的字段cat /proc/1/status里面会有Pid:、PPid:、State:、VmSize:等字段。VmSize就是进程虚拟内存大小。看到这些真实数据之后你再回头看 PCB 这个概念就不会觉得它是纸面上的术语了。5.2 上下文切换操作系统最核心的换人动作上下文切换context switch是操作系统的一个高难度动作。来想象一场接力赛运动员 A 正在跑突然要换 B 跑但 A 脑中的所有进度记忆不能丢B 也必须从自己的记忆接着跑。CPU 上的寄存器就是运动员的短期记忆而 PCB 就是记忆的存档点。切换流程大约是这样的CPU 捕获一个中断或系统调用比如时间片到了或者进程主动请求 I/O。操作系统内核保存当前进程 A 的寄存器值到 A 的 PCB。从某个队列/列表里选出下一个要运行的进程 B这里的决策由调度器完成。从 B 的 PCB 中恢复之前保存的寄存器值。更新相关数据结构比如把 B 标记为 Running跳到 B 的 PC 位置继续执行。这件事看起来简单但有个先有鸡还是先有蛋的味道保存寄存器要用寄存器可寄存器里全是当前进程的东西。实际做法是CPU 的硬件会自动把一部分上下文压入内核栈操作系统再用软件把剩余寄存器保存到 PCB。这部分涉及中断入口的汇编代码第四章不细展开你只要记住一个关键点上下文切换期间操作系统自己的代码也在同一个 CPU 上运行它必须小心地不污染用户的寄存器现场。5.3 上下文切换的直接成本不只是浪费时间很多人以为进程切换的代价就是保存恢复几十个寄存器那点时间微不足道。但现代 CPU 上有两个隐藏的大坑第一个坑是缓存失效。CPU 有 L1/L2/L3 高速缓存里面装的是刚才那个进程访问过的代码和数据。切换到另一个进程后缓存里的内容全部不对口——新进程访问的是完全不同的内存地址原来的缓存命中率骤降CPU 只能一遍遍去读内存导致切换后的最初几十微秒新进程跑得特别慢。这就像厨师洗完手换了块案板之前的刀工手感全得重新找。第二个坑是 TLB 失效。CPU 中用于虚拟地址到物理地址转换的 TLB 缓存在进程切换后也需要失效或刷新否则可能访问到上一个进程的物理页。现代 Linux 通过给每个进程一个独立的地址空间标识ASID来减少这个开销但 TLB 失效仍然存在。这也是为什么操作系统不会频繁地在进程之间切换时间片一般设置在几毫秒到几十毫秒切换太频繁会让 CPU 大量时间耗在换人而不是干活上。这部分内容在今后的实验课里会专门量测现在知道这个原理就够用了。5.4 进程列表与启动顺序从 init 到你的终端操作系统内部维护着一系列双向链表把所有进程串起来。进程列表的每一项就是一个 PCB。你用pstree可以看到这棵进程树的形状pstree输出会是一棵倒挂的树最上面通常是systemd或者init下面是各种服务、你的登录 shell、以及你跑的程序。Linux 中的 fork 天然形成父子关系所以进程列表在逻辑上是一棵树而不是一堆散点。这个树状结构对理解父进程挂了会怎样很有用——父进程提前退出子进程会被systemd收养而不是直接断电消失。我自己在一台 Ubuntu 服务器上跑过一次pstree | wc -l忘了几百行当时就被震到了原来这台机器光进程的账本就记了这么厚一摞。而这一切靠的就是每个 PCB 几个字段、外加一次一次稳定的上下文切换。操作系统在最底层其实不像你想的那么神秘它就是一套极其严格的记账换人流程。6. 学完第四章怎么用它解释电脑上的怪现象6.1 为什么一个聊天软件会有十几个进程现在你可以回答这个热搜问题了为什么任务管理器里聊天软件、浏览器动不动有十几个进程。核心原因之一是多进程隔离。浏览器把每个标签页放进独立进程一个标签页崩溃不会拖垮整个浏览器聊天软件把主界面、消息推送、音视频通话拆到不同进程任何一个模块卡死你还能聊微信。这种设计的代价是每个进程都有独立的地址空间和 PCB内存开销更大但从稳定性和安全性看绝对划算。第二个原因是进程天然是并发的最小单位。你需要同时做很多事情接收消息网络 I/O阻塞、渲染界面CPU 密集、播放视频多媒体处理。如果全塞进单线程单进程任何一个阻塞都会卡住全部功能。所以现代软件普遍采用多进程架构你在任务管理器里看到的每个进程背后都是第四章讲的 fork/exec 机制的产物。6.2 进程池、IPC、线程这三个坑提前避一下学完进程之后你很快就会碰到几个看起来很像但完全不同的概念。进程池进程创建和销毁是有成本的fork 要复制地址空间退出要回收 PCB。高并发服务器如果每来一个请求就 fork 一次性能会崩。进程池的思路是启动时预先创建一批进程接活、干完、待命避免反复 fork。这就像餐厅提前备好十个传菜员而不是每来一桌客人就现招一个。IPC进程间通信进程之间地址空间相互隔离这也是安全的基础。但现实中进程总要交换数据比如浏览器主进程要把渲染任务分给渲染进程。解决方式就是 IPC包括管道、消息队列、共享内存、socket 等。这一块是后来的章节但你现在应该理解没有 IPC进程隔离就变成了信息孤岛这个抽象就得拆了重设计。线程如果只是想在一个进程里同时做多件事进程显得太重——每次切换上下文要保存一整套地址空间和寄存器。线程是轻量级进程多个线程共享同一个地址空间切换时就不用换地址空间成本更低。第四章先不深入线程但你现在记住进程是资源隔离的单位线程才是 CPU 调度的更小单位后面章节会推翻你进程调度单位的粗浅认知。6.3 排查CPU 飙高和进程卡死的实操思路读完第四章你就具备最基本的进程排查能力了。遇到电脑卡顿、CPU 占用高按这个顺序查top或htop看哪些进程 CPU 占用最高。对可疑进程ps -p PID -o pid,stat,etime,cmd看它的状态和运行时长。如果进程状态是RRunning且 CPU 一直 100%多半是死循环或计算密集用perf top或gdb附加上去看热点。如果状态是D不可中断睡眠通常等 I/O或S可中断睡眠且 CPU 不高但程序没反应大概率是阻塞在 I/O 或锁等待上用strace -p PID看它卡在哪个系统调用。我还踩过一个具体的坑以前在服务器上写了个后台任务发现top显示一个进程 CPU 占用 300%。当时很慌以为有病毒。后来才发现是多核 CPUtop默认按单核 100% 计的多线程程序占满多核时会显示超过 100%。这不是异常是正常的多线程并发。学完进程状态你就会明白top里的 CPU 占比其实是调度器统计的累计数据理解原理后就不会被吓到了。排查僵尸进程也很有用ps -ef | grep defunct能看到Z状态的僵局进程。遇到一堆僵尸说明某个父进程创建了一堆子进程却没调 wait。杀掉父进程前提是不重要僵局会被 systemd 收养回收。但最本质的解法是搞清楚代码里哪条路径漏了 wait这才是第四章要你在写代码时就绷紧的弦。6.4 一条绕不开的建议把书里的例子亲手跑一遍最后想说的是一点学习建议。OSTEP 第四章的文字其实不长核心概念就是机器状态、API、状态机、PCB 这几块。但如果你只是看第二天就会忘。我自己的做法把书里的 p1.c、p2.c、p3.c、p4.c 手敲一遍编译、运行、观察输出顺序的随机性。改代码做小实验fork 两次会发生什么子进程里修改全局变量父进程看得到吗在子进程里先 exec 再输出会发生什么配合ps、top、/proc/PID/去观察操作系统记录的现场。每个实验都会刷新你对抽象这个词的理解。抽象不是让你看不见本质而是让你在正确层级探索本质。当你拿着一份 Linux 内核的进程调度代码看到某个函数在保存寄存器到task_struct时你能意识到它正是在执行第四章描述的机制那 OS 的大门就算真正推开了。下一期我打算继续沿着这本书的脉络写第五章——进程 API 的更细探讨或者直接跳到调度那部分具体看整理进度。这些笔记写下来最大的收获其实不是给读者讲了多少而是逼着自己把很多好像懂了的地方重新想了一遍。如果你也在啃这本书欢迎在评论区留下你实验时遇到的现象我们一起把这本书读薄。
返回列表