线程的概念与实现
线程的概念与实现
复习定位
进程中可以同时有多个执行流——每个执行流是一个线程。同一进程的线程共享地址空间——所以它们可以直接通过全局变量通信——比进程间通信(消息队列pipe共享内存)快得多——但也意味着一个线程的越界写入全局数组可能破坏另一个线程正在访问的数据。线程是CPU调度的基本单位——调度器在线程间切换(而非在进程间切换)。
线程与进程的差别
进程是资源分配的基本单位。线程是CPU调度的基本单位。一个进程至少包含一个线程(主线程)。多线程的最大好处是在多核CPU上让多个线程在不同的核心上真正并行执行——每个核运行一个线程来提升吞吐。
同一进程的多个线程共享以下资源:地址空间(页表、mm_struct)、文件描述符表、信号处理函数表、当前工作目录和文件系统信息。每个线程独立拥有:线程ID(TID)、一组寄存器上下文(每个线程有自己的栈存储局部变量和函数调用链)、errno变量、信号掩码(signal mask)、调度策略和优先级。栈是线程私有——所以局部变量在线程间不冲突——全局变量和堆上分配的所有数据线程间共享。
内核级线程、用户级线程与混合模型
用户级线程(Thread Library级别的多线程):线程的管理完全在用户空间完成——内核不知道线程的存在。用户级线程切换只需保存/恢复少量寄存器——不经过内核(没有系统调用)——速度极快(纳秒级切换)。用户级线程的致命问题:一个线程在内核中阻塞(如read调用导致进程跨入内核态睡眠)——整个进程的所有线程全被阻塞——因为内核只认识进程、不知道进程内部还有其他的执行流。另一个问题:无法利用多核——因为内核将进程调度到单个CPU——而不是分散到多核。
内核级线程(Kernel Thread):线程的创建、调度和切换全部由操作系统内核管理(Windows、Linux的NPTL)。每个线程在task_struct中有独立的条目——调度器可以对它们单独调度到不同CPU核。一个线程在内核中阻塞不影响其他线程的调度。但线程切换成本更高——必须通过内核完成(系统调用或时钟中断触发上下文切换)。
混合模型:用户级线程库将多个用户线程映射到少数几个内核级线程(Kernel Thread)上。用户线程间切换不经过内核。内核仅看到少数内核线程——与用户态线程数的扩大不相关。这一模型的实现复杂度很高(每个用户线程栈、IO队列同步、系统调用线程阻塞的问题仍需解决)——典型应用较少。
Linux的线程——通过clone实现
Linux不区分进程和线程的统一创建方式——父子通过clone()系统调用共享资源的不同级别来判断:
fork() = clone(SIGCHLD, 0)
vfork() = clone(CLONE_VFORK|CLONE_VM|SIGCHLD, 0) // 共享内存,父等子exec
创建线程 = clone(CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD, ...)CLONE_VM设置后——子进程/线程与父进程共享地址空间——这是区分"进程"(独立地址空间)和"线程"(共享地址空间)的核心标记。CLONE_THREAD——使新线程加入同一线程组——getpid返回同一个tgid——每个线程独立的TID用gettid获得。Linux内核通过NPTL(Native POSIX Thread Library, glibc 2.3.2+)实现的pthread_create——底层就是用clone设置以上标记来创建线程。
多线程的同步挑战
线程切换可能发生在任意时刻——例如两个线程想给一个共享全局整型变量做自增操作 10000次——各自代码是counter++。这个操作的底层是三步(Load到寄存器→加一→Store回内存)——Thread A读到counter=100后切换到Thread B——B也读到和A同一个100并加一为101——切回A写回101——本应增加两次得102——实际只加了1。这一结果重复10000次——最终计数器远小于20000。这就是最基本的竞态条件(Race Condition)问题——需要同步机制(mutex、原子操作)来解决。
复习检查
进程和线程在"资源分配"和"CPU调度"中的角色各是什么——为什么说线程是调度的基本单位?
用户级线程在内核中阻塞后——整个进程的所有线程为什么都被阻塞?给出具体的read系统调用的执行时序图说明这一过程是如何发生的。
Linux的
clone()中CLONE_VM和CLONE_THREAD分别代表什么?如果设置CLONE_VM但不设置CLONE_THREAD——创建出来的是什么实体?用户态线程切换比内核态线程切换快多少——不经过内核的切换为什么更快?它们各自需要保存哪些上下文状态?
共享变量
counter++的汇编指令——画出两个线程交错执行Load-Add-Store的三条指令并演示竞态条件的存在。
进程与线程的资源共享和隔离的对比
| 资源类型 | 进程间(独立进程) | 线程间(同一进程) |
|---|---|---|
| 地址空间 | 独立(各自页表) | 共享(同一页表) |
| 全局变量 | 不可互相访问(需IPC) | 可直接访问 |
| 文件描述符表 | 独立(fork时复制) | 共享 |
| 信号处理表 | 独立(fork后子进程可更改) | 共享 |
| 当前工作目录 | 独立 | 共享 |
| errno | 独立 | 各自一份 |
| 栈 | 独立 | 独立(每个线程有独立的栈) |
| 寄存器 | 独立(每个线程自己的上下文) | 独立 |
理解这张表的含义——线程间共享地址空间是高效的——但也是危险的——一个线程越界写入数组可能覆盖另一个线程的关键数据——造成难以排查的bug。因此线程间同步(互斥锁/读写锁/条件变量)是保证共享数据一致性的关键手段。
用户态线程与内核态线程的详细对比
用户级线程(ULT, User-Level Thread):
- 线程管理和调度完全在用户空间(线程库中如GNU Pth)完成——不需要内核参与
- 线程切换只需要保存/恢复少量寄存器(纳秒级)——没有系统调用——没有上下文切换开销
- 一个线程阻塞(如在用户态线程库的I/O操作)会导致整个进程的全部线程都阻塞——因为内核看到的是进程被阻塞——不知道进程里还有多个线程
- 无法利用多核——因为内核只把进程调度到单个CPU——不会将线程分散到多个核心
内核级线程(KLT, Kernel-Level Thread):
- 线程的创建、调度和切换由操作系统内核管理(Linux NPTL, Windows)
- 每个线程是独立的调度实体——可以被调度到不同的CPU核心上——真正并行(多核可用)
- 一个线程阻塞不会影响同一进程的其他线程——因为内核在线程级别管理状态
- 线程切换需要进入内核态(系统调用或上下文切换)——开销大于用户级线程切换(约数微秒)
混合模型(两级模型):
- 用户线程库将多个用户线程映射到少量内核线程上
- 用户态切换快速——当一个用户线程在内核中阻塞时——同一进程中的其他用户线程可以被调度到其他内核线程上继续执行
- 实现复杂——类似Go的goroutine和Erlang的process在用户态调度器中实现的绿色线程就是混合模型——但运行时调度完全在用户空间——避免了每创建一个goroutine就创建一个内核线程的高开销
Linux线程的实现细节
在Linux上——线程通过clone()系统调用实现——与进程的统一入口:
unsigned long clone_flags;
// 创建进程(独立地址空间)
clone_flags = SIGCHLD; // 子进程退出时向父进程发SIGCHLD信号
// 创建线程(共享地址空间)
clone_flags = CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD;当clone()被调用时——内核创建一个新的task_struct(与fork相同)——但根据flags决定共享哪些资源。如果设置了CLONE_VM——新task的mm_struct指向与调用进程相同的地址空间——不创建新页表——这就是"线程"的底层本质——只是共享地址空间的进程。
NPTL(Native POSIX Thread Library)是在glibc 2.3.2+和Linux 2.6中引入的——提供了符合POSIX标准的线程实现——与之前的LinuxThreads实现相比——NPTL的线程管理更加高效和稳定(不再有信号处理不一致的问题)。
多核环境下的线程并行
在多核CPU上——内核调度器可以将同一进程的多个线程分配到不同的核心上——真正并行执行(而不是单核上的时分复用)。这意味着如果程序有4个线程、CPU有4核——理论上4个线程可以同时运行——处理能力是单核的4倍。
多线程并行需要解决的主要问题——线程间的数据依赖和同步开销。如果一个线程需要等待另一个线程的计算结果才能继续——那即使有4个核——等待的线程也不能继续执行——实际并行度受到限制。因此多线程程序的性能不能通过简单增加线程数来线性提升——还需要考虑同步开销、Cache miss、内存带宽等实际制约因素。
并发与并行的区别
并发(Concurrency):
多个任务在同一时间段内交替执行——但在单核CPU上同一时刻只有一个任务在真正执行(时分复用)
是逻辑上的同时发生
并行(Parallelism):
多个任务在多个CPU核心上同时执行——在同一时刻多个任务真正在同时运行
是物理上的同时发生多线程可以在单核上实现并发(伪并行——通过时间片切换让用户感觉任务在同时进行)——只有在多核上多线程才能实现真正的并行。并行编程的难度比并发编程大——因为除了需要处理同步——还需要考虑数据分布、Cache局部性和内存访问冲突等问题。
复习检查(续)
进程是资源分配的基本单位——线程是CPU调度的基本单位——同一进程的线程共享地址空间和文件描述符表——每个线程拥有独立的栈和寄存器。
用户级线程在内核中阻塞的后果——内核不知道线程的存在——内核把整个进程当作阻塞——该进程内的所有用户级线程都无法再被调度——即使其他用户级线程在用户态可以继续执行。
Linux中
CLONE_VM和CLONE_THREAD的作用——CLONE_VM共享地址空间——CLONE_THREAD使新线程加入同一线程组(共享tgid)。用户态线程切换快的原因——不经过内核(没有系统调用)——只需要在用户空间保存/恢复寄存器(纳秒)——而内核态线程切换需要进入内核态(约微秒级涉及特权级切换)。
并发与并行的区别——并发是逻辑上同时发生(单核时分复用)——并行是物理上同时发生(多核同时执行)。
线程的栈与线程本地存储(TLS)
每个线程在创建时——操作系统(或线程库)分配一段独立的内存区域作为线程的栈——存放线程的局部变量、函数调用帧、返回地址。线程栈的大小通常是几MB(在Linux上可用ulimit -s查看默认值)。
线程本地存储(TLS, Thread-Local Storage)——允许每个线程拥有独立的全局变量副本——不同的线程访问同名全局变量时实际上访问的是各自独立的存储区域——互不影响。C/C++中用__thread关键字声明——Go中通过goroutine-local存储实现类似效果。TLS常用于每个线程独立的日志缓冲区、数据库连接对象等。
多线程的同步机制
为了避免竞态条件——多线程程序需要同步机制来协调对共享数据的访问。常见的同步机制包括:
- 互斥锁(Mutex)——确保同一时间只有一个线程进入临界区——使用
pthread_mutex_lock/unlock或C++11的std::mutex。 - 读写锁(RWLock)——多个读者可以同时访问——写者互斥——使用
pthread_rwlock_rdlock/wrlock。 - 信号量(Semaphore)——计数器——控制对有限资源的访问——
sem_wait/sem_post。 - 条件变量(Condition Variable)——线程等待某个条件满足后继续执行——
pthread_cond_wait/signal。 - 原子操作(Atomic)——硬件支持的不可分割的读写操作——
std::atomic——用于简单的计数/标志位场景。
同步机制的选择取决于共享数据的访问模式:读多写少选读写锁——读少写多选互斥锁——简单的计数用原子操作——线程间协作用条件变量。
线程池的概念
频繁创建和销毁线程的代价比较大(每次创建均需要系统调用——内核分配栈——加入调度队列)——因此在实际的高并发应用中——通常使用线程池(Thread Pool)模式——预先创建固定数量的线程——任务通过队列传递给空闲线程执行——避免频繁创建/销毁线程的开销。
线程池的核心参数:
核心线程数: 始终存活的线程数量
最大线程数: 允许的最大线程数量(超过核心数的线程在空闲一段时间后被回收)
任务队列: 缓冲待处理任务的队列(有界/无界)
拒绝策略: 当任务队列满且线程数已达最大时——新任务的处理方式(丢弃/阻塞/抛异常)Java的ThreadPoolExecutor和Go的Goroutine(通过MPG调度器用户态调度——比内核线程池更轻量)都是线程池(或协程池)的具体实现。
复习检查(续二)
线程栈的默认大小——Linux上一般为8MB(可通过
ulimit -s查看)——线程栈过大浪费虚拟地址空间——过小可能导致栈溢出。线程本地存储(TLS)的使用场景——每个线程独立的日志缓冲区、数据库连接、随机数种子——避免锁竞争同时保持正确的独立性。
线程池的核心参数——核心线程数(保持存活)、最大线程数(上限)、任务队列(缓冲)、拒绝策略(饱和后策略)——合理配置可以避免频繁创建线程的开销。
互斥锁和读写锁的选择场景——读多写少用读写锁(多个读者可并发)——读少写多用互斥锁(写者独占)。
原子操作的使用场景——简单的共享计数器、标志位——不需要互斥锁——硬件级原子指令(如CAS、Fetch-and-Add)——比锁更轻量。
Go Goroutine与内核线程的映射关系
Go语言中——goroutine是用户态的"协程"(Coroutine)——由Go运行时调度——GOMAXPROCS参数限制了同时运行的用户线程在逻辑处理器上的工作数量。Go的MPG调度器(M:Machine对应内核线程——P:Processor代表逻辑处理器——G:Goroutine是goroutine)将成千上万的goroutine映射到少量内核线程(M)上执行——goroutine的创建和切换开销极低(几KB栈空间——切换只需纳秒)——使得在Go中创建百万个goroutine成为可能——而在同样场景下创建百万个内核线程就会耗尽系统资源。
线程安全的数据结构选择
在多线程环境中——使用非线程安全的数据结构并在外部加锁保护——是通用的做法。某些场景也可选择线程安全的阻塞队列(如concurrent_queue)、无锁队列(Lock-Free Queue基于CAS)、读写分离容器(ConcurrentHashMap)——它们在内部自己处理了同步——简化了单一线程使用时的锁管理复杂性。