Linux 网络系统
1.网络模型
为了使得多种设备能通过⽹络相互通信,和为了解决各种不同设备在⽹络互联中的兼容性问题,国际标标准化组织制定了开放式系统互联通信参考模型(pen System InterconnectionReference Model),也就是 OSI ⽹络模型,该模型主要有 7 层,第一层:应用层、表示层、会话层、传输层、网络层、数据链路层以及物理层。 每一层负责的职能都不同,如下:
- 应用层:负责给应用程序提供统一的接口
- 表示层:负责把数据转化成兼容另外一个系统能识别的格式。
- 会话层:负责建立、管理、和终止实体之间的通信会话。
- 传输层:负责端到端的数据传输
- 网络层:负责数据的路由、转发、分片
- 数据链路层,负责数据的封帧和差错检查、以及 mac 地址
- 物理层:负责在物理网络中传输数据帧。
- 由于 OSI 模型实在太复杂,提出的也只是概念理论上的层,并没有提供具体的实现⽅案。事实上,我们⽐较常⻅,也⽐较实⽤的是四层模型,即 TCP/IP ⽹络模型,Linux 系统正是按照这套⽹络模型来实现⽹络协议栈的。 1.应用层:负责向用户提供一组应用程序 HTTP 、DNS、FTP 2.传输层:负责端到端的通信 3.网络层:负责网络包的封装、分片、路由、转发 IP、ICMP 4.网络接口层:负责网络包在物理网络中的传输,比如网络包的封帧、MAC 寻找址,差错检测、以及通过网卡传输网络帧。
2.Linux ⽹络协议栈
我们可以把⾃⼰的身体⽐作应⽤层中的数据,打底⾐服⽐作传输层中的 TCP 头,外套⽐作⽹络层中 IP 头,帽⼦和鞋⼦分别⽐作⽹络接⼝层的帧头和帧尾。在冬天这个季节,当我们要从家⾥出去玩的时候,⾃然要先穿个打底⾐服,再套上保暖外套,最后穿上帽⼦和鞋⼦才出⻔,这个过程就好像我们把 TCP 协议通信的⽹络包发出去的时候,会把应⽤层的数据按照⽹络协议栈层层封装和处理。从下⾯这张图可以看到,应⽤层数据在每⼀层的封装格式。
其中:
- 传输层,给应⽤数据前⾯增加了 TCP 头;
- ⽹络层,给 TCP 数据包前⾯增加了 IP 头;
- ⽹络接⼝层,给 IP 数据包前后分别增加了帧头和帧尾这些新增和头部和尾部,都有各⾃的作⽤,也都是按照特定的协议格式填充,这每⼀层都增加了各⾃的协议头,那⾃然⽹络包的⼤⼩就增⼤了,但物理链路并不能传输任意⼤⼩的数据包,所以在以太⽹中,规定了最⼤传输单元(MTU)是 1500 字节,也就是规定了单次传输的最⼤ IP 包⼤⼩。当⽹络包超过 MTU 的⼤⼩,就会在⽹络层分⽚,以确保分⽚后的 IP 包不会超过 MTU ⼤⼩,如果 MTU 越⼩,需要的分包就越多,那么⽹络吞吐能⼒就越差,相反的,如果 MTU 越⼤,需要的分包就越⼩,那么⽹络吞吐能⼒就越好。知道了 TCP/IP ⽹络模型,以及⽹络包的封装原理后,那么 Linux ⽹络协议栈的样⼦,想必猜到了⼤概,它其实就类似于 TCP/IP 的四层结构:
第一层: 应用程序 第二层: 系统调用 (LVS) 第三层: Socket (TCP)(UDP) (ICMP) 第四层: IP (ARP) 第五层: MAC 网卡驱动程序 网卡
- 应用系统通过系统调用来跟 Socket 层进行数据交互
- Socket 层下面就是传输层、网络层、和网络接口层
- 最下面是网卡驱动程序和硬件网卡设备
3.Linux 接收⽹络包的流程
⽹卡是计算机⾥的⼀个硬件,专⻔负责接收和发送⽹络包,当⽹卡接收到⼀个⽹络包后,会通过 DMA 技术,将⽹络包放⼊到 Ring Buffer,这个是⼀个环形缓冲区。那接收到⽹络包后,应该怎么告诉操作系统这个⽹络包已经到达了呢?最简单的⼀种⽅式就是触发中断,也就是每当⽹卡收到⼀个⽹络包,就触发⼀个中断告诉操作系统。但是,这存在⼀个问题,在⾼性能⽹络场景下,⽹络包的数量会⾮常多,那么就会触发⾮常多的中断,要知道当 CPU 收到了中断,就会停下⼿⾥的事情,⽽去处理这些⽹络包,处理完毕后,才会回去继续其他事情,那么频繁地触发中断,则会导致 CPU ⼀直没玩没了的处理中断,⽽导致其他任务可能⽆法继续前进,从⽽影响系统的整体效率。所以为了解决频繁中断带来的性能开销。
Linux 内核在 2.6 版本中引⼊了 NAPI 机制,它是混合「中断和轮询」的⽅式来接收⽹络包,它的核⼼概念就是不采⽤中断的⽅式读取数据,⽽是⾸先采⽤中断唤醒『接收数据的』服务程序,然后 poll 的⽅法来轮询数据。 Tips:中断用来唤醒『接收数据的』服务程序,poll 的方式来轮询处理数据。
过程? 网络包到达网卡,网卡发起中断,于是执行网卡硬件的中断处理程序,中断程序执行完毕,执行『暂时屏蔽中断』,然后唤醒『软中断』来轮询数据,直到没有新数据时才恢复中断,这样一次中断处理多个网络包,于是降低网卡中断带来的性能开销。
软中断怎么执行? 从 RingBuffer 拷贝数据的到内核 struct sk_buff 缓冲区中,从而可以作为一个网络包交给网络协议栈逐层处理。⾸先,会先进⼊到⽹络接⼝层,在这⼀层会检查报⽂的合法性,如果不合法则丢弃,合法则会找出该⽹络包的上层协议的类型,⽐如是 IPv4,还是 IPv6,接着再去掉帧头和帧尾,然后交给⽹络层。到了⽹络层,则取出 IP 包,判断⽹络包下⼀步的⾛向,⽐如是交给上层处理还是转发出去。当确认这个⽹络包要发送给本机后,就会从 IP 头⾥看看上⼀层协议的类型是 TCP 还是 UDP,接着去掉 IP 头,然后交给传输层。传输层取出 TCP 头或 UDP 头,根据四元组「源 IP、源端⼝、⽬的 IP、⽬的端⼝」 作为标识,找出对应的 Socket,并把数据拷⻉到 Socket 的接收缓冲区。最后,应⽤层程序调⽤ Socket 接⼝,从内核的 Socket 接收缓冲区读取新到来的数据到应⽤层。 ⾄此,⼀个⽹络包的接收过程就已经结束了。
4.Linux 发送⽹络包的流程?
1- 用户态切换内核态,把应用数据拷贝到 Socket 发送缓冲区。 应⽤程序会调⽤ Socket 发送数据包的接⼝,由于这个是系统调⽤,所以会从⽤户态陷⼊到内核态中的 Socket 层,Socket 层会将应⽤层数据拷⻉到 Socket 发送缓冲区中。 2- 从 Socket 缓冲区取出数据包,然后传输层加+(TCP 头)/(UDP 头),然后网络层+(IP 头)、查路由表、得下一跳的 IP、分片 。准备好之后,那么就触发中断告诉网卡驱动程序这里有网络包要发送。然后网卡驱动程序通过 DMA,从发包队列中读取网络包,然后把这些放到硬件网卡的队列中间,然后物理网卡再把它发送出去 按照 TCP/IP 协议栈从上到下逐层处理。如果使⽤的是 TCP 传输协议发送数据,那么会在传输层增加 TCP 包头,然后交给⽹络层,⽹络层会给数据包增加 IP 包,然后通过查询路由表确认下⼀跳的 IP,并按照 MTU ⼤⼩进⾏分⽚。分⽚后的⽹络包,就会被送到⽹络接⼝层,在这⾥会通过 ARP 协议获得下⼀跳的 MAC 地址,然后增加帧头和帧尾,放到发包队列中。
5.Tips
电脑与电脑之间通常都是通话⽹卡、交换机、路由器等⽹络设备连接到⼀起,那由于⽹络设备的异构性,国际标准化组织定义了⼀个七层的 OSI ⽹络模型,但是这个模型由于⽐较复 杂,实际应⽤中并没有采⽤,⽽是采⽤了更为简化的 TCP/IP 模型,Linux ⽹络协议栈就是按照了该模型来实现的。 TCP/IP 模型主要分为应⽤层、传输层、⽹络层、⽹络接⼝层四层,每⼀层负责的职责都不同,这也是 Linux ⽹络协议栈主要构成部分。当应⽤程序通过 Socket 接⼝发送数据包,数据包会被⽹络协议栈从上到下进⾏逐层处理后,才会被送到⽹卡队列中,随后由⽹卡将⽹络包发送出去。⽽在接收⽹络包时,同样也要先经过⽹络协议栈从下到上的逐层处理,最后才会被送到应⽤程序。
零值拷贝?
DMA?
Direct Memory Access 直接访问内存技术 在 I/O 设备和内存进行数据传输的时候,数据传输的工作交给 DMA 控制器,CPU 不再参与任何和数据相关的搬运工作。
- CPU 发出对应的指令给磁盘控制器,然后返回;
- 磁盘控制器开始准备数据,然后把数据放到磁盘内部的缓冲,然后产生一个中断;
- CPU 收到中断信号,然后把磁盘缓冲区的数据一次一个字节一个的读进自己的寄存器,然后把寄存器的数据写入到内存。在数据传输的期间,CPU 无法执行其他的任务。
- 在数据的传输期间。CPU 是无法做其他的事情的。
问题:简单的搬运⼏个字符数据那没问题,但是如果我们⽤千兆⽹卡或者硬盘传输⼤量数据的时候,都⽤ CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。在进⾏ I/O 设备和内存的数据传输的时候,数据搬运的⼯作全部交给 DMA 控制器,⽽ CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
过程?
-
用户调用 read(),向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区,进程进入阻塞状态。
-
操作系统收到请求后,进一步将 I/O 请求发送 DMA ,然后 CPU 继续执行自己的任务。
- DMA 把请求发送给磁盘,
- 磁盘收到 DMA 的请求,将磁盘的数据读取到磁盘控制器的缓冲区,磁盘控制器的缓冲区满的时候,向 DMA 发出信号,告诉 DMA 自己已经满了。
- DMA 收到磁盘的信号,将磁盘控制器缓冲区的数据拷贝到内核的缓冲区,此时不占用 CPU。
- DMA 读取链足够多的数据的时候,这个时候,发送中断信号给 CPU,CPU 把数据从内核拷贝到用户空间,系统调用返回。
步骤: 磁盘拷贝数据到磁盘控制器的缓冲区——DMA 拷贝磁盘控制器的缓冲区的数据到内核——CPU 拷贝内核的数据到用户空间。(CPU 拷贝的前提是 DMA 告诉它内核空间已经有足够多的数据)
注意点: CPU 告诉 DMA 区复制数据 DMA 告诉 CPU 已经赋值的足够多了 早期的 DMA 在主板,现在每个 I/O 设备都有自己的 DMA 控制器
传统的⽂件传输?
如果服务端要提供⽂件传输的功能,我们能想到的最简单的⽅式是:将磁盘上的⽂件读取出来,然后通过⽹络协议发送给客户端。
传统的 I/O 工作设备的方式是:
用户数据的读取和写入——> 用户空间到内核空间的数据的读取和写入
内核空间数据的读取和写入——> 调用操作系统层面的 I/O 接口从磁盘读取和写入。
⾸先,期间共发⽣了 4 次⽤户态与内核态的上下⽂切换,因为发⽣了两次系统调⽤,⼀次是 read() ,⼀次是 write() ,每次系统调⽤都得先从⽤户态切换到内核态,等内核完成任务后,再从内核态切换回⽤户态。
上下⽂切换到成本并不⼩,⼀次切换需要耗时⼏⼗纳秒到⼏微秒,虽然时间看上去很短,但是在⾼并发的场景下,这类时间容易被累积和放⼤,从⽽影响系统的性能。其次,还发⽣了 4 次数据拷⻉,其中两次是 DMA 的拷⻉,另外两次则是通过 CPU 拷⻉的,下⾯说⼀下这个过程:
- 第⼀次拷⻉,把磁盘上的数据拷⻉到操作系统内核的缓冲区⾥,这个拷⻉的过程是通过 DMA 搬运的。
- 第⼆次拷⻉,把内核缓冲区的数据拷⻉到⽤户的缓冲区⾥,于是我们应⽤程序就可以使⽤这部分数据了,这个拷⻉到过程是由 CPU 完成的。
- 第三次拷⻉,把刚才拷⻉到⽤户的缓冲区⾥的数据,再拷⻉到内核的 socket 的缓冲区⾥,这个过程依然还是由 CPU 搬运的。
- 第四次拷⻉,把内核的 socket 缓冲区⾥的数据,拷⻉到⽹卡的缓冲区⾥,这个过程⼜是由 DMA 搬运的。
如何优化⽂件传输的性能?
操作设备的动作要交给内核去完成。(用户没有这个权限)一次操作系统的调用,发生两次上下文的切换
读取磁盘数据的时候,之所以要发⽣上下⽂切换,这是因为⽤户空间没有权限操作磁盘或⽹卡,内核的权限最⾼,这些操作设备的过程都需要交由操作系统内核来完成,所以⼀般要通过内核去完成某些任务的时候,就需要使⽤操作系统提供的系统调⽤函数。⽽⼀次系统调⽤必然会发⽣ 2 次上下⽂切换:⾸先从⽤户态切换到内核态,当内核执⾏完任务后,再切换回⽤户态交由进程代码执⾏。
如何减少「数据拷⻉」的次数?
文件传输的过程中,在用户空间不会对文件进行再加工。所以没有必要把数据搬运到内核空间。
传统的⽂件传输⽅式会历经 4 次数据拷⻉,⽽且这⾥⾯,「从内核的读缓冲区拷⻉到⽤户的缓冲区⾥,再从⽤户的缓冲区⾥拷⻉到 socket 的缓冲区⾥」,这个过程是没有必要的。因为⽂件传输的应⽤场景中,在⽤户空间我们并不会对数据「再加⼯」,所以数据实际上可以不⽤搬运到⽤户空间,因此⽤户的缓冲区是没有必要存在的。
如何实现零拷⻉?
本质:把内核空间的数据映射到用户缓冲区
零拷⻉技术实现的⽅式通常有 2 种: mmap + write sendfile 下⾯就谈⼀谈,它们是如何减少「上下⽂切换」和「数据拷⻉」的次数。
mmmap 替换 Read()
mmap + write 在前⾯我们知道, read() 系统调⽤的过程中会把内核缓冲区的数据拷⻉到⽤户的缓冲区⾥,于是为了减少这⼀步开销,我们可以⽤ mmap() 替换 read() 系统调⽤函数。
buf = mmap(file, len);
write(sockfd, buf, len);
mmap() 系统调⽤函数会直接把内核缓冲区⾥的数据「映射」到⽤户空间,这样,操作系统
内核与⽤户空间就不需要再进⾏任何的数据拷⻉操作。
具体过程?
- 应用进程调用 mmap()后,DMA 把磁盘数据拷贝到内核缓冲区
- 然后,应用系统和操作系统共享内核缓冲区
- 应用进程再调用 write(),操作系统直接把内核缓冲数据拷贝到 socket 缓冲区。(再内核态由 CPU 来搬运数据)
- 最后,内核的 Socket 缓冲区的数据,拷贝到网卡缓冲区,这个过程由 DMA 搬运。
但这还不是最理想的零拷⻉,因为仍然需要通过 CPU 把内核缓冲区的数据拷⻉到 socket 缓冲区⾥,⽽且仍然需要 4 次上下⽂切换,因为系统调⽤还是 2 次。
sendfile 在 Linux 内核版本 2.1 中,提供了⼀个专⻔发送⽂件的系统调⽤函数 sendfile() ,函数形 式如下:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是⽬的端和源端的⽂件描述符,后⾯两个参数是源端的偏移量和复制数据的⻓度,返回值是实际复制数据的⻓度。⾸先,它可以替代前⾯的 read() 和 write() 这两个系统调⽤,这样就可以减少⼀次系统调⽤,也就减少了 2 次上下⽂切换的开销。
它的前两个参数分别是⽬的端和源端的⽂件描述符,后⾯两个参数是源端的偏移量和复制数据的⻓度,返回值是实际复制数据的⻓度。 ⾸先,它可以替代前⾯的 read() 和 write() 这两个系统调⽤,这样就可以减少⼀次系统调⽤,也就减少了 2 次上下⽂切换的开销。其次,该系统调⽤,可以直接把内核缓冲区⾥的数据拷⻉到 socket 缓冲区⾥,不再拷⻉到⽤户态,这样就只有 2 次上下⽂切换,和 3 次数据拷⻉