进程与线程的创建与并发执行:操作系统实验核心解析
1. 实验任务拆解进程与线程的创建和并发执行到底在验证什么刚做完操作系统课的第一个实验题目是“进程、线程的创建与并发执行”说实话刚开始觉得无非就是调用几个API的事结果真正上手才发现水很深。这个实验看起来只是让学生学会用fork()创建进程、用pthread_create()创建线程再观察一下并发执行的现象但它背后其实覆盖了操作系统课程里最核心的几个概念进程地址空间隔离、线程资源共享、上下文切换开销、调度器的行为特征。我做完之后最大感受是这个实验不是让你学会调API而是逼你去体会“并发”这个词在操作系统层面真实的样子。从教学设计的角度看这个实验选得很有讲究。很多初学者第一次接触并发的概念容易从“并发就是多个东西同时跑”这种直觉出发但实验会通过现象告诉你进程之间的“同时”是假象多个进程在单核CPU上其实是轮流用时间片跑而线程之间看起来也在“同时”跑但它们共享同一份地址空间一不留神就会互相踩踏。这种体验靠看书看不出来必须亲手跑一遍代码、亲眼看输出结果才能建立起直觉。这个实验通常分两个阶段先创建多个子进程观察并发执行的现象再创建多个线程观察它们共享变量的效果。很多学校的实验指导书会把这两个阶段合在一起要求你最后回答一个问题——进程和线程在并发执行中有什么本质区别。如果你只是把代码粘贴跑一遍拿到结果就完了会错过最精华的部分。我在做的时候把两段代码分别加了计数器变量打印各自的地址和值变化对比出来的信息量比单纯跑通实验大得多。另外提醒一下国内高校的操作系统实验大多基于Linux环境编译器用GCC写C语言。如果你用的是Windows建议装个虚拟机或者直接租一台Linux云服务器因为fork()是Unix/Linux体系特有的系统调用原生Windows环境不支持用模拟层强行跑容易出一些莫名其妙的坑。2. 核心原理回顾fork的地址空间复制pthread的资源共享教材不会讲这么透这部分我要先花点篇幅说清楚进程创建和线程创建底层各自发生了什么事情。不夸张地说理解了这一节实验代码里99%的“神奇现象”你都能自己解释。2.1 fork()到底复制了什么fork()是Linux下创建进程的核心系统调用。很多人以为fork就是“把当前程序再复制一份跑起来”这个说法对了一半另一半是关键。fork复制的是调用进程的地址空间——包括代码段、数据段、堆、栈、打开的文件描述符表、环境变量等等但它是通过一种叫“写时复制”的机制来做的。写时复制的意思是fork返回时子进程和父进程其实共享同一份物理内存页并没有真的把所有数据拷贝一份。只有当某个进程尝试修改某个内存页时内核才会真正复制出这个页再让它改自己的副本。这样做的好处是创建进程的开销小坏处是两个进程看似有独立的地址空间但如果你在父进程或子进程里修改一个变量另一个进程是看不到变化的因为它们最终各自持有自己的副本。这里有一个实验里必考的细节fork()调用一次但是返回两次。它在父进程中返回子进程的PID在子进程中返回0失败时返回-1。这个设计不是随便定的它直接决定了你怎么在代码里区分父子进程的执行路径pid_t pid fork(); if (pid 0) { perror(fork error); exit(1); } else if (pid 0) { // 子进程从这里开始执行 printf(我是子进程 PID%d\n, getpid()); } else { // 父进程从这里开始执行 printf(我是父进程子进程PID%d\n, pid); }实验里最容易忽略的一点是fork之后父子进程不是分别从main函数开头重新跑一遍而是都从fork调用返回后那一行继续往下执行只是通过返回值不同走进了不同的分支。很多初学者写出来代码发现输出重复了好几遍就是因为没搞清楚这个“从调用点之后继续”的语义。2.2 pthread_create()为什么能共享全局变量线程和进程最大的差异在于同一进程内的线程共享地址空间。pthread_create()调用之后新线程和主线程位于同一个进程里全局变量、堆内存malloc出来的、文件描述符都是共享的。也就是说你在一个线程里修改某个全局变量的值另一个线程里立刻就能看到。#include pthread.h #include stdio.h #include stdlib.h #define THREAD_NUM 5 int shared_counter 0; void* worker(void* arg) { int id *(int*)arg; shared_counter; printf(线程%d执行完毕shared_counter%d\n, id, shared_counter); return NULL; } int main() { pthread_t tids[THREAD_NUM]; int ids[THREAD_NUM]; for (int i 0; i THREAD_NUM; i) { ids[i] i; pthread_create(tids[i], NULL, worker, ids[i]); } for (int i 0; i THREAD_NUM; i) { pthread_join(tids[i], NULL); } printf(所有线程结束最终shared_counter%d\n, shared_counter); return 0; }这段代码你跑几次就会发现输出结果可能不同甚至shared_counter的最终值在不同机器上表现不一样。这就牵扯出并发编程最核心的话题——竞争条件。多个线程同时执行shared_counter时这个操作在CPU层面并不是一步完成的它要经历“读取内存到寄存器、寄存器加1、写回内存”三步。如果两个线程同时对同一个变量做这三个动作就可能出现覆盖写的情况线程A读取了值0准备加1线程B也读取了值0准备加1然后A写回1B也写回1最终结果是1而不是2。这个现象叫竞争条件本实验的一大看点就是让你亲眼看到它发生。2.3 系统调用的返回值与错误处理实验报告里经常被忽略fork()和pthread_create()的返回值是理解整个实验的关键。不仅是fork()有三次返回的问题pthread_create()成功返回0失败返回错误码。很多同学写代码不检查这两个返回值导致出错时一脸懵。我强烈建议每一步都加上错误处理这不是形式主义而是真正帮你定位问题的路径——少一次perror(pthread_create)这种输出你可能要白白浪费半小时去猜数据错乱的原因。3. 实验环境准备该装什么、该配什么一次说清这个实验对操作系统实验来说算是对环境要求不高的一类不需要额外装虚拟机或模拟器只要有Linux环境就能做。但正因为如此“环境凑合能用就行”反而成了最大的坑。3.1 Linux环境的几种选择我用的是Ubuntu 22.04如果你手头是其他版本的Ubuntu或者CentOS差别也不大因为核心的编译工具链都是一样的。具体需要装的东西只有两个GCC编译器用sudo apt install build-essential基于Debian/Ubuntu的发行版或者sudo yum groupinstall Development Tools基于RedHat/CentOS的发行版就能装好。这个包里面除了GCC还会带上make之类的构建工具。如果系统没装man手册建议补一个sudo apt install manpages-posix-dev方便自己查fork、pthread_create的手册页。写代码我推荐VS Code配Remote-SSH插件直接在本地编辑远程Linux机器上编译运行。如果在纯终端环境也没问题用vim或nano都行对这个体量的代码来说编辑器只要能写就行不用纠结。注意Windows用户如果不想装虚拟机也可以考虑用WSLWindows Subsystem for Linux。WSL2对fork这类系统调用的支持已经很好了跑这个实验绰绰有余。3.2 编译链接时最容易被坑的pthread库GCC编译多线程程序需要在编译命令后面加-pthread这一点太容易踩坑了gcc -o thread_demo thread_demo.c -pthread不加这个参数时编译能通过因为pthread.h头文件可以正常引入但链接阶段会报对pthread_create未定义的引用之类的错误。原因是pthread库默认没有被链接进来必须显式指定-pthread或者是老版本的-lpthread。-pthread更规范因为它不只是链接库还会顺便帮编译器定义一些必要的宏推荐你用这个。编译顺序我建议展开写一下省得初学者把命令敲得千奇百怪。其实格式就是gcc [编译选项] 源文件名 -o 输出文件名 [链接库选项]所以完整的命令是gcc fork_demo.c -o fork_demo后面不用加什么东西而线程的就要在最后加上-pthread。把这个规则写进实验报告的步骤里能做到百分百稳。另外如果你的代码里调用了别的库比如数学库math.h里的sqrt需要加-lm。不过我建议实验里不要引入这类不必要的依赖保持代码纯净。否则多一个库就多一个链接失败的排查点纯属自找麻烦。3.3 我踩过的工具链坑内核线程调度与容器环境在查资料时看到有人用容器或云沙箱跑这个实验。如果你用的是Docker容器或者k8s沙箱环境需要留意一个细节容器的进程模型和宿主机不完全一致镜像默认的并发调度参数比如cpu-shares、pids-limit可能影响实验结果最典型的是进程创建数量受限导致fork失败。这不是你代码的问题而是环境约束建议还是在正规的Linux虚拟机或物理机上做实验否则报告里解释不了这些现象容易被老师扣分。4. 实验设计与代码实现从fork到pthread完整跑通整个实验这部分直接上完整可运行的代码配合我加的解释你可以照着抄完再对照自己的理解。为了照顾各种水平的读者我先给一个大纲式的设计思路然后逐个填代码。4.1 实验一多进程的并发创建fork实验目标父进程创建指定数量的子进程每个子进程输出自己的PID、父进程PID和当前计数器的值然后各自退出。父进程等待所有子进程结束再输出最终结果。这个设计能直观看到多个进程同时运行的输出交错现象。// fork_demo.c #include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h #define PROCESS_NUM 5 int main() { int i; pid_t pid; int counter 0; printf(父进程启动PID%d\n, getpid()); for (i 0; i PROCESS_NUM; i) { pid fork(); if (pid 0) { perror(fork失败); exit(1); } if (pid 0) { // 子进程执行路径 counter; printf(子进程[%d]: 我的PID%d, 父进程PID%d, counter%d\n, i1, getpid(), getppid(), counter); exit(0); // 子进程执行完必须退出否则会继续进入下一轮fork } // 父进程不做额外操作继续循环fork下一个子进程 } // 父进程等待所有子进程结束 for (i 0; i PROCESS_NUM; i) { wait(NULL); } printf(父进程结束counter最终值%d\n, counter); return 0; }这段代码运行之后你会看到输出顺序不是固定的每次跑可能都不一样。有时候第一个子进程的输出排在第三个后面有时候最后那个子进程先输出。这正是因为父进程连续fork了5个子进程而子进程们并非严格按照创建顺序依次执行它们都要去竞争CPU时间片。调度器按什么顺序找下一个可运行的进程策略多种多样但直观结果就是“看起来是乱跑的”。这里有个关键点值得注意子进程里的counter操作不会影响父进程的counter值因为每个子进程都从fork那一刻的快照里继承了counter的值这个值是当时父进程的counter。所以5个子进程打印的counter可能都是0或者少数几个是1——取决于具体是哪一轮fork的时候被复制走的。而父进程最后的counter还是0只在循环里做fork操作本身并没有增加counter。这个现象完美演示了“进程之间地址空间相互独立”。编译与运行gcc fork_demo.c -o fork_demo ./fork_demo4.2 实验二多线程的创建与并发执行pthread实验目标创建多个线程每个线程对全局计数器进行递增操作同时输出各自的线程ID、全局变量地址和值。最终主线程等待所有线程结束后输出全局计数的最终结果。这个设计用来观察线程间共享内存的行为并且顺带演示竞争条件。按照教学实验室的常见要求我这里把代码分成两个版本基础版和能输出统计信息的进阶版。// pthread_demo.c #include stdio.h #include stdlib.h #include pthread.h #define THREAD_NUM 5 int shared_counter 0; void* thread_func(void* arg) { int tid *((int*)arg); printf(线程%d启动当前shared_counter%d\n, tid, shared_counter); shared_counter; printf(线程%d结束shared_counter变成了%d\n, tid, shared_counter); return NULL; } int main() { pthread_t tids[THREAD_NUM]; int ids[THREAD_NUM]; int i; for (i 0; i THREAD_NUM; i) { ids[i] i; if (pthread_create(tids[i], NULL, thread_func, ids[i]) ! 0) { perror(pthread_create失败); exit(1); } } for (i 0; i THREAD_NUM; i) { pthread_join(tids[i], NULL); } printf(主线程输出最终shared_counter%d (期望值5)\n, shared_counter); return 0; }为了把“共享”看得更清楚你还可以在每个线程里printf(当前shared_counter的地址%p\n, (void*)shared_counter);你会看到多个线程打印出来的地址是同一个。这个细节是写报告时的加分包——它证明了线程确实共享了同一份变量内存。4.3 进阶验证给线程加锁对比加锁前后的结果为了体现实验的核心价值强烈建议你额外写一个加了互斥锁的版本// pthread_demo_mutex.c #include stdio.h #include stdlib.h #include pthread.h #define THREAD_NUM 5 int shared_counter 0; pthread_mutex_t lock; void* thread_func(void* arg) { int tid *((int*)arg); pthread_mutex_lock(lock); shared_counter; pthread_mutex_unlock(lock); printf(线程%d修改完毕shared_counter%d\n, tid, shared_counter); // 注意printf本身虽然是线程安全的但输出内容并不可控 // 这里是先解锁再打印所以counter的顺序可能与修改时刻不一致 return NULL; } int main() { pthread_t tids[THREAD_NUM]; int ids[THREAD_NUM]; int i; pthread_mutex_init(lock, NULL); for (i 0; i THREAD_NUM; i) { ids[i] i; if (pthread_create(tids[i], NULL, thread_func, ids[i]) ! 0) { perror(pthread_create失败); exit(1); } } for (i 0; i THREAD_NUM; i) { pthread_join(tids[i], NULL); } pthread_mutex_destroy(lock); printf(加锁后最终shared_counter%d\n, shared_counter); return 0; }你对比运行上面两段代码通常会发现没加锁的版本最终值经常小于5而加锁的版本稳定输出5。原因就是我前面说的竞争条件。shared_counter不是一个原子操作没有锁的保护时多个线程同时读改写会导致更新丢失。这个对比实验做下来对并发编程的本质理解能提升一大截。4.4 实验里的经典疑问为什么我输出的行数不对很多同学实验做完发现输出行数少了几行或者多了几行。先说少的情况——一般不是代码逻辑错了而是子进程或线程跑到一半被kill了或者主线程没等它们跑完就先exit了。把pthread_join或wait的作用搞清楚就明白了。至于输出行多最常见的原因是fork之后父子进程都继续执行了后面的代码没有在子进程分支加exit(0)或者让子进程走独立的函数。这点很多人初学时掉坑我在这里提前打个预防针。5. 多进程与多线程的差异实测一次对比实验带来的直观认知这个部分我觉得是实验中最有价值的一环也是实验报告里最能体现思考深度的部分。做完基本的创建实验后我们可以用一个“加了一个很大开销的重复计算”的对比实验来量化进程和线程在切换开销和资源共享上的表现差异。5.1 场景设计编写一段程序分别用多进程和多线程的方式完成同一个计算任务例如计算从1到100000000的累加。进程版用fork创建4个子进程每个子进程负责计算其中一段然后通过共享内存或管道把结果传回父进程线程版创建4个线程每个线程计算一段分别把部分和累加到共享的全局变量上这里需要用原子操作或者锁保证正确性。计算各自耗时。为了公平进程版的结果传递也用共享内存加信号量的方式避免用管道太慢带来的不公平线程版用锁来保护共享累加变量。用gettimeofday()或者clock_gettime()计时。// time_compare_process.c 核心片段 #include stdio.h #include stdlib.h #include sys/mman.h #include sys/wait.h #include unistd.h #include sys/time.h #define N 100000000 #define CHILD_NUM 4 int main() { struct timeval start, end; long long* result mmap(NULL, sizeof(long long), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); *result 0; gettimeofday(start, NULL); for (int i 0; i CHILD_NUM; i) { pid_t pid fork(); if (pid 0) { long long partial_sum 0; int begin i * (N / CHILD_NUM) 1; int end (i CHILD_NUM - 1) ? N : (i 1) * (N / CHILD_NUM); for (int j begin; j end; j) { partial_sum j; } __sync_fetch_and_add(result, partial_sum); exit(0); } } for (int i 0; i CHILD_NUM; i) wait(NULL); gettimeofday(end, NULL); printf(进程版结果%lld耗时%ld微秒\n, *result, (end.tv_sec - start.tv_sec) * 1000000 (end.tv_usec - start.tv_usec)); munmap(result, sizeof(long long)); return 0; }线程版本类似把mmap换成全局变量和pthread_mutex_lock保护。// time_compare_thread.c 核心片段 #define N 100000000 #define THREAD_NUM 4 long long result 0; pthread_mutex_t lock; void* worker(void* arg) { int idx *(int*)arg; long long partial_sum 0; int begin idx * (N / THREAD_NUM) 1; int end (idx THREAD_NUM - 1) ? N : (idx 1) * (N / THREAD_NUM); for (int j begin; j end; j) { partial_sum j; } pthread_mutex_lock(lock); result partial_sum; pthread_mutex_unlock(lock); return NULL; }两个程序跑下来线程版通常比进程版快一些尤其是在创建和销毁的开销上进程创建要复制页表、建立新的地址空间线程创建只是分配栈空间和线程控制块。但更值得关注的是内存共享的效率——进程之间即使通过共享内存传递数据也需要一次额外的mmap地址映射和原子操作而线程直接访问同一个指针。5.2 实验带来的几个直观结论如果上面这个对比实验你认真做完了会有几个非常直观的印象线程创建速度远快于进程创建速度。这个可以通过简单的大规模创建循环测试看出来比如连续创建10000个进程和10000个线程对比时间。进程之间要做数据交互必须借助IPC机制共享内存、管道、消息队列等代价比线程直接用共享变量大得多。使用共享内存的原子操作或锁都要记得初始化、用完清理不然会内存在内核里残留或者后续进程出现奇怪现象。我在写进程版的时候用__sync_fetch_and_add做原子加避免了锁的复杂度但还是得提前把内存区mmap好并把结果置0。这个细节如果不做第一个子进程读到的是未初始化内存里的随机值。这些结论对理解后面课程中学的信号量、死锁、生产者消费者问题特别有用。实验报告里如果能带着这些对比数据去写含金量会提高很多。6. 实验的进阶拓展等待回收机制、原子操作与共享内存的正确打开方式前面说到实验里很容易忽略回收机制尤其是僵尸进程。这里再展开讲一下并补充进阶知识因为这是操作系统实验绕不开的考点。6.1 为什么子进程结束以后必须被wait子进程有两种退出方式主动调用exit(0)或者从main函数return返回。无论哪种内核都会保留子进程的进程控制块包括PID、退出状态等直到父进程调用wait()或waitpid()来回收它。如果父进程一直不回收子进程就会变成僵尸进程占用内核资源却什么也不干。大量僵尸进程积累会导致系统进程数超限、无法再创建新进程。实验里如果在fork循环之后没有wait你可以在另一个终端用ps aux查看就会看到一堆defunct状态的僵尸进程。这个实验结果本身就是很好的教学素材。正确做法是每个进程都要有对应的回收int status; pid_t child_pid wait(status); if (WIFEXITED(status)) { printf(子进程%d正常退出退出码%d\n, child_pid, WEXITSTATUS(status)); }6.2 共享内存还是mmap进程间同步亲测经验进程之间的数据共享通常有管道、System V共享内存shmget、POSIX共享内存shm_open和mmap匿名映射等若干方式。做实验时很多人会选管道但管道是单向的父子进程要双向通信得开两个管道麻烦。这里我推荐mmap匿名映射理由有三一是代码简洁不用先打开临时文件再到shmget二是MAP_SHARED确保所有子进程看到同一份内存内容三是结尾用munmap清理方便。但用了共享内存就得自己保证同步。我建议用__sync_fetch_and_add这个GCC内置原子操作因为它不需要额外引入pthread库免掉一些链接的麻烦。这个函数的含义是“将第一个参数指向的内存地址里的值加上第二个参数返回旧值”整个过程在硬件层面保证原子性不会被打断。在进程版里每个子进程把算出的部分和累积进去不会出现覆盖写的问题。如果你是在跟老师讨论时被问到“为什么用原子操作而不是普通加法”这就是解释的锚点。普通加法在多个进程同时写同一块内存时会互相覆盖因为操作不是原子的原子操作则保证了不管多少进程同时写结果都是一致的。这里的底层依赖的是CPU提供的原子指令比如x86上的lock add指令这已经脱离纯软件层面了了解即可。6.3 线程同步的锁问题优先级反转与错误加锁的教训实验里用了pthread_mutex_lock之后需要特别注意一个经典问题如果多个线程里既有高优先级线程又有低优先级线程持有锁低优先级线程被调度器换出持有锁不放导致高优先级线程无法获得锁系统吞吐量反而变差——这就叫优先级反转。在真实项目中需要配合实时调度策略处理比如优先级继承协议。我们做实验的代码规模小遇不到这类问题但实验报告里顺手提一下会显得理解更深一层。另一个细节是锁的粒度。锁加的太粗在循环外一次性加锁整个计算过程都锁住会导致多线程退化成就相当于单线程执行性能没有提升。锁加的太细在运算循环内部反复加锁解锁则加锁解锁本身的开销会吃掉多线程的优势。实验时建议设计两版一版把shared_counter那一步加锁一版把整个计算过程用锁包裹对比执行时间这个数据写进报告特别能体现你思考的深度。7. 常见问题排查与实验避坑指南刷到这个贴的你可以直接照抄答案每次操作系统实验同一个班几十号人跑出问题来的花样千奇百怪。我把我在几次带实验课过程中见过的最高频问题整理成一个速查表并详细展开几个最典型的。现象可能原因解决办法fork之后输出重复打印了几轮fork把整个程序从调用点之后复制了一份父进程和子进程都继续执行后面的代码在子进程分支里末尾一定要调用exit(0)或者让子进程逻辑全部放在一个独立的函数并调用后exit编译链接pthread失败没加-pthread链接选项编译命令末尾加上-pthread线程版counter最终值小于预期多个线程同时操作shared_counter发生竞争条件用pthread_mutex加锁或用__sync_fetch_and_add原子操作运行后出现Segmentation fault操作了未初始化的指针或数组越界检查fork子进程里是否误用了父进程栈上指针线程传入的ids数组是否生命周期足够长几个子进程的输出顺序每次运行都不一样调度器并非保证创建顺序即执行顺序这不是bug恰恰说明进程并发执行存在调度如果需要按顺序输出应该在子进程里加某种同步机制运行后一堆“defunct”进程父进程没有调用wait回收子进程在循环里wait或使用sigaction处理SIGCHLD信号主线程退出后其他线程还没跑完就崩溃了主线程调用了exit(0)而不是pthread_join或者pthread_exit在主线程末尾pthread_join所有子线程再结束7.1 为什么逻辑正确输出顺序却是乱的我在批改报告时经常看到学生在结论里写“发现输出顺序不稳定说明Linux调度算法不稳定”。这个表述不够准确也不够深入。准确的表述应该是Linux系统的CFS调度器是根据动态优先级和虚拟运行时间来决定下一个获得CPU的执行单元的创建顺序与调度顺序没有必然关系。所以并发执行的现象恰恰证明了调度器存在也解释了什么叫“抢占式多任务”。如果写实验报告建议在这一部分从两个层面展开宏观上看输出顺序不定与调度有关微观上看共享计数器结果的错乱与原子性缺失有关。两个层面分别对应操作系统课程的“进程管理”和“进程同步”两个核心模块。7.2 传递参数给线程时的生命周期陷阱一个极容易被忽视的错误是我们用循环创建多个线程时把循环变量i的地址传给了线程函数。一旦主线程很快进入下一个循环i的值变了新启动的线程再去读这个地址里的值可能已经变成了下一个数导致所有线程打印的都是同一个ID。正确做法是给每个线程分配独立的存储空间比如用数组ids[i]或者动态malloc一块内存存放线程编号。这一点在报告里写出来是加分项。// 错误示范 for (int i 0; i 5; i) { pthread_create(tid[i], NULL, worker, i); // 危险i是循环变量地址共享 } // 正确做法 int ids[5]; for (int i 0; i 5; i) { ids[i] i; pthread_create(tid[i], NULL, worker, ids[i]); }如果你用动态malloc的方式还要注意一点在线程函数结束前不要提前free这块内存否则线程函数访问到的就是未定义内容。正确做法是线程函数内部用完参数后自行free或者用一个生命周期覆盖整个线程执行周期的存储区。7.3 fork和pthread混用时的重大禁忌如果你玩心大起想在一个程序里同时用fork和pthread那你得知道一个规矩fork出来的子进程里建议不要直接调用pthread相关的函数除非你清楚知道哪些锁和线程状态是会被继承的。因为子进程是从fork那一刻的进程状态快照里拷贝的如果父进程当时已经有多线程在运行且某些线程正持有锁子进程里的线程就会卡在这些锁上一辈子——这些锁的持有者是父进程里的线程但父进程的线程不会存在于子进程的地址空间里。这种死锁极其隐蔽排查起来非常痛苦。操作系统实验一般不要求大规模fork和pthread混用但你如果写个demo玩要记住这个坑一旦出现卡死先往锁上想想。8. 实验总结与个人操作体会这个实验做完我对“并发”这个词的理解从抽象变成了具体。以前看书上说“多进程开销大、多线程开销小”只是背下来应付考试现在自己用mmap共享内存、用pthread锁、用原子操作才真正理解到为什么现代操作系统会有这么多同步原语。我个人建议做实验时不要急着把代码交给老师先花时间做三件“非必须但极有价值”的事情第一件把fork进程版的代码里每个子进程增加一个独立局部变量并修改它然后在子进程里打印这个局部变量的地址。你会发现每个子进程打印出来的地址都一样但修改互不影响——因为虚拟地址虽然相同物理地址却不同是页表映射的关系。能从代码层面感受到虚拟内存这门课就没白上。第二件尝试用strace工具跟踪fork的系统调用过程比如在命令行里跑strace -f ./fork_demo能看到程序在用户态和内核态之间的切换轨迹。strace会显示每个进程调用了哪些系统调用这个输出是理解“系统调用是进程进入内核的唯一大门”这句话的最佳实证材料。第三件把实验要求的并发数从5调大到100跑一次然后看系统进程数暴涨的现象。这一下你就会明白为什么真实服务器上不能随意创建大量进程也会开始理解线程池这种技术存在的意义。从副产品变成教学素材的这部分往往才是实验报告里最吸引人的地方。操作系统这门课理论是一层皮实践是扎进去的根。实验一只是一个起点后面还会遇到生产者消费者、读者写者、文件系统等等更复杂的模型。把这次创建的每一个进程、线程都当成训练素材去观察它的生命周期你会越看越上瘾。