linux - 网络io到多路复用(马士兵视频学习实验)

本文深入探讨了网络IO模型,从创建TCP连接到多路复用技术的演变,包括BIO、NIO和IO多路复用(select、poll、epoll)。通过实例展示了TCP三次握手、四次挥手过程,并分析了nc命令的系统调用及内核中断。最后,介绍了零拷贝技术,揭示了提高网络通信效率的关键点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背景

这篇博客用来记录网络io到多路复用视频课程的实验。

创建tcp链接后,生成了一个socket并返回文件描述符重定向到8.

# 8代表文件描述符 < 输入 > 输出
$ exec 8<> /dev/tcp/www.baidu.com/80

# $$表示当前解释器的进程
# 进入到文件描述目录下
$ cd /proc/$$/fd
$ pwd
/proc/20222/fd

# 0 标准输入流 1 标准输出流 2 标准错误流 
# 8 指向了一个socket
# 出现socket后,tcp协议已经建立完成
$ ll
total 0
lrwx------. 1 root root 64 Nov 23 11:18 0 -> /dev/pts/1
lrwx------. 1 root root 64 Nov 23 11:18 1 -> /dev/pts/1
lrwx------. 1 root root 64 Nov 23 11:18 2 -> /dev/pts/1
lrwx------. 1 root root 64 Nov 23 15:56 255 -> /dev/pts/1
lrwx------. 1 root root 64 Nov 23 15:56 8 -> socket:[114771]

# 获取百度主页,走应用层协议。
# 1. 使用GET方法获取百度页面
# 2. -e参数为了转换\n换行符
# 3. 1表示标准输出重定向到文件描述符8,当重定向地址不是文件时需要使用&
$ echo -e 'GET / HTTP/1.0\n' 1>& 8

# 获取返回内容
# cat的标准输入0来自于重定向的8
$ cat 0<& 8
HTTP/1.0 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Length: 14615
...

tcp:

面向连接的,可靠的传输协议。3次握手,4次挥手

使用tcpdump来抓包,一次tcp链接所发包的详细过程。

# 1. 创建tcpdump来监听网卡是eth0,端口号是80·
$ tcpdump -nn -i eth0 port 80

# 2. curl www.baidu.com 向百度发送数据请求.
$ curl www.baidu.com

# 3. 收到返回消息
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes

# 1. 本机的随机端口号,发送给baidu服务器固定的默认80端口,一个请求握手的包,Sync
09:32:11.899274 IP 192.168.0.182.52166 > 14.215.177.38.80: Flags [S], seq 2068494884, win 29200, options [mss 1460,sackOK,TS val 3608087326                                ecr 0,nop,wscale 7], length 0

# 2. tcp环节,baidu返回一个握手确认的包, Sync + ack
09:32:11.904843 IP 14.215.177.38.80 > 192.168.0.182.52166: Flags [S.], seq 1922772699, ack 2068494885, win 8192, options [mss 1452,sackOK,n                               op,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,wscale 5], length 0

# 3. tcp环节,本机回复baidu一个握手确认的包,ack
09:32:11.904886 IP 192.168.0.182.52166 > 14.215.177.38.80: Flags [.], ack 1, win 229, length 0

# 此处,握手完成后,tcp链接建立完成,双方开辟物理资源。

# 4. 本地给baidu发送一个数据包,HTTP包的请求头,长度为77, [P]触发内核中断,交给上层服务处理
09:32:11.904951 IP 192.168.0.182.52166 > 14.215.177.38.80: Flags [P.], seq 1:78, ack 1, win 229, length 77: HTTP: GET / HTTP/1.1

# 5. baidu回复本机一个确认, ack, 长度为0
09:32:11.910543 IP 14.215.177.38.80 > 192.168.0.182.52166: Flags [.], ack 78, win 908, length 0

# 6. baidu发送给客户端一个数据包, 200 OK, 长度为2781,[P]触发内核中断,交给上层服务处理
09:32:11.911429 IP 14.215.177.38.80 > 192.168.0.182.52166: Flags [P.], seq 1:2782, ack 78, win 908, length 2781: HTTP: HTTP/1.1 200 OK

# 7. 客户端给baidu发送一个确认,ack, 长度为0
09:32:11.911440 IP 192.168.0.182.52166 > 14.215.177.38.80: Flags [.], ack 2782, win 272, length 0

