程序员计算机基础技术知识全书
书籍简介
本书是一本面向程序员的计算机基础技术知识指南,以程序员的实际工作需求为导向,系统梳理了编码、显示技术、输入输出、文件系统、内存、进程、操作系统、网络等核心计算机基础知识。不同于传统的计算机教材,本书强调理论与实践紧密结合,每个知识点都配套编程示例和实际应用场景,帮助读者建立完整的计算机知识体系,解决日常工作中遇到的各类底层技术问题。
面向读者
- 有1-3年工作经验的软件开发人员
- 希望系统补全计算机基础知识的前端/后端/全栈工程师
- 计算机相关专业的在校学生
- 对计算机底层原理感兴趣的技术爱好者
本书特色
- ✨ 实战导向:每个知识点都包含「理论讲解 + 代码示例 + 实践场景 + 常见坑点」四部分,学完就能用
- 📚 结构清晰:遵循「自底向上、由浅入深」的学习规律,从最基础的编码原理逐步深入到操作系统、网络等高级主题
- 🎯 通俗易懂:避免过于学术化的表述,多用类比和实际案例,降低学习门槛
- 💡 实用性强:所有内容都紧密结合程序员日常工作场景,重点讲解实际开发中会遇到的问题和解决方案
学习路径建议
- 初学者:按章节顺序从第1章开始学习,完成每章后的练习题
- 有基础的开发者:可以根据自己的薄弱环节选择性阅读对应章节
- 进阶学习:重点关注第6-10章的内存、进程、操作系统、网络和性能优化内容
内容结构
全书共10章+附录:
- 第1章:计算机系统概述 - 建立整体认知
- 第2章:字符编码与文本处理 - 解决编程中的各类编码问题
- 第3章:显示与图形技术 - 理解显示原理、字体、图像相关技术
- 第4章:输入输出设备 - 掌握IO系统底层实现
- 第5章:文件系统 - 理解文件系统原理与最佳实践
- 第6章:内存管理 - 写出更高效、更安全的代码
- 第7章:进程与线程 - 掌握并发编程原理
- 第8章:操作系统核心 - 理解操作系统整体架构
- 第9章:网络技术基础 - 精通网络编程
- 第10章:综合实践与性能优化 - 综合运用知识解决实际问题
版权说明
本书内容仅供学习使用,未经许可不得用于商业用途。
当前版本:v1.0.0
最后更新:2026年3月
第1章:计算机系统概述
学习目标
通过本章学习,你将能够:
- 理解计算机系统的整体层次结构
- 建立程序员所需的计算机知识体系框架
- 掌握本书的学习路径和方法
- 理解底层知识对上层编程的重要性
章节简介
作为全书的开篇,本章将带你建立对计算机系统的整体认知。我们会从程序员的视角出发,梳理计算机系统的各个层次,以及各个层次之间的关系,帮助你理解为什么程序员需要掌握底层知识,以及这些知识如何在实际工作中发挥作用。
本章内容
- 从硬件到应用的完整层次
- 各层次的核心职责和交互方式
- 抽象层的设计思想和意义
- 为什么程序员需要学习底层知识
- 必备的计算机基础知识图谱
- 知识体系的搭建方法
- 不同基础读者的学习建议
- 理论与实践结合的学习方法
- 如何将所学知识应用到实际工作中
学习建议
本章是全书的总纲,不需要记忆太多具体知识点,重点是理解整体框架和学习思路。建议至少通读一遍,明确自己的学习目标和重点。
难度:★☆☆☆☆
预计学习时间:1小时
1.1 计算机系统的层次结构
计算机系统是一个由多个抽象层组成的复杂系统,每层都建立在下层的基础之上,同时为上层提供服务。理解这个层次结构是掌握计算机系统原理的基础。
经典的计算机层次结构
从底层到上层,计算机系统通常分为以下几个层次:
┌─────────────────────────┐
│ 应用程序层 │ → 我们日常开发的App、网站、软件等
├─────────────────────────┤
│ 编程语言层 │ → C/C++/Java/Python/JS等编程语言、编译器、解释器
├─────────────────────────┤
│ 操作系统层 │ → Windows/Linux/macOS、系统调用、进程管理、文件系统等
├─────────────────────────┤
│ 指令集架构层(ISA) │ → CPU指令集、汇编语言、寄存器模型
├─────────────────────────┤
│ 微体系结构层 │ → CPU内部结构、缓存、流水线等
├─────────────────────────┤
│ 硬件逻辑层 │ → 门电路、触发器、ALU等数字逻辑部件
├─────────────────────────┤
│ 物理硬件层 │ → 晶体管、电路板、电子元件等物理器件
└─────────────────────────┘
各层次的核心职责
1. 物理硬件层
最底层的物理实现,包括:
- 半导体材料、晶体管
- 电路板、电容、电阻等电子元件
- 各种硬件设备的物理实现
这一层是电子工程领域的研究重点,普通程序员不需要深入了解,但理解基本原理有助于理解上层行为。
2. 硬件逻辑层
由数字逻辑电路组成,实现最基本的运算和存储功能:
- 门电路(与、或、非等)
- 触发器、寄存器
- 算术逻辑单元(ALU)
- 控制单元
这一层通过数字逻辑实现了二进制运算和数据存储,是计算机能够自动执行指令的基础。
3. 微体系结构层
这一层是CPU的内部实现架构,决定了指令的执行方式和性能:
- CPU流水线设计
- 缓存层次结构
- 乱序执行、超标量等优化技术
- 寄存器文件设计
不同的CPU架构(如x86、ARM)虽然指令集不同,但微体系结构层面有很多共通的设计思想。
4. 指令集架构层(ISA)
这一层是硬件和软件之间的接口,定义了CPU能够理解和执行的所有指令:
- 指令格式(运算指令、访存指令、控制指令等)
- 寄存器模型(通用寄存器、特殊功能寄存器)
- 内存寻址方式
- 中断和异常处理机制
指令集是软件和硬件之间的契约,上层的所有软件最终都要转化为指令集定义的指令才能被CPU执行。
5. 操作系统层
操作系统在硬件之上,为应用程序提供统一的抽象和服务:
- 进程和线程管理
- 内存管理
- 文件系统
- 设备驱动
- 系统调用接口
操作系统屏蔽了底层硬件的差异,让应用程序不需要关心具体的硬件细节,就能使用各种硬件资源。
6. 编程语言层
这一层是程序员直接打交道的层面,包括:
- 各种编程语言(C、Java、Python等)
- 编译器、解释器、虚拟机
- 标准库和框架
- 运行时环境
编程语言提供了更高层次的抽象,让程序员能够用更接近人类思维的方式编写代码,而不需要直接编写汇编指令。
7. 应用程序层
最上层是我们日常使用的各种软件:
- 桌面应用、手机App
- 网站、后端服务
- 游戏、办公软件
- 各种业务系统
这一层直接面向用户,解决具体的实际问题。
层次之间的交互
各层次之间通过明确的接口进行交互,下层为上层提供服务,上层通过接口调用下层的功能,而不需要关心下层的具体实现细节。这种抽象设计极大地降低了系统的复杂度,使得各个层次可以独立演进。
例如:
- 应用程序调用操作系统提供的系统调用接口来读写文件,不需要关心硬盘的具体型号和工作原理
- 编程语言的标准库封装了操作系统的系统调用,提供了更易用的API,让程序员不需要直接和系统调用打交道
- 编译器将高级语言代码翻译成CPU指令集能够理解的机器码,程序员不需要手动编写汇编
为什么程序员需要理解层次结构
很多程序员只关注最上层的应用层和编程语言层,对下层的原理了解不多,这会导致:
- 遇到底层相关的问题时无从下手(如性能问题、内存泄漏、并发问题等)
- 无法写出高效、稳定的代码
- 难以理解新技术的底层原理,学习新技术时只能停留在表面
- 排查问题时只能靠猜,无法从根本上解决问题
理解整个层次结构,能够让你建立完整的知识体系,在遇到问题时能够快速定位问题所在的层次,找到根本原因。
思考问题
- 你在日常开发中遇到过哪些和底层知识相关的问题?
- 你认为对于前端/后端工程师来说,最重要的底层知识有哪些?
- 了解层次结构对你的编程工作有什么帮助?
1.2 程序员的计算机知识体系
作为程序员,我们每天都在和各种技术打交道,从编程语言到框架,从数据库到分布式系统。但很多技术的底层原理都建立在计算机基础知识之上,建立完整的知识体系能够让你更好地理解和运用这些技术。
为什么程序员需要学习底层知识
很多开发者会有这样的疑问:“我平时开发业务代码,根本用不到这些底层知识,学了有什么用?”
学习底层知识的价值主要体现在以下几个方面:
1. 解决疑难问题的能力
实际开发中遇到的很多问题,尤其是性能问题、诡异的bug,往往和底层原理相关。如果你只懂上层API,遇到这类问题时就会无从下手。
例子:
- 接口响应慢,是网络问题?数据库问题?还是代码问题?
- 程序内存占用越来越高,是内存泄漏?还是缓存设计不合理?
- 并发场景下出现数据不一致,是锁的问题?还是事务隔离级别不对?
这些问题都需要具备底层知识才能快速定位和解决。
2. 写出更高效的代码
理解底层原理能够让你知道各种操作的开销,从而写出更高效的代码。
例子:
- 理解内存布局和缓存原理,你就会知道为什么顺序访问数组比随机访问快很多
- 理解文件系统的实现,你就会知道为什么随机读写比顺序读写慢,如何优化文件操作
- 理解网络协议的原理,你就会知道如何优化网络请求,减少延迟
3. 更好地理解新技术的本质
技术框架和工具更新换代很快,但底层原理是不变的。掌握了底层原理,学习新技术时就能很快抓住本质,而不是死记硬背API。
例子:
- 理解了进程和线程的原理,学习各种语言的并发模型就会很容易
- 理解了操作系统的IO模型,学习Node.js、Golang等的异步IO实现就会很轻松
- 理解了网络协议原理,学习各种微服务框架、RPC框架就会事半功倍
4. 突破职业瓶颈
对于工作3-5年的开发者来说,上层API的使用已经很熟练了,这时候制约你发展的往往就是底层知识的深度。很多大厂的面试也会重点考察底层原理,因为这能反映出一个开发者的潜力。
程序员必备的计算机知识图谱
作为程序员,你需要掌握的计算机基础知识主要包括以下几个方面:
📝 编码与文本处理
- 字符编码原理(ASCII、Unicode、UTF-8等)
- 文本处理、正则表达式
- 各种数据格式(JSON、XML、Protocol Buffer等)
🖥️ 显示与图形技术
- 像素、色彩原理
- 字体渲染原理
- 图像格式与压缩
- 2D/3D渲染基础
⌨️ 输入输出系统
- IO设备工作原理
- 总线与接口技术
- 存储设备(硬盘、SSD等)工作原理
- IO模型(阻塞IO、非阻塞IO、多路复用等)
📂 文件系统
- 文件系统的实现原理
- 常见文件系统(FAT32、NTFS、EXT4等)的特点
- 文件操作的底层实现
- 权限与安全机制
🧠 内存管理
- 内存硬件原理
- 地址空间、虚拟内存、分页机制
- 内存分配与回收算法
- 缓存原理与优化
- 常见内存问题(泄漏、溢出、野指针等)
🔄 进程与线程
- 进程与线程的概念
- 进程调度原理
- 进程间通信机制
- 同步与互斥(锁、信号量、条件变量等)
- 并发编程模型
- 常见并发问题(死锁、竞态条件等)
🎛️ 操作系统核心
- 操作系统整体架构
- 系统启动与引导过程
- 系统调用原理
- 驱动程序模型
- 操作系统安全机制
🌐 网络技术
- 网络分层模型(OSI七层、TCP/IP四层)
- 各层核心协议(以太网、IP、TCP、UDP、HTTP等)
- 网络编程基础
- 网络安全基础(加密、证书、HTTPS等)
- 常见网络问题排查
⚡ 性能优化
- 性能分析工具的使用
- 性能优化方法论
- 各个层面的优化技巧(CPU、内存、IO、网络等)
- 典型场景的优化实践
知识体系的搭建方法
搭建完整的知识体系不是一蹴而就的,需要长期的学习和积累。这里给大家一些建议:
1. 自上而下 vs 自下而上
两种学习路径:
- 自上而下:先学上层应用,遇到问题再往下挖原理,适合已经有工作经验的开发者
- 自下而上:从底层原理开始学,逐步往上,适合在校学生或者时间比较充足的开发者
两种路径没有优劣,适合自己的就是最好的。
2. 理论结合实践
学习底层知识一定要结合实践,不要只看书不写代码。每学一个知识点,都要写代码验证一下,这样才能真正理解。
例子:
- 学完编码原理,就写个程序处理一下各种编码的文件,看看乱码是怎么产生的
- 学完内存管理,就写个程序模拟一下内存分配,或者用工具分析一下程序的内存使用情况
- 学完网络协议,就用Wireshark抓包看看实际的网络请求是什么样的
3. 建立知识之间的联系
知识不是孤立的,要学会把不同的知识点联系起来,形成网络。
例子:
- 学习文件系统的时候,可以联系到内存管理(缓存)、IO系统(磁盘操作)
- 学习网络编程的时候,可以联系到IO模型、进程线程模型
- 学习性能优化的时候,需要综合运用所有知识点
4. 定期复盘和总结
每隔一段时间就复盘一下自己的知识体系,看看哪些地方掌握得还不够好,查漏补缺。可以通过写博客、做分享的方式来巩固自己的理解。
本章小结
建立完整的计算机知识体系是程序员成长的必经之路,虽然过程可能比较辛苦,但回报也是巨大的。它会让你在技术道路上走得更稳、更远。
接下来的章节,我们就会按照这个知识体系,逐个知识点进行详细讲解。
1.3 本书学习路径指南
本书的内容按照由浅入深、自底向上的顺序组织,适合不同基础的读者学习。这一节会根据不同读者的情况,给出相应的学习建议。
本书内容结构说明
本书的10章内容可以分为四个部分:
第一部分:基础层(第1-4章)
这部分是最基础的知识,是所有程序员都应该掌握的:
- 第1章:计算机系统概述(总纲)
- 第2章:字符编码与文本处理(日常开发中最常遇到的基础问题)
- 第3章:显示与图形技术(前端、客户端、游戏开发相关)
- 第4章:输入输出设备(所有IO操作的基础)
第二部分:系统层(第5-8章)
这部分是操作系统相关的核心知识,是进阶必备:
- 第5章:文件系统(文件操作的底层原理)
- 第6章:内存管理(写出高效代码的基础)
- 第7章:进程与线程(并发编程的基础)
- 第8章:操作系统核心(理解操作系统整体架构)
第三部分:网络层(第9章)
网络知识是现代程序员必备的,不管是前端还是后端都需要掌握:
- 第9章:网络技术基础(网络协议、网络编程、网络安全)
第四部分:实践层(第10章)
这部分是综合运用前面所学的知识,解决实际问题:
- 第10章:综合实践与性能优化(性能分析、优化方法论、实践案例)
不同读者的学习建议
情况1:初学者(工作1年以内,计算机基础薄弱)
学习路径:按章节顺序从头开始学习
- 重点学习第一部分(第1-4章),打牢基础
- 然后学习第二部分(第5-8章),重点理解内存、进程线程等核心概念
- 再学习第9章网络部分
- 最后学习第10章实践部分
学习建议:
- 不要急于求成,每个知识点都要理解透彻
- 一定要完成每章后的练习题
- 遇到看不懂的地方可以先标记,继续往下学,学到后面再回头看可能就理解了
情况2:有一定经验的开发者(工作1-3年,有一定基础)
学习路径:可以选择性重点学习
- 先快速浏览第1-4章,查漏补缺,重点看自己不熟悉的内容
- 重点学习第5-9章,尤其是内存管理、进程线程、网络这几个部分
- 深入学习第10章的性能优化内容
学习建议:
- 结合自己的工作场景学习,遇到和工作相关的内容重点研究
- 尝试将学到的知识应用到实际工作中,解决遇到的问题
- 可以重点学习自己薄弱的章节,不需要完全按顺序
情况3:资深开发者(工作3年以上,基础较好)
学习路径:重点学习进阶和实践内容
- 快速浏览全书,找到自己不熟悉的知识点
- 重点学习第6章(内存管理)、第7章(进程与线程)、第9章(网络)的进阶内容
- 深入学习第10章的性能优化方法论和实践案例
学习建议:
- 重点关注知识之间的联系,建立完整的知识体系
- 尝试将所学知识用于系统设计和架构优化
- 可以通过给团队做分享的方式巩固自己的理解
学习方法建议
1. 主动学习,不要被动接收
看书的时候多思考,多问几个为什么:
- 为什么要这么设计?
- 这样设计的好处是什么?坏处是什么?
- 如果我来设计会怎么做?
- 这个原理在什么场景下会用到?
2. 一定要动手实践
技术是练出来的,不是看出来的:
- 每个知识点都尽量写代码验证
- 完成每章后的练习题
- 尝试用学到的知识解决工作中遇到的实际问题
3. 善用工具辅助学习
有很多工具可以帮助你更好地理解底层原理:
- 编码相关:各种编码转换工具、hexdump查看二进制内容
- 系统相关:Process Explorer、top、vmstat、iostat等系统监控工具
- 内存相关:gdb、valgrind、Chrome DevTools内存分析等
- 网络相关:Wireshark抓包工具、tcpdump、curl等
- 性能分析:perf、火焰图、压力测试工具等
4. 多交流,多分享
- 遇到不懂的问题可以和同事、朋友交流
- 尝试把学到的知识分享给别人,教是最好的学
- 参与技术社区的讨论,看看别人是怎么理解这些问题的
如何将知识应用到实际工作中
很多人学了底层知识之后,觉得工作中用不上,其实是因为你没有主动去用。这里给大家一些思路:
1. 排查问题时多往底层想
遇到问题时不要只停留在上层API层面,多想想底层可能是什么原因:
- 接口响应慢?先分析是CPU密集?还是IO密集?
- 程序OOM了?先分析是内存泄漏?还是内存分配不合理?
- 并发出现问题?先想想是不是竞态条件?锁的粒度不对?
2. 做技术选型时考虑底层实现
选择技术方案时,不要只看表面的功能和性能测试数据,要了解底层实现原理:
- 这个数据库的存储引擎是什么?适合什么场景?
- 这个缓存的淘汰策略是什么?在高并发场景下会不会有问题?
- 这个RPC框架用的是什么IO模型?性能瓶颈可能在哪里?
3. 写代码时考虑底层开销
写代码的时候多想想你的代码在底层是怎么执行的:
- 这个循环会不会导致很多缓存失效?
- 这个文件操作会不会产生很多随机IO?
- 这个网络请求会不会有很多不必要的往返?
- 这个并发设计会不会有太多的锁竞争?
4. 性能优化时从底层入手
性能优化的时候,不要只想着换个更高配的机器,要从底层找原因:
- 瓶颈在哪里?CPU?内存?IO?网络?
- 有没有办法从算法层面优化?
- 有没有办法减少不必要的操作?
- 有没有办法利用底层特性提升性能?
写在最后
学习底层知识不是为了面试造火箭,而是为了在实际工作中能够更好地解决问题,写出更高效、更稳定的代码。希望这本书能够帮助你建立完整的计算机知识体系,在技术道路上越走越远。
接下来,就让我们开始正式的学习之旅吧!
练习题与扩展阅读
练习题
基础题
- 请画出计算机系统的层次结构,标明每层的名称和核心职责。
- 为什么程序员需要理解计算机系统的层次结构?请结合自己的工作经验举例说明。
- 列举3个你在实际开发中遇到的和底层知识相关的问题,以及你是怎么解决的。
思考题
- 有人说“现在编程语言和框架越来越高级,程序员不需要懂底层知识了“,你同意这个观点吗?为什么?
- 如果让你给刚入行的程序员推荐学习路径,你会怎么安排?
- 抽象层的设计给计算机系统带来了什么好处?又带来了什么弊端?
实践题
- 选择一个你常用的技术框架或工具,尝试分析它建立在哪些底层技术之上,梳理出它的技术栈层次。
- 列出你当前知识体系中比较薄弱的3个底层知识点,并制定一个学习计划。
扩展阅读
书籍推荐
-
《深入理解计算机系统》(Computer Systems: A Programmer’s Perspective)
- 经典中的经典,程序员必读的计算机系统教材
- 从程序员的视角讲解计算机系统原理,实践性很强
- 适合有一定基础的开发者深入学习
-
《编码:隐匿在计算机软硬件背后的语言》
- 非常通俗易懂的计算机原理科普书
- 从最基础的编码原理讲起,层层深入
- 适合初学者入门,建立对计算机的整体认知
-
《计算机组成原理》(唐朔飞版)
- 国内经典的计算机组成原理教材
- 讲解全面,适合系统学习硬件相关知识
- 适合想要深入了解硬件原理的开发者
-
《现代操作系统》(Modern Operating Systems)
- 操作系统领域的经典教材
- 详细讲解了操作系统的各个核心模块
- 适合深入学习操作系统原理
文章/教程推荐
-
- 讲解程序的编译、链接、运行的底层原理
- 理解程序运行的本质
-
- 开源的Linux内核讲解教程
- 适合想要了解Linux内核实现的开发者
-
- 通俗易懂的网络知识科普书
- 从用户输入网址开始,到页面返回的完整过程
- 适合网络知识入门
课程推荐
-
MIT 6.004 Computation Structures
- MIT经典的计算机组成原理课程
- 从数字逻辑讲到处理器设计
- 适合想要深入理解计算机硬件的开发者
-
MIT 6.828 Operating System Engineering
- MIT经典的操作系统课程
- 包含大量的实践内容,需要自己实现一个小型操作系统
- 适合深入学习操作系统原理
答案提示
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第2章:字符编码与文本处理
学习目标
通过本章学习,你将能够:
- 理解字符编码的本质和发展历史
- 掌握ASCII、Unicode、UTF-8等常见编码的原理和区别
- 解决编程中遇到的各类乱码问题
- 熟练使用正则表达式处理各类文本
- 理解不同编程语言的字符串实现差异
章节简介
字符编码是程序员日常开发中最常遇到的基础问题之一,乱码、编码转换、特殊字符处理等问题几乎每个开发者都遇到过。本章将从编码的本质开始讲起,系统梳理字符编码的发展历史、各种编码标准的原理和区别,以及编程中常见编码问题的解决方案。最后还会讲解正则表达式这一文本处理的利器,帮助你高效处理各类文本场景。
本章内容
- 信息的数字化表示
- 字符编码的基本概念
- 编码发展的历史背景
- ASCII编码标准详解
- 各种扩展编码(ISO-8859系列、GB2312、GBK、GB18030等)
- 多字节编码的问题和局限
- Unicode统一字符集的设计思想
- UTF-8、UTF-16、UTF-32编码原理
- 各种UTF编码的优缺点和适用场景
- BOM(字节顺序标记)的作用和问题
- 乱码产生的根本原因
- 常见编程语言的字符串实现(Python、Java、JavaScript、C/C++等)
- 编码转换的最佳实践
- 常见编码坑点和解决方案
- 正则表达式的基本语法
- 高级正则技巧(贪婪/非贪婪、分组、环视等)
- 常见文本处理场景的正则实现
- 正则的性能优化注意事项
学习建议
本章内容实用性很强,建议结合实际开发中遇到的编码问题学习。遇到乱码问题时不要靠猜,而是尝试用本章学到的知识分析问题的根本原因。正则表达式部分建议多写多练,掌握这一工具能极大提升文本处理效率。
难度:★★☆☆☆
预计学习时间:3小时
2.1 编码的本质
在开始讲解具体的编码标准之前,我们需要先理解编码到底是什么,以及为什么我们需要字符编码。
信息的数字化表示
计算机的本质是一个电子设备,它只能理解和处理二进制数据(0和1)。而我们人类在日常交流中使用的是各种自然语言的字符(中文、英文、日文等)、符号、数字等信息。要让计算机能够处理这些人类可理解的信息,就需要建立一个从人类字符到二进制数字的映射关系,这个映射过程就是编码。
换句话说:
编码就是将信息从一种格式转换为另一种格式的规则集合。
对于字符编码来说:
- 编码(Encode):将人类可理解的字符转换为计算机可处理的二进制字节序列
- 解码(Decode):将二进制字节序列转换回人类可理解的字符
字符 'A' → 编码 → 二进制 01000001 → 解码 → 字符 'A'
如果编码和解码使用的规则不一致,就会出现我们常说的乱码问题。
字符编码的核心三要素
一个完整的字符编码标准通常包含三个核心要素:
1. 字符集(Charset)
规定了这个编码标准支持哪些字符,每个字符对应一个唯一的编号(码位,Code Point)。
- 例如:ASCII字符集包含了英文字母、数字、常用符号共128个字符
- 例如:Unicode字符集包含了几乎所有人类语言的字符,超过14万个码位
2. 编码方式(Encoding)
规定了如何将字符的码位转换为实际的二进制字节序列。
- 同一个字符集可以有多种不同的编码方式
- 例如:Unicode字符集有UTF-8、UTF-16、UTF-32等多种编码方式
3. 字节序(Endianness)
对于多字节编码,需要规定字节的排列顺序(大端序/小端序)。
- 大端序(Big Endian):高位字节在前,低位字节在后
- 小端序(Little Endian):低位字节在前,高位字节在后
编码发展的历史背景
字符编码的发展历史其实就是计算机全球化的历史:
1. 计算机早期:只支持英文
计算机最早是在美国发明的,早期只需要处理英文字符,所以最早的编码标准ASCII(美国信息交换标准代码)只包含了128个字符,完全可以满足英文处理的需求。
2. 全球化初期:各国自己的编码标准
随着计算机在全球普及,各个国家都需要处理自己的语言文字,于是各个国家都制定了自己的编码标准:
- 欧洲:ISO-8859系列编码,支持欧洲各国语言
- 中国:GB2312、GBK、GB18030等编码,支持中文
- 日本:Shift_JIS编码,支持日文
- 韩国:EUC-KR编码,支持韩文
这一时期的问题是:不同国家的编码标准互不兼容,同一个二进制数值在不同的编码标准中代表不同的字符,跨语言处理非常容易出现乱码。
3. 全球化时代:统一编码标准
随着互联网的发展,不同国家和地区之间的信息交流越来越频繁,编码不兼容的问题越来越突出。于是国际组织制定了Unicode统一字符集,目标是包含全世界所有语言的字符,从根本上解决编码不兼容的问题。
现在Unicode已经成为了全球通用的编码标准,UTF-8是目前使用最广泛的Unicode实现方式。
为什么程序员必须理解编码
很多程序员觉得编码是个很底层的东西,平时开发用框架和库都帮我们处理好了,不需要理解。但实际上编码问题无处不在:
- 文本文件读写:打开文件时如果编码不对,就会出现乱码
- 网络请求:前后端交互时如果编码不一致,就会出现乱码
- 数据库存储:数据库编码和应用编码不一致,存储的内容就会乱码
- 字符串处理:不同语言的字符串实现不同,处理多字节字符时很容易出问题
- 日志分析:日志文件编码不对,分析时就会遇到各种问题
理解编码的本质,能够让你在遇到乱码问题时快速定位原因,而不是靠猜和试错来解决问题。
思考问题
- 你遇到过最印象深刻的乱码问题是什么?当时是怎么解决的?
- 为什么需要有编码和解码两个过程?直接用字符的码位作为存储格式不行吗?
- 你觉得Unicode统一编码标准有什么好处?有什么弊端?
2.2 ASCII与扩展编码
ASCII是最早的字符编码标准,也是所有现代编码的基础。这一节我们详细讲解ASCII编码以及后续的各种扩展编码。
ASCII编码详解
ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是1963年由美国国家标准学会制定的编码标准,主要用于显示现代英语和其他西欧语言。
ASCII编码的特点
- 总共包含128个字符,用7个二进制位表示
- 其中0-31和127是控制字符(如换行、回车、删除等)
- 32-126是可打印字符,包括英文字母、数字、常用符号
ASCII码表结构
| 码位范围 | 字符类型 | 说明 |
|---|---|---|
| 0-31 | 控制字符 | 不可打印,用于控制设备 |
| 32 | 空格 | 可打印 |
| 48-57 | 数字0-9 | 可打印 |
| 65-90 | 大写字母A-Z | 可打印 |
| 97-122 | 小写字母a-z | 可打印 |
| 其他 | 标点符号、特殊符号 | 可打印 |
| 127 | 删除控制符 | 控制字符 |
示例:
- ‘A’ → 65 → 二进制 01000001
- ‘a’ → 97 → 二进制 01100001
- ‘0’ → 48 → 二进制 00110000
- 空格 → 32 → 二进制 00100000
可以发现一个规律:同一个字母的大小写码位差32,这是一个很有用的特性,很多编程技巧都利用了这个特性。
ASCII的局限
ASCII最大的问题是只能表示英文字符,无法处理其他国家的语言。随着计算机在全球普及,这个问题越来越突出。
扩展ASCII编码
为了支持更多的字符,人们在ASCII的基础上进行了扩展,利用8位字节的最高位(原来ASCII只用到了低7位),这样就能表示256个字符。
ISO-8859系列编码
国际标准化组织(ISO)制定了一系列扩展ASCII编码标准,统称为ISO-8859系列:
- ISO-8859-1 (Latin-1):支持西欧语言
- ISO-8859-2 (Latin-2):支持中欧语言
- ISO-8859-3 (Latin-3):支持南欧语言
- ISO-8859-4 (Latin-4):支持北欧语言
- … 等等,共有15个标准
这些编码的低128位和ASCII完全兼容,高128位用于表示各个地区的特殊字符。
问题
ISO-8859系列解决了欧洲语言的编码问题,但还是无法支持像中文、日文这样字符数量巨大的亚洲语言,因为8位最多只能表示256个字符,远远不够。
中文编码标准
中文有上万个常用字符,8位字节完全不够用,所以中文编码使用了多字节编码方案,一个中文字符用2个或更多字节表示。
GB2312编码
GB2312是我国1980年发布的第一个中文编码标准:
- 全称《信息交换用汉字编码字符集·基本集》
- 共收录6763个常用汉字和682个非汉字符号
- 每个汉字用2个字节表示
- 低128位仍然和ASCII兼容,是对ASCII的中文扩展
- 兼容ASCII的部分称为半角字符,中文部分称为全角字符
编码规则:
- 第一个字节(高字节)范围:0xA1-0xF7
- 第二个字节(低字节)范围:0xA1-0xFE
- 两个字节的最高位都是1,和ASCII区分开
GBK编码
GB2312收录的汉字只有6763个,很多生僻字无法表示,所以1995年发布了GBK编码(汉字内码扩展规范):
- 共收录21003个汉字,包含了所有GB2312的汉字,同时增加了繁体汉字和更多生僻字
- 仍然是双字节编码
- 完全向下兼容GB2312
- 是Windows系统中文版本的默认编码
GB18030编码
为了支持更多的少数民族文字和 Unicode 字符,我国在2000年发布了GB18030编码:
- 采用1、2、4字节混合编码
- 收录了70244个汉字,包含了所有Unicode中的中文字符
- 完全向下兼容GB2312和GBK
- 是我国的国家强制标准
Big5编码
Big5是台湾、香港等地区使用的繁体中文编码标准:
- 双字节编码
- 收录13053个繁体汉字
- 和GB系列编码不兼容,同样的字节序列在GBK和Big5中表示不同的汉字,这就是为什么繁体网站如果编码不指定会出现乱码的原因。
多字节编码的问题
这些各个国家自己制定的扩展编码虽然解决了本国语言的表示问题,但带来了很多新的问题:
1. 不兼容问题
同一个二进制序列在不同的编码标准中可能代表完全不同的字符。例如:
- 字节序列
0xB0 0xA1在GBK编码中表示汉字“啊“ - 在Big5编码中表示汉字“屴“
- 在ISO-8859-1编码中表示两个特殊符号
这就导致如果没有指定编码,文本就无法正确解码,跨语言交流非常容易出现乱码。
2. 字符集有限
每个编码只能表示特定语言的字符,无法同时包含多种语言。例如一篇同时包含中文、日文、韩文的文档,就无法用单一的GBK或Shift_JIS编码来表示。
3. 处理复杂
多字节编码是变长的,有些字符是1字节,有些是2字节,处理字符串的时候很容易出问题:
- 计算字符串长度时需要区分单字节和多字节字符
- 截取字符串时很容易把一个多字节字符从中间截断,导致乱码
- 遍历字符串的复杂度比单字节编码高很多
正是因为这些问题,人们迫切需要一个全球统一的编码标准,这就催生了Unicode。
实践小技巧
在Windows系统中,如果遇到中文乱码,首先可以考虑是不是编码问题:
- 中国大陆的文件默认编码通常是GBK
- 台湾香港的文件默认编码通常是Big5
- 程序输出乱码通常是因为控制台编码和程序输出编码不一致
思考问题
- 为什么中文编码需要用多字节?单字节最多能表示多少个字符?
- GB2312、GBK、GB18030之间的关系是什么?
- 你遇到过GBK和UTF-8之间转换导致的乱码问题吗?当时是怎么解决的?
2.3 Unicode与UTF编码
Unicode(统一码、万国码)是为了解决传统编码的不兼容问题而产生的,它的目标是包含全世界所有语言的字符,为每个字符分配唯一的编码,从根本上解决乱码问题。
Unicode的设计思想
Unicode的核心思想很简单:
给全世界所有的字符分配一个唯一的数字编号(称为码位,Code Point),不管是什么平台、什么语言、什么程序。
Unicode的范围
Unicode使用0到0x10FFFF的码位空间,总共可以表示1,114,112个字符,目前已经分配了超过14万个字符,包含了几乎所有人类语言的字符、符号、表情符号等。
Unicode的码位通常表示为 U+xxxx 的形式,其中xxxx是十六进制的数值:
- U+0041 表示字符 ‘A’
- U+4E2D 表示汉字 ‘中’
- U+1F600 表示笑脸表情 ‘😀’
Unicode平面
Unicode的码位空间分为17个平面(Plane),每个平面包含65536个字符:
- 第0平面(基本多语言平面,BMP):U+0000 ~ U+FFFF,包含了最常用的字符,几乎所有常用汉字都在这个平面
- 第1-16平面(辅助平面):U+10000 ~ U+10FFFF,包含了生僻字、古代文字、表情符号等不常用的字符
UTF编码系列
Unicode只是定义了字符的码位,但是如何将这些码位转换为实际的二进制字节序列进行存储和传输,这就是UTF(Unicode Transformation Format)编码要解决的问题。
常用的UTF编码有三种:UTF-8、UTF-16、UTF-32,各有优缺点。
UTF-32编码
UTF-32是最简单的编码方式:
- 每个字符固定用4个字节(32位)表示
- 直接将码位的数值转换为4字节的二进制
- 是定长编码,处理非常简单
优点:
- 定长编码,字符串处理非常方便,计算长度、随机访问都是O(1)的复杂度
缺点:
- 空间浪费太大,对于英文文本,UTF-32的体积是ASCII的4倍
- 兼容性差,很多系统和软件都不支持
- 实际使用很少
UTF-16编码
UTF-16使用变长编码:
- BMP平面的字符(U+0000 ~ U+FFFF)用2个字节表示
- 辅助平面的字符(U+10000 ~ U+10FFFF)用4个字节表示(称为代理对)
代理对原理: 辅助平面的码位减去0x10000,得到20位的数值,分为高低各10位:
- 高10位加上0xD800,得到高代理(范围0xD800~0xDBFF)
- 低10位加上0xDC00,得到低代理(范围0xDC00~0xDFFF)
这样两个2字节的数值组成一个4字节的代理对,表示一个辅助平面的字符。
优点:
- 空间效率比UTF-32高,大部分常用字符只需要2字节
- 定长(对于BMP平面),处理比较方便
- Java、JavaScript、C#等语言内部的字符串表示就是UTF-16
缺点:
- 仍然有空间浪费,英文文本是ASCII的2倍
- 存在字节序问题,需要BOM标记
- 兼容ASCII但不兼容扩展ASCII
UTF-8编码
UTF-8是目前使用最广泛的Unicode编码,也是互联网的事实标准编码。它是一种变长编码,使用1-4个字节表示不同的字符:
UTF-8编码规则:
| 码位范围 | 字节数 | 编码格式 |
|---|---|---|
| U+0000 ~ U+007F | 1 | 0xxxxxxx |
| U+0080 ~ U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800 ~ U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 ~ U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
编码规则说明:
- 单字节字符:最高位为0,低7位就是ASCII码,完全兼容ASCII
- 多字节字符:第一个字节的前n位都是1,第n+1位是0,表示这个字符占n个字节;后续的每个字节都是以10开头
- 这种设计让UTF-8具有自同步性,从任意字节开始都能快速定位到字符的边界
示例:
- ‘A’(U+0041):单字节 → 01000001(和ASCII完全一样)
- ‘中’(U+4E2D):三字节 → 11100100 10111000 10101101
- ‘😀’(U+1F600):四字节 → 11110000 10011111 10011000 10000000
UTF-8的优点:
- 兼容ASCII:英文文本用UTF-8编码和ASCII完全一样,现有ASCII文本不需要修改就能用UTF-8打开
- 空间效率高:对于中文来说,UTF-8每个字符占3字节,和GBK差不多;对于英文来说只占1字节,比UTF-16省一半空间
- 无字节序问题:UTF-8不需要考虑字节序,不需要BOM标记
- 自同步性:编码设计合理,即使部分字节损坏,也不会影响后续的字符解码
- 广泛支持:几乎所有现代系统、编程语言、软件都原生支持UTF-8
UTF-8的缺点:
- 变长编码,字符串处理复杂度高,计算长度、随机访问都是O(n)的复杂度
- 中文占3字节,比GBK的2字节略大
BOM(字节顺序标记)
BOM(Byte Order Mark)是一种特殊的标记,放在文本文件的开头,用来表示文本的编码和字节序。
常见BOM标记
| 编码 | BOM标记 |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 LE(小端) | FF FE |
| UTF-16 BE(大端) | FE FF |
| UTF-32 LE(小端) | FF FE 00 00 |
| UTF-32 BE(大端) | 00 00 FE FF |
BOM的问题
- UTF-8的BOM是非标准的:UTF-8本身没有字节序问题,不需要BOM,很多系统和软件不支持带BOM的UTF-8文件
- 兼容性问题:很多程序处理带BOM的文件时会出现问题,比如Shell脚本、JSON文件、代码文件等,如果带BOM会导致执行错误
- 隐藏字符:BOM是不可见字符,很多编辑器不会显示,出现问题时很难排查
最佳实践:
- UTF-8文件不要加BOM
- UTF-16和UTF-32如果跨平台使用,建议加BOM,否则要明确指定字节序
- Windows系统的记事本默认会给UTF-8文件加BOM,这是很多乱码问题的根源,尽量不要用记事本编辑代码文件
编码选择建议
- 存储和传输优先选择UTF-8:这是目前的事实标准,兼容性最好,空间效率高
- 程序内部字符串表示:根据语言特性选择,比如Java/JS用UTF-16,Go/Rust用UTF-8,Python3用Unicode字符串
- 中文环境下如果需要兼容旧系统:可以考虑用GBK,但优先选择UTF-8
思考问题
- UTF-8、UTF-16、UTF-32各有什么优缺点?分别适合什么场景?
- 为什么UTF-8是目前使用最广泛的编码?
- BOM是什么?为什么UTF-8文件不建议带BOM?
- 计算一下“中国ABC“这个字符串用UTF-8、UTF-16、GBK编码分别占多少字节?
2.4 编程中的编码问题
理解了编码的原理之后,我们来看看实际编程中常见的编码问题,以及如何解决这些问题。
乱码产生的根本原因
乱码的本质就是编码和解码使用的字符集不一致。
graph LR
A[字符] -->|用GBK编码| B[字节序列]
B -->|用UTF-8解码| C[乱码]
常见的乱码场景
- 文件读写乱码:写文件时用的编码和读文件时用的编码不一致
- 网络传输乱码:发送方和接收方使用的编码不一致
- 数据库乱码:数据库、表、字段的编码和应用程序使用的编码不一致
- 控制台输出乱码:程序输出的编码和控制台的显示编码不一致
- HTML页面乱码:页面实际编码和meta标签中声明的编码不一致
乱码的表现形式
不同的编码不匹配会有不同的乱码表现:
- GBK文本用UTF-8解码:中文会变成类似“䏿–‡“这样的奇怪字符,每个中文变成2-3个乱码字符
- UTF-8文本用GBK解码:中文会变成类似“涓枃“这样的乱码,每个中文变成2个乱码字符
- 缺少字体:显示为方框“□“或者问号”?“,这不是编码问题,是系统没有对应的字体
- 不可见控制字符:显示为奇怪的符号,通常是BOM或者其他控制字符导致的
常见编程语言的字符串实现
不同的编程语言对字符串和编码的处理方式不同,理解这些差异能帮你避免很多问题。
1. Python
- Python 2:字符串有两种类型:
str(字节串)和unicode(Unicode字符串),默认编码是ASCII,很容易出现编码问题 - Python 3:字符串默认是Unicode字符串(
str类型),字节序列是bytes类型,编码问题比Python 2少很多
最佳实践:
- Python 3中,处理文本一律用
str类型,处理二进制数据一律用bytes类型 - 读写文件时明确指定编码:
open('file.txt', 'r', encoding='utf-8') - 网络传输时,字符串要编码为bytes发送,接收后解码为字符串
2. Java / JavaScript / C#
- 字符串内部使用UTF-16编码
- 每个
char类型是2字节,对应UTF-16的一个代码单元 - 辅助平面的字符需要用两个
char表示(代理对)
注意点:
length()方法返回的是代码单元的数量,不是字符的数量,辅助平面字符会被算成2个长度- 处理包含表情符号等辅助平面字符时要特别注意,避免截取字符串时截断代理对
3. Go / Rust
- 字符串内部使用UTF-8编码
- 字符串本身是字节序列的包装
- 处理字符时需要显式解码为Unicode标量值
特点:
- 性能好,空间效率高
- 标准库对UTF-8处理支持完善
- 处理多字节字符时需要注意字节和字符的区别
4. C/C++
- 没有内置的字符串编码支持,
char*本质上就是字节数组 - 编码处理完全靠开发者自己或者第三方库
- C++11之后引入了
u8string、u16string、u32string等类型支持不同编码
注意点:
- 字符串处理非常容易出现编码问题
- 需要自己管理编码转换
- 不同平台的默认编码不同(Windows中文默认GBK,Linux/macOS默认UTF-8)
编码问题常见解决方案
1. 统一编码标准
最佳实践:整个技术栈统一使用UTF-8编码,从源代码、文件存储、数据库、网络传输到前端页面,全部用UTF-8,从根源上避免编码问题。
需要统一的地方:
- 源代码文件编码:都用UTF-8无BOM
- 文件存储:所有文本文件都用UTF-8编码
- 数据库:数据库、表、字段的编码都设为
utf8mb4(MySQL的utf8是伪UTF-8,只支持3字节,utf8mb4才是真正的UTF-8,支持4字节的表情符号) - 后端接口:传输JSON默认用UTF-8
- 前端页面:HTML的meta标签设置
<meta charset="UTF-8"> - 服务器配置:Nginx、Apache等服务器默认编码设为UTF-8
2. 明确指定编码
所有涉及到编码转换的地方都要明确指定编码,不要依赖系统默认编码:
- 读写文件时明确指定encoding参数
- 调用编码转换接口时明确指定源编码和目标编码
- HTTP请求的Content-Type头明确指定charset
- 数据库连接字符串指定编码
错误示例(Python):
# 不要这样写,依赖系统默认编码,不同平台行为不同
with open('file.txt', 'r') as f:
content = f.read()
正确示例:
# 明确指定编码,行为一致
with open('file.txt', 'r', encoding='utf-8') as f:
content = f.read()
3. 正确处理编码转换
编码转换的正确流程是:
字节序列 → 按源编码解码 → Unicode字符串 → 按目标编码编码 → 新的字节序列
不要直接在两种字节编码之间直接转换,一定要先解码为Unicode中间格式。
错误示例(Python 2):
# 错误:直接在GBK字节串上调用encode,会先默认用ASCII解码,导致乱码
gbk_str.decode('gbk').encode('utf-8') # 正确
gbk_str.encode('utf-8') # 错误!
4. 避免常见坑点
坑点1:Windows记事本的UTF-8带BOM
Windows的记事本保存UTF-8文件时会自动在开头加上BOM(EF BB BF),这会导致很多问题:
- Shell脚本执行报错:
#!/bin/bash前面有BOM会导致无法识别 - JSON解析错误:JSON开头有BOM会导致解析失败
- 代码编译错误:源代码开头有BOM可能导致编译失败
解决方法:不要用Windows记事本编辑代码和配置文件,用VS Code、Notepad++等专业编辑器,保存时选择UTF-8无BOM。
坑点2:MySQL的utf8不是真正的UTF-8
MySQL的utf8编码最多只支持3字节,无法存储4字节的表情符号和生僻字,要使用utf8mb4编码才是真正的UTF-8。
解决方法:
- 创建数据库时用:
CREATE DATABASE dbname DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - 数据库连接字符串指定编码:
jdbc:mysql://localhost:3306/dbname?useUnicode=true&characterEncoding=utf8mb4
坑点3:HTTP请求的编码问题
- 表单提交默认编码是
application/x-www-form-urlencoded,要用URL编码 - 文件上传用
multipart/form-data,指定编码 - 响应头要明确指定Content-Type的charset:
Content-Type: text/html; charset=utf-8
坑点4:文件名编码问题
不同操作系统的文件名编码不同:
- Windows中文默认用GBK
- Linux/macOS默认用UTF-8
跨平台传输文件时很容易出现文件名乱码,建议文件名尽量用英文和数字,避免特殊字符。
编码问题排查思路
遇到乱码问题时,按照以下步骤排查:
- 确定各个环节的编码:搞清楚数据从产生到显示经过了哪些环节,每个环节使用的编码是什么
- 查看原始二进制数据:不要只看显示的乱码,用hexdump等工具查看原始字节序列,确定实际编码
- 逐环节排查:从数据源开始,逐环节检查编码是否正确,找到编码不匹配的地方
- 修复问题:统一编码或者正确转换编码
常用工具:
hexdump/xxd:查看文件的二进制内容iconv:编码转换工具chardet:自动检测文件编码的工具(Python库)- 浏览器的开发者工具:查看HTTP请求和响应的编码
- 数据库工具:查看数据库、表、字段的编码设置
思考问题
- 你遇到过哪些印象深刻的编码问题?是怎么解决的?
- 为什么统一使用UTF-8能解决大部分编码问题?
- MySQL的utf8和utf8mb4有什么区别?为什么建议用utf8mb4?
- 如何排查一个网页显示乱码的问题?
2.5 正则表达式与文本处理
正则表达式(Regular Expression,简称Regex/RegExp)是文本处理的强大工具,它使用一种模式语法来匹配、查找、替换符合特定规则的字符串。掌握正则表达式能极大提升文本处理的效率。
正则表达式的基本语法
1. 普通字符
普通字符就是字面意义上的字符,直接匹配对应的字符:
- 例如:
abc匹配字符串 “abc” - 大小写敏感,
A和a是不同的
2. 元字符
元字符是正则表达式中有特殊含义的字符,需要转义(前面加\)才能匹配字面意义:
| 元字符 | 含义 |
|---|---|
. | 匹配除换行符以外的任意单个字符 |
* | 匹配前面的子表达式零次或多次 |
+ | 匹配前面的子表达式一次或多次 |
? | 匹配前面的子表达式零次或一次,或表示非贪婪匹配 |
^ | 匹配字符串的开始位置 |
$ | 匹配字符串的结束位置 |
\ | 转义字符,用于匹配元字符本身 |
| ` | ` |
() | 分组,将括号内的表达式作为一个整体 |
[] | 字符类,匹配方括号中的任意一个字符 |
{} | 量词,指定匹配的次数 |
3. 量词
量词用来指定前面的子表达式匹配多少次:
| 量词 | 含义 |
|---|---|
{n} | 精确匹配n次 |
{n,} | 至少匹配n次 |
{n,m} | 匹配n到m次 |
* | 等价于{0,} |
+ | 等价于{1,} |
? | 等价于{0,1} |
4. 字符类
方括号[]用来定义字符类,匹配其中的任意一个字符:
| 写法 | 含义 |
|---|---|
[abc] | 匹配a、b、c中的任意一个字符 |
[^abc] | 匹配除了a、b、c以外的任意字符(取反) |
[a-z] | 匹配任意小写字母 |
[A-Z] | 匹配任意大写字母 |
[0-9] | 匹配任意数字 |
[a-zA-Z0-9] | 匹配任意字母或数字 |
5. 预定义字符类
正则表达式提供了一些常用的预定义字符类:
| 预定义 | 含义 | 等价于 |
|---|---|---|
\d | 匹配数字 | [0-9] |
\D | 匹配非数字 | [^0-9] |
\w | 匹配单词字符(字母、数字、下划线) | [a-zA-Z0-9_] |
\W | 匹配非单词字符 | [^\w] |
\s | 匹配空白字符(空格、制表符、换行符等) | [ \t\n\r\f\v] |
\S | 匹配非空白字符 | [^\s] |
\b | 匹配单词边界 | |
\B | 匹配非单词边界 |
正则表达式高级技巧
1. 贪婪匹配 vs 非贪婪匹配
- 贪婪匹配:默认情况下,量词
*、+、?、{n,m}会尽可能多的匹配字符 - 非贪婪匹配:在量词后面加
?,会尽可能少的匹配字符
示例:
对于字符串 <div>hello</div><div>world</div>
- 贪婪模式:
<div>.*</div>匹配整个字符串 - 非贪婪模式:
<div>.*?</div>匹配第一个<div>hello</div>
2. 分组与捕获
用括号()可以将表达式分组,分组匹配的内容可以被捕获和引用:
// 匹配日期格式,捕获年、月、日
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = regex.exec('2023-12-25');
console.log(match[1]); // 2023(第一个分组)
console.log(match[2]); // 12(第二个分组)
console.log(match[3]); // 25(第三个分组)
命名分组
很多正则引擎支持命名分组,给分组起个名字,更方便使用:
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = regex.exec('2023-12-25');
console.log(match.groups.year); // 2023
非捕获分组
如果不需要捕获分组的内容,可以用(?:)表示非捕获分组,只用来分组,不捕获内容:
// 匹配http或https,但不捕获协议部分
const regex = /(?:http|https):\/\/(\w+\.\w+)/;
3. 反向引用
可以用\1、\2等引用前面分组匹配到的内容:
示例:匹配重复的单词
// 匹配连续重复的单词,比如"hello hello"
const regex = /\b(\w+)\s+\1\b/;
regex.test('hello hello'); // true
regex.test('hello world'); // false
4. 环视(零宽断言)
环视用来匹配位置,而不是匹配字符,匹配的内容不包含在结果中:
| 类型 | 语法 | 含义 |
|---|---|---|
| 正向前瞻 | (?=pattern) | 匹配后面是pattern的位置 |
| 负向前瞻 | (?!pattern) | 匹配后面不是pattern的位置 |
| 正向后顾 | (?<=pattern) | 匹配前面是pattern的位置 |
| 负向后顾 | (?<!pattern) | 匹配前面不是pattern的位置 |
示例:
// 匹配"密码"后面跟着的数字,不包含"密码"本身
const regex = /(?<=密码:)\d+/;
regex.exec('密码:123456')[0]; // 123456
// 匹配不是以139开头的手机号
const regex = /^(?!139)\d{11}$/;
常见文本处理场景的正则实现
1. 手机号验证
^1[3-9]\d{9}$
解释:以1开头,第二位是3-9,后面跟着9位数字,总共11位。
2. 邮箱验证
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
解释:用户名部分可以包含字母、数字和特殊字符,@后面是域名,最后是后缀。
3. URL验证
^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$
4. 身份证号验证
^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$
5. 提取HTML标签中的内容
// 提取a标签中的链接
<a[^>]+href="([^"]+)"[^>]*>
// 提取标签之间的文本
<div[^>]*>(.*?)<\/div>
6. 替换敏感词
// 将文本中的"敏感词"替换为***
const text = '这是包含敏感词的文本';
const result = text.replace(/敏感词/g, '***');
正则表达式的性能优化
正则表达式如果写得不好,很容易出现性能问题,甚至导致回溯风暴(Catastrophic Backtracking),让程序卡住。
1. 避免不必要的回溯
- 尽量减少嵌套的量词,比如
(a+)*这种写法非常容易导致回溯风暴 - 非贪婪匹配要谨慎使用,可能会导致大量回溯
- 能用普通字符串匹配的就不要用正则
2. 优化匹配范围
- 尽量使用精确的字符类,而不是用
.* - 例如:匹配引号中的内容用
"[^"]*"比".*?"性能好很多
3. 提前终止匹配
- 用
^和$明确匹配的开始和结束位置,避免不必要的搜索 - 从左到右尽可能早地排除不匹配的情况
4. 复用正则表达式
- 不要在循环中重复创建正则表达式对象,提前创建好复用
- 开启合适的修饰符(如
i忽略大小写,g全局匹配,m多行模式)
5. 避免过度复杂的正则
- 过于复杂的正则表达式不仅难维护,性能也差
- 复杂的文本处理可以拆分成多个步骤,而不是写一个超级复杂的正则
正则表达式的常见坑点
- 点号
.不匹配换行符:默认情况下.不匹配换行符,如果需要匹配要使用s修饰符(单行模式) - 字符类中的元字符不需要转义:在
[]中,除了^、-、]之外,其他元字符都不需要转义 - 量词默认是贪婪的:不注意的话会匹配到意料之外的内容
- 零宽断言的支持:不是所有正则引擎都支持后顾断言,特别是JavaScript在ES2018之后才支持
- 不同语言的正则实现有差异:Python、Java、JavaScript等语言的正则引擎支持的特性略有不同
最佳实践
- 正则不是万能的:不要什么都用正则解决,简单的字符串处理用语言内置的字符串方法更高效
- 写注释:复杂的正则表达式要写注释,说明每个部分的作用
- 测试边界情况:测试正则的时候要覆盖各种边界情况,包括异常输入
- 优先使用现成的正则:常用的验证正则(手机号、邮箱等)优先使用经过验证的现成方案,不要自己瞎写
- 在线工具调试:可以用regex101.com等在线工具调试正则表达式,可视化地看匹配过程
思考问题
- 正则表达式的贪婪匹配和非贪婪匹配有什么区别?分别适合什么场景?
- 写一个正则表达式,匹配中国大陆的固定电话号码(格式:010-12345678 或 021-12345678)
- 什么是正则的回溯风暴?怎么避免?
- 你用正则表达式解决过什么复杂的文本处理问题?
练习题与扩展阅读
练习题
基础题
- 请简述ASCII、GBK、UTF-8三种编码的区别和各自的优缺点。
- 计算字符串“Hello 世界!🌍“在以下编码中分别占用多少字节:
- ASCII编码
- GBK编码
- UTF-8编码
- UTF-16编码
- 乱码产生的根本原因是什么?列举3种常见的乱码场景和解决方案。
- 为什么UTF-8编码的文件不建议带BOM?BOM会带来什么问题?
实操题
- 编写一个程序,读取一个GBK编码的文本文件,将其转换为UTF-8编码保存。
- 写一个正则表达式,匹配所有符合邮箱格式的字符串,并用它提取一段文本中的所有邮箱地址。
- 遇到一个中文网页显示乱码,请列出你的排查步骤和解决思路。
- 写一个正则表达式,验证密码强度:必须包含大小写字母和数字,长度在8-16位之间。
思考题
- 既然UTF-8已经是事实标准了,为什么还会有GBK等编码存在?我们应该在什么情况下使用非UTF-8编码?
- 为什么MySQL的utf8不是真正的UTF-8?使用utf8mb4有什么注意事项?
- 编程中字符串处理时,“字节长度“和“字符长度“有什么区别?哪些场景下需要区分这两个概念?
扩展阅读
书籍推荐
-
《Unicode Explained》
- 全面深入讲解Unicode标准的方方面面
- 适合想要深入理解Unicode原理的开发者
-
《Mastering Regular Expressions》(精通正则表达式)
- 正则表达式领域的经典著作
- 从基础到高级,全面讲解正则表达式的原理和应用
- 适合想要系统学习正则的开发者
-
《编码:隐匿在计算机软硬件背后的语言》
- 第2章的内容和本书的编码部分可以互相印证
- 从更底层的角度讲解编码的本质
在线资源
-
- Unicode标准的官方文档,最权威的资料来源
- 可以查询所有字符的码位和属性
-
- 推广UTF-8编码的网站,讲解了为什么应该统一使用UTF-8
- 包含很多最佳实践和常见问题解答
-
- 在线正则表达式调试工具
- 支持多种语言的正则引擎,可视化显示匹配过程
- 学习和调试正则的必备工具
-
- 国内非常经典的正则入门教程
- 通俗易懂,适合零基础快速入门
工具推荐
- chardet / cchardet:Python库,自动检测文本的编码
- iconv:命令行工具,进行编码转换
- hexdump / xxd:命令行工具,查看文件的二进制内容
- Notepad++ / VS Code:文本编辑器,支持查看和转换文件编码
- Wireshark:网络抓包工具,可以查看网络传输的原始字节
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第3章:显示与图形技术
学习目标
通过本章学习,你将能够:
- 理解像素、色彩等显示技术的基本原理
- 掌握显示设备的工作原理和技术参数
- 理解字体渲染的原理和常见的字体技术
- 掌握常见图像格式的原理和适用场景
- 了解2D图形渲染的基本流程和技术
章节简介
显示技术是人机交互的重要组成部分,我们每天面对的屏幕、网页、App界面背后都是显示与图形技术在支撑。对于前端工程师、客户端开发、游戏开发等领域的开发者来说,理解显示技术的底层原理尤为重要。本章将从最基础的像素和色彩原理讲起,系统讲解显示设备工作原理、字体技术、图像格式、2D图形渲染等核心内容,帮助你理解屏幕上的内容是如何一步步渲染出来的。
本章内容
- 像素的概念与显示分辨率
- 色彩模型(RGB、CMYK、HSV等)
- 色彩深度与Alpha通道
- 伽马校正与色彩空间
- CRT、LCD、OLED等显示技术原理
- 屏幕的核心技术参数(刷新率、响应时间、色域等)
- 显示输出接口(VGA、DVI、HDMI、DP等)
- 显示渲染流程:从帧缓冲区到屏幕显示
- 字体的基本概念(字形、字重、字号等)
- 常见字体格式(TTF、OTF、WOFF等)
- 字体渲染原理(光栅化、抗锯齿、次像素渲染)
- 网页和客户端的字体最佳实践
- 位图与矢量图的区别
- 无损压缩格式(PNG、GIF、WebP无损等)
- 有损压缩格式(JPEG、WebP、AVIF等)
- 不同场景的图像格式选择
- 图像压缩原理与优化技巧
- 2D渲染管线基本流程
- 常见2D渲染API(Canvas、SVG、Skia等)
- 坐标系统与变换(平移、旋转、缩放)
- 合成与混合模式
- 渲染性能优化要点
学习建议
本章内容和前端、客户端开发相关性很高,建议结合实际开发场景学习。如果你是前端开发者,可以重点关注字体渲染、图像格式优化、Canvas渲染等内容;如果是游戏开发者,可以重点关注图形渲染相关的原理。有条件的话可以动手做一些图像压缩、Canvas渲染的小实验,加深理解。
难度:★★☆☆☆
预计学习时间:3小时
3.1 像素与色彩原理
我们每天看到的屏幕显示内容,本质上都是由无数个微小的光点组成的,这些光点就是像素。理解像素和色彩的原理,是掌握所有显示技术的基础。
像素与分辨率
什么是像素
像素(Pixel,是Picture Element的缩写)是组成数字图像的最小单位。每个像素是一个独立的小方块,有自己的颜色和亮度,大量的像素按照矩阵排列组合起来,就形成了我们看到的完整图像。
┌────┬────┬────┬────┐
│ 红 │ 绿 │ 蓝 │ 红 │
├────┼────┼────┼────┤
│ 绿 │ 蓝 │ 红 │ 绿 │
├────┼────┼────┼────┤
│ 蓝 │ 红 │ 绿 │ 蓝 │
└────┴────┴────┴────┘
4x4像素的简单图像示例
分辨率
分辨率是指屏幕或图像在水平和垂直方向上的像素数量,通常表示为宽度×高度,例如:
- 1920×1080(1080P):水平方向1920个像素,垂直方向1080个像素,总共约207万像素
- 2560×1440(2K):约368万像素
- 3840×2160(4K):约829万像素
- 7680×4320(8K):约3317万像素
注意:分辨率越高,图像越清晰细腻,但需要的计算和存储资源也越多。
PPI(像素密度)
PPI(Pixels Per Inch,每英寸像素数)是指每英寸长度上的像素数量,反映了屏幕的细腻程度。
计算公式:
PPI = √(水平像素² + 垂直像素²) / 屏幕对角线尺寸(英寸)
示例:
- 5.5英寸1080P手机:√(1920²+1080²)/5.5 ≈ 401 PPI
- 27英寸4K显示器:√(3840²+2160²)/27 ≈ 163 PPI
PPI越高,屏幕越细腻,当PPI超过300时,人眼在正常观看距离下就很难分辨出单个像素点了,这就是苹果所谓的“Retina屏幕“。
DPI(每英寸点数)
DPI(Dots Per Inch)通常用于打印领域,表示每英寸可以打印的墨点数量,和PPI类似但应用场景不同。
色彩模型
色彩模型是用来表示和描述色彩的数学模型,常见的色彩模型有以下几种:
RGB色彩模型
RGB是最常用的色彩模型,也是显示器使用的色彩模型:
- R:Red(红)
- G:Green(绿)
- B:Blue(蓝)
这三种颜色是色光三原色,通过不同强度的组合可以表示出几乎所有的颜色。每种颜色的强度通常用0-255的整数表示(8位):
- R=0, G=0, B=0:黑色
- R=255, G=255, B=255:白色
- R=255, G=0, B=0:纯红色
- R=255, G=255, B=0:黄色
表示方法:
- 十六进制:
#RRGGBB,例如#FF0000表示红色 - RGB函数:
rgb(255, 0, 0) - RGBA:带Alpha通道的RGB,
rgba(255, 0, 0, 0.5)表示半透明红色
CMYK色彩模型
CMYK是印刷领域使用的色彩模型:
- C:Cyan(青)
- M:Magenta(品红)
- Y:Yellow(黄)
- K:Key(黑,这里K代表Key而不是Black是为了避免和Blue混淆)
RGB是加法混色(颜色叠加变亮),而CMYK是减法混色(颜料叠加变暗),因为印刷是靠颜料反射光线来显示颜色的。
HSV/HSB色彩模型
HSV是更符合人类感知的色彩模型:
- H:Hue(色相):0-360度表示不同的颜色,0°是红色,120°是绿色,240°是蓝色
- S:Saturation(饱和度):0-100%表示颜色的鲜艳程度,0%是灰色
- V:Value(明度):0-100%表示颜色的明亮程度,0%是黑色
HSB和HSV类似,B代表Brightness(亮度)。这种模型非常适合设计师调整颜色,因为可以直观地调整色相、饱和度和亮度。
HSL色彩模型
HSL和HSV类似:
- H:Hue(色相)
- S:Saturation(饱和度)
- L:Lightness(亮度)
区别在于亮度的计算方式,HSL的50%亮度是纯色,而HSV的100%明度是纯色。
色彩深度
色彩深度(Color Depth)是指每个像素能够表示的颜色数量,通常用位深度(bit depth)表示:
| 位深度 | 每个通道位数 | 可表示颜色数量 | 应用场景 |
|---|---|---|---|
| 8位 | 各通道共8位 | 256色 | 早期的彩色显示、GIF图像 |
| 16位 | R5G6B5 | 65536色 | 早期的移动设备、一些简单显示 |
| 24位 | R8G8B8 | ~1677万色 | 标准真彩色,目前最常用 |
| 32位 | R8G8B8A8 | ~1677万色+256级透明度 | 带Alpha通道的真彩色 |
| 30位/36位/48位 | 10/12/16位每通道 | 10亿/687亿/281万亿色 | 专业图像处理、HDR显示 |
我们常说的“真彩色“就是指24位深度,每个颜色通道8位,总共能表示2^24=16,777,216种颜色,已经超过了人眼能分辨的颜色数量。
Alpha通道
Alpha通道用来表示像素的透明度,0表示完全透明,255表示完全不透明。有了Alpha通道,我们就可以实现图像的半透明效果、图层混合等效果。
预乘Alpha(Premultiplied Alpha):
- 普通Alpha:RGB值表示原始颜色,和Alpha值分开存储
- 预乘Alpha:RGB值已经预先乘以了Alpha值,这样在进行图层混合时计算更快,很多图形API都使用这种方式
伽马校正(Gamma Correction)
什么是伽马校正
人眼对光线的感知是非线性的,对于暗部的变化更敏感,对亮部的变化不敏感。而图像传感器和显示器的光电转换也是非线性的,伽马校正就是用来调整这种非线性关系,让显示的颜色符合人眼的感知。
伽马值γ用来表示这种非线性关系:
输出亮度 = 输入电压^γ
- 显示设备的伽马值通常是2.2,也就是说输入电压是0.5时,输出亮度只有0.5^2.2≈0.22,而不是0.5
- 我们在保存图像时会预先进行伽马编码,把线性的亮度值转换为适合显示的非线性值
- 显示时显示器会自动进行伽马解码,还原正确的亮度
伽马校正的重要性
如果不进行伽马校正,图像的暗部会丢失很多细节,看起来会偏暗或者偏亮。不同系统的伽马值可能不同:
- Windows系统默认伽马值是2.2
- macOS系统默认伽马值是1.8(旧版,新版也改成2.2了)
- 网页标准伽马值是2.2
这就是为什么同一张图片在不同系统上看起来可能亮度不同的原因之一。
色彩空间
色彩空间(Color Space)定义了色彩的范围和表示方式,相同的RGB值在不同的色彩空间中可能表示不同的实际颜色。
常见的色彩空间
- sRGB:目前最常用的标准色彩空间,由微软和惠普制定,是网页、Windows系统、大多数消费级显示器的默认色彩空间,色域比较小。
- Adobe RGB:由Adobe制定,色域比sRGB大,包含更多的青绿色,适合印刷和专业图像处理。
- DCI-P3:数字电影行业使用的色彩空间,色域比sRGB大25%,现在很多高端手机、显示器都支持P3广色域。
- Rec.2020:超高清电视(UHDTV)使用的色彩空间,色域非常大,是未来的发展方向。
色域
色域是指色彩空间能够表示的颜色范围,通常用CIE 1931色度图上的三角形区域表示。三角形面积越大,能表示的颜色越多。
广色域:通常指色域超过sRGB的显示设备,比如支持DCI-P3的屏幕,能显示更鲜艳的颜色,特别是红色和绿色。
色彩管理
不同设备的色彩空间不同,为了让同一张图片在不同设备上显示效果一致,就需要色彩管理:
- 每个设备都有自己的ICC配置文件,描述了设备的色彩特性
- 操作系统和软件会根据ICC文件进行色彩转换,保证颜色显示一致
- 专业设计人员需要使用经过校色的显示器,保证颜色准确
编程相关的色彩问题
-
网页色彩表示:
- 支持十六进制、rgb()、rgba()、hsl()、hsla()等多种表示方式
- 现代浏览器支持16位颜色通道、广色域(CSS Color Level 4)
-
Canvas/WebGL颜色处理:
- 默认使用sRGB色彩空间
- 处理图像时要注意伽马校正和颜色空间转换
- 浮点纹理和HDR渲染需要线性色彩空间
-
图像处理注意事项:
- 颜色计算(如模糊、混合)应该在线性色彩空间中进行,在sRGB空间直接计算会得到错误的结果
- 半透明混合要注意Alpha通道的处理,特别是预乘Alpha的情况
思考问题
- 为什么24位真彩色能表示的颜色数量已经超过了人眼的分辨能力,但还有30位、36位等更高的色彩深度?
- RGB和CMYK都是三原色模型,为什么一个是加法混色一个是减法混色?
- 伽马校正的作用是什么?如果没有伽马校正会出现什么问题?
- 同样的RGB值为什么在不同的屏幕上显示的颜色可能不一样?
3.2 显示设备工作原理
显示设备是计算机输出信息的重要接口,从早期的CRT显示器到现在的LCD、OLED屏幕,显示技术经历了多代发展。理解显示设备的工作原理,有助于我们更好地选择和使用显示设备,解决显示相关的问题。
常见显示技术原理
CRT显示器(阴极射线管)
CRT是最早的显示技术,我们以前用的大屁股显示器就是CRT:
- 工作原理:通过电子枪发射电子束,轰击屏幕上的荧光粉发光,电子束按照行和列逐行扫描整个屏幕,利用人眼的视觉暂留效应形成完整的图像。
- 特点:色彩准确,响应速度快,可视角度大;但体积大、重量重、功耗高,有辐射,现在已经基本被淘汰。
LCD显示器(液晶显示器)
LCD是目前使用最广泛的显示技术,大部分桌面显示器、笔记本电脑、电视都使用LCD技术:
- 工作原理:液晶分子在电场作用下会改变排列方向,从而控制背光源发出的光线的透过率,通过彩色滤光片产生不同的颜色。
- 结构组成:背光源 → 偏光片 → 液晶层 → 彩色滤光片 → 偏光片 → 屏幕
- 常见LCD面板类型:
- TN面板:响应速度快,价格便宜,但可视角度小,色彩差,多用于低端显示器和电竞显示器
- IPS面板:可视角度大,色彩准确,是目前的主流面板类型
- VA面板:对比度高,黑色显示纯净,多用于曲面屏和高端电视
OLED显示器(有机发光二极管)
OLED是新一代显示技术,现在高端手机、高端电视都在使用OLED:
- 工作原理:每个像素都是一个有机发光二极管,不需要背光源,通电就可以自己发光。
- 优点:
- 可以做到像素级关闭,对比度无限高,黑色纯净
- 响应速度极快,没有拖影
- 厚度薄,可以做成曲面屏、折叠屏
- 可视角度大,色彩鲜艳
- 缺点:
- 容易烧屏(长时间显示固定静态内容会留下残影)
- 寿命比LCD短,特别是蓝色像素寿命较短
- 低亮度下PWM调光可能会伤眼
- 价格较高
其他显示技术
- Mini LED:LCD的改进版,使用大量微小的LED作为背光源,可以实现多分区背光,对比度接近OLED,没有烧屏问题,但厚度比OLED厚
- Micro LED:下一代显示技术,每个像素都是微小的LED,兼具OLED和LCD的优点,寿命长、亮度高、无烧屏,但目前成本极高,还未普及
- QLED:量子点技术,本质上还是LCD,通过量子点提升色域和色彩准确度
屏幕的核心技术参数
选购显示器或屏幕时,需要重点关注以下参数:
1. 分辨率
前面已经讲过,分辨率是屏幕的像素数量,分辨率越高图像越细腻。常见分辨率:
- 办公:1920×1080(1080P)、2560×1440(2K)
- 设计/影视:3840×2160(4K)
- 电竞:优先考虑高刷新率,分辨率1080P/2K即可
2. 刷新率
刷新率是屏幕每秒刷新画面的次数,单位是Hz(赫兹):
- 普通办公:60Hz足够
- 电竞游戏:144Hz、165Hz、240Hz甚至更高,刷新率越高画面越流畅,拖影越少
- 电影/视频:24Hz/60Hz即可,电影标准是24帧每秒
注意:刷新率需要和显卡的输出帧率匹配才能发挥效果,如果显卡只能跑60帧,用144Hz屏幕也没有意义。
3. 响应时间
响应时间是像素从一种颜色切换到另一种颜色需要的时间,单位是ms(毫秒):
- 普通办公:5ms以内足够
- 电竞游戏:1ms GTG(灰阶响应时间),响应时间太长会导致运动模糊和拖影
4. 色域
色域是屏幕能显示的颜色范围:
- 普通办公:100% sRGB足够
- 设计/影视:需要99% Adobe RGB、95% DCI-P3以上的广色域
- 注意:色域不是越大越好,还要看色彩准确度ΔE,ΔE<2才是专业级的色准
5. 对比度
对比度是屏幕最亮的白色和最暗的黑色的亮度比值:
- LCD屏幕典型对比度在1000:1到3000:1之间
- OLED屏幕对比度理论上是无限大,因为黑色可以完全关闭
- 高对比度的画面更有层次感,暗部细节更清晰
6. 亮度
亮度单位是nit(尼特):
- 普通办公:300nit足够
- HDR显示:需要400nit以上,专业HDR显示器需要1000nit甚至更高
- 户外使用的手机屏幕需要最高亮度达到1000nit以上才能在阳光下看清
7. 色深
色深就是我们前面讲的色彩深度,主流是8bit(1670万色),专业显示器是10bit(10.7亿色),色彩过渡更平滑,没有色阶断层。
显示输出接口
常见的显示接口有以下几种:
1. VGA(Video Graphics Array)
- 模拟接口,是最老的显示接口
- 最高分辨率2048×1536@60Hz,容易受干扰,画质差
- 现在新的显卡和显示器基本已经淘汰了VGA接口
2. DVI(Digital Visual Interface)
- 数字接口,取代VGA的接口
- 最高分辨率2560×1600@60Hz,不支持音频传输
- 现在也基本被HDMI和DP取代了
3. HDMI(High-Definition Multimedia Interface)
- 目前最主流的接口,同时支持视频和音频传输
- HDMI 2.0:最大支持4K@60Hz
- HDMI 2.1:最大支持10K@120Hz,支持VRR可变刷新率、ALLM自动低延迟等特性,适合游戏和高清视频
4. DP(DisplayPort)
- 行业标准的数字接口,主要用于电脑显示器
- DP 1.4:最大支持8K@60Hz或4K@144Hz
- DP 2.0:最大支持16K@60Hz或8K@120Hz,带宽比HDMI 2.1更高
- 支持菊花链,可以一个接口串联多个显示器
5. Type-C
- 多功能接口,既可以传输数据,也可以传输视频,还可以充电
- 基于DP Alt Mode模式,视频传输规格和DP一样
- 现在很多轻薄本、手机都用Type-C接口输出视频
显示渲染流程:从帧缓冲区到屏幕
我们在屏幕上看到的内容,需要经过一系列的处理流程才能显示出来:
1. 帧缓冲区(Frame Buffer)
帧缓冲区是内存中的一块区域,存储着要显示的图像的每个像素的颜色数据。每个像素的数据按照屏幕分辨率的顺序排列。
- 对于1920×1080分辨率32位色深的屏幕,一帧需要1920×1080×4 = 8294400字节≈8MB的显存
- 通常会使用双缓冲甚至三缓冲技术,避免画面撕裂
2. 显示控制器(Display Controller)
显示控制器是显卡中的硬件模块,负责从帧缓冲区中读取像素数据,处理后发送给显示器:
- 处理伽马校正、色彩空间转换
- 进行缩放、旋转等变换
- 生成显示接口所需的时序信号
3. 显示接口传输
像素数据通过HDMI/DP等接口传输到显示器,传输的是逐行扫描的像素数据,同时包含行同步和场同步信号,告诉显示器什么时候换行、什么时候换帧。
4. 屏幕显示
显示器接收到数据后,按照时序逐行点亮像素:
- 对于LCD:调整液晶分子的偏转角度,控制光线透过率
- 对于OLED:控制每个像素的发光亮度
- 一帧画面扫描完成后,开始下一帧的扫描
5. 画面撕裂与垂直同步
如果显卡输出帧率和显示器刷新率不同步,就会出现画面撕裂(上半部分是新一帧,下半部分是旧一帧)。
- 垂直同步(V-Sync):让显卡等待显示器的垂直同步信号再输出新的一帧,解决画面撕裂,但会增加延迟
- G-Sync(NVIDIA)/FreeSync(AMD):可变刷新率技术,让显示器的刷新率跟着显卡的帧率变化,既解决撕裂又不增加延迟
常见显示问题与解决方案
- 画面撕裂:开启垂直同步或者G-Sync/FreeSync
- 拖影/运动模糊:选择响应时间快的屏幕,开启显示器的 overdrive 功能
- 颜色偏色:校色,使用正确的色彩管理,检查数据线是否有问题
- 刷新率上不去:检查接口是否支持,检查显卡驱动,检查线材规格
- 烧屏(OLED):避免长时间显示静态画面,开启像素偏移功能
思考问题
- OLED和LCD各有什么优缺点?分别适合什么使用场景?
- 高刷新率屏幕对日常使用和游戏有什么提升?是不是刷新率越高越好?
- 什么是画面撕裂?它产生的原因是什么?如何解决?
- 如果你要选购一台显示器,你会优先考虑哪些参数?为什么?
3.3 字体技术
字体是文字显示的基础,我们在屏幕上看到的所有文字都是通过字体渲染出来的。对于前端开发、UI设计、排版相关的工作来说,理解字体技术的原理非常重要。
字体的基本概念
什么是字体
字体(Font)是一系列具有相同风格的字符的集合,定义了每个字符的形状(字形)、大小、字重、间距等属性。
字体相关术语
- 字形(Glyph):单个字符的形状描述,同一个字符可以有多个字形(比如大小写、不同字重)
- 字重(Font Weight):字体的粗细程度,通常用数值表示,100最细,900最粗,常见的400是正常,700是粗体
- 字号(Font Size):字体的大小,单位通常是磅(pt)或者像素(px)
- 字距(Kerning):两个字符之间的间距调整
- 行高(Line Height):两行文字基线之间的距离
- 基线(Baseline):文字对齐的基准线,大部分字母的底部都在基线上
- serif 衬线字体:笔画末端有装饰性的衬线,比如宋体、Times New Roman,适合印刷阅读
- sans-serif 无衬线字体:笔画末端没有衬线,比如黑体、Arial、Roboto,适合屏幕显示
- 等宽字体:每个字符的宽度都相同,比如Consolas、Monaco,适合写代码
字号详解
字号是指字体的大小,是排版中最基础也最重要的概念。不同场景下使用的单位和计算方式有所不同。
常见的字号单位
1. 点(Point,pt)
- 传统印刷业使用的标准单位,1磅大约等于 1/72 英寸
- 在标准的印刷分辨率(300DPI)下,1pt ≈ 1.333 像素
- Word、Pages 等排版软件默认使用磅作为单位
- CSS 也支持 pt 单位,但网页开发不推荐使用
2. 像素(Pixel,px)
- 屏幕显示使用的单位,1px 对应屏幕上的一个物理像素
- 是网页开发中最常用的单位,
font-size: 16px表示 16 像素高 - 在不同 DPI 的屏幕上,实际物理大小不同:
- 72DPI 屏幕:1px = 1/72 英寸 ≈ 0.3528mm
- 96DPI 屏幕:1px = 1/96 英寸 ≈ 0.2646mm
- 视网膜屏(2x):1px = 2 物理像素
3. 相对单位(em、rem、%)
- em:相对于父元素的字体大小,
1em= 当前父元素字体大小 - rem:相对于根元素(html)的字体大小,在整个页面中比例一致
- %:和 em 类似,
100%等于父元素字体大小 - 相对单位适合做响应式设计,让用户可以通过浏览器设置调整整体文字大小
4. 视口单位(vw、vh)
- vw:1vw = 视口宽度的 1%
- vh:1vh = 视口高度的 1%
- 适合需要根据屏幕大小动态调整的大标题
DPI 与字号的关系
DPI(Dots Per Inch,每英寸点数)表示屏幕的物理密度:
- 传统屏幕:96DPI 或 72DPI
- 高DPI屏幕(视网膜屏):192DPI 或更高(2x、3x 缩放)
在 CSS 中,px 是“逻辑像素“,不是物理像素:
- 在 1x 屏幕:1px 逻辑像素 = 1px 物理像素
- 在 2x 视网膜屏:1px 逻辑像素 = 4px 物理像素(2x2)
- 字号的逻辑大小不变,只是变得更清晰
字号的黄金比例
排版中常用一些约定俗成的字号比例,让页面更美观:
基准字号:16px(正文)
一级标题:≈ 1.8em ≈ 28.8px
二级标题:≈ 1.5em ≈ 24px
三级标题:≈ 1.25em ≈ 20px
小号文字:≈ 0.875em ≈ 14px
最小字号:≈ 0.75em ≈ 12px
现代网页设计趋势:
- 正文字号不小于 16px,保证移动端阅读舒适度
- 大标题可以更大,增强视觉层次感
- 正文行高通常在 1.5-1.6 倍字号之间
中文字号的编号系统
中文排版传统上使用“号“来表示字号:
| 字号 | 磅数(pt) | 像素(px/96DPI) | 用途 |
|---|---|---|---|
| 八号 | 5pt | ~7px | 极小注释 |
| 七号 | 5.5pt | ~7px | 注释 |
| 小六号 | 6.5pt | ~9px | 页脚、注释 |
| 六号 | 7.5pt | ~10px | 注释、图片说明 |
| 小五号 | 9pt | ~12px | 正文(报纸) |
| 五号 | 10.5pt | ~14px | 正文 |
| 小四号 | 12pt | ~16px | 正文 |
| 四号 | 14pt | ~19px | 小标题 |
| 小三号 | 15pt | ~20px | 小标题 |
| 三号 | 16pt | ~21px | 一级标题 |
| 小二号 | 18pt | ~24px | 一级标题 |
| 二号 | 22pt | ~29px | 大标题 |
| 一号 | 26pt | ~35px | 大标题 |
| 初号 | 36pt | ~48px | 封面标题 |
最佳实践
- 网页开发:正文使用 14-16px,移动端不小于 16px 避免缩放
- 响应式设计:使用 rem 作为单位,方便整体缩放
- 可访问性:不要使用过小的字号,正文字号不小于 14px
- 中文排版:因为文字结构比英文复杂,通常需要比英文更大一点的字号才能清晰阅读
- 印刷出版:使用 pt 单位,根据印刷尺寸计算合适字号
常见字体格式
1. TrueType(TTF)
- 由苹果和微软在80年代联合开发的字体格式
- 是目前最广泛使用的字体格式,Windows和macOS系统都原生支持
- 使用二次贝塞尔曲线描述字形,缩放不失真
- 文件扩展名为
.ttf
2. OpenType(OTF)
- 由微软和Adobe联合开发,是TrueType的扩展
- 支持更高级的排版特性(连字、alternate字符、小型大写字母等)
- 可以使用TrueType或者PostScript曲线描述字形
- 文件扩展名为
.otf - 支持Unicode,能包含大量字符
3. Web开放字体格式(WOFF/WOFF2)
- 专门为网页设计的字体格式
- WOFF是TTF/OTF的压缩版本,文件更小,加载更快
- WOFF2是WOFF的改进版,压缩率比WOFF高30%左右
- 所有现代浏览器都支持WOFF/WOFF2
- 文件扩展名为
.woff、.woff2 - 是网页字体的首选格式
4. Embedded OpenType(EOT)
- 微软开发的嵌入式字体格式,只有旧版IE浏览器支持
- 现在已经基本被淘汰,不需要再使用
5. 可变字体(Variable Fonts)
- 新一代的字体格式,一个字体文件可以包含多个字重、宽度、甚至斜体的变化
- 不需要为不同字重加载不同的字体文件,大大减少字体文件大小
- 可以通过CSS的
font-variation-settings属性灵活调整字体样式 - 现代浏览器都已经支持可变字体,是未来的发展方向
字体渲染原理
字体文件中存储的是字形的矢量描述(贝塞尔曲线),要在屏幕上显示出来,需要经过光栅化过程,把矢量形状转换为像素点阵。
字体渲染流程
- 字形解析:从字体文件中读取对应字符的矢量描述
- 栅格化:把矢量曲线转换为像素网格上的点阵
- 抗锯齿(Anti-aliasing):对边缘的像素进行半透明处理,让文字边缘更平滑
- 次像素渲染(Subpixel Rendering):利用LCD屏幕每个像素由RGB三个子像素组成的特性,进一步提升文字清晰度
- 显示:把渲染好的文字点阵输出到屏幕上
常见的字体渲染引擎
不同操作系统使用不同的字体渲染引擎,这就是为什么同一个字体在不同系统上显示效果不同的原因:
- Windows:
- GDI:旧的渲染引擎,字体比较锐利,但小字号可能会模糊
- DirectWrite:Windows 7之后引入的新渲染引擎,渲染效果更好,更平滑
- macOS / iOS:
- CoreText:苹果系统的渲染引擎,追求字体的打印效果,比较平滑,笔画较粗
- Linux:
- FreeType + Fontconfig:开源的渲染引擎,可配置性高,不同发行版效果可能不同
抗锯齿技术
抗锯齿是让文字边缘更平滑的技术,主要有以下几种:
- 灰度抗锯齿:根据字形覆盖像素的比例,调整像素的灰度值,让边缘看起来平滑
- 次像素抗锯齿(ClearType):利用LCD的RGB三个子像素单独调整亮度,相当于把水平分辨率提高了3倍,文字更清晰。但在OLED屏幕和高DPI屏幕上效果不明显,而且如果显示设备的子像素排列不同,可能会出现彩色边。
- 无抗锯齿:不做任何平滑处理,文字边缘有锯齿,但非常锐利,适合极低分辨率的屏幕。
字体渲染的常见问题
- 小字号模糊:低DPI屏幕上小字号容易模糊,需要字体做hinting(微调),在小字号下调整字形形状,让显示更清晰
- 字体发虚:抗锯齿过渡或者渲染引擎的问题,高DPI屏幕下会好很多
- 不同平台显示差异:因为渲染引擎不同,同一个字体在Windows和macOS上显示效果不同,设计时需要考虑这种差异
- 字体加载闪烁:网页加载外部字体时,会出现FOIT(Flash Of Invisible Text,文字先不可见后显示)或者FOUT(Flash Of Unstyled Text,先显示系统字体后替换为自定义字体)的问题
网页字体最佳实践
网页开发中使用自定义字体已经非常普遍,这里分享一些最佳实践:
1. 优先使用系统字体
如果不是必须使用特殊字体,优先使用系统自带的字体,加载最快,显示效果最好:
/* 系统字体栈 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
系统字体不需要下载,渲染效果最好,是大多数场景的首选。
2. 选择合适的字体格式
- 现代浏览器优先使用WOFF2,其次是WOFF,最后是TTF/OTF
- 不需要再支持EOT格式,除非需要兼容IE8及以下
- 示例:
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2'),
url('myfont.woff') format('woff'),
url('myfont.ttf') format('truetype');
}
3. 优化字体加载
- 字体子集化:只包含需要用到的字符,比如中文网页如果只需要常用汉字,可以把字体文件从几MB缩小到几十KB
- 预加载字体:使用
<link rel="preload">提前加载关键字体 - 使用font-display控制加载行为:
font-display: swap:先显示系统字体,字体加载完成后替换,避免文字不可见font-display: fallback:短时间内加载不出来就用系统字体,加载完成后替换font-display: optional:如果字体不能很快加载完成,就一直用系统字体
- 合理缓存字体:设置较长的Cache-Control头,让浏览器缓存字体文件
4. 避免常见问题
- 不要使用太多不同的字体,每个额外的字体都需要额外的HTTP请求和下载
- 注意字体的版权问题,不要随意使用商业字体
- 考虑 fallback 字体,即使自定义字体加载失败,也能有合适的替代字体显示
- 对于中文网页,大字体文件建议使用字体拆分或者云字体服务
字体的版权问题
字体是有版权的,和软件、音乐、图片一样,使用商用字体需要获得授权:
- 免费商用字体:思源黑体、思源宋体、站酷系列字体、阿里巴巴普惠体等,可以免费商用
- 商业字体:微软雅黑、苹方、方正系列等,需要购买授权才能商用
- 注意:很多系统自带的字体虽然你可以在自己电脑上用,但不代表你可以嵌入到网页、App或者商业设计中使用,需要确认版权
开发相关技巧
- 字体加载检测:可以使用
document.fontsAPI检测字体是否加载完成 - 可变字体的使用:如果使用可变字体,可以大大减少字体文件数量,同时提供更灵活的样式控制
- 不同平台字体适配:针对不同操作系统选择合适的字体,保证显示效果一致
- 无障碍考虑:保证字号足够大,行高合适,对比度足够,方便阅读
思考问题
- 为什么同一个字体在Windows和macOS上显示效果不同?
- 网页中使用自定义字体有哪些性能问题?如何优化?
- WOFF2格式比TTF格式有什么优势?为什么网页推荐使用WOFF2?
- 什么是可变字体?它相比传统字体有什么好处?
3.4 图像格式
我们在开发中会用到各种格式的图像,不同的图像格式有不同的特点和适用场景。选择合适的图像格式能够在保证画质的前提下最小化文件大小,提升页面加载速度。
位图 vs 矢量图
首先要区分两大类图像格式:位图和矢量图。
位图(Bitmap / Raster Image)
- 由像素点阵组成,每个像素有自己的颜色信息
- 缩放会失真,放大后会看到锯齿
- 适合存储照片、复杂图像
- 常见格式:JPEG、PNG、GIF、WebP、AVIF等
矢量图(Vector Image)
- 由数学公式描述的点、线、曲线、形状组成
- 无限缩放不失真,边缘始终平滑
- 文件大小和图像复杂度相关,和尺寸无关
- 适合存储图标、Logo、插图等简单图形
- 常见格式:SVG、AI、EPS等
选择建议:
- 照片、复杂插画:用位图格式
- 图标、Logo、可缩放图形:用矢量图格式(SVG)
常见位图格式详解
1. JPEG / JPG
- 全称:Joint Photographic Experts Group
- 特点:有损压缩,不支持透明度,支持24位真彩色
- 压缩原理:利用人眼对亮度信息敏感,对色度信息不敏感的特点,丢弃部分高频信息减少文件大小
- 压缩比:可调,通常10:1到20:1的压缩率肉眼看不出明显损失
- 优点:文件小,加载快,兼容性极好
- 缺点:有损压缩,有质量损失,不支持透明度,不适合保存线条、文字等边缘锐利的图像,多次编辑保存会导致质量越来越差
- 适用场景:照片、复杂图像、不需要透明度的场景,是网页中使用最广泛的图像格式之一
2. PNG
- 全称:Portable Network Graphics
- 特点:无损压缩,支持透明度(Alpha通道),支持8位/24位/32位色深
- 压缩原理:使用DEFLATE无损压缩算法,不会损失画质
- PNG有两种主要类型:
- PNG-8:8位索引色,最多256色,支持1位透明度,文件小,适合简单图形
- PNG-24/32:24位真彩色+8位Alpha通道,支持半透明,画质好但文件大
- 优点:无损压缩,画质好,支持完整的透明度,无压缩损失,适合多次编辑
- 缺点:文件体积比JPEG大很多,不适合存储照片
- 适用场景:图标、Logo、带有透明度的图像、需要清晰边缘的文字和线条图形
3. GIF
- 全称:Graphics Interchange Format
- 特点:无损压缩,最多256色,支持1位透明度,支持动画
- 压缩原理:LZW无损压缩
- 优点:支持动画,文件小,兼容性好
- 缺点:色彩少,只有256色,不支持半透明,画质差
- 适用场景:简单动画、表情图,其他场景已经基本被PNG和视频取代
4. WebP
- 开发公司:Google
- 特点:同时支持有损和无损压缩,支持透明度,支持动画
- 压缩率:
- 无损WebP比PNG小26%左右
- 有损WebP比JPEG小25-35%左右
- 动画WebP比GIF小64%左右
- 优点:压缩率高,画质好,功能全面,支持所有常用特性
- 缺点:兼容性比JPEG/PNG差一些,但现在所有现代浏览器都已经支持,旧版IE不支持
- 适用场景:几乎所有场景都适用,是JPEG和PNG的优秀替代者,能显著减少文件大小
5. AVIF
- 全称:AV1 Image File Format
- 开发组织:开放媒体联盟(AOMedia)
- 特点:基于AV1视频编码的新一代图像格式,支持有损/无损压缩,支持透明度,支持动画
- 压缩率:比WebP还要小20%左右,比JPEG小50%左右,是目前压缩率最高的图像格式
- 优点:压缩率极高,画质好,支持HDR、广色域
- 缺点:兼容性目前比WebP差,旧浏览器不支持,编码速度慢
- 适用场景:未来的主流图像格式,现在可以渐进式使用,作为WebP的替代
6. BMP
- 全称:Bitmap
- 特点:无压缩,原始像素数据
- 优点:画质无损,结构简单,容易解析
- 缺点:文件极大,没有压缩
- 适用场景:基本不用,只在一些特殊的Windows程序中使用
7. TIFF
- 全称:Tagged Image File Format
- 特点:支持无损压缩,支持多种色深,专业图像格式
- 优点:画质极高,支持图层、Alpha通道等专业特性
- 缺点:文件大,不适合网页使用
- 适用场景:专业印刷、摄影、图像处理领域
矢量图格式:SVG
- 全称:Scalable Vector Graphics
- 特点:基于XML的矢量图像格式,用文本描述图形
- 优点:
- 无限缩放不失真
- 文件小,适合简单图形
- 文本格式,可以用CSS和JavaScript控制样式和动画
- 无损压缩,编辑方便
- 缺点:
- 不适合存储复杂图像,比如照片,文件会比位图大很多
- 渲染性能比位图差
- 适用场景:图标、Logo、插图、简单动画、需要适配不同分辨率的图形
图像格式选择指南
| 场景 | 首选格式 | 次选格式 | 不建议格式 |
|---|---|---|---|
| 普通照片、商品图 | WebP/AVIF | JPEG | PNG/GIF |
| 透明背景图标、Logo | WebP/AVIF | PNG | JPEG |
| 简单动图、表情 | WebP/AVIF | GIF | - |
| 图标、矢量图形 | SVG | PNG | JPEG |
| 需要编辑的源文件 | PNG/TIFF | - | JPEG(多次编辑损失画质) |
图像压缩原理与优化技巧
图像压缩的本质
图像压缩的本质是利用图像中的冗余信息,通过算法去除冗余,减少文件大小:
- 空间冗余:相邻像素的颜色通常相似,存在大量重复信息
- 视觉冗余:人眼对某些信息不敏感,可以丢弃这些信息而不影响主观画质
- 编码冗余:可以用更短的编码表示出现频率高的信息
有损压缩 vs 无损压缩
- 无损压缩:解压后的数据和原始数据完全一致,没有任何损失,适合需要多次编辑的源文件
- 有损压缩:丢弃部分人眼不敏感的信息,换取更高的压缩率,适合最终发布的图像
图像优化最佳实践
1. 选择最合适的格式
优先使用现代格式WebP/AVIF,能显著减少文件大小,同时设置fallback到JPEG/PNG兼容旧浏览器:
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="描述">
</picture>
2. 选择合适的尺寸
不要使用比显示尺寸大的图像,提前根据需要的尺寸进行缩放。比如网页上只需要显示200x200的图片,就不要上传2000x2000的原图。
3. 调整压缩质量
根据场景选择合适的压缩质量,不需要追求100%的质量:
- JPEG通常质量设置在75-85之间,性价比最高
- WebP质量设置在80左右,画质和JPEG 85差不多,但文件小很多
- 不要过度压缩,否则会出现明显的块效应和模糊
4. 使用工具优化图像
- Squoosh:Google开发的在线图像压缩工具,支持各种格式,非常方便
- ImageOptim:macOS平台的无损图像压缩工具
- TinyPNG:在线PNG/JPEG压缩工具,压缩率高
- sharp:Node.js的图像处理库,适合批量处理图像
- ffmpeg:强大的音视频处理工具,也可以用来转换和压缩图像
5. 其他优化技巧
- 雪碧图(CSS Sprite):把多个小图标合并到一张图片里,减少HTTP请求
- 响应式图片:根据不同的屏幕尺寸提供不同大小的图片,避免在手机上加载过大的图片
- 渐进式JPEG:图片从模糊到清晰逐渐加载,用户感知更好
- 懒加载:不在视口内的图片延迟加载,提升页面首屏加载速度
常见问题
为什么保存JPEG多次后画质越来越差?
JPEG是有损压缩,每次保存都会重新压缩,丢弃部分信息,所以多次编辑保存会导致画质越来越差。如果需要多次编辑,应该用PNG或者TIFF等无损格式保存,最后导出的时候再保存为JPEG。
PNG-8和PNG-24怎么选?
如果图像颜色少(比如图标、Logo),用PNG-8,文件更小;如果图像颜色丰富,需要半透明效果,用PNG-24/32。
WebP已经这么好了,为什么还要用JPEG和PNG?
主要是兼容性问题,虽然现在现代浏览器都支持WebP,但还有一些旧设备和旧浏览器不支持,所以需要提供fallback。随着旧设备被淘汰,WebP会逐渐取代JPEG和PNG。
思考问题
- JPEG和PNG各有什么优缺点?分别适合什么场景?
- WebP相比JPEG和PNG有什么优势?为什么现在越来越多的网站使用WebP?
- 位图和矢量图有什么区别?什么时候应该用矢量图?
- 有哪些方法可以优化网页中的图像加载性能?
3.5 2D图形渲染基础
2D图形渲染是很多应用的基础,网页的UI、App界面、2D游戏等都是通过2D渲染技术绘制出来的。理解2D渲染的基本原理,能够帮助我们写出性能更好、效果更优的图形应用。
2D渲染管线基本流程
渲染管线(Rendering Pipeline)是指将图形数据转换为屏幕像素的整个过程,2D渲染管线相对3D来说比较简单,主要包含以下几个步骤:
1. 几何处理阶段
- 顶点输入:输入要绘制的图形的顶点坐标,比如矩形的四个顶点,三角形的三个顶点
- 坐标变换:将顶点从模型坐标转换到屏幕坐标,包括平移、旋转、缩放等变换
- 裁剪:裁剪掉屏幕外的部分,只保留需要绘制的内容
2. 光栅化阶段
- 扫描转换:将矢量图形(点、线、多边形)转换为像素网格上的片元(Fragment,也就是候选像素)
- 属性插值:计算每个片元的颜色、透明度、纹理坐标等属性,通过顶点属性插值得到
3. 片元处理阶段
- 纹理采样:如果使用纹理,根据纹理坐标采样纹理的颜色
- 颜色计算:计算每个片元的最终颜色,包括填充色、描边色、特效等
- 混合测试:和帧缓冲区中已经存在的像素进行混合,处理半透明效果
- 写入帧缓冲区:将最终的像素颜色写入到帧缓冲区中
常见2D渲染API
1. Canvas API
Canvas是HTML5提供的2D绘图API,通过JavaScript操作像素来绘制图形:
- 特点:
- 立即模式渲染,调用绘制函数后立即绘制到画布上
- 适合绘制大量动态的图形,比如游戏、数据可视化
- 性能好,适合复杂的动画和大量元素的场景
- 缩放会失真,因为是位图绘制
- 适用场景:2D游戏、复杂动画、数据可视化图表、图像处理
2. SVG(Scalable Vector Graphics)
SVG是基于XML的矢量图形格式,也是浏览器原生支持的2D绘图技术:
- 特点:
- 保留模式渲染,图形作为DOM节点存在,可以用CSS和JavaScript操作
- 矢量图形,缩放不失真
- 支持事件绑定,可以交互
- 元素太多时性能较差
- 适用场景:图标、插图、简单动画、需要交互的图形、需要适配不同分辨率的场景
3. Skia
Skia是Google开发的开源2D图形库,是Chrome浏览器、Android系统、Flutter框架的底层渲染引擎:
- 特点:
- 跨平台,性能优异
- 支持硬件加速
- 功能全面,支持各种2D绘制功能
- 是很多上层框架的底层依赖
4. Cairo
Cairo是开源的2D图形库,常用于GTK、GNOME等Linux桌面环境:
- 特点:
- 跨平台,支持多种输出后端
- 矢量渲染质量高
- 适合桌面应用的UI渲染
5. Direct2D
微软开发的Windows平台的2D渲染API,支持硬件加速,是Windows应用的主要2D渲染接口。
坐标系统与变换
常见的坐标系统
- 模型坐标(局部坐标):相对于图形自身原点的坐标
- 世界坐标:相对于整个画布原点的坐标
- 屏幕坐标(设备坐标):相对于屏幕左上角的坐标,通常左上角是(0,0),x轴向右,y轴向下,这和数学上的笛卡尔坐标系y轴向上不同,要特别注意。
常见的坐标变换
所有的坐标变换都可以通过矩阵乘法来实现:
1. 平移(Translate)
将图形移动到指定位置:
x' = x + tx
y' = y + ty
2. 旋转(Rotate)
绕原点旋转θ角度(顺时针为正,因为y轴向下):
x' = x * cosθ - y * sinθ
y' = x * sinθ + y * cosθ
3. 缩放(Scale)
沿x轴和y轴缩放:
x' = x * sx
y' = y * sy
4. 仿射变换(Affine Transform)
平移、旋转、缩放的组合,可以表示为3x3的矩阵:
[ a c tx ]
[ b d ty ]
[ 0 0 1 ]
变换公式:
x' = a*x + c*y + tx
y' = b*x + d*y + ty
多个变换可以通过矩阵乘法组合成一个变换矩阵,一次性应用到所有顶点,提升性能。
合成与混合模式
当多个图形重叠时,需要处理它们之间的合成关系,混合模式(Blend Mode)定义了上层像素和下层像素如何混合。
基本混合公式
结果颜色 = 上层颜色 * 源因子 + 下层颜色 * 目标因子
常见的混合模式
- 正常(Normal):默认模式,上层像素覆盖下层像素,如果上层有透明度,根据Alpha值混合:
结果 = 上层颜色 * 上层Alpha + 下层颜色 * (1 - 上层Alpha) - 正片叠底(Multiply):上下层颜色相乘,结果更暗,类似于两张透明胶片叠在一起
- 滤色(Screen):上下层颜色的补色相乘再取补,结果更亮,类似于两张幻灯片叠加投影
- 叠加(Overlay):结合正片叠底和滤色,亮部更亮,暗部更暗,增加对比度
- 相加(Add):上下层颜色相加,结果更亮,常用于发光、火焰等效果
- 差值(Difference):上下层颜色相减的绝对值,常用于反色效果
不同的混合模式可以实现各种特效,很多绘图软件和Canvas都支持这些混合模式。
2D渲染性能优化要点
2D渲染的性能问题通常出现在绘制大量元素或者复杂动画的场景,这里分享一些优化技巧:
1. 减少绘制调用(Draw Call)
- 绘制调用是GPU执行的绘制命令,每次绘制调用都有 overhead
- 尽量将多个小的绘制合并为一个大的绘制,比如将多个小图形合并到一个大的Canvas中
- 使用批处理技术,一次性绘制多个元素
2. 避免不必要的重绘
- 只重绘变化的区域,不要每次都重绘整个画布
- 使用脏矩形(Dirty Rectangle)技术,只更新需要变化的区域
- 离屏渲染:将不经常变化的内容先绘制到离屏Canvas上,需要的时候直接把整个离屏Canvas绘制到主画布上,避免每次都重复绘制这些元素
3. 利用硬件加速
- 现代GPU对2D渲染也有很好的加速支持,尽量使用支持硬件加速的渲染API
- 避免频繁读写像素数据,比如Canvas的getImageData/putImageData操作很慢,尽量少用
- 纹理尽量使用2的幂次方尺寸,GPU处理起来效率更高
4. 优化复杂绘制
- 大量重复的图形可以使用缓存,绘制一次后复用结果
- 矢量图形的光栅化比较耗时,复杂的矢量图形可以缓存为位图
- 动画尽量用transform和opacity属性,这些属性可以由GPU合成,不需要重绘
5. 避免过度绘制
- 过度绘制(Overdraw)是指同一个像素被多次绘制,浪费性能
- 尽量按照从后到前的顺序绘制,不需要绘制被完全遮挡的部分
- 对于半透明元素,要注意绘制顺序,从后往前绘制
Canvas vs SVG性能对比
- 元素数量少(<1000),需要交互:SVG性能更好,因为作为DOM节点操作方便
- 元素数量多(>1000),动态变化:Canvas性能更好,因为没有DOM overhead
常见的2D渲染问题
1. 图形边缘锯齿
- 原因:光栅化时没有做抗锯齿处理
- 解决:开启抗锯齿(AA),现在的渲染API默认都会开启抗锯齿
2. 文字模糊
- 原因:Canvas绘制文字时坐标不是整数,或者画布缩放后没有正确处理
- 解决:绘制时坐标取整,高DPI屏幕下将画布尺寸放大为CSS尺寸的2倍,然后用CSS缩小显示,提升清晰度
3. 动画卡顿
- 原因:绘制时间太长,达不到60fps
- 解决:优化绘制流程,减少绘制调用,利用硬件加速,将复杂计算放到Web Worker中
4. 半透明混合异常
- 原因:绘制顺序不对,或者混合模式设置错误
- 解决:半透明元素要从后往前绘制,使用正确的混合模式,注意预乘Alpha的问题
思考问题
- Canvas和SVG各有什么优缺点?分别适合什么场景?
- 什么是渲染管线?2D渲染管线主要包含哪几个阶段?
- 为什么y轴向下的屏幕坐标系和数学上的y轴向上的坐标系不同?开发中要注意什么?
- 提升2D渲染性能有哪些常用的优化技巧?
练习题与扩展阅读
练习题
基础题
- 简述sRGB和Adobe RGB两种色彩空间的区别,分别适合什么场景?
- OLED和LCD显示技术各有什么优缺点?选购显示器时你会优先考虑哪种技术?
- 常见的网页字体格式有哪些?为什么推荐使用WOFF2格式?
- 对比JPEG、PNG、WebP三种图像格式的优缺点,分别适合什么场景?
- Canvas和SVG作为两种2D渲染技术,各有什么特点?分别适合什么应用场景?
实操题
- 找一张JPEG格式的照片,分别用80%和50%的质量压缩,对比画质和文件大小的差异,找到你认为最佳的压缩质量平衡点。
- 编写一个简单的HTML页面,分别用Canvas和SVG绘制一个简单的动画(比如移动的矩形),对比两者的代码实现差异和性能表现。
- 尝试将一个TTF字体转换为WOFF2格式,对比转换前后的文件大小差异。
- 查看你正在使用的显示器的参数,计算它的PPI是多少,看看是否达到了Retina屏幕的标准。
思考题
- 为什么显示器使用RGB色彩模型,而印刷使用CMYK色彩模型?两者可以表示的颜色范围一样吗?
- 高DPI屏幕(2K/4K)相比1080P屏幕,对于开发者来说有哪些需要注意的地方?
- 可变字体相比传统字体有什么优势?会给网页排版带来什么变化?
- 随着AVIF等新图像格式的普及,JPEG和PNG会被完全取代吗?为什么?
扩展阅读
书籍推荐
-
《计算机图形学(第四版)》
- 计算机图形学领域的经典教材
- 系统讲解2D/3D图形渲染的基本原理和算法
- 适合想要深入学习图形学的开发者
-
《字体设计与排印》
- 全面讲解字体设计和排版的基础知识
- 适合前端、UI设计等需要和字体排版打交道的开发者阅读
-
《色彩管理:色彩设计的方法与实践》
- 讲解色彩理论和色彩管理的专业书籍
- 帮助你理解色彩空间、伽马校正、校色等专业知识
- 适合设计和前端开发人员阅读
在线资源
-
- MDN的图形开发指南,包含Canvas、SVG、WebGL等各种前端图形技术的详细文档
- 前端图形开发的必备参考资料
-
- Google官方的Web性能优化指南,图像优化部分非常详细
- 包含各种图像格式选择、压缩、加载优化的最佳实践
-
- Google Fonts提供的字体知识指南
- 包含字体技术、网页字体最佳实践等内容
-
The Hitchhiker’s Guide to Digital Colour
- 非常通俗易懂的数字色彩原理指南
- 讲解了伽马校正、色彩空间等容易混淆的概念
工具推荐
-
Squoosh:https://squoosh.app/
- Google开发的在线图像压缩工具,支持JPEG、PNG、WebP、AVIF等多种格式
- 可以直观地对比压缩质量和文件大小,非常方便
-
Font Squirrel:https://www.fontsquirrel.com/tools/webfont-generator
- 网页字体生成工具,可以将TTF/OTF字体转换为WOFF/WOFF2格式
- 还支持字体子集化,大大减小字体文件大小
-
Can I Use:https://caniuse.com/
- 查询各种Web技术的浏览器兼容性,包括图像格式、字体格式、CSS特性等
- 做Web开发必备的工具
-
DisplayCAL:https://displaycal.net/
- 开源的显示器校色工具,配合校色仪可以校准显示器的颜色,保证色彩准确
- 适合设计、视频编辑等对色彩准确度要求高的用户
-
Figma:https://www.figma.com/
- 在线UI设计工具,内置了丰富的字体、色彩、图形功能
- 可以用来做设计、原型,也可以用来导出各种格式的图像
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第4章:输入输出设备
学习目标
通过本章学习,你将能够:
- 理解计算机IO系统的整体架构和工作原理
- 掌握常见输入设备(键盘、鼠标等)的工作原理
- 理解存储设备(HDD、SSD等)的工作原理和性能差异
- 了解总线和接口技术的基本原理
- 掌握编程中IO操作的底层原理和优化方法
章节简介
输入输出(IO)设备是计算机与外界交互的接口,我们通过输入设备向计算机输入信息,通过输出设备获取计算机的处理结果。IO系统的性能往往是整个系统的性能瓶颈,理解IO系统的原理对于写出高性能的程序至关重要。本章将从IO系统概述开始,讲解常见输入设备、存储设备、总线接口技术的工作原理,以及编程中IO操作的底层实现和优化技巧。
本章内容
- IO系统的作用和层次结构
- IO控制方式(轮询、中断、DMA)
- IO性能指标(带宽、延迟、IOPS)
- 同步IO vs 异步IO,阻塞IO vs 非阻塞IO
- 键盘的工作原理(机械键盘、薄膜键盘)
- 键盘扫描码与按键处理流程
- 鼠标、触摸板等其他输入设备原理
- 输入事件的处理流程(从硬件到应用程序)
- 机械硬盘(HDD)的工作原理与性能参数
- 固态硬盘(SSD)的工作原理与特性
- 其他存储设备(U盘、光盘、磁带等)
- 存储层次结构(寄存器、缓存、内存、外存)
- 不同存储设备的性能对比与选型建议
- 总线的基本概念和作用
- 常见内部总线(PCIe、SATA、USB等)
- 常见外部接口(USB、Thunderbolt、SATA等)
- 总线的性能指标(带宽、频率、位宽)
- 文件IO、网络IO的底层实现
- 常见IO模型(阻塞、非阻塞、多路复用、异步IO)
- IO操作的性能开销
- IO密集型应用的优化方法
学习建议
本章内容和系统编程、后端开发相关性较高,如果你是后端工程师或者做系统级开发,建议重点学习IO模型、存储设备原理、IO优化等内容;如果是前端开发者,可以重点了解输入事件处理流程、IO性能的基本概念。学习时可以结合自己平时遇到的IO相关问题,比如磁盘IO瓶颈、网络请求优化等,加深理解。
难度:★★☆☆☆
预计学习时间:3小时
4.1 IO系统概述
IO(Input/Output,输入输出)系统是计算机系统中负责与外部设备进行数据交互的子系统。CPU和内存的速度提升非常快,但IO设备的速度提升相对较慢,IO系统往往是整个系统的性能瓶颈。
IO系统的层次结构
计算机的IO系统是一个分层的架构,每层负责不同的功能,上层不需要关心下层的具体实现细节:
┌─────────────────────────┐
│ 应用程序层 │ → 调用标准库的IO接口
├─────────────────────────┤
│ 标准库层 │ → 封装系统调用,提供易用的IO接口(如C的stdio、Java的IO类)
├─────────────────────────┤
│ 操作系统层 │ → 系统调用、文件系统、设备驱动、IO调度
├─────────────────────────┤
│ 设备控制器 │ → 硬件层面的控制,如磁盘控制器、网卡控制器
├─────────────────────────┤
│ 物理设备层 │ → 实际的硬件设备(硬盘、网卡、键盘等)
└─────────────────────────┘
各层的职责:
- 应用程序层:发起IO请求,比如读取文件、发送网络请求
- 标准库层:封装了底层的系统调用,提供跨平台的、易用的IO接口,不需要开发者直接和系统调用打交道
- 操作系统层:负责管理所有IO设备,提供系统调用接口,处理IO调度,保证公平性和效率
- 设备控制器:是硬件和软件之间的接口,负责控制具体的硬件设备,完成实际的IO操作
- 物理设备层:实际的硬件设备,负责最终的输入输出操作
IO控制方式
CPU和设备之间的数据传输有几种不同的控制方式,效率差异很大:
1. 程序控制IO(轮询方式)
- 工作原理:CPU不断轮询检查设备的状态,直到设备准备好后才进行数据传输
- 优点:实现简单,不需要额外的硬件支持
- 缺点:CPU利用率极低,大部分时间都在空转等待
- 适用场景:简单的嵌入式系统,或者速度非常慢的设备
2. 中断驱动IO
- 工作原理:CPU发起IO请求后就去做其他事情,设备准备好后向CPU发送中断信号,CPU响应中断后处理数据传输
- 优点:CPU不需要等待,利用率比轮询高很多
- 缺点:每个字的传输都需要中断,大量IO时中断次数太多,会消耗大量CPU资源
- 适用场景:字符设备、输入设备等低速IO设备
3. DMA(直接内存访问)
- 工作原理:DMA控制器接管数据传输的工作,CPU只需要告诉DMA控制器要传输的数据地址、长度和方向,DMA控制器自动完成整个数据块的传输,传输完成后再向CPU发送中断
- 优点:CPU不需要参与数据传输过程,大大降低了CPU的负担,适合大量数据的传输
- 缺点:需要额外的DMA控制器硬件支持
- 适用场景:磁盘、网卡等块设备,需要大量数据传输的场景
4. 通道方式
- 工作原理:通道是一个专门的IO处理器,有自己的指令集,可以独立执行通道程序,完成更复杂的IO操作,不需要CPU干预
- 优点:CPU的负担更小,适合大型计算机系统中大量IO的场景
- 缺点:硬件成本高,只在大型机、服务器中使用
- 适用场景:高端服务器、大型计算机系统
现在我们常用的PC和服务器中,大部分块设备(磁盘、网卡)都使用DMA方式进行数据传输,字符设备(键盘、鼠标)使用中断驱动方式。
IO性能指标
衡量IO性能主要有三个指标:
1. 带宽(Bandwidth)
- 单位时间内能够传输的数据量,单位通常是MB/s、GB/s
- 衡量的是IO系统的吞吐量,越大越好
- 比如SATA 3.0接口的带宽是6Gbps≈750MB/s,PCIe 4.0 x16的带宽是32GB/s
2. 延迟(Latency)
- 从发起IO请求到请求完成的时间,单位通常是ms(毫秒)、μs(微秒)
- 衡量的是IO系统的响应速度,越小越好
- 比如SSD的随机读延迟是0.1ms左右,HDD的随机读延迟是10ms左右
3. IOPS(Input/Output Operations Per Second)
- 每秒能够处理的IO请求数量
- 衡量的是IO系统处理小请求的能力,越大越好
- 比如普通HDD的IOPS是100-200左右,普通SSD的IOPS是10000-100000左右,高端NVMe SSD可以达到百万级IOPS
注意:这三个指标是独立的,高带宽不一定代表高IOPS,比如HDD的连续读写带宽可以达到100-200MB/s,但随机IOPS只有100左右,因为每次随机IO都需要寻道,延迟很高。
IO模型分类
在编程层面,我们通常从两个维度对IO模型进行分类:
维度1:是否阻塞
- 阻塞IO:发起IO请求后,线程会被挂起,直到IO操作完成才会继续执行
- 非阻塞IO:发起IO请求后,立即返回,不会阻塞线程,如果数据还没准备好,返回错误,应用程序可以轮询检查是否完成
维度2:是否同步
- 同步IO:IO操作由应用程序自己完成,或者需要应用程序主动等待操作完成
- 异步IO:IO操作由操作系统完成,操作完成后通知应用程序,整个过程应用程序不需要等待
常见的IO模型
- 阻塞IO(BIO):最传统的IO模型,线程发起IO请求后就阻塞等待,直到操作完成。优点是编程简单,缺点是线程利用率低,高并发场景下需要大量线程,开销大。
- 非阻塞IO:IO请求立即返回,线程可以做其他事情,定期轮询检查IO是否完成。优点是不阻塞线程,缺点是轮询会消耗CPU资源,效率低。
- IO多路复用(IO Multiplexing):用一个线程监听多个IO请求的状态,当某个请求准备好后,再处理这个请求。常见的实现有select、poll、epoll(Linux)、kqueue(BSD/macOS)、IOCP(Windows)。优点是线程利用率高,适合高并发场景,是现在高性能网络服务器的主流IO模型。
- 信号驱动IO:应用程序向操作系统注册IO信号,当IO准备好后,操作系统发送信号通知应用程序处理。用得比较少。
- 异步IO(AIO):应用程序发起IO请求后,立即返回,操作系统完成整个IO操作(包括数据拷贝)后通知应用程序。整个过程完全不阻塞线程,效率最高,但编程复杂,支持的平台有限。
高性能IO的发展方向:尽量减少线程阻塞,用尽可能少的线程处理尽可能多的IO请求,提升系统的并发能力。
IO系统的性能瓶颈
IO系统往往是整个计算机系统的性能瓶颈,原因是不同硬件之间的速度差异非常大:
- CPU和缓存:ns(纳秒)级,速度极快
- 内存:100ns级,速度很快
- SSD:100μs(微秒)级,比内存慢1000倍
- HDD:10ms(毫秒)级,比内存慢100000倍
- 网络:几十ms到几百ms级,跨地域的网络延迟更高
可以看到,存储和网络IO的速度比CPU和内存慢几个数量级,所以系统的整体性能往往被IO设备限制,这就是著名的“冯·诺依曼瓶颈“。
思考问题
- 为什么IO系统往往是计算机系统的性能瓶颈?
- DMA方式相比中断驱动方式有什么优势?为什么块设备都使用DMA方式?
- IO多路复用模型相比阻塞IO模型有什么优势?为什么高并发场景下都用IO多路复用?
- 带宽、延迟、IOPS三个指标之间有什么关系?分别适合衡量什么样的IO场景?
4.2 键盘与输入设备
输入设备是我们向计算机输入信息的接口,键盘、鼠标、触摸板、触摸屏等都是最常见的输入设备。理解输入设备的工作原理,有助于我们更好地处理用户输入,优化交互体验。
键盘的工作原理
键盘是最常用的输入设备,我们每天都在使用,但你知道按下一个按键到屏幕上显示出字符,中间经过了多少处理流程吗?
键盘的分类
1. 薄膜键盘
- 结构:上下两层导电薄膜,中间有绝缘层,按键按下时两层薄膜接触,电路导通
- 优点:成本低、噪音小、寿命长(一般1000万次敲击寿命)
- 缺点:手感差,没有段落感,按键冲突较多
- 适用场景:普通办公、日常使用
2. 机械键盘
- 结构:每个按键都是一个独立的机械轴,常见的轴有青轴、红轴、茶轴、黑轴等
- 优点:手感好,有段落感,按键冲突少,寿命长(一般5000万次敲击寿命)
- 缺点:成本高、噪音大(青轴)
- 适用场景:游戏、程序员、对输入手感有要求的用户
3. 静电容键盘
- 结构:利用电容容量变化来检测按键按下,不需要物理接触
- 优点:手感顺滑,寿命极长(1亿次以上敲击寿命),噪音小
- 缺点:价格昂贵
- 适用场景:高端用户、专业打字员
键盘的工作流程
当你按下一个按键时,键盘内部的微控制器会检测到按键按下,然后向计算机发送对应的扫描码(Scan Code):
- 按键检测:键盘的微控制器周期性扫描按键矩阵,检测哪个按键被按下或释放
- 生成扫描码:根据按下的按键,生成对应的扫描码,按下按键发送通码(Make Code),释放按键发送断码(Break Code)
- 发送到主机:通过USB/PS2接口将扫描码发送给计算机的键盘控制器
- 操作系统处理:操作系统的键盘驱动收到扫描码,转换为对应的虚拟键码(Virtual Key Code),然后生成键盘事件放入系统消息队列
- 应用程序处理:应用程序从消息队列中获取键盘事件,做出对应的处理(比如输入字符、触发快捷键等)
扫描码与键码
扫描码(Scan Code)
- 是键盘硬件产生的编码,和具体的硬件相关,同一个按键在不同键盘上的扫描码可能不同
- 按下按键时发送通码,释放按键时发送断码(通常是通码加上0x80)
- 常见的扫描码集有XT、AT、PS/2等
虚拟键码(Virtual Key Code)
- 操作系统定义的按键编码,和硬件无关,同一个按键的虚拟键码在系统中是固定的
- 比如Windows中VK_ENTER代表回车键,VK_A代表A键
- 操作系统会把扫描码转换为虚拟键码,提供给上层应用使用
字符编码
- 操作系统根据当前的输入法和键盘布局,将虚拟键码转换为对应的字符编码(比如UTF-8)
- 这就是为什么同一个按键在不同的输入法下会输入不同的字符
按键冲突(Key Rollover)
按键冲突是指同时按下多个按键时,键盘无法正确识别所有按键的情况:
- 普通薄膜键盘:通常只能支持2-3键无冲,同时按下多个键会出现冲突,无法正确识别
- 游戏键盘/机械键盘:通常支持6键无冲,甚至全键无冲(N-Key Rollover,NKRO),同时按下所有按键都能正确识别
- 对于游戏玩家和需要大量快捷键的用户,按键无冲很重要
键盘事件处理流程
我们以Windows系统为例,看一下从按键按下到应用程序收到事件的完整流程:
用户按下按键 → 键盘发送扫描码 → 键盘控制器接收 → 发送中断信号给CPU → 中断处理程序读取扫描码 → 转换为虚拟键码 → 生成键盘消息(WM_KEYDOWN/WM_CHAR等) → 发送到对应线程的消息队列 → 应用程序从消息队列取出消息 → 处理事件
常见的键盘事件
- 按键按下事件(Key Down):按键被按下时触发
- 按键释放事件(Key Up):按键被释放时触发
- 字符输入事件(Char / Key Press):按键按下后产生可输入字符时触发
开发中的常见问题
- 输入法冲突:处理快捷键的时候要注意输入法状态,比如中文输入法下的按键和英文输入法下可能不同
- 重复按键:按键按住不放会连续触发按键事件,可以通过系统设置调整重复延迟和重复速度
- 组合键处理:处理Ctrl、Shift、Alt等修饰键的组合,需要检测修饰键的状态
- 全局快捷键:注册系统级的全局快捷键,需要调用操作系统的API
其他输入设备
鼠标
- 工作原理:
- 机械鼠标:通过底部的滚球带动滚轮转动,检测位移
- 光电鼠标:通过LED发光照射桌面,光学传感器检测反射光的变化计算位移
- 激光鼠标:用激光代替LED,分辨率更高,在更多表面上都能使用
- 性能参数:
- DPI(Dots Per Inch):每英寸移动距离对应的像素点数,DPI越高鼠标越灵敏
- 回报率:鼠标每秒向计算机汇报位置的次数,单位Hz,回报率越高越流畅,游戏鼠标通常是1000Hz
- 按键事件:左键点击、右键点击、中键点击、滚轮滚动、侧键点击等
触摸板
- 工作原理:电容式触摸,检测手指的电容变化来识别位置和移动
- 支持手势:单指移动、双指滚动、双指缩放、三指切换窗口等
- 现在笔记本电脑的触摸板体验已经非常好,很多场景下可以代替鼠标使用
触摸屏
- 工作原理:
- 电阻式触摸屏:通过压力检测,精度高,但需要按压,不支持多点触控,现在已经很少用
- 电容式触摸屏:通过检测手指的电容变化,支持多点触控,是现在手机、平板的主流技术
- 触摸事件:触摸按下、触摸移动、触摸抬起、多点触摸等
其他输入设备
- 扫描仪、数位板、麦克风、摄像头、游戏手柄、体感设备等,都是常见的输入设备,原理各不相同,但处理流程都是类似的:硬件检测输入,转换为数字信号,发送给操作系统,操作系统生成对应的输入事件,提供给应用程序处理。
输入事件处理的最佳实践
1. 响应速度优先
输入事件的响应速度直接影响用户体验,输入事件的处理要尽可能快,不要在输入事件回调中做耗时操作,避免卡顿。
- 目标:键盘输入到屏幕显示的延迟要控制在100ms以内,超过这个时间用户就会感觉到明显的延迟
- 游戏场景要求更高,通常要控制在50ms以内
2. 合理处理输入事件
- 区分不同的事件类型,比如按键按下和字符输入是不同的事件,处理快捷键应该用按键按下事件,处理文本输入应该用字符输入事件
- 支持键盘导航,所有功能都应该可以通过键盘操作,提升无障碍体验
- 支持自定义快捷键,满足不同用户的使用习惯
3. 避免常见的坑
- 不要拦截系统级的快捷键(比如Alt+F4、Ctrl+Alt+Del等),会让用户感到困惑
- 处理好输入法的问题,中文输入法下不要触发快捷键
- 支持按键重复,也支持取消重复(比如游戏中的射击按键)
- 处理好触摸事件的冲突,比如滚动和点击的区分
思考问题
- 按下键盘上的A键,到屏幕上显示出字符“A“,中间经过了哪些处理流程?
- 机械键盘和薄膜键盘各有什么优缺点?你更喜欢用哪种?为什么?
- 为什么有时候同时按下多个按键会出现按键没反应的情况?怎么解决?
- 作为开发者,在处理用户输入的时候应该注意哪些问题来提升用户体验?
4.3 存储设备
存储设备是计算机中用来保存数据的设备,我们的程序、文件、操作系统都存储在存储设备中。存储设备的性能对系统整体性能影响非常大,特别是对于IO密集型的应用,比如数据库、文件服务器等。
存储层次结构
计算机系统中的存储设备是按照层次结构组织的,从上到下速度越来越慢,容量越来越大,价格越来越便宜:
┌─────────────────────────┐ 速度最快 容量最小 价格最高
│ CPU寄存器 │
├─────────────────────────┤
│ L1/L2/L3缓存 │
├─────────────────────────┤
│ 主存(内存) │
├─────────────────────────┤
│ SSD固态硬盘 │
├─────────────────────────┤
│ HDD机械硬盘 │
├─────────────────────────┤
│ 外接存储 │ (U盘、移动硬盘等)
├─────────────────────────┤
│ 离线存储 │ (磁带、光盘等)
└─────────────────────────┘ 速度最慢 容量最大 价格最低
这种层次结构的设计思想是利用局部性原理,将常用的数据放在速度快的存储中,不常用的数据放在速度慢的大容量存储中,在性能和成本之间取得平衡。
机械硬盘(HDD)
机械硬盘(Hard Disk Drive,HDD)是使用了几十年的传统存储设备,现在仍然在大容量存储场景中广泛使用。
工作原理
HDD内部是一个密封的腔体,包含多个高速旋转的磁盘片,磁头悬浮在磁盘片上方,通过移动磁头来读写磁盘上的数据:
- 磁盘片:涂有磁性材料的圆形盘片,数据存储在盘片的磁道上
- 磁道:盘片上的同心圆,每个磁道分为多个扇区,每个扇区通常是512字节或4KB
- 磁头:负责读写磁道上的数据,每个盘面对应一个磁头
- 主轴电机:带动磁盘片高速旋转,常见的转速有5400转/分钟、7200转/分钟、10000转/分钟、15000转/分钟
HDD读写过程
- 寻道时间:磁头移动到对应磁道的时间,通常是5-10ms
- 旋转延迟:等待要访问的扇区旋转到磁头下方的时间,7200转的硬盘平均旋转延迟是4.17ms
- 传输时间:实际读写数据的时间,取决于磁盘的传输速率
- 总访问时间 = 寻道时间 + 旋转延迟 + 传输时间,通常在10-20ms左右
性能参数
- 容量:通常是1TB-20TB
- 转速:5400/7200/10000/15000 RPM,转速越高性能越好,噪音和功耗也越高
- 平均寻道时间:5-10ms
- 连续读写带宽:100-250MB/s
- 随机IOPS:100-200,因为每次随机IO都需要寻道和旋转延迟,所以随机性能很差
优缺点
- 优点:容量大,价格便宜,每TB成本低,数据保存时间长,适合冷存储
- 缺点:随机性能差,延迟高,怕震动,噪音大,功耗高
固态硬盘(SSD)
固态硬盘(Solid State Drive,SSD)是使用闪存芯片作为存储介质的存储设备,没有机械结构,性能比HDD高很多。
工作原理
SSD的核心是NAND闪存芯片,数据存储在闪存单元中:
- 闪存单元:存储数据的基本单元,每个单元可以存储1bit(SLC)、2bit(MLC)、3bit(TLC)、4bit(QLC)数据
- 页(Page):读写的最小单位,通常是4KB/8KB/16KB
- 块(Block):擦除的最小单位,通常包含128-256个页,大小是512KB-2MB
- 闪存控制器:负责管理闪存芯片、实现磨损均衡、错误纠正、坏块管理等功能
SSD的关键特性
- 没有机械结构:不需要寻道和旋转延迟,随机访问延迟很低,通常是0.1ms左右
- 读写不对称:读速度比写速度快,擦除操作最慢,而且擦除必须按块进行
- 磨损均衡(Wear Leveling):闪存块有擦除次数限制(SLC约10万次,MLC约1万次,TLC约3000次,QLC约1000次),磨损均衡算法会平均分配擦除操作到各个块,延长SSD寿命
- 垃圾回收(Garbage Collection):修改数据时不能直接覆盖旧数据,需要先写到空闲页,旧页标记为无效,垃圾回收机制会回收无效页,整理出空闲块
- TRIM命令:操作系统通知SSD哪些页是已经删除的,可以提前回收,提升性能和寿命
性能参数
- 接口类型:
- SATA 3.0:最大带宽6Gbps≈550MB/s
- NVMe PCIe 3.0 x4:最大带宽32Gbps≈3.5GB/s
- NVMe PCIe 4.0 x4:最大带宽64Gbps≈7GB/s
- NVMe PCIe 5.0 x4:最大带宽128Gbps≈14GB/s
- 连续读写带宽:SATA SSD约500MB/s,NVMe SSD可以达到3-7GB/s
- 随机IOPS:普通SSD可以达到1万-10万IOPS,高端NVMe SSD可以达到百万级IOPS
- 延迟:随机读延迟约0.1ms,比HDD快100倍
- TBW(Total Bytes Written):总写入量,衡量SSD寿命的指标,例如500GB TLC SSD通常是300-600TBW
优缺点
- 优点:性能极高,特别是随机性能,延迟低,不怕震动,噪音小,功耗低
- 缺点:价格比HDD高,每TB成本是HDD的3-5倍,数据如果损坏恢复难度大,有写入寿命限制
SSD使用注意事项
- 不需要碎片整理,碎片整理会增加不必要的写入,缩短寿命,SSD的随机访问速度很快,碎片不影响性能
- 开启AHCI模式和TRIM命令,发挥SSD的最佳性能
- 不要满盘使用,保留10-20%的空闲空间,有利于垃圾回收和磨损均衡,提升性能和寿命
- 重要数据要备份,SSD一旦损坏,数据恢复难度比HDD大很多
其他存储设备
U盘/闪存盘
- 使用NAND闪存作为存储介质,通过USB接口连接
- 优点:体积小,便携,使用方便
- 缺点:性能较低,寿命短,容易损坏
- 适用场景:临时数据传输
光盘
- CD/DVD/蓝光光盘,通过激光读写数据
- 优点:成本低,保存时间长(理论上可以保存几十年)
- 缺点:容量小,速度慢,容易划伤
- 适用场景:数据归档、长期冷存储
磁带
- 使用磁性磁带存储数据
- 优点:容量极大(单盘可以达到几十TB),成本极低,保存时间长
- 缺点:顺序访问,随机访问性能极差,速度慢
- 适用场景:冷数据归档、备份,是大数据中心常用的冷存储介质
存储设备选型建议
系统盘/常用程序
- 优先选择NVMe SSD,性能最好,系统和程序启动速度快
- 容量建议512GB以上
数据盘/游戏盘
- 常用数据和游戏选择SSD,加载速度快
- 大文件、冷数据选择HDD,性价比高
服务器存储
- 高性能场景(数据库、缓存):选择高端NVMe SSD,获得高IOPS和低延迟
- 大容量存储(文件服务器、对象存储):选择HDD,性价比高
- 混合场景:使用SSD做缓存,HDD做存储,兼顾性能和成本
备份/归档
- 重要数据定期备份到HDD或者磁带,冷存储成本低,保存时间长
存储性能优化思路
- 缓存优化:利用内存、SSD作为缓存,将热点数据放在高速存储中,减少对慢速存储的访问
- 顺序IO优化:尽量将随机IO转换为顺序IO,提升HDD的性能,比如数据库的预写日志(WAL)就是顺序写
- 减少随机IO:合并小IO为大IO,减少IO次数
- 选择合适的RAID级别:RAID0提升性能,RAID1/RAID5/RAID6提升可靠性,RAID10兼顾性能和可靠性
- 分布式存储:大规模场景下使用分布式存储,将数据分散到多个节点,提升整体性能和容量
思考问题
- HDD和SSD的工作原理有什么不同?为什么SSD的随机性能比HDD高很多?
- 什么是磨损均衡和垃圾回收?它们对SSD的性能和寿命有什么影响?
- 存储层次结构的设计思想是什么?为什么需要多层存储?
- 如果你要搭建一个数据库服务器,你会怎么选择存储设备?为什么?
4.4 总线与接口技术
总线是计算机系统中各个组件之间传输数据的公共通道,接口是外部设备连接到计算机的端口。理解总线和接口技术,有助于我们了解不同设备之间的连接方式和性能瓶颈。
总线的基本概念
什么是总线
总线(Bus)是计算机内部各个组件之间传输数据的一组公共通信线路,各个组件通过总线连接在一起,互相传输数据。
早期的计算机各个组件之间是单独连接的,布线复杂,扩展性差。总线的出现简化了连接方式,只需要一组公共的线路,所有组件都连接到总线上,大大提升了扩展性。
总线的分类
按照功能和位置,总线可以分为几类:
- 片内总线:CPU芯片内部的总线,连接CPU内部的各个部件(寄存器、运算器、控制器等)
- 系统总线:主板上连接CPU、内存、控制器等核心组件的总线,也叫前端总线(FSB)
- IO总线:连接各种IO设备的总线,比如PCIe总线、SATA总线、USB总线等
总线的性能指标
- 位宽:总线一次能够传输的数据位数,比如32位、64位,位宽越大传输能力越强
- 频率:总线的工作频率,单位是MHz,频率越高速度越快
- 带宽:单位时间内能够传输的最大数据量,单位通常是GB/s
- 带宽计算公式:
带宽 = 频率 × 位宽 / 8 × 传输效率 - 例如PCIe 4.0 x16的带宽是16GT/s × 16 / 8 × 1(接近100%传输效率)= 32GB/s
- 带宽计算公式:
总线的工作方式
- 并行总线:同时传输多位数据,比如传统的PCI总线、ISA总线,优点是速度快,缺点是容易受干扰,频率做不高
- 串行总线:一次只传输1位数据,比如PCIe、USB、SATA,优点是抗干扰能力强,频率可以做很高,现在几乎所有新的总线都是串行总线
常见内部总线
1. PCIe总线(Peripheral Component Interconnect Express)
- 地位:目前主板上最核心的IO总线,几乎所有高速设备都连接到PCIe总线,包括显卡、SSD、网卡、USB控制器、SATA控制器等
- 版本与带宽:
版本 单通道带宽 x16带宽 发布时间 PCIe 1.0 250MB/s 4GB/s 2003年 PCIe 2.0 500MB/s 8GB/s 2007年 PCIe 3.0 985MB/s 15.75GB/s 2010年 PCIe 4.0 1.97GB/s 31.5GB/s 2017年 PCIe 5.0 3.94GB/s 63GB/s 2019年 PCIe 6.0 7.88GB/s 126GB/s 2022年 - 特点:
- 点对点串行传输,抗干扰能力强,频率高
- 可扩展通道数,x1、x4、x8、x16等,通道数越多带宽越大
- 全双工传输,上下行带宽相同
- 向下兼容,高版本的PCIe设备可以插在低版本的插槽上,只是速度会降低到低版本的水平
- 应用场景:显卡(通常x16)、NVMe SSD(通常x4)、高速网卡(x4/x8)等高速设备
2. SATA总线(Serial AT Attachment)
- 地位:专门用于连接存储设备的总线,是HDD、SATA SSD的标准接口
- 版本与带宽:
版本 带宽 实际传输速度 发布时间 SATA 1.0 1.5Gbps 150MB/s 2003年 SATA 2.0 3Gbps 300MB/s 2004年 SATA 3.0 6Gbps 600MB/s 2009年 - 特点:
- 专门为存储设备设计,协议简单,效率高
- 最多支持连接15个设备
- 支持热插拔
- 瓶颈:SATA 3.0的最大速度是600MB/s,已经成为SSD的性能瓶颈,现在越来越多的SSD改用NVMe PCIe接口
3. USB总线(Universal Serial Bus)
- 地位:最通用的外部总线,几乎所有外部设备都支持USB接口
- 版本与带宽:
版本 带宽 最大传输速度 发布时间 USB 1.0 12Mbps 1.5MB/s 1996年 USB 2.0 480Mbps 60MB/s 2000年 USB 3.0 (USB 3.1 Gen1) 5Gbps 500MB/s 2008年 USB 3.1 (USB 3.1 Gen2) 10Gbps 1000MB/s 2013年 USB 3.2 (USB 3.2 Gen2x2) 20Gbps 2000MB/s 2017年 USB4 2.0 80Gbps 8000MB/s 2022年 - 特点:
- 通用接口,支持几乎所有类型的外部设备
- 支持热插拔,即插即用
- 可以同时传输数据和供电,给外部设备供电
- 连接线最长可以到5米(USB 2.0)
- USB接口外形:
- Type-A:传统的USB接口,电脑上最常见
- Type-B:通常用于打印机、显示器等设备
- Micro USB:旧版安卓手机、小设备常用
- USB-C:新一代接口,正反可插,支持更高的功率和带宽,是未来的趋势
4. DMI总线(Direct Media Interface)
- Intel平台上连接CPU和PCH(南桥芯片)的总线,现在已经和PCIe整合,DMI 3.0的带宽是8GT/s x4,相当于PCIe 3.0 x4的带宽,约3.94GB/s。
常见外部接口
1. 雷电接口(Thunderbolt)
- 由Intel和苹果联合开发,现在最新的雷电3/4接口和USB-C接口外形相同,兼容USB-C设备
- 雷电3带宽40Gbps,雷电4带宽也是40Gbps但功能更完善,雷电5带宽80Gbps
- 可以同时传输数据、视频、供电,一个接口可以扩展出多个接口
- 常见于苹果电脑、高端PC和笔记本
2. 显示接口
- HDMI:最常见的视频接口,同时支持视频和音频传输,HDMI 2.1支持4K@144Hz、8K@60Hz
- DP(DisplayPort):主要用于电脑显示器,DP 2.0支持16K@60Hz,带宽比HDMI更高
- VGA:模拟接口,分辨率和刷新率有限,已经基本淘汰
- DVI:数字视频接口,不支持音频,也基本被淘汰
3. 网络接口
- RJ45以太网接口:最常见的有线网络接口,常见速率有10Mbps/100Mbps/1000Mbps(千兆)/10Gbps(万兆)
- 光口:光纤接口,用于高速网络,常见速率有10Gbps/25Gbps/40Gbps/100Gbps,用于服务器和数据中心
4. 音频接口
- 3.5mm音频接口:最常见的耳机/麦克风接口,现在很多手机已经取消了这个接口,改用USB-C或者蓝牙
- 光纤音频接口:S/PDIF接口,传输数字音频信号,音质更好
总线的发展趋势
- 串行化:所有新的总线都采用串行传输,替代传统的并行总线,抗干扰能力更强,频率更高
- 高带宽:总线带宽越来越高,PCIe已经发展到6.0,USB发展到USB4,能够满足越来越高的传输需求
- 接口统一化:USB-C/雷电接口正在统一各种外部接口,未来可能只需要一个接口就能连接所有外部设备
- 更高的集成度:越来越多的控制器被集成到CPU中,减少了中间总线的延迟,提升了性能
常见问题
为什么我的NVMe SSD速度跑不满?
检查你的SSD插在哪个PCIe插槽上,如果插在主板上由PCH提供的PCIe插槽,可能会和其他设备共享带宽,导致速度下降;应该插在CPU直连的PCIe插槽上,才能获得完整的带宽。还要确认主板和CPU支持对应的PCIe版本。
USB 3.0和USB 3.1有什么区别?
USB-IF组织为了营销把命名搞乱了:
- 原来的USB 3.0现在叫USB 3.1 Gen1,速度5Gbps
- 原来的USB 3.1现在叫USB 3.1 Gen2,速度10Gbps
- 原来的USB 3.2现在叫USB 3.2 Gen2x2,速度20Gbps 购买设备的时候要注意看实际的速率参数,不要被名字迷惑。
雷电3和USB-C有什么关系?
雷电3是协议,USB-C是接口外形,雷电3接口使用USB-C的外形,同时兼容USB-C设备,但是普通的USB-C接口不一定支持雷电3协议,只有标注了雷电标识的USB-C口才支持雷电协议,才能达到40Gbps的带宽。
思考问题
- 串行总线相比并行总线有什么优势?为什么现在新的总线都采用串行传输?
- PCIe总线的通道数是什么意思?x1、x4、x8、x16有什么区别?
- SATA 3.0的带宽是6Gbps,为什么实际传输速度只有约550MB/s?
- 为什么USB-C接口被认为是未来的统一接口?它有什么优势?
4.5 编程中的IO操作
我们在编程中会大量用到IO操作,比如读写文件、发送网络请求、访问数据库等,IO操作往往是程序性能的瓶颈。理解IO操作的底层原理,选择合适的IO模型,对提升程序性能至关重要。
IO操作的底层流程
我们以读取磁盘文件为例,看一下一次IO操作的完整流程:
- 应用程序发起read系统调用,请求读取文件内容
- 操作系统检查内核缓冲区中是否有需要的数据:
- 如果有,直接把内核缓冲区中的数据拷贝到应用程序的缓冲区,调用返回
- 如果没有,操作系统发起磁盘IO请求,告诉磁盘控制器要读取的数据位置
- 磁盘控制器通过DMA方式将磁盘上的数据拷贝到内核缓冲区
- 操作系统将内核缓冲区中的数据拷贝到应用程序的缓冲区
- read系统调用返回,应用程序拿到数据继续执行
可以看到,一次IO操作涉及两次数据拷贝:磁盘→内核缓冲区,内核缓冲区→应用程序缓冲区,还有操作系统和硬件的交互。
内核缓冲区的作用
操作系统会在内存中开辟一块内核缓冲区(Page Cache),缓存最近访问过的磁盘数据,减少磁盘IO次数:
- 读操作:如果要读的数据已经在Page Cache中,就不需要访问磁盘,直接从内存读取,速度快很多
- 写操作:应用程序写数据时,先写到Page Cache中,操作系统会在合适的时机批量刷到磁盘上,提升写性能
Page Cache的存在大大提升了IO性能,所以我们经常会看到第一次读文件很慢,第二次读就很快,因为数据已经被缓存到内存里了。
常见IO模型
我们在4.1节已经介绍了IO模型的分类,现在详细讲解每个模型的特点和适用场景。
1. 阻塞IO(BIO)
- 工作流程:应用程序发起IO调用后,线程被阻塞挂起,直到IO操作完成才返回继续执行
- 示例:Java的传统IO、Python的默认文件操作都是阻塞IO
# 阻塞IO示例,read调用会阻塞直到文件读取完成 with open('file.txt', 'r') as f: content = f.read() # 阻塞在这里 print(content) - 优点:编程模型简单,容易理解和开发
- 缺点:线程利用率低,高并发场景下需要大量线程,内存和上下文切换开销大
- 适用场景:并发量不高的场景,或者逻辑简单的程序
2. 非阻塞IO
- 工作流程:应用程序发起IO调用后,立即返回,如果数据还没准备好,返回错误,应用程序可以轮询检查数据是否准备好
- 示例:
import os f = os.open('file.txt', os.O_RDONLY | os.O_NONBLOCK) while True: try: content = os.read(f, 1024) break except BlockingIOError: # 数据还没准备好,做其他事情 print('waiting...') - 优点:线程不会阻塞,可以做其他事情,利用率高
- 缺点:轮询会消耗大量CPU资源,效率低
- 适用场景:很少单独使用,一般和IO多路复用结合使用
3. IO多路复用(IO Multiplexing)
- 工作原理:用一个线程监听多个IO请求的状态,当某个请求准备好后,再处理这个请求,不需要为每个请求开一个线程
- 常见实现:
- select:跨平台,监听的文件描述符数量有限(默认1024),性能随数量增加而下降
- poll:和select类似,但没有数量限制,性能还是随数量增加而下降
- epoll(Linux)/kqueue(BSD/macOS)/IOCP(Windows):性能高,没有数量限制,事件通知机制,是高性能服务器的首选
- 示例(Python selectors):
import selectors import socket sel = selectors.DefaultSelector() def accept(sock): conn, addr = sock.accept() conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, read) def read(conn): data = conn.recv(1024) if data: print(f'received {data}') conn.send(data) else: sel.unregister(conn) conn.close() sock = socket.socket() sock.bind(('localhost', 8080)) sock.listen(100) sock.setblocking(False) sel.register(sock, selectors.EVENT_READ, accept) while True: events = sel.select() for key, mask in events: callback = key.data callback(key.fileobj) - 优点:线程利用率极高,一个线程可以处理成千上万的连接,内存和上下文切换开销很小
- 缺点:编程复杂度比阻塞IO高,需要处理事件回调
- 适用场景:高并发网络服务器,是现在的主流方案,Nginx、Redis、Node.js等都用的是IO多路复用模型
4. 异步IO(AIO)
- 工作原理:应用程序发起IO请求后立即返回,操作系统完成整个IO操作(包括数据从磁盘/网卡拷贝到应用程序缓冲区)后,通知应用程序处理
- 和IO多路复用的区别:IO多路复用是通知你IO准备好了,你需要自己去读数据;异步IO是操作系统已经帮你把数据读好了,直接通知你用
- 优点:完全不阻塞线程,效率最高
- 缺点:编程复杂度高,支持的平台有限,Linux下的AIO实现不完善,Windows下的IOCP是成熟的异步IO实现
- 适用场景:对性能要求极高的场景,现在还不是很普及
IO密集型应用的优化方法
对于IO密集型的应用,比如Web服务器、数据库、文件服务器等,IO性能往往是瓶颈,这里分享一些优化方法:
1. 选择合适的IO模型
- 并发量不高:用阻塞IO,开发简单
- 高并发场景:用IO多路复用,是目前的主流方案
- 尽可能用成熟的网络框架,不要自己从头实现IO模型,比如Netty(Java)、libuv(C++)、Tornado(Python)等,这些框架已经把IO模型优化得很好了
2. 优化IO模式
- 批量IO:合并小的IO请求为大的IO请求,减少IO次数,比如批量写入、批量读取
- 顺序IO:尽量用顺序IO代替随机IO,特别是对于HDD,顺序IO的性能比随机IO高很多,比如数据库的WAL日志就是顺序写
- 异步IO:把同步IO改成异步IO,不要阻塞线程,提升并发能力
- 零拷贝(Zero Copy):减少数据拷贝的次数,比如Linux的sendfile系统调用,可以直接把文件数据发送到网络,不需要经过应用程序缓冲区,减少两次数据拷贝,大大提升文件传输性能,Nginx、Apache等都用了零拷贝技术
3. 利用缓存
- Page Cache:充分利用操作系统的Page Cache,热点数据尽量缓存到内存中,减少磁盘IO
- 应用层缓存:在应用层加一层缓存(Redis、Memcached等),缓存热点数据,减少对后端存储和数据库的访问
- 缓存预热:启动时把热点数据加载到缓存中,避免冷启动时大量请求穿透到数据库
4. 优化存储
- 用SSD代替HDD:对于随机IO多的场景,SSD的性能是HDD的上百倍,能极大提升性能
- RAID优化:根据场景选择合适的RAID级别,RAID0提升性能,RAID1提升可靠性,RAID10兼顾性能和可靠性
- 分布式存储:大规模场景下用分布式存储,把数据分散到多个节点,提升整体IO性能和容量
5. 网络IO优化
- 减少网络请求次数:合并请求,批量操作,减少RTT(往返时间)的影响
- 压缩数据:传输前压缩数据,减少传输的数据量
- 长连接复用:用长连接代替短连接,减少三次握手和四次挥手的开销
- 合适的缓冲区大小:设置合适的socket缓冲区大小,提升网络传输效率
- CDN加速:静态资源放到CDN上,让用户从最近的节点访问,降低延迟
常见的IO坑点
1. 忽略IO异常
IO操作很容易出现异常(比如磁盘满了、网络断了、文件不存在等),一定要处理IO异常,不要忽略错误,否则可能会导致数据丢失或者程序崩溃。
2. 资源泄漏
IO操作使用的资源(文件描述符、socket连接等)一定要记得关闭,最好用try-with-resources或者with语句,自动关闭资源,避免资源泄漏。
错误示例:
# 不好的写法,如果中间出现异常,文件不会关闭
f = open('file.txt', 'r')
content = f.read()
# 处理逻辑,如果这里抛出异常,f.close()不会执行
f.close()
正确示例:
# 好的写法,with语句会自动关闭文件
with open('file.txt', 'r') as f:
content = f.read()
3. 缓冲区问题
- 写入操作通常是写到缓冲区就返回了,不是立即写到磁盘/网络上,如果需要确保数据持久化,要调用flush或者fsync同步到磁盘
- 缓冲区大小设置要合适,太小会导致频繁IO,太大会浪费内存
4. 阻塞IO导致程序卡顿
UI程序中不要在主线程做IO操作,会导致界面卡顿,应该把IO操作放到后台线程执行,完成后再通知主线程更新UI。
思考问题
- 一次文件读取操作经过了哪些步骤?为什么内核缓冲区能提升IO性能?
- IO多路复用模型相比阻塞IO模型,为什么能支持更高的并发?
- 什么是零拷贝技术?它是怎么提升IO性能的?
- 你在开发中遇到过哪些IO相关的性能问题?是怎么优化的?
练习题与扩展阅读
练习题
基础题
- IO控制方式有哪几种?各有什么优缺点?分别适合什么场景?
- 衡量IO性能的三个核心指标(带宽、延迟、IOPS)分别是什么含义?分别适合衡量什么样的场景?
- HDD和SSD的工作原理有什么不同?各有什么优缺点?
- 常见的IO模型有哪几种?各有什么优缺点?分别适合什么场景?
- 什么是Page Cache?它对IO性能有什么影响?
实操题
- 用iostat(Linux)或者资源监视器(Windows)查看你电脑的磁盘IO情况,分别观察连续读写和随机读写的IOPS和带宽。
- 编写一个简单的文件复制程序,分别用阻塞IO和异步IO实现,对比两者的性能差异。
- 测试你的U盘、SSD、HDD的读写速度,对比不同存储设备的性能差异。
- 编写一个简单的TCP服务器,分别用BIO和IO多路复用模型实现,对比两者能支持的最大并发连接数。
思考题
- 为什么IO系统会成为计算机系统的性能瓶颈?未来有什么技术可能解决这个问题?
- 什么是局部性原理?它和存储层次结构有什么关系?
- 高并发网络服务器为什么都采用IO多路复用模型?它相比多线程阻塞IO模型有什么优势?
- 随着SSD的价格越来越低,未来HDD会被完全取代吗?为什么?
扩展阅读
书籍推荐
-
《UNIX环境高级编程》(第3版)
- 第14章高级IO、第16章网络IPC:套接字等章节详细讲解了UNIX系统下的IO操作
- 系统编程的经典著作,适合想深入学习系统IO的开发者
-
《高性能MySQL》
- 第7章MySQL高级特性、第8章优化服务器设置等章节讲解了存储和IO优化的内容
- 对于理解数据库的IO优化非常有帮助
-
《深入理解计算机系统》
- 第6章存储器层次结构、第10章系统级I/O等章节讲解了存储和IO的原理
- 帮助你从底层理解IO系统的工作原理
在线资源
-
- 知乎上的优质文章,详细讲解了Linux下的五种IO模型,图文并茂,通俗易懂
-
- 经典的C10K问题原文,讲解了如何让服务器支持1万个并发连接
- 是高性能服务器开发的必读文章
-
- MDN的Web性能优化指南,包含网络IO优化、资源加载优化等内容
- 适合前端开发者学习
-
- AWS官方的存储最佳实践白皮书
- 讲解了不同场景下的存储选型和优化方法,适合后端和运维人员阅读
工具推荐
- iostat / vmstat / dstat:Linux下的系统性能监控工具,可以查看CPU、内存、磁盘IO、网络等性能指标
- perf:Linux下的性能分析工具,可以分析程序的性能瓶颈,包括IO相关的瓶颈
- CrystalDiskMark:Windows下的磁盘性能测试工具,可以测试连续读写、4K随机读写等性能
- Wireshark:网络抓包工具,可以分析网络IO的详细情况,排查网络问题
- FIO:开源的IO性能测试工具,支持各种IO场景的测试,非常强大
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第5章:文件系统
学习目标
通过本章学习,你将能够:
- 理解文件系统的本质和核心功能
- 掌握常见文件系统(FAT32、NTFS、EXT4、Btrfs、XFS等)的特点和适用场景
- 理解文件操作的底层实现原理
- 掌握文件权限与安全的相关知识
- 学会在编程中正确高效地处理文件操作
章节简介
文件系统是操作系统中负责管理和存储文件的核心模块,我们日常使用的文件、目录背后都是文件系统在支撑。理解文件系统的原理,能够帮助我们更好地设计数据存储方案,优化文件操作性能,解决文件相关的问题。本章将从文件系统的本质讲起,介绍常见文件系统的特点,讲解文件操作的底层实现、权限与安全机制,以及编程中的文件操作最佳实践。
本章内容
- 文件系统的核心功能和抽象
- 文件、目录、inode的概念
- 虚拟文件系统(VFS)的作用
- 文件系统的通用架构
- Windows平台:FAT32、exFAT、NTFS
- Linux平台:EXT2/3/4、XFS、Btrfs、ZFS
- 移动/嵌入式平台:FAT32、exFAT、F2FS
- 不同文件系统的对比与选型建议
- 文件的创建、读取、写入、删除的底层流程
- 目录的实现原理
- 磁盘空间管理(块分配、空闲空间管理)
- 日志文件系统的工作原理
- Linux文件权限模型(用户、组、权限位、SUID/SGID/Sticky Bit)
- Windows ACL权限模型
- 文件系统级加密
- 常见文件安全问题与防范
- 文件操作的系统调用和标准库接口
- 大文件处理、稀疏文件、内存映射文件
- 文件操作的常见坑点与最佳实践
- 文件操作性能优化方法
学习建议
本章内容和系统编程、后端开发、运维都密切相关,建议结合实际使用的操作系统学习。如果你主要使用Windows,可以重点关注NTFS的相关内容;如果主要使用Linux,可以重点关注EXT4、XFS等Linux文件系统以及Linux的权限模型。学习时可以结合实际的文件操作问题,比如文件损坏、权限不足、磁盘空间不足等,加深理解。
难度:★★★☆☆
预计学习时间:3.5小时
5.1 文件系统的本质
文件系统是操作系统对存储设备上的数据进行组织和管理的一种机制,它抽象了底层存储设备的细节,让我们可以用“文件“和“目录“的直观方式来管理数据,而不需要关心数据在磁盘上的具体存储位置和方式。
文件系统的核心功能
文件系统主要提供以下核心功能:
- 数据的持久化存储:将数据存储在磁盘等非易失性存储设备上,断电后数据不会丢失
- 抽象与命名:用“文件“和“目录“的抽象概念来组织数据,用户可以通过文件名来访问数据,不需要关心底层的存储细节
- 访问控制:提供权限机制,控制不同用户对文件的访问权限
- 可靠性与容错:保证数据的完整性,出现故障时能够恢复数据
- 性能优化:通过缓存、预读、块分配等策略提升文件访问性能
文件系统的核心概念
1. 文件(File)
文件是对数据的抽象,是一组相关数据的集合,每个文件有一个文件名,用户通过文件名来访问文件。
- 文件的内容可以是任意的数据:文本、程序、图片、视频等等
- 文件通常有一些属性:文件名、大小、创建时间、修改时间、访问权限、所有者等
- 从操作系统的角度看,一切皆文件:不仅普通的数据是文件,目录、设备、管道、Socket等都被抽象为文件,用统一的接口来访问
2. 目录(Directory)
目录也叫文件夹,是用来管理和组织文件的容器,目录中存储了文件名和对应文件的引用。
- 目录是一种特殊的文件,它的内容是目录项的列表
- 目录可以嵌套,形成树形的目录结构
- 每个文件系统有一个根目录,是整个目录树的起点
3. inode(索引节点)
inode是类Unix文件系统中的核心概念,每个文件对应一个唯一的inode,存储了文件的元数据信息:
- 文件的权限(读、写、执行)
- 文件的所有者和所属组
- 文件的大小
- 文件的时间戳(创建时间、修改时间、访问时间)
- 文件数据块的存储位置指针
- 链接计数
注意:inode中不存储文件名,文件名是存储在目录中的,目录项将文件名和inode号关联起来。这也是为什么硬链接可以有多个文件名指向同一个文件的原因。
示例(Linux查看inode号):
$ ls -i test.txt
131463 test.txt # 131463就是inode号
4. 数据块
文件的实际内容存储在数据块中,数据块是文件系统分配磁盘空间的最小单位,通常是4KB大小。
- 一个大文件会占用多个数据块
- inode中存储了指向这些数据块的指针
- 为了支持大文件,通常会有直接指针、间接指针、二级间接指针、三级间接指针的设计
虚拟文件系统(VFS)
现代操作系统都支持多种不同的文件系统,为了给上层应用提供统一的接口,操作系统引入了虚拟文件系统(Virtual File System,VFS)的抽象层。
VFS的作用
- 提供统一的文件操作接口,上层应用不需要关心底层是什么文件系统,用同样的系统调用就可以操作不同的文件系统
- 可以透明地支持多种不同的文件系统,用户可以同时挂载EXT4、NTFS、FAT32等不同的文件系统,使用方式完全一样
- 抽象了文件系统的通用操作接口,新的文件系统只需要实现VFS定义的接口,就可以被操作系统支持
VFS的通用对象模型
VFS定义了四个核心对象:
- 超级块(Superblock):代表整个文件系统,存储文件系统的元信息(文件系统类型、块大小、inode数量等)
- inode对象:代表一个文件,存储文件的元数据
- 目录项对象(dentry):代表一个目录项,用来建立文件名和inode的映射
- 文件对象:代表一个进程打开的文件,存储文件和进程交互的相关信息
有了VFS这层抽象,上层应用调用open()、read()、write()等系统调用时,会先调用VFS的通用接口,VFS再根据文件所在的实际文件系统,调用对应文件系统的实现函数。
文件系统的通用架构
一个典型的文件系统从上到下分为几个层次:
┌─────────────────────────┐
│ 系统调用接口层 │ → 提供open/read/write等系统调用
├─────────────────────────┤
│ VFS虚拟文件系统层 │ → 统一接口,适配不同的文件系统
├─────────────────────────┤
│ 具体文件系统实现层 │ → EXT4/NTFS/FAT32等具体文件系统的实现
├─────────────────────────┤
│ 页缓存层 │ → 缓存文件数据,提升性能
├─────────────────────────┤
│ 块设备驱动层 │ → 与磁盘等存储设备交互
└─────────────────────────┘
文件系统的设计权衡
文件系统的设计需要在多个因素之间进行权衡:
- 性能 vs 可靠性:日志文件系统会记录操作日志,提升可靠性,但会带来一定的性能开销
- 空间利用率 vs 性能:小块可以提升空间利用率,但会增加元数据开销,降低性能
- 可扩展性 vs 复杂度:支持更大的容量和更多的文件会增加文件系统的复杂度
- 特性丰富度 vs 兼容性:更多的特性会带来更好的功能,但可能和其他系统不兼容
不同的文件系统会根据目标场景进行不同的权衡,比如面向桌面的文件系统会更注重性能和易用性,面向服务器的文件系统会更注重可靠性和扩展性,面向嵌入式的文件系统会更注重资源占用和响应速度。
思考问题
- 为什么我们可以用同样的方式操作硬盘上的文件、U盘上的文件和网络共享文件?这背后是什么在起作用?
- inode中为什么不存储文件名?文件名存在哪里?这样的设计有什么好处?
- 虚拟文件系统VFS的作用是什么?如果没有VFS会怎么样?
- 同一个文件可以有多个文件名吗?为什么?
5.2 常见文件系统
不同的操作系统和场景下有不同的文件系统,每种文件系统都有自己的特点和适用场景。这一节我们介绍常见的文件系统,帮助你在不同场景下选择合适的文件系统。
Windows平台常见文件系统
1. FAT32
- 全称:File Allocation Table 32
- 发布时间:1996年
- 特点:
- 结构简单,兼容性极好,几乎所有操作系统和设备都支持
- 最大支持2TB分区,单个文件最大4GB
- 没有日志,没有权限控制,不支持压缩、加密等高级特性
- 容易产生文件碎片,性能较差
- 适用场景:U盘、内存卡等需要跨平台使用的小容量存储设备,因为兼容性好
- 缺点:单个文件不能超过4GB,不适合大文件存储
2. exFAT
- 全称:Extended File Allocation Table
- 发布时间:2006年,微软开发
- 特点:
- 是FAT32的改进版,解决了FAT32的很多限制
- 最大支持128PB分区,单个文件最大16EB(几乎没有限制)
- 仍然没有日志和完善的权限控制
- 兼容性比FAT32略差,但现在主流操作系统(Windows、macOS、Linux、Android、iOS)都已经支持
- 适用场景:大容量U盘、移动硬盘,需要跨平台传输大文件的场景,是现在移动存储的首选格式
- 优点:兼顾兼容性和大文件支持,比NTFS更适合移动存储,因为没有日志,对闪存的写入更少,寿命更长
3. NTFS
- 全称:New Technology File System
- 发布时间:1993年,是Windows的默认文件系统
- 特点:
- 日志型文件系统,可靠性高, crash 后容易恢复
- 支持权限控制、压缩、加密、磁盘配额、稀疏文件、硬链接、软链接等高级特性
- 最大支持256TB分区,单个文件最大16TB
- 性能稳定,适合大容量存储
- 适用场景:Windows系统的系统盘和数据盘,是Windows平台的首选
- 缺点:
- 兼容性一般,macOS默认只能读不能写,Linux需要安装驱动才能支持
- 有日志写入,对U盘等闪存设备的寿命有一定影响,不适合移动存储
Linux平台常见文件系统
1. EXT系列(EXT2/EXT3/EXT4)
- EXT2:1993年发布,非日志型文件系统,性能不错,但可靠性差,crash后容易丢失数据,现在基本不用了
- EXT3:2001年发布,在EXT2的基础上增加了日志功能,可靠性大大提升,最大支持32TB分区,单个文件最大2TB
- EXT4:2008年发布,是EXT3的改进版,也是现在大多数Linux发行版的默认文件系统
- 最大支持1EB分区,单个文件最大16TB
- 支持盘区(Extent)分配,减少文件碎片,提升大文件性能
- 支持延迟分配、快速fsck、日志校验等特性,性能和可靠性都很好
- 非常稳定,经过了十几年的大量使用验证
- 适用场景:通用场景,Linux系统盘、数据盘都可以用,是Linux下的万金油文件系统
2. XFS
- 开发公司:SGI,1994年发布,现在是RHEL/CentOS系列的默认文件系统
- 特点:
- 高性能的日志型文件系统,特别擅长处理大文件和大容量存储
- 最大支持8EB分区,单个文件最大8EB
- 支持在线扩容,非常适合服务器和大数据场景
- 对于大量小文件的场景性能略逊于EXT4
- 适用场景:服务器、大数据存储、大容量磁盘阵列,适合大文件多的场景
3. Btrfs(B-tree File System)
- 开发公司:Oracle,2007年发布,是Linux下的新一代写时复制文件系统
- 特点:
- 写时复制(CoW)机制,数据更安全
- 支持快照、子卷、透明压缩、数据校验、RAID、在线扩容/缩容等高级特性
- 最大支持16EB分区
- 性能比EXT4略低,但特性非常丰富
- 适用场景:需要快照、数据保护、多磁盘管理的场景,是未来Linux文件系统的发展方向之一,现在很多NAS系统已经采用Btrfs
4. ZFS
- 开发公司:Sun,2005年发布,是一个企业级的文件系统+逻辑卷管理器
- 特点:
- 功能极其丰富,支持快照、克隆、RAID-Z、数据校验、压缩、重复数据删除等高级特性
- 数据完整性极高,有“写时复制+校验和“机制,几乎不可能出现数据静默损坏
- 最大支持256ZB分区
- 内存占用较高
- 适用场景:企业级存储、NAS、数据备份等对数据可靠性要求极高的场景
- 注意:因为许可证问题,Linux主线内核没有包含ZFS,需要单独安装,FreeBSD和macOS原生支持ZFS
移动/嵌入式平台常见文件系统
1. F2FS(Flash Friendly File System)
- 开发公司:三星,2012年发布
- 特点:
- 专门为闪存设备(SSD、eMMC、UFS)设计的文件系统
- 针对闪存的特性优化,磨损均衡更好,性能更高,寿命更长
- 日志结构的文件系统,特别适合闪存
- 适用场景:安卓手机、SSD、嵌入式设备的闪存存储,现在很多安卓手机的默认文件系统就是F2FS
2. YAFFS2 / JFFS2
- 专门为NAND闪存设计的嵌入式文件系统,适合小型嵌入式设备
- 现在已经逐渐被F2FS取代
其他文件系统
1. HFS+ / APFS
- HFS+是苹果macOS旧版的默认文件系统,现在已经被APFS取代
- APFS是苹果2017年发布的新一代文件系统,针对SSD优化,支持快照、加密、空间共享等特性,是现在macOS和iOS的默认文件系统
2. NFS / SMB / CIFS
- 网络文件系统,用于网络共享,不是本地磁盘的文件系统
- NFS主要用于Unix/Linux系统之间的共享
- SMB/CIFS主要用于Windows系统之间的共享,也支持跨平台共享
文件系统对比与选型建议
| 文件系统 | 最大分区 | 最大文件 | 日志 | 跨平台兼容性 | 适用场景 |
|---|---|---|---|---|---|
| FAT32 | 2TB | 4GB | 否 | 极好 | 小容量U盘,老设备兼容 |
| exFAT | 128PB | 16EB | 否 | 好 | 大容量U盘、移动硬盘,跨平台大文件传输 |
| NTFS | 256TB | 16TB | 是 | 一般 | Windows系统盘、数据盘 |
| EXT4 | 1EB | 16TB | 是 | 一般 | Linux通用场景,系统盘、数据盘 |
| XFS | 8EB | 8EB | 是 | 一般 | Linux服务器、大数据存储,大文件场景 |
| Btrfs | 16EB | 16EB | 是 | 差 | 需要快照、高级特性的Linux场景,NAS |
| ZFS | 256ZB | 16EB | 是 | 一般 | 企业级存储,对可靠性要求极高的场景 |
| F2FS | 16TB | 4EB | 是 | 一般 | 闪存设备,手机、SSD |
| APFS | 8EB | 8EB | 是 | 差 | 苹果macOS/iOS设备 |
选型建议
- Windows桌面:系统盘和数据盘都选NTFS,没问题
- Linux桌面/服务器通用场景:选EXT4,稳定可靠,兼容性好
- Linux服务器大数据/大容量存储:选XFS,适合大文件和大容量
- 移动存储(U盘/移动硬盘):
- 小于32G,不需要存大于4G的文件:FAT32,兼容性最好
- 大于32G,或者需要存大文件:exFAT,兼顾兼容性和大文件支持
- 只在Windows上用:NTFS也可以
- 闪存设备(手机/SSD):优先选针对闪存优化的文件系统,比如F2FS、APFS
- 企业级存储/数据备份:选ZFS或者Btrfs,数据可靠性高,支持快照等高级特性
- 跨平台共享:选exFAT或者用网络文件共享
常见问题
我的U盘应该用什么格式?
优先选exFAT,现在新的设备都支持,可以存储大于4G的文件,比NTFS更适合U盘,写入更少寿命更长。如果需要兼容非常老的设备,再考虑FAT32。
为什么Linux不用NTFS?
NTFS是微软的专有文件系统,Linux下的NTFS驱动是反向工程实现的,性能和稳定性不如原生的EXT4/XFS,而且NTFS的权限模型和Linux不兼容,所以Linux一般不用NTFS作为本地文件系统。
日志文件系统有什么好处?
日志文件系统会在修改文件内容之前,先把要做的修改写到日志区域,然后再执行实际的修改。如果中途断电或者崩溃,重启后可以根据日志恢复,不会导致文件系统损坏,大大提升了可靠性。现在主流的文件系统都是日志型文件系统。
思考问题
- 为什么移动存储设备通常用FAT32或者exFAT,而不用NTFS或者EXT4?
- EXT4相比EXT3有哪些改进?
- 针对SSD优化的文件系统和传统文件系统有什么不同?
- 如果你要搭建一个文件存储服务器,你会选择什么文件系统?为什么?
5.3 文件操作原理
我们平时对文件的操作(创建、读取、写入、删除)看起来很简单,但背后涉及到文件系统的很多复杂处理。理解这些操作的底层流程,有助于我们写出更高效的文件操作代码,排查文件相关的问题。
磁盘空间管理
在讲文件操作之前,我们需要先了解文件系统是如何管理磁盘空间的。
块(Block)
文件系统把磁盘划分为固定大小的块(Block),作为磁盘空间分配的最小单位,通常块的大小是4KB(可以格式化时设置)。
- 即使一个文件只有1字节,也会占用一个完整的块
- 大文件会占用多个块
- 块大小的选择是空间利用率和性能之间的权衡:块太小会增加元数据开销,降低性能;块太大会浪费空间(内部碎片)
空闲空间管理
文件系统需要记录哪些块是空闲的,可以分配给新的文件,常见的空闲空间管理方式:
- 空闲表:记录所有空闲块的起始地址和数量,适合磁盘空间不大的情况
- 空闲链表:把所有空闲块链成一个链表,分配时从链表头取,释放时插回链表,缺点是分配多个连续块时效率低
- 位图(Bitmap):用一个二进制位表示一个块是否空闲,0表示空闲,1表示已占用,位的位置对应块的编号。这种方式实现简单,查找连续块效率高,是现在主流文件系统常用的方式。
- 组描述符:EXT系列文件系统把磁盘分成多个块组,每个组有自己的位图和inode表,提升分配效率。
块分配策略
文件系统分配块时尽量让同一个文件的块连续存储,提升读取性能:
- 连续分配:给文件分配连续的块,性能最好,但容易产生外部碎片,文件大小不能动态增长,现在基本不用了
- 链式分配:每个块里存指向下一个块的指针,不需要连续空间,但随机访问性能差,可靠性低,一个块损坏后面的都访问不到
- 索引分配:inode里存储指向数据块的指针,是现在主流的分配方式。比如EXT4的inode里有12个直接指针(指向前12个数据块),1个间接指针(指向一个索引块,里面存数据块指针),1个二级间接指针,1个三级间接指针,可以支持非常大的文件。
- 盘区(Extent)分配:EXT4、XFS等现代文件系统采用Extent分配,一个Extent是一段连续的块,用起始块号和长度表示,大大减少了元数据的大小,提升了大文件的性能。
常见文件操作的底层流程
我们以EXT4文件系统为例,看一下常见文件操作的底层流程。
1. 文件读取流程
当我们调用read()读取一个文件时,大致流程如下:
- 进程调用read()系统调用,传入文件描述符、缓冲区地址、读取长度
- 操作系统根据文件描述符找到对应的文件对象,获取文件的inode
- 根据要读取的偏移量,计算对应的块号
- 检查Page Cache中是否有对应的缓存页:
- 如果有,直接把缓存中的数据拷贝到用户缓冲区,返回
- 如果没有,发起磁盘IO请求,从磁盘读取对应的块到Page Cache
- 把Page Cache中的数据拷贝到用户缓冲区
- read()系统调用返回,读取完成
2. 文件写入流程
当我们调用write()写入文件时,大致流程如下:
- 进程调用write()系统调用,传入文件描述符、缓冲区地址、写入长度
- 操作系统找到对应的文件和inode
- 根据写入的偏移量,分配需要的数据块(如果还没有分配的话)
- 把用户缓冲区中的数据拷贝到Page Cache对应的页中
- 标记这个页为脏页(Dirty Page),write()调用直接返回
- 操作系统会在合适的时机(比如脏页比例达到阈值、sync调用、定时刷脏)把脏页回写到磁盘上
注意:默认情况下write()返回后,数据不一定已经写到磁盘上,只是写到了Page Cache中,如果此时断电,可能会丢失数据。如果需要确保数据持久化到磁盘,需要调用fsync()或者fdatasync()系统调用。
3. 文件创建流程
当我们创建一个新文件时:
- 进程调用open()系统调用,传入文件路径和O_CREAT标志
- 操作系统解析文件路径,找到父目录的inode
- 检查权限,确认是否有写入权限
- 分配一个新的inode,初始化inode的元信息(权限、所有者、时间戳等)
- 在父目录中添加一个新的目录项,记录文件名和新的inode号
- 返回文件描述符,创建完成
4. 文件删除流程
当我们删除一个文件时(rm命令或者unlink()系统调用):
- 操作系统找到文件的inode,将链接计数减1
- 删除父目录中的对应目录项
- 如果链接计数变为0,说明没有任何文件名指向这个inode了,释放这个inode和对应的数据块,标记为空闲
- 如果还有其他硬链接指向这个inode,文件内容不会被删除,直到所有链接都被删除
注意:删除文件只是删除了目录项和减少了链接计数,实际的数据块还在磁盘上,只是被标记为空闲,直到被新的数据覆盖。这也是为什么删除的文件可以被数据恢复软件恢复的原因。
5. 目录的实现
目录是一种特殊的文件,它的内容是目录项的列表,每个目录项包含:
- inode号
- 文件名
- 其他元信息(文件类型等)
当我们遍历目录时,实际上就是读取目录文件的内容,解析出每个目录项。
硬链接和软链接的区别
- 硬链接:和原文件指向同一个inode,链接计数加1,删除原文件不会影响硬链接,不能跨文件系统,不能链接目录
- 软链接(符号链接):是一个独立的文件,内容是指向的目标文件的路径,有自己的inode,删除原文件软链接就失效了,可以跨文件系统,可以链接目录,支持相对路径和绝对路径
日志文件系统的工作原理
传统的非日志文件系统(比如EXT2)在修改文件时,需要修改inode、数据块、位图等多个元数据,如果修改中途断电,可能会导致文件系统不一致,比如inode已经分配了,但位图没有标记为已占用,或者反过来,导致数据丢失或者空间泄漏。
日志文件系统(Journaling File System)解决了这个问题:
- 在修改元数据和数据之前,先把要做的修改作为一个事务写到日志区域
- 日志写入完成后,再执行实际的修改操作
- 修改完成后,在日志中标记这个事务已完成
- 定期清理已经完成的日志
如果中途断电,重启后文件系统会检查日志:
- 如果事务已经完整写入日志,即使实际修改没完成,也可以根据日志重做,保证修改完成
- 如果事务没有完整写入日志,就忽略这个事务,不会修改实际的文件系统
这样就保证了文件系统的一致性,不会出现文件系统损坏的情况,fsck也会快很多。
日志的三种模式
- 日志模式(Journal):所有元数据和数据都先写入日志,再写入实际位置,最安全,但性能最差,因为所有数据都要写两次
- 顺序模式(Ordered):只记录元数据的日志,保证数据在元数据之前写入,是EXT3/EXT4的默认模式,兼顾安全性和性能
- 写回模式(Writeback):只记录元数据的日志,不保证数据的写入顺序,性能最好,但安全性最差,可能会出现旧数据出现在文件中的情况
文件系统性能优化要点
- 选择合适的块大小:大文件多的场景用大一点的块,小文件多的场景用小一点的块,兼顾性能和空间利用率
- 充分利用Page Cache:操作系统会自动缓存热数据,尽量避免直接IO绕过缓存,会严重降低性能
- 批量写入:合并小的写入操作,减少系统调用次数和IO次数
- 顺序读写优先:尽量避免随机读写,顺序读写的性能比随机读写高很多,特别是对于HDD
- 使用正确的刷盘策略:不需要立即持久化的不要频繁调用fsync,会严重影响性能;需要持久化的要记得调用fsync,避免数据丢失
- 预留空闲空间:文件系统快满的时候性能会急剧下降,至少保留10-20%的空闲空间
- 定期整理碎片:HDD可以定期碎片整理提升性能,SSD不需要碎片整理
思考问题
- 为什么删除文件的速度很快,几乎瞬间就能完成?删除的文件内容还在磁盘上吗?
- write()系统调用返回后,数据已经写到磁盘上了吗?如果此时断电会怎么样?
- 日志文件系统是怎么保证断电后文件系统不会损坏的?
- 硬链接和软链接有什么区别?分别适合什么场景?
5.4 权限与安全
文件系统的权限与安全机制是操作系统安全的第一道防线,它控制着不同用户对文件的访问权限,防止未授权的访问和数据泄露。理解文件权限机制,对于保证系统和数据的安全至关重要。
Linux文件权限模型
Linux系统继承了Unix的文件权限模型,简单高效,是服务器系统权限控制的基础。
三类用户
Linux将文件的访问者分为三类:
- 所有者(User):文件的创建者,默认对文件有完全控制权
- 所属组(Group):文件所属的用户组,组内的所有用户对文件有相同的权限
- 其他用户(Others):既不是所有者也不在所属组里的其他用户
三种基本权限
每个用户类别有三种基本权限:
| 权限 | 表示字符 | 对文件的作用 | 对目录的作用 |
|---|---|---|---|
| 读权限 | r | 可以读取文件内容 | 可以列出目录下的文件列表 |
| 写权限 | w | 可以修改、删除文件内容 | 可以在目录下创建、删除、重命名文件 |
| 执行权限 | x | 可以执行文件(程序、脚本等) | 可以进入目录(cd),访问目录下的文件 |
权限表示方法
-
字符表示法:用10个字符表示,例如
-rwxr-xr--- 第1位:文件类型,
-表示普通文件,d表示目录,l表示软链接,b表示块设备,c表示字符设备 - 第2-4位:所有者的权限(rwx)
- 第5-7位:所属组的权限(r-x)
- 第8-10位:其他用户的权限(r–)
- 第1位:文件类型,
-
数字表示法:用三位八进制数表示,r=4,w=2,x=1,相加得到权限值
- rwx = 4+2+1 = 7
- r-x = 4+0+1 = 5
- r– = 4+0+0 = 4
- 例如
rwxr-xr--对应的数字权限是754
示例:
$ ls -l test.txt
-rw-r--r-- 1 user group 1024 Mar 12 10:00 test.txt
这个文件的权限是644:所有者有读写权限,所属组和其他用户只有读权限。
特殊权限位
除了基本权限外,还有三个特殊权限位:
1. SUID(Set User ID)
- 作用于可执行文件,用户执行该文件时,会临时获得文件所有者的权限
- 表示:所有者的执行权限位变为
s,例如-rwsr-xr-x,数字表示为4755(前面加4) - 典型例子:
passwd命令,普通用户需要修改密码,需要写入/etc/shadow文件,而普通用户没有写权限,passwd命令设置了SUID位,执行时临时获得root权限,就可以修改shadow文件了
注意:SUID权限非常危险,不要随便给文件设置SUID,特别是可执行文件,容易被利用提权。
2. SGID(Set Group ID)
- 作用于可执行文件时,用户执行文件时会临时获得文件所属组的权限
- 作用于目录时,在该目录下创建的文件会继承目录的所属组,而不是创建者的默认组
- 表示:所属组的执行权限位变为
s,例如-rwxr-sr-x,数字表示为2755(前面加2) - 适用场景:团队共享目录,确保团队成员创建的文件都属于同一个组,方便共享
3. Sticky Bit(粘滞位)
- 作用于目录,只有文件的所有者和root才能删除目录下的文件,即使其他用户有写权限也不能删除别人的文件
- 表示:其他用户的执行权限位变为
t,例如drwxrwxrwt,数字表示为1777(前面加1) - 典型例子:
/tmp目录,所有人都可以在/tmp下创建文件,但不能删除别人的文件
修改权限的命令
chmod:修改文件权限,chmod 755 file,或者chmod u+x file给所有者加执行权限chown:修改文件所有者和所属组,chown user:group filechgrp:修改文件所属组,chgrp group file
Windows ACL权限模型
Windows使用ACL(访问控制列表)的权限模型,比Linux的权限模型更灵活,支持更细粒度的权限控制。
ACL的组成
每个文件或目录都有一个ACL,包含多个访问控制项(ACE),每个ACE定义了一个用户或组对该对象的访问权限。
常见的权限
- 完全控制:对文件有所有权限,包括修改权限、删除文件等
- 修改:可以读取、修改、删除文件
- 读取和执行:可以读取文件内容和执行文件
- 列出文件夹内容:可以查看目录下的文件列表
- 读取:只能读取文件内容
- 写入:可以修改文件内容,或者在目录下创建文件
- 特殊权限:更细粒度的权限控制,比如删除子文件夹和文件、读取权限、更改权限等
权限继承
默认情况下,子目录和文件会继承父目录的权限,也可以关闭继承,单独设置权限。
有效权限
用户的最终权限是所有分配给用户的权限、用户所在组的权限的合集,拒绝权限优先于允许权限。
文件系统级加密
除了权限控制外,文件系统还支持加密,即使磁盘被物理偷走,没有密钥也无法获取数据。
常见的加密方式
-
全磁盘加密(FDE):加密整个磁盘,系统启动时需要输入密码才能解密,所有数据写入磁盘时自动加密,读取时自动解密。
- Windows:BitLocker
- Linux:LUKS(Linux Unified Key Setup)
- macOS:FileVault
- 优点:保护整个磁盘的数据,即使磁盘丢失数据也不会泄露
- 缺点:系统启动需要密码,性能有轻微损失
-
文件级加密:只加密特定的文件或目录,需要访问时单独解密。
- Windows:EFS(Encrypting File System)
- Linux:eCryptfs、fscrypt
- 优点:更灵活,可以只加密敏感数据
- 缺点:需要用户手动管理加密文件,容易遗漏
-
应用层加密:由应用程序自己实现加密,比如压缩包加密、文档密码保护等,不依赖文件系统。
加密注意事项
- 一定要备份密钥和恢复密码,如果密钥丢失,数据就永远无法恢复了
- 加密会带来一定的性能开销,对于IO密集型应用要评估性能影响
- 全磁盘加密不会影响系统正常使用,对用户是透明的,建议笔记本电脑都开启全磁盘加密,防止电脑丢失导致数据泄露
常见文件安全问题与防范
1. 权限配置错误
- 问题:文件权限设置过大,比如配置文件设置为所有用户可写,或者日志文件设置为所有用户可读,导致敏感信息泄露
- 防范:
- 遵循最小权限原则,只给需要的用户分配最小必要的权限
- 配置文件、密钥文件等敏感文件权限设置为600,只有所有者能读写
- 可执行文件不要随便设置SUID/SGID权限
- 定期检查系统文件的权限,发现异常及时修复
2. 符号链接攻击
- 问题:攻击者创建软链接指向敏感文件,利用程序的文件操作漏洞,读写敏感文件
- 防范:
- 处理文件时检查文件类型,不要跟随符号链接
- 操作文件前验证文件的所有者和权限
- 使用安全的文件操作函数,避免路径穿越问题
3. 路径穿越攻击
- 问题:攻击者在路径中使用
../等字符,访问到程序限制目录之外的文件,比如读取../../etc/passwd文件 - 防范:
- 对用户传入的文件路径进行严格校验,过滤特殊字符
- 限制程序的文件访问根目录(chroot)
- 对路径进行规范化处理,解析出真实路径后再判断是否在允许的目录内
4. 临时文件安全问题
- 问题:临时文件权限设置不当,或者使用可预测的临时文件名,被攻击者利用修改或读取数据
- 防范:
- 使用系统提供的安全的临时文件创建函数,自动生成随机文件名,设置合适的权限
- 临时文件使用完后及时删除
- 临时目录设置Sticky Bit,防止别人删除你的临时文件
5. 数据残留问题
- 问题:删除文件只是删除了目录项,数据还在磁盘上,可能被数据恢复软件恢复
- 防范:
- 要彻底删除敏感文件,使用shred等工具覆盖文件内容后再删除
- SSD的磨损均衡机制会导致覆盖不一定能完全删除数据,对于SSD建议使用加密,直接丢弃密钥比删除数据更安全
- 淘汰旧磁盘时,要进行物理销毁或者全盘擦除
开发中的安全最佳实践
- 处理文件路径时一定要做校验,防止路径穿越
- 不要使用硬编码的文件路径,使用相对路径或者可配置的路径
- 创建文件时设置合理的权限,不要默认给所有用户读写权限
- 敏感数据不要明文存在文件中,要加密存储
- 操作文件时处理好异常,避免因为异常导致文件权限设置错误或者数据损坏
- 不要相信文件的扩展名,要验证文件的实际内容,防止上传恶意文件
思考问题
- Linux系统中,一个文件的权限是755,具体表示什么含义?适合什么类型的文件?
- SUID权限有什么作用?为什么说SUID权限很危险?
- Sticky Bit权限的作用是什么?适合用在什么场景?
- 要防止删除的文件被恢复,有哪些方法?
5.5 编程中的文件操作
文件操作是编程中最常见的操作之一,我们几乎每天都在和文件打交道。但文件操作有很多容易踩的坑,理解底层原理,遵循最佳实践,能帮助我们写出更高效、更安全的文件操作代码。
文件操作的基本接口
文件操作的接口通常分为两层:系统调用层和标准库层。
系统调用
操作系统提供的底层文件操作接口,直接和内核交互,不同操作系统的系统调用略有不同:
- Unix/Linux:
open(),read(),write(),close(),lseek(),fsync(),mmap()等 - Windows:
CreateFile(),ReadFile(),WriteFile(),CloseHandle()等
系统调用是最底层的接口,功能强大,但使用起来比较繁琐,而且不跨平台。
标准库接口
编程语言的标准库对系统调用进行了封装,提供了跨平台的、更易用的接口:
- C语言:
fopen(),fread(),fwrite(),fclose(),fseek()等(stdio库) - Python:
open()函数,文件对象的read(),write()等方法 - Java:
FileInputStream,FileOutputStream,BufferedReader等类 - Go:
os包下的Open(),Read(),Write()等函数
标准库接口通常带缓冲,比直接调用系统调用性能更好,而且跨平台,是我们日常开发的首选。
高级文件操作特性
1. 内存映射文件(mmap)
内存映射文件是一种高级的文件操作方式,它把文件的内容映射到进程的虚拟地址空间,用户可以像访问内存一样访问文件,不需要调用read/write系统调用。
工作原理:
- 把文件的页直接映射到进程的地址空间
- 访问映射的内存时,操作系统会自动把对应的文件页加载到内存
- 修改映射的内存时,操作系统会在合适的时机把修改写回磁盘
优点:
- 访问大文件速度快,不需要频繁的系统调用和数据拷贝
- 可以随机访问文件,像操作数组一样操作文件
- 可以实现进程间共享内存
- 比read/write性能更好,减少了内核态和用户态之间的数据拷贝
缺点:
- 不适合非常小的文件,页对齐会浪费空间
- 映射长度受限于虚拟地址空间大小
- 出现IO错误时会收到SIGBUS信号,需要特殊处理
适用场景:大文件处理、随机访问、进程间共享数据。
示例(Python):
import mmap
with open('large_file.bin', 'r+b') as f:
# 映射整个文件
mm = mmap.mmap(f.fileno(), length=0, access=mmap.ACCESS_WRITE)
# 像访问字节数组一样访问文件
print(mm[0:10]) # 读取前10字节
mm[0:5] = b'hello' # 修改前5字节
mm.flush() # 手动刷回磁盘
mm.close()
2. 稀疏文件
稀疏文件是一种特殊的文件,文件中大部分内容都是0,文件系统不会为全0的块分配实际的磁盘空间,节省磁盘空间。
特点:
- 显示的文件大小是逻辑大小,实际占用的磁盘空间很小
- 只有当写入非0数据时才会分配实际的磁盘块
- 很多下载工具、虚拟机镜像、数据库文件都使用稀疏文件节省空间
示例: 创建一个1GB的稀疏文件,实际只占用几KB磁盘空间:
$ dd if=/dev/zero of=sparse_file bs=1G count=0 seek=1
$ ls -lh sparse_file # 显示大小1G
-rw-r--r-- 1 user user 1.0G Mar 12 11:00 sparse_file
$ du -h sparse_file # 实际占用4.0K
4.0K sparse_file
3. 直接IO(Direct IO)
直接IO绕过操作系统的Page Cache,直接和磁盘交互,数据不经过内核缓冲区。
适用场景:
- 数据库等有自己缓存机制的应用,不需要内核缓存,避免双重缓存浪费内存
- 需要自己控制IO调度的场景
缺点:
- 性能通常比带缓存的IO差很多,因为没有利用Page Cache
- 内存缓冲区需要页对齐,使用起来比较麻烦
4. 异步IO
异步IO发起IO请求后立即返回,不需要等待IO完成,IO完成后操作系统会通知应用程序,提高并发能力。不同编程语言和平台有不同的异步IO实现,比如Linux的io_uring,Windows的IOCP,Python的asyncio等。
常见文件操作坑点与最佳实践
坑点1:忘记关闭文件
- 问题:打开文件后忘记关闭,会导致文件描述符泄漏,系统的文件描述符是有限的,泄漏完了就无法再打开新的文件
- 解决方法:
- 使用RAII机制,比如Python的
with语句,Java的try-with-resources,C++的智能指针,自动关闭文件 - 一定要在finally块中关闭文件
- 不要长时间打开不需要的文件,用完就关
- 使用RAII机制,比如Python的
正确示例(Python):
# 好的写法,with语句会自动关闭文件
with open('file.txt', 'r') as f:
content = f.read()
# 不好的写法,可能忘记关闭
f = open('file.txt', 'r')
content = f.read()
# 如果中间抛出异常,f.close()不会执行
f.close()
坑点2:忽略文件操作异常
- 问题:文件操作很容易出现异常(磁盘满、权限不足、文件不存在、磁盘损坏等),忽略异常会导致程序出现难以排查的问题,甚至数据丢失
- 解决方法:
- 所有文件操作都要捕获并处理异常
- 发生异常时要保证资源正确释放,数据一致性不受破坏
- 重要的文件操作要进行错误校验,不要假设操作一定会成功
坑点3:错误处理编码问题
- 问题:文本文件的编码处理错误,导致乱码或者读取失败
- 解决方法:
- 打开文本文件时明确指定编码,不要依赖系统默认编码
- 二进制文件用二进制模式打开(rb/wb),不要用文本模式
- 尽可能统一使用UTF-8编码,减少编码问题
正确示例(Python):
# 明确指定编码
with open('file.txt', 'r', encoding='utf-8') as f:
content = f.read()
# 二进制文件用二进制模式
with open('image.jpg', 'rb') as f:
data = f.read()
坑点4:刷盘策略错误
- 问题:
- 重要数据不调用fsync,系统崩溃或断电时数据丢失
- 频繁调用fsync,严重影响性能
- 解决方法:
- 需要持久化的数据,修改完成后调用fsync确保写到磁盘
- 不需要立即持久化的数据,不要频繁调用fsync,让操作系统自动刷盘
- 可以批量操作完成后统一调用一次fsync,平衡性能和安全性
坑点5:路径处理错误
- 问题:
- 硬编码路径,跨平台不兼容(Windows用\,Linux用/)
- 路径拼接错误,导致找不到文件
- 没有处理用户输入的路径,导致路径穿越漏洞
- 解决方法:
- 不要硬编码路径,使用配置文件或者环境变量配置
- 使用编程语言提供的路径处理库拼接路径,比如Python的
os.path.join()或者pathlib,Java的Paths,不要自己拼接字符串 - 对用户传入的路径进行严格校验,规范化后再使用,防止路径穿越
正确示例(Python):
from pathlib import Path
# 跨平台路径处理
base_dir = Path('/data')
file_path = base_dir / 'user' / 'file.txt' # 自动处理路径分隔符
# 解析为绝对路径,处理../等
resolved_path = file_path.resolve()
坑点6:大文件读取问题
- 问题:读取大文件时一次性读入内存,导致内存不足OOM
- 解决方法:
- 大文件分块读取,逐行或者逐块处理,不要一次性读入内存
- 使用生成器或者迭代器处理大文件
- 大文件处理优先使用内存映射文件
正确示例:
# 大文件逐行处理,不会占用太多内存
with open('large_file.txt', 'r', encoding='utf-8') as f:
for line in f:
process(line)
文件操作性能优化
1. 批量操作
合并小的读写操作,减少系统调用次数,比如把多次小的write合并成一次大的write,减少用户态和内核态的切换开销。
2. 充分利用缓存
- 操作系统的Page Cache会自动缓存热点数据,不要随便使用直接IO绕过缓存
- 应用层可以对热点文件内容进行缓存,减少磁盘IO次数
- 顺序读写比随机读写性能好很多,特别是对于HDD,尽量将随机IO转换为顺序IO
3. 选择合适的缓冲区大小
- 缓冲区太小会导致频繁的系统调用,性能差
- 缓冲区太大会浪费内存,而且如果进程崩溃会丢失更多数据
- 通常缓冲区大小设置为4KB、8KB、32KB比较合适,和磁盘块大小对齐性能最好
4. 预读优化
如果要顺序读取大文件,可以开启文件的预读选项,操作系统会提前把后续的内容加载到缓存中,提升读取性能。
5. 合理设置刷盘频率
- 不需要强持久化的场景,不要频繁刷盘,让操作系统自动批量刷盘,提升性能
- 需要强持久化的场景,批量操作后再刷盘,不要每次操作都刷盘
思考问题
- 内存映射文件相比传统的read/write方式有什么优势?适合什么场景?
- 为什么打开文件后一定要关闭?忘记关闭会有什么后果?
- 读取一个10GB的日志文件,一次性读入内存会有什么问题?应该怎么处理?
- 什么场景下需要调用fsync?频繁调用fsync会有什么问题?
练习题与扩展阅读
练习题
基础题
- 什么是inode?inode中存储了哪些信息?为什么inode中不存储文件名?
- 虚拟文件系统(VFS)的作用是什么?它是怎么做到支持多种不同文件系统的?
- 日志文件系统的工作原理是什么?它如何保证断电后文件系统的一致性?
- Linux系统中文件的755、644权限分别表示什么含义?分别适合什么类型的文件?
- 硬链接和软链接有什么区别?分别适合什么场景?
实操题
- 查看你电脑上文件系统的类型,每个分区的使用率和inode使用率。
- Linux:使用
df -h和df -i命令 - Windows:查看磁盘属性
- Linux:使用
- 在Linux系统中创建一个文件,分别创建它的硬链接和软链接,删除原文件,看看两个链接有什么不同的表现。
- 编写一个程序,分别用read/write方式和mmap方式读取一个大文件,对比两者的性能差异。
- 尝试设置文件的SUID、SGID、Sticky Bit权限,观察权限表示的变化。
思考题
- 为什么删除文件的时候,即使文件很大也能瞬间完成?删除的文件可以恢复吗?为什么?
- 为什么SSD不需要做碎片整理,而HDD需要?
- ext4文件系统对小于12个块的小文件和大文件的存储方式有什么不同?这种设计有什么好处?
- 当系统提示磁盘空间不足,但你删除了很多大文件之后还是提示空间不足,可能是什么原因?怎么排查?
扩展阅读
书籍推荐
-
《Linux内核设计与实现》
- 第13章虚拟文件系统、第14章块I/O层、第15章进程地址空间等章节详细讲解了Linux文件系统的实现原理
- 适合想要深入理解Linux文件系统底层实现的开发者
-
《操作系统导论》
- 第三部分持久性部分详细讲解了文件系统的设计和实现原理,包括磁盘、文件系统、日志结构文件系统等内容
- 通俗易懂,适合入门学习操作系统和文件系统原理
-
《UNIX环境高级编程》
- 第3章文件I/O、第4章文件和目录等章节讲解了Unix系统下的文件操作API和原理
- 系统编程的经典著作,每个Unix/Linux开发者都应该读
在线资源
-
- Linux内核官方的EXT4文件系统文档,最权威的资料
- 适合深入了解EXT4的特性和实现
-
- Btrfs文件系统的官方文档,包含所有特性的详细说明
-
- 微软官方的NTFS文件系统介绍,适合了解Windows平台的文件系统
-
- 通俗易懂的中文科普文章,讲解文件系统的基本工作原理
工具推荐
- df / du:Linux下查看磁盘使用率、文件大小的常用命令
- fsck:文件系统检查和修复工具,文件系统损坏时用来修复
- dumpe2fs / tune2fs:ext系列文件系统的查看和调整工具
- xfs_info / xfs_admin:XFS文件系统的查看和调整工具
- testdisk:开源的数据恢复工具,可以恢复误删的文件和分区
- iostat / pidstat:监控磁盘IO性能的工具
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第6章:内存管理
学习目标
通过本章学习,你将能够:
- 理解内存硬件的工作原理和性能特性
- 掌握虚拟内存、分页机制的实现原理
- 理解内存分配与回收的算法和机制
- 掌握CPU缓存的工作原理和局部性原理
- 能够排查和解决编程中常见的内存问题(内存泄漏、内存溢出、野指针等)
章节简介
内存是计算机中最重要的资源之一,程序的运行离不开内存。内存管理是操作系统的核心功能,也是程序员必须掌握的基础知识,理解内存管理的原理,能够帮助你写出更高效、更稳定的代码,排查和解决内存相关的疑难问题。本章将从内存硬件原理讲起,系统讲解虚拟内存、分页机制、内存分配、缓存原理等核心内容,以及编程中常见的内存问题和解决方案。
本章内容
- DRAM内存的工作原理
- 内存的层次结构与性能参数
- 内存的物理结构与寻址方式
- 内存双通道、多通道技术
- 物理地址 vs 虚拟地址
- 虚拟内存的设计思想和优势
- 分页机制的实现原理
- 页表、TLB快表、多级页表
- 页面置换算法
- 操作系统的内存分配机制
- 堆内存分配算法(伙伴系统、slab分配器等)
- 标准库的内存分配实现(ptmalloc、tcmalloc、jemalloc等)
- 内存回收与页面回收机制
- CPU缓存的层次结构(L1/L2/L3缓存)
- 缓存的工作原理(命中、缺失、写策略)
- 局部性原理(时间局部性、空间局部性)
- 缓存行、伪共享问题与优化
- 缓存性能优化方法
- 常见内存问题:内存泄漏、内存溢出、野指针、缓冲区溢出
- 内存问题的排查工具和方法
- 内存管理的最佳实践
- 不同编程语言的内存管理模型(手动管理、GC、ARC等)
学习建议
本章内容是计算机系统的核心知识,对所有开发者都非常重要,建议结合实际编程中的内存问题学习。如果你是C/C++开发者,需要重点学习内存分配、指针问题、内存泄漏排查等内容;如果你是Java/Python等带GC的语言的开发者,可以重点理解虚拟内存、缓存原理、GC的基本原理,以及如何优化程序的内存使用。学习时可以结合性能分析工具,实际观察程序的内存使用情况,加深理解。
难度:★★★★☆
预计学习时间:4小时
6.1 内存硬件原理
我们常说的内存(Memory,也叫主存、随机存取存储器RAM)是计算机中临时存储数据的地方,CPU可以直接读写内存中的数据,速度比硬盘快几个数量级。理解内存的硬件原理,有助于我们理解程序的性能特性,写出更高效的代码。
内存的基本概念
RAM(随机存取存储器)
内存是一种随机存取存储器,特点是可以按地址随机访问任意位置的数据,访问速度和数据所在的位置无关。与之相对的是顺序存取存储器(比如磁带),访问数据需要顺序查找,速度很慢。
RAM分为两种:
- SRAM(静态RAM):速度极快,不需要刷新,集成度低,价格昂贵,用作CPU的高速缓存(Cache)
- DRAM(动态RAM):需要定期刷新才能保存数据,集成度高,价格便宜,我们平时说的内存就是DRAM
我们主要讨论DRAM内存。
DRAM的工作原理
DRAM的基本存储单元是一个电容和一个晶体管:
- 电容充电代表存储数据1,放电代表存储数据0
- 晶体管相当于开关,控制读写操作
- 电容会漏电,所以需要每隔一段时间(通常是64ms)刷新一次,给电容重新充电,否则数据就会丢失
- 刷新操作会占用内存带宽,影响内存性能
DRAM的读写过程
- 内存控制器接收到CPU的读写请求,解析出地址
- 首先发送行地址,选中对应的行,激活该行的存储单元,数据被读取到行缓冲区
- 再发送列地址,选中对应的列,返回对应的数据
- 写入操作类似,将数据写入对应的存储单元
- 访问完成后,关闭行,准备下一次访问
内存的性能参数
- 容量:内存能存储的最大数据量,常见的有8GB、16GB、32GB、64GB等
- 频率:内存的工作频率,单位是MHz或MT/s(百万次传输每秒),频率越高速度越快,常见的有DDR4 2666、DDR4 3200、DDR5 4800、DDR5 5600等
- 带宽:单位时间内存能传输的数据量,计算公式是
频率 × 位宽 / 8,单通道DDR4 3200的带宽是3200MT/s × 64bit / 8 = 25.6GB/s - 时序(CL值):内存的延迟参数,CL(CAS Latency)是列地址选通延迟,单位是时钟周期,CL值越小延迟越低。同样频率下,CL值越小性能越好。
- 例如DDR4 3200 CL16的实际延迟是 16 / 3200MHz = 5ns,比DDR4 3600 CL18(18/3600=5ns)的延迟差不多
内存的物理结构
内存芯片的组织
- Bank:内存芯片内部划分为多个Bank,每个Bank可以独立进行读写操作,多个Bank可以并行工作,提升内存带宽
- 行/列:每个Bank是一个二维的存储矩阵,分为行和列
- 页:每个行的所有存储单元构成一个页,大小通常是4KB/8KB/16KB,同一页的访问速度很快,不同页之间切换需要行激活,有额外的延迟
内存模组(内存条)
我们平时用的内存条是把多个DRAM芯片焊接在PCB板上组成的:
- 位宽:每个内存条的位宽通常是64bit,ECC内存是72bit(多了8bit校验位)
- Rank:内存条上的独立访问区域,单Rank内存只有一个64bit的访问区域,双Rank有两个,可以并行访问,提升性能
- 金手指:内存条底部的金属触点,和主板上的内存插槽连接
- SPD芯片:存储内存条的参数信息(频率、时序、电压等),主板启动时会读取这些参数配置内存
DDR内存世代
DDR(双倍数据速率)内存是目前的主流,在一个时钟周期的上升沿和下降沿都传输数据,相当于频率翻倍:
| 世代 | 典型频率 | 单通道带宽 | 工作电压 | 发布时间 |
|---|---|---|---|---|
| DDR3 | 1333/1600MT/s | 10.6/12.8GB/s | 1.5V | 2007年 |
| DDR4 | 2666/3200MT/s | 21.3/25.6GB/s | 1.2V | 2014年 |
| DDR5 | 4800/5600MT/s | 38.4/44.8GB/s | 1.1V | 2020年 |
每一代DDR内存的带宽更高,电压更低,功耗更小,但互不兼容,插槽也不同,不能混用。
内存寻址方式
CPU访问内存的流程:
- CPU发出虚拟地址,经过内存管理单元(MMU)转换为物理地址
- 物理地址被发送到内存控制器
- 内存控制器将物理地址解析为通道号、Rank号、Bank号、行地址、列地址
- 内存控制器按照地址访问对应的存储单元,返回数据
内存 interleaving(交叉存取)
为了提升内存性能,内存控制器会把连续的地址分散到不同的Channel、Rank、Bank中,这样连续访问可以并行操作多个Bank,提升吞吐量。
多通道内存技术
什么是多通道
- 单通道:内存控制器和内存之间只有一条64bit的数据总线,带宽有限
- 双通道:两条独立的64bit总线,总位宽128bit,带宽翻倍
- 三通道/四通道:更多的通道,带宽更高,常见于高端平台和服务器
多通道的优势
多通道技术可以成倍提升内存带宽,对于内存带宽瓶颈的场景(比如核显、大数据处理、虚拟机)性能提升非常明显。
多通道的要求
- 需要主板支持多通道
- 需要将内存条插在对应的插槽中,通常是相同颜色的插槽
- 最好使用相同容量、相同频率、相同时序的内存条,兼容性更好
- 内存不需要完全相同,不同容量的内存也可以组成弹性双通道
注意
如果你的主板支持双通道,一定要插两根内存组成双通道,比单通道性能提升很多,特别是集成显卡的平台,性能差距可达30%以上。
内存对系统性能的影响
内存的性能主要从两个方面影响系统:
- 容量:内存容量不够的话,系统会使用交换分区(Swap),把不常用的内存页放到硬盘上,硬盘速度比内存慢很多,会导致系统非常卡顿。
- 普通办公:8GB足够
- 开发/游戏:16GB是标配
- 大数据/虚拟机/视频处理:32GB以上
- 带宽和延迟:如果内存带宽不够或者延迟太高,CPU需要等待内存数据,会导致CPU利用率上不去,程序性能下降。
- 普通场景:DDR4 3200足够
- 游戏、高性能计算:高频率低延迟的内存能提升性能
- 核显平台:高带宽内存对性能影响很大
常见内存问题
- 内存超频:可以通过超频内存的频率和调整时序提升性能,但可能会导致不稳定,需要散热和电压支持。
- ECC内存:带错误校验和纠正功能的内存,能够检测并纠正单bit错误,检测多bit错误,常用于服务器和需要高可靠性的场景,比普通内存贵。
- 内存兼容性问题:不同品牌、不同频率的内存混用可能会出现兼容性问题,导致蓝屏、死机等,尽量选择相同规格的内存。
思考问题
- SRAM和DRAM有什么区别?分别用在什么地方?
- 内存的频率、带宽、时序三个参数之间有什么关系?哪个对性能影响更大?
- 双通道内存相比单通道内存有什么优势?为什么双通道能提升性能?
- 为什么内存需要定时刷新?如果不刷新会怎么样?
6.2 地址空间与分页
地址空间和分页是现代操作系统内存管理的核心机制,它们让每个进程都拥有独立的、连续的、巨大的虚拟地址空间,同时高效地利用物理内存。理解虚拟内存和分页机制,是理解整个内存管理的基础。
物理地址 vs 虚拟地址
物理地址
物理地址是内存硬件的实际地址,是地址总线上传输的真实地址,对应内存芯片上的存储单元。
- 32位系统最大支持4GB物理地址空间(2^32 = 4GB)
- 64位系统理论上最大支持2^64地址空间,实际中通常只使用48位,支持256TB地址空间
早期的内存管理:直接使用物理地址
早期的计算机和简单的嵌入式系统中,程序直接使用物理地址:
- 程序加载时要分配连续的物理内存空间
- 所有程序都直接访问真实的物理内存
- 存在的问题:
- 内存空间不足:多个程序运行时,总内存需求超过物理内存大小就无法运行
- 安全问题:程序可以访问任意物理地址,恶意程序可以修改其他进程甚至操作系统的内存
- 效率低下:内存容易产生碎片,利用率低
- 重定位问题:程序每次加载的地址可能不同,需要重定位,开发复杂
为了解决这些问题,现代操作系统引入了虚拟内存机制。
虚拟内存的设计思想
虚拟内存的核心思想是:
每个进程拥有独立的、连续的、私有的虚拟地址空间,操作系统负责将虚拟地址转换为实际的物理地址,进程不需要关心物理内存的实际分配情况。
虚拟内存的优势
- 地址空间隔离:每个进程的地址空间是独立的,互相不影响,一个进程崩溃不会影响其他进程,提升了系统的稳定性和安全性
- 简化编程:程序员不需要关心物理内存的分配,程序链接时可以使用固定的地址,不需要重定位
- 内存利用率高:物理内存不需要连续分配,可以离散分配,减少碎片,允许多个进程共享物理内存页
- 扩大地址空间:可以使用比物理内存更大的地址空间,不常用的内存页可以换出到磁盘上,需要的时候再换入,相当于扩大了内存容量
分页机制
分页是实现虚拟内存最常用的方式,现在几乎所有的现代操作系统都使用分页机制。
分页的基本原理
- 把虚拟地址空间和物理地址空间都划分为固定大小的页(Page),页的大小通常是4KB,也支持2MB、1GB的大页(Huge Page)
- 虚拟地址空间的页称为虚拟页(Virtual Page, VP)
- 物理地址空间的页称为物理页(Physical Page, PP),也叫页帧(Page Frame)
- 操作系统维护一个页表(Page Table),记录虚拟页和物理页的映射关系
- 内存管理单元(MMU)硬件负责将虚拟地址转换为物理地址
地址转换过程
一个虚拟地址分为两部分:虚拟页号(VPN)和页内偏移(Offset)
- 虚拟地址 = 虚拟页号 + 页内偏移
- 物理地址 = 物理页号 + 页内偏移
- 页内偏移的位数由页大小决定,4KB页的页内偏移是12位(2^12=4096)
地址转换步骤:
- 根据虚拟地址提取虚拟页号
- 在页表中查找虚拟页号对应的物理页号
- 如果该页不在物理内存中,触发缺页异常,操作系统负责将该页从磁盘加载到物理内存,更新页表
- 将物理页号和页内偏移拼接成物理地址
- 访问物理地址对应的内存
页表
页表是存储虚拟页到物理页映射关系的表,每个进程有自己独立的页表:
- 每个页表项(PTE)除了存储物理页号外,还包含一些标志位:
- 有效位:表示该页是否在物理内存中
- 读写权限:控制该页是否可读、可写、可执行
- 用户位:控制该页是否可以被用户态访问
- 脏位:表示该页是否被修改过
- 访问位:表示该页最近是否被访问过,用于页面置换算法
多级页表
如果只有一级页表,32位系统4KB页的话,页表需要包含2^20 = 100万 个页表项,每个页表项4字节的话需要4MB,每个进程都需要4MB的页表空间,64位系统需要的空间更是大得无法接受。
为了减少页表的内存占用,现代操作系统使用多级页表:
- 把虚拟地址分为多段,每段对应一级页表的索引
- 32位系统通常用两级页表:页目录(一级) + 页表(二级)
- 64位系统通常用四级或五级页表
- 只有用到的虚拟地址区域才会分配页表项,大大节省了页表占用的内存空间
TLB(Translation Lookaside Buffer,快表)
每次地址转换都需要访问页表,而页表存在内存中,这样每次内存访问实际上需要两次内存访问(一次查页表,一次访问数据),性能会下降一半。
为了加速地址转换,CPU中集成了TLB快表,用来缓存最近使用的虚拟页号到物理页号的映射:
- TLB是一个高速的相联存储器,速度和CPU缓存差不多
- 地址转换时首先查找TLB,如果命中(TLB Hit),直接得到物理页号,不需要访问内存中的页表
- 如果TLB不命中(TLB Miss),才需要访问内存中的页表,并把映射关系缓存到TLB中
- TLB的容量通常很小,只有几十到几百个表项,但因为程序的局部性,TLB命中率通常在99%以上,大大提升了地址转换的性能
TLB优化技巧:
- 使用大页(Huge Page)可以减少TLB miss,因为一个大页覆盖更大的地址空间,同样的TLB容量可以覆盖更多的内存,适合内存密集型应用(如数据库、虚拟机)
- 尽量让内存访问具有空间局部性,提升TLB命中率
缺页异常与页面置换
当CPU访问的虚拟页不在物理内存中时,会触发缺页异常(Page Fault),操作系统的缺页异常处理程序会处理这个异常:
- 检查虚拟地址是否合法,如果不合法,发送SIGSEGV信号,段错误(Segmentation Fault)
- 如果合法,查找空闲的物理页,如果没有空闲页,调用页面置换算法选择一个要淘汰的页
- 如果要淘汰的页被修改过(脏页),先把它写回磁盘
- 从磁盘(交换分区或者文件)读取需要的页到物理内存
- 更新页表项,标记为有效
- 重新执行触发异常的指令
交换分区(Swap)
操作系统会在磁盘上划分一块区域作为交换分区,用来存储不常用的内存页。当物理内存不足时,操作系统会把不常用的页换出到交换分区,需要的时候再换入,这样可以运行比物理内存更大的程序。
- 交换分区的速度比内存慢很多,所以当系统大量使用交换分区时,性能会急剧下降
- 现在内存价格很低,服务器通常配置足够的内存,减少交换分区的使用
- 桌面系统建议配置和内存差不多大的交换分区,支持休眠等功能
页面置换算法
当物理内存不足时,需要选择一个内存页淘汰,把它换出到磁盘,页面置换算法的目标是尽可能减少页面置换的次数(降低缺页率)。
常见的页面置换算法:
- 最佳置换算法(OPT):选择未来最长时间不会被访问的页淘汰,性能最好,但无法实现,只能作为理论参考
- 先进先出算法(FIFO):选择最早进入内存的页淘汰,实现简单,但性能差,可能出现Belady异常(增加物理内存缺页率反而升高)
- 最近最少使用算法(LRU,Least Recently Used):选择最近最久没有被访问的页淘汰,性能接近OPT,是最常用的算法
- 实现LRU需要记录每个页的访问时间,硬件支持的话可以通过页表的访问位实现
- 实际系统中通常使用近似LRU算法,比如时钟算法(Clock Algorithm),性能接近LRU但实现简单
- 最不常用算法(LFU,Least Frequently Used):选择访问次数最少的页淘汰,适合访问模式比较固定的场景
颠簸(Thrashing)
当系统的物理内存严重不足时,会频繁地进行页面换入换出,大部分时间都在处理缺页异常,CPU利用率很低,这种现象称为颠簸。
- 原因:进程太多,每个进程分到的物理页太少,频繁发生缺页
- 解决方法:增加物理内存,或者减少同时运行的进程数量
思考问题
- 虚拟内存有什么好处?如果没有虚拟内存会怎么样?
- 分页机制中,为什么页大小通常是4KB?如果页太大或太小会有什么问题?
- TLB的作用是什么?为什么TLB能大大提升地址转换的性能?
- 什么是缺页异常?发生缺页异常时操作系统会做哪些处理?
6.3 内存分配与回收
内存分配与回收是内存管理的核心功能,操作系统需要高效地管理物理内存,为进程分配和回收内存空间,同时标准库也会在用户态提供更细粒度的内存分配,满足程序的动态内存需求。
操作系统的内存管理
操作系统的内存管理分为两个层面:
- 物理内存管理:管理整个系统的物理页,分配给各个进程
- 虚拟内存管理:管理每个进程的虚拟地址空间,将虚拟页映射到物理页
物理内存分配
操作系统需要管理所有的物理页,为进程分配和回收物理页,常用的物理内存分配算法:
1. 伙伴系统(Buddy System)
伙伴系统是Linux内核使用的物理页分配算法:
- 原理:把物理内存按2的幂次方大小划分成多个块,每个块大小是4KB、8KB、16KB…直到最大的块大小
- 当需要分配n个页时,找到大于等于n的最小的2的幂次方的块分配
- 如果没有合适的块,就把更大的块分裂成两个相等的伙伴块,直到得到需要的大小
- 回收时,检查回收的块的伙伴块是否也是空闲的,如果是就合并成更大的块
- 优点:实现简单,合并快速,减少外部碎片
- 缺点:内部碎片,比如需要5个页的话要分配8个页,浪费3个页的空间
2. Slab分配器
伙伴系统是以页为单位分配的,但操作系统经常需要分配小于页大小的内核对象(比如进程描述符、inode等),Slab分配器就是用来分配小对象的:
- 原理:为每种类型的内核对象创建一个缓存,缓存中包含多个Slab,每个Slab由一个或多个连续的物理页组成,里面存放固定大小的对象
- 分配时直接从对应的缓存中分配空闲对象,回收时放回缓存
- 优点:分配小对象速度极快,不会产生内部碎片,缓存常用的对象,避免重复初始化
- Linux内核中使用的是改进的Slub分配器,性能更好,更节省内存
进程虚拟地址空间布局
每个进程的虚拟地址空间是独立的,32位Linux系统的典型布局:
┌───────────────────┐ 0xFFFFFFFF
│ 内核空间 │ 1GB,内核使用,所有进程共享
├───────────────────┤ 0xC0000000
│ 栈区 │ 从高地址向低地址增长,存储函数调用栈、局部变量
├───────────────────┤
│ 内存映射区 │ 动态库、文件映射、mmap分配的内存
├───────────────────┤
│ 堆区 │ 从低地址向高地址增长,动态分配的内存(malloc/new)
├───────────────────┤
│ 数据段(BSS) │ 未初始化的全局变量和静态变量,初始化为0
├───────────────────┤
│ 数据段(Data) │ 已初始化的全局变量和静态变量
├───────────────────┤
│ 代码段(Text) │ 程序的可执行代码、只读常量
└───────────────────┘ 0x00000000
64位系统的虚拟地址空间更大,布局类似,但各个段的范围更大。
用户态内存分配:malloc实现
我们在程序中调用的malloc()/free()是C标准库提供的函数,属于用户态的内存分配,不是系统调用。标准库的内存分配器会提前向操作系统申请大块的内存,然后切分成小块分配给程序,减少系统调用的开销。
内存分配器的设计目标
- 高性能:分配和释放操作要尽可能快
- 低碎片:减少内存碎片,提高内存利用率
- 通用性:支持各种大小的内存分配请求
- 可移植性:在不同的操作系统和硬件平台都能运行
- 线程安全:支持多线程环境下的并发分配
常见的内存分配器
1. ptmalloc
ptmalloc是GNU C库(glibc)的默认内存分配器:
- 原理:基于Doug Lea的dlmalloc,支持多线程
- 使用分箱(Binning)技术,把不同大小的内存块放到不同的链表中,分配时直接从对应大小的链表中取
- 每个线程有自己的分配区(Arena),减少锁竞争
- 优点:稳定、兼容性好,是Linux的默认实现
- 缺点:内存碎片较多,多线程下性能一般,释放的内存不一定会还给操作系统
2. tcmalloc(Thread-Caching Malloc)
tcmalloc是Google开发的内存分配器,是gperftools的一部分:
- 核心优化:每个线程有自己的线程本地缓存,小对象分配直接在本地缓存中进行,不需要加锁,性能极高
- 大对象使用Central Cache分配,多个线程共享,使用页级分配
- 优点:多线程下性能比ptmalloc高很多,内存碎片少,释放的内存会还给操作系统
- 缺点:比ptmalloc多占用一点额外内存
- 很多高性能应用(比如Chrome、Redis)都使用tcmalloc代替默认的ptmalloc
3. jemalloc
jemalloc是Jason Evans开发的内存分配器,最早用于FreeBSD,现在是Firefox、Rust、Facebook的默认分配器:
- 核心优化:基于多层级的缓存设计,支持多核扩展,低碎片
- 使用大小类和范围分配,每个CPU有自己的缓存,并发性能很好
- 内建内存 profiling 功能,方便排查内存问题
- 优点:性能和tcmalloc相当甚至更好,在多线程大内存分配场景下表现优异,内存利用率高
- 现在很多高并发服务都使用jemalloc来提升性能
分配器性能对比
| 分配器 | 单线程性能 | 多线程性能 | 内存碎片 | 额外内存开销 |
|---|---|---|---|---|
| ptmalloc | 中等 | 一般 | 高 | 低 |
| tcmalloc | 高 | 高 | 低 | 中等 |
| jemalloc | 高 | 很高 | 很低 | 中等 |
选择建议:
- 默认情况下用系统默认的ptmalloc就可以
- 高并发多线程服务,建议换成jemalloc或tcmalloc,能带来明显的性能提升
- 内存受限的嵌入式场景,考虑更轻量的分配器比如musl libc的malloc
malloc的底层实现
malloc分配内存主要通过两个系统调用:
- brk():扩展堆区的边界,适合分配较小的内存
- mmap():在内存映射区分配一块匿名内存,适合分配较大的内存(通常大于128KB)
free释放内存时:
- 如果是brk分配的小内存,放回空闲链表,不一定会还给操作系统,会被缓存起来下次分配使用
- 如果是mmap分配的大内存,调用munmap直接还给操作系统
- 这就是为什么有时候程序占用的内存不会随着free而下降的原因,缓存的内存可以复用,减少系统调用
内存回收
操作系统的内存回收
操作系统会在内存不足时回收内存:
- 回收页缓存:回收不常用的文件缓存页,这些页可以从磁盘重新读取,不需要写回(如果是干净的)
- 交换出匿名页:把不常用的匿名内存页写到交换分区,释放物理内存
- OOM Killer:如果内存严重不足,会杀死占用内存多的进程,释放内存
垃圾回收(GC)
对于Java、Python、Go、JavaScript等带自动垃圾回收的语言,不需要手动调用free释放内存,垃圾回收器会自动回收不再使用的内存:
- 原理:跟踪所有内存对象的引用,找出不再被引用的对象,自动回收它们占用的内存
- 常见的GC算法:标记清除、标记复制、标记整理、分代回收、引用计数等
- 优点:减轻程序员的负担,减少内存泄漏和野指针问题
- 缺点:有运行时开销,GC停顿会影响响应时间,需要调优
手动内存管理
C/C++等语言需要手动管理内存,调用malloc/new分配,free/delete释放:
- 优点:没有GC开销,内存使用完全可控,性能更高
- 缺点:容易出现内存泄漏、野指针、双重释放等问题,对程序员要求高
常见问题
为什么malloc分配的小内存free后,进程的内存占用没有下降?
因为ptmalloc等分配器会把释放的小内存缓存起来,留给后续的malloc使用,不会立即还给操作系统,这样可以避免频繁的系统调用,提升性能。如果很长时间不使用,或者分配了大内存,才会还给操作系统。
什么是内存碎片?
内存碎片分为两种:
- 外部碎片:总空闲内存足够,但都是不连续的小块,无法分配连续的大块内存
- 内部碎片:分配的内存比实际需要的大,多余的部分浪费了
好的内存分配器会尽量减少内存碎片,提升内存利用率。
如何选择内存分配器?
- 普通应用:默认的ptmalloc足够
- 高并发多线程服务:jemalloc或tcmalloc,性能更好
- 嵌入式系统:轻量级分配器,减少内存开销
- 需要调试内存问题:使用带调试功能的分配器,比如AddressSanitizer
思考问题
- 操作系统的伙伴系统分配算法有什么优缺点?
- 标准库的malloc为什么不直接向操作系统申请内存,而是提前申请大块内存再切分?
- tcmalloc和jemalloc相比默认的ptmalloc有什么优势?为什么高并发场景下性能更好?
- 自动垃圾回收和手动内存管理各有什么优缺点?分别适合什么场景?
6.4 缓存原理
CPU的运算速度比内存快很多倍,CPU访问内存需要等待几百个时钟周期,而CPU大部分时间都在等待内存数据,这就是著名的“内存墙“问题。为了弥补CPU和内存之间的速度差距,现代CPU都引入了多级高速缓存(Cache),理解缓存的工作原理,对于写出高性能的代码至关重要。
CPU缓存的层次结构
现代CPU通常有三级缓存:L1、L2、L3缓存,速度从快到慢,容量从小到大,离CPU越近速度越快,价格越高,容量越小。
┌─────────────────────────┐
│ CPU寄存器 │ 速度:1ns 容量:几百字节
├─────────────────────────┤
│ L1缓存 │ 速度:1-2ns 容量:32KB-64KB 每个核心独立
├─────────────────────────┤
│ L2缓存 │ 速度:3-5ns 容量:256KB-512KB 每个核心独立
├─────────────────────────┤
│ L3缓存 │ 速度:10-20ns 容量:几MB到几十MB 所有核心共享
├─────────────────────────┤
│ 主存(内存) │ 速度:100ns 容量:几GB到几十GB
└─────────────────────────┘
各级缓存的特点
- L1缓存:每个CPU核心独立拥有,分为L1i(指令缓存)和L1d(数据缓存),分别存储指令和数据,速度最快,容量最小。
- L2缓存:也是每个核心独立拥有,比L1大,速度稍慢,存储核心近期使用的指令和数据。
- L3缓存:多个核心共享,比L2大,速度更慢,用来协调多个核心之间的数据共享。
缓存的工作原理
缓存的设计基于程序的局部性原理:
程序在运行时,对内存的访问是不均匀的,在一段时间内,总是倾向于访问某一小部分内存区域。
局部性分为两种:
- 时间局部性:如果某个数据被访问了,那么它在不久的将来很可能会被再次访问。(比如循环中的变量)
- 空间局部性:如果某个数据被访问了,那么它附近的数据也很可能会被访问。(比如数组的顺序访问)
缓存就是利用局部性原理,把CPU近期可能访问的数据从内存加载到缓存中,让CPU可以快速访问。
缓存的基本操作
- 读操作:CPU要读取某个地址的数据时,首先检查缓存中是否有这个数据:
- 缓存命中(Cache Hit):数据在缓存中,直接从缓存读取,速度很快
- 缓存缺失(Cache Miss):数据不在缓存中,需要从内存读取,同时把数据和附近的一块数据加载到缓存中
- 写操作:CPU要写入数据时,有两种写策略:
- 写直达(Write Through):同时写入缓存和内存,实现简单,但写入速度慢
- 写回(Write Back):只写入缓存,标记缓存行为脏,当缓存行被替换时才写回内存,性能更高,是现在主流的写策略
缓存行(Cache Line)
缓存不是以字节为单位存储的,而是以缓存行为单位,缓存行是缓存和内存之间传输数据的最小单位,通常大小是64字节。
- 当缓存缺失时,会把整个64字节的缓存行从内存加载到缓存中,而不仅仅是需要的那个字节
- 这就是空间局部性的体现,因为访问一个数据后,附近的数据很可能也会被访问,提前加载到缓存可以提升后续访问的速度
注意:缓存行的大小是64字节,意味着如果你的程序访问数组中的一个元素,整个64字节的数组元素都会被加载到缓存中,顺序访问数组时后面的元素都已经在缓存里了,速度非常快。
缓存映射方式
缓存如何确定内存地址对应哪个缓存位置?有三种映射方式:
- 直接映射:每个内存地址只能映射到一个固定的缓存位置,实现简单,但冲突率高
- 全相联映射:每个内存地址可以映射到任意缓存位置,冲突率低,但成本高,速度慢
- 组相联映射:缓存分成多个组,每个内存地址映射到固定的组,可以放到组内的任意位置,是直接映射和全相联的折中,也是现在CPU常用的映射方式,比如8路组相联,就是每个组有8个缓存行。
缓存一致性(MESI协议)
在多核CPU中,每个核心有自己的L1/L2缓存,多个核心可能缓存了同一个内存地址的数据,需要保证各个核心的缓存数据一致,这就是缓存一致性。
主流的缓存一致性协议是MESI协议,每个缓存行有四个状态:
- M(Modified,修改):缓存行的数据被修改了,和内存不一致,只有当前核心有这个副本
- E(Exclusive,独占):缓存行的数据和内存一致,只有当前核心有这个副本
- S(Shared,共享):缓存行的数据和内存一致,多个核心都有这个副本
- I(Invalid,无效):缓存行的数据无效,不能使用
当某个核心修改了缓存行,其他核心的对应缓存行会被标记为无效,需要从内存重新读取,保证数据一致。
伪共享问题
伪共享(False Sharing)是多核CPU下常见的性能问题,会严重降低程序的性能。
什么是伪共享
当两个不同的变量存储在同一个缓存行中,两个不同的核心分别修改这两个变量时,即使它们互不相关,也会导致缓存行不断地在两个核心之间失效和同步,性能急剧下降。
示例:
// 两个变量a和b在内存中相邻,位于同一个64字节的缓存行中
long a;
long b;
// 核心1不断修改a
void core1() {
while(1) a++;
}
// 核心2不断修改b
void core2() {
while(1) b++;
}
这种情况下,虽然两个核心修改的是不同的变量,但因为它们在同一个缓存行,每次修改都会导致另一个核心的缓存行失效,需要重新从内存读取,性能比单线程还慢很多,这就是伪共享。
如何避免伪共享
缓存行填充:在变量之间填充无用的字节,让不同的变量位于不同的缓存行:
// 每个变量占64字节,独占一个缓存行
struct {
long a;
char padding[64 - sizeof(long)]; // 填充到64字节
long b;
char padding2[64 - sizeof(long)];
} data;
或者使用编译器的对齐属性,让变量按64字节对齐。
注意:不要过度填充,会浪费缓存空间,只需要在会被多个核心并发修改的变量之间填充即可。
缓存性能优化方法
理解缓存原理,我们可以写出对缓存更友好的代码,提升程序性能:
1. 充分利用局部性
- 时间局部性:尽量让最近访问过的数据尽快再次访问,避免被换出缓存
- 把常用的数据放在一起,减少分散访问
- 避免在循环中访问大量不相关的数据
- 空间局部性:尽量顺序访问连续的内存地址
- 数组优先顺序访问,避免随机访问
- 数据结构设计尽量紧凑,避免过多的指针跳转
- 多维数组按行优先顺序访问(C/C++是行优先)
示例:顺序访问 vs 随机访问
// 顺序访问数组,速度非常快,缓存命中率接近100%
for (int i = 0; i < N; i++) {
sum += array[i];
}
// 按列访问二维数组,空间局部性差,缓存命中率低,速度慢很多
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
sum += array[i][j];
}
}
// 优化:交换内外层循环,改为行优先访问,速度提升很多
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
sum += array[i][j];
}
}
2. 优化数据结构
- 结构体成员按访问频率排序:把经常一起访问的成员放在一起,按大小排序,减少结构体的大小
- 避免过度使用指针:指针跳转是随机访问,缓存命中率低,尽量使用数组和连续存储
- 使用更小的数据类型:用更小的类型可以让更多的数据放到缓存里,提升缓存利用率,比如能用int就不用long,能用float就不用double
3. 避免伪共享
- 多线程并发修改的变量要注意缓存行对齐,避免在同一个缓存行
- 尽量让每个线程访问自己的数据,避免多个线程修改同一缓存行的数据
4. 使用大页(Huge Page)
普通页大小是4KB,大页是2MB或者1GB,使用大页可以:
- 减少页表的大小,节省内存
- 提升TLB命中率,减少TLB miss的开销
- 适合内存占用大、访问连续的应用,比如数据库、Java虚拟机、大数据处理等
5. 缓存预热
对于需要低延迟的场景,可以提前把要访问的数据加载到缓存中,避免第一次访问时的缓存缺失延迟。
性能分析工具
可以使用以下工具分析程序的缓存命中率:
- perf:Linux下的性能分析工具,可以统计cache-misses、TLB-misses等事件
- valgrind:可以模拟缓存行为,统计缓存命中率
- Intel VTune:Intel的性能分析工具,图形化展示缓存使用情况
优化缓存性能的核心指标是缓存命中率,命中率越高,程序性能越好。
思考问题
- 什么是局部性原理?时间局部性和空间局部性分别是什么?举例说明。
- 为什么顺序访问数组比随机访问快很多?
- 什么是伪共享?它为什么会导致性能下降?如何避免伪共享?
- 缓存行的大小通常是多少?它的大小对程序性能有什么影响?
6.5 编程中的内存问题
内存问题是编程中最常见也最难排查的问题之一,尤其是在C/C++等手动管理内存的语言中,内存问题往往会导致程序崩溃、数据损坏、内存泄漏、安全漏洞等严重后果。即使是带GC的语言,也会遇到内存使用不合理、内存泄漏等问题。这一节我们介绍常见的内存问题以及排查和解决方法。
常见的内存问题
1. 内存泄漏(Memory Leak)
定义:程序中已经不再使用的内存没有被释放,导致内存占用持续增长,最终耗尽系统内存。
产生原因(手动管理内存语言):
- malloc/new分配的内存没有free/delete释放
- 关闭文件、Socket等资源时没有释放关联的内存
- 缓存中的对象没有清理,只加不删
- 隐式内存泄漏:对象还被引用但实际上已经不会再使用了
产生原因(带GC的语言):
- 长生命周期的对象持有短生命周期对象的引用,比如静态集合类只加元素不删除
- 资源未关闭,比如流、连接没有关闭
- 监听器、回调没有注销
- ThreadLocal没有清理,线程池线程复用导致对象无法回收
危害:
- 程序内存占用越来越高,运行变慢
- 系统内存耗尽,程序被OOM Killer杀死
- 服务器需要频繁重启,影响可用性
排查方法:
- C/C++:使用Valgrind、AddressSanitizer等工具检测内存泄漏
- Java:使用jmap、jhat、jstat、VisualVM、MAT等工具分析堆内存
- Python:使用memory_profiler、objgraph等工具
- Go:使用pprof分析内存
- 观察进程的内存占用变化,如果持续增长不下降,很可能有内存泄漏
2. 内存溢出(Out Of Memory,OOM)
定义:程序需要的内存超过了系统能够分配的最大内存,导致内存分配失败。
常见原因:
- 内存泄漏累积,最终耗尽内存
- 一次性加载过大的数据到内存,比如一次性读取整个大文件到内存
- 死循环或者递归过深,导致栈溢出(Stack Overflow)
- JVM等虚拟机的堆内存设置太小,不足以支撑业务需求
- 大量大对象同时分配,没有足够的连续内存
危害:
- 程序直接崩溃,无法继续运行
- 可能导致数据丢失或者损坏
解决方法:
- 排查修复内存泄漏问题
- 大文件分块读取,不要一次性加载到内存
- 优化数据结构和算法,减少内存占用
- 调整虚拟机的内存参数
- 使用更高效的内存分配方式,比如内存池
3. 野指针(Wild Pointer)
定义:指向已经被释放的内存或者未分配的内存的指针。
产生原因:
- 指针释放后没有置为NULL,继续使用
- 使用未初始化的指针
- 指针操作超过了数组的边界
- 返回了栈上局部变量的指针,函数返回后栈空间已经被回收
危害:
- 读取到垃圾数据,程序逻辑错误
- 修改野指针指向的内存可能会破坏其他数据,导致程序崩溃
- 可能被攻击者利用,执行任意代码,造成安全漏洞
防范方法:
- 指针释放后立即置为NULL
- 指针使用前确保已经初始化
- 不要返回局部变量的指针
- 数组访问注意边界检查
- 打开编译器的警告选项,检测未初始化的变量
4. 缓冲区溢出(Buffer Overflow)
定义:向缓冲区写入的数据超过了缓冲区的大小,导致数据覆盖了缓冲区后面的内存区域。
产生原因:
- 字符串操作没有检查长度,比如strcpy、sprintf等不安全的函数
- 数组越界写入
- 输入数据长度没有校验
危害:
- 破坏栈上的返回地址,导致程序崩溃
- 可能被攻击者利用,篡改返回地址执行恶意代码,是严重的安全漏洞
- 破坏其他内存数据,导致不可预知的错误
防范方法:
- 使用安全的字符串函数,比如strncpy、snprintf,指定最大长度
- 所有数组访问做边界检查
- 对用户输入的长度做严格校验
- 开启编译器的栈保护、地址随机化等安全选项
- 高版本编译器会默认开启缓冲区溢出保护
5. 双重释放(Double Free)
定义:对同一块内存调用free/delete两次。
产生原因:
- 错误的逻辑路径导致重复释放
- 多线程环境下不同线程同时释放同一块内存
- 释放后指针没有置空,后续误释放
危害:
- 破坏内存分配器的结构,导致程序崩溃
- 可能被攻击者利用,执行任意代码,是安全漏洞
防范方法:
- 释放内存后立即将指针置为NULL,free(NULL)是安全的
- 多线程环境下内存释放做好同步
- 使用智能指针管理内存,避免手动释放
6. 空指针解引用
定义:访问NULL指针指向的内存,会导致程序崩溃,段错误。
产生原因:
- 指针没有初始化就使用
- 内存分配失败,返回NULL,没有检查就直接使用
- 指针被提前释放置空,后续继续使用
防范方法:
- 指针使用前检查是否为NULL
- 内存分配后检查分配是否成功
- 避免在释放指针后继续使用
内存问题排查工具
1. C/C++ 工具
- Valgrind:强大的内存调试工具,可以检测内存泄漏、野指针、缓冲区溢出、双重释放等几乎所有内存问题,缺点是会让程序运行变慢很多。
- AddressSanitizer(ASAN):Google开发的内存检测工具,集成在GCC/Clang编译器中,编译时加上
-fsanitize=address参数即可,性能比Valgrind好很多,检测能力也很强,是现在的首选工具。 - gdb:程序崩溃时用gdb调试,查看调用栈,定位崩溃位置。
- mtrace:GNU C库自带的内存跟踪工具,检测内存泄漏。
2. Java 工具
- jmap:生成堆转储快照,查看内存使用情况
- jhat:分析堆转储文件
- jstat:实时查看JVM内存使用、GC情况
- VisualVM / JConsole:图形化JVM监控工具
- MAT(Memory Analyzer Tool):专业的堆内存分析工具,可以快速定位内存泄漏
3. Go 工具
- pprof:Go内置的性能分析工具,可以分析内存分配、CPU使用等
- go vet:静态代码检查,检测常见的代码问题
- dlv:Go语言调试器,可以调试内存相关问题
4. 通用工具
- top / htop:查看进程的内存占用变化
- free:查看系统整体内存使用情况
- ps:查看进程的内存使用
- /proc/pid/maps:查看进程的虚拟地址空间布局
- /proc/pid/smaps:查看进程各内存区域的详细使用情况
内存管理最佳实践
通用最佳实践
- 遵循最小权限原则:只分配需要的内存,不要分配多余的内存
- 谁分配谁释放:内存分配和释放放在同一层次,同一模块,避免跨模块释放内存
- 避免频繁分配释放小内存:频繁分配释放小内存会导致内存碎片,性能下降,可以使用内存池复用对象
- 检查分配结果:内存分配后要检查是否成功,不要假设分配一定成功
- 初始化内存:分配的内存要初始化,避免读取到垃圾数据
- 避免大对象分配:大对象会占用大量连续内存,容易导致内存碎片,尽量用小对象
- 合理设置缓存大小:缓存不要无限增长,设置过期策略和最大容量,避免内存泄漏
C/C++ 最佳实践
- 优先使用智能指针:C++11之后使用std::unique_ptr、std::shared_ptr等智能指针,自动管理内存,避免手动释放
- 不要使用不安全的函数:避免使用strcpy、strcat、sprintf等不安全的字符串函数,使用带长度参数的安全版本
- 打开编译警告:开启-Wall、-Wextra等编译警告,提前发现问题
- 使用内存检测工具:开发和测试阶段使用AddressSanitizer检测内存问题,提前发现bug
带GC语言最佳实践
- 避免不必要的对象创建:减少GC的压力
- 不要不必要地持有对象引用:避免长生命周期对象持有短生命周期对象的引用,导致无法回收
- 及时释放资源:流、连接、文件等资源使用完及时关闭,最好用try-with-resources等语法自动关闭
- 合理设置GC参数:根据应用特点调整GC策略和内存大小
- 避免内存泄漏:注意静态集合、监听器、ThreadLocal等常见的内存泄漏点
不同编程语言的内存管理模型
| 语言 | 内存管理方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| C/C++ | 手动管理,malloc/new分配,free/delete释放 | 性能最高,内存完全可控 | 容易出现内存问题,开发效率低 | 高性能、底层开发、资源受限场景 |
| Rust | 所有权机制,编译期检查内存安全 | 性能高,内存安全,没有GC | 学习曲线陡峭,开发效率一般 | 系统编程、高性能、安全敏感场景 |
| Java/Go | 自动垃圾回收 | 开发效率高,不需要手动管理内存 | 有GC开销,需要调优 | 后端服务、业务开发、大部分通用场景 |
| Python/JS/PHP | 自动垃圾回收,解释执行 | 开发效率极高 | 性能较低,内存占用高 | 脚本、前端、快速开发场景 |
| Objective-C/Swift | ARC自动引用计数 | 编译期自动插入释放代码,没有GC停顿 | 循环引用需要手动处理 | Apple平台开发 |
思考问题
- 什么是内存泄漏?如何排查和定位内存泄漏问题?
- 野指针和空指针有什么区别?分别会导致什么问题?
- 缓冲区溢出为什么会有安全风险?如何防范缓冲区溢出?
- 带GC的语言还会有内存泄漏吗?如果有,是什么原因导致的?
练习题与扩展阅读
练习题
基础题
- 简述CPU缓存的层次结构,各级缓存的特点和速度差异。
- 什么是局部性原理?时间局部性和空间局部性分别是什么?举一个你编程中利用局部性原理优化性能的例子。
- 什么是虚拟内存?它有什么优势?
- 常见的内存问题有哪些?分别会导致什么后果?
- 什么是TLB?它的作用是什么?TLB miss会有什么影响?
实操题
- 编写两个二维数组遍历的程序,一个按行优先遍历,一个按列优先遍历,对比两者的运行时间,解释差异原因。
- 编写一个简单的程序,故意制造内存泄漏,然后用Valgrind或者AddressSanitizer检测这个内存泄漏,看能不能定位到问题。
- 查看你电脑的CPU缓存大小、行大小等信息:
- Linux:
lscpu命令 - Windows:任务管理器 → CPU详情
- Linux:
- 用top或者任务管理器观察你常用软件的内存占用变化,看看哪些软件有内存泄漏的迹象。
思考题
- 为什么CPU需要多级缓存?直接用一个大容量的高速缓存不行吗?
- 什么是伪共享?如何在编程中避免伪共享问题?
- 分页机制中,为什么使用多级页表而不是单级页表?
- 现在内存价格越来越便宜,还有必要优化程序的内存使用吗?为什么?
扩展阅读
书籍推荐
-
《深入理解计算机系统》
- 第6章存储器层次结构、第9章虚拟内存等章节,深入讲解了内存管理和缓存原理,是本章内容的绝佳补充
- 每个程序员都应该读的经典书籍
-
《计算机体系结构:量化研究方法》
- 第2章存储器层次结构,深入讲解了缓存、虚拟内存的设计和性能分析
- 适合想要深入理解计算机体系结构的开发者
-
《C专家编程》
- 第7章对内存、指针和内存管理有很精彩的讲解,适合C/C++开发者阅读
-
《垃圾回收的算法与实现》
- 全面讲解了各种垃圾回收算法的原理和实现,适合想要深入理解GC的开发者
在线资源
-
What Every Programmer Should Know About Memory
- 经典的内存知识科普文章,每个程序员都应该读一遍,深入讲解了内存硬件、缓存、虚拟内存等知识,以及如何写出对内存友好的代码
-
- 可视化讲解CPU缓存的工作原理,非常直观易懂
-
- Linux内核文档中关于缓存和内存屏障的讲解,适合底层开发人员阅读
-
- 讲解Go语言内存分配器的实现原理,适合Go开发者
工具推荐
- perf:Linux下的性能分析工具,可以分析缓存命中率、TLB命中率、CPU周期等性能指标
- Valgrind:强大的内存调试工具,可以检测内存泄漏、野指针、缓冲区溢出等问题
- AddressSanitizer:集成在GCC/Clang中的内存检测工具,性能比Valgrind好,检测能力强
- MAT(Memory Analyzer Tool):Java堆内存分析工具,快速定位内存泄漏
- pprof:Go和C++的性能分析工具,可以分析内存分配情况
- cachegrind:Valgrind的工具之一,可以模拟CPU缓存,分析程序的缓存命中率
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第7章:进程与线程
学习目标
通过本章学习,你将能够:
- 理解进程和线程的基本概念和区别
- 掌握进程调度的基本算法和原理
- 理解常见的进程间通信机制和适用场景
- 掌握同步与互斥的原理和常用方法(锁、信号量、条件变量等)
- 能够识别和解决常见的并发问题(死锁、竞态条件等)
- 理解不同编程语言的并发模型特点
章节简介
进程和线程是操作系统的核心概念,也是并发编程的基础。现代计算机都是多核CPU,并发编程已经成为开发者必备的技能,理解进程和线程的原理,能够帮助你写出高效、稳定的并发程序,解决并发场景下的各种问题。本章将从进程的基本概念讲起,系统讲解线程与并发、进程调度、进程间通信、同步与互斥等核心内容,以及编程中常见的并发问题和解决方案。
本章内容
- 进程的定义和特征
- 进程的状态与生命周期
- 进程控制块(PCB)
- 进程的创建、销毁和层次结构
- 线程的定义和优势
- 进程与线程的区别
- 多线程的实现模型(用户级线程、内核级线程、混合型)
- 上下文切换的原理和开销
- 调度的概念和目标
- 常见的调度算法(FCFS、SJF、RR、优先级调度、多级反馈队列等)
- Linux和Windows的调度机制
- 调度的性能指标(吞吐量、响应时间、周转时间等)
- 进程间通信的目的和分类
- 常见IPC机制:管道、消息队列、共享内存、信号量、Socket、信号等
- 各种IPC机制的优缺点和适用场景
- 进程同步与通信的关系
- 竞态条件与临界区问题
- 互斥锁、读写锁、自旋锁的原理和适用场景
- 信号量、条件变量的使用
- 死锁的产生条件、预防和检测方法
- 常见并发问题:竞态条件、死锁、活锁、饥饿
- 并发问题的调试和排查工具
- 并发编程的最佳实践
- 不同编程语言的并发模型对比
学习建议
本章内容是并发编程的基础,对于后端开发、高性能服务开发尤为重要。建议结合实际的并发编程场景学习,比如多线程下载、并发服务器等,动手写一些并发程序,体会并发编程的优势和容易遇到的问题。同步与互斥、死锁等概念比较抽象,可以结合实际例子理解,多做相关的练习题加深印象。
难度:★★★★☆
预计学习时间:4.5小时
7.1 进程的概念
进程(Process)是操作系统进行资源分配和调度的基本单位,是程序运行的实例。我们平时打开的应用程序、运行的服务都是进程。理解进程的基本概念,是理解整个操作系统和并发编程的基础。
什么是进程
进程是程序的一次执行过程,是操作系统分配资源(内存、CPU时间、文件句柄等)的基本单位。
进程和程序的区别
- 程序:是静态的指令和数据的集合,存储在磁盘上,是永久的
- 进程:是程序的动态执行过程,有生命周期,是暂时的,同一个程序可以对应多个进程(比如打开多个浏览器窗口,就是同一个浏览器程序的多个进程)
进程的组成
一个进程通常包含以下内容:
- 代码段:程序的可执行代码
- 数据段:全局变量和静态变量
- 堆:动态分配的内存(malloc/new分配的内存)
- 栈:函数调用栈,存储局部变量、函数参数、返回地址等
- 进程控制块(PCB):操作系统管理进程的数据结构,存储进程的所有信息
- 打开的文件描述符:进程打开的文件、Socket等资源
- 信号处理函数:进程对各种信号的处理方式
- 地址空间:独立的虚拟地址空间
进程的特征
进程具有以下基本特征:
- 动态性:进程是程序的一次执行过程,有生命周期,状态会不断变化
- 并发性:多个进程可以同时在系统中运行,宏观上并行,微观上交替执行
- 独立性:每个进程有独立的地址空间,进程之间互相隔离,一个进程的崩溃不会影响其他进程
- 异步性:进程以不可预知的速度向前推进,操作系统需要保证进程执行的结果是确定的
- 结构性:每个进程都有自己的代码段、数据段、栈、PCB等结构
进程的状态与生命周期
一个进程在其生命周期中会处于不同的状态,通常有五种基本状态:
1. 新建状态(New)
进程正在被创建,操作系统正在为其分配资源和初始化PCB,还没有准备好运行。
2. 就绪状态(Ready)
进程已经准备好运行,获得了除CPU之外的所有资源,等待被调度器分配CPU时间。
- 系统中会有一个就绪队列,存放所有处于就绪状态的进程
3. 运行状态(Running)
进程正在CPU上执行指令,单核CPU同一时间只能有一个进程处于运行状态,多核CPU可以有多个进程同时运行。
4. 阻塞状态(Blocked / Waiting)
进程正在等待某个事件发生(比如等待IO完成、等待信号、等待锁),暂时无法运行,即使CPU空闲也不能执行。
- 等待的事件发生后,进程会进入就绪状态,等待调度
5. 终止状态(Terminated)
进程执行完成或者被异常终止,等待操作系统回收资源。
状态转换
新建 → 就绪 → 运行 → 终止
↓↑ ↓↑
阻塞 ←
- 就绪 → 运行:调度器选择一个就绪进程分配CPU
- 运行 → 就绪:进程时间片用完,或者被更高优先级的进程抢占
- 运行 → 阻塞:进程等待某个事件(IO、锁、信号等)
- 阻塞 → 就绪:等待的事件发生了
- 运行 → 终止:进程执行完成,或者被杀死
进程控制块(PCB)
进程控制块(Process Control Block)是操作系统中用来描述和管理进程的数据结构,每个进程对应一个唯一的PCB,存储了进程的所有信息。
PCB通常包含以下信息:
- 进程标识信息:进程ID(PID)、父进程ID(PPID)、用户ID(UID)、组ID(GID)等
- 进程状态信息:当前状态(就绪、运行、阻塞等)、优先级、调度相关信息
- 进程控制信息:程序计数器(PC,下一条要执行的指令地址)、寄存器的值(上下文切换时保存)
- 内存管理信息:页表指针、虚拟地址空间信息、内存使用情况
- IO信息:打开的文件描述符列表、IO设备使用情况
- 统计信息:CPU使用时间、内存使用情况、IO操作统计等
操作系统通过PCB来管理所有进程,进程切换时,操作系统会保存当前进程的上下文到PCB中,然后加载下一个进程的上下文。
进程的创建与销毁
进程的创建
操作系统创建进程的通常步骤:
- 分配一个唯一的PID,创建PCB
- 为进程分配地址空间和资源
- 初始化PCB,设置默认的进程状态、优先级等
- 将进程加入就绪队列,等待调度执行
常见的创建进程的系统调用:
- Unix/Linux:
fork()创建子进程,exec()系列函数加载新的程序 - Windows:
CreateProcess()函数创建新进程
fork()的特点
Unix/Linux中的fork()系统调用会创建一个和父进程几乎完全一样的子进程:
- 子进程获得父进程地址空间的副本,父子进程有独立的地址空间,修改各自的数据不会互相影响
fork()调用一次返回两次:父进程返回子进程的PID,子进程返回0- 子进程会继承父进程的打开的文件描述符、信号处理方式、优先级等
- 通常
fork()之后会调用exec()加载新的程序,替换子进程的地址空间
进程的销毁
进程终止的方式:
- 正常终止:进程执行完成,调用
exit()系统调用主动退出,返回退出状态码 - 异常终止:进程运行时出现错误(比如段错误、除零错误),被操作系统终止
- 被其他进程杀死:其他进程调用
kill()系统调用发送终止信号
进程终止后,操作系统会回收进程占用的资源(内存、文件句柄等),但PCB会保留一段时间,保存退出状态码,直到父进程调用wait()或者waitpid()获取退出状态,这时候PCB才会被完全回收。如果父进程没有调用wait(),已经终止的子进程会变成僵尸进程(Zombie Process)。
进程的层次结构
进程之间有父子关系,形成树形的层次结构:
- 每个进程都有一个父进程,除了系统启动时创建的init进程(PID=1)
- 父进程创建子进程,子进程可以再创建自己的子进程
- 父进程终止时,子进程会被init进程收养
常见的特殊进程
- init进程(PID=1):系统启动后运行的第一个用户态进程,负责启动其他系统服务,是所有进程的祖先。
- 僵尸进程:子进程已经终止,但父进程没有调用
wait()回收它的PCB,子进程的PCB仍然存在,占用PID资源,需要避免僵尸进程。 - 孤儿进程:父进程已经终止,子进程还在运行,会被init进程收养,由init进程负责回收。
- 守护进程(Daemon Process):在后台运行的服务进程,没有控制终端,通常系统服务都是守护进程,比如httpd、sshd等。
进程的并发执行
多个进程可以同时在系统中运行,宏观上是并行的,用户感觉多个程序同时在执行;微观上,单核CPU上进程是交替执行的,通过快速的上下文切换,让用户感觉是同时运行的。
进程并发执行的好处:
- 提高CPU利用率,避免CPU等待IO时空闲
- 提高系统吞吐量,同时处理多个任务
- 改善用户体验,用户可以同时运行多个程序,不会因为一个程序卡住而影响其他程序
思考问题
- 进程和程序有什么区别?同一个程序可以对应多个进程吗?举例说明。
- 进程有哪几种基本状态?它们之间是如何转换的?
- 什么是PCB?它存储了哪些信息?为什么需要PCB?
- 什么是僵尸进程和孤儿进程?它们有什么危害?如何避免僵尸进程?
7.2 线程与并发
线程(Thread)是CPU调度的基本单位,是进程中的一个执行流。多线程技术让一个进程可以同时执行多个任务,充分利用多核CPU的性能,是现代并发编程的基础。
为什么需要线程
早期的操作系统只有进程的概念,每个进程有独立的地址空间,进程之间切换开销很大。随着多核CPU的出现,人们需要一种更轻量的执行单元,能够在同一个进程内并发执行,共享进程的资源,减少切换开销,线程就应运而生了。
相比多进程,多线程有以下优势:
- 创建和切换开销小:线程的创建、销毁、切换比进程快很多,因为线程共享进程的地址空间和资源,不需要重新分配资源
- 通信方便:同一个进程内的线程共享同一块地址空间,可以直接通过全局变量、堆内存通信,不需要复杂的进程间通信机制
- 资源利用率高:一个进程内的多个线程可以共享进程的内存、文件句柄等资源,不需要重复分配
- 充分利用多核CPU:多线程可以在多个CPU核心上并行执行,提升程序性能
什么是线程
线程是进程中的一个执行流,是CPU调度和分派的基本单位。
- 一个进程可以包含多个线程,至少有一个主线程
- 同一个进程内的所有线程共享进程的地址空间和资源
- 每个线程有自己独立的栈、寄存器上下文、线程本地存储(TLS)
进程和线程的区别
| 特性 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 资源分配的基本单位,有独立的地址空间和资源 | CPU调度的基本单位,共享进程的资源,只有少量独立资源(栈、寄存器等) |
| 切换开销 | 大,需要切换地址空间、页表、缓存等 | 小,只需要切换栈和寄存器 |
| 通信 | 需要进程间通信机制(管道、共享内存等) | 可以直接通过全局变量、堆内存通信,需要注意同步问题 |
| 安全性 | 高,进程之间地址空间隔离,一个进程崩溃不会影响其他进程 | 低,线程之间共享地址空间,一个线程崩溃可能导致整个进程崩溃 |
| 并发性 | 较低,切换开销大 | 较高,切换开销小,适合高并发场景 |
例子:你打开一个浏览器进程,浏览器会有多个线程:一个线程渲染页面,一个线程处理用户输入,一个线程下载资源,多个线程同时工作,提升用户体验。如果每个任务都开一个进程,开销会非常大。
线程的实现模型
线程的实现有三种模型:用户级线程、内核级线程、混合型线程。
1. 用户级线程(User-Level Thread, ULT)
用户级线程完全在用户态实现,由用户态的线程库管理,操作系统内核感知不到线程的存在。
- 优点:
- 线程切换不需要内核参与,开销极小,速度快
- 调度可以由应用程序自己控制,更灵活
- 不需要内核支持,可以在不支持线程的操作系统上实现
- 缺点:
- 内核不知道线程的存在,如果一个线程发起阻塞的系统调用(比如read文件),整个进程都会被阻塞,其他线程也无法执行
- 无法真正利用多核CPU,操作系统调度的单位是进程,同一个进程的多个线程只能在同一个核心上交替执行
- 现在纯用户级线程已经很少使用了,早期的协程就是用户级线程的一种。
2. 内核级线程(Kernel-Level Thread, KLT)
内核级线程由操作系统内核管理,内核负责线程的调度和切换。
- 优点:
- 线程的调度由内核负责,如果一个线程阻塞,其他线程还可以继续执行
- 可以真正利用多核CPU,同一个进程的多个线程可以同时在不同的核心上并行执行
- 内核提供完整的线程功能,支持复杂的调度策略
- 缺点:
- 线程切换需要内核参与,开销比用户级线程大
- 频繁的线程切换会消耗大量CPU资源
- 现在主流操作系统的线程实现都是内核级线程,比如Linux的pthread,Windows的线程。
3. 混合型线程(N:M模型)
混合型线程结合了用户级线程和内核级线程的优点,用户态的多个用户级线程映射到内核的多个内核级线程上。
- 优点:
- 兼顾用户级线程的低切换开销和内核级线程的并发性
- 用户级线程切换开销小,内核级线程可以利用多核CPU
- 缺点:实现复杂,需要用户态和内核态之间的协调
- Go语言的goroutine调度就是N:M模型的实现,M个内核线程上运行G个goroutine,通过Go runtime调度。
上下文切换
上下文切换(Context Switch)是指CPU从一个进程/线程切换到另一个进程/线程执行的过程。
上下文切换的过程
- 保存当前进程/线程的上下文(程序计数器、寄存器的值、栈指针等)到PCB/TCB中
- 加载下一个要执行的进程/线程的上下文,恢复寄存器的值
- 跳转到程序计数器指向的位置,继续执行
上下文切换的开销
上下文切换有一定的开销,主要包括:
- 直接开销:保存和恢复寄存器、切换页表、刷新缓存等操作的CPU时间
- 间接开销:切换后CPU缓存失效,需要重新从内存加载数据,缓存命中率下降,导致程序执行变慢
线程切换的开销比进程切换小很多,因为线程不需要切换地址空间和页表,只需要切换寄存器和栈。
什么时候会发生上下文切换
- 进程/线程的时间片用完了
- 高优先级的进程/线程抢占CPU
- 进程/线程发起阻塞的系统调用(IO、锁、信号等),主动让出CPU
- 中断发生时
注意:过多的上下文切换会消耗大量CPU时间,降低系统性能。比如创建太多线程,会导致频繁的上下文切换,CPU大部分时间都花在切换上,真正执行任务的时间反而很少。
并发 vs 并行
并发(Concurrency)
并发是指多个任务在宏观上同时进行,微观上可能是交替执行的。比如单核CPU上运行多个进程,快速切换,让用户感觉多个程序同时在运行,这就是并发。
并行(Parallelism)
并行是指多个任务在物理上同时执行,需要多核CPU的支持,多个核心同时执行不同的任务。
区别:
- 并发是逻辑上的同时发生,并行是物理上的同时发生
- 单核CPU只能实现并发,不能实现并行
- 多核CPU可以同时实现并发和并行
多线程的性能收益
- CPU密集型任务:计算密集型任务,多线程可以并行执行,加速比接近核心数,理论上n核CPU可以提升n倍性能
- IO密集型任务:IO密集型任务大部分时间在等待IO,多线程可以在等待的时候切换执行其他任务,提升系统吞吐量,即使是单核CPU也能通过多线程提升性能
常见的线程模型
1. 一对一模型(1:1)
一个用户级线程对应一个内核级线程,是现在主流操作系统使用的模型,比如Linux的pthread,Windows的线程。
- 优点:实现简单,支持真正的并行,一个线程阻塞不影响其他线程
- 缺点:线程切换开销较大,线程数量不能太多,通常最多几千个线程
2. 多对多模型(N:M)
N个用户级线程映射到M个内核级线程,比如Go语言的goroutine,Erlang的进程。
- 优点:切换开销小,可以支持大量的并发(几十万甚至上百万),兼顾并发性和性能
- 缺点:实现复杂,需要用户态调度器的支持
3. 多对一模型(N:1)
N个用户级线程映射到1个内核级线程,也就是纯用户级线程,现在很少使用。
线程的组成
每个线程有自己独立的资源:
- 线程栈:每个线程有独立的栈,存储函数调用栈、局部变量,默认大小通常是1MB-8MB
- 寄存器上下文:程序计数器、栈指针、通用寄存器的值,线程切换时保存和恢复
- 线程ID(TID):每个线程有唯一的标识
- 线程本地存储(TLS):线程私有的全局变量,每个线程有独立的副本,其他线程访问不到
- 信号掩码:线程可以独立屏蔽某些信号
同一个进程内的线程共享以下资源:
- 进程的地址空间(代码段、数据段、堆、共享库等)
- 进程打开的文件描述符、Socket
- 进程的用户ID、组ID
- 信号处理函数
- 进程的当前工作目录
思考问题
- 进程和线程有什么区别?什么时候应该用多进程,什么时候应该用多线程?
- 上下文切换的开销主要来自哪里?为什么线程切换比进程切换快?
- 并发和并行有什么区别?单核CPU可以实现并行吗?
- 用户级线程和内核级线程各有什么优缺点?现在主流的实现是哪种?
7.3 进程调度
进程调度是操作系统的核心功能之一,负责选择下一个要运行的进程/线程,分配CPU时间。调度算法的好坏直接影响系统的性能和用户体验。
调度的基本概念
什么是调度
在多任务操作系统中,同时运行的进程/线程数量通常多于CPU核心数量,操作系统需要决定哪个进程/线程获得CPU的使用权,这个决策过程就是调度。
调度的目标
不同的系统有不同的调度目标:
- 高CPU利用率:让CPU尽可能保持忙碌,减少空闲时间
- 高吞吐量:单位时间内完成的任务数量尽可能多
- 短响应时间:交互式任务的响应时间尽可能短,用户体验好
- 短周转时间:任务从提交到完成的时间尽可能短
- 公平性:每个进程/线程都能获得公平的CPU时间,避免饥饿
- 实时性:实时任务能在规定的时间内完成
这些目标之间往往是冲突的,比如要提高吞吐量可能会牺牲响应时间,调度算法需要在多个目标之间权衡。
调度的层次
调度分为三个层次:
- 长程调度(作业调度):选择哪些作业可以进入系统,分配资源,创建进程,现在的操作系统通常没有长程调度
- 中程调度(交换调度):负责进程在内存和磁盘之间的换入换出,平衡内存使用
- 短程调度(进程调度):选择下一个要运行的进程/线程,是最频繁的调度,我们通常说的调度就是指短程调度
抢占式调度 vs 非抢占式调度
- 非抢占式调度:进程一旦获得CPU就一直运行,直到主动让出CPU(阻塞或者执行完成),实现简单,但是响应时间长,不适合交互式系统
- 抢占式调度:操作系统可以强制剥夺当前进程的CPU使用权,分配给其他进程,响应时间短,用户体验好,是现在主流操作系统的调度方式
- 抢占时机:时间片用完、更高优先级进程就绪、系统调用返回、中断返回时
常见的调度算法
1. 先来先服务(FCFS, First Come First Served)
- 原理:按照进程到达就绪队列的顺序调度,先到的先运行
- 优点:实现简单,公平
- 缺点:平均周转时间长,短任务可能会被前面的长任务阻塞,出现“护航效应“,不适合交互式系统
- 适用场景:批处理系统,任务执行时间比较平均的场景
2. 短作业优先(SJF, Shortest Job First)
- 原理:优先选择估计运行时间最短的进程运行
- 优点:平均周转时间最短,吞吐量高
- 缺点:
- 需要提前知道进程的运行时间,实际中很难估计
- 长作业可能会一直被短作业抢占,出现饥饿现象,永远得不到运行
- 是非抢占式的,不适合交互式系统
- 抢占式版本:最短剩余时间优先(SRTF),新的短作业到达时,如果剩余运行时间比当前运行的进程短,就抢占CPU,性能更好,但复杂度更高
3. 时间片轮转(RR, Round Robin)
- 原理:每个进程分配一个固定大小的时间片,进程运行完时间片就被抢占,放到就绪队列末尾,轮流执行
- 时间片大小的选择:
- 时间片太大:退化为FCFS算法,响应时间长
- 时间片太小:上下文切换太频繁,浪费CPU时间,吞吐量下降
- 合适的时间片:通常是10-100ms,让大部分交互式任务能在一个时间片内完成
- 优点:公平,响应时间短,适合交互式系统,是分时系统的经典调度算法
- 缺点:平均周转时间比SJF长,上下文切换开销大
4. 优先级调度(Priority Scheduling)
- 原理:每个进程有一个优先级,优先选择优先级最高的进程运行
- 优先级分类:
- 静态优先级:进程创建时确定优先级,运行过程中不变
- 动态优先级:运行过程中调整优先级,比如等待时间长的进程优先级升高,运行时间长的进程优先级降低,避免饥饿
- 抢占式版本:高优先级进程到达时可以抢占低优先级进程的CPU
- 优点:可以给重要的任务分配高优先级,保证关键任务的响应时间
- 缺点:低优先级进程可能会一直得不到运行,出现饥饿,需要动态调整优先级或者老化机制解决
- 适用场景:需要区分任务优先级的场景,比如实时系统、服务器系统
5. 多级反馈队列(Multilevel Feedback Queue)
- 原理:结合了RR和优先级调度的优点,是现在最通用的调度算法
- 实现方式:
- 设置多个就绪队列,每个队列有不同的优先级,最高优先级队列时间片最短,越低的队列时间片越长
- 新进程首先进入最高优先级队列,按RR调度
- 如果进程在时间片内完成,就退出系统
- 如果时间片用完还没完成,就降到下一级低优先级队列
- 低优先级队列的进程如果等待时间太长,可以升到高优先级队列(老化机制,避免饥饿)
- 调度时优先选择高优先级队列中的进程,只有高优先级队列为空时才调度低优先级队列
- 优点:
- 兼顾短作业和长作业,短作业在高优先级队列很快完成,长作业在低队列慢慢运行
- 响应时间短,适合交互式任务
- 不需要提前知道进程的运行时间,适应性强
- 适用场景:通用操作系统,比如Windows、Linux、macOS的调度算法都是基于多级反馈队列改进的
6. 实时调度算法
实时系统对任务的完成时间有严格要求,调度算法需要保证任务在截止时间前完成:
- 速率单调调度(RMS):静态优先级调度,周期越短优先级越高,适合硬实时系统
- 最早截止时间优先(EDF):动态优先级调度,截止时间越早优先级越高,利用率更高
Linux的调度机制
Linux的调度器经历了几次演变:
- O(1)调度器:Linux 2.6内核使用,调度时间复杂度O(1),适合大服务器,但对交互式任务支持不好
- CFS调度器(完全公平调度器):Linux 2.6.23之后使用,是现在的默认调度器
CFS调度器的原理
CFS的核心思想是“完全公平“,给每个进程公平的CPU时间:
- 每个进程有一个虚拟运行时间(vruntime),进程运行的时间越长,vruntime越大
- 调度器总是选择vruntime最小的进程运行
- 优先级高的进程运行相同的物理时间,vruntime增长更慢,相当于获得更多的CPU时间
- 用红黑树来管理就绪队列,找到最小vruntime的进程效率很高
- 自动适应不同的负载,根据进程数量动态调整时间片大小
- 支持组调度、带宽控制等高级特性
Linux的进程优先级
- 实时进程优先级:0-99,数值越大优先级越高,用实时调度算法(FIFO、RR)
- 普通进程优先级:100-139,对应nice值-20到+19,nice值越低优先级越高,默认nice值是0
Windows的调度机制
Windows也是基于多级反馈队列的抢占式调度:
- 共32个优先级,0-31
- 0级:系统使用,用于零页线程
- 1-15级:普通优先级,动态调整
- 16-31级:实时优先级,固定不变
- 优先级提升:前台窗口进程、IO完成的进程、等待事件的进程会提升优先级
- 时间片大小:根据版本不同,时间片通常是20ms左右,前台进程的时间片更长
调度的性能指标
衡量调度算法性能的常用指标:
- CPU利用率:CPU处于忙状态的时间比例,越高越好
- 吞吐量:单位时间内完成的进程数量,越高越好
- 周转时间:进程从提交到完成的总时间,越短越好
- 周转时间 = 完成时间 - 提交时间
- 带权周转时间 = 周转时间 / 服务时间,衡量单位服务时间的等待时间
- 等待时间:进程在就绪队列中等待的总时间,越短越好
- 响应时间:从用户提交请求到系统第一次响应的时间,对于交互式系统越短越好
调度优化建议
对于应用开发者,我们可以通过一些方式配合调度器提升程序性能:
- 合理设置线程数量:不要创建太多线程,避免频繁上下文切换,CPU密集型任务线程数≈CPU核心数,IO密集型任务可以适当多一点
- 合理设置优先级:给关键任务设置更高的优先级,但不要滥用高优先级,避免优先级反转
- 避免忙等待:等待事件时使用阻塞等待,不要占着CPU空转,浪费CPU资源
- CPU亲和性:把进程/线程绑定到特定的CPU核心上运行,提高缓存命中率,减少上下文切换开销
- 避免不必要的优先级调整:不要频繁修改线程优先级,会干扰调度器的正常调度
思考问题
- 调度算法的主要目标是什么?这些目标之间有什么冲突?
- 时间片轮转算法中,时间片太大或太小会有什么问题?如何选择合适的时间片?
- 多级反馈队列调度算法有什么优点?为什么它是现在主流操作系统的首选调度算法?
- 什么是优先级反转?如何解决优先级反转问题?
7.4 进程间通信
进程间通信(Inter-Process Communication,IPC)是指不同进程之间交换数据和信号的机制。因为进程之间的地址空间是隔离的,每个进程都有自己独立的虚拟地址空间,一个进程无法直接访问另一个进程的内存,所以需要操作系统提供专门的IPC机制来实现进程间的数据交换。
IPC的目的和分类
IPC的目的
- 数据传输:一个进程需要将数据发送给另一个进程
- 共享数据:多个进程需要共享同一份数据,一个进程修改了数据,其他进程可以看到修改
- 通知事件:一个进程需要向另一个或多个进程发送通知,比如进程终止、IO完成等
- 资源共享:多个进程之间共享有限的资源(比如内存、文件、设备等)
- 进程控制:一个进程需要控制另一个进程的执行(比如debugger控制目标进程)
IPC的分类
根据通信方式和使用场景,IPC可以分为以下几类:
- 单主机IPC:同一台主机上的进程之间通信
- 传统IPC:管道、信号、消息队列、共享内存、信号量
- 套接字IPC:Unix Domain Socket
- 跨主机IPC:不同主机上的进程之间通信,通常使用网络套接字(Socket)、RPC等
常见的IPC机制
1. 管道(Pipe)
管道是最古老也是最简单的IPC机制,通常用于父子进程之间的通信。
- 匿名管道:
- 是半双工的,数据只能单向流动,一端读一端写
- 只能用于有亲缘关系的进程之间(父子进程、兄弟进程)
- 本质是内核中的一块缓冲区,数据写入缓冲区的一端,从另一端读出
- 遵循FIFO(先进先出)的原则
- 用法:Shell中的
|就是管道,比如ps aux | grep nginx,把ps命令的输出作为grep命令的输入
- 命名管道(FIFO):
- 有名字的管道,在文件系统中存在一个FIFO文件
- 可以用于任意两个进程之间的通信,不需要有亲缘关系
- 也是半双工的
- 可以通过文件系统路径访问
- 优点:简单易用,适合父子进程之间传递少量数据
- 缺点:半双工,不适合大量数据传输,只能单向传输,匿名管道只能用于亲缘进程
- 适用场景:父子进程之间简单的数据传输,Shell命令中的管道
2. 信号(Signal)
信号是一种异步通信机制,用于通知进程某个事件发生了,是最简单的异步通知机制。
- 常见的信号:
- SIGINT:Ctrl+C发送的中断信号,默认终止进程
- SIGKILL:强制杀死进程,不能被捕获和忽略
- SIGSEGV:段错误信号,访问非法内存时发送
- SIGCHLD:子进程终止时发送给父进程的信号
- 进程可以设置信号的处理方式:
- 忽略信号(SIGKILL和SIGSTOP不能忽略)
- 捕获信号,执行自定义的信号处理函数
- 执行默认操作
- 优点:非常轻量,适合异步通知事件,开销极小
- 缺点:只能传递简单的信号编号,不能传递复杂数据,信号可能会丢失,不适合可靠的数据传输
- 适用场景:进程间的简单事件通知,异常处理,进程终止通知等
3. 消息队列(Message Queue)
消息队列是操作系统维护的一个消息链表,进程可以向队列中发送消息,也可以从队列中读取消息。
- 特点:
- 消息是有类型的,接收进程可以按类型读取消息,不需要先进先出
- 消息是离散的,每个消息有固定的大小上限
- 内核维护消息队列,即使进程退出,消息队列仍然存在
- 支持多对多通信,多个进程可以向同一个队列发消息,也可以从同一个队列读消息
- 优点:
- 支持异步通信,发送方发完消息就可以返回,不需要等待接收方
- 支持消息类型,灵活度高
- 消息可以持久化,即使接收方没准备好,消息也不会丢失
- 缺点:
- 消息有大小限制,不适合大量数据传输
- 有用户态和内核态之间的数据拷贝开销
- 接口比较复杂
- 适用场景:不同进程之间传递小的结构化消息,任务队列等
4. 共享内存(Shared Memory)
共享内存是最快的IPC机制,允许多个进程共享同一块物理内存区域,多个进程可以直接读写这块内存,不需要内核介入,几乎没有额外开销。
- 原理:多个进程的虚拟地址空间映射到同一块物理内存页,一个进程修改了共享内存的内容,其他进程可以立即看到
- 注意:共享内存本身没有同步机制,多个进程同时读写共享内存会出现竞态条件,需要配合信号量、互斥锁等同步机制使用
- 优点:速度最快,没有数据拷贝开销,适合大量数据传输
- 缺点:没有同步机制,需要自己实现同步,使用复杂,容易出现并发问题
- 适用场景:大量数据的高效传输,比如视频处理、大数据计算等需要在进程间传递大量数据的场景
5. 信号量(Semaphore)
信号量主要用于进程间的同步和互斥,保护共享资源,不是用来传输数据的。
- 原理:信号量是一个计数器,用来表示可用资源的数量
- P操作(wait):信号量减1,如果减到小于0,进程阻塞等待
- V操作(post):信号量加1,如果有等待的进程,唤醒一个
- 二值信号量:信号量的值只能是0或1,相当于互斥锁,用来保护临界区
- 计数信号量:信号量的值可以大于1,用来表示可用资源的数量
- 优点:轻量,高效,适合进程间的同步和互斥
- 缺点:只能用来同步,不能传输数据,使用不当容易出现死锁
- 适用场景:保护共享资源,实现进程间的同步和互斥,配合共享内存使用
6. Unix域套接字(Unix Domain Socket)
Unix域套接字是专门用于同一主机上进程间通信的套接字,和网络套接字接口类似,但不需要经过网络协议栈,效率更高。
- 特点:
- 支持TCP(流套接字)和UDP(数据报套接字)两种模式
- 接口和网络Socket几乎一样,容易使用,代码可以和网络Socket复用
- 比网络Socket快很多,不需要网络协议栈处理,没有校验和计算、序号确认等开销
- 支持在进程之间传递文件描述符
- 优点:
- 功能强大,支持可靠的字节流和数据报传输,不需要处理分片、重传等问题
- 接口通用,和网络编程接口一致,学习成本低
- 效率比管道、消息队列更高
- 可以跨平台使用(Windows也有类似的实现)
- 缺点:比共享内存慢,有数据拷贝开销
- 适用场景:同一主机上不同进程之间的通用通信,比如服务和客户端之间的通信,现在很多服务都用Unix域套接字作为本地通信的首选
7. 网络套接字(Socket)
网络套接字用于不同主机之间的进程通信,当然也可以用于同一主机内的进程通信。
- 常见的有TCP和UDP套接字
- 优点:跨网络,支持不同主机之间的通信,功能强大
- 缺点:同一主机内通信的话,效率比Unix域套接字低,有网络协议栈开销
- 适用场景:跨主机的分布式通信
各种IPC机制的对比
| IPC机制 | 有无数据拷贝 | 通信方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 匿名管道 | 有 | 半双工,亲缘进程 | 父子进程简单通信 | 简单易用 | 只能单向,亲缘进程,数据量小 |
| 命名管道 | 有 | 半双工,任意进程 | 无亲缘进程简单通信 | 简单,任意进程 | 半双工,数据量小 |
| 信号 | 无 | 异步 | 事件通知 | 轻量,开销小 | 不能传复杂数据,不可靠 |
| 消息队列 | 有 | 全双工,任意进程 | 小结构化消息传递 | 异步,可靠,支持消息类型 | 有大小限制,拷贝开销 |
| 共享内存 | 无 | 全双工,任意进程 | 大量数据高速传输 | 速度最快,无拷贝 | 无同步,需要自己加锁 |
| 信号量 | 无 | - | 同步互斥 | 高效,轻量 | 不能传数据 |
| Unix域套接字 | 有 | 全双工,任意进程 | 通用本地通信 | 功能强大,接口通用,高效 | 有拷贝开销,比共享内存慢 |
| 网络套接字 | 有 | 全双工,跨主机 | 分布式跨网络通信 | 跨网络,通用 | 效率低,协议开销大 |
IPC选择建议
- 简单事件通知:用信号
- 父子进程简单数据传输:用匿名管道
- 大量数据高速传输:用共享内存 + 信号量/互斥锁
- 本地通用服务通信:优先用Unix域套接字,接口通用,效率高
- 小的结构化消息传递:可以用消息队列,现在更常用的是Unix域套接字或者消息队列中间件
- 跨主机通信:用网络套接字或者RPC框架
思考问题
- 为什么需要进程间通信机制?进程之间为什么不能直接访问对方的内存?
- 共享内存是最快的IPC机制,为什么不是所有场景都用共享内存?
- Unix域套接字和网络套接字有什么区别?为什么同一主机内的通信优先用Unix域套接字?
- 如果你要实现一个本地的RPC框架,你会选择哪种IPC机制?为什么?
7.5 同步与互斥
同步与互斥是并发编程的核心问题,当多个进程/线程并发访问共享资源时,如果不进行同步控制,就会出现竞态条件,导致程序结果不正确。理解同步与互斥的原理,是写出正确高效的并发程序的基础。
竞态条件与临界区
竞态条件(Race Condition)
当多个进程/线程同时访问和修改共享数据时,最终的结果取决于进程/线程执行的相对时序,结果不确定,这种情况就叫做竞态条件。
示例:两个线程同时对全局变量count做加1操作
int count = 0;
void increment() {
count++; // 这行代码实际分为三步:读取count的值,加1,写回count
}
如果两个线程同时执行increment:
- 理想情况:线程1执行完三步,线程2再执行,count变成2
- 竞态情况:线程1读取count=0,还没写回的时候,线程2也读取count=0,两个线程都加1写回,最终count=1,而不是正确的2
竞态条件会导致程序结果不确定,出现难以排查的bug,必须避免。
临界区(Critical Section)
访问共享资源的代码片段叫做临界区,同一时间只能有一个进程/线程进入临界区执行,其他进程/线程必须等待,这样才能避免竞态条件。
临界区的访问需要满足四个条件:
- 互斥:同一时间只能有一个进程进入临界区
- 前进:如果临界区为空,应该允许一个进程立即进入,不能无限等待
- 有限等待:进程等待进入临界区的时间必须是有限的,不能出现饥饿
- 让权等待:进程不能进入临界区时,应该让出CPU,不要忙等
互斥的实现方法
1. 互斥锁(Mutex)
互斥锁是最常用的同步机制,用来保护临界区,保证同一时间只有一个线程持有锁,进入临界区。
- 操作:
- lock():获取锁,如果锁已经被持有,就阻塞等待
- unlock():释放锁,唤醒等待的线程
- 原理:通常由操作系统内核实现,锁的状态由内核维护,获取失败时线程会阻塞,让出CPU,不会忙等
- 注意:获取锁之后必须在所有路径上释放锁,包括异常路径,否则会导致死锁
2. 自旋锁(Spinlock)
自旋锁和互斥锁类似,区别是获取锁失败时不会阻塞,而是一直循环尝试获取锁,直到成功。
- 优点:不需要上下文切换,开销小,适合临界区非常短,锁持有时间很短的场景
- 缺点:长时间自旋会浪费CPU资源,如果锁持有时间长,性能比互斥锁差
- 适用场景:内核态编程,临界区非常短,多处理器系统
- 注意:单核CPU上不要用自旋锁,自旋的过程中其他线程无法运行,锁永远不会被释放
3. 读写锁(Read-Write Lock)
读写锁适合读多写少的场景,允许多个线程同时读,但同一时间只能有一个线程写,写操作和读操作互斥。
- 锁的状态:
- 读模式:多个线程可以同时获取读锁,适合读操作
- 写模式:只能有一个线程获取写锁,写的时候不能读
- 优点:读多写少的场景下比互斥锁并发度高很多
- 缺点:实现复杂,有一定的开销,如果写操作多,性能不如普通互斥锁
- 适用场景:缓存、配置等读多写少的共享资源
4. 信号量(Semaphore)
信号量是一个计数器,用来控制访问共享资源的线程数量,可以实现互斥和同步。
- 操作:
- P操作(wait):计数器减1,如果计数器<0,线程阻塞等待
- V操作(post):计数器加1,如果有等待的线程,唤醒一个
- 二值信号量:计数器只能是0或1,相当于互斥锁,可以用来实现互斥
- 计数信号量:计数器可以大于1,表示可用资源的数量,用来控制同时访问资源的最大线程数
- 适用场景:生产者消费者问题、限流、资源池等场景
5. 条件变量(Condition Variable)
条件变量用来实现线程之间的等待通知机制,当某个条件不满足时,线程阻塞等待,其他线程满足条件后通知等待的线程。
- 条件变量总是和互斥锁一起使用:
- wait():释放互斥锁,阻塞等待条件,被唤醒后重新获取互斥锁
- signal():唤醒一个等待的线程
- broadcast():唤醒所有等待的线程
- 为什么需要和互斥锁一起使用:条件判断本身是共享的,需要互斥锁保护,避免竞态条件
- 适用场景:生产者消费者问题、事件通知、线程池任务调度等场景
6. 原子操作
对于简单的操作(比如加1、减1、比较交换),可以用CPU提供的原子操作实现同步,不需要锁。
- 原子操作是不可中断的,要么全部执行完成,要么不执行,不会被其他线程打断
- 常见的原子操作:原子加、原子减、比较并交换(CAS)
- 优点:开销极小,不需要上下文切换,没有死锁问题
- 缺点:只能用于简单的操作,复杂场景还是需要锁
- 适用场景:计数器、状态标志等简单的共享变量操作
死锁
死锁是指多个进程/线程互相等待对方持有的资源,导致所有线程都无限期阻塞,无法继续执行。
死锁产生的四个必要条件
- 互斥条件:资源是独占的,同一时间只能被一个进程持有
- 持有并等待:进程已经持有了至少一个资源,又请求其他进程持有的资源
- 不可剥夺:进程持有的资源只能自己释放,不能被其他进程强行剥夺
- 循环等待:多个进程之间形成循环等待资源的关系,每个进程都等待下一个进程持有的资源
四个条件同时满足时,就会产生死锁。
死锁的预防
预防死锁就是破坏四个必要条件中的一个或多个:
- 破坏互斥条件:让资源可以共享,比如只读资源可以同时访问,但很多资源无法共享,适用场景有限
- 破坏持有并等待:进程一次性申请所有需要的资源,或者申请资源时先释放已经持有的资源
- 破坏不可剥夺条件:进程申请的资源无法满足时,强制释放已经持有的资源
- 破坏循环等待:对资源进行编号,进程必须按编号顺序申请资源,避免循环等待
死锁的避免
- 银行家算法:在分配资源前检查系统是否处于安全状态,如果分配后系统可能进入死锁,就拒绝分配
- 实现复杂,开销大,实际系统中很少使用
死锁的检测和恢复
- 允许系统发生死锁,定期检测死锁,然后通过剥夺资源、终止进程等方式恢复
- 检测方法:资源分配图、检测循环等待
- 恢复方法:终止一个或多个死锁进程,剥夺资源
死锁的最佳实践
实际开发中避免死锁的最佳实践:
- 避免一个线程同时持有多个锁
- 如果必须持有多个锁,所有线程都按相同的顺序获取锁,避免循环等待
- 不要持有锁的同时等待其他资源
- 使用定时锁,获取锁超时后释放已经持有的锁,避免无限等待
- 尽量减少锁的粒度和持有时间,减少锁冲突的概率
经典同步问题
1. 生产者消费者问题
- 问题描述:多个生产者线程生产数据放到缓冲区,多个消费者线程从缓冲区取数据消费,需要保证缓冲区满时生产者等待,缓冲区空时消费者等待,线程之间不会互相干扰。
- 解决方案:用一个互斥锁保护缓冲区,两个条件变量(非空、非满),或者用信号量实现。
2. 读者写者问题
- 问题描述:多个读者可以同时读,写者和其他写者、读者都互斥,分为读优先和写优先两种模式。
- 解决方案:用读写锁实现,或者用互斥锁和条件变量实现。
3. 哲学家进餐问题
- 问题描述:五个哲学家围坐在桌子旁,每人左右两边各有一根筷子,哲学家要么思考,要么吃饭,吃饭需要同时拿到左右两根筷子,如何避免死锁和饥饿。
- 解决方案:最多允许四个哲学家同时拿左边的筷子,或者哲学家先拿编号小的筷子再拿编号大的,破坏循环等待条件。
同步与互斥的最佳实践
- 尽量避免共享:最好的同步就是没有同步,尽量不要共享可变数据,使用消息传递代替共享内存,从根源上避免竞态条件
- 最小化临界区:临界区越小越好,只在必须保护的地方加锁,减少锁的持有时间
- 避免锁嵌套:尽量不要同时持有多个锁,如果必须持有,严格按顺序获取,避免死锁
- 选择合适的同步原语:根据场景选择合适的锁,读多写少用读写锁,短临界区用自旋锁,简单操作用原子操作
- 避免忙等:除了非常短的临界区,不要用自旋锁忙等,浪费CPU资源
- 小心使用无锁编程:无锁编程(CAS等)复杂度很高,容易出现问题,优先使用成熟的锁机制,没有特殊必要不要自己实现无锁数据结构
思考问题
- 什么是竞态条件?举一个你开发中遇到的竞态条件的例子。
- 互斥锁和自旋锁有什么区别?分别适合什么场景?
- 死锁产生的四个必要条件是什么?如何预防死锁?
- 条件变量为什么要和互斥锁一起使用?单独使用条件变量会有什么问题?
7.6 编程中的并发问题
并发编程相比串行编程要复杂很多,很容易出现各种难以排查的bug,这一节我们介绍编程中常见的并发问题,以及排查解决方法和最佳实践。
常见的并发问题
1. 竞态条件(Race Condition)
问题描述:多个线程同时读写共享数据,最终结果取决于线程执行的时序,结果不确定。
- 例子:多个线程同时对计数器做加1操作,结果小于预期值
- 根本原因:对共享数据的访问没有正确同步,临界区没有加锁保护
- 排查方法:
- 代码审查,检查所有共享数据的访问是否都加了正确的锁
- 使用静态代码分析工具检测竞态条件
- 使用ThreadSanitizer等动态检测工具
- 解决方法:
- 对共享资源的访问加锁保护
- 尽量避免共享可变数据,使用消息传递代替共享内存
- 对于简单操作使用原子操作
2. 死锁(Deadlock)
问题描述:多个线程互相等待对方持有的锁,导致所有线程都无限期阻塞,程序卡住无法继续执行。
- 死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待
- 排查方法:
- 查看线程的调用栈,看各个线程都在等待什么锁
- 使用jstack、pstack、gdb等工具查看线程状态
- 使用ThreadSanitizer、jconsole等工具检测死锁
- 解决方法:
- 避免一个线程同时持有多个锁
- 所有线程按相同的顺序获取锁
- 使用带超时的锁获取接口,超时后释放已经持有的锁
- 减少锁的粒度和持有时间
3. 活锁(Livelock)
问题描述:线程没有阻塞,但是一直不断重复相同的操作,无法继续推进执行。
- 例子:两个线程都主动释放自己的锁给对方,结果双方都拿到对方释放的锁,又马上释放,一直重复这个过程
- 和死锁的区别:死锁是线程都阻塞了,活锁是线程还在运行但无法推进
- 解决方法:
- 引入随机等待时间,避免多个线程同时释放和获取锁
- 调整重试的机制,避免无限重试
4. 饥饿(Starvation)
问题描述:某个或某些线程一直得不到需要的资源,一直无法执行。
- 原因:
- 优先级调度,低优先级线程一直被高优先级线程抢占
- 锁不公平,某些线程一直抢不到锁
- 资源被其他线程一直占用不释放
- 解决方法:
- 使用公平锁,按申请顺序分配锁
- 避免设置过高的优先级
- 限制资源持有时间,避免长时间占用资源
5. 伪共享(False Sharing)
问题描述:多个线程修改同一个缓存行中的不同变量,导致缓存行频繁失效,性能急剧下降。
- 原理:之前缓存章节讲过,缓存的最小单位是缓存行(通常64字节),如果两个变量在同一个缓存行,一个线程修改其中一个变量,会导致整个缓存行失效,其他线程访问同缓存行的其他变量也会缓存miss
- 排查方法:
- 使用perf等性能分析工具观察缓存miss率
- 查看并发修改的变量是否在内存中相邻
- 解决方法:
- 缓存行填充,在变量之间填充无用字节,让不同线程修改的变量位于不同的缓存行
- 调整数据结构,避免多个线程频繁修改同一个缓存行的数据
6. 上下文切换开销过高
问题描述:系统中线程数量太多,频繁的上下文切换消耗大量CPU资源,导致真正执行任务的CPU时间很少,系统吞吐量低。
- 表现:CPU使用率中sys占比很高,用户态占比低,系统负载高但处理速度慢
- 原因:
- 线程数量远大于CPU核心数,频繁切换
- 锁冲突严重,线程频繁阻塞和唤醒
- 解决方法:
- 合理设置线程池大小,CPU密集型任务线程数≈核心数,IO密集型可以适当多一点
- 减少锁冲突,降低阻塞概率
- 使用更轻量的并发模型,比如协程,减少上下文切换开销
7. 线程安全问题
很多常用的数据结构不是线程安全的,多个线程同时访问会出现问题:
- 例子:多个线程同时往ArrayList中add元素,可能会出现数组越界、丢失元素、size不正确等问题
- 解决方法:
- 使用线程安全的数据结构,比如ConcurrentHashMap、CopyOnWriteArrayList
- 对非线程安全的数据结构访问加锁保护
- 每个线程使用独立的数据结构,避免共享
并发问题的调试和排查工具
1. 通用工具
- gdb / lldb:C/C++程序调试工具,可以查看线程状态、调用栈、锁持有情况
- strace:跟踪系统调用,查看线程正在做什么操作
- perf:性能分析工具,可以分析上下文切换次数、缓存命中率、CPU使用率等
- ThreadSanitizer(TSAN):Google开发的并发问题检测工具,集成在GCC/Clang中,编译时加上
-fsanitize=thread参数,可以检测竞态条件、死锁等问题,非常强大
2. Java相关工具
- jstack:查看Java进程的线程堆栈信息,查看每个线程的状态,等待的锁
- jconsole / VisualVM:图形化工具,监控线程状态、锁情况、死锁检测
- Arthas:阿里开源的Java诊断工具,可以在线查看线程状态、死锁等问题
3. Go相关工具
- pprof:Go内置的性能分析工具,可以查看goroutine状态、栈信息
- go trace:跟踪程序运行时的调度、Syscall、GC等事件,分析并发问题
- go vet:静态代码检查,检测常见的并发问题
4. 动态检测工具
- Valgrind:包含DRD和Helgrind工具,可以检测C/C++程序的并发问题
- Intel Inspector:Intel的并发问题检测工具
并发编程最佳实践
1. 尽量避免并发
如果业务不需要并发,就不要用并发,并发会大大增加复杂度和bug率。如果确实需要提升性能或者吞吐量,再考虑使用并发。
2. 优先使用成熟的并发库和框架
不要自己实现复杂的同步逻辑,尽量使用标准库或者成熟第三方库提供的并发工具:
- 线程池、协程池
- 线程安全的数据结构
- 同步工具类(CountDownLatch、CyclicBarrier、Semaphore等)
- 消息队列、Actor模型等更高级的并发模型
3. 最小化共享数据
- 尽量不要共享可变数据,从根源上避免竞态条件
- 优先使用消息传递的方式通信,而不是共享内存,比如Go的“不要通过共享内存来通信,要通过通信来共享内存“的原则
- 如果必须共享,优先共享不可变数据,不需要同步
- 共享可变数据一定要加正确的锁保护
4. 正确使用锁
- 锁的粒度越小越好,只在必要的地方加锁,减少锁的持有时间
- 避免在锁中执行耗时操作(IO、sleep等)
- 避免嵌套锁,如果必须持有多个锁,严格按相同的顺序获取锁
- 优先使用可重入锁,避免同一线程重复获取锁死锁
- 使用tryLock带超时的获取锁接口,避免无限等待
- 锁必须在所有路径上释放,包括异常路径,使用RAII机制(C++的lock_guard,Java的try-with-resources,Python的with语句)自动释放锁
5. 正确使用线程池
- 不要无限制创建线程,线程的创建和销毁开销大,而且太多线程会导致上下文切换开销过高
- 合理设置线程池大小:
- CPU密集型任务:线程数≈CPU核心数 + 1
- IO密集型任务:线程数≈核心数 × (1 + 平均等待时间/平均计算时间),或者压测确定最佳值
- 不同类型的任务使用不同的线程池,避免互相影响
- 给线程设置有意义的名字,方便排查问题
6. 做好错误处理和超时
- 并发场景下一定要处理好异常,避免一个线程的异常导致整个程序崩溃
- 阻塞操作一定要设置超时时间,避免无限等待
- 线程池提交的任务要捕获异常,避免异常抛出导致线程退出
7. 测试并发代码
- 并发代码的bug很难复现,要做充分的测试
- 做压力测试,模拟高并发场景
- 用ThreadSanitizer等工具检测并发问题
- 多做代码审查,并发代码的逻辑很难通过测试覆盖所有场景,代码审查非常重要
不同编程语言的并发模型对比
| 语言 | 并发模型 | 特点 | 优点 | 缺点 |
|---|---|---|---|---|
| C/C++ | 多线程/多进程,手动管理同步 | 灵活,性能高 | 复杂度高,容易出问题 | 高性能、底层场景 |
| Java | 多线程,共享内存模型,synchronized/lock | 生态成熟,工具丰富 | 锁开销大,容易出现并发问题 | 后端服务开发 |
| Go | Goroutine + Channel,CSP模型 | 轻量,高并发,语法层面支持并发 | 调度开销小,开发效率高 | 通用后端、高并发场景 |
| Erlang/Elixir | Actor模型,轻量进程,消息传递 | 高可用,容错性好,天生分布式 | 性能略低,学习曲线陡 | 电信、高可用系统 |
| Python/JS | 多线程/多进程/异步IO | 异步IO性能不错,语法简单 | 多线程受GIL限制,CPU密集型性能差 | IO密集型、脚本、前端 |
| Rust | 所有权机制,编译期检查并发安全 | 性能高,内存安全,并发安全 | 学习曲线陡 | 高性能、高可靠场景 |
协程(Coroutine)
协程是用户态的轻量级线程,由用户态调度,上下文切换开销极小,支持高并发:
- 优势:可以轻松支持几十万甚至上百万并发,切换开销比线程小很多
- 适用场景:IO密集型高并发服务,比如Web服务、API网关等
- 代表:Go的goroutine,Java的Project Loom,Python的asyncio,JavaScript的async/await
思考问题
- 你在开发中遇到过哪些并发问题?是怎么排查和解决的?
- 死锁和活锁有什么区别?如何避免活锁?
- 什么是伪共享?如何检测和解决伪共享问题?
- 你觉得什么并发模型最好用?适合什么场景?
练习题与扩展阅读
练习题
基础题
- 进程和线程有什么区别?分别适合什么场景?
- 什么是上下文切换?为什么线程切换比进程切换快?
- 常见的进程间通信机制有哪些?各有什么优缺点?
- 死锁产生的四个必要条件是什么?如何预防死锁?
- 竞态条件是什么?如何避免竞态条件?
实操题
- 编写一个多线程程序,多个线程同时对一个全局变量做100万次加1操作,不加锁的情况下看结果是否正确,加锁后再看结果。
- 分别用管道、消息队列、共享内存实现两个进程之间的数据传输,对比性能差异。
- 编写一个死锁程序,然后用调试工具定位死锁,分析死锁产生的原因。
- 实现一个生产者消费者模型,用互斥锁和条件变量实现,多个生产者线程往队列里放数据,多个消费者线程从队列取数据。
思考题
- 为什么现在很多高并发框架都使用协程?协程相比线程有什么优势?
- 互斥锁和自旋锁各有什么优缺点?什么时候应该用自旋锁?
- 进程的上下文切换和线程的上下文切换分别需要保存哪些内容?为什么上下文切换会有开销?
- 很多语言推荐“不要通过共享内存来通信,要通过通信来共享内存“,你怎么理解这句话?这种方式有什么好处?
扩展阅读
书籍推荐
-
《操作系统导论》
- 第二部分虚拟化部分详细讲解了进程、线程、并发、锁等内容,通俗易懂,非常适合入门学习操作系统的并发机制。
-
《并发编程实战》(Java并发编程实战)
- Java并发编程的经典著作,深入讲解了Java并发编程的原理和最佳实践,适合Java开发者。
-
《Unix环境高级编程》
- 第10章信号、第15章进程间通信、第11章和第12章线程部分,详细讲解了Unix系统下的进程、线程和IPC机制。
-
《七周七并发模型》
- 介绍了七种不同的并发编程模型,包括线程与锁、函数式编程、Actor模型、CSP、数据并行、Lambda架构、流式计算,拓宽并发编程的视野。
在线资源
-
- IBM的文档,详细讲解了不同的线程实现模型。
-
- Linux内核官方文档,介绍CFS调度器的设计和实现原理。
-
- 深入讲解Go语言的goroutine调度器实现原理。
-
- 一系列关于并发编程的优质文章,深入讲解内存模型、锁、无锁编程等内容。
工具推荐
- ThreadSanitizer:Google开发的并发问题检测工具,可以检测竞态条件、死锁等问题,支持C/C++/Go/Java等语言。
- perf:Linux性能分析工具,可以分析上下文切换次数、锁竞争等性能问题。
- jstack/jconsole:Java的线程分析工具,可以查看线程状态,检测死锁。
- gdb/lldb:调试工具,可以查看线程调用栈,分析并发问题。
- go trace/pprof:Go语言的性能和并发分析工具。
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第8章:操作系统核心
学习目标
通过本章学习,你将能够:
- 理解操作系统的整体架构和核心模块
- 掌握操作系统从开机到启动完成的整个引导过程
- 理解系统调用的原理和实现机制
- 了解驱动程序的作用和运行机制
- 掌握现代操作系统的核心特性和设计思想
章节简介
操作系统是管理计算机硬件和软件资源的核心系统软件,所有应用程序都运行在操作系统之上。理解操作系统的核心原理,能够帮助我们更好地理解程序的运行环境,写出更稳定、更高效的程序。本章将从操作系统的整体架构讲起,讲解系统启动过程、系统调用原理、驱动程序模型,以及现代操作系统的核心特性,帮助你建立对操作系统的整体认知。
本章内容
- 操作系统的地位和作用
- 内核架构:宏内核、微内核、混合内核
- 操作系统的核心模块和功能
- 用户态和内核态的区别
- 计算机从加电到操作系统启动的完整流程
- BIOS/UEFI的作用和区别
- 主引导记录(MBR)和GUID分区表(GPT)
- 引导加载程序(GRUB、systemd-boot等)的工作原理
- 内核初始化和用户空间启动流程
- 系统调用的作用和意义
- 系统调用的实现机制(中断、快速系统调用)
- 常见的系统调用分类
- 标准库和系统调用的关系
- 系统调用的开销和优化方法
- 驱动程序的作用和分类
- 驱动程序的运行机制
- 设备模型:字符设备、块设备、网络设备
- 驱动程序的加载和运行
- 常见的驱动框架
- 微内核与外核设计思想
- 容器和虚拟化技术原理
- 实时操作系统特性
- 分布式操作系统基础
- 操作系统的安全机制
学习建议
本章内容偏理论和底层,如果你是应用层开发者,重点理解系统调用原理、用户态和内核态的区别、操作系统启动过程这些和日常开发相关的内容即可;如果你做底层开发、系统编程或者运维,建议深入学习操作系统架构、驱动程序、容器虚拟化等内容。学习时可以结合自己使用的操作系统(Linux/Windows/macOS)对比理解,会更容易掌握。
难度:★★★☆☆
预计学习时间:3.5小时
8.1 操作系统架构
操作系统是计算机系统中最核心的系统软件,它管理着计算机的所有硬件和软件资源,为上层应用提供抽象的接口和运行环境。理解操作系统的架构,能帮助我们更好地理解程序的运行机制。
操作系统的地位和作用
操作系统位于硬件和应用程序之间,承上启下:
┌─────────────────────────┐
│ 应用程序 │ 浏览器、办公软件、服务等
├─────────────────────────┤
│ 操作系统 │ 内核、系统库、驱动程序
├─────────────────────────┤
│ 硬件层 │ CPU、内存、磁盘、外设等
└─────────────────────────┘
操作系统的核心作用:
- 资源管理:管理CPU、内存、磁盘、外设等所有硬件资源,公平合理地分配给各个进程使用
- 抽象接口:为上层应用提供统一的抽象接口,屏蔽底层硬件的细节,让应用程序不需要关心硬件的具体实现
- 隔离保护:隔离不同的进程,防止进程之间互相干扰,保证系统安全稳定
- 提升效率:通过调度、缓存、并发等技术提升系统的整体效率和资源利用率
内核态与用户态
为了保护操作系统的安全,现代CPU都提供了不同的特权级别,操作系统将运行环境分为内核态和用户态:
内核态(Kernel Mode)
- 拥有最高特权,可以直接访问所有硬件资源,执行任意指令
- 操作系统的核心代码运行在内核态,包括进程调度、内存管理、驱动程序等
- 内核态的错误是致命的,可能导致整个系统崩溃
用户态(User Mode)
- 特权级别较低,不能直接访问硬件资源,也不能直接访问内核地址空间
- 所有的应用程序都运行在用户态
- 用户态的程序要访问硬件资源,必须通过系统调用接口进入内核态,由操作系统代为访问
- 用户态的错误通常只会影响当前进程,不会导致整个系统崩溃
状态切换
当用户态程序需要执行特权操作(比如读写文件、分配内存、发送网络请求)时,需要通过系统调用从用户态切换到内核态,操作系统完成操作后再切换回用户态。
- 状态切换有一定的开销,需要保存和恢复上下文
- 频繁的系统调用会降低程序性能
操作系统内核架构
内核是操作系统的核心部分,负责管理系统资源和提供核心功能,常见的内核架构有三种:宏内核、微内核、混合内核。
1. 宏内核(Monolithic Kernel)
宏内核是最传统的内核架构,所有的操作系统功能(进程管理、内存管理、文件系统、驱动程序、网络协议栈等)都运行在内核态,是一个单独的大程序。
- 优点:
- 性能高,所有模块都在内核态运行,模块之间直接调用,没有通信开销
- 实现简单,成熟稳定
- 缺点:
- 内核庞大复杂,维护难度大
- 一个模块的bug可能导致整个内核崩溃
- 扩展性差,添加新功能需要重新编译内核
- 代表系统:Linux、Unix、早期的Windows
Linux是最典型的宏内核实现,但Linux也吸收了微内核的优点,支持动态加载内核模块,需要的功能可以动态加载,不需要的时候卸载,兼顾了性能和扩展性。
2. 微内核(Micro Kernel)
微内核架构的设计思想是尽可能把内核的功能放到用户态运行,内核只保留最核心的功能:
- 内核只负责最基本的功能:进程调度、进程间通信、基本的内存管理
- 文件系统、驱动程序、网络协议栈等都作为独立的用户态服务运行
- 服务之间通过消息传递进行通信
- 优点:
- 内核小巧,稳定可靠,安全性高
- 扩展性好,添加功能只需要修改对应的用户态服务,不需要修改内核
- 一个服务崩溃不会影响整个系统,稳定性高
- 缺点:
- 性能较低,服务之间通过消息传递通信,需要频繁的用户态和内核态切换,开销大
- 实现复杂
- 代表系统:Minix、QNX、L4、HarmonyOS微内核
微内核适合对稳定性、安全性要求高的场景,比如嵌入式系统、航空航天、自动驾驶等领域。
3. 混合内核(Hybrid Kernel)
混合内核结合了宏内核和微内核的优点,内核核心部分运行在内核态,同时把一些非核心的功能放到内核态或者用户态运行,兼顾性能和扩展性。
- 核心功能在内核态运行,保证性能
- 驱动程序、文件系统等模块可以动态加载,运行在内核态
- 吸收了微内核的消息传递机制,同时保留了宏内核的高性能
- 代表系统:Windows NT内核、macOS XNU内核、Android内核
Windows是典型的混合内核,大部分功能运行在内核态,同时支持动态加载驱动,兼顾性能和扩展性。
操作系统的核心模块
无论是哪种架构的操作系统,通常都包含以下核心模块:
1. 进程管理模块
- 负责进程和线程的创建、调度、销毁
- 实现CPU调度算法,分配CPU时间
- 进程间通信机制
- 进程同步与互斥支持
2. 内存管理模块
- 管理物理内存和虚拟地址空间
- 实现分页、分段、虚拟内存机制
- 负责内存的分配、回收、置换
- 内存保护,隔离不同进程的地址空间
3. 文件系统模块
- 管理磁盘上的文件和目录
- 实现文件系统的逻辑,处理文件的读写、权限管理
- 磁盘空间管理
- 缓存管理,提升文件访问性能
4. 设备驱动模块
- 管理和控制各种硬件设备(磁盘、网卡、显卡、键盘等)
- 提供统一的设备访问接口,屏蔽硬件差异
- 处理硬件中断
5. 网络协议栈
- 实现各种网络协议(TCP/IP、UDP、HTTP等)
- 处理网络数据的发送和接收
- 提供网络编程接口
6. 系统调用接口
- 是用户态程序和内核之间的接口
- 提供标准化的系统调用函数,比如open、read、write、fork等
- 处理用户态到内核态的切换
系统调用与标准库
应用程序通常不会直接调用系统调用,而是通过标准库封装的接口:
- 系统调用:操作系统提供的最底层接口,和平台相关,每个操作系统的系统调用不同
- 标准库:对系统调用进行封装,提供跨平台的统一接口,比如C标准库的fopen、fread等函数,内部封装了不同操作系统的系统调用
- 标准库会根据不同的操作系统调用对应的系统调用,实现跨平台兼容性
比如C语言的fopen()函数:
- 在Linux上内部调用
open()系统调用 - 在Windows上内部调用
CreateFile()系统调用 - 应用程序只需要调用标准库函数,不需要关心底层操作系统的差异
思考问题
- 为什么操作系统要区分内核态和用户态?如果所有程序都运行在内核态会有什么问题?
- 宏内核和微内核各有什么优缺点?分别适合什么场景?
- 为什么应用程序通常不直接调用系统调用,而是通过标准库?
- 上下文切换和系统调用切换有什么区别?哪个开销更大?
8.2 启动与引导过程
我们每天开机的时候,计算机从按下电源键到进入操作系统桌面,中间经历了复杂的引导过程。理解系统的启动流程,有助于我们排查系统启动故障,理解操作系统的加载和初始化过程。
系统启动的完整流程
现代计算机从加电到操作系统启动完成,大致分为以下几个阶段:
加电 → BIOS/UEFI初始化 → POST自检 → 选择启动设备 → 加载引导加载程序 → 加载内核 → 内核初始化 → 启动用户空间服务 → 登录界面
下面我们详细讲解每个阶段。
阶段1:BIOS/UEFI初始化
按下电源键后,计算机首先会运行主板上固化的固件程序:BIOS或者UEFI。
BIOS(Basic Input/Output System,基本输入输出系统)
BIOS是传统的固件接口,已经有几十年的历史:
- 存储在主板的ROM芯片中,计算机加电后首先运行
- 负责最基础的硬件初始化和自检
- 功能比较简单,使用16位模式,最大支持2.2TB的硬盘,MBR分区
- 现在新的主板已经逐渐淘汰BIOS,改用UEFI
UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口)
UEFI是BIOS的替代者,是现代计算机的标准固件:
- 支持32位和64位模式,功能强大
- 支持最大9.4ZB的硬盘,GPT分区
- 图形界面,支持鼠标操作
- 支持安全启动(Secure Boot),防止恶意软件篡改引导过程
- 启动速度更快
- 兼容BIOS启动模式
POST上电自检(Power-On Self Test)
BIOS/UEFI运行后首先执行POST自检:
- 检查CPU、内存、硬盘、显卡等硬件是否正常
- 如果硬件有故障,主板会发出蜂鸣声报错,屏幕显示错误信息
- 自检通过后,初始化基本的硬件驱动(显卡、硬盘、键盘等)
选择启动设备
自检完成后,BIOS/UEFI会根据用户设置的启动顺序(U盘优先、硬盘优先、光驱优先等),尝试从对应的启动设备启动。
- 每个启动设备的第一个扇区(512字节)是引导扇区,如果最后两个字节是0x55AA,说明这个设备是可引导的
- BIOS/UEFI会把引导扇区的内容加载到内存,然后跳转到这个位置执行,把控制权交给引导加载程序
阶段2:引导加载程序(Boot Loader)
引导加载程序(Boot Loader)的作用是加载操作系统内核到内存,然后启动内核。
常见的引导加载程序
- GRUB(GRand Unified Bootloader):Linux系统最常用的引导加载程序,功能强大,支持多系统启动,可以引导Linux、Windows等系统
- Windows Boot Manager:Windows系统的引导加载程序
- systemd-boot:轻量级的Linux引导加载程序,用于systemd管理的系统
- UEFI Shell:UEFI自带的命令行Shell,可以手动执行引导命令
引导过程
以GRUB为例,引导加载程序的工作流程:
- 阶段1:BIOS/UEFI加载GRUB的第一阶段代码(存储在MBR的前446字节),功能很简单,加载第二阶段的代码
- 阶段1.5:加载文件系统驱动,这样就可以识别ext4等文件系统
- 阶段2:加载/boot分区下的GRUB配置文件,显示启动菜单,让用户选择要启动的系统或者内核版本
- 用户选择后,GRUB加载对应的内核文件和initramfs/initrd到内存
- 跳转到内核入口点,把控制权交给内核
MBR vs GPT分区表
硬盘的分区表有两种格式:MBR和GPT,和BIOS/UEFI对应。
MBR(Master Boot Record,主引导记录)
- 传统的分区表格式,和BIOS配合使用
- 存储在硬盘的第一个扇区(512字节),其中:
- 前446字节:引导加载程序代码
- 接下来64字节:分区表,最多只能记录4个主分区
- 最后2字节:魔数0x55AA,表示是可引导设备
- 最大支持2.2TB的硬盘,最多4个主分区(可以用扩展分区实现更多逻辑分区)
- 现在已经逐渐被GPT取代
GPT(GUID Partition Table,全局唯一标识分区表)
- 新一代的分区表格式,和UEFI配合使用
- 最多支持128个分区,没有主分区扩展分区的概念
- 最大支持9.4ZB的硬盘(1ZB=10亿TB)
- 有冗余备份,分区表在硬盘头尾各存一份,损坏后可以恢复
- 支持安全启动,更安全
- 是现在新硬盘的标准分区格式
initramfs/initrd
initramfs(初始RAM文件系统)是一个微型的根文件系统,打包在镜像中,和内核一起加载到内存:
- 包含了必要的驱动模块(硬盘驱动、文件系统驱动等)和工具
- 内核启动时首先挂载initramfs作为临时根文件系统
- 加载必要的驱动,然后挂载真实的根文件系统
- 切换到真实的根文件系统,启动init进程
- 作用:内核不需要包含所有硬件的驱动,减少内核体积,同时可以支持加载第三方驱动
阶段3:内核初始化
内核被加载到内存后,开始执行内核初始化:
- 内存初始化:检测系统内存大小,初始化内存管理模块
- 进程调度初始化:初始化调度器,创建0号 idle 进程和1号 init 进程
- 设备初始化:扫描所有硬件设备,加载对应的驱动程序
- 挂载根文件系统:根据引导参数指定的根分区,挂载真实的根文件系统
- 启动init进程:把控制权交给用户空间的1号init进程,内核态初始化完成,进入用户空间启动阶段
阶段4:用户空间初始化
内核启动完成后,启动用户空间的第一个进程(PID=1),负责启动所有用户空间的服务和程序。
init进程
init进程是用户空间的第一个进程,所有其他用户进程都是它的子进程:
- 传统SysV init:旧的init系统,串行启动服务,速度慢
- systemd:现在主流Linux发行版的init系统,并行启动服务,速度快,功能强大
- launchd:macOS的init系统
以systemd为例,用户空间启动流程:
- systemd作为1号进程启动,读取配置文件
- 按照依赖关系并行启动系统服务:日志服务、网络服务、文件系统挂载、数据库、Web服务等
- 启动图形界面或者登录服务,显示登录界面
- 用户登录后启动桌面环境或者Shell,系统启动完成
不同操作系统的启动流程对比
Windows启动流程
- UEFI/BIOS初始化,POST自检
- 加载启动设备的EFI分区中的Windows Boot Manager
- Windows Boot Manager读取BCD(启动配置数据),加载Windows内核(ntoskrnl.exe)
- 内核初始化,加载驱动程序
- 启动smss.exe(会话管理器)、csrss.exe(客户端运行时子系统)、wininit.exe等系统进程
- 启动服务和登录界面,用户登录后启动explorer.exe资源管理器,进入桌面
macOS启动流程
- 按下电源键,运行BootROM,初始化硬件,POST自检
- 加载EFI引导程序,然后加载macOS内核
- 内核初始化,加载驱动,启动launchd进程(PID=1)
- launchd启动系统服务和代理程序
- 启动登录窗口,用户登录后加载桌面环境
启动常见问题排查
- 开机黑屏无反应:检查电源、硬件连接,POST自检失败,硬件故障
- 提示找不到启动设备:检查启动顺序设置,硬盘是否损坏,引导扇区是否损坏
- Grub Rescue模式:GRUB配置损坏,需要修复GRUB引导
- 内核panic:内核启动失败,可能是内核损坏、驱动不兼容、硬件故障
- 启动后卡住:查看系统日志,通常是某个服务启动失败,或者磁盘挂载错误
思考问题
- BIOS和UEFI有什么区别?UEFI相比BIOS有哪些优势?
- MBR和GPT分区表各有什么优缺点?为什么GPT逐渐取代了MBR?
- initramfs的作用是什么?如果没有initramfs会怎么样?
- 系统启动时如果显示“Operating System not found“,可能是什么原因?怎么排查?
8.3 系统调用原理
系统调用(System Call)是用户态程序访问操作系统内核服务的唯一接口,用户态程序要访问硬件资源、执行特权操作,都必须通过系统调用进入内核态,由操作系统代为完成。理解系统调用的原理,有助于我们理解程序的运行机制,优化程序性能。
什么是系统调用
操作系统为用户态程序提供了一组标准化的内核服务接口,比如:
- 文件操作:open、read、write、close等
- 进程管理:fork、exec、exit、wait等
- 内存管理:mmap、brk、mprotect等
- 网络操作:socket、connect、send、recv等
- 设备管理:ioctl、mmap设备等
用户态程序不能直接访问内核资源,必须通过系统调用才能访问内核服务,这样保证了系统的安全性和稳定性,避免恶意程序不能随意破坏系统。
为什么需要系统调用
- 安全隔离:用户态程序权限低,不能直接操作内核资源,防止恶意程序破坏系统
- 统一抽象:为上层应用提供统一的接口,屏蔽底层硬件差异,应用程序不需要关心不同硬件的实现细节
- 权限控制:操作系统可以统一管理和控制资源的访问,保证公平性和安全性
- 避免重复实现:通用的功能由操作系统统一实现,应用程序不需要重复造轮子
系统调用的实现机制
系统调用的本质是用户态主动触发一个异常或者中断,让CPU切换到内核态,执行对应的系统调用处理函数,完成后返回用户态。
不同的架构有不同的系统调用触发方式:
- 中断/异常方式:早期的实现方式,通过触发软中断进入内核,比如x86架构的int 0x80中断
- 快速系统调用:现代CPU提供的专门的系统调用指令,性能更高,比如x86的sysenter/sysexit指令,x86_64的syscall/sysret指令,ARM的svc指令
x86_64架构系统调用流程
我们以x86_64架构为例,看一下系统调用的完整流程:
-
**用户态准备参数:
- 系统调用号放在rax寄存器中,比如read的系统调用号是0,write是1
- 参数依次放在rdi、rsi、rdx、r10、r8、r9寄存器中
- 不需要切换到内核栈
-
**执行syscall指令:
- CPU从用户态切换到内核态
- 保存用户态的上下文(寄存器、栈指针等)
- 跳转到内核的系统调用处理函数入口
-
**内核处理系统调用:
- 根据rax中的系统调用号,在系统调用表中找到对应的处理函数
- 执行对应的内核处理函数,完成具体的操作
- 处理完成后,把返回值放在rax寄存器中
-
执行sysret指令返回用户态:
- 恢复用户态上下文
- CPU从内核态切换回用户态
- 用户态程序从syscall指令之后继续执行,rax寄存器中是系统调用的返回值
系统调用表
内核中维护了一个系统调用表,是一个数组,下标是系统调用号,数组元素是对应系统调用处理函数的地址。每个系统调用有唯一的编号,内核根据系统调用号找到对应的处理函数。
比如Linux x86_64的部分系统调用号:
- 0:sys_read
- 1:sys_write
- 2:sys_open
- 3:sys_close
- 57:sys_fork
- 59:sys_execve
- 60:sys_exit
系统调用的开销
系统调用需要在用户态和内核态之间切换,有一定的开销,主要包括:
- 上下文切换开销:保存和恢复寄存器上下文
- **内核态和用户态切换的开销
- **各种检查和安全验证的开销
一次简单的系统调用通常需要几百个CPU周期。虽然单次开销不大,但如果频繁调用系统调用,累积的开销就会很大。
减少系统调用开销的优化方法:
- 批量操作:合并多次小的IO操作合并成一次大的操作,减少系统调用次数,比如批量写入文件时攒够一定数据再调用write,而不是写一个字节调用一次write
- 使用缓存:用户态缓存数据,减少直接操作缓存,必要时才调用系统调用同步到内核,比如标准库的fread/fwrite函数内部有缓冲区,减少实际的read/write系统调用
- 使用更高效的系统调用:比如使用mmap代替read/write,减少系统调用次数和数据拷贝
- 批处理系统调用:比如io_uring,一次提交多个系统调用,减少切换开销
标准库与系统调用的关系
我们平时编程时很少直接调用系统调用,而是通过标准库封装的接口:
应用程序 → C标准库(glibc → 系统调用 → 内核
以C标准库的fopen/fread/fwrite等函数,内部封装了对应的系统调用,并且做了很多优化:
- **用户态缓冲区,减少系统调用次数
- **跨平台封装,屏蔽不同操作系统的系统调用差异
- **错误处理和参数检查
- **额外的易用的易用的接口
比如printf函数,内部会把要输出的内容放到缓冲区,缓冲区满了或者遇到换行符时才调用write系统调用输出,大大减少系统调用次数,提升性能。
直接调用系统调用
某些场景下我们也可以直接调用系统调用,绕过标准库,Linux中可以使用syscall函数直接调用系统调用:
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
int main() {
const char *str = "Hello World\n";
// 直接调用write系统调用,系统调用号1,标准输出1,字符串地址,长度12
syscall(SYS_write, 1, str, 12);
return 0;
}
直接调用系统调用可以获得更好的性能,但会失去标准库的额外开销,但是代码不可跨平台,不同操作系统的系统调用号和接口不同,一般不推荐直接调用,除非有特殊的性能需求。
常见的系统调用分类
1. 进程控制类
- 进程创建、退出:fork、vfork、clone、exit、wait、execve等
- 进程调度:nice、sched_*等
- 信号相关:kill、sigaction、signal等
2. 文件操作类
- 文件操作:open、read、write、close、lseek、stat等
- 目录操作:mkdir、rmdir、readdir、rename等
- 文件权限:chmod、chown、access等
3. 内存管理类
- 内存分配:mmap、munmap、brk、mprotect等
- 内存同步:msync、madvise等
4. 网络操作类
- 套接字操作:socket、bind、listen、accept、connect、send、recv等
- 网络属性:getsockopt、setsockopt等
5. 设备管理类
- 设备操作:ioctl、mmap设备等
- 挂载操作:mount、umount等
6. 系统信息类
- 获取系统信息:getpid、getuid、getgid、uname、sysinfo等
- 时间相关:gettimeofday、clock_gettime等
现代系统调用优化技术
io_uring
io_uring是Linux 5.1之后引入的新一代异步IO框架,是近年来性能极高的系统调用机制,可以大大减少系统调用开销:
- 应用程序和内核之间共享一个环形队列,应用程序把要执行的系统调用请求放到队列中,内核批量处理,处理完成后通知应用程序,一次提交多个系统调用,只需要一次用户态内核态切换,大大降低了系统调用开销,性能比传统的阻塞IO和IO多路复用还要高很多,是现在高并发IO的新方向。
vDSO(虚拟动态共享对象)
vDSO是内核映射到用户态地址空间的一段内核代码,一些不需要系统调用就可以直接调用某些高频系统调用,比如gettimeofday、time等,不需要进内核态,直接在用户态完成,开销和普通函数调用一样快,大大减少了这些高频系统调用的开销。
思考问题
- 为什么用户态程序不能直接访问内核资源,必须通过系统调用?
- 系统调用和普通函数调用有什么区别?哪个开销更大?为什么?
- 标准库为什么要封装系统调用?直接调用系统调用有什么优缺点?
- 频繁调用系统调用会有什么问题?如何优化?
8.4 驱动程序模型
驱动程序(Device Driver)是操作系统和硬件设备之间的接口,操作系统通过驱动程序来控制和管理各种硬件设备。没有驱动程序,硬件设备就无法工作。理解驱动程序的原理,有助于我们排查硬件相关的问题,理解操作系统对硬件的抽象。
什么是驱动程序
驱动程序是运行在内核态的特殊程序,是硬件设备和操作系统之间的桥梁:
- 向上:为操作系统内核提供统一的设备访问接口
- 向下:和硬件设备通信,控制硬件设备的工作
- 屏蔽硬件的具体实现细节,让操作系统可以用统一的方式操作不同的硬件设备
为什么需要驱动程序
计算机的硬件设备种类繁多,不同厂商的设备实现不同,操作方式也不同,操作系统不可能内置所有硬件的操作逻辑。驱动程序作为独立的模块,由硬件厂商提供,负责具体的硬件操作,操作系统只需要调用驱动提供的统一接口就可以操作硬件,不需要关心硬件的具体实现。
┌─────────────────────────┐
│ 操作系统内核 │
├─────────────────────────┤
│ 驱动程序 │ → 设备A驱动、设备B驱动、设备C驱动
├─────────────────────────┤
│ 硬件设备 │ → 硬盘、网卡、显卡、键盘等
└─────────────────────────┘
驱动程序的分类
根据驱动程序控制的设备类型,驱动程序主要分为几类:
1. 字符设备驱动
- 字符设备是按照字节流来访问的设备,不能随机寻址,读写操作通常是串行的
- 常见的字符设备:键盘、鼠标、串口、终端、打印机等
- 字符设备驱动提供open、read、write、ioctl等接口,应用程序可以像访问普通文件一样访问字符设备
- 字符设备在/dev目录下有对应的设备文件,比如/dev/tty、/dev/input/mouse等
2. 块设备驱动
- 块设备是按照块为单位来访问的设备,可以随机寻址,支持随机读写
- 常见的块设备:硬盘、SSD、U盘、光盘等
- 块大小通常是512字节或者4KB,读写操作以块为单位
- 块设备支持缓存机制,性能比字符设备高
- 块设备在/dev目录下也有对应的设备文件,比如/dev/sda、/dev/sdb等
3. 网络设备驱动
- 网络设备是特殊的设备,负责数据包的发送和接收
- 和字符设备、块设备不同,网络设备没有对应的/dev目录下的设备文件,应用程序通过socket接口访问网络设备
- 网络设备驱动负责处理网络数据包的接收和发送,实现网络协议栈的底层接口
- 常见的网络设备:以太网网卡、无线网卡等
4. 其他驱动
- 总线驱动:PCI、USB、SATA等总线驱动,负责管理总线上的设备
- 显卡驱动:负责控制显卡,实现2D/3D渲染、显示输出等功能
- 音频驱动:负责控制声卡,实现音频输入输出
- 文件系统驱动:实现不同文件系统的逻辑,比如ext4、NTFS驱动等
驱动程序的运行机制
驱动程序运行在内核态,和操作系统内核紧密配合:
1. 驱动的加载
驱动程序可以两种方式加载:
- 静态编译进内核:驱动代码和内核编译在一起,内核启动时自动加载,适合必要的驱动比如硬盘驱动
- 动态加载模块:驱动编译为独立的内核模块,需要的时候动态加载到内核,不需要的时候可以卸载,比如大多数外设驱动。Linux下可以通过insmod/rmmod/modprobe等命令加载卸载内核模块。
2. 设备识别和初始化
- 系统启动时,总线驱动会扫描总线上的设备,识别设备的ID和类型
- 找到对应的驱动程序,加载驱动
- 驱动初始化设备,分配资源(中断、IO地址、内存等)
- 注册设备接口到操作系统,应用程序可以访问
3. 中断处理
大多数硬件设备通过中断和CPU通信:
- 硬件设备完成操作或者有事件通知时,会向CPU发送中断信号
- CPU收到中断后,暂停当前执行的程序,跳转到内核的中断处理程序
- 内核调用对应驱动的中断处理函数,处理硬件事件
- 处理完成后恢复之前的程序继续执行
比如按下键盘按键时,键盘会发送中断信号,CPU收到中断后调用键盘驱动的中断处理函数,读取按键值,发送给上层应用。
4. 设备操作接口
驱动程序为上层提供统一的操作接口:
- 字符设备:file_operations结构体,包含open、read、write、ioctl、release等函数指针
- 块设备:block_device_operations结构体,包含读写块、同步等接口
- 网络设备:net_device_ops结构体,包含数据包发送、接收等接口
应用程序通过系统调用访问设备时,操作系统会调用对应的驱动接口函数。
常见的驱动框架
为了简化驱动开发,操作系统提供了各种驱动框架,驱动开发者只需要实现框架定义的接口即可,不需要关心内核的复杂逻辑。
Linux驱动框架
Linux内核提供了完善的驱动框架:
- 字符设备驱动框架:简化字符设备驱动的开发
- 块设备驱动框架:通用块层,抽象块设备的通用逻辑
- 网络设备驱动框架:网络子系统,提供网络设备的通用接口
- USB驱动框架:支持各种USB设备的开发
- PCI驱动框架:支持PCI设备的开发
- 设备树(Device Tree):描述硬件设备的信息,驱动不需要硬编码硬件参数,方便跨平台移植
Windows驱动框架
Windows提供了WDF(Windows Driver Frameworks)驱动框架:
- KMDF(Kernel-Mode Driver Framework):内核模式驱动框架
- UMDF(User-Mode Driver Framework):用户模式驱动框架,可以把驱动放到用户态运行,提高系统稳定性,驱动崩溃不会导致系统蓝屏
驱动程序相关的常见问题
1. 驱动签名
为了系统安全,现代操作系统都要求驱动程序必须经过数字签名:
- Windows:未签名的驱动无法加载,防止恶意驱动破坏系统
- Linux:开启Secure Boot后,内核模块也需要签名才能加载
- 驱动签名保证了驱动来自可信的厂商,没有被篡改
2. 驱动稳定性
驱动程序运行在内核态,权限很高,如果驱动有bug,很容易导致系统崩溃(蓝屏、内核panic):
- Windows的蓝屏很多时候都是第三方驱动bug导致的
- Linux的内核panic也经常和驱动问题相关
- 稳定的系统要尽量使用经过认证的稳定版本驱动,不要随便安装第三方驱动
3. 驱动兼容性
不同版本的操作系统内核接口可能变化,驱动需要适配不同的内核版本:
- Windows驱动一般兼容多个版本的系统
- Linux内核版本之间API经常变化,驱动通常需要针对不同内核版本编译
4. 开源驱动 vs 闭源驱动
- 开源驱动:源代码公开,社区维护,稳定性好,兼容性好,但功能可能有限,比如Linux的开源显卡驱动
- 闭源驱动:由厂商提供,不公开源代码,功能完善,性能高,但可能有兼容性问题和安全隐患,比如NVIDIA的闭源显卡驱动
驱动开发注意事项
如果你需要开发驱动程序,需要注意:
- 内核态编程和用户态不同:没有标准库,内存管理需要特别小心,出错会导致系统崩溃
- 并发安全:驱动可能被多个进程同时调用,需要处理好同步和互斥
- 中断处理要快:中断处理程序不能执行耗时操作,否则会影响系统响应
- 内存访问要小心:内核态可以访问所有内存,非法访问会导致系统崩溃
- 可移植性:尽量使用内核提供的标准API,不要使用硬件相关的硬编码,方便跨平台移植
思考问题
- 为什么需要驱动程序?操作系统不能直接控制硬件吗?
- 字符设备和块设备有什么区别?分别适合什么类型的设备?
- 驱动程序运行在用户态还是内核态?为什么?
- 为什么不稳定的驱动程序会导致系统崩溃?
8.5 现代操作系统特性
随着计算机技术的发展,操作系统也在不断演进,出现了很多新的特性和设计思想,满足不同场景的需求。本节我们介绍现代操作系统的一些核心特性。
操作系统架构的演进
传统的宏内核和微内核架构已经发展了几十年,现在出现了很多新的架构设计:
1. 混合内核
我们之前讲过混合内核结合了宏内核和微内核的优点,是现在主流桌面和服务器操作系统的选择:
- Windows NT内核、macOS XNU内核、Linux内核(带动态模块)都属于混合内核的范畴
- 核心功能在内核态运行保证性能,非核心功能可以动态加载或者运行在用户态,兼顾性能和扩展性
2. 外核(Exokernel)
外核是一种更激进的设计思想,内核只负责资源的隔离和分配,不提供抽象接口:
- 内核只做最基本的资源隔离,把硬件资源直接暴露给应用程序
- 应用程序可以自己实现需要的抽象(文件系统、内存管理等)
- 性能极高,没有内核的额外开销
- 适合对性能要求极高的特殊场景,比如高性能计算、专用服务器
- 代表系统:MIT的Exokernel
3. 单内核模块化
现代的宏内核都在向模块化方向发展,功能都做成可动态加载的模块,需要的时候加载,不需要的时候卸载:
- Linux的内核模块机制就是典型代表,驱动、文件系统、网络协议都可以做成模块动态加载
- 既保留了宏内核的高性能,又有很好的扩展性
- 内核本身可以做得很小,只保留最核心的功能
容器与虚拟化技术
容器和虚拟化是现在云计算的基础技术,本质上都是操作系统提供的资源隔离能力。
虚拟化技术
虚拟化技术可以在一台物理机上运行多个独立的虚拟机,每个虚拟机有自己的操作系统:
- 原理:通过Hypervisor(虚拟机监控器)模拟硬件资源,每个虚拟机看到的都是独立的虚拟硬件
- 类型:
- 类型1虚拟化:Hypervisor直接运行在硬件上,性能高,比如VMware ESXi、KVM、Xen
- 类型2虚拟化:Hypervisor运行在宿主操作系统上,比如VMware Workstation、VirtualBox
- 优点:隔离性强,每个虚拟机独立操作系统,安全性高
- 缺点:开销大,每个虚拟机都需要运行完整的操作系统,占用资源多,启动慢
容器技术
容器是更轻量的虚拟化技术,多个容器共享同一个操作系统内核:
- 原理:利用操作系统的内核特性(Linux的Namespace和Cgroups)实现进程级的隔离
- Namespace:隔离PID、网络、文件系统、用户等资源,每个容器看到的是独立的系统环境
- Cgroups:限制容器的资源使用(CPU、内存、磁盘IO、网络等)
- 代表:Docker、Kubernetes
- 优点:
- 轻量,不需要运行完整操作系统,启动快,资源占用小
- 性能接近原生,几乎没有额外开销
- 一致性好,开发测试生产环境一致
- 缺点:隔离性比虚拟机弱,共享内核,一个容器影响内核可能会影响其他容器
- 适用场景:微服务部署、持续集成、云原生应用
容器和虚拟化的对比:
| 特性 | 虚拟机 | 容器 |
|---|---|---|
| 隔离级别 | 硬件级 | 进程级 |
| 性能 | 有一定开销 | 接近原生 |
| 启动时间 | 分钟级 | 秒级甚至毫秒级 |
| 资源占用 | 大,每个VM几GB | 小,每个容器几MB |
| 隔离性 | 强 | 中等 |
| 适用场景 | 异构系统、需要强隔离的场景 | 微服务、云原生、同构应用 |
实时操作系统(RTOS)
实时操作系统(Real-Time Operating System)是专门为实时场景设计的操作系统,要求任务必须在规定的时间内完成,确定性高。
实时系统的特点
- 确定性:任务的响应时间是确定的,最大响应时间有严格的保证
- 高优先级任务抢占:高优先级任务可以立即抢占低优先级任务,保证实时性
- 低延迟:中断响应和任务切换延迟极低,通常是微秒级
- 可靠性高:长时间稳定运行,不能崩溃
分类
- 硬实时系统:任务必须在截止时间前完成,超过截止时间会导致灾难性后果,比如航空航天、自动驾驶、工业控制、医疗设备等场景
- 软实时系统:允许偶尔超过截止时间,不会导致严重后果,比如视频播放、音频处理等场景
常见的实时操作系统
- FreeRTOS:小型嵌入式实时操作系统,开源,广泛应用于微控制器、物联网设备
- QNX:微内核实时操作系统,安全性高,广泛应用于汽车、医疗、工业控制领域
- VxWorks:工业级实时操作系统,可靠性极高,用于航空航天、军事、机器人等领域
- RT-Linux:Linux的实时补丁,给Linux增加实时特性,兼顾Linux的生态和实时性
分布式操作系统
分布式操作系统管理多台计算机的资源,让用户使用起来像使用一台计算机一样:
- 所有节点组成一个统一的系统,用户不需要关心资源在哪个节点上
- 透明性:访问远程资源和本地资源一样
- 高可用:单个节点故障不会影响整个系统的可用性
- 可扩展:可以通过增加节点线性提升系统性能
- 代表性的分布式系统:Google的Fuchsia、各类分布式存储和计算系统
操作系统安全机制
现代操作系统越来越重视安全,提供了很多安全机制:
1. 内存保护
- 地址空间隔离:每个进程有独立的虚拟地址空间,不能访问其他进程的内存
- DEP/NX:数据不可执行,防止缓冲区溢出攻击执行恶意代码
- ASLR(地址空间布局随机化):进程的地址空间布局每次启动都随机化,防止攻击者定位攻击目标
- Stack Canary:栈保护,检测栈溢出攻击
2. 权限控制
- 用户权限隔离:普通用户和管理员权限分离,最小权限原则
- 能力机制(Capability):基于能力的权限控制,每个进程只有必要的权限
- 强制访问控制(MAC):比如Linux的SELinux、Windows的Mandatory Integrity Control,系统强制控制资源访问,即使程序有漏洞也不能越权访问
3. 安全启动
- UEFI Secure Boot:只允许加载签名的内核和驱动,防止恶意软件篡改引导过程和内核
- Measured Boot:测量启动过程中的所有组件,存到TPM芯片中,可以远程验证系统是否被篡改
4. 沙箱机制
- 把不可信的程序放到沙箱中运行,限制其能访问的资源,即使被攻破也不会影响整个系统
- 浏览器的沙箱、移动应用的沙箱、Docker容器等都是沙箱机制的应用
5. 硬件安全支持
- TPM/SE/TrustZone:可信执行环境,安全存储密钥、敏感数据,执行敏感操作,和普通操作系统隔离
- 内存加密:加密内存中的数据,即使物理内存被窃取也无法获取数据
操作系统的发展趋势
- 云原生优化:针对容器、微服务、云计算场景优化,提升资源利用率和弹性
- 异构计算支持:支持GPU、NPU、FPGA等异构计算资源,满足AI、高性能计算等场景需求
- 安全强化:零信任、内存安全、可信执行环境等安全特性越来越重要
- 低延迟/实时性:满足自动驾驶、工业互联网、元宇宙等低延迟场景的需求
- 轻量化:针对物联网、边缘计算场景的轻量级操作系统,资源占用小,启动快
- 分布式:针对分布式场景优化,更好地支持多节点集群和分布式应用
思考问题
- 容器和虚拟机有什么区别?分别适合什么场景?
- 实时操作系统和普通操作系统相比有什么特点?适合什么场景?
- 操作系统有哪些常见的安全机制?它们是如何保护系统安全的?
- 你觉得未来操作系统会向什么方向发展?
练习题与扩展阅读
练习题
基础题
- 内核态和用户态有什么区别?为什么要区分这两种状态?
- 宏内核、微内核、混合内核各有什么优缺点?分别适合什么场景?
- 简述计算机从按下电源键到进入操作系统桌面的完整流程。
- BIOS和UEFI有什么区别?UEFI相比BIOS有哪些优势?
- 什么是系统调用?系统调用和普通函数调用有什么区别?
- 驱动程序的作用是什么?字符设备和块设备有什么区别?
实操题
- 查看你的操作系统的内核版本和启动信息:
- Linux:使用
uname -a和dmesg命令 - Windows:查看系统信息和事件查看器
- Linux:使用
- 编写一个简单的程序,分别直接调用系统调用和通过标准库调用,对比两者的性能差异。
- 查看你的电脑上加载了哪些驱动程序/内核模块:
- Linux:
lsmod命令 - Windows:设备管理器
- Linux:
- 尝试用容器运行一个简单的Nginx服务,体验容器的启动速度和资源占用。
思考题
- 为什么系统调用会有开销?有哪些方法可以减少系统调用的开销?
- 操作系统启动过程中initramfs的作用是什么?如果没有initramfs会出现什么问题?
- 微内核架构性能不如宏内核,但为什么很多嵌入式和安全关键场景都使用微内核?
- 容器技术和虚拟机技术各有什么优缺点?未来容器会完全取代虚拟机吗?
扩展阅读
书籍推荐
-
《操作系统导论》
- 全书都在讲操作系统的核心原理,本章内容可以对应到本书的第一部分引言、第二部分虚拟化相关章节,非常适合入门学习。
-
《现代操作系统》(第4版)
- 操作系统领域的经典教材,详细讲解了操作系统的各个模块,包括进程、内存、文件系统、I/O、安全等,适合深入学习。
-
《Linux内核设计与实现》
- 讲解Linux内核的设计和实现原理,包括进程管理、内存管理、系统调用、驱动模型等内容,适合想要深入了解Linux内核的开发者。
-
《UEFI原理与编程》
- 详细讲解UEFI的原理和开发,适合想要了解现代计算机启动过程和UEFI技术的读者。
在线资源
-
- 可以查询不同架构Linux的系统调用号和参数,了解系统调用的细节。
-
- 操作系统开发的维基百科,包含操作系统开发的所有相关知识,非常适合学习底层原理。
-
- 酷壳的文章,通俗易懂地讲解了Linux容器技术的底层实现原理。
-
- Linux内核官方的驱动开发文档,是学习Linux驱动开发的权威资料。
工具推荐
- strace:Linux下的系统调用跟踪工具,可以跟踪程序执行过程中调用的所有系统调用和参数,排查程序问题非常有用。
- dmesg:查看内核日志,包括启动信息、驱动日志、内核错误信息等,排查驱动和系统问题必备。
- lsmod/modinfo:查看内核模块信息,加载卸载内核模块。
- vmstat/mpstat:查看系统性能指标,包括系统调用次数、上下文切换次数等。
- Docker/Podman:容器运行时工具,用来体验和使用容器技术。
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第9章:网络技术基础
学习目标
通过本章学习,你将能够:
- 理解网络分层模型和各层的核心作用
- 掌握TCP/IP协议族的核心协议原理(IP、TCP、UDP、HTTP等)
- 理解网络数据包的传输过程和常见网络设备的作用
- 掌握网络编程的基本原理和常见模型
- 了解网络安全基础和常见的网络攻击防护方法
- 能够排查常见的网络问题
章节简介
网络技术是现代程序员必备的基础知识,无论是前端、后端还是客户端开发,都离不开网络。理解网络原理,能够帮助你写出更高效、更安全的网络应用,快速排查网络相关的问题。本章将从网络分层模型讲起,系统讲解链路层、网络层、传输层、应用层各层的核心协议原理,网络编程基础,以及网络安全相关的知识,帮助你建立完整的网络知识体系。
本章内容
- OSI七层模型和TCP/IP四层模型
- 各层的核心功能和作用
- 网络数据包的封装和解封装过程
- 常见的网络设备(交换机、路由器、网关等)的作用
- 以太网协议和MAC地址
- ARP地址解析协议
- IP协议原理、IP地址分类、子网划分
- ICMP协议与ping命令
- 路由原理和路由表
- UDP协议原理、特点和适用场景
- TCP协议原理:三次握手、四次挥手、流量控制、拥塞控制
- TCP/UDP对比和选型建议
- 端口和套接字的概念
- HTTP/HTTPS协议原理和常见字段
- DNS域名系统原理
- FTP、SSH、WebSocket等常见应用层协议
- RESTful API设计和GraphQL简介
- Socket编程原理和常见接口
- 常见网络IO模型:BIO、NIO、多路复用、AIO
- RPC远程过程调用原理
- 网络编程常见坑点和最佳实践
- 常见网络攻击方式:XSS、CSRF、SQL注入、DDoS等
- 加密算法基础:对称加密、非对称加密、哈希算法
- HTTPS和TLS/SSL协议原理
- 防火墙、WAF等安全设备的作用
- 网络安全最佳实践
学习建议
本章内容对所有开发者都非常重要,建议结合实际开发场景学习。如果你是后端开发者,重点掌握TCP/IP协议细节、网络编程模型、HTTPS原理;如果是前端开发者,重点掌握HTTP/HTTPS、WebSocket、Web安全相关内容;如果是运维或者网络相关开发者,可以深入学习路由、网络安全等内容。学习时可以结合Wireshark抓包工具,实际观察网络数据包的结构和传输过程,加深理解。
难度:★★★☆☆
预计学习时间:4小时
9.1 网络分层模型
计算机网络是一个非常复杂的系统,为了简化设计和实现,人们把网络通信的过程拆分成多个层次,每层负责不同的功能,层与层之间通过标准接口通信,这就是网络分层模型。理解分层模型是掌握网络原理的基础。
为什么需要分层
网络通信涉及到很多复杂的问题:
- 物理传输介质怎么传0和1?
- 数据怎么传送到目标地址?
- 数据传输出错了怎么办?
- 不同的应用程序怎么区分不同的网络数据?
如果把所有功能都放在一起实现,会非常复杂,难以维护和扩展。分层的设计思想可以大大降低复杂度:
- 各层独立:每层只需要关心自己的功能,不需要知道其他层的实现细节,通过接口和上下层交互
- 易于实现和维护:某一层的实现变化不会影响其他层,只要接口不变
- 标准化:各层的功能和接口标准化,不同厂商的设备可以互相兼容
- 易于排障:网络出问题时,可以逐层排查,快速定位问题所在的层级
两种常见的分层模型
1. OSI七层参考模型
OSI(Open Systems Interconnection)七层模型是国际标准化组织制定的网络参考模型,是理论上的标准,把网络通信分为七层:
| 层级 | 名称 | 作用 | 常见协议/设备 |
|---|---|---|---|
| 7 | 应用层 | 为应用程序提供网络服务,直接和用户交互 | HTTP、FTP、DNS、SSH |
| 6 | 表示层 | 数据格式转换、加密解密、压缩解压缩 | 加密协议、SSL/TLS |
| 5 | 会话层 | 建立、管理和终止会话 | RPC、NetBIOS |
| 4 | 传输层 | 端到端的通信,流量控制、可靠传输 | TCP、UDP |
| 3 | 网络层 | 地址寻址、路由选择、分组转发 | IP、ICMP、路由器 |
| 2 | 数据链路层 | 相邻节点之间的帧传输、差错校验、流量控制 | 以太网、ARP、交换机 |
| 1 | 物理层 | 传输原始的比特流,定义物理介质、电气特性 | 网线、光纤、集线器、网卡 |
OSI七层模型是理论上的完美分层,但实际应用中并没有完全实现,现在广泛使用的是TCP/IP四层模型。
2. TCP/IP四层模型(DoD模型)
TCP/IP四层模型是互联网实际使用的标准模型,是事实上的工业标准,把OSI七层简化为四层:
| TCP/IP四层 | 对应OSI层 | 作用 | 常见协议 |
|---|---|---|---|
| 应用层 | 应用层+表示层+会话层 | 负责具体的应用层协议,处理应用业务逻辑 | HTTP、HTTPS、FTP、DNS、SSH、WebSocket |
| 传输层 | 传输层 | 端到端的通信,提供可靠或不可靠的传输 | TCP、UDP |
| 网络层(网际互联层) | 网络层 | 地址寻址和路由选择,把数据包从源主机发送到目标主机 | IP、ICMP、ARP |
| 网络接口层(链路层) | 数据链路层+物理层 | 控制物理介质的访问,传输帧 | 以太网、Wi-Fi、网卡、驱动程序 |
TCP/IP模型更简单实用,我们平时讨论网络协议时通常用TCP/IP四层模型。
网络数据包的封装和解封装
网络数据在各层之间传递时,每层都会加上自己的头部信息,这个过程叫做封装;接收方收到数据后,逐层去掉对应的头部,得到原始数据,这个过程叫做解封装。
封装过程(发送方):
- 应用层:用户数据产生,加上应用层头部,比如HTTP头
- 传输层:加上TCP/UDP头部,包含源端口、目标端口等信息,形成TCP段
- 网络层:加上IP头部,包含源IP、目标IP等信息,形成IP数据包
- 链路层:加上以太网帧头,包含源MAC、目标MAC等信息,形成以太网帧
- 物理层:转换为比特流,通过物理介质传输
应用层数据 → [HTTP头][数据]
↓ 传输层封装
[TCP头][HTTP头][数据]
↓ 网络层封装
[IP头][TCP头][HTTP头][数据]
↓ 链路层封装
[以太网头][IP头][TCP头][HTTP头][数据]
↓ 物理层
比特流传输
解封装过程(接收方):
- 物理层:收到比特流,转换为以太网帧,交给链路层
- 链路层:检查以太网帧头,确认目标MAC是自己,去掉以太网头,把IP包交给网络层
- 网络层:检查IP头,确认目标IP是自己,去掉IP头,把TCP段交给传输层
- 传输层:检查TCP/UDP头,根据端口号找到对应的应用程序,去掉TCP头,把应用层数据交给应用层
- 应用层:处理应用层数据,比如HTTP服务器解析HTTP请求
这种分层封装的方式让各层只需要处理自己的头部,不需要关心其他层的数据,大大简化了实现复杂度。
常见网络设备对应的层级
| 设备 | 工作层级 | 作用 |
|---|---|---|
| 集线器(Hub) | 物理层 | 简单的信号放大和广播,所有端口共享带宽,现在基本被淘汰 |
| 交换机(Switch) | 数据链路层 | 根据MAC地址转发数据帧,不同端口之间带宽独立,是局域网的核心设备 |
| 路由器(Router) | 网络层 | 根据IP地址转发数据包,实现不同网络之间的通信,是互联网的核心设备 |
| 三层交换机 | 数据链路层+网络层 | 既可以做二层交换,也可以做三层路由 |
| 防火墙 | 网络层/传输层/应用层 | 过滤网络流量,隔离不同安全级别的网络 |
| 负载均衡器 | 传输层/应用层 | 将请求分发到多个服务器,提升系统处理能力和可用性 |
分层思想的实际意义
理解网络分层对我们排查网络问题非常有帮助:
- 物理层问题:网线没插好、光纤断了、无线信号差,属于物理层问题,表现为网络完全不通或者丢包严重
- 链路层问题:MAC地址冲突、ARP欺骗、交换机配置错误,表现为局域网内不通
- 网络层问题:IP配置错误、路由错误、网关配置错误,表现为跨网段不通、ping不通目标IP
- 传输层问题:端口被防火墙拦截、TCP连接异常,表现为IP能ping通,但是端口连接不上
- 应用层问题:应用协议配置错误、程序bug,表现为端口能通,但业务功能不正常
比如用户反馈网站打不开,可以逐层排查:
- 先ping域名看能不能解析到IP → 检查DNS(应用层)
- ping目标IP看能不能通 → 检查网络层和链路层
- telnet 80/443端口看能不能通 → 检查传输层和防火墙
- 用curl访问看能不能得到正确响应 → 检查应用层HTTP服务
思考问题
- 为什么网络要采用分层设计?如果不分层会有什么问题?
- OSI七层模型和TCP/IP四层模型有什么区别和联系?我们实际工作中常用哪种模型?
- 一个HTTP请求从浏览器发出到收到响应,分别经过了TCP/IP四层模型的哪些层?每层都做了什么工作?
- 如果ping一个外网IP不通,可能是哪些层级出现了问题?怎么排查?
9.2 链路层与网络层
链路层和网络层是网络通信的基础层,负责将数据包从源主机传输到目标主机,理解这两层的原理有助于我们排查网络连接问题,理解数据包的传输过程。
数据链路层
数据链路层负责同一个局域网内相邻节点之间的数据传输,最常见的链路层协议是以太网协议,现在几乎所有的局域网都是以太网。
以太网协议
以太网是目前使用最广泛的局域网技术,定义了链路层的帧格式和传输规则。
以太网帧格式
一个标准的以太网帧大小在64-1518字节之间,结构如下:
┌──────────┬──────────┬────────┬─────────┬──────────┬──────────┐
│ 目标MAC │ 源MAC │ 类型 │ 数据 │ FCS校验 │ 帧间隙 │
│ 6字节 │ 6字节 │ 2字节 │ 46-1500字节│ 4字节 │ 12字节 │
└──────────┴──────────┴────────┴─────────┴──────────┴──────────┘
- 目标MAC地址:接收方的物理地址
- 源MAC地址:发送方的物理地址
- 类型:表示上层协议类型,0x0800表示IP协议,0x0806表示ARP协议
- 数据:上层协议的数据包,最大1500字节,这个大小叫做MTU(最大传输单元)
- FCS:帧校验序列,用来检测帧在传输过程中是否出错
MAC地址
MAC地址(Media Access Control Address)是网卡的物理地址,是每个网卡全球唯一的标识符,长度48位,通常表示为12个十六进制数,比如00:1A:2B:3C:4D:5E。
- MAC地址是烧录在网卡中的,理论上不会改变
- 链路层通过MAC地址来标识同一个局域网内的不同设备
- 注意:MAC地址是局域网内使用的,跨局域网传输时MAC地址会改变,而IP地址是跨网络的,全程不变。
ARP协议(地址解析协议)
ARP协议的作用是根据IP地址获取对应的MAC地址,是链路层和网络层之间的桥梁。
- 工作原理:
- 主机A要和同一局域网内的主机B通信,知道主机B的IP地址,但不知道MAC地址
- 主机A在局域网内广播ARP请求:“IP地址是192.168.1.100的MAC地址是什么?”
- 局域网内所有主机都收到这个请求,只有IP地址匹配的主机B会发送ARP响应,告诉主机A自己的MAC地址
- 主机A收到响应后,把IP和MAC的对应关系缓存到ARP缓存中,下次直接使用
- ARP缓存:每个主机都有ARP缓存表,存储IP到MAC的映射,有效期通常是15-30分钟
- ARP欺骗:攻击者发送伪造的ARP响应,欺骗其他主机把流量发到攻击者的机器上,是常见的局域网攻击手段,通常用静态ARP绑定、ARP防火墙来防范。
VLAN(虚拟局域网)
VLAN可以把一个物理交换机划分为多个逻辑上的虚拟局域网,不同VLAN之间二层隔离,不能直接通信,需要通过路由器转发。
- 作用:隔离广播域,提升网络安全性,方便网络管理
- 802.1Q协议:在以太网帧头中加入VLAN标签,标识帧属于哪个VLAN
网络层(IP层)
网络层负责跨网络的数据包传输,把数据包从源主机经过多个网络转发到目标主机,核心协议是IP协议(Internet Protocol)。
IP协议
IP协议是TCP/IP协议族的核心,所有上层协议的数据都封装在IP数据包中传输。IP协议是无连接、不可靠的协议,不保证数据包一定能到达,也不保证顺序,可靠性由上层的TCP协议保证。
IP地址
IP地址是互联网上每个主机的唯一标识,目前主要有两个版本:IPv4和IPv6。
IPv4地址
IPv4地址长度32位,通常用点分十进制表示,比如192.168.1.1,分为网络部分和主机部分:
- 网络部分:标识主机所属的网络
- 主机部分:标识网络内的具体主机
IP地址分类
早期的IP地址分为五类:
| 类别 | 开头 | 范围 | 网络数 | 每个网络的主机数 | 用途 |
|---|---|---|---|---|---|
| A类 | 0 | 0.0.0.0 ~ 127.255.255.255 | 126 | 1600万 | 大型网络 |
| B类 | 10 | 128.0.0.0 ~ 191.255.255.255 | 16384 | 65534 | 中型网络 |
| C类 | 110 | 192.0.0.0 ~ 223.255.255.255 | 200万 | 254 | 小型网络 |
| D类 | 1110 | 224.0.0.0 ~ 239.255.255.255 | - | - | 组播 |
| E类 | 1111 | 240.0.0.0 ~ 255.255.255.255 | - | - | 保留 |
现在分类的方式已经很少使用了,取而代之的是CIDR(无类域间路由),用子网掩码来划分网络和主机部分。
子网掩码
子网掩码用来区分IP地址的网络部分和主机部分,长度也是32位,网络部分全为1,主机部分全为0,例如:
- A类默认子网掩码:255.0.0.0(/8)
- B类默认子网掩码:255.255.0.0(/16)
- C类默认子网掩码:255.255.255.0(/24)
CIDR表示法:在IP地址后面加/网络位数,比如192.168.1.0/24表示前24位是网络位,后8位是主机位,这个网段有256个IP地址。
特殊IP地址
- 127.0.0.0/8:回环地址,用于本机通信,127.0.0.1就是本机
- 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16:私有IP地址,用于局域网,不能直接访问公网,需要NAT转换
- 0.0.0.0:表示所有地址,通常用于服务监听所有网卡
- 255.255.255.255:广播地址,同一局域网内所有主机都能收到
- 169.254.0.0/16:自动专用IP地址,DHCP获取失败时自动分配的地址
NAT(网络地址转换)
由于公网IPv4地址不足,局域网内的私有IP访问公网时需要通过NAT转换为公网IP:
- 源NAT(SNAT):修改数据包的源IP,把私有IP转换为公网IP,多个内网主机共享一个公网IP
- 目的NAT(DNAT):修改数据包的目的IP,把公网IP和端口映射到内网的某个主机和端口
- 端口映射就是一种DNAT,把公网的端口映射到内网服务器的端口
- NAT解决了IPv4地址不足的问题,但也导致了很多问题,比如P2P连接困难,需要打洞技术
IPv6
IPv4的地址只有32位,最多只能有43亿个地址,现在已经耗尽了,IPv6是下一代IP协议,地址长度128位,几乎可以无限分配:
- 表示方式:8组四位十六进制数,用冒号分隔,比如
2001:0db8:85a3:0000:0000:8a2e:0370:7334,可以省略连续的0,简写为2001:db8:85a3::8a2e:370:7334 - 优势:地址空间极大,不需要NAT,端到端可达,内置安全特性,性能更好
- 现在越来越多的网络已经支持IPv6,是未来的发展方向
ICMP协议(互联网控制报文协议)
ICMP协议是网络层的辅助协议,用来传递控制消息,比如网络不可达、主机不可达、超时等。
我们常用的ping命令就是基于ICMP协议:
- ping向目标主机发送ICMP Echo Request请求
- 目标主机收到后返回ICMP Echo Reply响应
- 计算往返时间和丢包率,判断网络是否通畅
traceroute(Windows下是tracert)命令也是基于ICMP协议,用来探测数据包从源主机到目标主机经过的路由路径。
路由原理
当源主机和目标主机不在同一个局域网时,IP数据包需要经过多个路由器转发才能到达目标主机,这个过程就是路由。
路由表
每个路由器和主机都有一张路由表,记录不同网段的下一跳地址:
目标网络 子网掩码 下一跳地址 接口 度量值
0.0.0.0 0.0.0.0 192.168.1.1 eth0 100 # 默认路由
192.168.1.0 255.255.255.0 0.0.0.0 eth0 0 # 直连网段
10.0.0.0 255.0.0.0 192.168.1.2 eth0 50 # 10网段的下一跳是192.168.1.2
- 最长匹配原则:数据包的目标IP和路由表中的条目匹配时,选择最长前缀匹配的条目
- 默认路由:0.0.0.0/0,所有匹配不到其他路由的数据包都走默认路由,通常是网关地址
路由选择过程
- 主机发送IP数据包时,先判断目标IP和自己是否在同一个网段
- 同网段:直接通过ARP获取目标MAC,封装成帧直接发送
- 不同网段:把数据包发送给默认网关(路由器)
- 路由器收到数据包后,根据目标IP查找路由表,找到下一跳地址
- 路由器重新封装链路层帧,把数据包转发给下一跳路由器
- 经过多次转发,最终数据包到达目标主机所在的局域网,发送给目标主机
常见的路由协议
路由器之间通过路由协议交换路由信息,自动生成路由表:
- 内部网关协议(IGP):同一个自治系统内使用,比如RIP、OSPF
- 外部网关协议(EGP):不同自治系统之间使用,比如BGP,是互联网的核心路由协议
常见问题排查
- 同一局域网内主机不通:检查IP配置、子网掩码是否正确,ARP缓存是否正常,防火墙是否拦截
- 跨网段不通:检查网关配置是否正确,路由器路由是否正确,防火墙是否拦截
- ping不通公网:检查DNS配置、网关配置、NAT是否正常、公网出口是否正常
思考问题
- MAC地址和IP地址有什么区别?为什么需要两个地址?
- ARP协议的作用是什么?ARP欺骗的原理是什么?如何防范?
- 什么是子网划分?给定IP段192.168.1.0/24,要划分4个子网,每个子网至少30台主机,子网掩码应该是多少?每个子网的网段和可用IP范围是什么?
- 什么是NAT?它解决了什么问题?带来了什么新的问题?
9.3 传输层协议
传输层运行在网络层之上,负责端到端的通信,为应用层提供端到端的通信服务,屏蔽下层网络的细节,让应用层不需要关心数据包怎么在网络中传输。传输层有两个核心协议:TCP和UDP。
传输层的作用
网络层负责把数据包从源主机送到目标主机,但数据包到达目标主机后,还需要交给对应的应用程序处理。传输层的作用就是:
- 端口寻址:通过端口号区分不同的应用程序,把数据交给正确的应用程序
- 提供端到端的通信服务:为应用程序提供不同质量的通信服务,包括可靠的字节流服务(TCP)和不可靠的数据报服务(UDP)
- 流量控制和拥塞控制:控制发送速率,避免网络拥塞和接收方处理不过来
端口与套接字
端口号
端口号是传输层的地址,用来标识主机上的不同应用进程,长度16位,范围0-65535:
- 0-1023:知名端口,分配给常用的服务,比如HTTP=80,HTTPS=443,SSH=22,FTP=21等,需要root权限才能绑定
- 1024-49151:注册端口,分配给普通应用程序使用
- 49152-65535:动态端口/私有端口,客户端发起连接时随机使用的源端口
套接字(Socket)
网络通信的两端需要用套接字来唯一标识:
- 套接字 = IP地址 + 端口号
- 一个TCP连接由两端的套接字唯一标识:
源IP:源端口 <-> 目标IP:目标端口 - 同一个主机上,不同的连接不能有完全相同的四元组(源IP、源端口、目标IP、目标端口)
UDP协议(用户数据报协议)
UDP(User Datagram Protocol)是一种无连接的、不可靠的传输层协议。
UDP的特点
- 无连接:通信前不需要建立连接,发数据的时候直接封装成UDP包发出去就可以,不需要握手,延迟低
- 不可靠:不保证数据包一定到达,不保证顺序,不重传,出错了直接丢弃
- 面向数据报:应用层交下来的数据直接封装成UDP包,一次发送一个完整的UDP包,不会拆分也不会合并,保留报文边界
- 没有流量控制和拥塞控制:不管网络拥塞情况,想发多少就发多少
- 头部开销小:UDP头部只有8字节,比TCP的20字节小很多,传输效率高
UDP头部格式
┌──────────┬──────────┐
│ 源端口 │ 目标端口 │ 各2字节
├──────────┼──────────┤
│ 长度 │ 校验和 │ 各2字节
└──────────┴──────────┘
- 长度:UDP数据报的总长度,包括头部和数据
- 校验和:检测UDP数据报在传输过程中是否出错,出错就丢弃
UDP的适用场景
UDP虽然不可靠,但传输效率高,延迟低,适合对实时性要求高、可以容忍少量丢包的场景:
- 实时音视频通话:微信电话、视频会议等,丢个几帧不影响观看,但是延迟高了就会卡
- 直播、流媒体
- 游戏:特别是多人在线游戏,低延迟比可靠性更重要,丢包可以通过重传来补救,但延迟高了会严重影响体验
- DNS查询:小数据包,一次请求一响应,不需要建立连接,效率高
- 物联网设备:带宽小、功耗低的场景
TCP协议(传输控制协议)
TCP(Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层协议,是现在互联网应用最广泛的协议,HTTP、HTTPS、FTP、SSH等大部分应用层协议都基于TCP。
TCP的特点
- 面向连接:通信前需要先通过三次握手建立连接,通信结束后需要四次挥手释放连接
- 可靠传输:保证数据不丢、不重复、按序到达,出错会重传
- 面向字节流:没有报文边界,应用层的数据被看成一连串无结构的字节流,TCP可以拆分和合并,应用程序需要自己处理消息边界
- 流量控制:根据接收方的接收能力调整发送速率,避免接收方处理不过来
- 拥塞控制:根据网络拥塞情况调整发送速率,避免网络拥塞
- 全双工通信:连接建立后,双方都可以同时发送和接收数据
TCP头部格式
TCP头部最小20字节,最大60字节(包含选项字段):
┌──────────┬──────────┬──────────┬──────────┐
│ 源端口 │ 目标端口 │ 各2字节
├──────────┴──────────┴──────────┴──────────┤
│ 序列号(SEQ) │ 4字节
├───────────────────────────────────────────┤
│ 确认号(ACK) │ 4字节
├────┬────┬────┬──────────┬─────────────────┤
│ 头长│保留│ 标志位 │ 窗口大小 │ 头长4位,保留6位,标志位6位,窗口2字节
├──────────┬──────────┬─────────────────────┤
│ 校验和 │ 紧急指针 │ 各2字节
├───────────────────────────────────────────┤
│ 选项(可选,最大40字节) │
└───────────────────────────────────────────┘
- 序列号(SEQ):本报文段第一个数据字节的序号,用来解决乱序问题
- 确认号(ACK):期望收到对方下一个报文段的第一个字节的序号,确认号之前的字节都已经正确收到
- 标志位:
- SYN:同步标志,建立连接时使用
- ACK:确认标志,确认号字段有效,连接建立后所有报文都必须把ACK置1
- FIN:结束标志,释放连接时使用
- RST:复位标志,重置连接
- PSH:推送标志,接收方应该尽快把数据交给应用层
- URG:紧急标志,紧急指针有效
- 窗口大小:用来实现流量控制,表示接收方现在能接收的字节数
三次握手:TCP连接建立过程
TCP建立连接需要三次握手,也就是三个数据包交换:
客户端 服务器
│ SYN=1, SEQ=x │
│ ───────────────────────────────────────────────────▶ │
│ SYN=1, ACK=1, ACK=x+1, SEQ=y │
│ ◀─────────────────────────────────────────────────── │
│ ACK=1, ACK=y+1, SEQ=x+1 │
│ ───────────────────────────────────────────────────▶ │
│ │
│ 连接建立成功 │
过程说明:
- 第一次握手:客户端发送SYN报文,SYN=1,序列号SEQ=x,客户端进入SYN_SENT状态,请求建立连接
- 第二次握手:服务器收到SYN后,回复SYN+ACK报文,SYN=1,ACK=1,确认号ACK=x+1,自己的序列号SEQ=y,服务器进入SYN_RCVD状态
- 第三次握手:客户端收到SYN+ACK后,回复ACK报文,ACK=1,确认号ACK=y+1,序列号SEQ=x+1,客户端进入ESTABLISHED状态,服务器收到ACK后也进入ESTABLISHED状态,连接建立成功
为什么需要三次握手,而不是两次?
- 防止已经失效的连接请求报文突然又传到服务器,导致服务器建立不必要的连接,浪费资源
- 双方都要确认对方的发送和接收能力是正常的,三次握手可以保证双方都知道对方有正常的收发能力
- 同步双方的初始序列号,序列号是可靠传输的基础,需要双方确认对方的初始序列号
四次挥手:TCP连接释放过程
TCP断开连接需要四次挥手,因为TCP是全双工的,双方都需要单独关闭发送通道:
客户端 服务器
│ FIN=1, SEQ=u │
│ ───────────────────────────────────────────────────▶ │
│ ACK=1, ACK=u+1, SEQ=v │
│ ◀─────────────────────────────────────────────────── │
│ 客户端到服务器的方向关闭,服务器还可以继续发数据 │
│ FIN=1, ACK=1, ACK=u+1, SEQ=w │
│ ◀─────────────────────────────────────────────────── │
│ ACK=1, ACK=w+1, SEQ=u+1 │
│ ───────────────────────────────────────────────────▶ │
│ │
│ 连接释放完成 │
过程说明:
- 第一次挥手:客户端发送FIN报文,FIN=1,序列号SEQ=u,客户端进入FIN_WAIT_1状态,表示客户端没有数据要发送了,请求关闭连接
- 第二次挥手:服务器收到FIN后,回复ACK报文,ACK=1,确认号ACK=u+1,序列号SEQ=v,服务器进入CLOSE_WAIT状态,客户端收到ACK后进入FIN_WAIT_2状态,此时客户端到服务器的通道关闭,但服务器还可以发送数据
- 第三次挥手:服务器数据发送完后,发送FIN报文,FIN=1,ACK=1,确认号ACK=u+1,序列号SEQ=w,服务器进入LAST_ACK状态
- 第四次挥手:客户端收到FIN后,回复ACK报文,ACK=1,确认号ACK=w+1,序列号SEQ=u+1,客户端进入TIME_WAIT状态,等待2MSL(最长报文寿命,通常1-2分钟)后进入CLOSED状态,服务器收到ACK后进入CLOSED状态,连接释放完成
为什么需要TIME_WAIT状态?
- 保证最后一个ACK报文能到达服务器,如果服务器没收到ACK会重发FIN,TIME_WAIT状态可以处理重发的FIN
- 让本次连接的所有报文都从网络中消失,避免新连接收到旧连接的报文
TCP的可靠传输机制
TCP通过以下机制保证可靠传输:
- 序列号和确认应答:每个字节都有序号,接收方收到后回复确认号,发送方如果在超时时间内没收到确认就重传
- 超时重传:发送方发送报文后启动定时器,超时没收到确认就重传报文
- 流量控制:滑动窗口机制,接收方通过TCP头的窗口字段告诉发送方自己还能接收多少数据,发送方根据窗口大小调整发送量,避免接收方处理不过来
- 拥塞控制:发送方根据网络拥塞情况调整发送速率,避免网络拥塞,主要有四个算法:慢启动、拥塞避免、快重传、快恢复
TCP的适用场景
TCP适合对可靠性要求高,对延迟要求不高的场景:
- Web服务(HTTP/HTTPS)
- 文件传输(FTP、SFTP)
- 邮件(SMTP、POP3)
- 远程登录(SSH)
- 数据库访问
- 所有不允许丢包的场景
TCP vs UDP对比和选型
| 特性 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接 | 无连接 |
| 可靠性 | 可靠,保证不丢、不重复、有序 | 不可靠,不保证交付,不保证顺序 |
| 传输方式 | 面向字节流,无报文边界 | 面向数据报,有报文边界 |
| 性能 | 传输效率低,头部开销大,延迟高 | 传输效率高,头部开销小,延迟低 |
| 流量控制/拥塞控制 | 有 | 无 |
| 连接数量 | 一对一全双工 | 一对一、一对多、多对多 |
| 适用场景 | 对可靠性要求高的场景,Web、文件传输、邮件等 | 对实时性要求高的场景,音视频、游戏、DNS等 |
选型建议:
- 需要可靠传输优先选TCP
- 实时性要求高、可以容忍少量丢包选UDP
- 现在很多应用会在UDP之上自己实现可靠传输机制,比如QUIC、WebRTC,兼顾UDP的低延迟和TCP的可靠性
思考问题
- TCP和UDP各有什么优缺点?分别适合什么场景?
- TCP建立连接为什么需要三次握手?两次握手会有什么问题?
- TCP断开连接为什么需要四次挥手?TIME_WAIT状态的作用是什么?
- TCP是怎么保证可靠传输的?有哪些机制?
9.4 应用层协议
应用层是TCP/IP模型的最上层,直接为应用程序提供网络服务,我们日常开发接触最多的就是应用层协议。应用层协议定义了应用程序之间通信的规则和报文格式。
HTTP协议(超文本传输协议)
HTTP是互联网上使用最广泛的应用层协议,是Web的基础,所有的网站、Web服务都基于HTTP协议。
HTTP的特点
- 客户端-服务器模型:客户端发起请求,服务器响应请求
- 无状态:HTTP协议本身不保存客户端的状态,每个请求都是独立的,服务器不知道多个请求是否来自同一个客户端,需要通过Cookie、Session、Token等机制来维护状态
- 简单灵活:请求和响应结构简单,支持各种数据类型(文本、图片、音视频等)
- 明文传输:HTTP本身是明文传输的,内容容易被窃听和篡改,所以现在通常使用HTTPS加密传输
HTTP请求结构
一个HTTP请求由三部分组成:请求行、请求头、请求体:
GET /index.html HTTP/1.1 # 请求行:方法 路径 HTTP版本
Host: www.example.com # 请求头
User-Agent: Mozilla/5.0
Accept: text/html
Connection: keep-alive
# 空行分隔头和体
username=test&password=123456 # 请求体(GET请求通常没有请求体)
请求方法
HTTP/1.1定义了八种请求方法:
- GET:获取资源,请求参数通常放在URL中,请求体为空,幂等
- POST:提交数据,通常用于创建资源,请求体包含提交的数据,非幂等
- PUT:更新资源,替换整个资源,幂等
- DELETE:删除资源,幂等
- HEAD:和GET类似,但只返回响应头,不返回响应体,用来获取资源元信息
- OPTIONS:查询服务器支持的请求方法和跨域权限
- PATCH:部分更新资源,非幂等
- CONNECT:建立隧道连接,用于HTTPS代理
- TRACE:回显请求,用于调试
常见请求头
- Host:请求的主机名和端口
- User-Agent:客户端标识,浏览器、操作系统等信息
- Accept:客户端能接收的内容类型
- Content-Type:请求体的类型,常见的有:
- application/x-www-form-urlencoded:普通表单提交
- multipart/form-data:文件上传
- application/json:JSON格式数据
- Content-Length:请求体的长度
- Cookie:客户端保存的Cookie信息
- Authorization:认证信息,通常是Bearer Token等
- Referer:请求来源页面
- Cache-Control:缓存控制
HTTP响应结构
HTTP响应也由三部分组成:状态行、响应头、响应体:
HTTP/1.1 200 OK # 状态行:版本 状态码 状态描述
Server: nginx # 响应头
Content-Type: text/html; charset=utf-8
Content-Length: 1024
Cache-Control: max-age=3600
<!DOCTYPE html> # 响应体
<html>
...
</html>
状态码
状态码表示请求的处理结果,分为五类:
- 1xx:信息性状态码,表示请求已接收,继续处理
- 100 Continue:继续发送请求的剩余部分
- 101 Switching Protocols:切换协议,比如切换到WebSocket
- 2xx:成功状态码,表示请求已成功处理
- 200 OK:请求成功
- 201 Created:资源创建成功
- 204 No Content:请求成功,没有响应体
- 206 Partial Content:部分内容,用于断点续传
- 3xx:重定向状态码,表示需要进一步操作才能完成请求
- 301 Moved Permanently:永久重定向,资源已经永久移动到新地址
- 302 Found:临时重定向,资源临时移动
- 304 Not Modified:资源未修改,可以使用缓存
- 4xx:客户端错误状态码,表示请求有错误
- 400 Bad Request:请求参数错误
- 401 Unauthorized:未认证,需要登录
- 403 Forbidden:权限不足,禁止访问
- 404 Not Found:资源不存在
- 405 Method Not Allowed:请求方法不允许
- 429 Too Many Requests:请求过多,被限流
- 5xx:服务器错误状态码,表示服务器处理出错
- 500 Internal Server Error:服务器内部错误
- 502 Bad Gateway:网关错误,上游服务异常
- 503 Service Unavailable:服务不可用,通常是过载或维护
- 504 Gateway Timeout:网关超时,上游服务响应超时
常见响应头
- Content-Type:响应体的类型和编码
- Content-Length:响应体的长度
- Content-Encoding:响应体的压缩方式,gzip、deflate等
- Cache-Control:缓存控制,max-age=3600表示可以缓存3600秒
- Set-Cookie:设置Cookie,服务器让客户端保存Cookie
- Location:重定向的目标地址
- Access-Control-Allow-Origin:跨域控制,允许哪些域名跨域访问
- ETag:资源的标识,用于缓存验证
- Last-Modified:资源最后修改时间
HTTP的发展
- HTTP/0.9:1991年发布,只有GET方法,只能返回HTML
- HTTP/1.0:1996年发布,支持多种请求方法,支持多媒体
- HTTP/1.1:1999年发布,是目前使用最广泛的版本,支持长连接、虚拟主机、断点续传等
- HTTP/2:2015年发布,二进制协议,多路复用,头部压缩,服务器推送,性能比HTTP/1.1提升很多
- HTTP/3:基于QUIC协议,使用UDP传输,更快的连接建立,队头阻塞问题解决,性能更好,目前正在普及中
HTTPS协议
HTTPS就是HTTP + SSL/TLS,在HTTP和TCP之间加了一层SSL/TLS加密层,解决了HTTP明文传输的问题:
- 加密:传输的数据都是加密的,第三方无法窃听内容
- 完整性:数据不会被篡改,篡改会被检测到
- 身份认证:确认网站的真实身份,防止中间人攻击
HTTPS的握手过程:
- 客户端发送Client Hello,支持的加密套件、随机数等
- 服务器返回Server Hello,选择加密套件,发送证书
- 客户端验证证书合法性,生成预主密钥,用服务器公钥加密后发送给服务器
- 服务器用私钥解密得到预主密钥,双方各自生成会话密钥
- 后续的HTTP数据都用会话密钥加密传输
DNS协议(域名系统)
DNS的作用是把域名(比如www.example.com)解析为对应的IP地址,因为网络层只认IP地址,不认域名。
DNS的工作原理
DNS是一个分布式的层次数据库,采用递归查询和迭代查询相结合的方式:
- 客户端查询本地DNS缓存,有就直接返回
- 没有的话向本地DNS服务器发起递归查询,本地DNS服务器负责帮客户端查询
- 本地DNS服务器如果有缓存直接返回,否则进行迭代查询:
- 先查根域名服务器,得到顶级域(.com)服务器地址
- 查顶级域服务器,得到二级域(example.com)服务器地址
- 查权威域名服务器,得到www.example.com的IP地址
- 本地DNS服务器把结果返回给客户端,同时缓存结果
DNS记录类型
常见的DNS记录类型:
- A记录:域名指向IPv4地址
- AAAA记录:域名指向IPv6地址
- CNAME记录:域名指向另一个域名,相当于别名
- MX记录:邮件交换记录,指向邮件服务器地址
- TXT记录:文本记录,通常用来做验证,比如域名所有权验证、SPF反垃圾邮件
- NS记录:域名服务器记录,指定该域名由哪个DNS服务器解析
DNS优化
- DNS缓存:浏览器、操作系统、本地DNS服务器都会缓存DNS解析结果,减少查询时间
- 域名预解析:提前解析需要用到的域名,减少用户访问时的延迟
- 智能DNS:根据用户地理位置返回最近的服务器IP,提升访问速度
其他常见应用层协议
FTP(文件传输协议)
- 用于在主机之间传输文件,默认端口20(数据端口)和21(控制端口)
- 有主动模式和被动模式两种工作模式
- 明文传输,不安全,现在通常使用SFTP(SSH File Transfer Protocol)代替,基于SSH加密传输
SSH(安全外壳协议)
- 用于远程登录服务器,默认端口22
- 加密传输,安全可靠,代替了传统的明文传输的Telnet协议
- 还可以用来做端口转发、隧道、文件传输(SFTP/SCP)
SMTP/POP3/IMAP(邮件协议)
- SMTP:简单邮件传输协议,默认端口25,用于发送邮件
- POP3:邮局协议版本3,默认端口110,用于接收邮件,下载到本地后服务器上的邮件会被删除
- IMAP:互联网邮件访问协议,默认端口143,用于接收邮件,邮件保存在服务器上,可以多端同步
WebSocket
- HTML5提供的全双工通信协议,基于TCP,默认端口80(ws)和443(wss加密)
- 握手阶段使用HTTP协议,握手成功后变成TCP连接,双方可以实时双向发送数据
- 适合需要实时通信的场景:聊天、实时推送、在线游戏、协同编辑等
RESTful API设计
REST(Representational State Transfer)是现在最流行的API设计风格:
- 资源:每个URL对应一个资源
- 用HTTP方法表示操作:GET获取、POST创建、PUT更新、DELETE删除
- 无状态:每个请求包含所有必要的信息,服务器不保存状态
- 统一接口:使用标准的HTTP状态码、请求头、响应头
- 响应通常使用JSON格式
GraphQL
GraphQL是Facebook推出的API查询语言,是REST的替代方案:
- 客户端可以精确指定需要的数据结构,避免过度获取或者数据不足
- 一个请求可以获取多个资源,减少请求次数
- 强类型 schema,便于校验和文档生成
- 适合复杂的前端应用,尤其是移动应用,可以减少请求数量,提升性能
思考问题
- HTTP协议是无状态的,怎么实现保持用户登录状态?
- HTTP状态码301和302有什么区别?分别适合什么场景?
- HTTPS是怎么保证传输安全的?为什么HTTPS可以防窃听和篡改?
- DNS的工作原理是什么?输入URL到浏览器显示页面的过程中,DNS起到什么作用?
9.5 网络编程基础
网络编程是现代程序员必备的技能,几乎所有的应用都需要网络通信功能。理解网络编程的原理,能够帮助我们写出高性能、高可靠的网络应用。
Socket编程原理
Socket(套接字)是网络编程的基础抽象,是应用层和传输层之间的接口,我们通过Socket来发送和接收网络数据。
什么是Socket
Socket是操作系统提供的网络编程接口,封装了底层的网络协议细节,让应用程序不需要关心TCP/IP的实现细节,只需要调用Socket接口就可以实现网络通信。
- Socket可以看成是两个网络通信端点的抽象,每个Socket对应一个
IP:端口 - 常见的Socket类型:
- 流套接字(SOCK_STREAM):对应TCP协议,提供可靠的、面向连接的字节流服务
- 数据报套接字(SOCK_DGRAM):对应UDP协议,提供不可靠的、无连接的数据报服务
- 原始套接字(SOCK_RAW):可以直接访问IP层,用来构造自定义的IP数据包,用于ping、traceroute等工具
TCP Socket编程流程
服务器端流程
- 创建Socket:调用
socket()函数,创建一个TCP套接字 - 绑定地址:调用
bind()函数,把Socket绑定到指定的IP和端口 - 监听连接:调用
listen()函数,让套接字进入监听状态,等待客户端连接 - 接受连接:调用
accept()函数,阻塞等待客户端连接,有客户端连接时返回一个新的Socket,专门用来和这个客户端通信 - 收发数据:通过
read()/recv()和write()/send()函数和客户端通信 - 关闭连接:通信完成后,调用
close()关闭Socket
服务器:
socket() → bind() → listen() → accept() → recv()/send() → close()
客户端流程
- 创建Socket:调用
socket()函数创建TCP套接字 - 连接服务器:调用
connect()函数,连接到服务器的IP和端口 - 收发数据:连接成功后,通过
read()/recv()和write()/send()和服务器通信 - 关闭连接:通信完成后调用
close()关闭连接
客户端:
socket() → connect() → send()/recv() → close()
UDP Socket编程流程
UDP是无连接的,不需要建立连接:
服务器端
- 创建UDP Socket:
socket(AF_INET, SOCK_DGRAM, 0) - 绑定地址:
bind() - 接收数据:
recvfrom(),可以得到发送方的地址 - 发送数据:
sendto(),指定接收方地址
客户端
- 创建UDP Socket
- 发送数据:
sendto()指定服务器地址 - 接收数据:
recvfrom()
UDP不需要建立连接,每次发送数据都需要指定目标地址,适合短消息、实时性要求高的场景。
常见网络IO模型
网络IO模型决定了程序处理多个连接的能力,是高性能网络编程的核心。
1. 阻塞IO(BIO)
- 原理:所有IO操作都是阻塞的,一个线程只能处理一个连接
- 工作流程:accept()阻塞等待连接,连接建立后,recv()阻塞等待数据,处理完一个连接才能处理下一个
- 优点:编程简单,容易实现
- 缺点:并发能力低,每个连接需要一个线程,线程资源有限,而且线程切换开销大,高并发场景下性能很差
- 适用场景:连接数不多的简单场景
2. 非阻塞IO(NIO)
- 原理:把Socket设置为非阻塞模式,IO调用会立即返回,不会阻塞线程
- 工作流程:线程轮询所有Socket,看哪个Socket有数据可读可写,有事件了就处理
- 优点:一个线程可以处理多个连接,不需要为每个连接开线程
- 缺点:轮询会消耗大量CPU,大部分轮询都是空转,效率低
- 适用场景:很少单独使用,通常和IO多路复用结合
3. IO多路复用(IO Multiplexing)
- 原理:用一个线程监听多个Socket的状态,当某个Socket有事件时,才通知应用程序处理
- 核心是多路复用器:select/poll/epoll(Linux)、kqueue(BSD/macOS)、IOCP(Windows)
- 工作流程:
- 把要监听的Socket注册到多路复用器上
- 调用多路复用器的等待函数,阻塞等待事件发生
- 有事件发生时,返回所有就绪的Socket列表
- 遍历就绪的Socket列表,逐个处理
- 优点:一个线程可以处理成千上万的连接,线程开销小,并发能力强,是现在高性能网络服务器的主流模型
- 缺点:编程复杂度比BIO高,需要处理事件驱动的逻辑
- **常见实现:
- select:跨平台,最多支持1024个连接,性能随连接数增加而下降
- poll:和select类似,但没有最大连接数限制,性能还是随连接数增加而下降
- epoll:Linux下的高性能多路复用器,事件通知机制,性能不会随连接数增加而下降,是现在Linux服务器的首选
- 适用场景:高并发网络服务,比如Web服务器、网关、即时通信服务等,Nginx、Redis、Node.js等都用的是IO多路复用模型
4. 异步IO(AIO)
- 原理:用户发起IO请求后立即返回,不需要等待,内核完成IO操作后通知用户程序
- 和IO多路复用的区别:IO多路复用是通知你Socket就绪了,需要你自己去读写数据;异步IO是内核已经帮你把数据读写完成了,直接通知你结果
- 优点:性能最高,完全不阻塞线程,可以充分利用CPU
- 缺点:编程复杂,实现难度高,跨平台支持不好,Linux下的原生AIO实现不够完善,现在实际使用还不多
- 适用场景:对性能要求极高的场景,现在高并发场景下更多还是用IO多路复用 + 多线程的模式
5. 信号驱动IO
- 原理:Socket注册信号处理函数,IO就绪时内核发送SIGIO信号通知程序处理,很少使用。
高并发模型对比
| 模型 | 线程数 | 并发能力 | 编程复杂度 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| BIO | 连接数 | 低(几百) | 低 | 低 | 连接少的简单场景 |
| NIO | 1 | 中等(几千) | 中 | 中 | 很少单独使用 |
| IO多路复用 | 1+ | 高(几万到几十万) | 中高 | 高 | 高并发场景,主流方案 |
| AIO | 1+ | 极高 | 高 | 很高 | 高性能场景,还未普及 |
RPC远程过程调用
RPC(Remote Procedure Call)远程过程调用,让调用远程服务就像调用本地函数一样简单,屏蔽了网络通信的细节,是现在分布式系统的核心技术。
RPC的原理
RPC封装了网络通信的细节,让用户不需要关心网络传输、序列化、错误处理等细节,就像调用本地函数一样调用远程服务:
客户端调用本地Stub函数 → Stub将参数序列化 → 发送网络请求到服务器
↓
服务器接收请求 → 反序列化参数 → 调用实际服务函数 → 结果序列化返回给客户端
↓
客户端Stub收到结果 → 反序列化返回给调用者
RPC的核心组件
- 客户端Stub:客户端的代理,封装远程调用的细节,把函数调用转换为网络请求
- 服务端Stub:服务端的代理,接收网络请求,反序列化参数,调用对应的服务函数
- 序列化/反序列化:把内存中的对象转换为字节流在网络上传输,常见的序列化协议:JSON、Protocol Buffer、Thrift、Hessian等
- 网络传输层:负责数据的传输,通常使用TCP协议,也有用HTTP、UDP的
- 服务注册与发现:分布式场景下,客户端需要知道服务端的地址,通过服务注册中心发现服务地址
常见的RPC框架
- gRPC:Google开源的RPC框架,基于HTTP/2,使用Protocol Buffer序列化,性能高,跨语言
- Thrift:Apache开源的跨语言RPC框架,性能优异
- Dubbo:阿里巴巴开源的Java RPC框架,国内使用广泛
- Spring Cloud:基于HTTP的微服务框架,使用RESTful API
网络编程常见坑点和最佳实践
常见坑点
-
TCP粘包/拆包问题:TCP是面向字节流的,没有报文边界,发送端发送的多个数据包可能会被粘在一起,或者一个包被拆成多个,接收方需要自己处理报文边界
- 解决方法:
- 固定长度报文,不够的补位
- 特殊分隔符分隔,比如换行符分隔
- 报文头加长度字段,先读取长度,再读取对应长度的内容
- 解决方法:
-
忽略错误处理:网络是不可靠的,所有的网络调用都可能失败,必须处理各种错误情况:连接中断、超时、重置等,不要假设网络调用一定会成功
-
阻塞模式下的超时问题:阻塞IO一定要设置超时时间,否则连接断了或者网络有问题时,线程会一直阻塞,无法释放资源
-
大端小端字节序问题:网络字节序是大端序,不同主机的字节序可能不同,多字节整数传输时一定要转成网络字节序,接收方再转为主机字节序
-
连接泄漏:网络连接使用完一定要关闭,特别是异常路径下也要关闭,否则会导致连接泄漏,文件描述符耗尽
-
流量控制和拥塞控制:不要发送太快,要考虑接收方的处理能力和网络拥塞情况,避免被系统丢包
最佳实践
-
优先使用成熟的网络库:不要自己从零实现Socket服务,优先使用成熟的高性能网络库:
- C++:libevent、libuv、Boost.Asio
- Java:Netty
- Go:标准库net包
- Python:asyncio、Twisted 这些库已经处理了所有底层的复杂问题,性能和稳定性都经过了验证。
-
合理设置超时时间:所有的网络操作都要设置超时时间,包括连接超时、读取超时、写入超时,避免无限等待
-
心跳机制:长连接场景下要加心跳机制,定期检测连接是否存活,及时清理死连接
-
限流和熔断:高并发场景下要加限流机制,避免请求量过大打垮服务;下游服务故障时要熔断,避免雪崩效应
-
二进制协议优先:性能要求高的场景优先使用二进制序列化协议(Protobuf、Thrift),比JSON性能高很多,体积小
-
IO密集型服务用IO多路复用模型:高并发网络服务优先使用IO多路复用 + 线程池的模型,充分利用CPU资源
-
使用长连接减少握手开销:频繁通信的场景尽量使用长连接,避免TCP三次握手和四次挥手的开销,比如连接池、HTTP keep-alive
-
合理设置缓冲区大小:Socket的发送和接收缓冲区大小要根据业务场景合理设置,太小会导致频繁IO,太大会浪费内存
思考问题
- 简述TCP服务端Socket编程的主要流程,每个系统调用的作用是什么?
- IO多路复用模型相比多线程阻塞模型有什么优势?为什么高并发场景下都用IO多路复用?
- 什么是TCP粘包问题?为什么会出现?怎么解决?
- RPC相比直接使用HTTP通信有什么优势?适合什么场景?
9.6 网络安全基础
网络安全是每个开发者都必须掌握的知识,现在网络攻击越来越普遍,了解常见的攻击方式和防护方法,才能写出安全可靠的应用,避免数据泄露和系统被入侵。
常见网络攻击方式
1. XSS攻击(跨站脚本攻击)
原理:攻击者在网页中注入恶意的JavaScript脚本,当用户访问网页时,脚本会在用户的浏览器中执行,窃取用户的Cookie、账号信息,或者执行恶意操作。
- 类型:
- 存储型XSS:恶意脚本被存储到服务器数据库中,所有访问该内容的用户都会被攻击,比如论坛的帖子评论中注入脚本
- 反射型XSS:恶意脚本放在URL中,诱导用户点击链接,服务器将脚本反射回浏览器执行
- DOM型XSS:攻击完全发生在客户端,通过修改页面DOM执行脚本,不需要服务器参与
- 危害:窃取用户Cookie、冒充用户操作、钓鱼、挂马
- 防范方法:
- 所有用户输入的内容在输出到页面时都进行HTML转义,替换特殊字符< > ’ “ &等
- 设置Cookie的HttpOnly属性,禁止JavaScript读取Cookie
- 对用户输入进行过滤和校验,禁止输入危险的标签和脚本
- 使用内容安全策略CSP,限制页面可以加载的脚本来源
2. CSRF攻击(跨站请求伪造)
原理:攻击者诱导用户登录A网站后,访问攻击者的恶意网站,恶意网站自动向A网站发送请求,利用用户在A网站的登录状态,冒充用户执行操作(比如转账、修改密码)。
- 攻击条件:用户登录了A网站,并且没有退出,Cookie还在有效期内,访问了恶意网站
- 危害:冒充用户执行敏感操作,比如转账、发消息、修改信息
- 防范方法:
- 使用CSRF Token:每个请求携带一个随机的Token,服务器验证Token是否合法
- 验证Referer字段,检查请求是否来自合法的来源
- 敏感操作增加二次验证,比如短信验证码、密码确认
- 重要操作使用POST请求,不要用GET请求执行修改操作
3. SQL注入攻击
原理:攻击者在输入的参数中注入SQL语句,服务器没有对输入进行校验,直接拼接到SQL语句中执行,导致攻击者可以执行任意SQL语句,获取、修改、删除数据库数据,甚至控制数据库服务器。
- 例子:登录接口的SQL语句是
SELECT * FROM users WHERE username='$username' AND password='$password',攻击者输入用户名' OR 1=1 --,拼接后的SQL变成SELECT * FROM users WHERE username='' OR 1=1 --' AND password='xxx',直接绕过密码验证 - 危害:拖库(获取整个数据库的数据)、删库、篡改数据、获取服务器权限
- 防范方法:
- 永远不要拼接SQL语句,使用预编译SQL(PreparedStatement)和参数绑定
- 不要使用动态SQL,禁止用户输入直接拼接到SQL中
- 数据库权限最小化,应用的数据库账号不要给超级管理员权限,只给必要的权限
- 对用户输入进行严格的校验和过滤,特殊字符转义
- 生产环境禁止返回数据库错误信息到前端,避免泄露表结构
4. DDoS攻击(分布式拒绝服务攻击)
原理:攻击者控制大量肉鸡(被入侵的机器),同时向目标服务器发送大量的合法或伪造的请求,占满服务器的带宽和资源,导致正常用户无法访问。
- 常见类型:
- 流量型攻击:发送大量的流量占满服务器带宽,比如UDP洪水、ICMP洪水
- 资源耗尽型攻击:发送大量合法请求,占满服务器的连接、CPU、内存资源,比如SYN洪水、CC攻击
- 危害:服务不可用,业务中断,造成巨大经济损失
- 防范方法:
- 接入高防服务、CDN、流量清洗,清洗异常流量
- 带宽扩容,冗余部署
- 优化服务器性能,提升抗攻击能力
- 封禁异常IP,限制单IP的请求频率
- SYN Cookie、限速、限流等防护措施
5. 目录遍历攻击
原理:攻击者在URL中输入../等字符,穿越到网站根目录之外,读取服务器上的敏感文件,比如配置文件、源码、/etc/passwd等。
- 例子:网站的图片地址是
/download?filename=1.jpg,攻击者访问/download?filename=../etc/passwd,如果没有校验,就可以读取/etc/passwd文件 - 危害:泄露服务器敏感文件、源码、配置信息,甚至获取服务器权限
- 防范方法:
- 对用户传入的文件名进行严格校验,禁止包含
../等特殊字符 - 路径规范化后判断是否在允许的目录范围内
- 不要把敏感文件放在web目录下
- 权限最小化,web进程不要有敏感文件的读取权限
- 对用户传入的文件名进行严格校验,禁止包含
6. 命令注入攻击
原理:攻击者在输入中注入系统命令,服务器没有校验直接执行,导致攻击者可以执行任意系统命令,控制服务器。
- 例子:接口需要执行ping命令,用户输入IP地址
127.0.0.1; rm -rf /,如果直接拼接到系统命令中执行,就会执行删除根目录的命令 - 危害:完全控制服务器,删除数据、窃取数据、安装木马
- 防范方法:
- 尽量不要执行系统命令,如果必须执行,禁止把用户输入拼接到命令中
- 使用安全的函数执行命令,参数分开传递,不要用shell解析
- 对用户输入进行严格的过滤和校验,禁止包含|&;等特殊字符
- 运行服务的用户权限最小化,不要用root权限运行
加密算法基础
加密算法是网络安全的基础,用来保证数据的保密性、完整性、身份认证。
对称加密算法
加密和解密使用同一个密钥:
- 常见算法:AES、DES、3DES、SM4(国密)
- 优点:加密解密速度快,效率高,适合大量数据加密
- 缺点:密钥分发困难,需要安全地把密钥传给对方
- 适用场景:大数据量加密、本地数据加密
非对称加密算法
加密和解密使用不同的密钥,分为公钥和私钥,公钥公开,私钥自己保存:
- 常见算法:RSA、ECC、SM2(国密)
- 用法:
- 公钥加密,私钥解密:用来加密传输数据,只有私钥持有者能解密
- 私钥签名,公钥验签:用来做身份认证和防篡改,私钥签名的内容只有对应的公钥能验证
- 优点:安全性高,不需要传输私钥,解决密钥分发问题
- 缺点:加密解密速度慢,只适合小数据量加密
- 适用场景:密钥交换、数字签名、身份认证
哈希算法(摘要算法)
将任意长度的数据转换为固定长度的摘要,是单向的,不能从摘要反推出原始数据:
- 常见算法:MD5(不安全)、SHA-1(不安全)、SHA-256、SHA-512、SM3(国密)
- 特点:
- 相同的输入一定得到相同的输出
- 不同的输入几乎不可能得到相同的输出(抗碰撞)
- 单向不可逆
- 用途:数据完整性校验、密码存储、数字签名
- 注意:MD5和SHA-1已经被破解,不要用于安全相关的场景,密码存储应该用慢哈希算法(bcrypt、scrypt、PBKDF2),而不是普通的哈希算法
数字签名
数字签名用来验证数据的完整性和发送者的身份:
- 原理:发送方对数据的哈希值用私钥签名,接收方用公钥验证签名是否合法
- 作用:
- 身份认证:确认数据是私钥持有者发送的
- 不可否认:发送方不能否认发送过该数据
- 完整性:数据没有被篡改过
HTTPS与TLS/SSL协议
HTTPS就是HTTP + TLS/SSL,在HTTP和TCP之间加了一层TLS加密层,保证通信的安全。
TLS握手过程
- 客户端Hello:客户端发送支持的TLS版本、加密套件、随机数给服务器
- 服务器Hello:服务器选择双方都支持的加密套件和TLS版本,返回服务器的数字证书、服务器生成的随机数
- 客户端验证证书:客户端验证证书的合法性(是否是可信CA签发、是否过期、域名是否匹配),验证通过后生成预主密钥,用服务器公钥加密后发送给服务器
- 服务器解密预主密钥:服务器用私钥解密得到预主密钥,双方根据之前的随机数和预主密钥生成相同的会话密钥
- 握手完成:双方通知对方后续使用会话密钥加密通信,握手完成,后续的HTTP数据都用会话密钥对称加密传输
数字证书
数字证书用来证明网站的身份,由可信的CA(证书颁发机构)签发:
- 证书包含:网站域名、公钥、有效期、CA签名等信息
- 证书链:根CA签发中级CA证书,中级CA签发用户证书,形成证书链,客户端通过信任的根证书来验证证书的合法性
- 注意:不要使用自签名证书,客户端不信任,存在中间人攻击风险,应该使用可信CA签发的证书
HTTPS的作用
- 保密性:传输的数据都是加密的,第三方无法窃听内容
- 完整性:数据传输过程中被篡改会被检测到
- 身份认证:验证网站的真实身份,防止钓鱼网站和中间人攻击
常见安全设备和机制
- 防火墙:位于内外网之间,根据规则过滤网络流量,禁止非法访问,分为网络层防火墙和应用层防火墙
- WAF(Web应用防火墙):专门防护Web应用的攻击,比如XSS、SQL注入、CSRF、CC攻击等
- 入侵检测系统(IDS)/入侵防御系统(IPS):检测网络中的入侵行为,发现攻击及时报警或阻断
- VPN(虚拟专用网络):在公网上建立加密的专用通道,远程访问内部网络时使用,保证通信安全
- 零信任架构:默认不信任任何内部或外部的用户和设备,每次访问都需要验证授权,是现在安全架构的发展方向
网络安全最佳实践
开发阶段
- 最小权限原则:所有系统、数据库、服务的权限最小化,只给需要的最小权限,不要用root/管理员权限运行服务
- 输入校验:所有用户输入都要做严格的校验和过滤,永远不要信任用户的输入
- 不要硬编码敏感信息:密码、密钥、Token等敏感信息不要硬编码在代码和配置文件中,应该用加密的配置中心或者环境变量存储
- 密码安全:用户密码不要明文存储,要用bcrypt等慢哈希算法加盐存储,不要用MD5、SHA1
- 输出转义:所有用户输入输出到页面、日志、数据库的地方都要做对应的转义,避免注入攻击
- HTTPS全站加密:所有网站和接口都用HTTPS,不要用明文HTTP传输敏感信息
- 漏洞扫描:代码上线前做安全扫描,定期做渗透测试,及时修复安全漏洞
运行阶段
- 定期更新补丁:操作系统、应用、依赖库的安全补丁要及时更新,修复已知漏洞
- 日志和监控:完善安全日志和监控,及时发现异常访问和攻击行为
- 访问控制:敏感接口、后台管理系统加IP白名单、二次认证,限制访问权限
- 定期备份:数据定期备份,异地存储,防止被攻击后数据丢失
- 安全培训:开发和运维人员定期做安全培训,提升安全意识
思考问题
- XSS和CSRF攻击的原理是什么?如何防范这两种攻击?
- SQL注入的原理是什么?为什么预编译SQL能防止SQL注入?
- 对称加密和非对称加密各有什么优缺点?HTTPS握手过程中是怎么结合使用这两种加密方式的?
- 为什么要使用慢哈希算法存储用户密码,而不是普通的SHA-256哈希?
练习题与扩展阅读
练习题
基础题
- OSI七层模型和TCP/IP四层模型分别有哪些层?每层的核心作用是什么?
- TCP和UDP协议各有什么特点?分别适合什么场景?
- TCP建立连接为什么需要三次握手?断开连接为什么需要四次挥手?
- HTTP请求中GET和POST方法有什么区别?分别适合什么场景?
- 常见的网络攻击方式有哪些?分别怎么防范?
- TCP的三次握手中,为什么最后一次握手需要客户端发送ACK报文?
实操题
- 用Wireshark抓包工具,抓取一个HTTP请求的完整过程,查看以太网帧、IP包、TCP段、HTTP请求的结构,理解各层的头部字段。
- 编写一个简单的TCP服务器和客户端,实现简单的聊天功能,体会Socket编程的流程。
- 用
dig或者nslookup命令解析一个域名,查看DNS解析过程和返回的记录。 - 用
telnet或者nc命令测试目标服务器的端口是否开放,建立TCP连接发送数据。 - 模拟一个简单的XSS攻击场景,理解XSS的原理和防范方法。
思考题
- 输入一个URL到浏览器显示出页面,整个过程都发生了什么?请从网络层面详细描述。
- TCP是可靠传输协议,为什么我们在应用层还需要做超时重传和幂等设计?
- 为什么HTTPS能够防止中间人攻击?如果用户信任了伪造的根证书会有什么问题?
- 很多应用层协议都基于TCP,有没有可能基于UDP实现可靠传输?如果可以,怎么实现?
- HTTP是无状态的,那电商网站的购物车是怎么实现保存用户状态的?原理是什么?
扩展阅读
书籍推荐
-
《TCP/IP详解 卷1:协议》
- 网络领域的经典圣经,详细讲解了TCP/IP协议族的所有细节,每个网络从业者都应该读。
-
《HTTP权威指南》
- HTTP协议的权威指南,深入讲解HTTP协议的方方面面,包括请求响应结构、缓存、认证、代理等内容。
-
《Web安全深度剖析》
- 全面讲解Web安全的各种攻击方式和防护方法,适合开发者了解Web安全知识。
-
《UNIX网络编程》
- 网络编程的经典著作,详细讲解了Socket编程的原理和实现,适合系统学习网络编程。
在线资源
-
- MDN的HTTP文档,全面详细,是学习HTTP的最佳资料之一。
-
- 免费的TCP/IP指南,内容通俗易懂,覆盖了TCP/IP协议的所有核心内容。
-
- 开源的Web安全学习资料,涵盖了各种常见Web攻击和防护方法。
-
- Cloudflare的学习中心,有很多关于网络、安全、CDN的通俗易懂的科普文章。
-
- Cloudflare的技术博客,详细讲解HTTP/3和QUIC协议的原理和优势。
工具推荐
- Wireshark:最流行的网络抓包分析工具,可以抓取和分析各种网络数据包,排查网络问题的神器。
- tcpdump:Linux下的命令行抓包工具,适合在服务器上抓包分析。
- curl / Postman:HTTP请求测试工具,用来测试接口和调试HTTP请求。
- dig / nslookup / host:DNS查询工具,排查DNS问题。
- telnet / nc(netcat):网络调试工具,可以测试端口连通性、发送TCP/UDP数据。
- nmap:端口扫描和网络探测工具,用来扫描服务器开放的端口和服务。
- Charles / Fiddler:HTTP代理工具,可以拦截、查看、修改HTTP/HTTPS请求,调试接口非常方便。
- OpenSSL:SSL/TLS工具,可以生成证书、测试HTTPS连接、加密解密等。
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
第10章:综合实践与性能优化
学习目标
通过本章学习,你将能够:
- 掌握系统性能分析的方法和常用工具的使用
- 理解性能优化的方法论和通用优化思路
- 能够对CPU、内存、IO、网络等各个层面的性能问题进行分析和优化
- 掌握典型业务场景的性能优化实践
- 了解分布式系统的基础原理和设计思想
- 能够综合运用前面章节所学的知识解决实际工作中的性能问题
章节简介
本章是全书的综合实践章节,将前面所学的计算机基础知识综合应用到实际的性能优化场景中。性能优化是程序员进阶的必备能力,优秀的工程师不仅要能实现功能,还要能写出高性能、高可靠的代码。本章将从性能分析方法讲起,介绍性能优化的通用方法论,CPU、内存、IO、网络各个层面的优化技巧,典型业务场景的优化实践,以及分布式系统的基础知识,帮助你建立完整的性能优化知识体系。
本章内容
- 性能分析的基本思路和指标
- 系统层面性能分析工具(top、vmstat、iostat、perf等)
- 应用层面性能分析工具(debugger、profiler、APM监控等)
- 性能瓶颈定位的方法论
- 性能优化的基本原则和常见误区
- 阿姆达尔定律与优化收益评估
- 性能优化的通用步骤:测量、分析、优化、验证
- 不同层面的优化优先级:架构优化 > 算法优化 > 代码优化
- CPU密集型场景优化
- 内存使用优化
- 磁盘IO优化
- 网络性能优化
- 数据库性能优化
- 前端性能优化
- 分布式系统的核心概念和设计目标
- 常见的分布式架构模式
- 分布式缓存、消息队列、负载均衡的原理和应用
- 分布式一致性问题和解决方案(CAP定理、BASE理论、一致性算法)
- 微服务架构设计基础
学习建议
本章内容实践性很强,建议结合你在实际工作中遇到的性能问题来学习。学习时可以找一个实际的项目,按照本章介绍的方法进行性能分析和优化,把理论知识用到实际中。性能优化没有银弹,需要结合具体场景具体分析,不要盲目优化,一定要先测量定位瓶颈,再针对性优化。
难度:★★★☆☆
预计学习时间:4小时
10.1 系统性能分析工具
性能优化的第一步是性能分析,只有找到真正的性能瓶颈,才能针对性地优化,盲目优化只会做无用功。工欲善其事必先利其器,掌握常用的性能分析工具,能够帮助我们快速定位性能瓶颈。
性能分析的基本思路
核心性能指标
分析性能之前,我们需要先明确要关注哪些核心指标:
1. CPU相关
- CPU使用率:用户态使用率(us)、系统态使用率(sy)、空闲率(id)、等待IO使用率(wa)
- us高说明应用程序占用大量CPU
- sy高说明系统调用或者内核占用大量CPU
- wa高说明CPU在等待IO,瓶颈在磁盘或网络IO
- 负载(Load Average):1分钟、5分钟、15分钟的平均等待运行的进程数
- 负载大于CPU核心数说明有进程在等待CPU
- 持续高负载说明CPU资源不足或者有阻塞的进程
- 上下文切换次数:每秒上下文切换的次数,过高的上下文切换会消耗大量CPU资源
2. 内存相关
- 内存使用率:已用内存/总内存,注意buffer和cache是操作系统用的缓存,不算真正的内存不足
- 交换分区使用率(Swap usage):使用了交换分区说明物理内存不足,性能会严重下降
- 缺页异常次数:每秒缺页异常的次数,高缺页率说明内存访问局部性差或者内存不足
3. 磁盘IO相关
- IO使用率(util%):磁盘忙碌的时间百分比,接近100%说明磁盘IO饱和
- IOPS:每秒IO操作次数,HDD通常100-200,SSD通常1万-10万
- 吞吐量:每秒读写的数据量,单位MB/s
- 平均IO等待时间(await):IO操作的平均等待时间,正常应该小于10ms,大于100ms说明IO压力大
4. 网络相关
- 网络带宽使用率:入站/出站带宽占总带宽的比例
- 网络连接数:TCP连接数,TIME_WAIT、ESTABLISHED状态的数量
- 丢包率/错误率:网络数据包的丢包和错误比例,过高说明网络有问题
- 延迟:网络请求的往返时间RTT
5. 应用相关
- 吞吐量(QPS/TPS):每秒处理的请求数/事务数
- 响应时间:请求的平均响应时间、P95/P99响应时间
- 错误率:请求的错误比例
性能分析的通用步骤
- 先整体后局部:先看系统整体的CPU、内存、IO、网络指标,确定瓶颈大致在哪一层,再深入分析
- 从外到内:先看应用层面的指标(QPS、响应时间、错误率),再看系统层面的指标
- 对比指标:性能问题通常是相对的,要和正常情况的指标对比,才能发现异常
- 瓶颈定位顺序:通常按照CPU → 内存 → 磁盘IO → 网络的顺序排查
系统层面性能分析工具(Linux平台)
Linux系统有丰富的性能分析工具,最常用的有以下这些:
1. top / htop
最常用的系统监控工具,查看整体的CPU、内存、进程运行情况:
top
输出说明:
- 第一行:系统时间、运行时间、登录用户数、1/5/15分钟负载
- 第二行:总进程数、运行中、睡眠、停止、僵尸进程数
- 第三行:CPU使用率:us(用户态)、sy(系统态)、ni(低优先级进程)、id(空闲)、wa(等待IO)、hi(硬中断)、si(软中断)
- 第四行:内存使用情况:总内存、已用、空闲、缓冲、缓存
- 第五行:交换分区使用情况
- 下面的列表:各个进程的资源使用情况,PID、用户、CPU使用率、内存使用率、运行时间、进程名等
htop是top的增强版,界面更友好,支持鼠标操作,功能更强大,推荐优先使用。
2. vmstat
虚拟内存统计工具,查看系统整体的CPU、内存、IO、上下文切换情况:
vmstat 1 # 每秒输出一次统计信息
输出字段说明:
- procs:r(等待运行的进程数)、b(不可中断睡眠的进程数)
- memory:swpd(使用的交换内存)、free(空闲内存)、buff(缓冲区)、cache(页缓存)
- swap:si(从交换分区读入的大小)、so(写入交换分区的大小),这两个值大于0说明内存不足
- io:bi(从磁盘读入的块数)、bo(写入磁盘的块数)
- system:in(每秒中断次数)、cs(每秒上下文切换次数),cs过高说明上下文切换太频繁
- cpu:us、sy、id、wa、st,和top的CPU含义一样
3. iostat
磁盘IO性能分析工具,查看磁盘的使用率、IOPS、吞吐量、延迟等指标:
iostat -x 1 # 每秒输出一次扩展IO信息
输出关键字段:
- %util:磁盘IO使用率,接近100%说明磁盘饱和
- r/s、w/s:每秒读/写IO次数,加起来是IOPS
- rkB/s、wkB/s:每秒读/写的KB数,加起来是吞吐量
- await:平均每个IO的等待时间(毫秒),正常应该<10ms,>100ms说明IO压力大
- svctm:平均IO服务时间,通常是磁盘的固有性能,HDD约8-10ms,SSD约0.1ms
4. pidstat
查看单个进程的CPU、内存、IO、线程等详细统计信息:
pidstat -u 1 -p <pid> # 查看指定进程的CPU使用情况
pidstat -r 1 -p <pid> # 查看指定进程的内存使用情况
pidstat -d 1 -p <pid> # 查看指定进程的IO使用情况
pidstat -t 1 -p <pid> # 查看进程内各个线程的资源使用情况
非常适合定位单个进程的性能瓶颈。
5. perf
Linux性能计数器工具,功能非常强大,可以分析CPU热点函数、缓存命中率、上下文切换、缺页异常等底层性能问题:
perf top # 实时显示系统中CPU占用最高的函数,找CPU热点
perf record -g -p <pid> sleep 10 # 采样进程10秒的调用栈
perf report # 分析perf record生成的报告,看CPU消耗在哪些函数
perf stat -p <pid> # 统计进程的性能事件:指令数、缓存miss、分支预测miss等
perf是排查CPU性能问题的神器,可以深入到函数级别查看CPU消耗。
6. free
查看系统内存使用情况:
free -h # 人类可读的格式显示内存使用
注意:buffer和cache是操作系统的缓存,是为了提升性能,不算应用实际占用的内存,available列是真正可用的内存大小。
7. df / du
查看磁盘空间使用情况:
df -h # 查看各个分区的磁盘使用率
du -sh * # 查看当前目录下各个文件/目录的大小
8. netstat / ss
查看网络连接、端口监听、网络统计信息:
ss -tunlp # 查看所有TCP/UDP监听的端口和对应的进程
ss -s # 查看网络连接统计信息,各个状态的连接数
netstat -i # 查看网络接口的流量和错误统计
ss是netstat的升级版,性能更好,推荐使用。
9. sar
系统活动报告工具,可以记录和查看历史的性能数据,分析过去的性能问题:
sar -u 1 # 查看CPU使用率历史
sar -r 1 # 查看内存使用率历史
sar -n DEV 1 # 查看网络流量历史
默认会保存最近7天的性能数据,适合排查历史问题。
10. dstat
全能的系统资源统计工具,整合了vmstat、iostat、netstat等工具的功能,输出更直观:
dstat # 同时显示CPU、磁盘、网络、分页、系统等信息
应用层面性能分析工具
1. 通用调试工具
- gdb / lldb:C/C++程序的调试工具,可以断点调试、查看调用栈、分析core dump文件
- strace:跟踪进程的系统调用,看进程在做什么系统调用,耗时在哪里,非常适合排查阻塞、IO相关的问题
strace -tt -p <pid> # 跟踪进程的所有系统调用,显示时间戳 strace -c -p <pid> # 统计进程的系统调用次数和耗时 - ltrace:跟踪进程调用的动态库函数
2. 语言相关的Profiler
每种编程语言都有自己的性能分析工具:
- Java:jstack(看线程栈)、jmap(堆dump)、jstat(GC统计)、VisualVM、JProfiler、Arthas(阿里开源的Java诊断工具,功能非常强大)
- Go:pprof(内置的性能分析工具,可以分析CPU、内存、goroutine、锁竞争等)
- Python:cProfile、line_profiler、memory_profiler、py-spy
- C/C++:gprof、perf、Valgrind(内存问题检测)
- Node.js:Chrome DevTools、clinic.js
3. APM(应用性能监控)系统
线上生产环境通常会部署APM系统,全链路监控应用的性能:
- 开源APM:SkyWalking、Pinpoint、Zipkin、Jaeger
- 商业APM:Datadog、New Relic、听云
- 功能:
- 全链路追踪,查看请求各个阶段的耗时
- 接口性能监控:QPS、响应时间、错误率
- 数据库、缓存、外部调用的性能监控
- JVM、进程资源监控
- 异常和错误告警
APM系统是排查线上性能问题的首选工具,可以快速定位慢请求、瓶颈点。
4. 前端性能分析工具
- 浏览器开发者工具:Network面板看网络请求性能,Performance面板看页面加载和运行时性能
- Lighthouse:谷歌开源的网页性能分析工具,给出性能评分和优化建议
- Web Vitals:谷歌定义的核心网页性能指标:LCP(最大内容绘制)、FID(首次输入延迟)、CLS(累积布局偏移)
性能瓶颈定位实战
常见瓶颈的定位思路
1. CPU高负载排查
- 用top/htop看CPU使用率,是us高还是sy高,wa是不是很高
- wa高说明是IO瓶颈,不是CPU本身的问题
- sy高说明系统调用、内核、上下文切换消耗多
- us高说明是应用程序本身消耗CPU多
- us高的话用perf top/pprof看CPU热点函数,定位是哪个函数消耗CPU
- 上下文切换高的话用pidstat -w看是自愿切换还是非自愿切换,是不是线程太多了
2. 内存问题排查
- 用free看是不是真的内存不足,是不是buffer/cache占用太多
- Swap使用率高说明物理内存不足,需要扩容或者优化内存使用
- 用top看哪个进程占用内存多
- 内存泄漏的话,用对应语言的内存profiler工具分析,看哪里的内存没有释放
3. IO瓶颈排查
- 用iostat看磁盘%util是不是接近100%,await是不是很高
- 用pidstat -d看哪个进程占用IO多
- 用strace或者lsof看进程在读写什么文件
- 是随机IO多还是顺序IO多,是不是可以优化成顺序IO或者加缓存
4. 网络问题排查
- 用ss看连接数是不是太多,TIME_WAIT是不是太多
- 用sar -n看网络带宽是不是打满了
- 用ping/mtr看网络延迟和丢包率
- 用tcpdump/Wireshark抓包分析具体的网络请求
性能分析注意事项
- 不要过早优化:先找到瓶颈再优化,不要凭感觉盲目优化
- 测量,不要猜测:所有的优化都要有数据支撑,用工具测量瓶颈在哪里
- 对比基准线:要知道正常情况下的指标是多少,异常才有对比依据
- 生产环境小心使用工具:有些工具(比如perf、strace)会有一定的性能开销,生产环境高负载时谨慎使用
思考问题
- 系统Load高但CPU使用率不高,可能是什么原因?怎么排查?
- 磁盘%util已经100%了,但吞吐量很低,可能是什么原因?
- 排查线上服务响应变慢的问题,你的排查步骤是什么?
- 怎么区分CPU瓶颈是应用本身计算量大导致的,还是锁竞争、上下文切换导致的?
10.2 性能优化方法论
性能优化是一个系统性的工作,不是靠零散的技巧就能做好的,需要遵循科学的方法论,才能用最小的投入获得最大的收益,避免盲目优化和过度优化。
性能优化的基本原则
1. 不要过早优化
“过早优化是万恶之源”,这是编程界的名言。过早优化有很多弊端:
- 优化会增加代码复杂度,降低可读性和可维护性
- 早期的架构和需求还不稳定,优化可能会白费功夫
- 你以为的性能瓶颈可能不是真正的瓶颈,优化错了地方
- 占用过多的时间精力,影响业务功能的交付
正确的做法:先实现正确的功能,当性能确实出现问题,或者性能指标达不到要求的时候,再进行优化。开发阶段优先保证代码的正确性、可读性和可维护性,不要为了不需要的性能牺牲这些特性。
2. 不要盲目优化,先测量再优化
性能优化的第一准则:先测量,再优化,不要猜测。
- 很多时候你以为的性能瓶颈和实际的瓶颈完全不一样
- 没有测量就没有优化的依据,也无法评估优化的效果
- 80%的性能问题集中在20%的代码上,找到这20%的热点代码优化,才能获得最大收益
- 优化前后都要测量,评估优化的效果,避免做负优化
错误案例:看到一段代码写得不够“优雅“,就花很多时间优化,但这段代码的执行频率很低,对整体性能几乎没有影响,白白浪费时间。
3. 权衡取舍,平衡各方面需求
性能优化不是追求极致的性能,而是在多个因素之间平衡:
- 性能 vs 复杂度:不要为了一点点性能提升大幅增加代码复杂度,提升维护成本
- 性能 vs 开发成本:评估优化需要投入的时间和带来的收益是否成正比
- 性能 vs 可维护性:不要为了优化写出难以理解和维护的代码
- 性能 vs 可靠性:优化不能牺牲系统的稳定性和可靠性,不能引入bug
- 短期收益 vs 长期收益:有些优化短期收益大,但会给长期维护带来负担,要综合考虑
4. 以用户体验为核心
性能优化的最终目标是提升用户体验,而不是追求漂亮的性能指标:
- 有时候系统的吞吐量很高,但用户的响应时间很长,用户体验还是很差
- 优先优化用户感知明显的路径,比如页面首屏加载、核心接口的响应时间
- 不要为了提升QPS牺牲用户的响应时间,平衡吞吐量和延迟
阿姆达尔定律
阿姆达尔定律是性能优化的基础定律,用来计算系统优化后能获得的最大性能提升:
加速比 = 1 / [ (1 - 优化部分占比) + 优化部分占比 / 优化倍数 ]
- 优化部分占比:要优化的部分在整个系统运行时间中的占比
- 优化倍数:优化后这部分的性能提升倍数
示例:某个系统中,数据库查询占总运行时间的60%,如果把数据库查询优化到原来的3倍快,整个系统的加速比是多少?
加速比 = 1 / [(1-0.6) + 0.6/3] = 1 / (0.4 + 0.2) = 1 / 0.6 ≈ 1.67倍
即使把数据库查询优化到无限快,加速比最多也只有1/(1-0.6)=2.5倍,因为剩下的40%的代码无法优化。
阿姆达尔定律的启示:
- 优化占比大的部分才能获得最大的收益,优化占比很小的部分收益有限
- 不存在完美的优化,加速比是有上限的,当优化到一定程度后,再投入的收益会越来越低
- 优先优化系统中耗时占比最大的瓶颈点,性价比最高
性能优化的通用步骤
性能优化可以遵循“测量-分析-优化-验证“四步流程,形成闭环:
第一步:建立性能基准线,明确优化目标
优化之前首先要明确:
- 现有的性能指标是多少:QPS、响应时间、CPU使用率、内存使用率等
- 优化的目标是什么:比如QPS提升50%,响应时间降低30%,CPU使用率降到50%以下
- 设定合理的优化目标,不要不切实际地追求极致性能
没有基准线的优化是盲目的,你不知道优化了多少,也不知道什么时候优化完成。
第二步:测量定位性能瓶颈
使用上一节介绍的性能分析工具,找到系统的性能瓶颈:
- 先整体后局部:先看系统整体的CPU、内存、IO、网络指标,确定瓶颈在哪一层
- 从外到内:先看应用层面的QPS、响应时间、错误率,再看系统层面指标,最后定位到具体的函数或者代码
- 找到热点:用profiler工具找到CPU、内存、IO消耗最多的热点代码
- 分析瓶颈的根本原因:是算法问题?架构问题?配置不合理?还是资源不足?
注意:找到最主要的瓶颈,一次只解决一个最严重的瓶颈,解决完再重新测量,找下一个瓶颈。
第三步:设计和实施优化方案
根据瓶颈的根本原因,设计针对性的优化方案:
- 优先选择性价比最高的优化方案:投入最少,收益最大
- 遵循优化优先级:架构优化 > 算法优化 > 代码优化 > 参数调优
- 优化方案要经过设计和评审,避免引入新的问题
- 小步快跑,每次做一个小的优化,验证效果后再做下一个,避免一次改太多出问题不好回滚
第四步:验证优化效果,评估收益
优化完成后要重新测量性能指标,验证优化是否达到目标:
- 对比优化前后的性能指标,看是否达到预期目标
- 检查有没有引入副作用:比如延迟升高、错误率上升、系统稳定性下降
- 记录优化的投入和收益,总结经验
- 如果没有达到预期,回到第二步重新分析瓶颈
如果优化效果不明显,说明瓶颈找错了,或者优化方案不对,需要重新分析。
不同层面的优化优先级
性能优化的效果和投入成本在不同层面差异很大,优先在更高层面做优化,投入产出比更高:
1. 架构优化(最高优先级,投入产出比最高)
架构层面的优化往往能带来数量级的性能提升,是性价比最高的优化:
- 例子:
- 引入缓存,减少数据库查询,性能可以提升几十上百倍
- 把同步调用改成异步,系统吞吐量提升数倍
- 单体架构改成分布式微服务架构,支持水平扩展
- 引入消息队列削峰填谷,提升系统处理能力
- 读写分离、分库分表解决数据库瓶颈
- 优点:性能提升大,一次优化长期受益
- 缺点:架构调整成本高,影响范围大,需要充分评估和测试
2. 算法和数据结构优化(次高优先级)
好的算法和数据结构能带来几十上百倍的性能提升:
- 例子:
- O(n²)的算法改成O(n log n),数据量大的时候性能提升非常明显
- 不合适的数据结构换成更合适的,比如频繁查找的场景用哈希表代替数组遍历
- 减少不必要的计算,提前缓存计算结果
- 优点:性能提升明显,不需要大规模架构调整
- 缺点:需要修改代码,对开发人员的算法能力要求高
3. 代码优化(中等优先级)
在不改变架构和算法的前提下,优化代码实现,提升性能:
- 例子:
- 减少不必要的对象创建和内存分配,复用对象
- 减少IO次数,批量读写,合并请求
- 优化循环逻辑,减少循环内的计算
- 避免内存泄漏,优化内存使用
- 选择更高效的库和API
- 优点:改动小,见效快,不需要大规模调整
- 缺点:通常只能带来百分之几十的性能提升,很难有数量级的提升
4. 参数调优(低优先级)
调整系统和应用的配置参数:
- 例子:
- JVM参数调优:堆大小、垃圾回收器调整
- 操作系统参数调优:文件描述符限制、TCP参数调整
- 数据库参数调优:缓存大小、连接数调整
- Web服务器参数调优:进程数、连接数调整
- 优点:不需要修改代码,调整灵活
- 缺点:性能提升有限,通常只能优化10%-30%左右,而且优化空间有限,无法解决根本性的性能问题
5. 硬件升级(最低优先级,成本最高)
如果软件层面的优化已经做到极致,性能还是不满足需求,再考虑升级硬件:
- 例子:CPU升级、增加内存、SSD替换HDD、带宽扩容
- 优点:简单直接,不需要改代码
- 缺点:成本高,很多时候不是硬件不够,而是软件层面的问题,盲目升级硬件解决不了根本问题,还会造成资源浪费
优化优先级的核心思想:尽可能在更高层面解决问题,投入越少,收益越大。不要一开始就纠结代码细节或者升级硬件,先从架构和算法层面找优化空间。
性能优化的常见误区
误区1:过度优化,追求极致性能
为了一点点性能提升,大幅增加代码复杂度,牺牲可维护性和稳定性,得不偿失。性能优化要适可而止,只要满足业务需求就好,不要追求极致。
误区2:优化非热点代码
花很多时间优化执行频率很低、对整体性能影响很小的代码,投入产出比极低。要优化就优化热点代码,把20%的热点代码优化好就能解决80%的性能问题。
误区3:忽略用户体验,只看吞吐量
很多时候系统吞吐量很高,但用户的请求响应时间很长,用户体验还是很差。要平衡吞吐量和响应时间,优先保证核心路径的响应时间。
误区4:优化后不做验证
优化完不测量验证,想当然地认为优化生效了,实际上可能没有效果,甚至引入了性能下降或者bug。所有优化都要验证效果。
误区5:忽略长期维护成本
有些优化方案短期能提升性能,但会给后续的维护带来很大负担,比如写了大量难以理解的高性能代码,后面没人能维护,bug率上升,反而得不偿失。
性能优化的最佳实践
- 数据驱动:所有优化都基于测量数据,不要凭感觉
- 小步迭代:每次做小的优化,快速验证,避免大的改动出问题
- 先解决主要矛盾:优先优化最严重的瓶颈,解决完一个再解决下一个
- 回归测试:优化后要做完整的功能测试和性能测试,避免引入bug和性能回退
- 文档记录:记录优化的原因、方案、效果,方便后续维护
- 避免过早优化和过度优化:平衡性能、开发成本、可维护性三者的关系
思考问题
- 阿姆达尔定律的核心思想是什么?它对性能优化有什么指导意义?
- 为什么架构优化的优先级比代码优化高?举一个你遇到的架构优化带来明显性能提升的例子。
- 你在实际工作中遇到过哪些性能优化的误区?是怎么解决的?
- 一个系统接口响应很慢,你会怎么排查和优化?描述你的步骤。
10.3 典型场景优化实践
前面我们讲了性能分析方法和优化方法论,这一节我们针对实际开发中常见的场景,介绍具体的优化实践技巧,覆盖CPU、内存、IO、网络、数据库、前端等各个层面。
CPU密集型场景优化
CPU密集型场景的主要瓶颈在CPU计算,常见于科学计算、音视频编码、加密解密、复杂业务逻辑处理等场景。
优化方法
-
算法与数据结构优化
- 优先优化算法复杂度,把O(n²)的算法降到O(n)或O(n log n),这是最有效的优化
- 选择合适的数据结构,比如频繁查找用哈希表,有序数据用二分查找,避免线性遍历
- 减少不必要的计算,缓存重复计算的结果,避免重复计算
- 提前退出循环,减少不必要的计算
-
减少运行时开销
- 减少不必要的对象创建和销毁,尤其是在循环中,复用对象减少GC开销(Java/Python等带GC的语言)
- 避免过多的函数调用和上下文切换,热点路径的函数可以考虑内联
- 减少锁竞争,用无锁数据结构、原子操作代替重量级锁,减少锁的粒度和持有时间
- 优化循环,把循环内不变的计算提到循环外,减少循环内的操作
-
并发优化
- 利用多核CPU,把计算任务拆分,多线程并行执行,注意拆分的任务粒度要合适,避免线程切换开销超过收益
- CPU密集型任务的线程数建议设置为CPU核心数+1,不要设置太多,避免频繁上下文切换
- 使用线程池复用线程,避免频繁创建销毁线程的开销
-
编译/运行时优化
- 开启编译器优化选项,比如GCC的-O2/-O3优化,Go的编译优化
- 使用更高效的语言/库实现核心计算逻辑,比如C/Rust实现的库比纯Python/Java快很多
- 使用SIMD指令优化,利用CPU的向量计算能力并行处理数据
- 热点代码做JIT编译优化,解释执行的热点函数编译为本地代码执行
-
硬件加速
- 并行计算任务可以考虑用GPU加速,比如CUDA/OpenCL,适合大规模并行计算
- 使用专用的硬件加速卡,比如加密卡、编码卡等
内存使用优化
内存优化主要目标是减少内存占用,避免内存泄漏,提升内存访问效率。
优化方法
-
减少内存占用
- 使用更高效的数据结构,比如用数组代替链表,用更小的数据类型,比如能用int就不用long,能用短结构就不用长结构
- 避免不必要的内存分配,对象复用、对象池、内存池等,减少内存分配和GC压力
- 压缩数据,比如整数压缩、字符串编码优化,减少数据占用的内存
- 稀疏数据使用更紧凑的存储方式,比如位图、稀疏数组,避免浪费空间
-
提升内存访问效率
- 利用局部性原理,优化数据结构内存布局,提升缓存命中率,比如数组顺序访问比链表随机访问快很多
- 避免伪共享,多线程并发修改的变量做缓存行填充,避免缓存行冲突
- 大块连续内存分配比分散的小内存分配效率高,访问速度也快
- 大内存访问考虑使用大页(Huge Page),减少TLB miss,提升地址转换效率
-
避免内存泄漏和内存浪费
- 及时释放不再使用的资源,关闭文件、连接,释放对象引用,避免内存泄漏
- 缓存要设置合理的大小和过期策略,避免缓存无限增长占用内存
- 避免大对象分配和内存碎片,使用内存池减少碎片
- 带GC的语言避免创建不必要的临时对象,减少GC频率和停顿时间
-
内存相关参数调优
- JVM调优:调整堆大小、新生代老年代比例、选择合适的垃圾回收器,减少GC停顿
- 调整操作系统的内存参数:swappiness、overcommit_memory等,根据场景优化
- 不需要持久化的数据尽量放在内存中,减少磁盘IO,用内存换取性能
磁盘IO优化
磁盘IO是最常见的性能瓶颈之一,尤其是HDD的随机IO性能很低,优化IO能带来显著的性能提升。
优化方法
-
减少IO次数
- 增加缓存:用Redis、Guava Cache等缓存热点数据,尽量少访问磁盘,这是最有效的优化
- 批量操作:批量读写代替多次小IO,比如批量写数据库、批量读文件,减少系统调用开销和IO次数
- 合并小IO:把多个小的IO请求合并成一个大的IO,提升效率
- 预读:提前读取需要的数据到缓存,利用顺序IO的高性能
-
优化IO模式
- 尽量用顺序IO代替随机IO,顺序IO的性能是随机IO的几十上百倍(HDD),比如数据库的WAL日志就是顺序写
- 选择合适的IO模型:高并发场景用IO多路复用、异步IO代替阻塞IO,提升吞吐量
- 直接IO:有自己的缓存机制的场景可以用直接IO绕过系统Page Cache,减少内存拷贝
- 异步IO:非阻塞处理IO,避免线程阻塞,提升CPU利用率
-
文件系统和磁盘优化
- 优先使用SSD代替HDD,随机IO性能提升几十上百倍,对于随机IO多的场景效果明显
- 选择合适的文件系统:XFS/EXT4适合通用场景,Btrfs/ZFS适合大容量存储和快照等特性
- 调整文件系统挂载参数:noatime不更新访问时间,提升IO性能
- RAID优化:RAID0提升性能,RAID10兼顾性能和可靠性,根据场景选择合适的RAID级别
- 磁盘分区对齐,4K对齐提升SSD性能
-
数据存储优化
- 数据压缩:存储前压缩数据,减少数据量,虽然增加CPU开销,但减少IO开销,整体收益更高,尤其是冷数据
- 日志合并:LSM树结构(比如LevelDB、RocksDB)把随机写转换成顺序写,大幅提升写性能
- 避免小文件:大量小文件会浪费磁盘空间,inode占用多,读写性能差,尽量合并小文件,比如用tar打包或者存储在数据库中
- 冷热数据分离:热数据存在高性能存储,冷数据存在低成本大容量存储,提升性价比
网络性能优化
网络IO也是常见的性能瓶颈,尤其是分布式系统中,网络调用的开销很大。
优化方法
-
减少网络请求次数
- 合并请求:多个小请求合并成一个大请求,减少网络往返次数,比如前端的资源合并、接口合并
- 批量操作:批量获取数据、批量写入,避免多次调用
- 数据压缩:请求和响应数据压缩,比如gzip/br压缩,减少传输数据量,降低带宽占用和传输时间
- 缓存:静态资源用CDN缓存,接口数据用客户端缓存、服务端缓存,重复请求不用回源
-
优化传输效率
- 减少传输数据量:去掉不必要的字段,使用更高效的序列化协议,比如Protobuf代替JSON,体积小序列化快
- 选择合适的传输协议:长连接代替短连接,减少三次握手四次挥手开销;QUIC/HTTP3代替TCP,减少握手延迟和队头阻塞
- 合适的缓冲区大小:设置合理的Socket发送和接收缓冲区,提升吞吐量
- 协议优化:TCP参数调优,比如调整滑动窗口大小、开启TCP BBR拥塞控制,提升网络传输效率
-
架构层面优化
- 就近访问:CDN加速静态资源,边缘计算节点让用户就近访问,减少物理距离带来的延迟
- 服务部署在同一机房/可用区,减少跨机房的网络延迟
- 负载均衡:请求分发到多个服务器,提升整体吞吐量
- 异步非阻塞:网络IO用异步非阻塞模型,提升系统并发能力,避免线程阻塞
-
可靠性与性能平衡
- 超时与重试:设置合理的超时时间,失败重试避免网络波动影响,重试要有退避策略,避免雪崩
- 熔断降级:下游服务故障时熔断,避免级联失败,保证核心功能可用
- 流量控制:限流、削峰填谷,避免流量突增打垮服务
数据库性能优化
数据库是大部分业务系统的核心瓶颈,数据库优化的收益通常很明显。
优化方法
-
索引优化
- 合理创建索引:频繁查询的字段、条件字段、关联字段建索引,避免全表扫描
- 联合索引遵循最左前缀原则,覆盖索引避免回表查询
- 避免索引滥用:不要给每个字段都建索引,索引会降低写性能,占用存储空间
- 定期优化索引:删除无用、重复的索引,重建碎片多的索引
-
SQL语句优化
- 避免SELECT *,只查询需要的字段,减少数据传输和内存占用
- 避免大表关联查询,复杂查询可以考虑拆成多个简单查询,或者做适当的冗余
- 批量操作代替循环单条操作,减少交互次数
- 避免深分页,比如limit 100000,10性能差,用游标分页或者上次ID条件优化
- 避免在where条件中对字段做函数运算、类型转换,导致索引失效
-
数据库架构优化
- 读写分离:主库写,从库读,提升读性能
- 分库分表:数据量大的时候垂直拆分(按业务分库)、水平拆分(按字段分表),提升处理能力
- 增加缓存层:热点数据用Redis等缓存,减少数据库查询压力
- 搜索引擎:复杂查询、全文搜索场景用Elasticsearch等搜索引擎,避免数据库做复杂查询
- 异步写入:非核心写操作异步化,通过消息队列异步写入数据库,提升响应速度
-
数据库参数调优
- 内存配置:给数据库分配足够的内存,提升缓存命中率,比如MySQL的innodb_buffer_pool_size设置为物理内存的50%-70%
- 连接数配置:合理设置最大连接数,避免连接数过多占用过多内存
- 持久化策略调整:可以接受少量数据丢失的场景,调整刷盘策略,提升写性能
- 存储优化:数据库用SSD存储,提升IO性能
前端性能优化
前端性能直接影响用户体验,首屏加载速度、交互流畅度对用户留存影响很大。
优化方法
-
资源加载优化
- 减少资源体积:JS/CSS/HTML压缩混淆,图片压缩,用WebP/AVIF等更高效的图片格式
- 减少HTTP请求:合并JS/CSS资源,雪碧图合并小图片,减少请求次数
- 资源缓存:静态资源设置合理的HTTP缓存头,利用浏览器缓存,避免重复加载
- CDN加速:静态资源放到CDN,用户就近访问,提升加载速度
- 按需加载:路由懒加载、组件懒加载、图片懒加载,首屏只加载必要的资源
- 预加载/预解析:提前加载后续会用到的资源,提升后续页面打开速度
-
运行时性能优化
- 减少重绘重排:避免频繁修改DOM和样式,批量修改,用CSS transform代替top/left等触发布局的属性
- 防抖节流:滚动、输入等频繁触发的事件做防抖节流,减少执行次数
- 长列表优化:虚拟滚动,只渲染可视区域的内容,避免大量DOM节点占用内存和渲染时间
- Web Worker:复杂计算放到Web Worker中执行,避免阻塞主线程
- 垃圾回收优化:避免频繁创建大量临时对象,减少GC导致的卡顿
-
体验优化
- 骨架屏、loading状态提升用户感知体验,减少等待焦虑
- 接口请求并行化,避免串行请求增加等待时间
- 接口错误降级、弱网优化,提升异常情况下的用户体验
- 离线缓存:PWA Service Worker实现离线访问,弱网环境也能使用基础功能
通用优化原则
- 缓存为王:能加缓存的地方尽量加缓存,用空间换时间,是性价比最高的优化方式
- 批量处理:批量读写、批量请求,减少IO和交互次数
- 异步非阻塞:非核心路径异步处理,提升响应速度和系统吞吐量
- 数据就近访问:数据尽可能靠近计算的地方,减少数据传输开销
- 权衡取舍:没有完美的优化,在性能、复杂度、成本之间找到合适的平衡点
性能优化是一个持续的过程,要结合具体的业务场景和瓶颈点,针对性地优化,不要盲目套用技巧,所有优化都要经过测量验证效果。
思考问题
- 数据库查询慢,你会从哪些方面排查和优化?
- 前端页面首屏加载慢,常见的优化思路有哪些?
- 高并发场景下,系统的响应时间变长,可能的瓶颈有哪些?怎么排查?
- 为什么说顺序IO比随机IO快很多?在实际开发中怎么利用这个特性优化性能?
10.4 分布式系统基础
随着业务的发展,单台服务器的性能和容量已经无法满足需求,分布式系统已经成为现代大型应用的标准架构。理解分布式系统的基础原理,是后端工程师进阶的必备技能。
分布式系统基本概念
什么是分布式系统
分布式系统是指将多个独立的计算机通过网络连接,共同协作完成同一任务的系统。对用户来说,就像在使用一个单独的系统一样,不需要关心背后的多个节点。
- 优点:高性能、高可用、可扩展、容量大
- 缺点:复杂度高、需要处理网络问题、一致性问题、运维复杂
分布式系统的设计目标
- 高性能(Performance):响应时间短、吞吐量高,比单系统性能更好
- 高可用(Availability):全年无故障,服务可用时间占比高,通常用几个9来衡量,比如99.9%可用性意味着年 downtime 小于8.76小时
- 可扩展性(Scalability):通过增加服务器节点就能线性提升系统的性能和容量,支持业务增长
- 一致性(Consistency):多个节点的数据保持一致,用户不管访问哪个节点都能看到相同的数据
- 分区容错性(Partition Tolerance):网络分区故障时,系统仍然能正常提供服务
这些目标之间往往是冲突的,需要根据业务场景权衡取舍。
分布式架构的演进
1. 单体架构
所有功能都在一个应用中,部署在一台服务器上。
- 优点:开发简单,部署方便,适合初期小团队小业务
- 缺点:耦合度高,迭代慢,无法水平扩展,单台服务器有性能瓶颈
2. 垂直拆分架构
按照业务垂直拆分成多个独立的应用,每个应用部署在独立的服务器上,比如电商系统拆分成用户、商品、订单等应用。
- 优点:业务解耦,独立开发部署,不同业务可以独立扩容
- 缺点:公共功能重复开发,跨业务调用复杂,单个业务还是单体架构,仍有单点问题
3. 分布式服务架构(SOA/微服务)
将系统拆分成多个独立的服务,每个服务负责单一功能,服务之间通过网络通信调用,是现在大型应用的主流架构。
- 优点:服务独立迭代、独立扩容,技术栈灵活,能支撑超大规模业务
- 缺点:架构复杂度高,需要处理分布式带来的各种问题,运维成本高
分布式核心组件
1. 负载均衡
负载均衡是分布式系统的入口,将请求均匀分发到后端多个服务器节点,避免单节点压力过大,提升系统整体吞吐量和可用性。
- 常见负载均衡方式:
- DNS负载均衡:域名解析到多个IP,实现简单,但是调度粒度粗,无法感知后端节点状态
- 硬件负载均衡:F5、A10等硬件设备,性能高,但是价格昂贵
- 软件负载均衡:Nginx、LVS、HAProxy等,成本低,灵活可配置,是现在的主流
- 常见调度算法:轮询、加权轮询、随机、最少连接、IP哈希、一致性哈希
- 高可用:负载均衡本身不能是单点,通常要做主从部署,避免单点故障
2. 分布式缓存
缓存是提升系统性能的核心组件,将热点数据放在内存中,减少数据库查询压力,提升响应速度。
- 常见缓存产品:Redis、Memcached,现在Redis是主流,支持丰富的数据结构、持久化、集群模式
- 缓存典型问题:
- 缓存穿透:查询不存在的数据,请求穿透到数据库,解决方案:缓存空值、布隆过滤器
- 缓存击穿:热点key过期,大量请求同时打到数据库,解决方案:加锁、热点key永不过期
- 缓存雪崩:大量key同时过期,大量请求打到数据库,解决方案:过期时间加随机值、多级缓存
- 缓存更新策略:先更数据库再删缓存,保证数据一致性,避免脏读
- 缓存架构:本地缓存(Caffeine、Guava Cache) + 分布式缓存(Redis)的多级缓存架构,性能更高
3. 消息队列
消息队列是分布式系统中实现异步解耦、削峰填谷的核心组件。
- 核心作用:
- 异步解耦:非核心流程异步处理,降低响应时间,模块之间解耦
- 削峰填谷:缓冲突增的流量,保护后端服务不被打垮
- 最终一致性:通过消息保证多个服务的数据最终一致
- 广播通知:一条消息可以被多个消费者消费,实现一对多通信
- 常见消息队列产品:
- Kafka:高吞吐量、高可用,适合大数据、日志收集、流式处理场景
- RabbitMQ:功能丰富,可靠性高,适合对可靠性要求高的业务场景
- RocketMQ:阿里开源,高性能、高可靠,适合电商等金融业务场景
- Pulsar:新一代云原生消息队列,支持多租户、无限存储空间
- 消息可靠性保证:生产者确认、持久化、消费者确认、重试机制,保证消息不丢失
- 常见问题:消息重复消费(要保证消费幂等)、消息顺序性、消息堆积
4. 分布式存储
单台服务器的存储容量有限,分布式存储将数据分散存储在多个节点上,提供大容量、高可用、高可靠的存储服务。
- 分布式文件存储:FastDFS、MinIO、HDFS,存储图片、视频等非结构化数据
- 分布式数据库:MySQL分库分表、TiDB、Spanner,解决单库容量和性能瓶颈
- 分布式对象存储:阿里云OSS、AWS S3,现在云原生场景的主流存储方式
分布式一致性问题
分布式系统中,多个节点之间通过网络通信,网络是不可靠的,会有延迟、丢包、分区等问题,如何保证多个节点之间的数据一致性,是分布式系统最核心的难点。
CAP定理
CAP定理是分布式系统的基础定理,指出分布式系统不可能同时满足三个特性:
- 一致性(Consistency):所有节点在同一时间看到的数据是一致的
- 可用性(Availability):每个请求都能得到正常的响应,不会超时或者错误
- 分区容错性(Partition Tolerance):发生网络分区(节点之间网络不通)时,系统仍然能正常提供服务
CAP定理告诉我们,在分布式系统中,P是必须满足的,因为网络分区是不可避免的,所以我们只能在C和A之间权衡:
- CP系统:优先保证一致性,牺牲可用性,比如分布式数据库、金融系统,要求数据强一致
- AP系统:优先保证可用性,牺牲一致性,比如大多数互联网应用,保证高可用,允许短暂的数据不一致
BASE理论
BASE理论是对CAP的折中,是现在互联网分布式系统的事实标准:
- 基本可用(Basically Available):出现故障时,允许损失部分可用性,比如降级、限流,保证核心功能可用
- 软状态(Soft State):允许系统存在中间状态,数据同步有延迟,不影响系统整体可用性
- 最终一致性(Eventually Consistent):不需要保证实时强一致,经过一段时间后,数据最终会达到一致状态
BASE理论牺牲强一致性来获得高可用性,适合大多数互联网业务场景,用户体验影响很小,但系统可用性大大提升。
一致性级别
分布式系统的一致性分为多个级别,根据业务需求选择合适的级别:
- 强一致性:任何时刻所有节点的数据都是一致的,任何读请求都能返回最新写入的值,实现成本高,性能低,比如银行转账系统
- 顺序一致性:所有进程看到的操作顺序和实际发生的顺序一致
- 最终一致性:不需要实时一致,经过一段时间后数据最终一致,实现成本低,性能高,适合大多数互联网场景,比如电商的商品库存、用户信息等
常见一致性算法
为了实现分布式一致性,人们设计了很多一致性算法:
- Paxos:经典的强一致性算法,比较复杂,难实现
- Raft:更容易理解和实现的强一致性算法,现在被广泛使用,比如Etcd、Consul都是基于Raft算法
- ZAB:Zookeeper使用的一致性算法,保证数据的一致性
- Gossip协议:最终一致性算法,通过节点之间随机通信同步数据,实现简单,高可用,比如Redis Cluster、Consul都是用Gossip协议
这些算法的核心目标是在不可靠的网络环境下,保证多个节点之间的数据一致性。
微服务架构基础
微服务是现在分布式架构的主流实践,将单体应用拆分为多个小型服务,每个服务独立开发、独立部署、独立扩容,服务之间通过轻量级的HTTP/RPC通信。
微服务的优势
- 服务独立迭代,开发效率高,小团队可以独立负责一个服务
- 技术栈灵活,不同服务可以选择合适的技术栈
- 高可用,单个服务故障不会影响整个系统
- 弹性伸缩,不同服务可以根据流量独立扩容,节省资源
微服务的核心问题
- 服务发现:服务上线下线动态感知,客户端不需要硬编码服务地址,常见服务发现组件:Nacos、Consul、Eureka
- 服务治理:限流、降级、熔断,防止服务雪崩,常见组件:Sentinel、Hystrix、Resilience4j
- 分布式追踪:全链路追踪,排查分布式系统中的请求慢、错误问题,常见组件:SkyWalking、Jaeger、Zipkin
- 分布式事务:跨服务调用时保证数据一致性,常见解决方案:TCC、可靠消息最终一致性、Saga模式
- API网关:统一流量入口,做认证、鉴权、限流、监控、路由等,常见组件:Spring Cloud Gateway、Kong、APISIX
微服务最佳实践
- 服务拆分合理,按照领域边界拆分,避免拆分过细导致复杂度太高
- 服务自治,每个服务独立存储、独立部署,减少服务之间的耦合
- 接口兼容性,服务接口升级要向下兼容,避免影响消费者
- 完善的监控告警,分布式系统复杂度高,必须要有完善的可观测性,及时发现问题
- 自动化CICD,微服务数量多,必须要有自动化的构建、测试、部署流程,提升效率
分布式系统常见问题和应对
- 网络问题:网络延迟、丢包、分区是常态,必须做好重试、超时、熔断等容错机制,假设网络一定会出问题
- 一致性问题:优先使用最终一致性,只有必要的场景才用强一致,降低复杂度
- 幂等性:所有接口都要设计成幂等的,因为网络重试、消息重复是不可避免的
- 可观测性:完善的监控、日志、链路追踪是分布式系统排障的基础,没有观测性的分布式系统就是黑盒
- 故障演练:定期做故障注入演练,验证系统的容错能力,提前发现问题
分布式系统没有银弹,需要根据业务场景在性能、可用性、一致性、复杂度之间权衡,选择最合适的架构,不要为了分布式而分布式,适合业务的才是最好的。
思考问题
- 为什么分布式系统中优先保证AP而不是CP?什么场景下需要CP?
- 消息队列有什么作用?什么场景下需要使用消息队列?
- 缓存穿透、击穿、雪崩分别是什么?怎么解决?
- 微服务架构相比单体架构有什么优缺点?什么业务场景适合微服务?
练习题与扩展阅读
练习题
基础题
- 性能分析的通用步骤是什么?性能优化有哪些基本原则?
- 阿姆达尔定律的核心思想是什么?对性能优化有什么指导意义?
- 性能优化的优先级是什么?为什么架构优化的优先级比代码优化高?
- 常见的缓存问题有哪些?分别怎么解决?
- CAP定理的三个特性分别是什么?为什么分布式系统只能同时满足其中两个?
- 消息队列在分布式系统中的作用是什么?适合什么场景?
实操题
- 找一个你负责的系统,按照性能分析步骤,测量系统的性能指标,定位当前的性能瓶颈在哪里,设计优化方案。
- 对一段热点代码进行性能优化,用profiler工具测量优化前后的性能提升,评估优化收益。
- 搭建一个简单的微服务demo,包含服务注册发现、负载均衡、RPC调用三个核心组件,理解微服务的基本原理。
- 用压测工具(JMeter、wrk等)对你的服务进行压测,找到系统的瓶颈点,优化到压测指标符合预期。
思考题
- 为什么说“过早优化是万恶之源“?怎么平衡性能优化和业务开发的关系?
- 分布式系统怎么保证数据一致性?强一致性和最终一致性分别适合什么业务场景?举几个你遇到的场景。
- 性能优化和系统稳定性之间怎么平衡?优化过程中如何避免引入稳定性问题?
- 你在实际工作中遇到过最严重的性能问题是什么?是怎么排查和解决的?
扩展阅读
书籍推荐
-
《性能之巅:系统、企业与云可观测性》(第2版)
- 性能优化领域的经典著作,系统讲解了系统性能分析和优化的方法,覆盖了CPU、内存、IO、网络等各个层面的优化,非常全面。
-
《数据密集型应用系统设计》
- 分布式系统领域的圣经,深入讲解了分布式系统的核心概念、数据模型、一致性、分布式事务等核心内容,每个后端开发者都应该读。
-
《微服务设计》
- 微服务架构的经典书籍,讲解了微服务的设计原则、架构模式、服务治理等内容,是微服务入门的必读。
-
《高可用架构》
- 讲解高可用分布式系统的设计思想和实践,包含降级、熔断、限流、灾备等核心内容,适合架构师和后端开发者。
-
《计算机程序的构造和解释》(SICP)
- 虽然不是专门讲性能优化的书,但能提升你对程序本质的理解,写出更高效优雅的代码。
在线资源
-
- Google官方的SRE系列书籍,讲解了Google的运维和性能优化最佳实践,非常值得学习。
-
- 本书作者Brendan Gregg的个人网站,有大量关于性能分析和优化的文章和工具,是性能优化领域的权威资料。
-
- 美团技术团队分享了很多分布式系统、性能优化、微服务相关的实战文章,非常接地气,适合国内开发者学习。
-
- MIT 6.824分布式系统课程,是学习分布式系统的经典课程,有丰富的论文和实验。
-
- 酷Shell的高并发编程系列文章,通俗易懂地讲解了高并发系统的设计和优化思路。
工具推荐
- 压测工具:wrk、JMeter、k6、Locust,用来对系统进行压力测试,找到性能瓶颈。
- 链路追踪:SkyWalking、Jaeger、Zipkin,分布式系统全链路追踪,排查慢请求和错误。
- APM监控:Prometheus + Grafana、Datadog、New Relic,系统性能指标监控和告警。
- 性能分析:perf、bcc/BPF、eBPF,Linux下高级性能分析工具,深入分析系统性能问题。
- 微服务组件:Nacos(服务发现)、Sentinel(限流熔断)、Dubbo/Spring Cloud(微服务框架)、RocketMQ/Kafka(消息队列)。
参考答案
练习题的参考答案可以在附录/练习题参考答案.md中找到。建议先独立思考完成,再查看答案。
附录
本书附录包含一些附加参考资料,方便读者查阅。
常见术语表
A
- ABI (Application Binary Interface):应用程序二进制接口,定义了应用程序和操作系统之间、应用程序和库之间的二进制交互规范。
- ALU (Arithmetic Logic Unit):算术逻辑单元,CPU中负责执行算术和逻辑运算的部件。
- API (Application Programming Interface):应用程序编程接口,定义了不同软件组件之间的交互规范。
- ASCII (American Standard Code for Information Interchange):美国信息交换标准代码,是最基础的字符编码标准。
B
- BIOS (Basic Input Output System):基本输入输出系统,计算机开机后运行的第一个程序,负责硬件初始化和引导操作系统。
- BSD (Berkeley Software Distribution):伯克利软件发行版,是Unix操作系统的一个重要分支。
C
- Cache:高速缓存,位于CPU和主存之间的高速存储器,用于缓存常用数据,提升访问速度。
- CLI (Command Line Interface):命令行界面,用户通过输入文本命令来操作计算机的界面。
- CPU (Central Processing Unit):中央处理器,计算机的核心计算部件。
- CSS (Cascading Style Sheets):层叠样式表,用于描述网页的表现样式。
D
- DMA (Direct Memory Access):直接内存访问,允许硬件设备直接访问内存而不需要CPU介入的技术。
- DNS (Domain Name System):域名系统,将域名转换为IP地址的分布式系统。
- DRAM (Dynamic Random Access Memory):动态随机存取存储器,是计算机主存的主要类型。
E
- EOF (End of File):文件结束标记,表示已经到达文件末尾。
- EPROM (Erasable Programmable Read-Only Memory):可擦除可编程只读存储器。
F
- FAT (File Allocation Table):文件分配表,是一种早期的文件系统。
- FCFS (First Come First Served):先来先服务,是一种常见的调度算法。
- FS (File System):文件系统,负责管理和存储文件的操作系统模块。
G
- GUI (Graphical User Interface):图形用户界面,用户通过图形元素操作计算机的界面。
H
- HTTP (Hypertext Transfer Protocol):超文本传输协议,是Web应用的核心协议。
- HTTPS (Hypertext Transfer Protocol Secure):安全的超文本传输协议,在HTTP基础上加入了SSL/TLS加密。
I
- I/O (Input/Output):输入输出,指计算机与外部设备之间的数据传输。
- ISA (Instruction Set Architecture):指令集架构,定义了CPU能够执行的指令集合和编程模型。
- IP (Internet Protocol):互联网协议,是网络层的核心协议。
- IPC (Inter-Process Communication):进程间通信,指不同进程之间交换数据的机制。
J
- JIT (Just-In-Time):即时编译,是一种在程序运行时将字节码编译为本地机器码的技术。
K
- Kernel:内核,操作系统的核心部分,负责管理系统资源和提供硬件抽象。
L
- LRU (Least Recently Used):最近最少使用,是一种常见的缓存淘汰算法。
- LSB (Least Significant Bit):最低有效位,二进制数中最右边的位。
M
- MMU (Memory Management Unit):内存管理单元,负责虚拟地址到物理地址的转换。
- MOSFET (Metal-Oxide-Semiconductor Field-Effect Transistor):金属氧化物半导体场效应晶体管,是现代集成电路的基础元件。
- MSB (Most Significant Bit):最高有效位,二进制数中最左边的位。
N
- NAND Flash:与非闪存,是固态硬盘(SSD)使用的主要存储介质。
- OSI (Open Systems Interconnection):开放系统互连,是一种网络分层参考模型。
O
- OS (Operating System):操作系统,管理计算机硬件和软件资源的系统软件。
P
- PCB (Process Control Block):进程控制块,操作系统中用于描述进程状态和资源的数据结构。
- PCI (Peripheral Component Interconnect):外设部件互连,是一种常见的总线标准。
- PID (Process ID):进程ID,操作系统中每个进程的唯一标识符。
- POSIX (Portable Operating System Interface):可移植操作系统接口,是Unix操作系统的标准接口规范。
R
- RAM (Random Access Memory):随机存取存储器,即内存。
- ROM (Read-Only Memory):只读存储器,只能读取不能修改的存储器。
- RPC (Remote Procedure Call):远程过程调用,允许程序调用另一台机器上的子程序。
- RTOS (Real-Time Operating System):实时操作系统,能够保证任务在规定时间内完成的操作系统。
S
- SDK (Software Development Kit):软件开发工具包,用于开发特定平台应用的工具集合。
- SSD (Solid State Drive):固态硬盘,使用闪存芯片作为存储介质的存储设备。
- TCP (Transmission Control Protocol):传输控制协议,是面向连接的、可靠的传输层协议。
- TLS (Transport Layer Security):传输层安全,用于网络通信的加密协议。
U
- UDP (User Datagram Protocol):用户数据报协议,是无连接的、不可靠的传输层协议。
- UID (User ID):用户ID,操作系统中每个用户的唯一标识符。
- USB (Universal Serial Bus):通用串行总线,是一种常见的外设接口标准。
V
- VFS (Virtual File System):虚拟文件系统,操作系统中对不同文件系统的抽象层。
- VM (Virtual Machine):虚拟机,通过软件模拟的完整计算机系统。
- VPN (Virtual Private Network):虚拟专用网络,通过加密技术在公共网络上建立专用网络的技术。
W
- WWW (World Wide Web):万维网,是基于HTTP协议的分布式信息系统。
工具推荐
本附录汇总了开发、调试、性能分析等各个场景下常用的工具,帮助读者提升开发效率和问题排查能力。
一、系统与性能分析工具
Linux系统工具
| 工具名称 | 用途 |
|---|---|
| top/htop | 实时查看系统整体的CPU、内存、进程运行情况 |
| vmstat | 查看虚拟内存、CPU、上下文切换等系统整体统计信息 |
| iostat | 查看磁盘IO使用率、IOPS、吞吐量、延迟等磁盘性能指标 |
| pidstat | 查看单个进程的CPU、内存、IO、线程等详细统计 |
| perf | Linux性能计数器,分析CPU热点函数、缓存命中率、调用栈等 |
| free | 查看系统内存使用情况 |
| df/du | 查看磁盘空间使用和目录大小 |
| ss/netstat | 查看网络连接、端口监听、网络统计信息 |
| sar | 系统活动报告,查看历史性能数据 |
| dstat | 全能系统统计工具,同时显示CPU、磁盘、网络、分页等信息 |
| strace | 跟踪进程的系统调用,排查IO、阻塞等问题 |
| lsof | 查看进程打开的文件、网络连接等信息 |
| tcpdump/Wireshark | 网络抓包分析工具,排查网络问题 |
| bcc/BPF | 高级eBPF性能分析工具,深入分析内核和应用性能 |
Windows系统工具
| 工具名称 | 用途 |
|---|---|
| 任务管理器 | 查看系统CPU、内存、磁盘、网络使用情况,进程管理 |
| 资源监视器 | 更详细的系统资源监控,查看进程的IO、网络等详细数据 |
| Process Explorer | 增强版任务管理器,查看进程详细信息、句柄、DLL等 |
| Process Monitor | 监控进程的文件、注册表、网络等操作 |
| Windbg | Windows内核和用户态调试工具 |
| Performance Monitor | 系统性能计数器,监控各种系统性能指标 |
二、开发与调试工具
通用开发工具
| 工具名称 | 用途 |
|---|---|
| Git | 代码版本管理工具 |
| VS Code/IDE(IDEA/CLion/Visual Studio) | 代码开发IDE |
| CMake/Make | C/C++项目构建工具 |
| Maven/Gradle | Java项目构建工具 |
| npm/yarn/pnpm | 前端Node.js项目包管理工具 |
| Docker | 容器化工具,统一开发和部署环境 |
| Postman/ApiPost | API接口测试工具 |
| curl/wget | 命令行HTTP请求工具 |
| jq | 命令行JSON处理工具 |
调试工具
| 工具名称 | 用途 |
|---|---|
| gdb/lldb | C/C++程序调试工具 |
| pdb | Python调试工具 |
| jstack/jmap/jstat/Arthas | Java诊断调试工具 |
| go pprof | Go语言内置的性能分析工具 |
| Chrome DevTools | 前端调试和性能分析工具 |
| Valgrind | C/C++内存问题检测工具,检测内存泄漏、野指针等 |
| AddressSanitizer(ASAN) | 编译器内置的内存问题检测工具,比Valgrind性能更好 |
| ThreadSanitizer(TSAN) | 并发问题检测工具,检测竞态条件、死锁等 |
三、网络工具
| 工具名称 | 用途 |
|---|---|
| ping/mtr | 网络连通性和延迟测试 |
| dig/nslookup/host | DNS查询工具,排查DNS解析问题 |
| telnet/nc(netcat) | 端口连通性测试,发送TCP/UDP数据 |
| iperf | 网络带宽测试工具 |
| Nmap | 端口扫描和网络探测工具 |
| Charles/Fiddler | HTTP/HTTPS代理调试工具,拦截查看修改请求 |
| curl | 命令行HTTP客户端,测试接口 |
| ab/wrk | HTTP服务压测工具 |
四、数据库工具
| 工具名称 | 用途 |
|---|---|
| MySQL Shell/psql | 数据库命令行客户端 |
| Navicat/DataGrip/DBeaver | 数据库图形化管理工具,支持多种数据库 |
| mysqldump/pg_dump | 数据库备份工具 |
| explain | SQL执行计划分析,优化慢查询 |
| pt-query-digest | MySQL慢查询日志分析工具 |
| Redis Desktop Manager/Another Redis Desktop Manager | Redis图形化管理工具 |
| Elasticsearch Head/Kibana | Elasticsearch可视化管理工具 |
五、性能测试工具
| 工具名称 | 用途 |
|---|---|
| wrk | 高性能HTTP压测工具 |
| JMeter | 功能强大的压测工具,支持HTTP、数据库等多种场景 |
| k6 | 基于JavaScript的现代化压测工具 |
| Locust | Python编写的分布式压测工具,可扩展 |
| sysbench | 系统性能基准测试工具,支持CPU、内存、IO、数据库等测试 |
| FIO | 磁盘IO性能测试工具 |
六、分布式与微服务工具
| 工具名称 | 用途 |
|---|---|
| Nginx/OpenResty | Web服务器、反向代理、负载均衡 |
| HAProxy/LVS | 高性能负载均衡工具 |
| Consul/Nacos/Eureka | 服务发现与配置中心 |
| Sentinel/Hystrix | 限流熔断、服务治理工具 |
| SkyWalking/Jaeger/Zipkin | 分布式全链路追踪工具 |
| Prometheus + Grafana | 监控和可视化系统,收集和展示指标 |
| Alertmanager | 告警工具,监控指标异常告警 |
| Kafka/RocketMQ/RabbitMQ | 消息队列 |
| Redis/Memcached | 分布式缓存 |
| Kubernetes/K8s | 容器编排工具,管理容器化应用 |
七、安全工具
| 工具名称 | 用途 |
|---|---|
| OpenSSL | 加密解密、证书生成、SSL/TLS测试工具 |
| Nmap | 端口扫描、安全探测 |
| sqlmap | SQL注入检测工具 |
| Wireshark | 网络抓包分析 |
| ClamAV | 开源杀毒软件 |
| Hashcat | 密码破解工具(安全测试用途) |
选择工具的原则:够用就好,不要追求工具的大而全,掌握几个常用工具的深入用法比浅尝辄止很多工具更有用。同时要注意,工具只是辅助,核心是理解背后的原理,才能真正解决问题。