在开发应用时,我们使用 socket 实现网络数据的收发。以tcp为例,server端通过 socket, bind, listen来创建服务端,然后通过 accept接收客户端连接;客户端通过 socket和 connect系统调用来创建客户端。用于数据收发的系统调用包括 send, recv, sendto, recvfrom等。除了上述系统调用之外,另外还有多路复用技术 select,poll, epoll,也常常在网络应用中使用。
// tcp 服务端
int fd = socket(AF_INET, SOCK_STREAM, 0);
bind(listen_fd,(struct sockaddr *)&server_addr, sizeof(server_addr))}
listen(listen_fd, 5);
int accetp_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client);
// tcp 客户端
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
connect(sock_fd, (struct sockaddr *)&addr_serv,sizeof(struct sockaddr));
在做应用开发时,使用上述系统调用比较简单和简洁。对于 linux 网络来说,这些系统调用只是冰山一角,在 linux 网络收发包的过程中,报文要经过协议栈,网卡驱动以及网卡硬件本身的处理。
1网络分层
如下是 linux 收发包示意图:
上图中包括的环节有网卡,网卡驱动,网络层,传输层,socket 层,应用层。收包时报文的流动方向是从下到上,发包时反之。其中网络应用工作在用户态;传输层、网络层以及网卡驱动工作在内核态;socket 层是 linux 提供的系统调用,是用户态和内核态的桥梁。应用层、socket、传输层网络层是软件部分,网卡是硬件部分,网卡驱动是软件与硬件的桥梁。
1.1网卡
dma
dma即直接内存访问,直接内存访问的意思是硬件来访问内存,数据读写过程中不需要cpu参与。在收包方向,网卡收到包之后,会通过dma引擎将数据保存在主机内存中,然后将收包事件通知给 cpu,之后cpu便可以处理这个报文;在发包方向,cpu将报文放到对应的内存buffer之后,将信息通知网卡,之后网卡便会通过dma引擎将buffer中的数据取出并发送出去。dma过程中, cpu和网卡之间只需要一些通知指令,cpu不需要参与数据的读写过程。设想一下,如果没有dma, 那么在收发包时就需要cpu不断地从网卡中读写数据进行收发包,这种效率会非常低下。
环形队列
环形队列中的元素称为bd(buffer descriptor),是一个描述符,该描述符中存储的并不是真正的报文数据,而是报文的元数据。考虑最简单的情况,bd中应包括一个buffer指针,指向实际存储数据的内存地址,另外还有一个变量是内存buffer的长度。实际中bd的结构会更复杂,比如一个报文太长,在一个buffer中存不下,这样就需要有其它的信息,比如报文存了几个buffer;另外网卡收到数据之后,并不一定从buffer的开始处存储报文数据,这样就需要在buffer的开始处留出一段空间,有时还需要在buffer的末尾处保留一段空间。环形队列是cpu软件和网卡硬件通信的桥梁,网卡硬件和cpu都可以访问环形队列。
struct buffer_desc {
char *buffer;
int length;
};
1.2网卡驱动
网卡驱动与网卡通信的方式有两种:中断和轮询,这也是软件和硬件交互的两种方式。以收包为例,对于中断方式,即网卡收到数据包之后,会触发一个中断,然后网卡驱动注册的中断处理程序便会从网卡中读取数据并做后续的处理;轮询方式是cpu不断地查询网卡的相关寄存器,判断当前有没有新的包需要处理,有则处理,没有则这次轮询空转。当流量较小的时候,在没有数据时,轮询方式会导致cpu空跑,浪费cpu资源,所以轮询方式适用于流量较大的场景。
中断提高实时性(因为在linux中,中断的优先级最高,高于线程和软中断,中断到来之后会立即得到响应),但是如果在流量比较大的场景,网卡产生中断的速度就会非常快,这样会让cpu为处理中断事件浪费所有的时间。
轮询方式适用于网络流量比较大的场景,比如路由器,路由器是通信专用设备,功能比较单一,核心功能就是处理网络流量,所以可以使用轮询方式(即使短时间内流量小导致cpu空转也不会造成其它影响,因为路由器上也没有其它任务来抢cpu)。DPDK中就使用了轮询方式。
linux内核中提供了napi方式,该方式既不是纯中断方式,也不是纯轮询方式,而是集合了中断和轮询,发挥了两种方式的优点。当中断到来时,napi便会关中断,然后处理包,因为关了中断,如果在napi处理包的过程中又来了新的报文,napi处理过程就不会被打断。napi每一次处理均是批量处理,不是处理一个报文就返回,也就是说在napi处理过程中,虽然新到的数据包没有中断,napi也会处理它。
napi的退出机制:napi并不是一直在轮询,当接收队列中的报文都被处理完毕,当然就会返回;但是如果接收队列中的报文非常多,短时间内没有处理完,会一直处理报文吗 ?不会的,为了防止处理网络报文的任务一直占用这cpu,napi有主动退出机制,一个是时间维度,一个是报文数量维度,时间维度是 napi 处理时间超过某个时间便会返回,未处理的报文等到下次调度时再次处理,数量维度是处理的报文数量达到一定的数量时,也会主动返回,未处理的包等到下次调度时再次处理。
napi —— linux 网卡驱动收包机制-CSDN博客
1.3网络层
网络层,最常用的是ip。
ip层主要的作用即路由,在收包方向上根据目的ip决定报文是接收并上传到传输层(目的ip是本地 ip),还是转发(目的ip不是本地ip)。
另外 ip 层也需要处理分片,之所以处理分片,是因为报文的长度大于 mtu。
linux中的netfilter功能也是在ip层实现。
netfilter_netfilter模块报文处理-CSDN博客
1.4传输层
传输层包括两个协议,tcp和udp。
tcp有如下3个特点:
1.4.1面向连接
tcp的服务端和客户端通信之前需要建立连接,建立连接之后才可以收发数据,收发数据结束之后需要断开连接。建立连接过程需要3次握手,断开连接过程中需要4次挥手。
tcp断开连接时,可能是4次挥手,也可能是3次挥手。3次挥手的时候,第二次挥手和第三次挥手合并到了一个报文中。
1.4.2延时ack
如果收到数据之后就立即发送ack的话,那么会导致网络带宽利用率低,因为ack报文没有有效数据,只有tcp, ip协议头。
延时ack是在收到数据之后不立即发送ack,而是等待一定的时间(最小等待时间是40ms,最大等待时间是200ms) 再发送ack;如果在等待期间,本端有数据要发送,那么ack也会跟着数据一块发送出去,不会受40ms和200ms的约束,比如收到数据之后开始等待,等待了10ms,还没到最小等待时间 40ms,如果这个时候要发送数据,那么就会停止等待(停止定时器),ack随数据一块发送出去。
延时ack通过定时器来实现,收到数据之后启动一个定时器,定时器的超时时间,最小是40ms,最大是200ms,如果定时器超时,就会发送ack;如果在定时器超时之前,ack随着本端数据一块发送了出去,那么定时器就会被取消。
延时ack的最小等待时间和最大等待时间,用两个宏来表示。
#define TCP_DELACK_MAX ((unsigned)(HZ/5)) /* maximal time to delay before sending an ACK */
#define TCP_DELACK_MIN ((unsigned)(HZ/25)) /* minimal time to delay before sending an ACK */
因为延时ack的存在,被动关闭的一方在收到FIN报文的时候并不会立即发送ack,而是会有一定的延时。如果在等待的这段时间之内,没有数据要发送,本端也没有做其它动作(比如关闭连接),那么超时之后就会单独发送一个ack;如果本端还有数据发送,那么ack就会跟着数据一块发送出去。这种情况下就是4次挥手。
收到FIN之后,说明对端不会再发送新的数据到来,如果这个时候,本端接收完缓存的数据之后也调用了close将连接关闭,并且这个时候延时ack定时器还没有超时,那么ack就会和FIN一块发送出去。这种情况下就是3次挥手。
1.4.3FIN
当tcp连接的一方要关闭本端的发送时便会向对端发送FIN报文,FIN报文通过函数tcp_send_fin发送。
如果本端调用了close或者shutdown(fd, SHUT_WR) 之后就表示本端已经停止了发送,如果之后再调用send函数,那么会受到SIGPIPE信号,在这种情况下应用会被SIGPIPE杀死,如果不想应用被直接杀死,可以在send函数的最后一个参数中带上 MSG_NOSIGNAL 标志,这样就不会被杀死,而是返回错误码 “Broken pipe”。
3 次挥手:
// server 端
// 收到 FIN 之后,说明对端已经停止了发送数据
// 这个时候 read 返回 0 之后说明没有数据需要处理
// 此时立即关闭连接,调用 close 时会发送 FIN
// 因为延时 ack 还没有超时, 所以 ACK 和 FIN 一块发送出去
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#define PORT (12345)
#define DATA_MAX_LEN 1024
int main(int argc, char *argv[])
{
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if(listen_fd < 0)
{
printf("create listen socket error : %s\n", strerror(errno));
return -1;
}
printf("tcp server listen fd: %d\n", listen_fd);
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(PORT);
if(bind(listen_fd, (struct sockaddr *)(&server_addr), sizeof(struct sockaddr)) < 0)
{
printf("bind error: %s\n", strerror(errno));
return -1;
}
if(listen(listen_fd, 32))
{
printf("listen error: %s\n\a", strerror(errno));
return -1;
}
struct sockaddr_in client_addr;
socklen_t client_addrlen = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_addrlen);
if(client_fd < 0) {
printf("accept error: %s\n\a", strerror(errno));
return -1;
}
printf("tcp server accept fd: %d\n", client_fd);
char data[DATA_MAX_LEN] = {0};
while(1) {
int n = read(client_fd, data, DATA_MAX_LEN);
if(n < 0) {
printf("read error: %s\n\a", strerror(errno));
break;
} else if(n == 0) {
// 读取数据之后立即关闭连接,这个时候延时 ack 还没有超时,
// close 会导致本端也会向对端发送 FIN,所以此时 ACK 和 FIN 就会合并到一个报文中发送
// 如果打开睡眠函数,让睡眠时间超过延时 ack 的时间,那么延时 ack 超时之后便会发送 ack
// close 时的 FIN 会单独发送
// usleep(220 * 1000);
int value = 1;
// 设置 QUICKACK, 的时候会立即发送一个 ack
// QUICKACK 模式不是永久生效的,如果要想要保证每次收到数据之后都立即返回 ack,需要在每次收到数据之后,都要做一次这个设置
// if (setsockopt(client_fd, IPPROTO_TCP, TCP_QUICKACK, (char *)&value, sizeof(int)) < 0) {
// printf("set quick ack error\n");
// return -1;
// }
close(client_fd);
printf("client fd closed\n");
break;
}
data[n] = 0;
printf("received %d bytes: %s\n", n, data);
}
close(listen_fd);
return 0;
}
// client 端
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <unistd.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#define PORT (12345)
int main(int argc, char *argv[])
{
int connect_fd = socket(AF_INET, SOCK_STREAM, 0);
if(connect_fd < 0)
{
printf("create socket error: %s\n", strerror(errno));
return -1;
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(PORT);
if(connect(connect_fd, (struct sockaddr *)(&server_addr), sizeof(server_addr)) < 0)
{
printf("connect error: %s\n", strerror(errno));
return -1;
}
char data[64] = "hello, server";
int ret = send(connect_fd, data, strlen(data), 0);
if(ret != strlen(data)) {
printf("send data error: %s\n", strerror(errno));
return -1;
}
sleep(2);
close(connect_fd);
return 0;
}
这是使用 tcpdump 抓包的结果截图,从下图中可以看到
第 6 个报文是客户端向服务端发送的 FIN,第一次挥手
第 7 个报文是服务端向客户端发送的 ACK + FIN 报文,第二次挥手
第 8 个报文是客户端向服务端发送的 ACK, 第三次挥手
4 次挥手:
如果我们将上述服务端的代码做一下修改,把第 69 行的 usleep(220 * 1000) 打开,这样的话当服务端 read 返回 0 之后不立即关闭 fd,而是延时一段时间再调用 close。延时时间是 220ms,超过了延时 ack 的最大时间,这样的话在 usleep() 期间,本端就会返回 ACK。之后调用 close,向对端发送 FIN。这样的话,第二次挥手和第 3 次挥手就不会合并。
抓包结果如下图所示
第 6 个报文是客户端向服务端发送的 FIN,第一次挥手
第 7 个报文是服务端向客户端发送的 ACK,第二次挥手,从第 6 和第 7 个报文的时间可以看出来,延时 ack 这次的延时在 45ms 左右
第 8 个报文,服务端向客户端发送的 FIN,第三次挥手
第 9 个报文,客户端向服务端发送的 ACK,第四次挥手
如果把上述服务端的代码做一下修改,把第 74 - 77 行的代码打开,这样在设置 quickack 的时候会发送 ack。然后再调用 close,发送 FIN,这样也是 4 次挥手。
从下图可以看出,第 6、7、8、9 报文是 4 次挥手的报文。
1.4.4字节流
tcp 是字节流协议,也就是说在 tcp 这一层,收发数据的边界与用户收发数据的边界可能会出现不一致。比如用户发送了一个长度为 2000 字节的报文,tcp 发送的时候可能会分两次发送,一次发送 1000; 如果用户先后发送了两个报文,长度分别是 1000 字节和 2000 字节,tcp 也可能第一次发送 500字节的报文,第二次发送 1000 字节的报文,第 3 次发送 1500 字节的报文, tcp 分 3 次包数据发送出去。
1.4.5可靠
tcp 最大的特点就是可靠性。丢包,乱序是导致不可靠的原因,tcp 可以通过序列号,重传等技术解决这样的问题,从而保证传输是可靠的。
从上边 3 个方面来说,udp 与 tcp 是相反的:
(1)没有连接
udp 的通信双方在通信之前,不需要建立连接。从这个角度来看,tcp 是面向连接的协议,是一对第一的通信方式,那么 tcp 只能支持单播,不支持多播和广播,因为多播和广播是一对多的通信方式;udp 支持多播和广播。
(2)数据报
udp 是数据报协议,也就是说数据收发的边界就是用户收发数据的边界,比如用户发送了 1000 字节的数据,那么 udp 就会发送 1000 字节的数据,udp 不会改变报文的边界。
(3)不可靠
udp 是不可靠的协议,如果报文在传输过程中出现了丢包或者乱序,udp 协议无法发现这些问题。如果使用 udp 协议,还要达到可靠的目标,可以在应用层实现。
1.4.6socket 层和应用层
本片文章值关注使用 socket 进行 tcp 通信。比较简单,不做太多记录。
2struct sk_buff
在内核网络代码中随处可见的一个变量名skb, 数据结构就是struct sk_buff(本文中也会使用skb来表示一个sk_buff)。无论是在哪一层,一个报文在 linux 内核中就用sk_buff 来表示,这方便在各个网络层之间交换数据,而不需要复制数据。
sk_buff 中的字段比较多,本文中只关注两类字段,一类用于报文数据管理,一类用于 sk_buff 管理。
(1)报文数据管理
sk_buff 中有 4 个指针分别指向数据缓存的不同位置,数据缓存就是实际存放报文数据的一段内存。
head: 数据缓存的起始位置
end: 数据缓存的结束位置
data: 数据存放的起始位置
tail: 数据存放的结束位置
也就是说head和end指向缓存的起止位置,缓存申请好之后,这两个指针指向的位置就保持不变;data和tail指向实际数据存储的起止位置,随着报文在不同的层次之间传递,会出现添加协议头(发送方向,tcp, ip, mac 头逐层添加)或者删除报文头(接收方向,mac, ip, tcp 头逐层删除)的情况,这个时候通过操作data来实现,当需要向缓存中追加数据的时候需要移动tail。headroom是head 和data之间的空间,tailroom是tail和end之间的空间,这两个room的大小 >= 0。
另外还有3个成员 transport_header, network_header, mac_header,分别表示 tcp 头,ip 头, mac 头相对于 head 的偏移量。这 3 个成员是每层协议头相对于 head 的偏移量,不是指针。
从上图中可以看到,sk_buff->end 是报文数据可用空间的结尾,但不是内存 buffer 的结尾,内存 buffer 的结尾处还有一个数据结构 struct skb_shared_info。struct skb_shared_info 两个重要的成员是 frags 和 frag_list。frags 是用在 SG, 网卡支持这种方式,才能向这个里边放数据;frag_list 是 ip 分片。
(2)sk_buff 管理
tcp有发送缓冲区,也有接收缓冲区,缓冲区中的元素是一个skb,缓冲区是一个双向链表;tcp接收侧有一个乱序队列,tcp解决乱序问题主要使用这个队列,这个队列使用红黑树实现。
以 tc 发送缓冲区为例,tcp_sendmsg 中调用tcp_sendmsg_locked, tcp_sendmsg_locked中调用skb_entail将skb入队到发送缓冲区中。tcp发送缓冲保存在struct sock 中,成员struct sk_buff_head sk_write_queue。
下图是 tcp 发送缓冲区的示意图:
tcp接收缓冲区在 struct sock 中,成员是sk_receive_queue, 在函数tcp_queue_rcv中入队。
接收时乱序队列的入队函数是tcp_data_queue_ofo,乱序队列在数据结构struct tcp_sock 中,成员是 out_of_order_queue。