Linux笔记---线程
1. 线程的介绍
1.1 线程的概念
基本定义: 线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程(Process)之中(或者说是进程的一部分、对进程的划分),是进程中的实际运作单位。
- 把进程想象成一个工厂车间。这个车间有自己的厂房(地址空间)、原材料仓库(内存数据)、生产设备(CPU时间)、规章制度(权限)等资源。
- 把线程想象成这个工厂车间里的工人。一个车间(进程)可以有一个或多个工人(线程)。这些工人共享车间的所有资源(厂房、仓库、设备、制度),但他们各自独立地执行不同的具体任务(如搬运、组装、质检)。
核心特征:
属于进程: 一个线程必须存在于某个进程中。进程是资源分配的基本单位,线程是CPU调度的基本单位。
共享资源: 同一个进程内的所有线程共享该进程所拥有的全部资源,包括:
- 地址空间(内存): 代码段、数据段、堆、全局变量、静态变量、打开的文件描述符等。
- 环境: 当前工作目录、用户ID、组ID、进程权限等。
私有资源: 每个线程也有自己独有的资源,用于维持其独立执行的能力:
- 线程ID: 唯一标识。
- 寄存器组: 程序计数器(PC)、栈指针(SP)等,用于保存执行现场。
- 栈: 用于存储局部变量、函数调用参数和返回地址。每个线程有自己的栈空间。
- 错误码: errno(某些系统下)。
- 信号掩码:
- 优先级: (如果操作系统支持线程优先级调度)。
执行: 线程是程序执行流的最小单元。一个标准的单线程程序只有一个执行流(主线程)。多线程程序则在一个进程内有多个独立的执行流并发或并行地运行。
1.2 为什么要引入线程
引入线程主要是为了解决传统进程模型在并发编程和资源利用效率上的一些局限性:
更轻量级的并发:
- 创建/销毁开销小: 创建或终止一个线程比创建一个新进程快得多,因为线程共享其所属进程的资源(如地址空间、文件描述符),操作系统需要分配和初始化的资源(主要是栈和寄存器)远少于创建一个全新的进程。
- 切换开销小: 在同一进程内切换线程(上下文切换)比在不同进程间切换(进程上下文切换)开销小得多。因为线程共享地址空间和大部分资源,切换时只需保存和恢复少量私有寄存器状态(PC, SP, 寄存器等),无需切换内存映射表(如页表)、刷新TLB等耗时操作。这使得线程间切换非常高效。
- 结论: 线程是实现并发编程的一种更轻量、更高效的方式,特别适合需要频繁创建和销毁执行单元或需要快速响应的场景(如Web服务器处理大量并发请求)。
提高资源利用率与系统吞吐量:
- 充分利用多核CPU: 现代计算机普遍拥有多核CPU。多线程允许一个进程内的多个线程真正并行地在不同CPU核心上同时运行,极大地提高了计算密集型任务的执行速度和CPU利用率。单进程单线程只能利用一个核心。
- 重叠I/O与计算: 当一个线程因等待I/O操作(如读写磁盘、网络通信)而阻塞时,同一个进程内的其他线程可以继续运行,利用CPU进行计算。这避免了整个进程被阻塞,提高了CPU和I/O设备的整体利用率和系统吞吐量。例如,一个GUI程序可以用一个线程处理用户界面交互,另一个线程执行后台计算,保证界面不卡顿。
简化通信与数据共享:
- 天然共享内存: 由于同一进程内的线程共享地址空间(堆、全局变量等),它们之间的通信变得极其简单和快速,直接通过读写共享内存即可实现,无需像进程间通信(IPC)那样使用管道、消息队列、共享内存(需显式设置)、套接字等复杂且相对较慢的机制。
- 简化程序设计: 对于需要紧密协作、频繁交换大量数据的任务,使用多线程比使用多进程+IPC在程序设计上通常更直观、代码更简洁(尽管引入了同步的复杂性)。
提高响应性:
- 在交互式应用中(如GUI、游戏、服务器),将耗时操作(如网络请求、复杂计算)放在单独的线程中执行,可以保证主线程(如UI线程)保持响应,及时处理用户输入或更新界面,避免程序“假死”,提升用户体验。
总结引入线程的原因: 为了在保持进程提供的资源隔离和安全性的同时,提供一种更轻量、高效、易于共享数据的并发执行机制,从而更好地利用多核处理器、提高系统资源利用率(CPU、I/O)、提升程序响应速度和系统吞吐量。
1.3 线程vs进程
- 资源 vs 执行: 进程是资源分配的容器;线程是执行调度的实体。
- 隔离 vs 共享: 进程间高度隔离;线程间高度共享(同一进程内)。
- 开销: 进程创建/销毁/切换开销大;线程开销小。
- 通信: 进程间通信复杂且慢(IPC);线程间通信简单且快(共享内存),但必须同步。
- 崩溃影响: 进程崩溃不影响其他进程;线程崩溃很可能导致整个进程崩溃。
- 并行利用: 多线程能充分利用单机多核进行并行计算(线程数量不宜超过处理器数量)。
特性 | 进程 (Process) | 线程 (Thread) |
基本定义 | 资源分配的基本单位。拥有独立地址空间和系统资源。 | 共享其所属进程的所有资源(地址空间、文件描述符、环境等)。拥有私有栈、寄存器、线程ID、错误状态、信号掩码、优先级。 |
资源所有权 | 拥有独立的地址空间、文件描述符表、信号处理、环境变量等系统资源。 | 共享其所属进程的所有资源(地址空间、文件描述符、环境等)。拥有私有栈、寄存器、线程ID、错误状态、信号掩码、优先级。 |
创建/销毁开销 | 大。需要分配和初始化独立的地址空间、页表、资源表等。 | 小。主要分配栈和少量寄存器状态,共享进程资源。 |
切换开销 | 大 (进程上下文切换)。需要切换地址空间(页表、TLB刷新)、CPU状态、内核栈等。 | 小 (线程上下文切换)。只需切换私有寄存器、栈指针等,共享地址空间不变。 |
通信方式 | 复杂且慢。必须使用操作系统提供的IPC机制:管道、消息队列、共享内存(需显式管理)、信号量、套接字等。 | 简单且快。天然共享进程内存(全局变量、堆)。直接读写即可通信。但需同步机制(互斥锁、信号量等)保护共享数据。 |
健壮性 | 高。一个进程崩溃(如段错误)通常不会直接影响其他进程(地址空间隔离)。 | 低。一个线程崩溃(如非法内存访问)可能导致整个进程终止,因为共享地址空间,破坏共享数据会波及其他线程。 |
独立性/隔离性 | 高。进程间有严格的地址空间隔离和资源保护。 | 低。线程间共享大部分资源,隔离性弱。 |
并发性 | 进程间可以并发或并行执行(多进程编程)。 | 线程间可以并发或并行执行(多线程编程)。使得进程内部出现多个可并发执行的执行流。 |
系统资源占用 | 多。每个进程都有独立的内存映像、内核数据结构(PCB)等。 | 少。多个线程共享一个进程的资源,额外开销主要是私有栈和线程控制块(TCB)。 |
适用场景 | 需要高隔离性、高安全性的任务(如不同用户的程序、关键服务)。需要利用多机并行(分布式)。 | 需要高效并发、紧密协作、频繁数据共享的任务(如计算密集型并行、I/O密集型服务器、GUI响应)。需要充分利用单机多核。 |
1.4 线程的实现方式
内核支持线程(Kernel-Level Threads, KLT)和用户级线程(User-Level Threads, ULT)是两种不同的线程实现和管理方式。
1.4.1 内核支持线程vs用户级线程
特性 | 内核支持线程 (KLT) | 用户级线程 (ULT) |
管理主体 | 操作系统内核 (OS Kernel) | 用户空间线程库 (Runtime Library / Thread Library) |
内核感知 | 内核知道每个KLT的存在,并直接调度它们。 | 内核不知道ULT的存在。它只看到并调度承载ULT的进程(单个KLT)。 |
实现位置 | 在内核空间实现,需要内核支持(系统调用)。 | 在用户空间实现,完全由用户级库管理(如POSIX的pthreads库 通常实现为KLT,但像早期的Java“绿色线程”、GNU Pth是ULT)。 |
线程控制块 (TCB) | 存放在内核空间 (Kernel Space)。 | 存放在用户空间 (User Space)。 |
创建/销毁/切换 | 需要陷入内核态(系统调用),开销相对较大。 | 完全在用户态进行(库函数调用),开销极小(类似函数调用)。 |
调度 | 内核负责调度。内核调度器决定哪个KLT获得CPU时间。 | 用户级线程库负责调度。库的调度器决定进程内哪个ULT获得CPU时间。内核只调度该进程对应的那个KLT。 |
阻塞影响 | 一个KLT阻塞(如I/O)时,内核可以调度同一进程内的另一个KLT或不同进程的KLT运行。不会阻塞整个进程。 | 一个ULT阻塞(如I/O)时,由于内核只看到进程阻塞,会阻塞整个进程(即承载它的那个KLT),导致该进程内的所有ULT都无法执行。 |
利用多处理器 (SMP) | 良好支持。内核可以将同一进程的不同KLT分配到不同的CPU核心上真正并行执行。 | 不支持(纯ULT模型)。因为内核只把一个进程(对应一个KLT)调度到一个核心上,该进程内的所有ULT只能在该核心上并发(交替执行),无法并行。 |
性能 | 创建/销毁/切换开销较大,但能更好地利用多核和避免I/O阻塞导致的进程停顿。 | 创建/销毁/切换开销极小,但无法利用多核,且一个ULT阻塞会导致整个进程(所有ULT)阻塞。 |
健壮性 | 相对较高。内核管理,一个线程崩溃通常不影响整个系统。 | 依赖于线程库实现。线程库崩溃可能导致整个进程崩溃。 |
灵活性 | 由内核策略决定,用户控制相对较弱。 | 用户级线程库可以定制自己的调度策略(如优先级、轮转),灵活性高。 |
例子 | 现代操作系统的主流线程实现: - Windows 线程 - Linux POSIX线程 (pthreads) - macOS POSIX线程 (pthreads) | 历史上的/特定语言的实现: - GNU Portable Threads (Pth) - 早期Java的“绿色线程” (Green Threads) - Python的greenlet(需配合事件循环) - 一些协程库(Coroutine Libraries) |
1.4.1 混合模型:两级模型 (Many-to-Many)
为了结合KLT和ULT的优点,克服各自的缺点,现代操作系统和线程库普遍采用混合模型(也称为两级模型,Many-to-Many Model)。
原理:
- 用户程序创建和管理的是用户级线程 (ULT)。
- 用户级线程库将这些ULT映射(绑定)到一组内核支持线程 (KLT) 上执行。
- 内核只看到并调度这些KLT。
- ULT库负责将ULT调度到可用的KLT上运行。
优点:
- 开销可控: ULT的创建/销毁/切换开销小(用户态)。
- 避免阻塞: 如果一个ULT在KLT上阻塞(如I/O),内核只会阻塞那个KLT。库可以将该ULT从阻塞的KLT上解除绑定,并把其他就绪的ULT调度到进程内其他未阻塞的KLT上继续执行。整个进程不会被阻塞。
- 利用多核: 库可以将ULT映射到多个KLT上,内核可以将这些KLT调度到不同的CPU核心上并行执行。
- 灵活性: ULT库可以有自己的调度策略。
2. Linux当中的线程
在Linux当中,我们可以使用pthread库来进行多线程编程。
pthread库是 POSIX 线程标准的实现,在Linux上,最主要的实现是 NPTL (Native POSIX Threads Library),是 glibc (GNU C Library) 核心且不可或缺的一部分。
pthread库提供的是内核支持线程还是用户级线程呢?
实际上,在Linux当中,pthread库采用的是混合模型来实现线程,在用户空间上提供线程的概念,而在内核层面上将用户线程1 : 1绑定到轻量级进程(线程)。
在很多操作系统的教科书当中都会说明,线程又叫做轻量级进程,从其概念上就可以很好地理解这样命名的原因。
但是在Linux当中,这样的命名可不只是因为形象,而是因为Linux内核当中轻量级进程确实是一种特殊的进程。
实际上,在Linux内核当中,只有一种运行实体,那就是task_struct(也就是说Linux中只有PCB而不存在TCB)。轻量级进程就是与主进程共享全局资源的进程。
2.1 轻量级进程(LWP)
2.1.1 概念
轻量级进程本质上是 Linux 内核调度和管理的线程(Thread)。 它是 Linux 内核线程实现的具体表现形式。
- 核心特性:
- 共享资源: 多个 LWP(属于同一个进程)共享其父进程的:虚拟地址空间、文件描述符表、信号处理程序、环境变量、用户/组 ID (UID/GID)。
- 共享资源: 多个 LWP(属于同一个进程)共享其父进程的:虚拟地址空间、文件描述符表、信号处理程序、环境变量、用户/组 ID (UID/GID)。
- 独立的执行状态: 每个 LWP 拥有自己独立的:
- 线程 ID (TID) - 在同一个进程内唯一,在系统内也唯一(Linux 中线程和进程使用相同的 ID 命名空间,TID 其实就是内核视角的 PID)。
- 程序计数器 (PC)
- 寄存器集 (Registers)
- 栈 (Stack) - 每个线程有自己的栈空间(在进程的地址空间内分配)。
- 信号掩码 (Signal Mask)
- 线程特定数据 (Thread-Local Storage - TLS)
- 调度优先级和策略(部分可独立设置)
- “轻量级”的含义:
- 创建开销小: 创建一个新的 LWP(通过 clone() 系统调用,并指定共享资源的标志,如 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND)不需要复制父进程的地址空间等主要资源,只需设置独立的执行栈和上下文,因此比创建完整进程快得多。
- 切换开销小: 同一个进程内的 LWP 切换,因为共享地址空间(TLB 缓存无需完全刷新)、文件表等,上下文切换通常比进程间切换快。
- 通信开销小: 共享地址空间的 LWP 之间通信极其高效,可以直接读写共享内存区域(全局变量、堆内存),仅需注意同步问题(互斥锁、信号量等)。
- 内核可见: LWP 是内核直接知晓并调度的实体。内核调度器看到的是一个个 LWP,并根据它们的调度策略和优先级进行调度。
- 调度单位: LWP 是 Linux 内核进行 CPU 调度的基本单位。 当我们说“内核调度进程”时,实际上更精确地说,内核是在调度该进程内的一个或多个 LWP。
2.1.2 关键点:LWP 就是 Linux 的线程
- 在 Linux 中,当你在用户空间使用 POSIX 线程库 (pthreads) 创建线程时 (pthread_create),底层正是通过 clone() 系统调用创建了一个新的 LWP。
- 一个传统的“单线程进程”实际上就是由一个 LWP 构成的进程。
- 一个“多线程进程”则是由多个共享资源的 LWP 组成的。
- ps -aL 命令可以查看系统当中的线程。
$ ps -aL | head -1 && ps -aL | grep mythread PID LWP TTY TIME CMD 2711838 2711838 pts/235 00:00:00 mythread 2711838 2711839 pts/235 00:00:00 mythread -L 选项:打印线程信息
2.1.3 历史背景与术语演变
- LinuxThreads: Linux 早期使用一个名为 LinuxThreads 的线程库。在这个模型中,每个用户线程对应一个内核 LWP。然而,它存在一些问题,比如信号处理不完美、管理线程效率低、不符合 POSIX 标准(例如,所有线程有不同 PID)等。此时,“LWP”这个术语被广泛使用来指代内核线程。
- NPTL (Native POSIX Thread Library): 为了克服 LinuxThreads 的缺点,引入了 NPTL(集成在 glibc 中)。NPTL 极大地提高了 Linux 线程的性能和 POSIX 兼容性。在 NPTL 中:
- 用户线程仍然通过 LWP(内核线程)实现。
- 但引入了更高效的同步原语(Futex)。
- 实现了线程组的概念,同一个进程的所有线程共享一个 PID(getpid() 返回相同值),通过 TID 区分线程(gettid())。
- 信号处理更符合 POSIX 标准。
- 现代术语: 在现代 Linux 文档和讨论中:
- “线程” (Thread) 是最常用和最标准的术语,指代用户空间的可调度执行流。
- “内核线程” (Kernel Thread) 指在内核空间运行、没有用户空间上下文的后台任务(如 kworker, ksoftirqd)。
- “轻量级进程 (LWP)” 这个术语逐渐淡化,主要用于描述用户线程在内核中的具体实现实体。在讨论内核调度细节或查看 /proc 文件系统(如 /proc/[pid]/task/[tid]/)时可能还会遇到。ps/top 等工具也常用 LWP 列名显示线程 ID (TID)。