资讯动态

操作系统实验二线程创建:从pthread_create到多线程同步避坑指南

发布时间:2026/10/13 3:37:16 来源:尧图企业网站定制
简介这份资源是重庆大学操作系统实验二的配套资料包面向计算机专业学生及操作系统自学者聚焦多线程编程中线程创建这一核心知识点。实验围绕线程库与系统API的调用展开涉及线程属性初始化、线程函数定义、生命周期管理以及互斥锁、条件变量、信号量等同步互斥机制并延伸至线程池的复用策略帮助读者理解线程作为调度最小单位的运行原理。压缩包共77个文件约275KB以35个C源文件和29个头文件为主体辅以4个汇编文件、3个Makefile及少量说明文档构成一套可编译运行的实验工程骨架便于对照实验指导书逐步调试。目前已有53人学习下载。通过该资料读者可掌握线程创建与管理的完整实现路径积累同步互斥与竞态条件处理的排错经验为后续多进程、多线程进阶话题打下实践基础。1. 线程创建实验到底在考什么从一次“跑不通”的课程设计说起很多同学拿到“操作系统实验二线程的创建”这个题目时第一反应是去网上找一份能跑的代码编译通过、截图交差。但真正动手才会发现线程创建这件事远不是调一个pthread_create那么简单——为什么打印顺序每次都不一样为什么加了sleep就“正常”了为什么主线程一退出子线程的输出就消失了这些现象背后考的是你对线程生命周期、调度不确定性、共享资源竞争的理解。这篇笔记就围绕“线程的创建”这个核心把从环境准备、最小可运行代码、参数含义到多线程竞争、同步收尾、常见翻车的完整路径讲清楚。适合正在做操作系统实验、需要交一份能讲明白的代码和报告的同学也适合想重新梳理 pthread 基础的一线开发者。下面所有代码都在 Linux 环境下用 gcc 验证过你照着敲就能复现。2. 把 pthread 最小例子跑起来环境、编译与第一个可执行文件线程创建实验的第一步不是写代码而是把编译环境理顺。Linux 下 pthread 并不是默认链接的很多人第一次编译就卡在undefined reference to pthread_create这不是代码写错了而是链接参数没加。这一章先把环境确认、最小代码、编译命令、运行观察四件事做完让你手里有一个确定能跑起来的基线版本后面所有讨论都基于它。2.1 确认环境与安装编译工具链先确认你用的是 Linux 或者能跑 gcc 的环境。打开终端执行下面几条命令看输出是否正常# 查看 gcc 版本确认编译器存在 gcc --version # 查看系统线程库位置一般能看到 libpthread ls /usr/lib/x86_64-linux-gnu/ | grep pthread # 查看 CPU 核心数后面分析并发行为会用到 nproc如果gcc --version报 command not found说明编译工具链没装。Debian/Ubuntu 系一般用sudo apt install build-essential这条命令会把 gcc、make、libc 开发头文件一起装上。libpthread在新版 glibc 里已经并入主库但编译时仍然建议显式加-pthread这是最稳妥的写法。nproc的输出记一下它决定了你的机器能真正并行跑几个线程后面观察调度顺序时会用到。提示不要用-lpthread代替-pthread。前者只链接库后者还会设置必要的编译宏行为更正确。2.2 写一个能观察线程 ID 的最小程序下面这段代码是整个实验的基线。它创建两个线程每个线程打印自己的线程 ID 和一段文字主线程等待它们结束#include stdio.h #include stdlib.h #include pthread.h #include unistd.h // 线程执行体arg 是创建时传入的参数 void *worker(void *arg) { int id *(int *)arg; // 取出传入的编号 pthread_t tid pthread_self(); // 获取当前线程自身的 ID printf(worker %d start, tid%lu\n, id, (unsigned long)tid); sleep(1); // 模拟一段工作放大调度差异 printf(worker %d end\n, id); return NULL; // 线程正常退出 } int main(void) { pthread_t t1, t2; int id1 1, id2 2; // 创建两个线程第四个参数是传给 worker 的指针 if (pthread_create(t1, NULL, worker, id1) ! 0) { perror(create t1); return 1; } if (pthread_create(t2, NULL, worker, id2) ! 0) { perror(create t2); return 1; } // 主线程等待两个子线程结束否则可能提前退出 pthread_join(t1, NULL); pthread_join(t2, NULL); printf(main thread done\n); return 0; }编译命令必须带-pthreadgcc -Wall -g -pthread thread_demo.c -o thread_demo ./thread_demo-Wall打开常见警告-g保留调试信息方便后面用 gdb 看线程状态。运行几次你会发现两个 worker 的 start 顺序并不固定有时 1 在前有时 2 在前。这不是 bug而是线程调度的正常表现——创建顺序不等于执行顺序。pthread_self()返回的是线程库内部的标识和系统层面的 TID 不是一回事想看系统 TID 得用syscall(SYS_gettid)这个后面排查时会用到。2.3 四个参数逐个说清pthread_create 到底要什么pthread_create的原型是int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg)。四个参数各有讲究参数含义常见取值注意点thread输出参数回填线程 IDt1必须传有效地址不能传 NULLattr线程属性NULL 表示默认属性要设分离态、栈大小才需要填start_routine线程入口函数函数名签名必须是void *(*)(void *)arg传给入口函数的参数任意指针不能传局部变量的地址给长生命周期线程最容易翻车的是第四个参数。上面代码里id1是 main 栈上的变量因为 main 一直活到 join 结束所以安全。但如果你在循环里创建线程把循环变量i的地址传进去所有线程可能读到同一个最终值。正确做法是每个线程分配独立的内存或者用intptr_t直接传值。这个坑在实验报告里经常被扣分因为现象是“所有线程打印的编号都一样”看起来像玄学其实是典型的悬空指针问题。2.4 用返回值判断创建是否成功pthread_create成功返回 0失败返回错误码但它不会设置errno所以不能用perror直接打印。正确写法是拿返回值去strerrorint ret pthread_create(t1, NULL, worker, id1); if (ret ! 0) { fprintf(stderr, create failed: %s\n, strerror(ret)); return 1; }失败常见原因有两个一是线程数超过系统限制ulimit -u可以看进程数上限二是栈空间不够默认每个线程栈 8MB创建几百个线程就可能失败。实验里一般不会碰到但如果你写了个循环创建上千线程的测试就要注意这个边界。把返回值检查加上是让代码从“能跑”变成“可靠”的第一步。3. 多线程竞争与同步为什么加了 sleep 结果就“对”了基线版本跑通之后很多人会尝试让多个线程操作同一个变量然后发现结果不对。这一章就围绕共享资源竞争展开先复现问题再讲清楚pthread_join的收尾作用最后给出互斥锁的修法。理解这一章你的实验报告才有深度而不是只贴一段能跑的代码。3.1 复现计数错误一个必现的竞争场景下面这段代码让两个线程各对全局变量加 100 万次理论上结果应该是 200 万#include stdio.h #include pthread.h long counter 0; // 全局共享变量 void *add(void *arg) { for (int i 0; i 1000000; i) { counter; // 非原子操作读-改-写三步 } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(t1, NULL, add, NULL); pthread_create(t2, NULL, add, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(counter %ld\n, counter); return 0; }编译运行你会看到结果几乎总是小于 200 万而且每次都不一样。原因在于counter在机器层面是“读入寄存器、加一、写回内存”三步两个线程可能同时读到同一个旧值各自加一后写回其中一次加法就丢了。这就是竞争条件。如果你在循环里加一句sleep(0)或者打印结果可能反而“接近正确”因为打印拖慢了执行减少了重叠窗口——但这只是掩盖问题不是解决问题。3.2 pthread_join 不只是等待它决定了主线程何时退出很多人把pthread_join当成可有可无的收尾其实它有两个作用一是阻塞主线程直到子线程结束二是回收子线程的资源。如果不 join 也不 detach子线程结束后会留下僵尸状态占用线程表项。更严重的是如果主线程先退出整个进程结束子线程会被强制终止输出可能丢失。你可以把上面代码的 join 注释掉再跑会看到counter打印得很早甚至子线程还没跑完。pthread_join的第二个参数可以接收线程返回值。如果线程入口函数return (void *)resultjoin 时就能拿到。实验里如果要求“线程返回计算结果”就要用这个机制而不是靠全局变量。写法是void *result; pthread_join(t1, result); printf(thread returned %ld\n, (long)result);注意返回值是指针类型不能直接返回栈上变量的地址否则 join 拿到的是悬空指针。3.3 用互斥锁把竞争修掉加锁位置比锁本身更重要修竞争的标准做法是互斥锁。下面给出加锁版本#include stdio.h #include pthread.h long counter 0; pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; // 静态初始化 void *add(void *arg) { for (int i 0; i 1000000; i) { pthread_mutex_lock(lock); // 进入临界区 counter; // 现在这三步被保护 pthread_mutex_unlock(lock); // 离开临界区 } return NULL; }这样结果稳定是 200 万。但要注意锁的粒度直接影响性能。如果把pthread_mutex_lock放在 for 循环外面只加一次锁结果也对但两个线程就完全串行了失去了并发的意义。实验报告里可以对比两种写法的耗时用clock_gettime测一下能看出锁粒度对性能的影响。另一个常见错误是忘记解锁尤其是在有return或break的分支里程序会死锁。建议用pthread_mutex_lock后紧跟unlock的固定结构中间不要有提前返回。3.4 条件变量线程创建实验里最容易被忽略的同步工具有些实验要求“一个线程生产数据另一个线程消费数据”这时候光靠互斥锁不够还需要条件变量来避免忙等待。下面是一个最小生产者消费者模型#include stdio.h #include pthread.h int ready 0; // 共享状态 pthread_mutex_t m PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond PTHREAD_COND_INITIALIZER; void *producer(void *arg) { pthread_mutex_lock(m); ready 1; // 生产数据 pthread_cond_signal(cond); // 通知消费者 pthread_mutex_unlock(m); return NULL; } void *consumer(void *arg) { pthread_mutex_lock(m); while (ready 0) { // 必须用 while 防止虚假唤醒 pthread_cond_wait(cond, m); } printf(consumed\n); pthread_mutex_unlock(m); return NULL; }关键点是pthread_cond_wait必须放在while里而不是if里因为条件变量可能被虚假唤醒。另一个点是pthread_cond_wait会原子地释放锁并阻塞被唤醒后重新加锁所以调用前必须已经持有锁。这两个细节在实验报告里写清楚能体现你对同步机制的理解深度。4. 避坑与排查线程创建实验里最常见的五类翻车这一章集中讲踩坑。下面五条都是我在帮别人看实验代码时反复遇到的每条按“现象、原因、解决”写清楚你对照自己的代码排查。4.1 编译报 undefined reference to pthread_create现象代码看起来没问题gcc 编译时报链接错误提示找不到pthread_create。原因编译命令没加线程库链接参数。解决在 gcc 命令末尾加-pthread注意是编译和链接都要生效所以放在命令最后即可。不要只加-lpthread虽然有时也能过但-pthread会同时定义_REENTRANT宏行为更正确。如果你用 CMake就在target_link_libraries里加pthread。4.2 所有线程打印的编号都一样现象循环创建 5 个线程每个线程打印传入的编号结果全是 5 或者全是某个固定值。原因把循环变量i的地址传给了所有线程线程真正读取时循环已经结束i变成了最终值。解决每个线程单独分配一块内存存编号或者用intptr_t把值直接转成指针传入。示例for (int i 0; i 5; i) { int *p malloc(sizeof(int)); *p i; pthread_create(t[i], NULL, worker, p); } // worker 里用完记得 free(arg)4.3 程序偶尔卡死不动现象运行后没有输出或者输出几行后停住CtrlC 才能结束。原因大概率是死锁。常见情况有两种一是同一个线程对普通互斥锁连续 lock 两次二是两个线程以相反顺序获取两把锁。解决用pthread_mutex_trylock排查或者用 gdb attach 上去看各线程栈。gdb -p 进程号然后thread apply all bt能直接看到卡在哪个 lock 上。预防办法是固定加锁顺序并且绝不在持锁时调用可能再次加锁的函数。4.4 主线程退出导致子线程输出丢失现象子线程明明有 printf但终端只看到主线程的输出。原因主线程没 join 就 return 了进程结束子线程被强制终止。解决所有创建的线程都要 join 或者 detach。如果线程是分离态主线程仍然需要等待足够时间或者用pthread_exit让主线程退出但不结束进程。实验里最稳妥的做法就是逐个 join。4.5 加了锁结果反而更慢现象加互斥锁后结果正确了但耗时比单线程还长。原因锁粒度过大或者临界区里有耗时操作导致线程大部分时间在等锁。解决把不需要保护的代码移出临界区只保护真正共享的变量。比如计数场景可以每个线程先本地累加最后再合并这样锁只加一次。用clock_gettime(CLOCK_MONOTONIC, ...)测一下加锁前后的耗时用数据说话实验报告里这也是个加分项。5. 进阶技巧用 gdb 和 strace 看清线程的真实行为代码能跑、结果正确之后如果你想让实验报告更有说服力或者想真正搞懂线程创建背后发生了什么就需要借助调试工具。这一章讲两个我常用的手段用 gdb 观察线程创建和切换用 strace 看 clone 系统调用。最后给一个线程池的简化思路作为从“创建线程”到“管理线程”的过渡。5.1 用 gdb 查看线程列表和切换过程编译时加-g然后gdb ./thread_demo。在 main 里pthread_create之后打断点运行到断点后执行(gdb) info threads # 列出当前所有线程 (gdb) thread 2 # 切换到 2 号线程 (gdb) bt # 查看该线程调用栈 (gdb) thread apply all bt # 查看所有线程栈排查死锁很有用info threads输出里带*的是当前线程每行前面的编号是 gdb 分配的不是系统 TID。你可以在 worker 里pthread_self()打印的值和 gdb 编号对照理解两者区别。如果程序卡死thread apply all bt能一眼看出哪个线程持锁未放、哪个线程在等锁比盲目加 printf 高效得多。5.2 用 strace 看 pthread_create 背后的 clone 调用线程创建在 Linux 上最终走的是clone系统调用。用 strace 跟踪一下strace -f -e traceclone ./thread_demo 21 | head -20-f表示跟踪子进程和线程-e traceclone只过滤 clone 调用。你会看到每次pthread_create对应一次clone后面带一堆标志位比如CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_THREAD。这些标志正是线程和进程的本质区别线程共享地址空间和文件描述符所以能直接读写同一个全局变量。看懂这一层实验报告里“线程与进程区别”那部分就不用抄书了直接写你观察到的系统调用。5.3 从创建线程到线程池一个简化版的思路实验只要求创建线程但实际工程里频繁创建销毁线程开销很大常见做法是线程池。简化思路是主线程先创建固定数量的 worker 线程它们都阻塞在一个任务队列上有新任务就放进队列并唤醒一个 worker。核心结构是互斥锁加条件变量加队列。下面是一个极简骨架typedef struct { void (*func)(void *); void *arg; } task_t; task_t queue[256]; int head 0, tail 0, count 0; pthread_mutex_t qlock PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_empty PTHREAD_COND_INITIALIZER; void *worker(void *arg) { while (1) { pthread_mutex_lock(qlock); while (count 0) { pthread_cond_wait(not_empty, qlock); // 队列空则等待 } task_t t queue[head]; head (head 1) % 256; count--; pthread_mutex_unlock(qlock); t.func(t.arg); // 在锁外执行任务 } return NULL; }这个骨架省略了退出机制和边界检查但足以说明线程池的核心线程只创建一次任务通过队列分发。如果你在实验里加一个这样的扩展报告深度会明显不一样。不过要注意任务执行必须放在解锁之后否则一个慢任务会阻塞整个队列。5.4 一个我常犯的错误忽略返回值检查最后说一个我自己的血泪教训。早期写多线程代码时我总觉得pthread_create不会失败所以从来不检查返回值。直到有一次在资源受限的环境里循环创建线程程序莫名其妙少跑了很多任务排查半天才发现是创建失败但被忽略了。从那以后我养成了一个习惯凡是返回 int 的 pthread 函数一律检查返回值并用strerror打印。这个习惯让我在后来的项目里提前发现过好几次资源泄漏。线程创建实验虽然简单但把返回值检查、join 收尾、锁的边界这三件事做扎实后面做更复杂的并发程序会少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