欢迎来到操作系统的核心主题——线程。在现代计算中,无论是流畅的用户界面、高性能的服务器,还是复杂的科学计算,线程都扮演着至关重要的角色。本讲义将从最基本的概念出发,层层递进,深入探讨线程的模型、动机、实现方式以及在多核时代面临的挑战与机遇。让我们一起揭开线程的神秘面纱,掌握并发与并行的强大力量。
第一章:进程与线程的基本概念 🌱
在深入线程之前,我们首先需要理解它所处的环境——进程。
1.1 实际观察:认识运行中的程序 【了解】
在学习理论之前,我们可以通过一些简单的命令来直观感受操作系统中正在运行的实体。在 Linux 系统中,常用的进程观察命令包括:
ps
: 显示当前时刻的进程快照。pstree
: 以树状图的形式显示进程之间的父子关系。top
: 实时动态地显示系统中的进程活动情况。
1.2 进程与线程的定义与类比 【核心考点】
【核心定义】
- 进程(Process):是操作系统进行资源分配的最小单位。它包含了程序运行所需的代码、数据、文件句柄等一系列资源。
- 线程(Thread):是操作系统进行CPU调度的最小单位。它也被称为轻量级进程,是进程中的一个执行流。
为了更好地理解这两者的关系,我们可以做一个类比:
- 家庭与成员的类比:一个进程好比一个家庭,这个家庭拥有整套的资源,比如房子(地址空间)、汽车、存款等。而线程则像是家庭中的每个成员(配偶、孩子)。所有成员都住在同一栋房子里,共享家庭的公共资源。但是,每个成员又可以独立地去做自己的事情(执行不同的任务),比如一个成员在做饭,另一个在看电视。
第二章:为何需要线程?动机与优势 ✨
2.1 多线程的动机 【重点】
为什么我们需要在进程的基础上,进一步引入线程的概念?
- 现代应用的需求:今天我们使用的大多数应用程序,如办公软件(WPS, MS Office)、浏览器、媒体播放器等,内部都包含了多个并发执行的任务。例如,一个文档编辑器可能同时需要:
- 更新用户界面显示
- 接收用户的键盘鼠标输入
- 进行后台的拼写检查
- 定时自动备份文档 将这些任务分配给独立的线程,可以使程序结构更清晰,响应更及时。
- 创建开销的考量:创建进程是一个“重量级”的操作,需要分配独立的内存空间和大量的内核数据结构,开销很大。相比之下,创建线程是一个“轻量级”的操作,因为它共享了大部分父进程的资源,速度快得多。
- 代码简化与效率提升:将复杂的任务分解为多个协同工作的线程,可以简化编程模型,并提高程序的执行效率。
- 内核设计:现代操作系统的内核本身通常也是多线程的,以便高效地处理各种系统调用和硬件中断。
2.2 多线程服务器架构 【重点】
多线程的一个经典应用场景是网络服务器。一个典型的多线程服务器工作流程**(如图 2.1 所示)**如下:
- 请求监听:主线程在一个循环中持续监听来自客户端的连接请求。
- 创建服务线程:一旦接收到一个新的请求,服务器不再亲自处理,而是创建一个新的线程。
- 并发处理:新创建的线程专门负责处理这个客户端的请求,例如查询数据库、读取文件等。
- 继续监听:与此同时,主线程并不需要等待请求处理完成,而是立即返回去继续监听更多的客户端请求。
这种架构极大地提高了服务器的吞吐量和响应能力。 ![[Pic/Pasted image 20251014215012.png]] [图 2.1:多线程服务器架构示意图]
2.3 线程的四大优点 【重点】
总结起来,使用线程主要有以下四个方面的优势:
- 响应性 (Responsiveness):在一个多线程应用中,即使一个线程因为执行耗时操作(如 I/O)而被阻塞,其他线程仍然可以继续执行。这对于需要与用户交互的图形界面(GUI)程序尤为重要,可以避免界面“卡死”。
- 资源共享 (Resource Sharing):同一进程内的所有线程共享该进程的内存空间和文件等资源。这种共享是自动的,比进程间通信(如共享内存、消息传递)更简单、高效。
- 经济性 (Economy):如前所述,创建和切换线程的开销远低于创建和切换进程。
- 可扩展性 (Scalability):在多核处理器架构下,多线程程序可以真正地实现并行执行,充分利用多处理器的计算能力,这是单线程程序无法做到的。
第三章:线程模型与状态 ⚙️
3.1 单线程与多线程进程模型对比 【核心考点】
一个进程内部的结构会因其是单线程还是多线程而有显著不同**(如图 3.1 所示)**。
【知识回顾】 进程的内存空间通常被划分为代码段、数据段、堆和栈等区域。
- 单线程进程:只有一个执行流,因此只有一套寄存器和唯一的栈。
- 多线程进程:包含多个执行流。
- 共享资源:所有线程共享进程的代码段、数据段、打开的文件和堆。这意味着一个线程对全局变量的修改,对其他所有线程都是可见的。
- 私有资源:每个线程都拥有自己独立的一套寄存器和独立的栈。栈用于存储函数的局部变量和返回地址,这保证了线程执行的独立性。
![[Pic/Pasted image 20251014215226.png]] [图 3.1:单线程进程与多线程进程的内部结构对比]
3.2 虚拟地址空间中的线程 【重点】
在一个进程的虚拟地址空间中**(如图 3.2 所示)** ,代码段和堆是所有线程共享的。堆从低地址向高地址增长,用于动态内存分配(如 malloc
)。而每个线程都有自己的栈,栈空间通常从高地址向低地址增长,用于存放局部变量、函数参数等。多个线程的栈散布在进程地址空间的空闲区域。 ![[Pic/Pasted image 20251014215317.png]] [图 3.2:虚拟地址空间中多线程的内存布局]
3.3 线程调度状态 【重点】
线程调度器(Scheduler)负责在物理 CPU 上复用(切换)线程。调度器根据线程的状态来决定下一个应该运行哪个线程。线程的主要状态包括:
- 运行中 (Running):线程当前正在 CPU 上执行。
- 阻塞 (Blocked):线程正在等待某个事件发生(如等待 I/O 完成、等待锁),暂时无法继续执行。
- 就绪 (Ready):线程已经准备好运行,万事俱备,只等待调度器分配 CPU。
- 已退出 (Exited):线程已经执行完毕,但其资源可能尚未被完全回收。
3.4 核心线程调度原语 【了解】
操作系统提供了一些基本的原语(Primitives)来控制线程的调度和状态转换:
yield()
: 当前线程主动放弃 CPU,从运行状态变为就绪状态,让调度器选择其他就绪线程运行。sleep()
: 当前线程因故需要暂停执行并等待某个事件,从运行状态变为阻塞状态(例如,试图向一个已满的缓冲区写入数据)。wakeup()
: 由另一个线程或中断处理程序调用,用于唤醒某个处于阻塞状态的线程,使其变为就绪状态(例如,缓冲区有了可用空间)。
第四章:并发与并行编程 🚀
4.1 并发 vs. 并行 【核心考点】
这两个术语经常被混用,但它们有本质的区别 (如图 4.1 所示)。
【核心辨析】
- 并发 (Concurrency):指逻辑上同时处理多个任务的能力。在一个单核系统上,操作系统通过快速地在多个线程之间切换,使得这些线程在宏观上看起来像是在同时运行,但这是一种逻辑上的同时性。
- 并行 (Parallelism):指物理上同时执行多个任务的能力。这必须在多核或多处理器系统上才能实现,即多个线程在同一时刻真正在不同的 CPU 核心上运行,这是物理上的同时性。
简单来说:并发是“看起来同时”,并行是“真的同时”。 ![[Pic/Pasted image 20251014215450.png]] [图 4.1:单核并发执行与多核并行执行的对比]
4.2 多核编程的挑战 【重点】
多核处理器为并行计算提供了硬件基础,但也给程序员带来了新的挑战:
- 活动划分 (Dividing activities):如何将一个大的任务有效地分解成可以并行执行的多个小任务。
- 平衡 (Balance):确保每个并行任务的工作量大致相等,避免出现某些核心忙碌而其他核心空闲的“木桶效应”。
- 数据分割 (Data splitting):合理地划分数据,使其能被不同的核心并行处理。
- 数据依赖 (Data dependency): 必须小心处理不同任务之间的数据依赖关系,确保执行顺序的正确性。
- 测试和调试 (Testing and debugging):并行程序中的错误(如竞态条件)通常是偶发的、难以复现的,这给测试和调试带来了巨大的困难。
4.3 并行类型 【重点】
实现并行计算通常有两种主要策略:
- 数据并行 (Data Parallelism):将同一份操作应用到大规模数据集的不同子集上。例如,将一个大数组分成四份,在四个核心上同时对每个元素执行加法操作。
- 任务并行 (Task Parallelism):将不同的、独立的操作分配给不同的核心去执行。例如,一个线程负责图像渲染,另一个线程负责物理计算。
在实际应用中,这两种策略往往是混合使用的。
第五章:线程的实现与管理 🛠️
5.1 线程库 API 【了解】
为了让程序员能够创建和管理线程,操作系统或第三方库提供了线程库(Thread Library),它本质上是一套应用程序接口(API)。线程库的实现主要有两种方式:
- 用户级线程库:线程的管理完全在用户空间进行,内核对此一无所知。优点是切换速度快,缺点是一个线程阻塞会导致整个进程阻塞。
- 内核级线程库:线程的管理由操作系统内核直接支持。这是目前主流的方式,如 Windows 和 Linux 的线程模型。
5.2 Pthreads (POSIX 线程) 【重点】
- Pthreads 是 POSIX (Portable Operating System Interface) 标准中关于线程的一部分,定义了一套用于创建和同步线程的 C 语言 API 标准(IEEE 1003.1c)。
- 它是一套规范,而非具体实现。这意味着不同的 UNIX-like 系统(如 Solaris, Linux, macOS)都提供了遵循这套规范的线程库。
- 核心函数示例:
pthread_create()
: 用于创建一个新的线程。pthread_join()
: 用于等待一个指定的线程执行结束。pthread_exit()
: 用于线程自我终止。pthread_attr_init()
: 初始化线程的属性。
5.3 隐式线程:让专业工具做专业事 【重点】
随着线程数量的增加,由程序员手动管理所有线程变得越来越复杂且容易出错。因此,**隐式线程(Implicit Threading)**技术应运而生。其核心思想是,将线程的创建、管理和调度等底层工作转移给编译器和运行时库来完成,程序员只需关注于识别出程序中可以并行的部分。
常见的隐式线程技术包括:
线程池 (Thread Pool):
- 思想:在程序启动时,预先创建一定数量的线程,并将它们放入一个“池”中。当有任务需要执行时,从池中取出一个空闲线程来执行任务;任务完成后,线程并不销毁,而是返回池中等待下一个任务。
- 优点:
- 避免了频繁创建和销毁线程带来的开销。
- 可以方便地控制并发线程的数量,防止资源耗尽。
- 将任务提交与任务执行解耦,使任务调度策略更加灵活。
OpenMP:
- 一个面向共享内存并行编程的 API,支持 C、C++ 和 Fortran。
- 通过在代码中加入简单的编译器指令(
#pragma
),程序员可以轻松地标识出可以并行的代码块(通常是循环),编译器会自动将其转换为并行的多线程代码。 - 例如:
#pragma omp parallel for
可以自动将一个for
循环并行化。
大中央调度 (Grand Central Dispatch, GCD):【了解】
- 苹果公司为其 macOS 和 iOS 操作系统开发的技术。
- 它将任务封装在“块”(Block)中,并提交到适当的调度队列(Dispatch Queue)。系统会自动管理一个线程池,从队列中取出块并分配给可用的线程去执行。
第六章:多线程编程中的关键问题 ⚠️
6.1 fork()
和 exec()
系统调用的语义 【核心考点】
在多线程环境下,传统的 fork()
系统调用(用于创建子进程)带来了语义上的歧义:
- 问题:当一个多线程进程中的某个线程调用
fork()
时,新的子进程应该复制父进程的所有线程,还是只复制那个调用fork()
的线程? - 解决方案:一些 UNIX 系统提供了两个版本的
fork()
。通常的约定是,fork()
只复制调用者线程,因为子进程往往会立即调用exec()
。 exec()
的行为:exec()
系统调用(用于加载一个新程序)的行为是明确的。它会用新程序完全替换当前进程的整个内存空间,因此该进程(包括其所有线程)的旧内容都会被销毁。
6.2 信号处理 (Signal Handling) 【重点】
信号是 UNIX 系统中用于通知进程发生某个异步事件的机制。在多线程环境中,信号的传递变得复杂:
- 问题:当一个信号被发送给一个进程时,应该由哪个线程来处理它?
- 传递选项:
- 传递给信号适用的特定线程。
- 传递给进程中的每一个线程。
- 传递给进程中的某一个指定线程。
- 指定一个“信号处理线程”,专门负责接收该进程的所有信号。
- 实现:通常,可以使用
kill()
发送信号给进程,或使用pthread_kill()
发送信号给进程内的特定线程。
6.3 线程取消 (Thread Cancellation) 【重点】
指的是在一个线程完成其任务之前,主动将其终止。
- 目标线程 (Target Thread):被取消的线程。
- 两种模式:
- 异步取消 (Asynchronous Cancellation):立即终止目标线程。这种方式很危险,因为目标线程可能正处于关键操作(如更新共享数据、持有锁)的中间状态,强行终止可能导致资源不被释放或数据不一致。
- 延迟取消 (Deferred Cancellation):这是默认和推荐的方式。取消请求只是被标记为待定(pending)。目标线程会周期性地检查自己是否应该被取消,只有在到达一个安全的“取消点”(cancellation point,例如
pthread_testcancel()
)时,才会执行清理操作并终止自己。
6.4 线程本地存储 (Thread-Local Storage, TLS) 【重点】
- 动机:有时我们希望每个线程都拥有自己独立的、类似全局变量的数据副本,而这个变量在线程的整个生命周期内都存在。
- 定义:TLS 允许每个线程拥有自己私有的静态数据副本。虽然变量名相同,但每个线程访问和修改的都是自己的那一份,互不干扰。
- 应用场景:当使用线程池等无法控制线程创建过程的场景时,或者当多个线程需要调用一个使用了全局变量但又不是线程安全的库函数时,TLS 非常有用。
第七章:案例研究:Linux 中的线程实现 🐧
7.1 Linux 的线程哲学 【了解】
- 在 Linux 内核中,并没有严格区分进程和线程。它们都被统一视为“任务(Task)”。
- 线程的创建是通过一个特殊的系统调用
clone()
来完成的。 clone()
调用非常灵活,它允许新创建的子任务与父任务有选择地共享资源,如地址空间、文件描述符表等。- 如果子任务与父任务不共享地址空间,那么它就是一个传统的进程。
- 如果子任务与父任务共享地址空间,那么它就是一个线程。
7.2 在 Linux 中观察线程 【了解】
除了之前提到的进程观察命令,还可以使用特定参数来观察线程:
ps -ef | grep [进程名]
: 查看特定程序的主进程。top -H -p [pid]
: 实时显示指定进程(pid)下的所有线程信息。-H
选项是关键。ps -mp [pid]
: 列出指定进程(pid)的所有线程。