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写回1,B也写回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用户如果不想装虚拟机,也可以考虑用WSL(Windows 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", i+1, 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跑一次,然后看系统进程数暴涨的现象。这一下你就会明白为什么真实服务器上不能随意创建大量进程,也会开始理解线程池这种技术存在的意义。从副产品变成教学素材的这部分,往往才是实验报告里最吸引人的地方。操作系统这门课,理论是一层皮,实践是扎进去的根。实验一只是一个起点,后面还会遇到生产者消费者、读者写者、文件系统等等更复杂的模型。把这次创建的每一个进程、线程都当成训练素材去观察它的生命周期,你会越看越上瘾。
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写回1,B也写回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用户如果不想装虚拟机,也可以考虑用WSL(Windows 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", i+1, 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跑一次,然后看系统进程数暴涨的现象。这一下你就会明白为什么真实服务器上不能随意创建大量进程,也会开始理解线程池这种技术存在的意义。从副产品变成教学素材的这部分,往往才是实验报告里最吸引人的地方。操作系统这门课,理论是一层皮,实践是扎进去的根。实验一只是一个起点,后面还会遇到生产者消费者、读者写者、文件系统等等更复杂的模型。把这次创建的每一个进程、线程都当成训练素材去观察它的生命周期,你会越看越上瘾。