问题场景:第二个客户端为何无法连接?
上周,我写了一个130行代码的echo服务器,单客户端连接正常,响应正常,但是第二个客户端尝试连接则无响应,为什么?
阻塞的echo服务器流程图↓
那么,的确是accept()和recv()导致了阻塞,这背后发生了什么才会导致所谓的阻塞?怎么恢复?
先讲结论
accept()阻塞的本质:进程被内核放入等待队列,并让出CPU,直到内核网络协议栈检测到TCP全连接队列有连接,将该进程唤醒。
recv()阻塞的本质:进程被内核放入等待队列,并让出CPU,直到内核网络协议栈检测到对应的内核缓冲区有数据,将该进程唤醒。
accept()阻塞现象、本质、唤醒机制全链路
accept()阻塞的现象
服务器启动后,只要没有客户端的连接,服务器就处于阻塞状态,CPU使用率为0% 证明如下:
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()的唤醒机制
我们再来详细讲一下当客户端发起连接,第六步第七步的唤醒机制发生了什么:
-
网卡→收到来自客户端的SYN包
-
网卡→通过DMA控制器直接把SYN包写入到内存
-
网卡→向CPU发送硬件中断信号
-
CPU→收到硬件中断信号,跳转至中断处理程序。
-
中断处理函数→简单快速确认一下此SYN包的数据一致性、中断来源等,标记SYN包为软中断 NET_RX_SOFTIRQ 待处理。
-
软中断服务程序→调用TCP协议栈入口函数
-
TCP协议栈→收到SYN包,从链路层传至IP层传至TCP层,SYN包在半连接队列中,TCP层与此包进行三次握手建立连接,建立连接之后将该连接从半连接队列放入全连接队列
-
内核→检查TCP连接队列中的全连接队列:发现队列不为空,立即调用wake_up(),进程则从等待队列移动到CPU调度器的运行队列,状态由TASK_INTERRUPTIBLE变为TASK_RUNNING,进程被唤醒 ⭐
-
CPU调度器根据调度算法挑选进程,当进程被选中后,从CPU调度器的运行队列进入到运行状态,进程状态依旧是TASK_RUNNING(细节:TASK_RUNNING有两种含义,①进程在就绪态,②进程在运行态,因为这两种进程状态没有数值上的变化,只是从正在等待调度变成了正在执行)
-
内核→从全连接队列中取出连接,创建对应的socket并为其分配文件描述符client_fd,client_fd作为accept()的返回值传递回用户空间的应用程序,CPU从内核模式切换回用户模式,进程继续占用CPU执行程序。
accept()唤醒机制链路图↓
recv()阻塞现象、本质、唤醒机制全链路
recv()阻塞的现象
服务器与客户端连接后,只要客户端不发送数据过来,服务器就处于阻塞状态,CPU使用率为0% 证明如下:
recv()阻塞的本质
与accept()一样,recv()同样不是普通的函数,而是系统函数,我们用刚才的方式再讲一遍recv()。当我们调用recv()的时候,实际上发生了这些:
-
(进程TASK_RUNNING)程序→系统调用(syscall),用户模式指令流发生中断。
-
(进程TASK_RUNNING)CPU→从用户模式通过软中断切换至内核模式
-
(进程TASK_RUNNING)内核→检查已连接socket的接受缓冲区是否有数据。
-
(TASK_RUNNING→TASK_INTERRUPTIBLE)内核→若检查结果无数据,把进程状态从TASK_RUNNING切换为TASK_INTERRUPTIBLE,则把进程放入等待队列,此进程挂起,开启阻塞状态 ⭐
-
(进程TASK_INTERRUPTIBLE)内核→调用schedule(),切换到其他进程,该进程CPU使用率为0%
-
(进程TASK_RUNNING)客户端→发送数据包,唤醒机制将进程唤醒,让进程进入Ready状态
-
(进程TASK_RUNNING)CPU调度器→调度该进程,进程进入Running状态,返回用户空间继续执行
recv()的唤醒机制
我们再来详细讲一下当客户端发来数据,第六步第七步的唤醒机制发生了什么:
-
网卡→收到来自客户端的数据包
-
网卡→通过DMA控制器直接把数据包写入到内核的环形缓冲区。(链路层)
-
网卡→给CPU发送硬件中断信号
-
CPU→接受硬件中断信号,跳转至中断处理函数
-
中断处理函数→简单快速确认数据完整性,中断来源,标记数据包为软中断NET_RX_SOFTIRQ待处理。
-
软中断服务程序→调用TCP协议栈入口函数
-
TCP协议栈→从环形缓冲区读取数据,从链路层传至IP层传至TCP层,数据包在TCP层完成可靠的数据传输,把正确的数据放到已连接socket的接收缓冲区里。
-
内核→检查已连接socket的接收缓冲区:发现缓冲区有数据,则立即调用wake_up(),进程则从等待队列移动到CPU调度器的运行队列,状态由TASK_INTERRUPTIBLE变为TASK_RUNNING,进程被唤醒 ⭐
-
CPU调度器→根据调度算法挑选进程,当进程被选中的时候,进程从CPU调度器的运行队列进入到运行状态,进程状态依旧是TASK_RUNNING。
-
内核→将内核socket的接收缓冲区的数据包根据TCP协议拷贝至应用程序的缓冲区,CPU从内核模式切换为用户模式,进程继续占用CPU执行程序。
唤醒链路图↓
结论与思考
为什么操作系统要设计阻塞机制?
理解了accept()和recv()如何阻塞后,我们自然会问: 为什么操作系统要这样设计?让进程直接"卡住"等待,这不是很低效吗?
恰恰相反,阻塞式I/O是一种效率优化,从操作系统视角来看,有以下几个理由
- 避免忙等待
试想如果不阻塞:
// 伪代码:非阻塞的忙等待模式
while (没有连接) {
// 不断循环检查,占用CPU
// 但连接可能几秒甚至几分钟后才来
}
缺陷:CPU时间被白白浪费在无意义的轮询上,其他进程无法使用CPU。
- 提升CPU利用率
进程检查资源不可用 → 立即睡眠 主动调用schedule() → 让出CPU CPU可以运行其他就绪进程 → 提高整体利用率
核心结论
我们亲眼观察到了:
- accept()阻塞:等待新连接时进程睡眠,CPU 0%
- recv()阻塞:等待数据时进程睡眠,CPU 0%
- 单线程瓶颈:当recv()阻塞时,无法返回accept()处理新客户端
所以,在下篇博客中,我将:引入select改造这个echo服务器,支持多客户端!敬请期待!