01 Q:请描述 TCP 三次握手建立连接、四次挥手断开连接的完整过程。为什么建立连接必须是三次,两次/四次都不行?主动关闭方为什么必须进入 TIME_WAIT?为什么是 2MSL?会带来什么问题,有哪些优化手段?
一、三次握手过程
- 第一次握手 :客户端调用
connect,向服务端发送 SYN 报文,携带客户端随机生成的初始序列号 ISN(c),客户端进入 SYN_SENT。 - 第二次握手 :服务端收到 SYN 后,回复 SYN+ACK 报文:确认号为 ISN(c)+1(SYN 标志占用一个序号),同时携带服务端自己的初始序列号 ISN(s);服务端为此连接创建 request_sock 放入半连接队列,进入 SYN_RCVD。
- 第三次握手 :客户端收到 SYN+ACK 后,回复 ACK,确认号为 ISN(s)+1;该 ACK 报文允许携带数据(也可以为空)。服务端收到 ACK 后,连接从半连接队列移入全连接队列,双方都进入 ESTABLISHED ,连接建立,随后服务端
accept()取走连接。
二、四次挥手过程
- 第一次挥手 :主动关闭方(假设是客户端)数据发送完毕,调用
close发送 FIN,进入 FIN_WAIT_1。 - 第二次挥手 :被动方收到 FIN 后立即回复 ACK(确认号为 FIN 序号+1),进入 CLOSE_WAIT ;主动方收到 ACK 后进入 FIN_WAIT_2 。此时处于半关闭状态:主动方不再发送数据,但被动方若还有未发完的数据可以继续发,主动方仍要接收。
- 第三次挥手 :被动方数据全部发完后,调用
close发送自己的 FIN,进入 LAST_ACK。 - 第四次挥手 :主动方收到 FIN 后回复 ACK,随即进入 TIME_WAIT ;被动方收到 ACK 后直接进入 CLOSED ;主动方等待 2MSL 后进入 CLOSED。
注意:TIME_WAIT 永远在主动关闭方;被动方没有 TIME_WAIT。
三、为什么必须是三次握手
两次为什么不行 :无法防止历史上过期的重复连接请求。假设客户端发出的 SYN1 在网络中滞留,客户端超时重发了 SYN2 并正常完成通信、关闭了连接;此后 SYN1 才到达服务端。两次握手下服务端发出 SYN+ACK 就单方面建立连接、分配 socket 和缓冲区资源,但客户端根本不认这个连接,会直接回复 RST 拒绝------在 RST 到达前,服务端的资源已经白白占用,攻击者只需不断发送这种"过期 SYN"就能耗尽服务端资源。三次握手下,服务端必须再收到客户端的 ACK 才建立完整连接,客户端发现自己没发过 SYN 却收到 SYN+ACK,就知道是过期报文,不会回 ACK(而是回 RST),连接无法建立。
此外三次握手还能完整确认双向通道的收发能力并同步双方 ISN:第一次握手证明客户端能发、服务端能收;第二次握手让客户端确认服务端能收能发、自己能收;第三次握手才让服务端确认客户端能收。两次握手缺少最后这一确认。
四次为什么多余 :理论上"告知我方 ISN"和"确认对方 ISN"可以分成两个报文(SYN、ACK 各一次,双向共四次),但建立连接时被动方通常没有数据需要先发送,ACK 可以搭在 SYN 报文里合并发送 ,所以三次是理论最小值,再拆一次纯属冗余。形成对照的是:挥手时被动方收到 FIN 的那一刻可能还有数据没发完,ACK 必须立刻回(否则对方会重传 FIN),而 FIN 要等数据发完才能发,二者无法合并,所以挥手天然是四次。
四、TIME_WAIT 的作用与 2MSL
主动关闭方必须停留 2MSL,原因有二:
- 保证最后一个 ACK 可靠到达:如果第四次挥手的 ACK 丢失,被动方会超时重传 FIN;主动方停留在 TIME_WAIT 期间仍能收到重传的 FIN 并再次回复 ACK,从而正常关闭。若主动方直接 CLOSED,重传 FIN 到达时只能回 RST,被动方会认为连接异常。
- 让本连接的旧报文在网络中自然消亡:等待期间,本次连接残留的延迟报文都会超过 MSL 而被丢弃,防止它们被误认为是下一个相同四元组新连接的数据。
MSL(Maximum Segment Lifetime) 是报文段在网络中的最大生存时间。2MSL 的计算是"一来一回":主动方发出的 ACK 最多 1 个 MSL 到达对端;若丢失,对端重传的 FIN 最多再 1 个 MSL 到达主动方,合计 2MSL 足以覆盖最坏情况。Linux 中 MSL 固定为 30 秒,TIME_WAIT 时长为 60 秒。
五、TIME_WAIT 带来的问题与优化
问题:高并发短连接 场景下,主动关闭方会快速积累大量 TIME_WAIT 连接,每个连接占用一个四元组槽位和少量内核内存;当本机作为客户端向固定对端发起大量连接时,可能耗尽本地端口,导致 connect 失败(Cannot assign requested address)。
常见优化手段:
- 改用长连接 + 连接池(如 HTTP Keep-Alive):从源头减少连接的建立与关闭次数,是最根本的手段;
- 让 TIME_WAIT 落在对端:服务端不主动关闭连接、由客户端先关,TIME_WAIT 分散到海量客户机上;
- SO_REUSEADDR:允许 bind 处于 TIME_WAIT 的地址端口,主要解决服务端程序重启时端口被占用的问题;
net.ipv4.tcp_tw_reuse=1:允许主动连接方安全复用 TIME_WAIT 的四元组(依赖 TCP 时间戳防旧报文,需同时开启 timestamps),这才是解决客户端端口耗尽的关键参数;
02 Q:请说明 epoll 的水平触发(LT)和边缘触发(ET)的核心区别,以及各自的使用场景。
【参考答案】
水平触发 LT(Level Triggered,默认模式) :只要 fd 处于就绪状态------读缓冲区里还有数据可读,或写缓冲区还有空间可写------每次调用 epoll_wait 通知,相当于"条件一直满足就一直通知"。本次没处理完没关系,下次 epoll_wait 还会再通知,事件不会丢失。LT 对阻塞、非阻塞 fd 都兼容,编程简单、不易出错,代价是同一就绪状态可能反复唤醒、epoll_wait 返回次数多,系统调用开销略大。
边缘触发 ET(Edge Triggered):只在 fd 状态发生"跳变"时通知一次------例如epoll_wait调用一次就会把该项从就绪链表移除,此后即使缓冲区里还有数据没读完,也不会再通知,直到下一次新的数据到达产生新的边沿。因此 ET 模式下必须:
第一,把 fd 设置为非阻塞;
第二,收到通知后循环 read/write 直到返回 EAGAIN(或 EWOULDBLOCK),确保一次把数据读干、写满。
如果用阻塞 fd,最后一次没有数据的 read 会把线程永久阻塞,导致其他所有连接饿死;如果没读到 EAGAIN 就返回,剩余数据不会再触发通知,该连接会被"饿死"。
使用场景 :LT 是默认且通用的选择,适合连接数中等、逻辑复杂、追求稳妥的网络程序,阻塞/非阻塞写法都能工作,Redis 的事件循环就使用 LT。ET 适合高并发、连接数庞大、追求极致性能的场景,它把"缓冲区有数据"这一状态的多次通知压缩为一次,减少 epoll_wait 的返回次数和重复遍历,但要求开发者完全掌控非阻塞 IO 的读写循环,Nginx 即采用 ET。此外还有 EPOLLONESHOT 选项:一个 fd 的事件只通知一次,处理完后必须用 epoll_ctl 重新武装,常用于多线程环境下保证同一个 fd 同一时刻只被一个线程处理。
03 Q:请描述进程的六种状态及状态间的转换条件;并说明 Linux 下父进程、子进程、僵尸进程、孤儿进程分别是什么关系。
一、六种进程状态(ps 中显示)
- R(Running / Runnable,运行或就绪):正在 CPU 上运行,或在运行队列中等待被调度,两种情况都显示为 R。
- S(Interruptible Sleep,可中断睡眠) :进程在等待某个事件或资源,如等待网络数据、管道输入、条件变量或执行
sleep,可以被信号唤醒。 - D(Uninterruptible Sleep,不可中断睡眠) :通常因发起阻塞式磁盘 IO 等内核操作进入,等待 IO 完成期间不响应任何信号,SIGKILL 也杀不掉,只能等内核唤醒,这是为了保证内核 IO 流程不被打断。
- T / t(Stopped / Traced,停止):收到 SIGSTOP、SIGTSTP(终端下 Ctrl+Z 发送的就是 SIGTSTP)等信号后暂停执行;小写 t 表示被调试器 ptrace 跟踪、停在断点处。
- Z(Zombie,僵尸) :进程已经
exit终止,但父进程尚未调用wait/waitpid回收,内核仍保留它的 task_struct、退出码和资源统计,ps 中显示为 Z 或 defunct。 - X(Dead,死亡):父进程回收后资源彻底释放的终态,瞬时存在,正常观察不到。
二、状态转换条件
- R → S :进程主动等待某事件(等待 IO、等待锁、
sleep),放弃 CPU; - R → D:进程发起不可中断的阻塞 IO(典型为磁盘读写);
- R → T:收到 SIGSTOP / SIGTSTP 等停止信号,或被调试器拦截在断点;
- R → Z → X :进程调用
exit或致命信号终止后先成为僵尸 Z,父进程wait回收后变为 X; - S → R:等待的事件发生(数据到达、资源可用、信号到达),被唤醒后回到运行队列;
- D → R:IO 完成后由内核唤醒;D 状态期间信号不能中断它;
- T → R :收到 SIGCONT 信号恢复运行。
特别:时间片耗尽是 R→R(从运行变为就绪,仍在运行队列),不是进入睡眠。
三、父进程、子进程、僵尸进程、孤儿进程
父进程与子进程 :父进程调用 fork 创建子进程,父进程中 fork 返回子进程的 PID,子进程中返回 0。fork 的瞬间,子进程获得父进程虚拟地址空间的一份副本------代码、数据、堆、栈内容相同,文件描述符表、环境变量、工作目录、信号处理方式等也被继承;但二者拥有独立的页表和独立的地址空间,底层物理页通过写时复制(COW)共享,任一方修改数据时内核才复制对应物理页,修改互不影响。PID、PPID、文件锁、未决闹钟等不被继承。
僵尸进程 :子进程退出后,内核必须保留它的退出状态等信息供父进程查询;若父进程既不 wait/waitpid 也不处理 SIGCHLD,子进程就一直处于 Z 状态。危害是每个僵尸仍占一个 task_struct 和一个 PID,僵尸积累过多会耗尽 PID 资源,导致系统无法创建新进程。处理方式:父进程主动 wait/waitpid;或注册 SIGCHLD 信号处理函数在其中异步回收(也可将 SIGCHLD 处置设为 SIG_IGN,由内核自动回收);若父进程本身有问题,可杀掉父进程,使僵尸变为孤儿进程,由 PID 1 回收。
孤儿进程 :父进程先退出、子进程仍在运行,该子进程即为孤儿进程,它会被 PID 1 的 init(现代系统为 systemd,容器内是其 1 号进程,还存在 subreaper 机制)领养 ,此后由 1 号进程负责 wait 回收。孤儿进程本身无害,因为有 1 号进程兜底,它退出后不会变成僵尸。
04 Q:epoll 为什么比 select/poll 性能高?
select/poll 的性能开销:
第一,每次调用都要把完整的 fd 集合从用户态拷贝到内核态,监听 n 个 fd 就是 O(n) 的拷贝;
第二,内核每次都要把当前线程挂接到这 n 个 fd 各自的设备等待队列上,然后线性遍历全部 fd 检查就绪,返回前再逐一摘除掉,也是 O(n);
第三,用户态拿到结果后还得把 n 个 fd 全部遍历一遍才能找出就绪的,又是 O(n)。
此外,select 额外有 1024 的 fd_set 硬上限,且其位图会被内核改写、每轮必须重新设置。
epoll 把"注册监听"和"等待就绪"拆成了两类操作,从而避免了上述重复劳动:
- fd 集合只注册一次 :通过
epoll_ctl把 fd 包装成 epitem 插入内核中的红黑树,并在该 fd 的设备等待队列上挂接一次回调函数 ;增删改是 O(log n),此后无论epoll_wait调用多少次都不需要重新拷贝全量集合、不需要重复挂接等待队列。 - 事件驱动代替轮询 :fd 就绪时,设备驱动触发回调
ep_poll_callback,自动把对应 epitem 挂入就绪双向链表;内核不需要在每次等待时扫描全部 fd。 - 只返回就绪 fd :
epoll_wait直接把就绪链表中的 fd 拷贝给用户态,返回复杂度是 O(k)(k 为本次就绪的 fd 数),与监听总数 n 无关,用户态也无需全量遍历。 - 监听数量没有 1024 硬上限,仅受内存和进程句柄数限制;
epoll_ctl还可以在其他线程中安全地增删 fd。
需要注意边界情况:epoll 的优势建立在"连接总数大、但每次只有少量连接活跃"的场景(典型如高并发长连接)。如果所有连接在每次等待时都有事件就绪(例如局域网内流量被打满),那么就绪处理本身就是 O(n),epoll 与 poll 的差距会明显缩小。
05 Q:进程和线程之间的核心区别是什么?多进程和多线程分别适合什么业务场景?
- 资源维度 :进程是资源分配的基本单位,每个进程拥有独立的虚拟地址空间和页表、独立的文件描述符表、独立的堆、信号处理表以及用户/组身份;线程隶属于某个进程,同一进程的多个线程共享 代码段、堆、全局数据、文件描述符表、信号处理函数和当前工作目录,但每个线程私有 自己的栈、寄存器上下文(PC、SP)、线程 ID、
errno、信号掩码、调度优先级和线程局部存储(TLS)。 - 调度维度:线程是 CPU 调度的基本单位,一个进程由一个或多个线程组成。Linux 在内核中统一用 task_struct 描述,进程可理解为拥有独立地址空间的线程组。
- 健壮性维度:进程间资源隔离,一个进程崩溃通常不影响其他进程;线程共享地址空间,一个线程发生非法内存访问会导致整个进程崩溃。
- 切换开销维度:进程创建、销毁要初始化和回收整套资源,切换时需要更换地址空间、切换页表并刷新 TLB,开销大;同一进程内的线程切换不更换地址空间,只需保存 PC、栈指针和通用寄存器等上下文,开销小。
- 通信维度:进程间通信必须借助 IPC(管道、消息队列、共享内存、信号、socket 等),机制复杂但隔离清晰;线程间可以直接读写同一进程的全局变量和堆,通信天然高效,但必须用互斥锁、条件变量、读写锁等同步手段解决竞态问题。
适用场景:
多线程适合高并发、IO 密集、任务间需要频繁共享大量数据、追求低切换成本的场景,如 Web 服务器的线程池、聊天服务、交易网关;CPU 密集型任务在多核机器上也可用多线程并行(C/C++ 无解释器锁限制)。
多进程适合对隔离性、容错性和安全性要求高的场景:例如 Chrome 每个站点/标签页使用独立进程,防止一个页面崩溃拖垮浏览器并隔离安全风险;Nginx 使用多个 worker 进程,一个 worker 异常不影响整体服务;特权服务也常用独立子进程做权限沙箱。工程上常见二者混合:多 worker 进程做隔离与多核利用,进程内部再用事件驱动或多线程处理并发。
06 什么是虚拟内存?它主要解决了直接使用物理内存的哪些问题?
虚拟内存是操作系统借助 CPU 的内存管理单元(MMU)在进程虚拟地址与物理内存地址之间建立的一层地址抽象。每个进程都拥有一个独立的、私有的、大小固定的连续虚拟地址空间(32 位系统为 4GB,64 位系统通常使用 48 位地址、用户态约 128TB/256TB);进程访问的永远是虚拟地址,由 MMU 通过多级页表翻译成物理地址,翻译结果缓存在 TLB 中,缺页时触发缺页中断由内核处理。进程完全不需要关心物理内存的实际分布。
它解决了直接使用物理内存的以下问题:
- 地址冲突问题:物理内存模式下,多个程序直接操作物理地址,链接和加载时必须互相避让,无法方便地同时运行。有了虚拟内存,每个进程都可以从相同的虚拟地址开始布局(如代码段固定加载在某虚拟地址),互不干扰,编译器和链接器也无需关心物理位置,共享库还能通过位置无关代码映射到各进程的任意虚拟地址。
- 安全隔离问题:每个进程的页表独立,一个进程无法访问其他进程的物理内存;页表项上的读写/执行权限位和内核态/用户态位还能保护内核空间和只读代码段,越界访问未映射或无权限的页面会触发段错误,从硬件层面实现了进程隔离与保护。
- 内存利用率问题 :通过按需分页 (malloc 后首次访问才真正分配物理页)、写时复制 (fork 后父子共享物理页,写入才复制)、页共享 (共享库、共享内存只存一份物理副本)和 swap 交换(不活跃页换出到磁盘),系统可以"超售"内存,运行总需求超过物理内存容量的程序。
- 物理碎片问题与编程简化:连续的虚拟页可以映射到任意离散的物理页框,物理内存的外部碎片对进程不可见;配合 mmap,还可以把文件、设备统一映射为内存进行访问。
代价是:地址翻译需要多级页表查询(TLB 缺失时有开销)、页表本身占用内存,以及 swap 换入换出可能引起明显的性能抖动。
07 Q:简述 TCP 粘包产生的原因和常见的解决方案。
产生原因 :TCP 是面向字节流的协议,协议栈只保证字节按序、可靠到达,不保留应用层每次 write 的消息边界 。具体来自两侧:发送端,Nagle 算法会把多个小数据包攒在一起合并发送,而大于 MSS 的消息又会被分段成多个报文;接收端,数据先进入内核接收缓冲区,若应用读取不及时,多条消息会堆积在一起,一次 recv 可能读出两条消息的拼接(粘包),也可能只读出一条消息的前半段(半包/拆包)。
常见解决方案:
- 固定长度消息:约定每条消息占固定字节数,不足则补齐,接收方每次按固定长度读取,实现最简单,但短消息浪费带宽,适用消息长度高度一致的场景。
- 特殊分隔符 :在消息末尾追加约定的分隔符,如
\r\n,接收方逐字节扫描到分隔符即得到一条完整消息。HTTP 头部、Redis 的 RESP 协议、FTP 都采用这种方式;要注意消息体内部出现分隔符时必须做转义。 - 消息头 + 消息体(长度前缀,最常用) :定义固定长度的消息头,头部中用固定字段(如 4 字节大端整数)标明消息体长度,接收方先读满固定长度的头部、解析出长度,再按长度读满整条消息体。接收缓冲区需要维护一个粘包/半包状态机:缓冲区中字节数不足一条完整消息时,保留残包等待下次数据到达(处理半包);字节数超过一条时,截取第一条后继续解析剩余部分(处理粘包)。