13 - 计算机网络与 Linux 操作系统底层篇
核心定位:计算机核心基础素养,覆盖 TCP 状态机与拥塞控制、HTTP/1.1~HTTP/3 演进、TLS 握手、Linux epoll 多路复用、零拷贝与线上系统排障。
Q1: 详细讲讲 TCP 三次握手与四次挥手的状态机流转?为什么建连需要三次握手而不是两次?为什么断连需要四次挥手?
回答(求职者口吻):
TCP 是面向连接、可靠的字节流传输协议:
- 三次握手(Connection Establishment):
- Step 1:客户端发送SYN=1, seq=x,进入SYN_SENT状态;
- Step 2:服务端收到后回复SYN=1, ACK=1, seq=y, ack=x+1,分配资源并进入SYN_RCVD状态;
- Step 3:客户端收到后回复ACK=1, seq=x+1, ack=y+1,进入ESTABLISHED状态;服务端收到后也进入ESTABLISHED状态。
- 为什么不能是两次握手:
① 防止历史失效的旧 SYN 报文突然到达服务端引起资源浪费(如客户端发送的一个 SYN 延迟到达,如果是两次握手,服务端一旦回复就单方面建立连接并空耗资源;三次握手下客户端可发送 RST 中止旧连接);
② 确保双方的初始序列号(ISN)都得到了对方的双向确认。 - 四次挥手(Connection Termination):
- Step 1:客户端发送FIN=1, seq=u,进入FIN_WAIT_1;
- Step 2:服务端收到后回复ACK=1, ack=u+1,进入CLOSE_WAIT;客户端收到后进入FIN_WAIT_2;
- Step 3:服务端处理完残留的在途数据后,主动发送FIN=1, ACK=1, seq=w, ack=u+1,进入LAST_ACK;
- Step 4:客户端收到后回复ACK=1, ack=w+1,进入TIME_WAIT状态(等待 2MSL 后彻底关闭);服务端收到 ACK 后进入CLOSED。
- 为什么挥手需要四次:- TCP 是全双工通信。客户端发送 FIN 仅代表“客户端不再发送数据,但仍能接收数据”;服务端收到后可能还有待处理的业务数据需要继续推送,所以服务端先回复 ACK 确认收到,待所有数据发送完毕后才单独发送自己的 FIN 报文。因此第二步和第三步通常无法合并。
Q2: 为什么客户端在最后一步挥手后必须进入 TIME_WAIT 状态?等待时间为什么是 2MSL?线上出现大量 TIME_WAIT 或 CLOSE_WAIT 怎么排查?
回答(求职者口吻):
1. 必须存在 TIME_WAIT 的两大核心原因:
- 原因 1:保证客户端发送的最后一个 ACK 能够可靠到达服务端。若该 ACK 在网络中丢失,服务端会重发 FIN 报文。处于 TIME_WAIT 的客户端可以再次重传 ACK,防止服务端永远卡在 LAST_ACK 状态无法正常关闭。
- 原因 2:防止“历史失效连接中的迟到报文”干扰新连接。等待 2MSL(Maximum Segment Lifetime,通常 60 秒)足以让本次连接产生的所有迷途数据包在网络中自然消亡,避免新老连接五元组相同时发生数据污染。
2. 线上异常状态深度排查:
- 大量 TIME_WAIT(通常出现在主动关闭连接的一方,如 HTTP 客户端/反向代理 Nginx):
- 根因:高并发短连接频繁创建与销毁(如 HTTP 请求未复用长连接 Keep-Alive,或 Nginx 代理未开启 upstream keepalive)。
- 治理:全面开启 HTTP Keep-Alive 复用 TCP 连接;在 Linux 内核层调整 net.ipv4.tcp_tw_reuse = 1(允许重用 TIME_WAIT 套接字)并调大本地端口范围 net.ipv4.ip_local_port_range。
- 大量 CLOSE_WAIT(出现在被动关闭的一方,如业务后端):
- 根因:应用代码严重 Bug!客户端已经主动断开连接发送了 FIN,但服务端的业务代码由于线程阻塞、死锁或忘记调用 socket.close() / resp.Body.Close(),导致未向客户端发送 FIN 报文。
- 治理:必须通过 pprof 排查代码中的资源泄漏和未关闭的 I/O 流。
Q3: TCP 的滑动窗口与拥塞控制(慢启动、拥塞避免、快重传、快恢复)机制是怎样的?
回答(求职者口吻):
TCP 通过流量控制与拥塞控制在不可靠的 IP 网络上实现自适应的高效传输:
- 流量控制(Flow Control - 滑动窗口机制):
- 接收方在 ACK 报文中通告自己的接收窗口大小(rwnd),发送方发送的数据量绝不能超过rwnd,防止发送太快挤爆接收方的内核缓冲区。 - 拥塞控制(Congestion Control - 保护整个网络):
- 发送方维护一个拥塞窗口(cwnd),真正能发送的数据量取 $\min(cwnd, rwnd)$。
- ① 慢启动(Slow Start):连接刚建立时 $cwnd = 1$(MSS),每收到一个 ACK,cwnd 翻倍呈指数级增长($1 \to 2 \to 4 \to 8 \dots$),直到触碰慢启动门限(ssthresh)。
- ② 拥塞避免(Congestion Avoidance):当 $cwnd \ge ssthresh$ 后,切换为线性增长(每个 RTT 只增加 1 个 MSS),试探网络带宽极限。
- ③ 快重传(Fast Retransmit):当接收方发现中间丢包时,连续发送 3 次相同的重复确认(Duplicate ACK)。发送方无需等待超时重传定时器(RTO)到期,立即重发丢失的数据包。
- ④ 快恢复(Fast Recovery):收到 3 个重复 ACK 后,将ssthresh减半,并将 $cwnd$ 置为新ssthresh + 3,继续进入拥塞避免线性增长,避免直接跌落回慢启动原点。
Q4: 详细讲讲 HTTP/1.1、HTTP/2、HTTP/3 的演进历史与核心技术突破?HTTP/2 为什么依然存在 TCP 队头阻塞?
回答(求职者口吻):
应用层传输协议经历了四代关键革新:
- HTTP/1.1(经典时代):
- 引入长连接(Connection: keep-alive)支持管道化流水线(Pipelining,但不可靠);引入Transfer-Encoding: chunked;
- 痛点:存在应用层队头阻塞(Head-of-Line Blocking),单个 TCP 连接上必须按序一问一答,浏览器只能通过并发 6 个 TCP 连接来缓解。 - HTTP/2(二进制帧与多路复用):
- 核心突破:
① 二进制分帧层(Binary Framing):将数据切分为 Header 帧和 Data 帧;
② 多路复用(Multiplexing):单个 TCP 连接上支持并发交错传输多个 Stream,彻底解决应用层队头阻塞;
③ HPACK 头部压缩 与 服务端推送(Server Push)。
- 遗留痛点(TCP 队头阻塞):底层依然依赖单一 TCP 连接。如果网络中丢失了某个 Stream 的单个数据包,TCP 协议栈为了保证顺序交付,会阻塞整条 TCP 连接上所有其他无关 Stream 的读取。 - HTTP/3(基于 UDP 的 QUIC 协议):
- 核心突破:彻底抛弃 TCP,采用基于 UDP 自研的 QUIC 协议:
① 彻底解决传输层队头阻塞:每个 Stream 拥有独立的滑动窗口与丢包恢复,单个 Stream 丢包不影响其他 Stream;
② 0-RTT / 1-RTT 极速建连:将传输握手与 TLS 1.3 握手合并;
③ 连接迁移(Connection Migration):基于 64 位 Connection ID 而不是 IP+端口四元组标识连接,手机在 Wi-Fi 与 4G/5G 切换时网络完全不中断。
Q5: 浏览器输入一个 HTTPS URL 到页面展示,中间发生了什么?详细描述 DNS 解析、TLS 1.3 握手与对称/非对称加密计算。
回答(求职者口吻):
这是一个贯穿计算机全栈的经典过程:
- URL 解析与 DNS 查询:
- 检查浏览器缓存 -> 操作系统 Hosts 文件 -> 本地 DNS 递归服务器 -> 根域名服务器 -> 顶级域名服务器 -> 权威 DNS 服务器,获取目标 IP。 - TCP 三次握手建连。
- TLS 1.3 安全握手(1-RTT 建连):
- Client Hello:客户端发送支持的 TLS 版本、密码套件列表、随机数 $Random_C$,并携带 ECDHE 密钥交换算法的临时公钥参数。
- Server Hello:服务端选定 TLS 1.3 和密码套件,返回服务端临时公钥、服务端证书(包含 CA 签名的 RSA/ECC 公钥)与数字签名。
- 证书校验与对称密钥协商:客户端校验 CA 证书链合法性与域名一致性;双方利用各自的私钥和对方的公钥通过 ECDH 算法直接计算出完全相同的共享主密钥(Master Secret),推导出后续对称加密会话密钥(如 AES-256-GCM)。 - HTTP 数据安全通信:
- 客户端发送对称加密的 HTTP GET 请求,服务端解密并返回 HTML 报文。 - 浏览器端渲染引擎解析:
- 解析 HTML 构建 DOM 树 -> 解析 CSS 构建 CSSOM 树 -> 合并为 Render Tree(渲染树) -> Layout 布局计算(重排/回流) -> Paint 绘制(重绘) -> GPU 栅格化合成并在屏幕上呈现。
Q6: Linux 的 I/O 多路复用机制演进:select、poll 与 epoll 的区别?epoll 底层为什么选用红黑树加就绪链表?
回答(求职者口吻):
I/O 多路复用是指单个进程可以监视多个文件描述符(FD),一旦某个 FD 就绪就能够通知程序进行读写:
- select vs poll vs epoll 核心对比:
-select:底层使用线性 Bitmap 数组,单个进程能监视的 FD 数量有硬限制(默认 1024);每次调用需要把整个 fd_set 从用户态全量拷贝到内核态,且内核通过 $O(N)$ 轮询遍历唤醒,性能随 FD 数量线性衰减。
-poll:底层改为基于链表的pollfd数组,解除了 1024 数量限制,但依然存在全量内存拷贝与 $O(N)$ 轮询开销。
-epoll(Linux 2.6+ 现代标准):彻底解决上述瓶颈。 - epoll 底层两大核心数据结构:
- 红黑树(Red-Black Tree):存储所有通过epoll_ctl注册监听的 Socket FD。$O(\log N)$ 复杂度高效支持海量连接的快速增删查改,且无需重复从用户态拷贝。
- 就绪双向链表(rdllist):当某个 Socket 有数据到达时,网卡中断触发驱动回调函数,内核直接将该就绪的 FD 插入就绪双向链表中。
- 当调用epoll_wait时,仅需检查就绪链表是否为空,时间复杂度为 $O(1)$,直接返回就绪事件列表给用户态,性能与总连接数无关,仅取决于活跃连接数。 - 水平触发(LT) vs 边缘触发(ET):
- LT(Level Triggered,默认):只要缓冲区还有未读数据,每次epoll_wait都会反复通知,较为安全容错。
- ET(Edge Triggered,极致性能):仅在状态发生变化(从未就绪到就绪)的那一瞬间通知一次。要求应用层必须使用非阻塞 I/O并循环读取(while read)直到返回EAGAIN,否则残留数据将永久丢失。
Q7: 什么是零拷贝(Zero-Copy)技术?传统 I/O 的 4 次上下文切换与 4 次数据拷贝是怎么产生的?sendfile 底层原理是什么?
回答(求职者口吻):
零拷贝是指计算机执行 I/O 操作时,CPU 不需要将数据从一个内存区域复制到另一个内存区域的技术:
- 传统文件传输(
read+write)的 4 次切换与 4 次拷贝:
- ① 调用read():用户态切换到内核态(切换1);DMA 控制器从磁盘拷贝数据到内核页缓存(PageCache)(拷贝1);
- ②read()返回:内核态切换回用户态(切换2);CPU 将数据从内核页缓存拷贝到用户态内存缓冲区(拷贝2);
- ③ 调用write():用户态切换到内核态(切换3);CPU 将数据从用户内存拷贝到Socket 缓冲区(拷贝3);
- ④write()返回:内核态切换回用户态(切换4);DMA 控制器将 Socket 缓冲区数据拷贝到网卡硬件(拷贝4)。
- 痛点:经历了 4 次上下文切换,2 次 DMA 硬件拷贝和 2 次昂贵的 CPU 内存拷贝。 sendfile系统调用(零拷贝标准实现):
-sendfile(socket_fd, file_fd, offset, count):
- 仅需 2 次上下文切换;
- DMA 将磁盘数据拷贝到内核 PageCache;
- 在支持 Scatter/Gather DMA 的网卡下,CPU 仅需将 PageCache 的内存地址描述符和数据长度传递给 Socket 缓冲区(无真实数据拷贝),网卡 DMA 直接从 PageCache 读取数据发送到网络,实现 CPU 真正的 0 次数据拷贝,大幅解放 CPU 算力。
Q8: Linux 进程、线程与协程的本质区别是什么?进程的虚拟内存空间布局是怎样的?
回答(求职者口吻):
1. 进程、线程与协程本质对比:
- 进程(Process):操作系统分配资源(独立虚拟地址空间、文件句柄、页表)的最小单位。进程切换开销大(需切换页目录 CR3、刷新 TLB 缓存)。
- 线程(Thread):操作系统 CPU 调度的最小单位。同一进程内的所有线程共享进程的虚拟地址空间和堆内存,但拥有独立的程序计数器、寄存器组和栈空间。
- 协程(Coroutine / Goroutine):完全运行在用户态的轻量级线程。由编程语言运行时(Go Runtime)调度,无内核态切换开销,初始栈仅 2KB,创建与调度耗时仅几纳秒。
2. Linux 64 位进程虚拟内存空间布局(从低地址到高地址):
- 0x00000000(保留区,捕获 NULL 指针错误);
- Text Segment(代码段):只读,存放编译后的机器二进制指令;
- Data Segment(数据段):存放已初始化的全局变量和静态变量;
- BSS Segment(BSS 段):存放未初始化的全局变量,系统自动清零;
- Heap(堆区):向上(高地址)动态增长,存放 malloc/new 动态分配的内存;
- Memory Mapping Area(文件映射与动态库区):存放 mmap 映射的文件及共享动态链接库(.so);
- Stack(栈区):向下(低地址)增长,存放函数局部变量、返回地址与调用帧;
- Kernel Space(内核空间,高位 128TB):操作系统内核专属,用户态代码严禁直接访问。
Q9: 什么是孤儿进程与僵尸进程(Zombie Process)?如何避免僵尸进程的产生?操作系统如何处理?
回答(求职者口吻):
1. 孤儿进程(Orphan Process):
- 定义:父进程在子进程退出前就已经挂掉或退出了,子进程成为“孤儿”。
- 处理机制:Linux 系统会自动将所有孤儿进程过继给 1 号进程(systemd / init)。当孤儿进程终止时,1 号进程会自动调用 wait() 回收其退出状态,不会对系统产生危害。
2. 僵尸进程(Zombie Process - defunct):
- 定义:子进程已经终止(退出),但父进程既没有挂掉,也没有调用 wait() / waitpid() 来读取子进程的退出状态码,导致子进程的进程描述符 task_struct 依然残留在内核进程表中。
- 危害:大量的僵尸进程会迅速耗尽系统的最大进程数上限(pid_max),导致系统无法再创建任何新进程。
3. 避免与治理最佳实践:
- 方案 1(父进程异步回收):父进程注册并捕获 SIGCHLD 信号,在信号处理函数中调用 waitpid(-1, &status, WNOHANG) 非阻塞回收子进程。
- 方案 2(忽略信号):父进程显式设置 signal(SIGCHLD, SIG_IGN),告知内核子进程退出后自动释放资源,不产生僵尸。
- 治理手段:如果系统中已经产生僵尸进程,直接 kill -9 僵尸进程是无效的(因为进程本身已经死亡),必须 kill -9 <父进程PID> 杀死其父进程,使僵尸进程变成孤儿进程,由 1 号进程统一回收。
Q10: 线上 Linux 服务器 CPU 飙升 100%、或者内存报警时,你标准的排查工具链和步骤是怎样的?
回答(求职者口吻):
线上排障必须讲究科学的工具链与方法论(USE 方法:使用率、饱和度、错误数):
一、CPU 100% 深度排查四步法:
1. top:查看哪个进程(PID)CPU 占用最高,同时观察 load average 与 us(用户态)/ sy(内核态)占比。
2. top -H -p <PID>:展示该进程下 CPU 占用最高的热点线程 ID(TID)。
3. 定位堆栈与代码行:
- 如果是 Java 服务:将 TID 转换为十六进制(printf "%x\n" <TID>),使用 jstack <PID> | grep -A 30 <hex_tid> 定位到具体卡死的类和代码行;
- 如果是 Go 服务:直接拉取 CPU Profile:go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30,使用 top 或火焰图(Flame Graph)毫秒级定位消耗 CPU 最高的函数(如死循环、高频正则、密集序列化)。
二、内存暴涨与 OOM 排查步骤:
1. free -m & vmstat 1:查看系统剩余内存与 swap 交换分区使用情况。
2. dmesg -T | grep -i oom:查看是否有进程触发了 Linux 内核的 OOM-Killer。
3. 定位内存泄漏:
- Go 服务:查看堆内存分配分析:go tool pprof http://localhost:6060/debug/pprof/heap,通过 inuse_space(当前持有)和 alloc_space(累计分配)对比,找出未被 GC 释放的大对象或 Goroutine 堆积。