13 - 计算机网络与 Linux 操作系统底层篇

核心定位:计算机核心基础素养,覆盖 TCP 状态机与拥塞控制、HTTP/1.1~HTTP/3 演进、TLS 握手、Linux epoll 多路复用、零拷贝与线上系统排障。


Q1: 详细讲讲 TCP 三次握手与四次挥手的状态机流转?为什么建连需要三次握手而不是两次?为什么断连需要四次挥手?

回答(求职者口吻)
TCP 是面向连接、可靠的字节流传输协议:

  1. 三次握手(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)都得到了对方的双向确认
  2. 四次挥手(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 网络上实现自适应的高效传输:

  1. 流量控制(Flow Control - 滑动窗口机制)
    - 接收方在 ACK 报文中通告自己的接收窗口大小(rwnd),发送方发送的数据量绝不能超过 rwnd,防止发送太快挤爆接收方的内核缓冲区。
  2. 拥塞控制(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 队头阻塞?

回答(求职者口吻)
应用层传输协议经历了四代关键革新:

  1. HTTP/1.1(经典时代)
    - 引入长连接(Connection: keep-alive)支持管道化流水线(Pipelining,但不可靠);引入 Transfer-Encoding: chunked
    - 痛点:存在应用层队头阻塞(Head-of-Line Blocking),单个 TCP 连接上必须按序一问一答,浏览器只能通过并发 6 个 TCP 连接来缓解。
  2. HTTP/2(二进制帧与多路复用)
    - 核心突破
    二进制分帧层(Binary Framing):将数据切分为 Header 帧和 Data 帧;
    多路复用(Multiplexing):单个 TCP 连接上支持并发交错传输多个 Stream,彻底解决应用层队头阻塞;
    HPACK 头部压缩服务端推送(Server Push)
    - 遗留痛点(TCP 队头阻塞):底层依然依赖单一 TCP 连接。如果网络中丢失了某个 Stream 的单个数据包,TCP 协议栈为了保证顺序交付,会阻塞整条 TCP 连接上所有其他无关 Stream 的读取
  3. 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 握手与对称/非对称加密计算。

回答(求职者口吻)
这是一个贯穿计算机全栈的经典过程:

  1. URL 解析与 DNS 查询
    - 检查浏览器缓存 -> 操作系统 Hosts 文件 -> 本地 DNS 递归服务器 -> 根域名服务器 -> 顶级域名服务器 -> 权威 DNS 服务器,获取目标 IP。
  2. TCP 三次握手建连
  3. 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)。
  4. HTTP 数据安全通信
    - 客户端发送对称加密的 HTTP GET 请求,服务端解密并返回 HTML 报文。
  5. 浏览器端渲染引擎解析
    - 解析 HTML 构建 DOM 树 -> 解析 CSS 构建 CSSOM 树 -> 合并为 Render Tree(渲染树) -> Layout 布局计算(重排/回流) -> Paint 绘制(重绘) -> GPU 栅格化合成并在屏幕上呈现。

Q6: Linux 的 I/O 多路复用机制演进:select、poll 与 epoll 的区别?epoll 底层为什么选用红黑树加就绪链表?

回答(求职者口吻)
I/O 多路复用是指单个进程可以监视多个文件描述符(FD),一旦某个 FD 就绪就能够通知程序进行读写:

  1. select vs poll vs epoll 核心对比
    - select:底层使用线性 Bitmap 数组,单个进程能监视的 FD 数量有硬限制(默认 1024);每次调用需要把整个 fd_set 从用户态全量拷贝到内核态,且内核通过 $O(N)$ 轮询遍历唤醒,性能随 FD 数量线性衰减。
    - poll:底层改为基于链表的 pollfd 数组,解除了 1024 数量限制,但依然存在全量内存拷贝与 $O(N)$ 轮询开销。
    - epoll(Linux 2.6+ 现代标准):彻底解决上述瓶颈。
  2. epoll 底层两大核心数据结构
    - 红黑树(Red-Black Tree):存储所有通过 epoll_ctl 注册监听的 Socket FD。$O(\log N)$ 复杂度高效支持海量连接的快速增删查改,且无需重复从用户态拷贝。
    - 就绪双向链表(rdllist:当某个 Socket 有数据到达时,网卡中断触发驱动回调函数,内核直接将该就绪的 FD 插入就绪双向链表中。
    - 当调用 epoll_wait 时,仅需检查就绪链表是否为空,时间复杂度为 $O(1)$,直接返回就绪事件列表给用户态,性能与总连接数无关,仅取决于活跃连接数。
  3. 水平触发(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 不需要将数据从一个内存区域复制到另一个内存区域的技术:

  1. 传统文件传输(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 内存拷贝。
  2. 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 averageus(用户态)/ 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 堆积。