当讨论 tcp 的时候, 我们能想到很多概念:
传输层协议,面向连接,可靠,字节流,状态机,三次握手,四次挥手,端口号,连接队列,mss,rtt,定时器,ack,流控,拥塞控制,重传机制,窗口,慢启动,序列号,保序,发送缓冲区,接收缓冲区,nagle,minshall,autocrok,fastopen,慢路径和快路径,延迟 ack,NODELAY, linear, SO_REUASEADDR,SO_REUSEPORT,tso,time-wait, address already in use,性能,吞吐量,延时等等。
tcp 的复杂度之高,令人望而生畏。
tcp 之所以包含这么多概念,是因为 tcp 即要保证可靠性,又追求高性能。可靠性和高性能之间有时候并不能兼得。比如 ack 机制,就是接收侧收到报文之后给发送侧发送一个回应,发送方收到这个报文之后则最终确定该报文发送成功,ack 是保证可靠性的一个方法,但是会导致链路上有效数据比例降低,减小带宽的有效利用率。所以在 tcp 的发展过程中出现了延迟 ack,即接收侧收到一个报文之后不立即回应一个 ack,而是等一定的时间,如果在这段时间之内有数据要发送给对方,就让这个报文捎带 ack,如果在超时时间之内本端不会发送数据,那么就不得不发送一个单独的 ack,这样在可靠性和性能之间取得一定的平衡。
tcp 在追求高性能方面,又要兼顾延时和吞吐量。而这两个目标之间,很多时候也不能兼得。比如 tcp 中的 nagle,autocork 算法,都是尽量把发送间隔比较近的小报文合并成一个比较大的报文进行发送,这样可以提高吞吐量,但这些技术同时也会增大报文的延时,因为在将小报文合并成大报文的过程中,前边的报文不能立即发送,有一个等待的时间,进而导致延时增加。
tcp 的高复杂度,不仅体现在概念多,还体现在概念和概念之相互交叉,盘根错节。比如当提到 tcp 的可靠性,会涉及到序列号,确认机制,重传机制等。当提到重传机制,会涉及到定时重传,快速重传。
想完全搞清楚 tcp 的细节,难度是非常大的。本文只是介绍 tcp 协议栈中使用的主要数据结构,冰山一角。数据结构是软件的骨架,从数据结构开始了解一个软件,可以对软件的功能有一个整体的认识。在介绍数据结构之前,首先记录 tcp 的一些基本概念。通过这篇笔记,争取对 tcp 有一个初步的认识。
1 最简代码
下边的代码,连接建立之后,客户端和服务端各收发 5 个报文,然后退出。
server:
socket, bind(), listen(), accept(), send(), recv。
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#define SERVER_IP ("192.168.1.103")
#define SERVER_PORT (12345)
#define MAX_LISTENQ (32)
int main() {
int ret = -1;
int accetp_fd = -1;
int listen_fd = -1;
struct sockaddr_in client_addr;
struct sockaddr_in server_addr;
socklen_t client = sizeof(struct sockaddr_in);
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
printf("create socket error: %s\n", strerror(errno));
return -1;
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP); /**< 0.0.0.0 all local ip */
server_addr.sin_port = htons(SERVER_PORT);
if (bind(listen_fd,(struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
printf("bind[%s:%d] error.\n", SERVER_IP, SERVER_PORT);
return -1;
}
if (listen(listen_fd, MAX_LISTENQ) < 0) {
printf("listen error.\n");
return -1;
}
accetp_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client);
if(accetp_fd < 0) {
printf("accept error.\n");
return -1;
}
char buff[10] = {'\0'};
for (int i = 0; i < 5; i++) {
ret = recv(accetp_fd, buff, 10, 0);
printf("recv len: %d, data: %s\n", ret, buff);
sleep(1);
send(accetp_fd, buff, 10, 0);
}
close(listen_fd);
return 0;
}
client:
socket(),connect(),send()/recv()。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERVER_PORT (12345)
#define SERVER_IP "192.168.1.103"
#define MAX_BUFSIZE (512)
int main(int argc,char *argv[]) {
int sock_fd;
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if(sock_fd < 0) {
printf("create socket error.\n");
return -1;
}
struct sockaddr_in addr_serv;
memset(&addr_serv, 0, sizeof(addr_serv));
addr_serv.sin_family = AF_INET;
addr_serv.sin_port = htons(SERVER_PORT);
addr_serv.sin_addr.s_addr = inet_addr(SERVER_IP);
if(connect(sock_fd, (struct sockaddr *)&addr_serv,sizeof(struct sockaddr)) < 0){
printf("connect error.\n");
return -1;
}
char buff[10] = "hello tcp.";
for (int i = 0; i < 5; i++) {
send(sock_fd, buff, 10, 0);
int ret = recv(sock_fd, buff, 10, 0);
sleep(1);
printf("recv len: %d, data: %s\n", ret, buff);
}
close(sock_fd);
return 0;
}
2 tcp 在网络模型中的位置
从网络分层模型的角度来看,有两种著名的模型: OSI 七层模型和 TCP/IP 四层模型。两种模型的区别主要在应用层,四层模型中的应用层在七层模型中用三层来表示,分别是应用层,表示层,会话层。两种模型没有本质的区别,都可以描述网络的分层结构,TCP/IP 四层模型更具体,更好理解。
从 tcp 协议栈在 linux 中所处的位置来看,tcp 位于 linux 内核。向上对接 socket 接口,向下对接 ip 协议栈。
3 tcp 首部
每一种网络协议都有自己的协议头,一般情况下,协议头会包含源、目的信息(以太头的源 mac 和目的 mac,ip 首部的源 ip 和目的 ip,tcp 头的源端口和目的端口,用于标记报文的来源和目的),长度信息,校验和(用于对报文进行校验,以判断报文内容是不是正确)以及本协议特有的一些字段(比如 ip 首部的 ip 版本号字段,tcp 首部的序列号字段)。
协议头后边即为实际的数据。协议头描述了后边的实际数据,相当于数据的元数据。
当然,也有一些协议或者报文,没有实际的数据,比如 arp 协议,icmp 协议,tcp 中的握手报文。这些报文,都是纯粹的用于协商的协议报文,可以归结为纯控制面的报文,没有实际的有效数据。
header len |
首部长度,占 4 个 bit,这个值乘以 4 就是 tcp 首部的长度,所以首部最大长度是 15 * 4Byte = 60Byte。 ① tcp 首部长度是 4B 的整数倍 ② tcp 首部固定长度 20B, 选项字段最长 40B,选项字段是有长度限制的,不是想放多少选项就能放多少选项的 |
checksum |
这个校验和包括了 tcp 的首部和 tcp 的数据。 除了以上两项数据,tcp 计算校验和的时候还要加上伪首部。csum_tcpudp_magic() 是 tcp 和 udp 计算校验和的函数。 |
tcp 伪首部:
通过伪首部的校验,可以判断报文是不是发给本机的。伪首部中的报文长度是 tcp 报文的长度(包括 tcp 首部和数据)。
像 tcp,ip, http 这些协议,都是标准协议。标准化就可以让不同的产品进行互联互通,比如在通信设备方便,思科的设备就可以和华为的设备互联互通;在操作系统方面,linux 和 windows 可以互联互通。
4 状态机
tcp 状态机给人的直观感觉是状态多,状态转换过程复杂。
如果从状态所处的阶段对状态进行分类,可以简化对状态机的理解。tcp 阶段分为三个,建立连接阶段,断开连接阶段,以及数据传输阶段,数据传输阶段状态是 ESTABLISHED;建立连接阶段的状态主要包括 SYN_SENT, SYN_RCVD;断开连接阶段的状态包括 FIN_WAIT_1, FIN_WAIT_2, TIME_WAIT, CLOSE_WAIT, LAST_ACK。
也可以从建立连接和断开连接过程中主动的一方和被动的一方将状态进行分类,主动的一方的状态包括 SYN_SENT, FIN_WAIT_1, FIN_WAIT_2, TIME_WAIT; 被动的一方的状态包括 SYN_RCVD, CLOSE_WAIT, LAST_ACK。
建立连接过程状态机:
断开连接过程状态机:
5 数据结构
当使用 socket() 创建一个 tcp socket 的时候,内核需要创建哪些数据结构 ?
socket() 返回一个 fd,能不能用 read() 和 write() 操作这个 fd ?
一台机器上管理着很多个 tcp 连接,这些连接怎么管理,放到一个全局链表里,还是放到哪里 ?
tcp 作为一个传输层协议,有很多操作,这些操作有没有一个结构体进行维护 ?
5.1 socket; sock, inet_sock, inet_connection_sock, tcp_sock
一个 socket 可以代表一个 tcp 连接,但是在看 tcp 代码的时候,发现有多个结构体的后缀都有 sock,那么这些结构体之间是什么关系呢。
(1)socket 创建过程
首先整理一下 socket 的创建过程。创建 tcp socket 的代码是 socket(AF_INET, SOCK_STREAM, 0)。创建 socket 的过程中,在内核具体做了哪些事情呢。
使用 systemtap 打印 tcp_init_sock() 的调用栈如下:
0xffffffff9c85e3f0 : tcp_init_sock+0x0/0x170 [kernel]
0xffffffff9c878cee : tcp_v4_init_sock+0xe/0x30 [kernel]
0xffffffff9c896056 : inet_create+0x1d6/0x360 [kernel]
0xffffffff9c79596f : __sock_create+0xcf/0x1a0 [kernel]
0xffffffff9c797777 : __sys_socket+0x57/0xe0 [kernel]
0xffffffff9c797816 : __x64_sys_socket+0x16/0x20 [kernel]
0xffffffff9c0042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff9ca000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff9ca000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
socket 创建过程:
// socket 的系统调用如下,在该系统调用中调用 __sys_socket()
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
return __sys_socket(family, type, protocol);
}
// 这个函数的工作主要分两步
// 1、创建一个 socket,sock_create() 中完成 socket 的创建和初始化工作
// 2、从进程资源中选择一个空闲的 fd, 将 fd 和 sock 关联起来,最后返回 fd
int __sys_socket(int family, int type, int protocol)
{
int retval;
struct socket *sock;
int flags;
retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
return retval;
return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}
// static const struct net_proto_family inet_family_ops = {
// .family = PF_INET,
// .create = inet_create,
// .owner = THIS_MODULE,
// };
// 函数 __sock_create() 完成 struct socket 和 struct sock 的创建
// sock_alloc() 创建 struct socket,pf->create() 创建 struct sock
int __sock_create(struct net *net, int family, int type, int protocol,
struct socket **res, int kern)
{
const struct net_proto_family *pf;
sock = sock_alloc();
err = pf->create(net, sock, protocol, kern);
}
// 函数 inet_create() 里边也是主要做了两件事, 创建 struct sock 并初始化
// 1、sk_alloc() 创建了一个 struct sock 结构体,这个结构体是个协议类型强相关的
// 是从协议的 slab 池中申请一个内存块,
// 如果是创建 tcp 的 sock 的话,那么这里申请的内存块的大小就是 struct tcp_sock 的大小
// struct proto tcp_prot = {
// .init = tcp_v4_init_sock,
// .obj_size = sizeof(struct tcp_sock),
// };
// 2、调用协议对应的 init 函数,对 socket 进行初始化,对于 tcp 来说就是调用 tcp_v4_init_sock()
static int inet_create(struct net *net, struct socket *sock, int protocol,
int kern)
{
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
if (!sk)
goto out;
if (sk->sk_prot->init) {
err = sk->sk_prot->init(sk);
if (err) {
sk_common_release(sk);
goto out;
}
}
}
// tcp_v4_init_sock() 会直接调用 tcp_init_sock() 函数,完成一些初始化的工作
// 比如 tcp 的接收方向使用的乱序队列,发送方向使用的重传队列
// 还有 tcp 使用的一些定时器,发送缓冲区的大小,等等
void tcp_init_sock(struct sock *sk)
{
struct inet_connection_sock *icsk = inet_csk(sk);
struct tcp_sock *tp = tcp_sk(sk);
tp->out_of_order_queue = RB_ROOT;
sk->tcp_rtx_queue = RB_ROOT;
tcp_init_xmit_timers(sk);
sk->sk_write_space = sk_stream_write_space;
}
(2)struct socket 和 struct sock 的区别
简单来说,socket 是用户空间和内核空间的一个桥梁。linux 从用户空间来看,一切皆文件,socket 在用户空间也是通过一个 fd 来表示,socket 中的成员,file, ops 都是一切皆文件的体现。
struct socket {
socket_state state;
short type;
unsigned long flags;
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
struct socket_wq wq;
};
struct sock 是 socket 在内核态的表示。struct sock 中包括一些 tcp, udp 共用的成员,比如 gso,底层通知可读,可写的函数指针等(sk_data_ready, sk_write_space 等)。也包括 tcp 特有的重传队列等。
struct sock {
struct sock_common __sk_common;
int sk_gso_type;
union {
struct sk_buff *sk_send_head;
struct rb_root tcp_rtx_queue;
};
unsigned int sk_gso_max_size;
void (*sk_state_change)(struct sock *sk);
void (*sk_data_ready)(struct sock *sk);
void (*sk_write_space)(struct sock *sk);
void (*sk_error_report)(struct sock *sk);
int (*sk_backlog_rcv)(struct sock *sk,
struct sk_buff *skb);
};
struct sock 和 struct socket 两个结构体是一个事物的两个呈现方式,这两个结构体中都有各自的指针,可以相互访问。
struct socket {
struct sock *sk;
};
struct sock {
struct socket *sk_socket;
};
(3)包含关系
从 tcp_sock, inet_connection_sock, inet_sock 这几个结构体的注释来看,它们都给第一个成员加了注释,注释的内容就是说第一个成员必须放在开始的位置,之所以放在开始的位置,就是保证这几个结构体之间可以直接进行强制类型转换。
struct tcp_sock {
/* inet_connection_sock has to be the first member of tcp_sock */
struct inet_connection_sock inet_conn;
u16 tcp_header_len; /* Bytes of tcp header to send */
u16 gso_segs; /* Max number of segs per GSO packet */
}
struct inet_connection_sock {
/* inet_sock has to be the first member! */
struct inet_sock icsk_inet;
struct request_sock_queue icsk_accept_queue;
struct inet_bind_bucket *icsk_bind_hash;
}
struct inet_sock {
/* sk and pinet6 has to be the first two members of inet_sock */
struct sock sk;
#if IS_ENABLED(CONFIG_IPV6)
struct ipv6_pinfo *pinet6;
#endif
/* Socket demultiplex comparisons on incoming packets. */
#define inet_daddr sk.__sk_common.skc_daddr
#define inet_rcv_saddr sk.__sk_common.skc_rcv_saddr
#define inet_dport sk.__sk_common.skc_dport
#define inet_num sk.__sk_common.skc_num
__be32 inet_saddr;
}
包含关系如下:
struct tcp socket 包含 struct inet_connection_sock,
struct inet_connection_sock 包含 struct inet_sock,
struct inet_sock 包含 struct sock。
包含关系如下图所示:
(4)为什么要定义这么多的结构体,只使用一个 struct tcp_sock 不是已经足够了吗
四个结构体,struct sock, struct inet_sock, struct inet_connection_sock, struct tcp_sock,体现了面向对象中的继承与多态的特点。
struct sock 可以说是一个基类,里边的成员是一个 sock 所需要具备的最基本的功能。
struct inet_connection_sock 继承了 struct inet_sock,udp 套接字 struct udp_sock 也继承了 struct inet_sock。多个类继承了同一个类,这体现了多态的特点。
这种设计方式一方面可以简化每一层接口的封装,另一方面保证了接口的稳定性,当某个结构体需要做修改时,只修改对应的这个结构体就可以,这个结构体的继承者不需要修改。
这样的实现也使得代码很有层次感,struct sock 是最上层的最抽象的数据结构,struct tcp_sock 是最底层的最具体的数据结构。
(5)每个结构体的主要成员
讨论了 5 个结构体,socket, sock, inet_sock, inet_connection_sock, tcp_sock。记录一下每个结构体中的一些重要成员(少数几个自己能看懂并理解的数据成员而已)。
// struct tcp_sock 继承自 struct inet_connection_sock,处于继承关系的最底层
// 在 struct inet_connection_sock 之外,增加了 tcp 特有的功能,比如窗口,nagle 算法等
// 这些功能严格来说是 tcp 特有的功能,不属于面向连接的范畴
// 专属于面向连接的功能,在 struct inet_connection_sock 中实现
struct tcp_sock {
/* inet_connection_sock has to be the first member of tcp_sock */
struct inet_connection_sock inet_conn;
// 接收侧下次要接收的目标序列号
u32 rcv_nxt;
// 下次发送报文的时候,序列号从 snd_nxt 开始
u32 snd_nxt;
// nagle 算法
u8 nonagle : 4,
// 拥塞窗口
u32 snd_cwnd;
// 接收窗口
u32 rcv_wnd;
// 接收侧乱序队列
struct rb_root out_of_order_queue;
struct sk_buff *ooo_last_skb;
};
struct inet_connection_sock {
// 服务端的连接队列
struct request_sock_queue icsk_accept_queue;
// 重传定时器
struct timer_list icsk_retransmit_timer;
// 延时 ack 定时器
struct timer_list icsk_delack_timer;
// 处理连接相关的函数
const struct inet_connection_sock_af_ops *icsk_af_ops;
}
// 这个结构体里边维护着一些网络层的信息,addr, ttl 这些
struct inet_sock {
__be32 inet_saddr;
__s16 uc_ttl;
};
(6)总结一下 socket 创建过程做的事情
① 一切皆文件
站在用户态的角度来看 linux, 一切皆文件,同样 socket 在用户态看来也是一个文件,用 fd 来表示。在 socket 这一层,会涉及到四个主要的对象,struct socket, int fd,struct file,struct file_operations socket_file_ops。这四个对象之间相互之间都可以找到。
sock->file = file; // 通过 socket 找 file
file->private_data = sock; // 通过 file 找 socket
file->ops 中存储的是 socket_file_ops
fd_install(unsigned int fd, struct file *file)
会将 fd 和 file 关联起来,通过 fd 即可以找到 file
所以系统调用的时候,向内核传递一个 fd,就能找到这个 socket 相关的所有数据结构。
fd 通过 struct task_struct 中的 fd table 找到 struct file,
通过 struct file 可以找到 struct sock,
通过 struct file 也可以找到 sock_file_ops,
通过 struct sock 强制类型转换就能找到 struct tcp_sock。
② 协议相关部分
对于 tcp 来说,会创建 struct tcp_sock 结构体,并且在函数 tcp_init_sock() 中做初始化,初始化的内容包括定时器,收发包缓冲区等。
5.2 file operations 和 struct proto tcp_prot
tcp 需要提供的系统调用包括数据面的收发数据,也包括控制面的 bind(), listen(), accept(), connet() 等。数据面的收发数据可以通过 read(),write() 来完成,这些 api 都可以在 struct file_operations 中实现,但是控制面的系统调用,在 struct file_operations 中没有对应的接口,这些接口都是在 struct proto 中实现。对于 tcp 来说,所有的系统调用,最终的调用都是调用的 struct proto 中实现的函数。
对于 socket() 来说,尽管体现了一切接文件的思想,但是 struct file_operations socket_file_ops 也并不能包含 socket() 的所有的操作,所以有一个更详细的 struct proto tcp_prot。struct file_opearations socket_file_ops 和 struct proto tcp_proto 两个数据结构共同表示 tcp 的操作,才能将 tcp 的操作表示全面。
5.2.1 struct file_operations socket_file_ops
struct file_operations,每一个在用户态可以抽象成文件的对象,在内核态都需要定义一个自己的 struct file_operations。个人认为 struct file_operations 是 “一切皆文件” 中最重要的一个结构体。
在用户态通过 socket() 创建 socket 的时候,内核返回一个 fd。特定类型的 fd 都要实现自己的 file_operations。socket 的 file_operations 是 socket_file_ops。
用户态调用对应的函数的时候,在内核态首先根据 fd 找到对应的 struct file 结构体,然后从 file 结构体中找到对应的 file ops,进而调用到 file_operations 中对应的方法。
对于 tcp 来说,这些函数底层就是封装的 tcp 的对应的函数,比如 sock_read_iter() 对应 tcp_recvmsg(), sock_write_iter() 对应 tcp_sendmsg()。
static const struct file_operations socket_file_ops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = compat_sock_ioctl,
#endif
.mmap = sock_mmap,
.release = sock_close,
.fasync = sock_fasync,
.sendpage = sock_sendpage,
.splice_write = generic_splice_sendpage,
.splice_read = sock_splice_read,
.show_fdinfo = sock_show_fdinfo,
};
5.2.2 struct proto tcp_prot
struct proto 定义一种协议,在内核中,每个“传输层”的协议都会定义一个这样的结构体。inet_init() 函数是内核中网络初始化的函数,在该函数中注册了 tcp, udp, raw, ping 四种不同的 “传输层”协议。之所以说是传输层协议,是因为每层协议都对应一种 socket 类型。
static int __init inet_init(void)
{
struct inet_protosw *q;
struct list_head *r;
int rc;
sock_skb_cb_check_size(sizeof(struct inet_skb_parm));
rc = proto_register(&tcp_prot, 1);
rc = proto_register(&udp_prot, 1);
rc = proto_register(&raw_prot, 1);
rc = proto_register(&ping_prot, 1);
}
// 1、tcp, udp, raw, ping,每种协议都对应一种 socket 类型
// 2、从注释可以看到,当系统启动的时候,会将 inetsw_array 这个数组中的元素插到 inetsw 链表中
// inetsw_array 和 inetsw 都是全局的数据结构
// 3、每个 socket 创建的时候,都会调用的 inet_create() 函数,
//
/* Upon startup we insert all the elements in inetsw_array[] into
* the linked list inetsw.
*/
static struct inet_protosw inetsw_array[] =
{
{
.type = SOCK_STREAM,
.protocol = IPPROTO_TCP,
.prot = &tcp_prot,
.ops = &inet_stream_ops,
.flags = INET_PROTOSW_PERMANENT |
INET_PROTOSW_ICSK,
},
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_UDP,
.prot = &udp_prot,
.ops = &inet_dgram_ops,
.flags = INET_PROTOSW_PERMANENT,
},
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_ICMP,
.prot = &ping_prot,
.ops = &inet_sockraw_ops,
.flags = INET_PROTOSW_REUSE,
},
{
.type = SOCK_RAW,
.protocol = IPPROTO_IP, /* wild card */
.prot = &raw_prot,
.ops = &inet_sockraw_ops,
.flags = INET_PROTOSW_REUSE,
}
};
5.3 tcp_hashinfo
tcp_hashinfo 是一个全局实例,类型是 struct inet_hashinfo。在该结构体中包括四个哈希表,tcp 协议栈根据不同的划分策略将套接字放入对应的哈希表中进行管理。这 4 个哈希表存储的元素是 tcp 连接或者 tcp 服务端,不同的划分策略,表现为计算哈希表的 key 所使用的字段不同,每个哈希表有不同的用途。
在软件开发过程中,全局变量的数量虽然不多,但是全局数据结构往往管理着一些重要的数据,是软件运行的基础。
我们试想以下几个问题:
当一个 SYN 请求到来的时候,怎么查找有没有对应的监听套接字 ?
当一个数据报文到来的时候,怎么查找有没有对应的连接 ? 根据什么进行查找 ?
创建 tcp 服务端的时候怎么确保这个端口是不是已经被使用了 ?从哪里查 ?
当客户端 connect() 的时候,不用开发者手动指定端口号,但是内核也会查找一个可用的端口号,从哪里查 ?
上边几个问题的答案就是 tcp_hashinfo 这个全局的数据结构,tcp_hashinfo 中保存着所有的 tcp socket。这些哈希表根据用途不同,key 也是不一样的。
查找一个连接,ehash, e 是 established。
确定一个端口能不能用, bhash,b 是 bind。
查找一个服务端,lhash2 和 listening_hash,l 是 listening。
tcp_hashinfo 也会被上一节中提到的 tcp_prot 引用,tcp_prot 会被每个 socket 通过 sk_prot 引用。所以每个 socket 都能通过 socket --> sk_prot --> tcp_hashinfo 获取到 tcp_hashinfo。
struct proto tcp_prot = {
.h.hashinfo = &tcp_hashinfo,
};
哈希表使用链地址法处理哈希冲突。如下图所示,每个 hash 值对应一个 bucket,一个 bucket 对应一个链表,链表中保存着这个哈希值的所有元素。
哈希表的插入,查找,删除操作发生的场景可以用以下 6 句话来概括:
① 创建服务端:会将 listening socket 加入到 bhash, lhash2 以及 listening_hash 中;关闭这个服务端,则会把 socket 从这几个哈希表中删除。可见一个 socket 在同一时刻并不是只放到了其中一个哈希表,可能会放在多个哈希表中。
② 客户端创建连接:会将 socket 加入到 ehash 和 bhash 中;关闭这条连接,则将之从 ehash 和 bhash 中删除。
③ 创建服务端的时候会在 bhash 中进行查找,以确认这个端口号是不是有冲突,有冲突则返回失败,无冲突则 bind 成功;客户端发起连接的时候,一般应用不指定端口,而是内核分配端口,同样会在 bhash 中进行查找,选一个没有被使用的端口提供给客户端来使用。
④ 在接收数据的时候会根据报文头中的四元组在 ehash 中找到对应的套接字, 找到套接字之后则会放入套接字的接收缓冲区。
⑤ 在建立连接的三次握手中,服务端有一个中间态,服务端并不是三次握手完成之后,才将连接信息加入到 ehash 和 bhash 中,在三次握手的过程中就已经向 ehash 中存放了这个连接信息。这个中间态就是服务端收到 SYN 报文之后,创建了一个 request_sock,并加入到了 ehash,这样服务端收到第三次握手的 ACK 之后,会创建一个正式的 sock,加入到 bhash 和 ehash,然后将中间态的 request_sock 删除。
⑥ 在关闭连接的过程中,也有一个中间态,这个中间态用 inet_timewait_sock 来维护。当客户端进入 TIME_WAIT 状态后,会创建一个 inet_timewait_sock 并加入 bhash 和 ehash 中,同时把 sock 从 bash 和 ehash 中删除,待 TIME_WAIT 定时器超时,会在函数 tw_timer_handler() 中将 inet_timewait_sock 从 bhash 和 ehash 中删除。之所以有中间态,个人认为在 TIME_WAIT 状态,需要完成的功能比较单一,只保留一个轻量的 sock 即可,可以提高资源使用率。
从套接字的分类来看,可以分成两类,监听套接字和连接套接字:
监听套接字会加入到三个套接字,bhash, listening_hash, lhash2。
连接套接字会加入到连个套接字,ehash 和 bhash。连接套接字包括客户端的套接字,也包括服务端的套接字。
下边通过分析源码,记录操作 4 个哈希表的具体位置:
5.3.1 创建服务端
创建服务端,需要经过 socket() --> bind() --> listen() 之后通过 accept() 接收新的连接, bind() 和 listen() 都会涉及到哈希表的操作。
① bind()
bind() 函数的作用就是把一个 socket 和一个 ip:port 二元组绑定在一块,在 bind 的过程中,最主要的就是确认这个端口号有没有被使用,如果没有被使用,bind 会返回成功;如果被使用了,还要分两种情况,如果使用这个端口的 socket 都设置了 reuse, 那么 bind() 会返回成功,否则返回失败。
// bind() 调用栈,port 的冲突检测在函数 inet_csk_get_port() 中进行
0xffffffff9145dd20 : inet_csk_get_port+0x0/0x5f0 [kernel]
0xffffffff914975b1 : __inet_bind+0x1c1/0x280 [kernel]
0xffffffff91397b92 : __sys_bind+0xd2/0xf0 [kernel]
0xffffffff91397bc6 : __x64_sys_bind+0x16/0x20 [kernel]
0xffffffff90c042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
int __inet_bind(struct sock *sk, struct sockaddr *uaddr, int addr_len,
u32 flags)
{
if (snum || !(inet->bind_address_no_port ||
(flags & BIND_FORCE_ADDRESS_NO_PORT))) {
// 通过 get_port() 来判断 port 能不能被绑定
// 如果返回非 0, 说明不能被绑定,则返回 EADDRINUSE,也就是常见的 Address already in use
// 对于 tcp 套接字来说,sk_prot->get_port() 对应函数 inet_csk_get_port()
// struct proto tcp_prot = {
// .get_port = inet_csk_get_port,
// };
if (sk->sk_prot->get_port(sk, snum)) {
inet->inet_saddr = inet->inet_rcv_saddr = 0;
err = -EADDRINUSE;
goto out_release_sock;
}
}
}
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
// hinfo 即全局数据结构 tcp_hashinfo
struct inet_hashinfo *hinfo = sk->sk_prot->h.hashinfo;
// bhash 的 key 的计算方式只包括 port 字段
// 首先通过 port 找到该 port 在哈希表中对应的 bucket
head = &hinfo->bhash[inet_bhashfn(net, port,
hinfo->bhash_size)];
// 遍历这个 bucket,
// 如果该 port 已经存在,则走 tp_found 进行处理, 否则走 tb_not_found 进行处理
inet_bind_bucket_for_each(tb, &head->chain)
if (net_eq(ib_net(tb), net) && tb->l3mdev == l3mdev &&
tb->port == port)
goto tb_found;
tb_not_found:
// 该 port 还不存在,则创建 bucket 中的一个节点
// 这个时候只是在 bucket 中创建了 port 对应的一个节点,
// 用户 socket 还没有加入到哈希表中
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep,
net, head, port, l3mdev);
if (!tb)
goto fail_unlock;
tb_found:
// tb->owners 不为空,则说明已经有 socket 在使用这个端口
if (!hlist_empty(&tb->owners)) {
// 冲突检测,如果端口不能共享使用,则返回失败
if (inet_csk_bind_conflict(sk, tb, true, true))
goto fail_unlock;
}
success:
// inet_bind_hash() 是将 socket 加入到 bhash 当中
// sk->icsk_bind_hash 记录了加入 bhash 当中的哪个节点
// 将 socket 加入到 bhash 中就是操作的 sk->sk_bind_node,使之链接到哈希表中
// void inet_bind_hash(struct sock *sk, struct inet_bind_bucket *tb,
// const unsigned short snum)
// {
// inet_sk(sk)->inet_num = snum;
// sk_add_bind_node(sk, &tb->owners);
// inet_csk(sk)->icsk_bind_hash = tb;
// }
if (!inet_csk(sk)->icsk_bind_hash)
inet_bind_hash(sk, tb, port);
WARN_ON(inet_csk(sk)->icsk_bind_hash != tb);
ret = 0;
fail_unlock:
spin_unlock_bh(&head->lock);
return ret;
}
② listen()
// listen() 系统调用,最终会调用 inet_hash() 将监听套接字加入到 listening_hash 和 lhash2 中
0xffffffff9145b650 : inet_hash+0x0/0x40 [kernel]
0xffffffff9145c90c : inet_csk_listen_start+0xac/0xd0 [kernel]
0xffffffff91496552 : inet_listen+0x92/0x180 [kernel]
0xffffffff91397c58 : __sys_listen+0x68/0xa0 [kernel]
0xffffffff91397ca2 : __x64_sys_listen+0x12/0x20 [kernel]
0xffffffff90c042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
// inet_hash() 内部调用 __inet_hash() 来实现
int inet_hash(struct sock *sk)
{
int err = 0;
if (sk->sk_state != TCP_CLOSE)
err = __inet_hash(sk, NULL);
return err;
}
int __inet_hash(struct sock *sk, struct sock *osk)
{
// hashinfo 即全局数据结构 tcp_hashinfo
struct inet_hashinfo *hashinfo = sk->sk_prot->h.hashinfo;
// 根据监听的端口获取 listening_hash 中的 bucket
ilb = &hashinfo->listening_hash[inet_sk_listen_hashfn(sk)];
// 将 sock 加入到 listening_hash 中
if (IS_ENABLED(CONFIG_IPV6) && sk->sk_reuseport &&
sk->sk_family == AF_INET6)
__sk_nulls_add_node_tail_rcu(sk, &ilb->nulls_head);
else
__sk_nulls_add_node_rcu(sk, &ilb->nulls_head);
// 将 sk 加入到 lhash2 中
inet_hash2(hashinfo, sk);
}
可以看到,listen 套接字用两个数据结构来维护,listening_hash 和 lhash2。
lhash2 在内核 4.16 版本引入,可以在 https://elixir.bootlin.com/linux/latest/source 上快速查看不同内核版本的代码。
在 lhash2 出现之前,只使用 listening_hash 来维护监听套接字。
listening_hash 的 key 只用 port 进行计算,当多个套接字监听同一个 port 的时候,就会出现哈希表的 bucket 所对应的链表太长,
进而导致当收到一个 SYN 包的时候,查询效率低下。
lhash2 的 key 使用 addr 和 port 两个字段进行计算,这样就会使监听套接字更加分散,从而提高查询匹配效率。
5.3.2 建立连接
建立连接的过程一般是客户端发起,经过三次握手,完成连接的建立。
① 第一次握手:客户端 -- (SYN) --> 服务端
客户端:
// tcp 发起连接的函数是 tcp_v4_connect(),调用栈如下:
0xffffffffb7e79c40 : tcp_v4_connect+0x0/0x4d0 [kernel]
0xffffffffb7e95331 : __inet_stream_connect+0xd1/0x370 [kernel]
0xffffffffb7e95606 : inet_stream_connect+0x36/0x50 [kernel]
0xffffffffb7d980ca : __sys_connect+0x9a/0xd0 [kernel]
0xffffffffb7d98116 : __x64_sys_connect+0x16/0x20 [kernel]
0xffffffffb76042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffffb80000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffffb80000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
// 一般情況下,客戶端不指定端口号,而是由内核分配
// 客户端分配端口的过程和服务端 bind() 时函数 inet_csk_get_port() 中的逻辑是有点类似的
// 遍历哈希表 --> 判断端口能不能用 --> 确定端口之后,将连接信息加入到 bhash 中
// 不仅仅是服务端的 bind() 会把套接字加入到 bhash 中,客户端确定端口号之后也会将套接字加入到 bhash 中
// 两者的区别是,服务端的 bind(),是端口号已经确定,主要工作是冲突检测,判断该端口能不能被这个套接字使用
// 客户端是端口号没有确定,而是需要遍历可选范围内端口,判断每一个端口是否可用,一旦找到一个可用端口,
// 则结束查找。
int __inet_hash_connect(struct inet_timewait_death_row *death_row,
struct sock *sk, u64 port_offset,
int (*check_established)(struct inet_timewait_death_row *,
struct sock *, __u16, struct inet_timewait_sock **))
{
// tcp_hashinfo
struct inet_hashinfo *hinfo = death_row->hashinfo;
// cat /proc/sys/net/ipv4/ip_local_port_range
// 32768 60999
inet_get_local_port_range(net, &low, &high);
// 遍历端口
for (i = 0; i < remaining; i += 2, port += 2) {
// 获取该端口对应 bhash
head = &hinfo->bhash[inet_bhashfn(net, port,
hinfo->bhash_size)];
// 如果这个端口已经被使用了,则判断能不能被 reuse,
// 可以被 reuse 则使用该端口,不可以被使用则遍历下一个端口
inet_bind_bucket_for_each(tb, &head->chain) {
if (net_eq(ib_net(tb), net) && tb->l3mdev == l3mdev &&
tb->port == port) {
if (tb->fastreuse >= 0 ||
tb->fastreuseport >= 0)
goto next_port;
WARN_ON(hlist_empty(&tb->owners));
if (!check_established(death_row, sk,
port, &tw))
goto ok;
goto next_port;
}
}
// 如果该端口没有被使用,则选择这个端口号作为客户端的端口号
// 创建一个 tb,
// 后边通过函数 inet_bind_hash() 和 inet_ehash_nolisten() 将 socket 加入到 bhash 和 ehash
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep,
net, head, port, l3mdev);
if (!tb) {
spin_unlock_bh(&head->lock);
return -ENOMEM;
}
tb->fastreuse = -1;
tb->fastreuseport = -1;
goto ok;
next_port:
spin_unlock_bh(&head->lock);
cond_resched();
}
offset++;
if ((offset & 1) && remaining > 1)
goto other_parity_scan;
return -EADDRNOTAVAIL;
ok:
// 将 sock 加入到 bhash
inet_bind_hash(sk, tb, port);
if (sk_unhashed(sk)) {
inet_sk(sk)->inet_sport = htons(port);
// 将 sock 加入到 ehash
inet_ehash_nolisten(sk, (struct sock *)tw, NULL);
}
return 0;
}
服务端:
// 服务端收到 SYN 报文之后,在 tcp_v4_conn_request() 函数中处理
0xffffffff99a79690 : tcp_v4_conn_request+0x0/0x50 [kernel]
0xffffffff99a6f084 : tcp_rcv_state_process+0x214/0xd62 [kernel]
0xffffffff99a7af14 : tcp_v4_do_rcv+0xb4/0x1e0 [kernel]
0xffffffff99a7d3b1 : tcp_v4_rcv+0xc11/0xc50 [kernel]
int tcp_conn_request(struct request_sock_ops *rsk_ops,
const struct tcp_request_sock_ops *af_ops,
struct sock *sk, struct sk_buff *skb)
{
// 服务端收到 syn 之后,并不是立即创建一个 struct sock 来表示一条连接
// 而是创建了一个中间过渡态的 request_sock,
// request_sock 在函数 inet_csk_reqsk_queue_hash_add() 中加入到 ehash 中
// 这样收到第三次握手的 ACK 报文之后就能找到这个 request_sock,
// 最终会创建一个 struct sock 加入到 bhash 和 ehash 中,并将 request_sock 从 ehash 中删除
req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie);
if (!req)
goto drop;
if (fastopen_sk) {
} else {
tcp_rsk(req)->tfo_listener = false;
if (!want_cookie)
inet_csk_reqsk_queue_hash_add(sk, req,
tcp_timeout_init((struct sock *)req));
af_ops->send_synack(sk, dst, &fl, req, &foc,
!want_cookie ? TCP_SYNACK_NORMAL :
TCP_SYNACK_COOKIE,
}
return 0;
}
② 第二次握手:服务端 --(SYN + ACK)--> 客户端
由上边分析可以看到,在第一次握手的时候,客户端创建了 sock 并加入到了 bhash 和 ehash 中,服务端创建了 request_sock 并加入到了 ehash 中。在第二次握手的过程中,并没有新的数据结构的创建,hash 表也没有新的插入或者删除操作。
服务端:
// 服务端向客户端发送 syn + ack,发送函数为 tcp_v4_send_synack(),
// 调用栈如下
0xffffffff99a55d40 : ip_build_and_send_pkt+0x0/0x1c0 [kernel]
0xffffffff99a7c510 : tcp_v4_send_synack+0x70/0xd0 [kernel]
0xffffffff99a69735 : tcp_conn_request+0x925/0xb80 [kernel]
0xffffffff99a6f084 : tcp_rcv_state_process+0x214/0xd62 [kernel]
0xffffffff99a7af14 : tcp_v4_do_rcv+0xb4/0x1e0 [kernel]
0xffffffff99a7d3b1 : tcp_v4_rcv+0xc11/0xc50 [kernel]
// 客户端收到 syn + ack 之后,就会将连接状态设置成 ESTABLISHED,
// 处理函数为 tcp_finish_connect(),调用栈如下
0xffffffff99a6ed70 : tcp_finish_connect+0x0/0x100 [kernel]
0xffffffff99a6f3a5 : tcp_rcv_state_process+0x535/0xd62 [kernel]
0xffffffff99a7af14 : tcp_v4_do_rcv+0xb4/0x1e0 [kernel]
0xffffffff99a7d2e3 : tcp_v4_rcv+0xb43/0xc50 [kernel]
③ 第三次握手:客户端 -- (ACK) --> 服务端
客户端收到服务端发送的 syn + ack 之后,会向服务端发送 ack,即第三次握手。发送 ack 报文通过函数tcp_send_ack() 完成。
服务端:
// 服务端收到 ack 之后会创建一个 struct sock,并把它加入到 bhash 和 ehash 中
// 同时将第一次握手时创建的 request_sock 从 ehash 中删除
0xffffffff99a7bad0 : tcp_v4_syn_recv_sock+0x0/0x3d0 [kernel]
0xffffffff99a7dd25 : tcp_check_req+0x135/0x590 [kernel]
0xffffffff99a7d042 : tcp_v4_rcv+0x8a2/0xc50 [kernel]
struct sock *tcp_v4_syn_recv_sock(const struct sock *sk, struct sk_buff *skb,
struct request_sock *req,
struct dst_entry *dst,
struct request_sock *req_unhash,
bool *own_req)
{
struct sock *newsk;
// 基于 request_sock 创建 newsk
newsk = tcp_create_openreq_child(sk, req, skb);
if (!newsk)
goto exit_nonewsk;
// 将 newsk加入 bhash
if (__inet_inherit_port(sk, newsk) < 0)
goto put_and_exit;
// 将 newsk 加入 ehash,同时将 request_sock 从 ehash 删除
*own_req = inet_ehash_nolisten(newsk, req_to_sk(req_unhash),
&found_dup_sk);
}
5.3.3 接收数据
tcp 接收数据时,最重要的是从 hash 表中查找,确定这个报文是属于哪条连接的。如果报文中有数据,这个报文可能就属于一条已经建立的连接,如果报文中没有数据,这说明这调连接处于连接建立的过程中。下边我们就记录查找过程。
// tcp_v4_rcv() 是 tcp 接收的入口函数
// 在该函数中,通过调用 __inet_lookup_sock() 来查找报文所属的 sock
int tcp_v4_rcv(struct sk_buff *skb)
{
lookup:
sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source,
th->dest, sdif, &refcounted);
}
// __inet_lookup_skb 中获取了报文的源 ip 地址和目的 ip 地址,
// 然后调用 __inet_lookup
static inline struct sock *__inet_lookup_skb(struct inet_hashinfo *hashinfo,
struct sk_buff *skb,
int doff,
const __be16 sport,
const __be16 dport,
const int sdif,
bool *refcounted)
{
const struct iphdr *iph = ip_hdr(skb);
return __inet_lookup(dev_net(skb_dst(skb)->dev), hashinfo, skb,
doff, iph->saddr, sport,
iph->daddr, dport, inet_iif(skb), sdif,
refcounted);
}
// __inet_lookup() 中先从 ehash 中查找,
// 如果找到对应 sock,则说明数据属于一条已经建立的连接,或者正在建立的连接
// 如果在 ehash 中找不到 sock,则从 lhash2 中找,
// 如果在 lhash2 中找到了 sock,说明这个报文可能是一个连接请求报文
// 如果在 ehash 和 lhash2 中都找不到对应的 sock,则这个报文无法处理,要返回错误
static inline struct sock *__inet_lookup(struct net *net,
struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff,
const __be32 saddr, const __be16 sport,
const __be32 daddr, const __be16 dport,
const int dif, const int sdif,
bool *refcounted)
{
sk = __inet_lookup_established(net, hashinfo, saddr, sport,
daddr, hnum, dif, sdif);
*refcounted = true;
if (sk)
return sk;
*refcounted = false;
return __inet_lookup_listener(net, hashinfo, skb, doff, saddr,
sport, daddr, hnum, dif, sdif);
}
5.3.4 断开连接
断开连接要经过四次挥手,我们以客户端主动断开连接为例来分析连接断开过程中的相关源码。
建立连接过程中,sock 创建并加入 hash 表的时机比较早。在第一次握手的时候,客户端就创建了 sock 加入到了 bhash 和 ehash,服务端创建了 request_sock 加入到了 ehash 中。
断开连接过程中,sock 从 hash 表中删除的时机比较晚,均是在最后一个状态。客户端的最后状态是 TIME_WAIT,服务端的最后状态是 LAST_ACK。
① 第一次挥手
// 客户端调用 close(),内核最终调用 tcp_close() 进行关闭。调用栈如下
0xffffffffacc63980 : tcp_close+0x0/0x50 [kernel]
0xffffffffacc95072 : inet_release+0x42/0x80 [kernel]
0xffffffffacb9474d : __sock_release+0x3d/0xa0 [kernel]
0xffffffffacb947c1 : sock_close+0x11/0x20 [kernel]
0xffffffffac72feae : __fput+0xbe/0x250 [kernel]
0xffffffffac50defa : task_work_run+0x8a/0xb0 [kernel]
0xffffffffac403c6b : exit_to_usermode_loop+0xeb/0xf0 [kernel]
0xffffffffac4043f8 : do_syscall_64+0x198/0x1a0 [kernel]
0xfffffffface000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xfffffffface000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
// tcp_close() 调用 __tcp_close() 来执行套接字的关闭
void tcp_close(struct sock *sk, long timeout)
{
lock_sock(sk);
__tcp_close(sk, timeout);
release_sock(sk);
sock_put(sk);
}
void __tcp_close(struct sock *sk, long timeout)
{
if (unlikely(tcp_sk(sk)->repair)) {
} else if (data_was_unread) {
} else if (sock_flag(sk, SOCK_LINGER) && !sk->sk_lingertime) {
} else if (tcp_close_state(sk)) { // tcp_close_state() 函数中会将套接字状态设置成 TCP_FIN_WAIT1
tcp_send_fin(sk); // tcp_send_fin() 函数中会向对端发送 FIN
}
}
// 服务端收到 FIN 报文之后在函数 tcp_fin() 中处理,
// 在此函数中,将状态成 TCP_CLOSE_WAIT,然后向对端回复 ACK
0xffffffffaf26cba0 : tcp_fin+0x0/0x180 [kernel]
0xffffffffaf26e0cf : tcp_data_queue+0x79f/0xb50 [kernel]
0xffffffffaf26e642 : tcp_rcv_established+0x1c2/0x5c0 [kernel]
0xffffffffaf27af8a : tcp_v4_do_rcv+0x12a/0x1e0 [kernel]
0xffffffffaf27d2e3 : tcp_v4_rcv+0xb43/0xc50 [kernel]
② 第二次挥手
服务端回复 ACK,客户端接收到 ACK 后将状态改成 TCP_FIN_WAIT2。
③ 第三次挥手第三次挥手是服务端调用 close(),函数调用栈与客户端调用 tcp_close() 相同。
④ 第四次挥手
第四次挥手,对于服务端来说比较简单,收到 ACK 报文之后就把 sock 从 bhash 和 ehash 删除。而对于客户端来说稍微复杂,因为客户端在发送 ACK 之后不是立即关闭 sock,而是要进入 TIME_WAIT 状态。
void tcp_fin(struct sock *sk)
{
switch (sk->sk_state) {
case TCP_FIN_WAIT2:
// 客户端收到第三次握手的 FIN 报文,则会向对端发送 ACK,之后进入 TIME_WAIT 状态
tcp_send_ack(sk);
tcp_time_wait(sk, TCP_TIME_WAIT, 0);
break;
default:
}
void tcp_time_wait(struct sock *sk, int state, int timeo)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
const struct tcp_sock *tp = tcp_sk(sk);
struct inet_timewait_sock *tw;
struct inet_timewait_death_row *tcp_death_row = &sock_net(sk)->ipv4.tcp_death_row;
// 创建 一个 inet_timewait_sock,这个 sock 在 TIME_WAIT 过程中代替 struct sock 工作
tw = inet_twsk_alloc(sk, tcp_death_row, state);
if (tw) {
// 将一些 TIME_WAIT 状态中用得着的信息赋值个 tw
tcptw->tw_rcv_nxt = tp->rcv_nxt;
// 启动 TIME_WAIT 定时器,定时器超时处理函数是 tw_timer_handler()
inet_twsk_schedule(tw, timeo);
// 在该函数中会将 tw 加入 ehash 和 bhash 中
// 同时将 sk 从 ehash 中删除
inet_twsk_hashdance(tw, sk, &tcp_hashinfo);
} else {
}
// 将 sk 从 bhash 中删除
tcp_done(sk);
}
// TIME_WAIT 定时器处理函数
// 具体工作由 inet_twsk_kill() 实现
static void tw_timer_handler(struct timer_list *t)
{
struct inet_timewait_sock *tw = from_timer(tw, t, tw_timer);
if (tw->tw_kill)
__NET_INC_STATS(twsk_net(tw), LINUX_MIB_TIMEWAITKILLED);
else
__NET_INC_STATS(twsk_net(tw), LINUX_MIB_TIMEWAITED);
inet_twsk_kill(tw);
}
static void inet_twsk_kill(struct inet_timewait_sock *tw)
{
// 将 tw 从 ehash 中删除
sk_nulls_del_node_init_rcu((struct sock *)tw);
// 将 sk 从 bhash 中删除
inet_twsk_bind_unhash(tw, hashinfo);
}
(5)服务端关闭
服务端套接字即 listening sock, 关闭过程比较简单,只涉及到本端的操作,没有交互报文。
// 在 __tcp_close() 入口判断如果是状态是 TCP_LISTEN,则说明是监听套接字
// 通过 tcp_set_state() 将状态设置为 TCP_CLOSE
void __tcp_close(struct sock *sk, long timeout)
{
if (sk->sk_state == TCP_LISTEN) {
tcp_set_state(sk, TCP_CLOSE);
/* Special case. */
inet_csk_listen_stop(sk);
goto adjudge_to_death;
}
}
void tcp_set_state(struct sock *sk, int state)
{
switch (state) {
case TCP_CLOSE:
// 将套接字从 listening_hash 和 lhash2 中删除
sk->sk_prot->unhash(sk);
if (inet_csk(sk)->icsk_bind_hash &&
!(sk->sk_userlocks & SOCK_BINDPORT_LOCK))
// 将套接字从 bhash 中删除
inet_put_port(sk);
fallthrough;
}
}
5.4 队列
队列在网络领域使用较多。队列有两个作用,一个是缓冲,可以将生产者和消费者解耦,适用于生产和消费速度不一致的情况,比如在一段时间内,生产速度大于消费速度,数据就可以在队列中缓存;另一个作用,队列是一种先入先出的数据结构,天然具有保序功能,保序功能属于可靠性的一部分。在网络领域,不仅仅 tcp 协议使用了队列,ip 层的 qdisc 队列,网卡的 ring buffer 均是队列。
tcp 中在多个地方使用了队列,建立连接过程中服务端的连接队列;发包过程中的发送队列,重传队列;收包过程中的接收队列,乱序队列, 共 5 个队列。
5 个队列在数据结构中的位置:
队列 |
所在数据结构 |
成员名 |
连接队列 |
struct inet_connection_sock |
icsk_accept_queue |
发送队列 |
struct sock |
sk_write_queue |
重传队列 |
struct sock |
tcp_rtx_queue |
接收队列 |
struct sock |
sk_receive_queue |
乱序队列 |
struct tcp_sock |
out_of_order_queue |
连接队列属于控制面的队列;发送队列和重传队列属于数据面发送方向的队列,接收队列和乱序队列属于数据面接收方向的队列。
(1)控制面:连接队列
tcp 中的半连接队列和全连接队列共用一个队列,即 icsk_accept_queue。当服务端收到 syn 报文之后,会入队一个 struct request_sock,这个结构体中有一个成员 struct sock *sk,当收到第三次握手的 ack 报文时会创建一个 struct sock 挂到 sk。tcp accept 即获取 struct request_sock 中的 struct sock。队列中 struct request_sock 的个数即半连接队列的长度,struct sock 的个数即全连接队列的长度。如下图所示,连接队列中半连接的个数是 6, 全连接的个数是 4。
两个队列都有一个最大值,用来限制队列的最大长度,这两个最大值均可以通过配置进行修改。半连接队列可以通过 /proc/sys/net/ipv4/tcp_max_syn_backlog 来修改,该配置和机器的内存大小有关系,内存越大,这个配置的默认值就会越大;全连接队列的最大长度是 min(backlog, /proc/sys/net/core/somaxconn),其中 backlog 是 listen 的第二个参数。
最大值 |
实际值 |
|
半连接队列 |
max_syn_backlog |
queue len |
全连接队列 |
sk->sk_max_ack_backlog |
sk->sk_ack_backlog |
连接创建过程中需要考虑一些特殊情况,比如在建立连接过程中客户端挂了,或者队列满了。与这些异常情况相关的配置,主要有以下四个。
/proc/sys/net/ipv4/tcp_syn_retries |
第一次握手时,发送 syn 没有收到 ack 时,尝试发送 syn 的最大次数 |
/proc/sys/net/ipv4/tcp_synack_retries |
第二次握手时,服务端发送 syn + ack 后收不到 ack 时,尝试发送 syn + ack 的最大次数 |
/proc/sys/net/ipv4/tcp_syncookies |
半连接队列满的时候的处理方式,如果开启了 cookie,那么就不会将新的 struct request_sock 放到半连接队列中,而是通过 cookie 来建立连接;如果没有开启 cookie,则直接丢掉客户端发过来的 syn 包,客户端收不到 syn + ack 报文时便会重传 syn 报文,最大重传次数受 /proc/sys/net/ipv4/tcp_synack_retries 限制。 |
/proc/sys/net/ipv4/tcp_abort_on_overflow |
全连接队列满的时候的的处理策略受这个配置影响,如果是 0 则丢弃 ack 报文,如果是 1 则向对端发送 rst 报文 |
下边通过源码,看一下以上四个配置的使用场景。
① /proc/sys/net/ipv4/tcp_syn_retries
当客户端发起连接的时候会向服务端发送 syn 报文,如果没有收到服务端的 syn + ack 报文,就会重传,最大重传次数受这个配置限制。
static int tcp_write_timeout(struct sock *sk)
{
// icsk->icsk_retransmits 为实际的重传次数
// 超过最大重传次数则放弃重传,创建连接失败
if ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) {
if (icsk->icsk_retransmits)
__dst_negative_advice(sk);
retry_until = icsk->icsk_syn_retries ? : net->ipv4.sysctl_tcp_syn_retries;
expired = icsk->icsk_retransmits >= retry_until;
} else {
}
}
② /proc/sys/net/ipv4/tcp_synack_retries
创建连接过程中,第二次握手,服务端向客户端发送 syn + ack 报文,如果服务端没有收到客户端的 ack,那么就会重传 syn + ack 报文,最大重传次数受这个配置限制。
// req->num_timeout 是 syn + ack 重传的次数
// 最大值是 tcp_synack_retries
static void syn_ack_recalc(struct request_sock *req,
const int max_syn_ack_retries,
const u8 rskq_defer_accept, int *expire, int *resend)
{
if (!rskq_defer_accept) {
*expire = req->num_timeout >= max_syn_ack_retries;
*resend = 1;
return;
}
*expire = req->num_timeout >= max_syn_ack_retries &&
(!inet_rsk(req)->acked ||
req->num_timeout >= rskq_defer_accept);
*resend = !inet_rsk(req)->acked ||
req->num_timeout >= rskq_defer_accept - 1;
}
③ /proc/sys/net/ipv4/tcp_abort_on_overflow
struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
struct request_sock *req,
bool fastopen, bool *req_stolen)
{
// 对于 tcp v4 来说 syn_recv_sock 即函数 tcp_v4_syn_recv_sock
// 在函数 tcp_v4_syn_recv_sock 中会判断全连接队列是不是满
// 如果满的话,那么返回的 child 是 NULL,进而走到 listen_overflow 分支
child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL,
req, &own_req);
if (!child)
goto listen_overflow;
listen_overflow:
//如果 sysctl_tcp_abort_on_overflow 是 0,则直接返回 NULL
// 在上级函数中直接丢弃报文
// 如果是 1 的话,则向对端回 rst 报文
if (!sock_net(sk)->ipv4.sysctl_tcp_abort_on_overflow) {
inet_rsk(req)->acked = 1;
return NULL;
}
embryonic_reset:
// 向对端发送 rst 报文
if (!(flg & TCP_FLAG_RST)) {
req->rsk_ops->send_reset(sk, skb);
} else if (fastopen) { /* received a valid RST pkt */
reqsk_fastopen_remove(sk, req, true);
tcp_reset(sk, skb);
}
return NULL;
}
// 这个函数中会判断全连接队列是不是满,
// 如果是满的会则走 exit_overflow 分支,返回 NULL
// 否则返回 struct sock
struct sock *tcp_v4_syn_recv_sock(const struct sock *sk, struct sk_buff *skb,
struct request_sock *req,
struct dst_entry *dst,
struct request_sock *req_unhash,
bool *own_req)
{
if (sk_acceptq_is_full(sk))
goto exit_overflow;
}
④ /proc/sys/net/ipv4/tcp_syncookies
正常情况下,服务端收到 syn 报文之后,会创建一个 struct request_sock,并将之放到半连接队列。但是,在半连接队列满的时候,处理方式受 tcp_syncookies 的影响,tcp_syncookies 有三个取值,分别是 0、1、2, 对应不同的处理方式。
tcp_syncookies 取值 |
处理方式 |
0 |
将 syn 报文丢掉 |
1 |
默认值。队列满了,使用 cookies 建立连接 |
2 |
不使用队列,直接使用 cookies 建立连接,用于测试 |
使能 syncookies 之后,服务端收到 syn 报文时,就不会创建一个 struct request_sock 放入连接队列,而是使用密码学的思想,将连接过程的信息经过一定的编码方式放入 cookies (初始序列号或者时间戳),发送给客户端,客户端返回 ack 的时候,这些信息也会随着报文返回,最后服务端通过解析 cookies 来获取连接的信息,最终完成连接的建立。
(2)数据面,发送方向:发送队列,重传队列
发送队列使用双向循环链表来管理,重传队列使用红黑树来管理。
下图展示了报文在发送过程中经过发送队列和重传队列的过程。用户调用 send() 后最终在内核里边会调用到 tcp_sendmsg_locked(), 在该函数中将报文生产到发送队列;tcp_write_xmit() 做真正的发送操作,报文发送之后即从发送队列中移除,然后加入到乱序队列;重传的时候会将重传队列中的报文发送出去,重传不会将报文从重传队列中移除,只有收到这个报文的 ack,才会真正将报文从重传队列中移除。
(3)数据面,接收方向:接收队列,乱序队列
接收队列使用双向循环链表来管理,乱序队列使用红黑树来管理。发送侧一个双向循环链表,一个红黑树,接收侧一个双向循环链表,一个红黑树。
rcv_next 是接收侧要接收的下一个字节序列号,比如是 2000,如果当前收到的报文的序列号正好是 2000,那么这个报文就会直接入队到接收队列,之后用户调用 tcp_recvmsg() 的时候就可以将报文拷贝给用户;如果当前收到的报文不是 2000,比如是 2500,那么这个报文就不能放入接收队列,因为在 2500 之前的 2000 ~ 2499 还没有接收,现在收到的这个报文是乱序报文,放入乱序队列。
那么放入乱序队列中的报文什么时候出队呢,还是以上边这个例子为参考,如果后边收到了序列号是 2000 的报文,并且将 2000 ~ 2499 这个空洞填满,那么就会将序列号为 2500 的报文从乱序队列出队,入队到接收队列中。
// 在这个函数中会判断接收报文 seq 和 rcv_next 之间的关系
static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
{
// 如果报文的序列号和 rcv_next 相等,则直接将报文放入接收队列
// 放入接收队列的报文,可以被用户接收
if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
// 将报文放入接收队列
eaten = tcp_queue_rcv(sk, skb, &fragstolen);
if (skb->len)
tcp_event_data_recv(sk, skb);
// 每接收一个报文,都要尝试将乱序队列中的报文转移到接收队列
// 因为这个报文可能将乱序队列中的空洞填上
if (!RB_EMPTY_ROOT(&tp->out_of_order_queue)) {
tcp_ofo_queue(sk);
}
}
// 如果 seq 比 rcv_nxt 大,那么就将报文放入乱序队列
tcp_data_queue_ofo(sk, skb);
}
6 tcp 主要流程调用栈
6.1 创建 socket
0xffffffff9c85e3f0 : tcp_init_sock+0x0/0x170 [kernel]
0xffffffff9c878cee : tcp_v4_init_sock+0xe/0x30 [kernel]
0xffffffff9c896056 : inet_create+0x1d6/0x360 [kernel]
0xffffffff9c79596f : __sock_create+0xcf/0x1a0 [kernel]
0xffffffff9c797777 : __sys_socket+0x57/0xe0 [kernel]
0xffffffff9c797816 : __x64_sys_socket+0x16/0x20 [kernel]
0xffffffff9c0042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff9ca000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff9ca000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
6.2 收包调用栈
典型的收包过程如下,收包过程,底层调用上层,设备驱动收到包之后传递给 ip,ip 传送给 tcp。
0xffffffff9c79be80 : sock_def_readable+0x0/0x60 [kernel]
0xffffffff9c86e98f : tcp_rcv_established+0x50f/0x5c0 [kernel]
0xffffffff9c87af8a : tcp_v4_do_rcv+0x12a/0x1e0 [kernel]
0xffffffff9c87d2e3 : tcp_v4_rcv+0xb43/0xc50 [kernel]
0xffffffff9c850abc : ip_protocol_deliver_rcu+0x2c/0x1d0 [kernel]
0xffffffff9c850cad : ip_local_deliver_finish+0x4d/0x60 [kernel]
0xffffffff9c850da0 : ip_local_deliver+0xe0/0xf0 [kernel]
0xffffffff9c85102b : ip_rcv+0x27b/0x36f [kernel]
0xffffffff9c7be835 : __netif_receive_skb_core+0x5c5/0xca0 [kernel]
0xffffffff9c7befad : netif_receive_skb_internal+0x3d/0xb0 [kernel]
0xffffffff9c7bfa0a : napi_gro_receive+0xba/0xe0 [kernel]
0xffffffffc046964e
0xffffffffc046964e (inexact)
0xffffffffc04680d7 (inexact)
0xffffffff9c7c026d : __napi_poll+0x2d/0x130 [kernel] (inexact)
0xffffffff9c7c0763 : net_rx_action+0x253/0x320 [kernel] (inexact)
0xffffffff9cc000d7 : __do_softirq+0xd7/0x2d6 [kernel] (inexact)
0xffffffff9c0f27a7 : irq_exit+0xf7/0x100 [kernel] (inexact)
0xffffffff9ca01e7f : do_IRQ+0x7f/0xd0 [kernel] (inexact)
0xffffffff9ca00a8f : ret_from_intr+0x0/0x1d [kernel] (inexact)
0xffffffff9c97cd9e : native_safe_halt+0xe/0x10 [kernel] (inexact)
0xffffffff9c97cc5a : __cpuidle_text_start+0xa/0x10 [kernel] (inexact)
0xffffffff9c97cef0 : default_idle_call+0x40/0xf0 [kernel] (inexact)
0xffffffff9c122354 : do_idle+0x1f4/0x260 [kernel] (inexact)
0xffffffff9c12258f : cpu_startup_entry+0x6f/0x80 [kernel] (inexact)
0xffffffff9c05929b : start_secondary+0x19b/0x1e0 [kernel] (inexact)
0xffffffff9c000107 : secondary_startup_64_no_verify+0xc2/0xcb [kernel] (inexact)
文章来源:https://www.toymoban.com/news/detail-829869.html
6.3 发包调用栈
0xffffffff9c854a40 : ip_finish_output2+0x0/0x430 [kernel]
0xffffffff9c856630 : ip_output+0x70/0xe0 [kernel]
0xffffffff9c85605d : __ip_queue_xmit+0x15d/0x430 [kernel]
0xffffffff9c872592 : __tcp_transmit_skb+0x552/0xaf0 [kernel]
0xffffffff9c873eb5 : tcp_write_xmit+0x435/0x12b0 [kernel]
0xffffffff9c874d62 : __tcp_push_pending_frames+0x32/0xf0 [kernel]
0xffffffff9c860c68 : tcp_sendmsg_locked+0xc38/0xda0 [kernel]
0xffffffff9c860df7 : tcp_sendmsg+0x27/0x40 [kernel]
0xffffffff9c79713e : sock_sendmsg+0x3e/0x50 [kernel]
0xffffffff9c7971e7 : sock_write_iter+0x97/0x100 [kernel]
0xffffffff9c32baf2 : new_sync_write+0x112/0x160 [kernel]
0xffffffff9c32f1e5 : vfs_write+0xa5/0x1a0 [kernel]
0xffffffff9c32f45f : ksys_write+0x4f/0xb0 [kernel]
0xffffffff9c0042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff9ca000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff9ca000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
发送的系统调用可以调用 write() 也可以调用 sendto(),调用这两个的时候,系统的调用栈稍有不同,两者都会调用 sock_sendmsg(), sock_sendmsg() 后边的调用逻辑就是相同的了。文章来源地址https://www.toymoban.com/news/detail-829869.html
fs/ write() 系统调用:
write() 更偏向于文件系统的语义,所以会通过 f->ops 进行调用,
从 write() 系统调用定义的位置也能看出来,定义在 fs/read_write.c 中
对于 socket 的的 ops 就是 socket_file_ops,对应的发送函数是 sock_write_iter()。
0xffffffff91397100 : sock_sendmsg+0x0/0x50 [kernel]
0xffffffff913971e7 : sock_write_iter+0x97/0x100 [kernel]
0xffffffff90f2baf2 : new_sync_write+0x112/0x160 [kernel]
0xffffffff90f2f1e5 : vfs_write+0xa5/0x1a0 [kernel]
0xffffffff90f2f45f : ksys_write+0x4f/0xb0 [kernel]
0xffffffff90c042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
sendto() 系统调用:
sendto() 更偏向于网络的语义,所以在内核中会通过 fd 找到对应的 struct file 结构体,
file->private_data 存放的就是 socket 结构体,进而以找到对应的发送函数。
sendto() 系统调用定义在 net/socket.c 中
0xffffffff91397100 : sock_sendmsg+0x0/0x50 [kernel]
0xffffffff9139843e : __sys_sendto+0xee/0x160 [kernel]
0xffffffff913984d4 : __x64_sys_sendto+0x24/0x30 [kernel]
0xffffffff90c042bb : do_syscall_64+0x5b/0x1a0 [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel]
0xffffffff916000ad : entry_SYSCALL_64_after_hwframe+0x65/0xca [kernel] (inexact)
到了这里,关于linux tcp 主要数据结构的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!