# 最后,客户端给服务发送一个断开的链接。4次分手过程

# 8. 本地给baidu发送一个断开链接请求
09:32:11.911552 IP 192.168.0.182.52166 > 14.215.177.38.80: Flags [F.], seq 78, ack 2782, win 272, length 0
# 9. baidu给本机发送一个确定, ack
09:32:11.917116 IP 14.215.177.38.80 > 192.168.0.182.52166: Flags [.], ack 79, win 908, length 0
# 10. baidu给本机发送一个断开链接请求
09:32:11.917118 IP 14.215.177.38.80 > 192.168.0.182.52166: Flags [F.], seq 2782, ack 79, win 908, length 0
# 11. 本机给baidu发送一个确认,ack
09:32:11.917144 IP 192.168.0.182.52166 > 14.215.177.38.80: Flags [.], ack 2783, win 272, length 0

nc

# 1. 利用nc创建一个服务端
$ nc -l 8080

# 2. 服务端发送请求
$ nc localhost 8080

# 实验中,先启动一个服务端,不开启客户端
$ nc -l 8080

# 1. 查看nc的进程下的描述符(这里有两个文件描述符???)
$ ps -ef | grep nc
$ ll /proc/XXXX/fd
lrwx------ 1 root root 64 Nov 24 10:03 0 -> /dev/pts/2
lrwx------ 1 root root 64 Nov 24 10:03 1 -> /dev/pts/2
lrwx------ 1 root root 64 Nov 24 10:03 2 -> /dev/pts/2
lrwx------ 1 root root 64 Nov 24 10:03 3 -> socket:[751943]     #

# 2. 目前,并没有创建客户端的链接,但是出现了两个socket
# 此处的一个socket是用来监听(并且仅有服务端才有监听的概念)

# 3. 新创建了一个客户端,4号描述符代表链接成功了(前两个文件描述符消失,创建出一个新的文件描述符)
$ ll /proc/XXXX/fd
lrwx------ 1 root root 64 Nov 24 10:03 0 -> /dev/pts/2
lrwx------ 1 root root 64 Nov 24 10:03 1 -> /dev/pts/2
lrwx------ 1 root root 64 Nov 24 10:03 2 -> /dev/pts/2
lrwx------ 1 root root 64 Nov 24 10:03 3 -> socket:[751943]     # 
lrwx------ 1 root root 64 Nov 24 10:03 4 -> socket:[751944]

strace

# -ff 用来抓取将要执行的进程线程的对内核的调用
# -o 将所有的系统调用,放到文件里面
$starce -ff -o out nc -l 8080

