《从阻塞开始:accept()和recv()阻塞的本质及唤醒机制》

45 阅读7分钟

问题场景:第二个客户端为何无法连接?

上周,我写了一个130行代码的echo服务器,单客户端连接正常,响应正常,但是第二个客户端尝试连接则无响应,为什么?

阻塞的echo服务器流程图

image.png

那么,的确是accept()和recv()导致了阻塞,这背后发生了什么才会导致所谓的阻塞?怎么恢复?

先讲结论

accept()阻塞的本质:进程被内核放入等待队列,并让出CPU,直到内核网络协议栈检测到TCP全连接队列有连接,将该进程唤醒。

recv()阻塞的本质:进程被内核放入等待队列,并让出CPU,直到内核网络协议栈检测到对应的内核缓冲区有数据,将该进程唤醒。

accept()阻塞现象、本质、唤醒机制全链路

accept()阻塞的现象

服务器启动后,只要没有客户端的连接,服务器就处于阻塞状态,CPU使用率为0% 证明如下:

ad047f6c5ce60eb73b0458158720a140.jpg

accept()阻塞的本质

accept()不是普通的函数,而是系统函数,当我们调用accept()的时候,实际上发生了这些:

进程状态变迁: 发生了什么:

(进程TASK_RUNNING)1. 程序→系统调用(syscall),用户模式指令流发生中断。 (现代x86使用的是syscall/sysenter指令,过去x86 32位是0×80软中断。)

(进程TASK_RUNNING)2. CPU→从用户模式切换至内核模式

(进程TASK_RUNNING)3. 内核→检查TCP全连接队列是否有连接。 TCP连接队列有全连接队列和半连接队列,这里检查的是全连接队列里面是否有连接。

(进程TASK_RUNNING→TASK_INTERRUPTIBL)4. 内核→若检查结果

①有连接:有连接 → 立即返回新fd,进程依旧Running ②无连接,则把进程放入等待队列,此进程挂起,开启阻塞状态(Waiting)

(进程TASK_INTERRUPTIBLE)5. 内核→调用schedule(),切换到其他进程,这个被挂起的进程CPU使用率为0

(进程TASK_RUNNING) 6. 客户端→发起连接,唤醒机制将进程唤醒,让进程从等待队列到CPU调度器的运行队列,从Waiting进入Ready状态

(进程TASK_RUNNING)7. CPU调度器→调度该进程,进程从CPU调度器的运行队列出去,从Ready状态进入Running状态,返回用户空间继续执行

accept()的唤醒机制

我们再来详细讲一下当客户端发起连接,第六步第七步的唤醒机制发生了什么:

  1. 网卡→收到来自客户端的SYN包

  2. 网卡→通过DMA控制器直接把SYN包写入到内存

  3. 网卡→向CPU发送硬件中断信号

  4. CPU→收到硬件中断信号,跳转至中断处理程序。

  5. 中断处理函数→简单快速确认一下此SYN包的数据一致性、中断来源等,标记SYN包为软中断 NET_RX_SOFTIRQ 待处理。

  6. 软中断服务程序→调用TCP协议栈入口函数

  7. TCP协议栈→收到SYN包,从链路层传至IP层传至TCP层,SYN包在半连接队列中,TCP层与此包进行三次握手建立连接,建立连接之后将该连接从半连接队列放入全连接队列

  8. 内核→检查TCP连接队列中的全连接队列:发现队列不为空,立即调用wake_up(),进程则从等待队列移动到CPU调度器的运行队列,状态由TASK_INTERRUPTIBLE变为TASK_RUNNING,进程被唤醒

  9. CPU调度器根据调度算法挑选进程,当进程被选中后,从CPU调度器的运行队列进入到运行状态,进程状态依旧是TASK_RUNNING(细节:TASK_RUNNING有两种含义,①进程在就绪态,②进程在运行态,因为这两种进程状态没有数值上的变化,只是从正在等待调度变成了正在执行)

  10. 内核→从全连接队列中取出连接,创建对应的socket并为其分配文件描述符client_fd,client_fd作为accept()的返回值传递回用户空间的应用程序,CPU从内核模式切换回用户模式,进程继续占用CPU执行程序。

