1. Linux基础概念
1.1 目录结构
第一层:解决什么问题?
Windows有C盘、D盘------每个分区是一棵独立的目录树。但在Linux服务器上,你可能有十几个磁盘分区,如果每个分区都挂一个盘符,管理起来非常混乱。而且很多系统工具(如 /bin、/etc)需要放在固定的路径------它们不应该跟随盘符变化。
Linux采用单一根目录树------所有分区都"挂载"到这棵树的某个节点上。不管是硬盘、U盘还是网络存储,对外看起来都在同一个文件系统里。
第二层:如何实现?
Linux文件系统是一棵以 / 为根的倒置树:
/ # 根目录
├── /bin # 常用系统命令
├── /boot # 内核和启动文件
├── /dev # 设备文件(一切皆文件)
├── /etc # 系统配置文件
├── /home # 用户主目录
├── /lib # 系统库文件
├── /proc # 虚拟文件系统(进程和系统信息)
├── /tmp # 临时文件
├── /usr # 用户程序和数据
└── /var # 日志、缓存等可变数据
磁盘分区通过"挂载"(mount)操作 ,把分区附加到某个目录节点上。比如 /home 可以单独挂一个分区------这样重装系统时 /home 的数据不受影响。
第三层:实际场景与常见问题
df -h查看挂载的分区使用状况du -h查看目录下文件的空间占用大小- 绝对路径 :从
/开始的完整路径。相对路径:从当前目录出发的路径 - 服务器部署时,通常把
/var(日志)、/home(用户数据)单独分区,防止日志写满影响系统
1.2 基础指令
第一层:解决什么问题?
Linux服务器通常没有图形界面,一切操作通过命令完成。操作文件、管理进程、查看网络------都需要对应的命令。
第二层:核心命令分类
| 分类 | 常用命令 | 作用 |
|---|---|---|
| 文件操作 | ls, cd, cp, mv, rm, mkdir, touch, cat, more, less, head, tail, find |
文件和目录的增删改查 |
| 压缩解压 | tar, gzip, zip, unzip |
文件打包压缩 |
| 进程管理 | ps, top, kill, jobs, fg, bg |
查看和控制进程 |
| 网络状态 | netstat, ifconfig, ping, ss |
查看网络连接和配置 |
| 系统信息 | date, reboot, shutdown, uname, free, df, du |
查看和管理系统状态 |
第三层:实际场景与常见问题
kill默认发 SIGTERM(15号信号),进程可以捕获并做清理工作;kill -9(SIGKILL)直接暴力终止,进程无法捕获netstat -anp查看所有网络连接、监听端口及对应进程- 面试常考:在日志文件中搜索某个关键字 ------
grep "error" /var/log/app.log | tail -20
2. 文件权限
2.1 权限体系
第一层:解决什么问题?
Linux是多用户操作系统------root、普通用户、服务账户同时存在。不是你创建的文件就应该被所有人读写------比如密码文件 /etc/shadow 只能root访问。需要一个机制精确控制"谁可以对文件做什么"。
第二层:如何实现?
Linux将访问者分为三类,每类有三种操作权限:
| 用户分类 | 含义 |
|---|---|
u(user/owner) |
文件所有者 |
g(group) |
文件所属用户组 |
o(other) |
其他人 |
| 操作分类 | 含义 | 文件 | 目录 |
|---|---|---|---|
r(read) |
读 | 查看内容 | 列出目录内容 |
w(write) |
写 | 修改内容 | 创建/删除文件 |
x(execute) |
执行 | 执行文件 | 进入目录 |
权限表示 :rwxrwxrwx 共9位,三位一组,对应 u/g/o。或用数字:r=4, w=2, x=1,755 = rwxr-xr-x。
第三层:实际场景与常见问题
创建文件时的权限计算:
最终权限 = 创建时请求的权限 & (~umask)
# umask 默认通常是 0022
# 创建文件默认 666 → 666 & (~022) = 666 & 755 = 644 = rw-r--r--
# 创建目录默认 777 → 777 & (~022) = 755 = rwxr-xr-x
常见命令:
| 命令 | 作用 |
|---|---|
chmod |
修改文件权限 |
chown |
修改文件所有者 |
chgrp |
修改文件所属组 |
粘滞位(sticky bit) :设置在共享目录上(如 /tmp),设置为 chmod +t。效果:目录中每个人都能创建文件,但只有文件所有者和root能删除自己的文件------防止用户互相删别人的文件。
3. 基础IO与文件系统
3.1 文件描述符与文件流指针
第一层:解决什么问题?
程序要读写文件,需要一种"操作句柄"来标识打开的文件。就好像你去银行取钱,给柜员一个号码牌------你不需要知道钱在哪个保险柜,只需通过号码牌操作。
第二层:如何实现?
Linux提供两套IO接口:
| 层级 | 接口 | 操作句柄 | 特点 |
|---|---|---|---|
| 系统调用 | open/read/write/close |
文件描述符(fd),整数 | 无缓冲,直接进入内核 |
| 标准库 | fopen/fread/fwrite/fclose |
*文件流指针(FILE)** | 有用户态缓冲区 |
文件描述符 :每个进程有一个文件描述符表,fd 是这张表的索引。0 = 标准输入,1 = 标准输出,2 = 标准错误。
文件流指针和文件描述符的关系 :FILE 结构体中包含了 fd 成员。标准库函数最终也会调用系统调用,但会先经过一块用户态缓冲区减少系统调用次数。
第三层:实际场景与常见问题
- 重定向 :通过修改文件描述符对应的文件描述信息,改变数据流向。
ls > output.txt就是把 fd=1(标准输出)重定向到一个文件 - 文件流指针的缓冲区:标准库为了减少系统调用次数,内部维护了缓冲区。这也导致一个问题------程序崩溃时缓冲区的数据可能没来得及写到磁盘
3.2 文件系统
第一层:解决什么问题?
文件在磁盘上不是随便堆放的------需要一种组织方式,让你能快速找到某个文件,知道它的数据存在磁盘的哪些扇区。文件系统就是把"磁盘上一堆扇区"组织成"目录树和文件"的管理方案。
第二层:如何实现?
一个典型的Linux文件系统(如ext2/ext4)将磁盘分区划分为:
| 区域 | 作用 |
|---|---|
| 超级块 | 记录文件系统整体信息(类型、大小、空闲块数量) |
| inode区 | 每个文件一个inode,存文件属性(大小、权限、时间戳)+ 数据块指针 |
| 数据块区 | 存储文件的实际内容 |
| 块位图 | 标记哪些数据块被使用 |
| inode位图 | 标记哪些inode被使用 |
文件的数据存储流程:文件 → inode → 数据块指针(直接/间接/双重间接指针指向数据块)
文件的读取流程:路径 → 找到目录的inode → 读目录内容(文件名→inode映射)→ 找到目标inode → 读数据块
第三层:实际场景与常见问题
软链接(符号链接) :是一个独立的文件,内容是目标文件的路径。删除原文件后软链接断开。
- 可跨文件系统,可指向目录
硬链接 :是一个文件目录项,指向同一个inode。删除原文件后只要硬链接还在,数据就不丢。
- 不可跨文件系统,不可指向目录
ln创建硬链接,ln -s创建软链接
4. 进程管理
4.1 进程概念
第一层:解决什么问题?
程序是静躺在硬盘上的二进制文件,进程是跑起来的程序------一个正在运行的、有自己内存空间和执行上下文的程序实例。操作系统需要管理同时运行的多个进程,给它们分配CPU时间、隔离内存、处理它们的创建和消亡。
第二层:如何实现?
进程创建 :通过 fork() 系统调用,以父进程为模板创建一个新进程。新进程是父进程的"克隆"------代码段、数据段、堆栈、文件描述符表全部复制(或共享)。
进程状态:
创建 ──→ 就绪 ──→ 运行 ──→ 终止
↑ ↓
└── 阻塞 ──┘
| 状态 | 含义 |
|---|---|
| 就绪态 | 准备好了,等待CPU调度 |
| 运行态 | 正在CPU上执行 |
| 阻塞态 | 等待某个事件(IO完成、信号) |
第三层:实际场景与常见问题
僵尸进程 :子进程已退出,但父进程没有调用 wait 回收。子进程的退出状态信息还在内核中占据资源(PCB未释放)。ps aux 状态为 Z。
孤儿进程 :父进程先退出了,子进程还在运行。孤儿被 init进程(PID=1) 收养,由init负责回收------孤儿不会变僵尸。
守护进程(Daemon) :脱离终端、在后台长期运行的进程(如 sshd、crond)。实现步骤:fork → 子进程 setsid 创建新会话 → 再次 fork → 关闭不需要的文件描述符 → 切换工作目录到 /。
4.2 进程等待与进程替换
第一层:解决什么问题?
父进程创建子进程后,如何知道子进程的任务完成了?如果子进程先退出,父进程怎么获取它的退出状态?这就是进程等待要解决的。
如果你想在程序中运行另一个程序(比如写一个Shell,用户输入 ls 就要执行 ls 命令),不能重启一个进程------需要在当前进程中把另一个程序的代码替换进来。这就是进程替换。
第二层:如何实现?
进程等待 :父进程调用 wait/waitpid 等待子进程退出。wait 是阻塞等待任意一个子进程,waitpid 可以指定等待特定子进程,也可以非阻塞轮询。
SIGCHLD信号 :子进程退出时,内核向父进程发送SIGCHLD信号。父进程可以在信号处理函数中调用 waitpid 回收子进程------实现异步回收,不用阻塞等待。
进程替换 :exec 函数族(execl/execv/execle/execve/execlp/execvp)。用新程序替换当前进程的代码段和数据段,PID不变。
c
// Shell 的基本工作流程
pid_t pid = fork();
if (pid == 0) {
execlp("ls", "ls", "-l", NULL); // 子进程:替换为 ls
exit(1);
} else {
waitpid(pid, NULL, 0); // 父进程:等待子进程完成
}
第三层:实际场景与常见问题
- 如何避免僵尸进程?①调用
wait/waitpid;②注册SIGCHLD信号处理函数;③将父进程fork两次,子进程再fork孙子进程后立即退出,孙子被init接管 fork后的写时拷贝:fork不会真正复制父进程的物理内存,父子进程共享同一份物理页,只有在其中一个尝试写入时才复制------这就是"写时拷贝技术"
4.3 环境变量
第一层:解决什么问题?
很多程序的行为需要根据环境调整------比如 PATH 决定去哪里找可执行文件,HOME 决定用户主目录在哪,LANG 决定显示语言。把每个配置都写进代码不现实。
环境变量就是操作系统提供给进程的一组"全局配置参数",让程序可以读取而不需要自己维护。
第二层:如何实现?
每个进程启动时,内核将环境变量表(environ 字符指针数组)放在进程地址空间中。进程通过 getenv/setenv/putenv 读写。
第三层:实际场景与常见问题
| 命令 | 作用 |
|---|---|
env / printenv |
查看所有环境变量 |
echo $PATH |
查看某个环境变量 |
export VAR=value |
设置(当前Shell及子进程生效) |
VAR=value ./program |
临时设置,仅本次运行生效 |
面试常考:如何在代码中获取/设置环境变量?→ getenv("PATH")、setenv("MY_VAR", "hello", 1)
5. 虚拟地址空间
5.1 为什么需要虚拟地址空间
第一层:解决什么问题?
没有虚拟地址空间时,程序直接操作物理内存------这会带来一堆问题:
- 不安全:一个进程可以随意读写另一个进程的内存
- 不好管理:物理内存有碎片,程序要适应不同的物理内存布局
- 不灵活:程序编译时就要绑死物理地址,换个机器可能就不能跑
虚拟地址空间给每个进程一个"假象"------独占全部内存,由操作系统和硬件(MMU)负责把虚地址翻译到物理地址。
第二层:如何实现?
每个进程拥有独立的虚拟地址空间(32位系统4GB),通过页表映射到物理内存:
虚拟地址 ──→ 页表 ──→ 物理地址
带来的好处:
| 好处 | 说明 |
|---|---|
| 提高内存利用率 | 不连续使用物理内存,减少碎片 |
| 增加内存访问控制 | 页表中有权限位,可控制读/写/执行 |
| 保持进程独立性 | 每个进程有自己的页表,互不干扰 |
内存管理方式演进:
| 方式 | 原理 | 缺点 |
|---|---|---|
| 分段式 | 按逻辑段(代码段、数据段、堆栈)分配 | 外部碎片严重 |
| 分页式 | 把内存分成固定大小的页(4KB),按页映射 | 内部碎片(最后一页可能用不满) |
| 段页式 | 先按段分,段内再分页 | Linux实际使用的方案 |
第三层:实际场景与常见问题
面试核心题:如何通过虚拟地址找到物理内存?
- 虚拟地址分解为:页目录索引 + 页表索引 + 页内偏移(二级页表)
- 通过CR3寄存器找到页目录
- 页目录项 → 页表 → 物理页框 + 页内偏移 → 物理地址
- TLB(快表)缓存了最近的映射结果,避免每次查页表
6. 程序编译与调试
6.1 编译链接全过程
第一层:解决什么问题?
源代码(.c)变成可执行程序,不是一键完成的。C语言的编译是主要环节(已在C语言篇详述),这里重点讲链接阶段 ------多个目标文件(.o)如何被拼成一个可以执行的程序。
第二层:如何实现?
GCC的一次编译 = 预处理 → 编译 → 汇编 → 链接。Linux环境下,链接的核心工作是:符号解析 + 重定位 ,把多个 .o 文件和库文件合并为可执行程序。
静态链接:库的代码在链接时直接嵌入到可执行文件中。
- 优点:运行时不需要库文件,独立部署
- 缺点:可执行文件大,库更新需要重新编译
动态链接 :链接时只记录"需要哪个库的哪个符号",运行时由动态链接器 ld.so 加载 .so 文件。
- 优点:可执行文件小、库共享节省内存、库更新不需要重新编译
- 缺点:运行时依赖库文件存在
| 操作 | 命令 |
|---|---|
| 生成静态库 | ar -cr libxxx.a file1.o file2.o |
| 生成动态库 | gcc -shared -fPIC -o libxxx.so file1.c |
| 使用静态库 | gcc main.c -L. -lxxx -static |
| 使用动态库 | gcc main.c -L. -lxxx |
GCC常见选项:
| 选项 | 含义 |
|---|---|
-E |
只预处理 |
-S |
生成汇编代码 |
-c |
生成目标文件 |
-o |
指定输出文件 |
-g |
生成调试信息 |
-O0/-O1/-O2/-O3 |
优化级别 |
-Wall |
显示所有警告 |
-I |
指定头文件搜索路径 |
-L |
指定库搜索路径 |
-l |
指定链接库名称(-lpthread) |
-static |
静态链接 |
-fPIC |
生成位置无关代码(动态库必需) |
第三层:实际场景与常见问题
库的搜索路径:
- 编译时
-L指定 + 系统默认路径(/usr/lib,/usr/local/lib) - 运行时:
LD_LIBRARY_PATH环境变量 +/etc/ld.so.conf配置的路径 ldd <可执行文件>查看它依赖哪些动态库
make 和 Makefile :大项目有几十个源文件,手动敲gcc太痛苦。Makefile定义了依赖关系 和构建规则,make读取Makefile自动完成增量编译。核心:目标、依赖、命令三要素。
6.2 GDB调试
第一层:解决什么问题?
程序崩溃了,日志信息不够用------你需要"进入"程序内部,看看崩溃时变量是什么值、调用栈是怎样的、代码执行到了哪一行。GDB就是你的"X光机"。
第二层:核心操作
| 操作 | 命令 |
|---|---|
| 编译准备(保留符号表) | gcc -g main.c -o main |
| 启动调试 | gdb main,然后 start 或 run |
| 逐步执行 | step(进入函数)、next(单步跨过)、until(跳到指定行) |
| 断点 | break 设置断点,continue 继续运行,delete 删除 |
| 查看信息 | print 打印变量,backtrace 打印调用栈,info locals 局部变量 |
| 监视 | watch 变量被修改时中断 |
第三层:实际场景与常见问题
- 调试coredump:
gdb ./program core,进去后bt看调用栈 - 必须用
-g编译才保留符号表和源码行号对应关系 - 发行版可以去符号(
strip),减小体积但无法调试
7. 进程信号
7.1 信号机制
第一层:解决什么问题?
进程运行过程中,有些事情需要"打断"它------比如用户按了 Ctrl+C 想终止程序、定时器到时间了、子进程退出了。如果让每个进程轮询检查这些事件,效率极低。
信号就是操作系统的"软件中断"------内核可以在任何时候打断进程,告诉它"发生了某个事件"。
第二层:如何实现?
完整流程分为五个阶段:
① 产生:
| 来源 | 示例 |
|---|---|
| 硬件产生 | Ctrl+C → SIGINT,除零 → SIGFPE |
| 软件产生 | kill 命令,raise/abort 函数,定时器 alarm |
② 注册 :信号到达进程后,在进程PCB的**未决信号集(pending)**中标记该信号。对于可靠信号(3464),还会链入对应的链表节点;非可靠信号(131),同一个信号只标记一次。
③ 注销:信号被处理前,从pending中清除标记。
④ 处理:三种处理方式:
| 方式 | 含义 |
|---|---|
SIG_DFL |
默认处理(终止、忽略、stop、continue) |
SIG_IGN |
忽略该信号 |
| 自定义函数 | 注册信号处理函数 signal(sig, handler) 或 sigaction |
⑤ 阻塞 :信号可以被阻塞 ------信号到来时标记在pending,但如果被阻塞就不立即处理,等待解除阻塞后再处理。sigprocmask 设置阻塞信号集。
信号种类:
| 分类 | 范围 | 特点 |
|---|---|---|
| 非可靠信号 | 1~31 | 不支持排队,丢失后不补偿 |
| 可靠信号 | 34~64 | 支持排队,不会丢失 |
第三层:实际场景与常见问题
SIGCHLD :子进程退出时自动发给父进程。配合信号处理函数 + waitpid,不用阻塞等待子进程就能异步回收。
sigaction 优于 signal :signal 在不同Unix系统上行为不一致,sigaction 是POSIX标准,功能更强。
函数可重入性 :信号处理函数可能在任何时刻被调用------如果它和主程序同时操作一个全局变量,就会出问题。可重入函数就是不依赖全局/静态数据的函数。信号处理函数中应该只调用可重入函数。
volatile :在信号处理函数和主程序之间共享的全局变量要加 volatile------告诉编译器每次从内存读,不要优化到寄存器中。
8. 进程间通信
8.1 为什么需要进程间通信
第一层:解决什么问题?
每个进程有独立的虚拟地址空间------这是好事(隔离性),但也带来了麻烦:进程A算出的数据怎么传给进程B?用文件中转?太慢。进程间通信(IPC)就是解决"隔离的进程之间如何高效交换数据"这个问题。
第二层:通信方式的共同原理
每种IPC机制本质都是:在内核中开辟一块公共区域,多个进程通过访问这块区域来交换数据。 内核为所有进程共享,所以它可以充当"邮局"。
8.2 管道
第一层:解决什么问题?
父子进程或亲缘进程之间需要传递数据------最简单的需求:"我在Shell里输入 ls | grep test,ls 的输出要自动变成 grep 的输入。"
第二层:如何实现?
匿名管道 :内核中的一块缓冲区,通过两个文件描述符访问------一个读端、一个写端。
c
int fd[2];
pipe(fd); // fd[0]读端, fd[1]写端
特性:
- 单向通信:数据只能从写端流向读端
- 只能用于亲缘进程 (父子),因为没有标识符,只能通过
fork继承fd - 自带同步与互斥:读写操作自动互斥,管道为空时读端阻塞、满时写端阻塞
- 字节流服务:没有消息边界
命名管道(FIFO) :有文件名标识的管道,任意进程都可以通过文件名打开。使用 mkfifo 命令或 mkfifo() 函数创建。
管道的生命周期:所有读端关闭后写入会触发SIGPIPE;所有写端关闭后读取返回0。
第三层:实际场景与常见问题
- Shell管道
|就是用匿名管道实现的 - 管道大小有限(通常64KB),超过
PIPE_BUF的写入可能非原子 - 命名管道可以用作两个不相关进程之间的通信桥梁
8.3 共享内存
第一层:解决什么问题?
管道和消息队列都要经过内核中转------数据先拷贝到内核缓冲区,再拷出来。两次拷贝,数据量大时效率低。
共享内存让两个进程直接映射同一块物理内存到各自的虚拟地址空间------数据不需要经过内核拷贝,是IPC中速度最快的。
第二层:如何实现?
- 一个进程通过
shmget创建共享内存段 - 使用
shmat将共享内存映射到自己地址空间 - 另一个进程通过同样的key用
shmat映射同一块内存 - 使用完毕后
shmdt解除映射,shmctl删除 - 配合
ipcs -m和ipcrm -m查看和删除
缺点 :共享内存自身不提供同步机制------多个进程同时写会数据错乱。必须配合信号量等同步机制使用。
第三层:实际场景与常见问题
- 大数据量传输(音视频流、数据库共享缓存)首选共享内存
- 必须自己实现同步
- 用完要删除,否则一直占用(
ipcs可以查看残余共享内存)
9. 多线程
9.1 线程概念
第一层:解决什么问题?
多进程能并行处理任务,但进程创建和切换开销大(需要复制页表、切换地址空间),进程间通信也麻烦。有时候只是想让一个程序同时干几件事------比如浏览器同时下载多个图片、渲染页面、响应用户点击。
线程就是"轻量级进程"------同一进程中的多个线程共享地址空间,创建和切换开销小得多,数据共享也天然高效。
第二层:如何实现?
Linux下线程的实现 :Linux没有专门的"线程"概念------线程是用 clone 系统调用创建的,共享了更多资源(虚拟地址空间、文件描述符表、信号处理等)。所以Linux线程本质上是一种特殊的进程(轻量级进程LWP)。
线程间的独有与共享:
| 独有(每个线程一份) | 共享(所有线程共同) |
|---|---|
| 线程ID | 虚拟地址空间(代码段、数据段、堆) |
| 栈(每个线程有自己的栈空间) | 文件描述符表 |
| 寄存器上下文 | 信号处理方式 |
| errno | 工作目录 |
| 信号屏蔽字 | 用户ID/组ID |
第三层:实际场景与常见问题
线程为什么需要被等待? 线程退出后资源没被回收------类似僵尸进程。pthread_join 等待线程结束并回收资源。
线程分离(detach) :调用 pthread_detach 后,线程退出时系统自动回收资源,不需要join。适用于"一走了之"的后台线程。
9.2 互斥同步 ------ 互斥锁
第一层:解决什么问题?
同一进程的线程共享数据段------两个线程同时修改一个全局变量 count++,由于这不是原子操作(读-改-写三步),最终结果可能比预期的少(丢失更新)。
互斥:保证临界区操作的互斥性------同一时刻只有一个线程访问临界资源。
第二层:如何实现?
pthread_mutex_t 互斥锁:
c
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 线程A
pthread_mutex_lock(&mutex);
// 临界区:操作共享数据
count++;
pthread_mutex_unlock(&mutex);
lock的底层实现依赖CPU的原子指令(如 xchg、cmpxchg),保证"检查+加锁"这两步不会被中断。
第三层:实际场景与常见问题
- 锁的粒度要合适------太大影响性能,太小逻辑复杂
- 死锁:A等B解锁、B等A解锁。避免策略:①所有线程按相同顺序加锁;②使用
pthread_mutex_trylock+ 回退重试;③使用超时机制 - 不要重复加锁同一个互斥锁(默认行为是死锁)
9.3 同步 ------ 条件变量
第一层:解决什么问题?
互斥锁解决了"同时访问"的问题,但没有解决"什么时候能访问"的问题------比如生产者生产了数据,消费者才知道可以去取了。单纯用锁的话,消费者只能不停加锁检查有没有数据(忙等待),CPU空转。
条件变量让线程可以在"条件不满足"时休眠,等待条件满足时被唤醒------实现了时序上的协调。
第二层:如何实现?
c
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 消费者:等待条件
pthread_mutex_lock(&mutex);
while (queue_empty()) { // 条件循环判断!
pthread_cond_wait(&cond, &mutex); // 原子:解锁→休眠→被唤醒→加锁
}
// 取出数据
pthread_mutex_unlock(&mutex);
// 生产者:通知条件满足
pthread_mutex_lock(&mutex);
// 放入数据
pthread_cond_signal(&cond); // 唤醒一个等待线程
pthread_mutex_unlock(&mutex);
为什么用 while 而不是 if? 被唤醒时条件可能再次被其他线程抢走,必须重新判断。
pthread_cond_wait 的内部操作流程:
- 将调用线程加入条件变量的等待队列
- 解锁mutex
- 线程休眠,等待被唤醒
- 被唤醒后重新加锁mutex
- 函数返回
第三层:实际场景与常见问题
- 使用
pthread_cond_broadcast(唤醒所有等待线程)还是pthread_cond_signal(唤醒一个)?看场景------生产者生产一个数据时用signal,生产一批后统一通知用broadcast - 不同的角色等待在不同的条件变量上------消费者等"not_empty",生产者等"not_full"
9.4 信号量(POSIX信号量)
第一层:解决什么问题?
互斥锁只有0和1(锁住与未锁),但有时资源有多份------比如有10个数据库连接可用,超过10个线程要来同时连接,第11个就得等。信号量可以控制"有限数量资源"的多线程访问。
第二层:如何实现?
信号量内部维护一个计数器------代表可用资源数量:
c
sem_t sem;
sem_init(&sem, 0, 10); // 初始10个资源
// 访问资源
sem_wait(&sem); // P操作:计数-1,如果<0则阻塞
// ... 使用资源 ...
sem_post(&sem); // V操作:计数+1,唤醒等待者
信号量既可以做互斥 (初始值=1),也可以做同步。
第三层:实际场景与常见问题
- 与互斥锁的区别:互斥锁是"只有一把钥匙",信号量是"有多少个空位"
- 信号量支持"忙闲不均"------资源紧张时排队,资源空闲时快速获取
9.5 生产者消费者模型
第一层:解决什么问题?
这是多线程编程中最经典的场景:一批线程负责生产数据(生产者),一批线程负责处理数据(消费者),它们之间通过一个线程安全的任务队列来中转。
这个模型要解决的核心问题是:生产者和消费者的速度不匹配------忙闲不均。队列充当"缓冲",让两者解耦。
第二层:如何实现?
c
// 核心:线程安全队列 + 两个条件变量
pthread_mutex_t mutex;
pthread_cond_t cond_producer; // 生产者等"队列不满"
pthread_cond_t cond_consumer; // 消费者等"队列不空"
Queue queue;
// 生产者
void *producer(void *arg) {
while (1) {
Data data = produce();
lock(&mutex);
while (queue_full(&queue))
wait(&cond_producer, &mutex); // 等队列有空位
push(&queue, data);
signal(&cond_consumer); // 通知消费者
unlock(&mutex);
}
}
// 消费者
void *consumer(void *arg) {
while (1) {
lock(&mutex);
while (queue_empty(&queue))
wait(&cond_consumer, &mutex); // 等队列有数据
Data data = pop(&queue);
signal(&cond_producer); // 通知生产者
unlock(&mutex);
consume(data);
}
}
优势:支持并发、支持忙闲不均、生产者和消费者解耦。
第三层:实际场景与常见问题
- 线程池中的任务队列就是生产者消费者模型的应用
- 当队列容量为1时,就是"同步"------生产者生产一个,消费者消费一个
- 注意条件变量要用
while循环判断(虚假唤醒 + 多消费者竞争)
9.6 读者写者模型
第一层:解决什么问题?
某些数据"读多写少"------比如一个热门的配置文件,100个线程读它,偶尔有一个线程来更新。如果用互斥锁,读操作之间也要互斥------100个读者只能排着队一个个读,毫无必要。
读者写者模型的规则:读者之间不互斥(可以同时读),但写者必须独占(写时不能有其他读者或写者)。
第二层:如何实现?
维护读者计数器:
- 第一个读者加写锁(阻止写者进入)
- 后续读者只加读者计数
- 最后一个读者释放写锁
- 写者直接加写锁
读者优先 vs 写者优先:读者优先可能导致写者饿死;写者优先可能导致读者饿死。需要根据实际场景选择。
第三层:实际场景与常见问题
- 数据库连接池的状态查询、配置中心的数据读取、缓存管理
- 要注意优先级反转问题------大量读者不断涌入,写者永远排不上队
9.7 线程池
第一层:解决什么问题?
每来一个任务就创建一个新线程去处理------任务完成后线程销毁。如果任务是高频短小的(如Web服务器处理HTTP请求),线程的创建/销毁开销甚至大于任务本身。而且如果短时间内涌入海量任务,就会创建海量线程------系统资源被耗尽。
线程池预先创建一批线程常驻内存,有任务时直接分配,用完回收而不销毁------避免频繁创建销毁的时间开销,同时控制系统中线程总数的上限。
第二层:如何实现?
核心:线程安全的任务队列 + 一组常驻工作线程。
c
// 线程池结构
struct ThreadPool {
Queue task_queue; // 线程安全的任务队列
pthread_t *threads; // 工作线程数组
int thread_count; // 线程数量
};
// 工作线程主循环
void *worker(void *arg) {
ThreadPool *pool = (ThreadPool *)arg;
while (1) {
Task task;
lock(&pool->mutex);
while (queue_empty(&pool->task_queue))
wait(&pool->cond, &pool->mutex); // 没任务就休眠
task = dequeue(&pool->task_queue);
unlock(&pool->mutex);
task.func(task.arg); // 执行任务
}
}
设计模式:生产者消费者模型------主线程是生产者(投递任务),工作线程是消费者(执行任务)。
第三层:实际场景与常见问题
- 线程数量设多少?CPU密集:核心数 + 1;IO密集:核心数 × 2 ~ 核心数 × N
- 线程池配合单例模式------整个程序只需要一个线程池实例
9.8 单例模式(线程安全)
第一层:解决什么问题?
某些资源只需被加载一次------比如配置文件、日志系统、数据库连接池。如果创建了多个实例,不仅浪费资源,而且可能导致状态不一致。单例模式保证一个类在全局只有一个实例。
第二层:如何实现?
饿汉方式:程序初始化时就创建实例,所有执行流直接使用。
cpp
class Singleton {
static Singleton _instance; // 静态初始化时创建,天然线程安全
public:
static Singleton &getInstance() { return _instance; }
};
优点:线程安全(main之前就构造完了);缺点:不管你用不用,资源先占着。
懒汉方式:需要用到时才加载。
cpp
class Singleton {
static Singleton *_instance;
static pthread_mutex_t _mutex;
public:
static Singleton *getInstance() {
if (_instance == NULL) { // 双重检查锁定
pthread_mutex_lock(&_mutex);
if (_instance == NULL)
_instance = new Singleton();
pthread_mutex_unlock(&_mutex);
}
return _instance;
}
};
注意双重检查锁定:外层if是为了避免每次都加锁,内层if是真正的安全检查。
第三层:实际场景与常见问题
- 线程安全的单例模式是面试高频考点 ------特别是双重检查锁定 + volatile(C++11后用
std::atomic) - C++11之后:
static Singleton &getInstance() { static Singleton instance; return instance; }天然线程安全
10. 网络基础 ------ TCP/UDP
10.1 网络分层与协议栈
第一层:解决什么问题?
两台机器通信涉及的事情太多了------物理信号怎么传(电压/无线电)、数据怎么编码、怎么找到目标机器、怎么保证数据不丢、数据怎么组织成应用能读的格式。如果把这些事情混在一起,任何一个环节改动都会影响全局。
网络分层就是把复杂问题拆成独立的层,每层只负责自己的事,上层不关心下层的实现细节。
第二层:TCP/IP四层模型
| 层级 | 功能 | 典型协议/设备 |
|---|---|---|
| 应用层 | 定义应用程序如何交换数据 | HTTP, DNS, FTP |
| 传输层 | 端到端数据传输 | TCP, UDP |
| 网络层 | 寻址和路由 | IP, ICMP, ARP |
| 链路层 | 相邻节点间的数据传输 | 以太网, MAC地址 |
第三层:实际场景
数据发送时,每层添加自己的协议头,形成嵌套------这叫"封装"。接收端逐层解封装。这也是为什么 MSS = MTU - IP头长度 - TCP头长度。
10.2 UDP
第一层:解决什么问题?
TCP提供了可靠传输,但"可靠"是有代价的------三次握手建立连接、确认应答、超时重传、流量控制。有些场景不需要这么重------视频直播丢几帧无所谓、DNS查询就一问一答、游戏实时状态不关心上一条。------快速、简单、低延迟比可靠更重要。
第二层:如何实现?
UDP的核心特征:
| 特性 | 含义 |
|---|---|
| 无连接 | 知道对端地址直接发,不需要建立连接 |
| 不可靠 | 不保证数据到达,没有确认和重传 |
| 面向数据报 | 整条收发,应用层发的数据报,UDP原样发送和接收 |
协议字段 :源端口、目的端口、数据报长度、校验和。协议头只有8字节。
第三层:实际场景与常见问题
- 不粘包 :因为面向数据报,每次
sendto的数据对应一次recvfrom,天然有边界 - 数据包长度有限制:UDP数据报不能超过65535字节(16位长度字段)
- 应用场景:DNS查询、视频直播、实时游戏、VoIP
10.3 TCP
第一层:解决什么问题?
UDP只管"扔出去",对方有没有收到、顺序对不对、传输是否太快------一概不管。但在大多数场景下------订票、转账、文件传输、网页浏览------这些保证是必须的。
TCP解决的就是"在不可靠的IP网络之上,建立起可靠的端到端连接"。
第二层:核心机制
三次握手
为什么是三次,不是两次或四次?
客户端 服务端
| ---SYN(seq=x)------→ | ① 客户端:我要连接
| ←-SYN+ACK(seq=y, ack=x+1)-| ② 服务端:收到,我也要连接
| ---ACK(ack=y+1)----→ | ③ 客户端:收到你的确认
| ESTABLISHED | ESTABLISHED
为什么三次? 两次握手的问题是------服务端收到第一个SYN就认为连接建立(进入ESTABLISHED),如果这个SYN在网络上延迟了很久才到达(而客户端早就放弃了),服务端就会为一个无效请求白白分配资源。第三次握手让服务端确认客户端确实还想要这个连接。
四次挥手
客户端 服务端
| ---FIN------→ | ① 客户端:我没数据了,想关闭
| ←-ACK------- | ② 服务端:知道了(还可能发数据)
| ←-FIN------- | ③ 服务端:我也没数据了
| ---ACK------→ | ④ 客户端:知道了
| TIME_WAIT (2MSL) | CLOSED
为什么是四次? 因为TCP是全双工的------一方发FIN只表示它没数据要发了,另一方可能还有数据要传。所以关闭需要两个方向各一次FIN+ACK。
TIME_WAIT状态:主动关闭方在收到最后ACK后,等待2MSL(报文最大生存时间的2倍)。作用:①确保对端收到ACK(如果对端没收到ACK,会重发FIN,TIME_WAIT期间能再回复);②让旧连接的报文在网络中彻底消失,不会干扰新连接。
TCP状态流转
LISTEN → SYN_RCVD → ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED
→ FIN_WAIT1 → FIN_WAIT2 → TIME_WAIT → CLOSED
面试常考哪些状态是服务端的、哪些是客户端的:
- 服务端:LISTEN → SYN_RCVD → ESTABLISHED → CLOSE_WAIT → LAST_ACK
- 客户端:SYN_SENT → ESTABLISHED → FIN_WAIT1 → FIN_WAIT2 → TIME_WAIT
第三层:实际场景与常见问题
- 三次握手失败:如果第一个SYN丢了,客户端会重传;如果服务端的SYN+ACK丢了,客户端会重传SYN;如果最后ACK丢了,服务端会重传SYN+ACK
- 大量TIME_WAIT(常见生产问题) :高并发短连接服务器可能积累大量TIME_WAIT,端口耗尽。解决方案:①调整内核参数(
tcp_tw_reuse);②使用长连接
10.4 TCP可靠传输机制
第一层:解决什么问题?
TCP的底层是IP------IP不保证数据一定到达,可能丢包、可能乱序、可能重复。TCP必须在这些不可靠的基础上,向上层提供"绝对可靠"的数据流。
第二层:六大机制
① 确认应答机制(ACK)
接收方每收到一个数据段,就回复一个ACK确认------发送方知道数据到了。如果一定时间内没收到ACK......?
② 超时重传机制
发送方每发一个数据段,启动一个定时器。超时未收到ACK → 重传。RTO(重传超时时间)根据网络RTT动态计算,不能太短也不能太长。
③ 序号/确认序号
每个字节都有一个序号。接收方回复的ACK带有"期望收到的下一个字节的序号",这样即使乱序到达也能正确重组。
④ 校验和
每个TCP报文都有校验和,接收方重新计算并对比------如果对不上,说明数据在传输中损坏,丢弃后等待重传。
⑤ 滑动窗口机制
为什么要滑动窗口? 如果发一个等一个ACK再发下一个------这叫"停止等待",网络利用率极低(大部分时间在等ACK)。
滑动窗口让发送方不等ACK就可以连续发送窗口大小的数据量:
| 已发送已确认 | 已发送未确认 (窗口内) | 可发送但未发送 | 不能发送 |
↑ 窗口左沿 ↑ 窗口右沿
- 收到ACK → 窗口左沿右移
- 接收方通告窗口大小 → 窗口右沿右移
- 窗口大小为0时停止发送
- 窗口大小取决于:接收方缓冲区剩余空间 + 网络拥塞状况
流量控制 ≠ 拥塞控制:流量控制是接收方能力 的限制("我收不了这么快"),拥塞控制是网络能力的限制("路上堵了")。
⑥ 捎带应答与延迟应答
捎带应答:ACK不单独发,而是"搭便车"附在数据包上发回去。
延迟应答:收到数据后不立即回ACK,而是等一会儿------期间如果上层读了数据,窗口会变大,就能通告更大的窗口;或者等上层有数据要回时捎带过去。
第三层:粘包问题
TCP是面向字节流 的------在传输层对数据边界不敏感,数据在发送/接收缓冲区中只是连续的字节流。应用层多次 send 的数据可能在缓冲区中粘连,接收方一次 recv 可能读到多次 send 的数据粘在一起------这就是粘包。
解决方案:
- 定长包:每个包固定大小
- 分隔符:每段数据用特殊字符分隔(如
\r\n) - 包头+包体:包头记录包体长度,先读包头再读指定长度的包体
11. IP地址与网络层
11.1 IP地址与子网划分
第一层:解决什么问题?
Internet上的每台设备需要一个唯一的地址来标识自己------就像寄信需要收件地址。同时,这个地址要能快速判断"目标是否和我在同一个网络内"------如果在同一个网络,直接发;如果不在,需要通过路由器中转。
第二层:如何实现?
IP地址的组成 :IP地址 = 网络号 + 主机号
子网掩码 :和IP地址按位与得到网络号。255.255.255.0 & 192.168.1.100 = 192.168.1.0
网段划分:
| 类别 | 范围 | 默认掩码 | 用途 |
|---|---|---|---|
| A类 | 1.0.0.0 ~ 127.255.255.255 | /8 | 大型网络 |
| B类 | 128.0.0.0 ~ 191.255.255.255 | /16 | 中型网络 |
| C类 | 192.0.0.0 ~ 223.255.255.255 | /24 | 小型网络 |
| D类 | 224.0.0.0 ~ 239.255.255.255 | --- | 组播 |
| E类 | 240.0.0.0 ~ 247.255.255.255 | --- | 保留 |
特殊IP地址:
- 127.0.0.1:本地回环
- 主机号全0:网络地址,标识网段本身
- 主机号全1:广播地址
- 255.255.255.255:有限广播
私有IP(组建私网用):10.x.x.x、172.16~172.31.x.x、192.168.x.x
第三层:实际场景
- 相邻网络不能使用相同的网络号
- 子网掩码取反 = 最大主机号
- 面试题:判断两个IP是否在同一子网 → 分别和掩码做与运算,比较结果
11.2 路由、ARP、NAT
第一层:解决什么问题?
数据要从A机器到B机器,但A和B不在同一个网络里------中间经过多个路由器。网路层要解决:①选哪条路?②知道IP但不知道对方MAC地址,怎么在局域网里找到它?③内网机器没有公网IP,怎么访问公网?
第二层:如何实现?
路由表:每个路由器维护一张路由表,保存"目的网络 → 下一跳"的映射。路由表生成算法:静态路由(手动配)、动态路由(RIP/OSPF/BGP自动学习)。
ARP(地址解析协议):已知IP地址,获取对应的MAC地址。主机广播ARP请求:"谁的IP是 xxx.xxx.xxx.xxx?告诉我你的MAC地址",目标主机单播回复。
NAT(网络地址转换):部署在网关设备上,修改IP报头的源地址。内网主机访问公网时,NAT将私网IP转为网关的公网IP+新端口,建立映射关系表。回程时根据映射表还原------多个内网机器共享一个公网IP上网。
第三层:实际场景与常见问题
route -n查看本机路由表arp -a查看ARP缓存- NAT解决了IPv4地址不足的问题,但也引入了一些协议兼容性问题
11.3 DNS域名解析
第一层:解决什么问题?
记住IP地址太难了------你能记住 baidu.com 但记不住 110.242.68.66。DNS就是把域名翻译成IP地址的"电话簿"。
第二层:如何实现?
域名的层级划分:
www.baidu.com
三级 二级 顶级
| 层级 | 说明 |
|---|---|
| 根域名服务器 | 全球13组,存储所有顶级域名的服务器地址 |
| 顶级域名服务器 | .com / .org / .cn / .jp 等 |
| 二级域名服务器 | baidu.com / qq.com |
| 三级域名 | zhidao.baidu.com |
域名解析流程(迭代查询):
浏览器 → 本地DNS缓存 → hosts文件 → 本地域名服务器
→ 根域名服务器(问.com在哪)
→ .com顶级域名服务器(问baidu.com在哪)
→ baidu.com权威域名服务器(返回IP)
→ 返回给浏览器
第三层:实际场景与常见问题
nslookup和dig命令查询DNS- 浏览器会缓存DNS结果一段时间(TTL)
- DNS解析失败时------先检查网络、检查DNS服务器配置(
/etc/resolv.conf)
12. 应用层协议
12.1 HTTP
第一层:解决什么问题?
网页浏览器需要从服务器获取HTML、图片、JS等资源。这些资源分布在不同的服务器上,需要一个统一的应用层协议来组织请求(我想要什么)和响应(这是你要的)。
第二层:核心概念
HTTP请求的格式:
GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
(空行)
(请求体,GET通常没有)
HTTP响应的格式:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
<html>...</html>
浏览器输入URL后发生的事情:
- URL解析:提取协议、域名、路径
- DNS解析:域名 → IP地址
- TCP连接:三次握手建立TCP连接
- 发送HTTP请求:组织HTTP请求报文并发送
- 服务器处理:解析请求,处理逻辑,返回响应
- 接收HTTP响应:客户端接收并解析响应
- 长连接/短连接 :HTTP/1.1默认长连接(
Connection: keep-alive),可复用TCP连接发送多个请求 - 渲染页面:浏览器根据HTML渲染页面
第三层:实际场景与常见问题
- HTTP是无状态协议------每次请求独立,服务器不记得上次谁来过(这催生了Cookie/Session)
- 长连接 vs 短连接:持久连接减少了TCP握手开销
- HTTP协议格式要记牢------面试中常让你手动组织一个HTTP请求/响应
13. 网络IO模型
13.1 阻塞IO、非阻塞IO、信号驱动IO、异步IO
第一层:解决什么问题?
一个服务器要同时处理几万个客户端连接。如果每个连接都用一个线程+阻塞IO来处理,那就需要一个线程等着 recv 返回------大部分时间线程都在空等。几万个连接 = 几万个线程?操作系统根本扛不住。
IO模型解决的就是"如何高效地同时监视大量IO事件,而不是一个线程卡在一个连接上"。
第二层:五种IO模型对比
| IO模型 | 调用行为 | 数据就绪 | 数据拷贝 | 典型实现 |
|---|---|---|---|---|
| 阻塞IO | 一直等 | 函数等 | 函数完成 | 默认 recv |
| 非阻塞IO | 立即返回 | 轮询 | 函数完成 | fcntl 设 O_NONBLOCK |
| 信号驱动IO | 立即返回 | 信号通知 | 函数完成 | SIGIO |
| 异步IO | 立即返回 | 内核通知 | 内核完成 | aio_read |
| 多路复用 | 阻塞在select/poll/epoll | 函数返回 | 函数完成 | select/poll/epoll |
关键区分------阻塞 vs 非阻塞、同步 vs 异步:
- 阻塞/非阻塞:调用后是否立即返回(能不能干别的事)
- 同步/异步:数据的拷贝由谁完成(同步=自己完成read操作,异步=内核帮你完成)
多路复用(select/poll/epoll)属于同步非阻塞------监控阶段不阻塞,但数据的read操作还是自己完成。
第三层:实际场景与常见问题
- 阻塞IO适合简单的一对一场景
- 非阻塞IO需要不断轮询,CPU消耗大
- 多路复用是高性能服务器的事实标准------NGINX、Redis、Node.js都在用
13.2 epoll
第一层:解决什么问题?
select和poll是早期多路复用方案,但它们有严重缺陷------每次调用都要把整个fd集合从用户态拷贝到内核,内核还要遍历整个集合才能找到就绪的fd。当连接数成千上万时,这个O(n)的遍历就变成了瓶颈。
epoll就是为了解决"海量连接下多路复用的性能问题"而设计的。
第二层:如何实现?
epoll的三大接口:
| 接口 | 作用 |
|---|---|
epoll_create |
在内核创建epoll实例(一棵红黑树 + 一个就绪链表) |
epoll_ctl |
添加/修改/删除要监控的fd |
epoll_wait |
等待就绪事件,直接返回已就绪的fd |
epoll为什么快:
- 事件驱动 :fd就绪时,内核的回调函数把它挂到就绪链表上。
epoll_wait直接读这个链表------不需要遍历所有fd - 内核与用户共享内存(mmap):避免每次数据拷贝
- 没有最大fd数量限制:select有1024限制
触发方式:
| 方式 | 行为 | 特点 |
|---|---|---|
| LT(水平触发) | 没处理完就绪事件,下次 epoll_wait 还会返回 |
默认方式,与传统IO一致 |
| ET(边缘触发) | 只在状态变化时通知一次,必须一次读完 | 高效但编程要求高(非阻塞 + 循环读) |
第三层:实际场景与常见问题
- 惊群问题 :多个进程/线程同时
epoll_wait同一个fd,一个事件到来全部被唤醒,但只有一个能处理------其他白跑一趟。Nginx通过加锁解决,Linux 4.5+ 提供了EPOLLEXCLUSIVE选项 - ET模式下必须一次读完所有数据(循环读取直到返回 EAGAIN)
- epoll 是Linux特有的,没有移植到其他Unix系统(BSD的kqueue类似)