# 1. 查看nc -l 8080所调用的内核命令,这里仅看一部分内容
...
socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) = 3					        # 创建一个socket并返回一个文件描述符3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [1], 4) = 0
bind(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0                                               # 为套接字分配名称
listen(3, 10)                           = 0                             # 监听文件描述符3
fcntl(3, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(3, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 4							# 创建一个socket并返回一个文件描述符4
setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(4, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
                                                                        # 为套接字分配名称
listen(4, 10)                           = 0                             # 监听文件描述符4
fcntl(4, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
select(5, [3 4], [], NULL, NULL											# 程序卡在select不向下继续执行了

# 2. 新开一个窗口,创建一个客户端
$ nc localhost 8080

# 3. 查看out文件的实时输出
$ tail -f out.XXXX
select(5, [3 4], [], NULL, NULL)        = 1 (in [3])
accept(3, {sa_family=AF_INET6, sin6_port=htons(53522), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [128->28]) = 5
close(3)                                = 0
close(4)                                = 0
fcntl(5, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(5, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
select(6, [0 5], [], NULL, NULL

# 从上面的结果看,能看到select继续往下走
# 并获取了文件描述符3的监听
# 关闭了文件描述符3,4。然后对文件描述符5进行了阻塞。

# 4. 在客户端发送一个hello发送给服务端
# 查看更新后的文件
select(6, [0 5], [], NULL, NULL)        = 1 (in [5])
recvfrom(5, "hello\n", 8192, 0, NULL, NULL) = 6				# 从文件描述符5中读取hello
write(1, "hello\n", 6)                  = 6                 # 向标准输出中输出1
select(6, [0 5], [], NULL, NULL                             # 进入轮询

# 5. 在服务端发送一个world发送给客户端
select(6, [0 5], [], NULL, NULL)        = 1 (in [0])
read(0, "world\n", 8192)                = 6                 # 从标准输入流中读到world
fcntl(5, F_GETFL)                       = 0x802 (flags O_RDWR|O_NONBLOCK)
fcntl(5, F_SETFL, O_RDWR)               = 0
sendto(5, "world\n", 6, 0, NULL, 0)     = 6                 # 发送world到文件描述符5
fcntl(5, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(5, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
select(6, [0 5], [], NULL, NULL                             # 继续轮询等待

网络通信的发展

初始阶段BIO,阻塞型IO

当程序作为服务端发起网络通信时,首先会创建一个socket并返回一个文件描述符fd,然后调用bind,调用listen,在进而调用accpet,最后等待客户端连接。

如果客户端,经历了3次握手之后,完成分配资源。

服务端的accpet会返回给客户端一个文件描述符4(递增+1),并调用read函数读取文件描述符4。

如果客户端没有发送数据内容,那么服务端将一直处于阻塞状态。

如果在阻塞的时候,新创建了一个客户端,与内核建立完成3次握手,此时服务端应当返回一个文件描述符给新的客户端。但是,由于服务端在阻塞,所以无法返回新的描述符给新的客户端。

弊端,长时间阻塞

多线程模型

为了解决BIO的阻塞情况,在服务端接受到链接请求后,并返回给一个文件描述符给客户端。然后,新启动一个线程调用read函数,来监听新的文件描述符的调用,此处是为阻塞。

当然,如果客户端越多启动的线程也随之越多。

线程的本质:

线程的创建,需要调用内核的clone,调用内核往往需要80软中断。开辟资源很慢,cpu负载比较高。

弊端,过多线程的创建

进阶阶段NIO,非阻塞型NIO

内核的更新,内核将文件描述符设置称为非阻塞的。

从启动流程,开始梳理。程序服务端启动,创建一个socket,然后调用bind,listen,accept。当内核程序将文件描述符设置为非阻塞后,服务端可以启动一个while循环,循环内accept(fd3)。

当客户端创建后,服务端返回一个文件描述符fd4给客户端,然后服务端循环读fd4,当读取不出内容时触发一个读空返回。

此时,如果有新的客户端创建,服务端收到信息返回一个新的文件描述符,然后进入到while循环中,循环读取文件内容。

弊端,如果有1w个客户端,但每一个都要调用一次read,每次都要触发80中断。

IO多路复用,select

内核中多了一个select的系统调用。服务端为了降低循环遍历read的系统调用,例如1w次降低为1次。服务端,此时每while一次仅需要调用一次select,遍历由内核来做并返回给服务端一个文件描述符,服务端只需要单独去读返回的文件描述符即可。内核来做O(n)的一万次遍历。

弊端:内核O(n),主动遍历

IO多路复用,poll

网卡和cpu的通信过程,当数据到达时会有高低电平,内核首先会将数据信息放到buffer中,然后触发cpu的中断,然后cpu根据中断回调Call Backup。

内核不再主动遍历1w个文件描述符,而是去查看有谁触发过中断,并把中断放在一个集合中,返回给服务端。

IO多路复用,epoll

服务端创建时,会创建一个socket得到一个文件描述符,然后bind,listen。此时,会调用epoll_create,内核空间会创建出一个文件描述符fd8,然后利用epoll_ctl将文件描述符fd6放到文件描述符fd8中,然后调用epoll_wait等待文件描述符fd8。

创建一个客户端后,accept接受到了文件描述符fd3,epoll_ctl将文件描述符fd3放入到文件描述符fd8中,然后继续进入到epoll_wait的等待中。

程序首先获得创建socket的文件描述符fd6,然后内核调用epoll得到文件描述符fd8,同时将fd8和fd6进程绑定,最后进入到epoll_wait的等待中。

当创建一个客户端后,建立完成tcp连接后,内核epoll机制通过fd6,它所关心事件accpet,创建客户端时会触发这个事件。此时,内核会将fd6挪到一个类似于数组当中,当发现是一个accpet,那么epoll就会接受这个客户端并返回一个文件描述符,fd3,epoll同时也会将fd3放入到内核当中。然后,继续wait8,一个是对fd3监听可读事件,一个是看fd6是否有accept事件。

epoll_ctl会将描述符常驻到内核的内存区域中,直到写释放关闭。

零拷贝
直接内存
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值