accept()唤醒机制链路图

image.png

recv()阻塞现象、本质、唤醒机制全链路

recv()阻塞的现象

服务器与客户端连接后,只要客户端不发送数据过来,服务器就处于阻塞状态,CPU使用率为0% 证明如下:

7e2a88f4689c788a11b4b110103076b3.jpg

recv()阻塞的本质

与accept()一样,recv()同样不是普通的函数,而是系统函数,我们用刚才的方式再讲一遍recv()。当我们调用recv()的时候,实际上发生了这些:

  1. (进程TASK_RUNNING)程序→系统调用(syscall),用户模式指令流发生中断。

  2. (进程TASK_RUNNING)CPU→从用户模式通过软中断切换至内核模式

  3. (进程TASK_RUNNING)内核→检查已连接socket的接受缓冲区是否有数据。

  4. (TASK_RUNNING→TASK_INTERRUPTIBLE)内核→若检查结果无数据,把进程状态从TASK_RUNNING切换为TASK_INTERRUPTIBLE,则把进程放入等待队列,此进程挂起,开启阻塞状态

  5. (进程TASK_INTERRUPTIBLE)内核→调用schedule(),切换到其他进程,该进程CPU使用率为0%

  6. (进程TASK_RUNNING)客户端→发送数据包,唤醒机制将进程唤醒,让进程进入Ready状态

  7. (进程TASK_RUNNING)CPU调度器→调度该进程,进程进入Running状态,返回用户空间继续执行

recv()的唤醒机制

我们再来详细讲一下当客户端发来数据,第六步第七步的唤醒机制发生了什么:

  1. 网卡→收到来自客户端的数据包

  2. 网卡→通过DMA控制器直接把数据包写入到内核的环形缓冲区。(链路层)

  3. 网卡→给CPU发送硬件中断信号

  4. CPU→接受硬件中断信号,跳转至中断处理函数

  5. 中断处理函数→简单快速确认数据完整性,中断来源,标记数据包为软中断NET_RX_SOFTIRQ待处理。

  6. 软中断服务程序→调用TCP协议栈入口函数

  7. TCP协议栈→从环形缓冲区读取数据,从链路层传至IP层传至TCP层,数据包在TCP层完成可靠的数据传输,把正确的数据放到已连接socket的接收缓冲区里。

  8. 内核→检查已连接socket的接收缓冲区:发现缓冲区有数据,则立即调用wake_up(),进程则从等待队列移动到CPU调度器的运行队列,状态由TASK_INTERRUPTIBLE变为TASK_RUNNING,进程被唤醒

  9. CPU调度器→根据调度算法挑选进程,当进程被选中的时候,进程从CPU调度器的运行队列进入到运行状态,进程状态依旧是TASK_RUNNING。

  10. 内核→将内核socket的接收缓冲区的数据包根据TCP协议拷贝至应用程序的缓冲区,CPU从内核模式切换为用户模式,进程继续占用CPU执行程序。

唤醒链路图

image.png

结论与思考

为什么操作系统要设计阻塞机制?

理解了accept()和recv()如何阻塞后,我们自然会问: 为什么操作系统要这样设计?让进程直接"卡住"等待,这不是很低效吗?

恰恰相反,阻塞式I/O是一种效率优化,从操作系统视角来看,有以下几个理由

  1. 避免忙等待

试想如果不阻塞:

// 伪代码:非阻塞的忙等待模式
while (没有连接) {
    // 不断循环检查,占用CPU
    // 但连接可能几秒甚至几分钟后才来
}

缺陷:CPU时间被白白浪费在无意义的轮询上,其他进程无法使用CPU。

  1. 提升CPU利用率

进程检查资源不可用 → 立即睡眠 主动调用schedule() → 让出CPU CPU可以运行其他就绪进程 → 提高整体利用率

核心结论

我们亲眼观察到了:

  1. accept()阻塞:等待新连接时进程睡眠,CPU 0%
  2. recv()阻塞:等待数据时进程睡眠,CPU 0%
  3. 单线程瓶颈:当recv()阻塞时,无法返回accept()处理新客户端

所以,在下篇博客中,我将:引入select改造这个echo服务器,支持多客户端!敬请期待!