
简介重庆大学操作系统实验二的配套压缩包面向计算机专业本科生与操作系统自学者聚焦线程创建这一多线程编程基础用于系统理解线程API、生命周期及同步互斥机制。整个资源共77个文件压缩包仅275KB主体为35个C源文件和29个头文件另含少量汇编、Makefile及说明文档对应epos2-master工程中的实验代码、构建配置与阅读指引内部分布于内核源码、用户程序与构建入口等模块。目前已有51人学习下载。以epos2-master为核心代码完整覆盖线程创建涉及的任务上下文、创建接口、线程调度与互斥同步逻辑便于对照课程实验要求逐段剖析同时保留编译脚本与目录结构可辅助复现实验环境、梳理启动流程和排查常见问题。对需要夯实操作系统基本功或准备相关实验报告的学生这份紧凑的工程源码是边读边练的实用参考。1. 拿到“重庆大学操作系统实验二线程的创建.zip”先别急着解压大部分同学第一次打开这个压缩包时都默认里面应该有一份写好的代码模板稍微改改就能跑。但真正做过操作系统实验的人都知道这个实验交的不是“会调用pthread_create”而是你能不能把“线程到底是什么”讲清楚并且用代码证明它。实验二的标题落在“线程的创建”上背后的核心是理解Linux下线程和进程的底层关系学会用pthread库写出能并发执行的程序并能在编译链接和运行阶段排查典型问题。这个实验适合两类人一类是操作系统课正在讲进程线程章节、需要动手验证理论的学生另一类是工作后想补Linux并发基础、把pthread_create用明白的开发者。如果你现在连“为什么要用-lpthread”都说不上来这篇文章就是按实验的完整路径帮你走一遍。2. 线程创建背后的原理为什么实验要求用pthread而不是fork2.1 线程和进程在Linux内核里都是“任务”很多教材开篇就讲“进程是资源分配的最小单位线程是调度的最小单位”这句话理论上没错但放到Linux源码里看会发现问题没那么简单。Linux内核并没有为线程单独设计一套数据结构无论是进程还是线程底层都是一个task_struct。创建线程的pthread_create最终会调用clone系统调用而clone可以精确控制新任务与调用者之间共享什么、不共享什么。通俗地讲线程就是“共享了地址空间和大部分资源的进程”。如果fork创建子进程子进程拿到的是父进程页表的拷贝配合写时复制两者的地址空间是隔离的而pthread_create创建的新任务和主线程共享同一个mm_struct、同一份文件描述符表、同一个信号处理函数。这就是为什么线程之间可以直接访问彼此的局部变量——前提是你能拿到那个变量的地址因为它们活在同一个虚拟地址空间里。实验二让你亲手创建线程本质上是逼你理解clone这个系统调用中间那层“共享”语义。你在代码里只写了一行pthread_create背后却是内核在为你管理栈、管理task_struct、管理调度器里的新实体。搞不懂这一层后续的线程同步实验互斥锁、条件变量会做得非常痛苦。2.2 为什么实验平台通常选Linux加pthreadWindows上创建线程也有CreateThread但课程实验选Linux有它很实际的原因。第一pthread是POSIX标准的一部分接口稳定文档充分几乎所有Linux发行版都自带glibc中的pthread实现。你在Ubuntu上写好代码换到CentOS、Debian甚至麒麟系统上同一份代码基本不用改。而Windows的线程API和Linux差异巨大如果实验平台是虚拟机里的Linux用pthread显然更方便。第二Linux下的线程模型更“透明”。你可以通过/proc/pid/status里的Threads字段看到线程数可以用ps -eLf观察每个线程的PID和tgid甚至可以用strace跟踪pthread_create底层是否真的调用了clone。这种可观测性对教学实验至关重要——作业要求你不知道该查什么。第三考试和面试也常常围绕Linux线程展开。比如“线程和进程的区别”“为什么线程切换比进程切换快”“pthread_create失败会返回什么错误码”这些问题的答案在Linux上都能用代码验证。所以我的建议是如果实验指导书没有指定操作系统优先用Ubuntu虚拟机便宜、稳定、遇到问题搜得到。2.3 创建线程时内核做的事从用户态调用pthread_create到线程真正跑起来中间发生了什么我用最精简的流程帮你在脑子里建立一个模型用户态pthread_create构造一个pthread_attr_t参数或者用默认属性传入函数指针和参数。glibc内部调用clone系统调用指定CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD等标志。内核为新线程分配task_struct并在内核栈顶部布置线程上下文。调度器择机把新线程放到某个CPU核心上运行从start_routine的第一条指令开始执行。线程函数返回后调用pthread_exit清理用户态栈最后内核回收task_struct。这个流程里最容易被忽视的是栈。每个线程都需要独立的栈空间默认大小通常是8MBulimit -s可以看到。线程的局部变量、函数调用栈都放在这里两个线程不能共用同一个栈。我在实验里见过最典型的失败案例线程函数里定义一个大数组超过了默认栈大小程序直接段错误而且崩溃位置飘忽不定。这就是因为线程栈默认8MB是“虚拟内存”真正访问到未映射的页才会报错。提示在实验报告里如果能写到“pthread_create通过clone系统调用实现并指定了CLONE_VM等标志”这段描述的价值远比你贴20行代码高。老师一眼就能看出你确实理解了原理。3. 搭建可复现的实验环境Ubuntu虚拟机加gcc3.1 准备一个干净的Linux环境虽然你在Windows上装MinGW也能编译pthread程序但实验二后续如果有实验三、实验四涉及进程同步和共享内存在Windows上会非常别扭。常见做法是用虚拟机装一个Ubuntu我自己的习惯是装LTS版本比如22.04或者24.04因为apt源稳定遇到奇怪问题时的解决方案也多。安装虚拟机这一步不展开太多细节只提醒一个关键参数磁盘容量至少给20GB内存建议给2GB以上。如果你要跑的是更复杂的并发实验内存再大一点更好。装好后第一件事是更新软件源sudo apt update sudo apt upgrade -y3.2 确认gcc和pthread库可用理论上装了build-essential后gcc就会带上但pthread是独立于标准C库的编译时需要显式链接。我们先用命令确认环境是完整的gcc --version ldconfig -p | grep libpthreadldconfig -p | grep libpthread的作用是查看系统里有没有libpthread.so这个动态链接库。在较新的glibc2.34及以上中pthread的很多函数已经合并到了libc里但你依然需要显式加-lpthread来保证链接正确。如果看到类似libpthread.so.0的输出说明环境没问题。另外确认一下默认线程栈大小ulimit -s在Ubuntu上通常是8192KB也就是8MB。这个值和后面调试线程栈溢出直接相关。如果你在写实验代码时要用大数组记得先看这个值。git --version这个命令不是必选项但如果你实验要提交代码给老师检查我建议你花两分钟把代码仓库初始化好。至少做到每次修改有记录提交信息写清楚“实现了线程创建”“修正了参数传递错误”这比最后交一个文件名是final_final_v2.c的文件要体面得多。提示虚拟机网络不太稳的时候不需要反复重装系统。检查一下/etc/netplan下的配置文件通常只是DNS或者网卡没拉起来的问题。重装是最耗时间的“解决方案”。4. 写一个最小的线程创建程序pthread_create的完整拆解4.1 先跑通两个线程各打印一次新建一个文件命名为thread_basic.c输入下面的代码#include stdio.h #include pthread.h void* print_message(void* arg) { // 参数从void*转回char*这是pthread传参的惯用写法 char* msg (char*)arg; printf(%s\n, msg); return NULL; } int main() { pthread_t t1, t2; // 创建线程1线程属性传NULL表示使用默认属性 pthread_create(t1, NULL, print_message, hello from thread 1); // 创建线程2传入不同的字符串参数 pthread_create(t2, NULL, print_message, hello from thread 2); // 等待两个线程都执行完毕再结束主线程 pthread_join(t1, NULL); pthread_join(t2, NULL); printf(main thread done\n); return 0; }编译命令gcc thread_basic.c -o thread_basic -lpthread运行./thread_basic代码逻辑说明pthread_create的第一个参数t1是线程标识符成功后内核会把新线程的ID写到这个变量里第二个参数NULL表示用默认线程属性包括默认栈大小、默认调度策略第三个参数是线程函数的函数指针线程启动后从这个函数开始执行第四个参数是要传给线程函数的值它被统一封装成void*这就是为什么线程函数签名必须是void* (*)(void*)。运行两次你看到的输出顺序可能不一样。一次可能是hello from thread 1 hello from thread 2 main thread done另一次可能是hello from thread 2 hello from thread 1 main thread done这完全正常。线程创建后谁先被调度是不确定的不要尝试去预判顺序这也正是实验报告里值得写一笔的“调度无确定性”。如果你在报告里写了“本次运行结果为…所以…”老师大概率会扣分因为换个机器结果可能就变了。4.2 四个参数逐个调attr、start_routine、arg第一个参数pthread_t* thread它是输出参数。注意pthread_t不一定是一个无符号长整型在Linux上通常是unsigned long但你在代码里不应该关心它的具体类型只把它当作不透明句柄使用就好。第二个参数const pthread_attr_t* attr大多数入门实验用不到直接传NULL。但如果你想调整线程栈大小就需要初始化一个pthread_attr_t并调用pthread_attr_setstacksize。比如你在线程函数里要递归8MB栈不够用就改成64MBpthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setstacksize(attr, 64 * 1024 * 1024); pthread_create(t1, attr, print_message, NULL);第三个参数void* (*start_routine)(void*)它是线程入口函数。这个函数的返回值和参数都必须是void*这是POSIX接口的历史原因。你不需要硬背但写代码时要记住如果你定义的函数签名是void print()编译器会报错或警告这个坑几乎每个初学者都踩过。第四个参数void* arg它是传给start_routine的参数。传普通变量时要小心最常见的方式是传一个整数的地址或者传一个结构体指针。如果只是传一个整数可以这样写pthread_create(t1, NULL, print_int, (void*)42);线程函数里再转回来int value (int)(intptr_t)arg;这里的关键是用intptr_t做中间转换保证指针和整数在32位和64位平台上的安全性。4.3 传参的边界传栈地址和传结构体的坑第一个坑是传局部变量的地址。int main() { int a 1; pthread_t t; pthread_create(t, NULL, print_int, a); // 如果主线程继续往下跑改动了栈上的变量a线程读到的值就不确定了 a 2; pthread_join(t, NULL); }子线程访问的a指向主线程栈上的同一块内存一旦主线程在子线程读取前修改了a子线程读到的可能是新值。这种数据竞争在实验里很难复现常常被误认为“编译器优化有bug”。正确做法是给每个线程动态分配一份独立的内存传完参数后在线程函数里释放int* arg malloc(sizeof(int)); *arg 42; pthread_create(t, NULL, print_int, arg); // 在线程函数内使用后free掉第二个坑是传结构体时只传了指针但结构体是临时构造的。struct thread_arg arg {1, 2}; pthread_create(t, NULL, thread_func, arg); // 如果arg是main里的局部变量且main提前退出或者被重用子线程读到的数据可能已损坏正确做法是malloc一个结构体每个线程独立持有线程函数结束时负责释放。判断标准很简单这个内存的生命周期能不能撑到子线程把它用完。你把握不住的时候就动态分配交给线程释放。提示实验报告里如果能画出“主线程栈、子线程栈、堆上动态分配的参数”三者的关系图并说明为什么栈上的参数有风险这个实验的分数不会低。5. 编译调试与避坑从“能跑”到“稳定跑”5.1 编译命令里-lpthread的位置不是随便放的很多人第一次编译pthread程序会碰到这样的报错undefined reference to pthread_create这个错误的头号原因就是编译时漏了-lpthread。但还有一个更隐蔽的情况-lpthread放在了源文件前面。比如这样gcc -lpthread thread_basic.c -o thread_basic在某些老版本gcc上链接库的顺序有讲究库应该放在需要它的目标文件之后。虽然现代gcc对顺序的宽容度提高了但为了不翻车统一写成源文件在前、库在后的习惯gcc thread_basic.c -o thread_basic -lpthread如果你用了pthread_join但只加了-pthread而没有-lpthread在某些环境下也会出问题。我的习惯是两个都加编译时加-pthread链接时显式加-lpthread。-pthread会在编译阶段定义_REENTRANT宏影响某些头文件的开窗比如errno的线程本地存储。5.2 坑一主线程抢先退出子线程还没跑完就没了很多同学初学时会写出这样的代码pthread_create(t1, NULL, print_message, thread1); // 没有pthread_join直接return 0 return 0;运行结果是什么样的有时候能看到线程打印有时候什么都看不到。原因是主线程return 0后进程里的其他线程会被立即终止内核不会给它们“优雅收尾”的机会。现象程序退出码是0但子线程的输出时常丢失。原因主线程结束导致整个进程退出内核将进程内所有线程一并回收。解决在主线程里调用pthread_join(t1, NULL)这会阻塞等待子线程结束。每个你创建的线程都应该有一个对应的join操作除非你把它detach掉。这个习惯能让你避开后续一大批“并发程序无缘无故退出”的问题。5.3 坑二打印顺序不稳定怀疑是玄学现象同一个程序连续运行几次输出的行顺序每次都不同。原因线程创建后各线程的调度顺序由内核调度器决定printf内部虽然对stdout有锁但多个线程拿锁的顺序是随机的。解决不要在实验报告里写“线程按创建顺序输出”。如果你需要确定顺序用互斥锁保护printf或者用一个原子标志控制执行顺序。但实验二一般不要求你控制输出顺序你只要解释清楚“顺序不确定由调度决定”就足够了。5.4 坑三忘记void*返回类型导致的编译警告现象编译时出现类似expected void * but argument is of type int的警告。原因线程函数签名不匹配例如写成了int func()。解决严格按void* (*)(void*)签名定义线程函数。就算函数不需要返回值也要写成void* func(void* arg) { return NULL; }。这条规则没有例外即使编译器只报警告不报错也应该修正因为未定义行为可能在你换一个编译器后炸出来。5.5 坑四线程栈溢出导致段错误且崩溃位置飘忽现象程序运行一会儿后段错误core dump提示的崩溃地址每次都不一样。原因线程函数里定义了一个超大局部数组或者递归深度过大访问到了未映射的栈空间。解决先用ulimit -s确认默认栈大小然后计算你的数组大小。如果确实需要更大栈用pthread_attr_setstacksize设置不要想着用全局数组代替局部数组来“绕过去”——全局变量在多线程环境下会引入数据竞争后患无穷。排查命令# 查看崩溃线程的调用栈前提是编译时加了-g gdb ./thread_basic core btbt打印backtrace如果看到多个帧说明栈确实被用到了深处。加-g编译是一个好习惯它能让你在调试时看到函数名和行号而不是一串地址。6. 进阶验证用共享变量证明“线程共享地址空间”实验二只要求创建线程但如果你想交一份有区分度的报告可以加一个“共享变量累计”的验证。原理很简单两个线程对同一个全局变量各加1000次如果两个线程确实共享地址空间最终结果应该是2000如果像进程一样隔离结果会是1000。#include stdio.h #include pthread.h int counter 0; void* add_loop(void* arg) { for (int i 0; i 1000; i) { counter; // 这里存在数据竞争仅实验演示用 } return NULL; } int main() { pthread_t t1, t2; pthread_create(t1, NULL, add_loop, NULL); pthread_create(t2, NULL, add_loop, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(counter %d\n, counter); return 0; }运行结果大概率不是2000而是介于1000和2000之间的一个数比如1734、1886等。为什么因为counter不是原子操作它分成读取、加一、写回三步两个线程可能同时读到同一个旧值然后都写回同一个新值就丢失了一次更新。这就是数据竞争是后续同步实验的引子。如果你在报告里写到“线程由于共享地址空间直接访问同一变量但非原子操作会导致结果不确定”那实验二的思考题基本就稳了。还有两个验证方法值得放进去查看线程数ps -eLf | grep thread_basic能看到两行LWP列是两个不同的线程IDPID列相同这直接证明两个线程属于同一个进程。查看线程状态cat /proc/pid/status | grep Threads输出类似Threads: 2也能佐证进程中存在两个线程。这个实验本质上不是什么高深技术它是在帮你建立“并发程序是可以被观察和分析的”这一观念。我自己的习惯是每写完一个线程程序都会跑一次valgrind --toolhelgrind检查数据竞争这个工具会对未加锁的共享访问给出警告。实验二阶段你可能用不上但如果你打算往后做线程池或生产者消费者实验这个工具能帮你省掉大量调试时间。最后说一个我亲自踩过的坑实验二我当时图省事把两个线程函数写成了同一个函数的不同分支结果代码里不小心用了一个静态局部变量两个线程互相覆盖调试了两个小时才找到。后来我养成了习惯——每个线程函数里尽量不要用静态变量优先把可变状态通过void* arg传进去。这个习惯延续到我现在写生产代码救过我很多次。希望这篇文章能帮你把实验二做得更扎实也帮你真正跨过“会写pthread_create”到“理解线程模型”之间的那道坎。本文还有配套的精品资源点击获取