本人志在持续更新计算机系统、计算机网络、C语言的核心知识点的系列合集以易懂、全面的方式讲解底层知识。对于正在准备面试八股的朋友来说本系列涵盖了本人面试中遇到的所有考点以及许多相关拓展知识读完后能帮助你从容面对大部分面试拷打对于想要深入学习计算机知识的朋友来说本系列比较系统地介绍了操作系统和网络等重点内容也举了不少例子大大有助于你从底层的视角去理解计算机系统。先说明本系列恐怕不是计算机小白或是想速通期末的朋友们的目标它需要一定系统和语言基础也并不是面向教材和考试要求去讲解所以更适合那些实操过代码、了解一些计算机系统知识、并且想要深入底层和扎实基础的朋友们去耐心学习。如果你是这样的人欢迎阅读该系列文章并分享自己的理解或提出文章中的模糊、错误的地方不排除有。想要阅读系列中其他内容或想要持续关注本系列更新可移步https://github.com/feiyangyang11/Cpp-Core-CS-Interview-Guide.git。线程线程基本定义线程是运行在进程上下文中的一条逻辑流和执行流是 CPU 调度和执行的基本单位一个进程是由一个或多个线程组成的一个进程刚启动时也只有一条线程被称为主线程举个例子打开qq就是运行一个进程而看消息的同时又能打字和接收信息就是靠多线程协作来实现线程与进程的关系一个进程里面可以同时运行多个线程每个线程有自己的线程上下文TID、栈、栈指针、程序计数器……一个进程里的所有线程又共享该进程的整个虚拟地址空间代码、堆、文件描述符表、页表……简单说进程其实是一个托管资源的容器而线程才是真正使用着资源、实际在CPU中工作的执行流或者一个不太准确的理解是线程是更轻量的进程但需要注意的是线程并不能完全替代进程。原因是它们共享进程的地址空间一旦某个线程崩溃可能导致整个进程崩溃而进程拥有独立、隔离的地址空间一个进程崩坏不会干扰其他进程。因此应用程序启动通常要运行一个单独的进程隔绝不同应用间互相影响为什么要有线程线程本身只把控少量资源大部分资源在进程上下文中被多线程共享。能够做到被多核 CPU 执行作为CPU的最小执行单位每个 CPU 核心上执行一个线程实现并行提高程序响应性能多任务同时交给不同线程后台线程执行磁盘IO等耗时操作主线程运行在CPU处理主要计算逻辑切换更轻量CPU需要轮流处理不同的任务而线程体量小于进程切换代价更小更适合CPU的并发执行模型总之线程只持有CPU运行所需的最小上下文以追求最低的切换代价和最强的并发处理能力线程的组成部分线程 一个可被操作系统调度的执行上下文内核维护的一组控制信息与所属进程共享资源的引用最核心的是PC 寄存器 栈 TCB它们决定了线程的运行、切换与恢复前面我们学习了进程的地址空间那么线程是如何分布在进程的地址空间中的呢下面将对照进程的地址分区来学习线程的各组成部分这是以线程的视角去观察进程各组成部分如何组成进程 P 的视角 ──────────────────────────────────── 用户虚拟地址空间 ├── .text ├── .rodata ├── .data / .bss ├── heap ├── mmap 区 ├── 线程 T1 用户栈 ├── 线程 T2 用户栈 └── 主线程用户栈 进入内核后关联的内核对象 ├── T1 task_struct / TCB │ └── T1 kernel stack ├── T2 task_struct / TCB │ └── T2 kernel stack ├── mm_struct │ └── 页表 ├── files_struct │ └── fdtable │ └── file 对象 ├── sighand_struct ├── fs_struct └── 调度、信号、定时器等其他结构 全局内核空间 ├── kernel text ├── kernel data ├── direct map ├── vmalloc 区 ├── modules 区 └── 其他内核映射区用户栈与内核栈进程在用户地址空间末段拥有一长段栈区而它内部又被各个线程瓜分成各自独立使用的栈区主线程的栈一般是进程启动时就创建好的常见位置在用户空间高地址附近。运行中创建的线程会从空闲空间分配一段独立的栈内存内核栈亦如此只是分配是在进程的内核虚拟地址空间中分配内存而线程栈的用途和进程栈是一样的或者说在进程篇中介绍进程栈的运行情况时实际上就是在描述它的某个线程栈的运行情况。这里只是简述一下↓详细可以移步进程篇-什么是进程上下文切换-SP栈指针中了解用户栈是如何通过 BP、SP、PC 等进行工作的以及进程篇-进程的组成部分-内核栈kernel stack中了解如何陷入内核态并使用内核栈的 。线程栈通过栈指针 SP 实现 局部变量 / 函数 的弹栈与压栈 创建变量则压栈栈顶变高变量生命周期结束则弹栈栈顶变矮。SP 再搭配栈基址指针 BP 描述一个函数栈帧以及用程序计数器 PC 记录函数的返回地址去实现函数调用以及函数内的局部变量定义等等而由于线程的栈空间只是逻辑隔离并不是严格隔离的私有内存因此如果实际运行中线程 A 如果拿到了线程 B 栈变量的地址也可能访问线程 B 的栈但这种行为非常危险会造成变量作用域混乱。如下面的代码int*pnullptr;voidthreadA(){intx10;px;//p中记录的线程A局部变量x的地址}voidthreadB(){*p20;//线程B修改了位于线程A栈空间中的变量值}TCB线程控制块 TCB管理线程信息和资源的数据结构通常 TCB 指的就是线程的内核 TCB但严格来说有些线程库会自己维护用户 TCB也就是在用户程序层面自己维护一份用户态线程描述信息通常分布在进程的用户地址空间。这里只研究内核 TCB内核 TCB 和进程篇中讲的 PCB 类似包含线程本身的信息线程 ID、线程状态、优先级、内核栈指针、寄存器上下文……线程的资源引用保存一个指向所属进程PCB的指针从而间接获取进程页表基址、线程内存空间入口、进程内存空间入口……PC程序计数器指向线程当前执行的代码位置用户空间代码段 / 共享库代码段它的使用方式也和进程篇中讲述的进程 PC 非常相似或者说这讲述的就是进程中某线程的 PC 使用方式线程被运行时它保存在 CPU 的指令寄存器中线程被换出时它被压入内核栈保存TLSThread Local Storage 线程局部存储线程级别的全局变量区普通全局变量是所有线程共享的intg0;//放在进程 bss 段但是有些变量你希望它“看起来像全局变量”但实际上每个线程有自己的一份这时候就用 TLSthread_localintx0;//把变量存放在线程自己的 TLS 区代码段、堆等其他系统资源所有线程的常量、全局变量、静态变量存放在进程的 rodata、data、bss 段详见进程篇-进程的组成部分-进程的用户态虚拟地址空间分区被所有线程共享所有线程共用的堆是进程的堆区所有线程共用同一段代码它在进程的 text 段除此之外同进程的线程还会共享页表、文件描述符表、虚拟地址空间等系统资源多线程并发当多线程并发访问共享资源时如果不通过合理的机制进行协调就有可能造成数据混乱、结果错误线程同步线程同步是保证多线程并发安全保证并发执行结果正确的一种方法核心是协调顺序互斥要理解线程同步首先要理解临界区它指的是多个线程共享的资源如进程的全局变量、进程的堆区变量……而线程同步的思想就是通过某些条件让线程串行化访问临界区主要的实现手段如下互斥锁MutexMutual Exclusion Lock 互斥锁一种用于保护临界区的同步原语保证同一时间最多只有一个线程进入临界区互斥锁只有一个只有持有锁才能访问临界区而未持有锁的线程必须等待获取锁后才能访问临界区简单的代码示例std::mutex mtx;intcount0;voidadd(){mtx.lock();//当前线程获取锁count;//进入临界区mtx.unlock();//释放锁}为什么不能用一个变量判断获取锁首先要判断锁是否空闲、再修改锁状态而普通变量如 int的判断和修改并不是原子操作因此可能造成这样一种情况——线程A判断变量值为0于是即将修改变量值为1此时线程B也判断变量值为0于是也修改为1。两个线程都以为自己成为了唯一访问者于是同时访问了临界区破坏了并发安全简单解释一下原子操作表示——从其他线程看来这个操作要么完全发生要么完全没有发生中间状态不可见而 std::mutex 本身及相关函数都是被设计为原子性的天然保证获取锁、释放锁不会出问题底层机制mutex 结构以 Linux pthread mutex 为参考mutex 底层大概是mutex --- owner持有锁的线程id --- state锁状态 --- wait queue等待锁的线程链表CASCompare And Swap硬件提供原子指令保证 Compare And Swap 查看、比较和修改值操作必是原子的这个机制就是 CASCAS(address,old,new)//CPU检查如果 *address old让 让 *address new并返回成功如果 *address ! old直接返回失败//即将如下逻辑原子化if(*addressold){*addressnew;returntrue;}else{returnfalse;}调用lock()时底层执行CAS(mutex_state, 0, 1)整个过程不可被打断但是 CPU 怎么通过硬件保证这个操作的原子性早期方案锁总线也就是锁住数据从内存向CPU输送的通道。线程A在 CPU 0 开始执行 CAS 时总线被锁住线程B在 CPU 1 就无法再操作内存中任何数据了。但这样整个系统暂停极其影响性能现代方案锁 Cache Line。现在CPU不是直接访问内存而是数据会被加载到CacheCPU去访问 Cache这里先简单提一下缓存一致性协议每个 CPU 核心的 Cache 不要求任何时刻存的数据完全一样而是要求对于同一个内存地址多个 Cache 中的数据最终必须保持一致并且满足一定的访问顺序规则那么当线程A在 CPU 0 开始对 mutex 执行 CAS 时mutex所在的 Cache Line 被锁住同时 CPU 0 根据缓存一致性机制先申请独占这个 Cache Line然后通知其他 CPU 核心如果它们的某个 Cache Line 中有 mutex 所在的内存块就将这个 Cache Line 设为无效副本。那么如果此时线程B在某 CPU 对 mutex 执行了 CAS它会发现 mutex 所在的 Cache Line 无效了于是向其他 CPU 核心或内存请求该内存块。如果 CPU 0 此时已经完成 mutex 写入CPU 1就能获取 mutex 新值如果 CPU 0 还在独占 Cache Line CPU 1 就会等待它写入。这样就严格控制了对 mutex 的修改时序打个简单的比方mutex 是家里钥匙CPU 0 和 CPU 1是住在一起的哥哥和弟弟。哥哥和弟弟现在手里都有钥匙但是哥哥去换门锁和配新钥匙此时弟弟手里的钥匙已经没用了他想开锁就只能去找哥哥拿。如果哥哥还在配他就要等哥哥配完如果哥哥配好了就可以直接拿新钥匙去开锁加锁 / 解锁 流程线程 B - 抢锁 - 执行用户态 CAS(mutex, 0, 1)不进入内核 - 成功直接返回 - 失败进入内核态将线程加入锁的等待队列线程沉睡 - 线程 A 释放锁执行 mutex_state0 - 查看等待队列非空 - 进入内核态唤醒等待队列中的线程比如线程 B - B被调度 - 再次执行抢锁内存屏障内存屏障限制 CPU 和编译器重排序x100;unlock();//没有屏障编译器可能认为上述代码可以优化成因为单线程看起来结果一样unlock();x100;mutex 包含lock acquire barrier获取锁时建立屏障保证后面的读取不会跑到 lock前unlock release barrier释放锁前建立屏障保证前面的修改不会跑到 unlock 后这就是内存屏障自旋锁Spinlock如果是互斥锁线程争抢锁失败后会陷入睡眠让出 CPU等待锁释放并被唤醒再次争抢锁如果是自旋锁线程争抢失败后仍占据 CPU 进行空转等待锁释放再争抢锁自旋锁适合在多核CPU、内核程序中使用因为内核大部分是短临界区不像用户程序的临界区可能被长时占用并且有些重要的线程是不允许睡眠的比如中断上下文要时刻保持响应中断它们无论如何都不会让出 CPU条件变量Condition Variable条件变量解决的问题是访问临界区不仅需要互斥还需要等待其他条件成立cond 内部结构体中也具有比较典型的场景是生产者与消费者——消费者必须等待生产者生产、临界区非空后才能进入临界区消费//消费者unique_locklock(mutex);//获取锁获取失败就睡眠成功则进入下面逻辑while(buffer.empty())//若临界区为空就循环抢锁{cond.wait(lock);//释放 mutex → 当前线程睡眠加入cond的等待队列 → 某处notify → 线程被唤醒重新抢夺 mutex → 返回}consume();//消费//生产者lock();//获取锁buffer.push(data);//生产unlock();//解锁cond.notify();//唤醒cond等待队列中的线程信号量semaphore信号量就是一个带原子操作的计数器 等待队列互斥锁适用的情况是临界区同时只能被一个线程访问。而有的时候临界区有多个资源可以供有限线程访问信号量就适用于这种情况结构类似structsemaphore{intcount;//表示临界区当前资源数量wait_queue waiters;//等待队列spinlock lock;//自旋锁};信号量只有两个操作 P操作 和 V操作或者sem_wait(sem)和sem_post(sem)。这两个操作的逻辑是P表示消耗一个资源。尝试将资源数量减1但如果资源数量0就让该线程沉睡V表示释放一个资源。将资源数量加1唤醒一个等待队列中的线程使用sem_init(sem,0,1);//初始化信号量资源数量1sem_wait(sem);//线程A执行P操作资源数量变为0线程A继续执行逻辑sem_wait(sem);//线程B执行P操作但资源数量为0线程B沉睡加入sem的等待队列sem_post(sem);//线程A执行完逻辑执行V操作资源数量变为0唤醒线程B原子变量原子变量就是变量自身提供机制来保证对变量的读取、写入是原子性的以 Cstd::atomic为例编译器会根据操作类型和内存序将其翻译成 CPU 支持的普通原子读写指令、原子读改写指令或内存屏障。CPU再借助缓存一致性协议保证多个核心对同一个原子变量的并发访问满足原子语义修改CPU定位变量所在的 Cache Line申请它的独占修改权其他核心中的同一 Cache Line 副本失效然后完成原子修改读取一般就是普通的读取指令但是会约束编译器重排指令读写锁规则是读锁之间可以并发写锁必须独占有写锁时读锁和其他写锁都不能进入底层依然是靠 CAS 等待队列 等机制实现频繁加锁的性能开销是什么多线程竞争锁的开销可以分为三类情况无竞争锁是空闲状态只有一个线程抢锁的情况下一次用户态CAS(lock, 0, 1)即可成功不会有上下文切换等开销……但 CAS 作为原子指令本身有一定开销。某线程修改变量时对其 cache line 独占并使其他核心中的变量副本失效这导致后续其他核心线程访问该 cache line 时需要先获取最新副本后再启动 CAS 访问此外还有内存屏障机制这会限制编译器重排和 CPU 乱序执行降低指令级并行能力有竞争但很快释放锁锁是占有状态线程执行抢锁未果它会先短暂自旋然后再进入休眠这一步有自旋浪费 CPU 的开销如果休眠前锁被释放那么就会执行 CAS也有开销竞争严重多个线程同时竞争一把锁最终只有一个线程能获取锁其他线程都会进入休眠产生 CPU 上下文切换的开销并且新线程进入后会逐渐挤出 cache 和 TLB 的数据原线程苏醒后缓存命中率骤降此外线程频繁切换会让分支预测状态变差、调度扰乱最根本问题更根本的损耗是锁把并行程序串行化如果程序中很大一部分工作被同一把锁保护这样即使开了多线程也依然不能并行执行任务不会有明显的提速反而线程切换会带来更多开销线程篇暂告一段落后续篇章在路上……