深入理解Linux网络笔记(六):深度理解TCP连接建立过程

本文为《深入理解Linux网络》学习笔记,使用的Linux源码版本是3.10,网卡驱动默认采用的都是Intel的igb网卡驱动

Linux源码在线阅读:https://elixir.bootlin.com/linux/v3.10/source

5、深度理解TCP连接建立过程

1)、深入理解listen

在服务端程序里,在开始接收请求之前都需要先执行listen系统调用

1)listen系统调用

可以在net/socket.c下找到listen系统调用的源码

// net/socket.c
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
	...
	// 根据fd查找socket内核对象
	sock = sockfd_lookup_light(fd, &err, &fput_needed);
	if (sock) {
		// 获取内核参数net.core.somaxconn
		somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
		if ((unsigned int)backlog > somaxconn)
			backlog = somaxconn;
		// 调用协议栈注册的listen函数
		err = security_socket_listen(sock, backlog);
		...
	}
	return err;
}

用户态的socket文件描述符只是一个整数而已,内核是没有办法直接用的。所以该函数中第一行代码就是根据用户传入的文件描述符来查找对应的socket内核对象

再接着获取了系统里的net.core.somaxconn内核参数的值,和用户传入的backlog比较后取一个最小值传入下一步

所以,虽然listen允许我们传入backlog(该值和半连接队列、全连接队列都有关系),但是如果用户传入的值比net.core.somaxconn还大的话是不会起作用的

接着通过调用sock->ops->listen进入协议栈的listen函数

2)协议栈listen

sock->ops->listen指针指的是inet_listen函数

// net/ipv4/af_inet.c
int inet_listen(struct socket *sock, int backlog)
{
	...
	// 还不是listen状态(尚未listen过)
	if (old_state != TCP_LISTEN) {
		...
		// 开始监听
		err = inet_csk_listen_start(sk, backlog);
		...
	}
	// 设置全连接队列长度
	sk->sk_max_ack_backlog = backlog;
	...
}

sk->sk_max_ack_backlog是全连接队列的最大长度。所以,服务端的全连接队列长度是执行listen函数时传入backlog和net.core.somaxconn之间较小的那个值

如果在线上遇到了全连接队列溢出的问题,想加大该队列长度,那么可能需要同时考虑执行listen函数式传入的backlog和net.core.somaxconn之

inet_csk_listen_start函数源码如下:

// net/ipv4/inet_connection_sock.c
int inet_csk_listen_start(struct sock *sk, const int nr_table_entries)
{
	...
	struct inet_connection_sock *icsk = inet_csk(sk);
	// icsk->icsk_accept_queue是接收队列
	// 1.接收队列数据结构的定义
	// 2.接收队列的申请和初始化
	int rc = reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
	...
}

在函数一开始,将struct sock对象强制转换成了inet_connection_sock,名叫icsk

这里简单讲讲为什么可以这么强制转换,这是因为inet_connection_sock是包含sock的。tcp_sock、inet_connection_sock、inet_sock、sock是逐层嵌套的关系,如下图所示,类似面向对象里继承的概念

深入理解Linux网络笔记(六):深度理解TCP连接建立过程_第1张图片

对于TCP的socket来说,sock对象实际上是一个tcp_sock。因为TCP中的sock对象随时可以强制类型转换为tcp_sock、inet_connection_sock、inet_sock来使用

在接下来的一行reqsk_queue_alloc中实际上包含了两件重要的事情

  1. 接收队列数据结构的定义
  2. 接收队列的申请和初始化
3)接收队列定义

icsk->icsk_accept_queue定义在inet_connection_sock下,是一个request_sock_queue类型的对象,是内核用来接收客户端请求的主要数据结构。我们平时说的全连接队列、半连接队列全都是在这个数据结构里实现的,如下图所示:

深入理解Linux网络笔记(六):深度理解TCP连接建立过程_第2张图片
// include/net/inet_connection_sock.h
struct inet_connection_sock {
	struct inet_sock	  icsk_inet;
	struct request_sock_queue icsk_accept_queue;
	...
};

request_sock_queue的定义如下:

// include/net/request_sock.h
struct request_sock_queue {
	// 全连接队列
	struct request_sock	*rskq_accept_head;
	struct request_sock	*rskq_accept_tail;
	...
	// 半连接队列
	struct listen_sock	*listen_opt;
	...
};

对于全连接队列来说,在它上面不需要进行复杂的查找工作,accept处理的时候只是先进先出地接受就好了。所以全连接队列通过rskq_accept_head和rskq_accept_tail以链表的形式来管理

和半连接队列相关的数据对象是listen_opt,它是listen_sock类型的

// include/net/request_sock.h
struct listen_sock {
	u8			max_qlen_log;
	...
	u32			nr_table_entries;
	struct request_sock	*syn_table[0];
};

因为服务端需要在第三次握手时快速地查找出来第一次握手时留存的request_sock对象,所以其实是用了一个哈希表来管理,就是struct request_sock *syn_table[0]。max_qlen_log和nr_table_entries都和半连接队列的长度有关

4)接收队列申请和初始化

了解了全/半连接队列数据结构以后,再回到inet_csk_listen_start函数中。它调用了reqsk_queue_alloc来申请和初始化icsk_accept_queue这个重要对象

// net/ipv4/inet_connection_sock.c
int inet_csk_listen_start(struct sock *sk, const int nr_table_entries)
{
	...
	int rc = reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
	...
}

在reqsk_queue_alloc这个函数中完成了接收队列request_sock_queue内核对象的创建和初始化。其中包括内存申请、半连接队列长度的计算、全连接队列头的初始化,等等

// net/core/request_sock.c
int reqsk_queue_alloc(struct request_sock_queue *queue,
		      unsigned int nr_table_entries)
{
	size_t lopt_size = sizeof(struct listen_sock);
	struct listen_sock *lopt;
	// 计算半连接队列的长度
	nr_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog);
	nr_table_entries = max_t(u32, nr_table_entries, 8);
	nr_table_entries = roundup_pow_of_two(nr_table_entries + 1);
	// 为listen_sock对象申请内存,这里包含了半连接队列
	lopt_size += nr_table_entries * sizeof(struct request_sock *);
	if (lopt_size > PAGE_SIZE)
		lopt = vzalloc(lopt_size);
	else
		lopt = kzalloc(lopt_size, GFP_KERNEL);
	...
	// 全连接队列头初始化
	queue->rskq_accept_head = NULL;
	// 半连接队列设置
	lopt->nr_table_entries = nr_table_entries;
	...
	queue->listen_opt = lopt;
	...
}

开头定义了一个struct listen_sock指针。这个listen_sock就是半连接队列

接下来计算半连接队列的长度。计算出来实际大小以后,开始申请内存。最后将全连接队列头queue->rskq_accept_head设置成了NULL,将半连接队列挂到了接收队列queue上

半连接队列上每个元素分配的是一个指针大小sizeof(struct request_sock *)。这其实是一个哈希表。真正的半连接用的request_sock对象是在握手过程中分配的,计算完哈希值后挂到这个哈希表上

5)半连接队列长度计算

reqsk_queue_alloc函数计算了半连接队列的长度

// net/core/request_sock.c
int reqsk_queue_alloc(struct request_sock_queue *queue,
		      unsigned int nr_table_entries)
{
	...
	// 计算半连接队列的长度
	nr_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog);
	nr_table_entries = max_t(u32, nr_table_entries, 8);
	nr_table_entries = roundup_pow_of_two(nr_table_entries + 1);
	...
	// 为了效率,不记录nr_table_entries
	// 而是记录2的N次幂等于nr_table_entries
	for (lopt->max_qlen_log = 3;
	     (1 << lopt->max_qlen_log) < nr_table_entries;
	     lopt->max_qlen_log++);
	...
}

传进来nr_table_entries在最初调用reqsk_queue_alloc的地方可以看到,它是内核参数net.core.somaxconn和用户调用listen时传入的backlog二者之间的较小值

在这个reqsk_queue_alloc函数里,又将会完成三次的对比和计算

  • min_t(u32, nr_table_entries, sysctl_max_syn_backlog)这句是再次和sysctl_max_syn_backlog内核对象取了一次最小值
  • max_t(u32, nr_table_entries, 8)这句保证nr_table_entries不能比8小,这是用来避免新手用户传入一个太小的值导致无法建立连接的
  • roundup_pow_of_two(nr_table_entries + 1)是用来上对齐到2的整数次幂的

下面通过两个实际的案例计算一下

假设:某服务器上内核参数net.core.somaxconn为128,net.ipv4.tcp_max_sync_backlog为8192。那么当用户backlog传入5时,半连接队列到底是多长呢?

和代码一样,计算分为四步,最终结果为16

  1. min(backlog, somaxconn)=min(5, 128)=5
  2. min(5, tcp_max_sync_backlog)=min(5, 8192)=5
  3. max(5, 8)=8
  4. roundup_pow_of_two(8+1)=16

somaxconn和tcp_max_sync_backlog保持不变,listen时的backlog加大到512。再算一遍,结果为256

  1. min(backlog, somaxconn)=min(512, 128)=128
  2. min(128, tcp_max_sync_backlog)=min(128, 8192)=128
  3. max(128, 8)=128
  4. roundup_pow_of_two(128+1)=256

半连接队列的长度是min(backlog, somaxconn, tcp_max_sync_backlog)+1再向上取整到2的N次幂,但最小不能小于16

如果在线上遇到了半连接队列溢出的问题,想加大该队列长度,那么就需要同时考虑somaxconn、backlog和tcp_max_sync_backlog三个内核参数

为了提升性能,内核并没有直接记录半连接队列的长度。而是采用了一种晦涩的方法,只记录其N次幂。假设队列长度为16,则记录max_qlen_log为4(2的4次方等于16),假设队列长度为256,则记录max_qlen_log为8(2的8次方等于256)

6)listen过程小结

listen最主要的工作就是申请和初始化接收队列,包括全连接队列和半连接队列。其中全连接队列是一个链表,而半连接队列由于需要快速地查找,所以使用的是一个哈希表。全/半两个队列是三次握手中很重要的两个数据结构,有了它们服务端才能正常响应来自客户端的三次握手。所以服务端都需要调用listen才行

全连接队列的长度:对于全连接队列来说,其最大长度是listen时传入的backlog和net.core.somaxconn之间较小的那个值。如果需要加大全连接队列长度,那么就要调整backlog和somaxconn

半连接队列的长度:对于半连接队列来说,其最大长度是min(backlog, somaxconn, tcp_max_sync_backlog)+1再向上取整到2的N次幂,但最小不能小于16。如果需要加大半连接队列长度,那么需要一并考虑backlog、somaxconn和tcp_max_sync_backlog这三个参数

2)、深入理解connect

客户端在发起连接的时候,创建一个socket,然后瞄准服务端调用connect就可以了

int main() {
    fd = socket(AF_INET, SOCK_STREAM, 0);
    connect(fd, ...);
    ...
}

socket函数执行完毕后,从用户层视角看到返回了一个文件描述符fd。但在内核中其实是一套内核对象组合,包含file、socket、sock等多个相关内核对象构成,每个内核对象还定义了ops操作函数集合

深入理解Linux网络笔记(六):深度理解TCP连接建立过程_第3张图片

接下来就进入connect函数的执行过程

1)connect调用链展开

当在客户端机上调用connect函数的时候,事实上会进入内核的系统调用源码中执行

// net/socket.c
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,
		int, addrlen)
{
	struct socket *sock;
	...
    // 根据用户fd查找内核中的socket对象
	sock = sockfd_lookup_light(fd, &err, &fput_needed);
	...
    // 进行connect
	err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,
				 sock->file->f_flags);
    ...
}

这段代码首先根据用户传入的fd(文件描述符)来查找对应的socket内核对象。对于AF_INET类型的socket内核对象来说,sock->ops->connect指针指向的是inet_stream_connect函数

// net/ipv4/af_inet.c
int inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
			int addr_len, int flags)
{
	...
	err = __inet_stream_connect(sock, uaddr, addr_len, flags);
	...
}

int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
			  int addr_len, int flags)
{
	struct sock *sk = sock->sk;
	...
	switch (sock->state) {
	...
	case SS_UNCONNECTED:
		...
		err = sk->sk_prot->connect(sk, uaddr, addr_len);
		...
		sock->state = SS_CONNECTING;
        ...
		break;
	}
  ...
}

刚创建完毕的socket的新状态就是SS_UNCONNECTED,所以__inet_stream_connect中的switch判断会进入case SS_UNCONNECTED的处理逻辑中

上述代码中sk取的是sock对象。对于AF_INET类型的TCP socket来说,sk->sk_prot->connect指针指向的是tcp_v4_connect方法

// net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
	...
    // 设置socket状态为TCP_SYN_SENT
	tcp_set_state(sk, TCP_SYN_SENT);
    // 动态选择一个端口
	err = inet_hash_connect(&tcp_death_row, sk);
	...
    // 函数用来根据sk中的信息,构建一个syn报文,并将它发送出去
	err = tcp_connect(sk);
    ...
}

在这里将把socket状态设置为TCP_SYN_SENT。再通过inet_hash_connect来动态地选择一个可用的端口

2)选择可用端口
// net/ipv4/inet_hashtables.c
int inet_hash_connect(struct inet_timewait_death_row *death_row,
		      struct sock *sk)
{
	return __inet_hash_connect(death_row, sk, inet_sk_port_offset(sk),
			__inet_check_established, __inet_hash_nolisten);
}

在调用__inet_hash_connect时传入的两个重要参数:

  • inet_sk_port_offset(sk):这个函数根据要连接的目的IP和端口等信息生成一个随机数
  • __inet_check_established:检查是否和现有ESTABLISH状态的连接冲突的时候用的函数

__inet_hash_connect函数比较长,先看前面这一段

// net/ipv4/inet_hashtables.c
int __inet_hash_connect(struct inet_timewait_death_row *death_row,
		struct sock *sk, u32 port_offset,
		int (*check_established)(struct inet_timewait_death_row *,
			struct sock *, __u16, struct inet_timewait_sock **),
		int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{
	...
    // 是否绑定过端口
	const unsigned short snum = inet_sk(sk)->inet_num;
	...
	if (!snum) {
		// 获取本地端口配置
		inet_get_local_port_range(&low, &high);
		...
        // 遍历查找
		for (i = 1; i <= remaining; i++) {
			port = low + (i + offset) % remaining;
			...
		}
		...
	}
    ...
}

在这个函数中首先判断了inet_sk(sk)->inet_num,如果调用过bind,那么这个函数会选择好端口并设置是在inet_num上。假设没有调用过bind,所以snum为0

接着调用inet_get_local_port_range,这个函数读取的是net.ipv4.ip_local_port_range这个内核参数,来读取管理员配置的可用的端口范围

net.ipv4.ip_local_port_range的默认值为32768 61000,意味着端口总可用的数量是61000-32768=28233个

接下来进入了for循环。其中offset是通过inet_sk_port_offset(sk)计算出的随机数。那这段循环的作用就是从某个随机数开始,把整个可用端口范围遍历一遍。直到找到可用的端口后停止

接下来看看如何确定一个端口是否可用

// net/ipv4/inet_hashtables.c
int __inet_hash_connect(struct inet_timewait_death_row *death_row,
		struct sock *sk, u32 port_offset,
		int (*check_established)(struct inet_timewait_death_row *,
			struct sock *, __u16, struct inet_timewait_sock **),
		int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{
        ...
		for (i = 1; i <= remaining; i++) {
			port = low + (i + offset) % remaining;
            // 查看是否是保留端口,是则跳过
			if (inet_is_reserved_local_port(port))
				continue;
            // 查找和遍历已经使用的端口的哈希链表
			head = &hinfo->bhash[inet_bhashfn(net, port,
					hinfo->bhash_size)];
			...
			inet_bind_bucket_for_each(tb, &head->chain) {
                // 如果端口已经被使用
				if (net_eq(ib_net(tb), net) &&
				    tb->port == port) {
					...
                    // 通过check_established继续检查是否可用
					if (!check_established(death_row, sk,
								port, &tw))
						goto ok;
					...
				}
			}
            // 未使用的话
			tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep,
					net, head, port);
			...
			goto ok;
		...
		}
		...
		return -EADDRNOTAVAIL;

ok:
		...
}

首先调用inet_is_reserved_local_port,判断要选择的端口是否在inet.ipv4.ip_local_reserved_ports中,在的话就不能用

整个系统中会维护一个所有使用过的端口的哈希表,它就是hinfo->bhash。接下来的代码就会在这里查找端口。如果在哈希表中没有找到,那么说明这个端口是可用的。至此端口就算是找到了。这个时候通过inet_bind_bucket_create申请一个inet_bind_bucket来记录端口已经使用了,并用哈希表的形式都管理了起来

遍历完所有端口都没找到合适的,就返回-EADDRNOTAVAIL,在用户程序上看到的就是Cannot assign requested address这个错误

当遇到Cannot assign requested address错误,应该去查一下net.ipv4.ip_local_port_range中设置的可用端口的范围是不是太小了

3)端口被使用过怎么办
// net/ipv4/inet_hashtables.c
int __inet_hash_connect(struct inet_timewait_death_row *death_row,
		struct sock *sk, u32 port_offset,
		int (*check_established)(struct inet_timewait_death_row *,
			struct sock *, __u16, struct inet_timewait_sock **),
		int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{
        ...
		for (i = 1; i <= remaining; i++) {
			port = low + (i + offset) % remaining;
			...
			inet_bind_bucket_for_each(tb, &head->chain) {
                // 如果端口已经被使用
				if (net_eq(ib_net(tb), net) &&
				    tb->port == port) {
					...
                    // 通过check_established继续检查是否可用
					if (!check_established(death_row, sk,
								port, &tw))
						goto ok;
					...
				}
			}
			...
		}
		...
}

port在bhash中如果已经存在,就表示有其他的连接使用过该端口了。请注意,如果check_established返回0,该端口仍然可以接着使用

一个端口怎么可以被用多次呢?

回忆一下四元组的概念,两对四元组中只要任意一个元素不同,都算是两条不同的连接。以下的两条TCP连接完全可以同时存在(假设192.168.1.101是客户端,192.168.1.100是服务端)

  • 连接1:192.168.1.101 5000 192.168.1.100 8090
  • 连接2:192.168.1.101 5000 192.168.1.100 8091

check_established作用就是检测现有的TCP连接中是否四元组和要建立的连接四元素完全一致。如果不完全一致,那么该端口仍然可用

这个check_established是由调用方传入的,实际上使用的是__inet_check_established,源码如下:

// net/ipv4/inet_hashtables.c
static int __inet_check_established(struct inet_timewait_death_row *death_row,
				    struct sock *sk, __u16 lport,
				    struct inet_timewait_sock **twp)
{
	...
    // 找到哈希桶
	struct inet_ehash_bucket *head = inet_ehash_bucket(hinfo, hash);
	...
    // 便利看看有没有四元组一样的,一样的话就报错
	sk_nulls_for_each(sk2, node, &head->twchain) {
		if (sk2->sk_hash != hash)
			continue;

		if (likely(INET_TW_MATCH(sk2, net, acookie,
					 saddr, daddr, ports, dif))) {
			tw = inet_twsk(sk2);
			if (twsk_unique(sk, sk2, twp))
				goto unique;
			else
				goto not_unique;
		}
	}
	...
unique:
	// 要用了,记录,返回0(成功)
	return 0;

not_unique:
	...
	return -EADDRNOTAVAIL;
}

该函数首先找到inet_ehash_bucket,这个和bhash类似,只不过这是所有ESTABLISH状态的socket组成的哈希表。然后遍历这个哈希表,使用INET_TW_MATCH来判断是否可用

INET_TW_MATCH源码如下:

// include/net/inet_hashtables.h
#define INET_TW_MATCH(__sk, __net, __cookie, __saddr, __daddr, __ports, __dif) \
	((inet_twsk(__sk)->tw_portpair == (__ports))	&&		\
	 (inet_twsk(__sk)->tw_daddr	== (__saddr))	&&		\
	 (inet_twsk(__sk)->tw_rcv_saddr	== (__daddr))	&&		\
	 (!(__sk)->sk_bound_dev_if	||				\
	   ((__sk)->sk_bound_dev_if == (__dif))) 	&&		\
	 net_eq(sock_net(__sk), (__net)))

在INET_TW_MATCH中将__saddr__daddr__ports都进行了比较。当然除了IP和端口,INET_TW_MATCH还比较了其他一些项目

如果匹配,就是四元组完全一致的连接,所以这个端口不可用,也返回-EADDRNOTAVAIL

如果不匹配,哪怕四元组中有一个元素不一样,例如服务端的端口号不一样,那么就返回0,表示该端口仍然可用于建立新连接

所以一台客户端机最大能建立的连接数并不是65535。只要服务端足够多,单机发出百万条连接没有任何问题

4)发起syn请求

再回到tcp_v4_connect

// net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
	...
    // 设置socket状态为TCP_SYN_SENT
	tcp_set_state(sk, TCP_SYN_SENT);
    // 动态选择一个端口
	err = inet_hash_connect(&tcp_death_row, sk);
	...
    // 函数用来根据sk中的信息,构建一个syn报文,并将它发送出去
	err = tcp_connect(sk);
  ...
}

这时inet_hash_connect已经返回了一个可用端口,接下来就进入tcp_connect

// net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{
	...
    // 申请并设置skb
	buff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);
	...
	tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
	...
    // 添加到发送队列sk_write_queue
	tcp_connect_queue_skb(sk, buff);
	...
    // 实际发出syn
	err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
	      tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
	...
    // 启动重传定时器
	inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
				  inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
	return 0;
}

tcp_connect做了这么几件事:

  • 申请一个skb,并将其设置为SYN包
  • 添加到发送队列上
  • 调用tcp_transmit_skb将该包发出
  • 启动一个重传定时器,超时会重发

该定时器的作用是等到一定时间后收不到服务端的反馈的时候来开启重传。首次超时时间是在TCP_TIMEOUT_INIT宏中定义的,该值在Linux 3.10版本中是1秒

// net/ipv4/tcp_output.c
void tcp_connect_init(struct sock *sk)
{
	...
    // 初始化为TCP_TIMEOUT_INIT
	inet_csk(sk)->icsk_rto = TCP_TIMEOUT_INIT;
	...
}

TCP_TIMEOUT_INIT在include/net/tcp.h中被定义成了1秒

// include/net/tcp.h
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))
5)connect小结

客户端在执行connect函数的时候,把本地socket状态设置成了TCP_SYN_SENT,选了一个可用的端口,接着发出SYN握手请求并启动重传定时器

TCP连接中客户端的端口会在两个位置确定

第一个位置,在connect的时候,会随机地从ip_local_port_range选择一个位置开始循环判断。找到可用端口后,发出syn握手包。如果端口查找失败,会报错Cannot assign requested address。这个时候应该首先想到去检查一下服务器上的net.ipv4.ip_local_port_range参数,是不是可以再放得多一些

如果因为某些原因不希望某些端口被用到,那么把它们写到inet.ipv4.ip_local_reserved_ports参数中就行了,内核在选择的时候会跳过这些端口

另外还要注意一个端口是可以被用于多条TCP连接

这里选择端口都是从ip_local_port_range范围中的某一个随机位置开始循环的。如果可用端很充足,则能快一些找到可用端口,那循环很快就能退出。假设实际中ip_local_port_range中的端口快被用光了,这时候内核就大概率要把循环多执行很多轮才能找到可用端口,这会导致connect系统调用的CPU开销上涨

如果在connect之前使用了bind,将会使得connect系统调用时的端口选择方式无效。转而使用bind时确定的端口。调用bind时如果传入了端口号,会尝试首先使用该端口号,如果传入了0,也会自动选择一个。但默认情况下一个端口只会被使用一次。所以对于客户端角色的socket,不建议使用bind

3)、完整TCP连接建立过程

在基于TCP的服务开发中,三次握手的主要流程如下图所示:

深入理解Linux网络笔记(六):深度理解TCP连接建立过程_第4张图片

服务端核心逻辑时创建socket绑定端口,listen监听,最后accept接收客户端的请求

// 服务端核心代码
int main() {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    bind(fd, ...);
    listen(fd, 128);
    accept(fd, ...);
}

客户端的核心逻辑是创建socket,然后调用connect连接服务端

// 客户端核心代码
int main() {
    fd = socket(AF_INET, SOCK_STREAM, 0);
    connect(fd, ...);
    ...
}
1)客户端connect

客户端通过调用connect来发起连接。在connect系统调用中会进入内核源码的tcp_v4_connect

// net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
	...
    // 设置socket状态为TCP_SYN_SENT
	tcp_set_state(sk, TCP_SYN_SENT);
    // 动态选择一个端口
	err = inet_hash_connect(&tcp_death_row, sk);
	...
    // 函数用来根据sk中的信息,构建一个syn报文,并将它发送出去
	err = tcp_connect(sk);
  ...
}

在这里将完成把socket状态设置为TCP_SYN_SENT。再通过inet_hash_connect来动态地选择一个可用的端口后,进入tcp_connect

// net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{
	...
    // 申请并设置skb
	buff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);
	...
	tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
	...
    // 添加到发送队列sk_write_queue
	tcp_connect_queue_skb(sk, buff);
	...
    // 实际发出syn
	err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
	      tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
	...
    // 启动重传定时器
	inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
				  inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
	return 0;
}

在tcp_connect申请和构造SYN包,然后将其发出。同时还启动了一个重传定时器,该定时器的作用是等到一定时间后收不到服务端的反馈的时候来开启重传。在Linux 3.10版本中首次超时时间是1秒

总结一下,客户端在调用connect的时候,把本地socket状态设置成了TCP_SYN_SENT,选了一个可用的端口,接着发出SYN握手请求并启动重传定时器

2)服务端响应SYN

在服务端,所有的TCP包(包括客户端发来的SYN握手请求)都经过网卡、软中断,进入tcp_v4_rcv。在该函数中根据网络包(skb)TCP头信息中的目的IP信息查到当前处于listen状态的socket,然后继续进入tcp_v4_do_rcv处理握手过程

// net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
	...
    // 服务端收到第一步握手SYN或者第三步ACK都会走到这里
	if (sk->sk_state == TCP_LISTEN) {
		struct sock *nsk = tcp_v4_hnd_req(sk, skb);
		...
	}
    ...
	if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
		rsk = sk;
		goto reset;
	}
	...
}

在tcp_v4_do_rcv中判断当前socket时listen状态后,首先会到tcp_v4_hnd_req查看半连接队列。服务端第一次响应SYN的时候,半连接队列里必然空空如也,所以相当于什么也没干就返回了

// net/ipv4/tcp_ipv4.c
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
	...
    // 查找listen socket的半连接队列
	struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,
						       iph->saddr, iph->daddr);
	...
}

在tcp_rcv_state_process里根据不同的socket状态进行不同的处理

// net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
			  const struct tcphdr *th, unsigned int len)
{
	...
	switch (sk->sk_state) {
	...
    // 第一次握手
	case TCP_LISTEN:
		...
        // 判断是否为SYN握手包
		if (th->syn) {
			...
			if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)
				return 1;
            ...
		}
		...
	}
    ...
}

其中conn_request是一个函数指针,指向tcp_v4_conn_request。服务端响应SYN的主要处理逻辑都在这个tcp_v4_conn_request里

// net/ipv4/tcp_ipv4.c
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{
	...
    // 看看半连接队列是否满了
	if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
		want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
		if (!want_cookie)
			goto drop;
	}

	// 在全连接队列满的情况下,如果有young_ack,那么直接丢弃
	if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
		NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
		goto drop;
	}
    // 分配request_sock内核对象
	req = inet_reqsk_alloc(&tcp_request_sock_ops);
    ...
    // 构造syn+ack包
	skb_synack = tcp_make_synack(sk, dst, req,
	    fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);
    ...
	if (likely(!do_fastopen)) {
		...
        // 发送syn+ack响应
		err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr,
		     ireq->rmt_addr, ireq->opt);
		...
        // 添加到半连接队列,并开启计时器
		inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
		...
	}
    ...
}

在这里首先判断半连接队列是否满了,如果满了进入tcp_syn_flood_action去判断是否开启了tcp_syncookies内核参数。如果队列满,并未开启tcp_syncookies,那么该握手包将被直接丢弃

接着还要判断全连接队列是否满。因为全连接队列满也会导致握手异常,那干脆就在第一次握手的时候也判断了。如果全连接队列满了,且young_ack数量大于1的话,那么同样也是直接丢弃

young_ack是半连接队列里保持着的一个计数器。记录的是刚有SYN到达,没有被SYN_ACK重传定时器重传过SYN_ACK,同时也没有完成过三次握手的sock数量

接下来是构造synack包,然后通过ip_build_and_send_pkt把它发送出去

最后把当前握手信息添加到半连接队列,并开启计时器。计时器的作用是,如果某个时间内还收不到客户端的第三次握手,服务端会重传synack包

总结一下,服务端响应ack的主要工作是判断接收队列是否满了。满的话可能会丢弃该请求,否则发出synack。申请request_sock添加到半连接队列中,同时启动定时器

3)客户端响应SYNACK

客户端收到服务端发来的synack包的时候,也会进入tcp_rcv_state_process函数。不过由于自身socket的状态TCP_SYN_SENT,所以会进入另一个不同的分支

// net/ipv4/tcp_input.c
// 除了ESTABLISHED和TIME_WAIT,其他状态下的TCP处理都走这里
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
			  const struct tcphdr *th, unsigned int len)
{
	...
	switch (sk->sk_state) {
	...
    // 服务器收到第一个ACK包
	case TCP_LISTEN:
		...
    // 客户端第二次握手处理    
	case TCP_SYN_SENT:
        // 处理synack包
		queued = tcp_rcv_synsent_state_process(sk, skb, th, len);
		...
		return 0;
	}
 	...
}

tcp_rcv_synsent_state_process是客户端响应synack的主要逻辑

// net/ipv4/tcp_input.c
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb,
					 const struct tcphdr *th, unsigned int len)
{
	...
		tcp_ack(sk, skb, FLAG_SLOWPATH);
        ...
        // 连接建立完成
		tcp_finish_connect(sk, skb);
        ...
		if (sk->sk_write_pending ||
		    icsk->icsk_accept_queue.rskq_defer_accept ||
		    icsk->icsk_ack.pingpong) {
			  // 延迟确认
              ...
		} else {
			tcp_send_ack(sk);
		}
	...
}

tcp_ack()->tcp_clean_rtx_queue()

// net/ipv4/tcp_input.c
static int tcp_clean_rtx_queue(struct sock *sk, int prior_fackets,
			       u32 prior_snd_una)
{
    // 删除发送队列
	...
        // 删除定时器
		tcp_rearm_rto(sk);
    ...
}
// net/ipv4/tcp_input.c
void tcp_finish_connect(struct sock *sk, struct sk_buff *skb)
{
	...
    // 修改socket状态
	tcp_set_state(sk, TCP_ESTABLISHED);
    ...
    // 初始化拥塞控制
	tcp_init_congestion_control(sk);
    ...
    // 保活计时器打开
	if (sock_flag(sk, SOCK_KEEPOPEN))
		inet_csk_reset_keepalive_timer(sk, keepalive_time_when(tp));
    ...
}

客户端将自己的socket状态修改为ESTABLISHED,接着打开TCP的保活计时器

// net/ipv4/tcp_output.c
void tcp_send_ack(struct sock *sk)
{
	...
    // 申请和构造ack包
	buff = alloc_skb(MAX_TCP_HEADER, sk_gfp_atomic(sk, GFP_ATOMIC));
	...
    // 发送出去
	tcp_transmit_skb(sk, buff, 0, sk_gfp_atomic(sk, GFP_ATOMIC));
}

在tcp_send_ack中构造ack包,并把它发送出去

客户端响应来自服务端的synack时清除了connect时设置的重传定时器,把当前socket状态设置为ESTABLISHED,开启保活计时器后发出第三次握手的ack确认

4)服务端响应ACK

服务端响应第三次握手的ack时同样会进入tcp_v4_do_rcv

// net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
	...
	if (sk->sk_state == TCP_LISTEN) {
		struct sock *nsk = tcp_v4_hnd_req(sk, skb);
		...
		if (nsk != sk) {
			...
			if (tcp_child_process(sk, nsk, skb)) {
				...
			}
			return 0;
		}
	}
  ...
}

不过由于这已经是第三次握手了,半连接队列里会存在第一次握手时留下的半连接信息,所以tcp_v4_hnd_req的执行逻辑会不太一样

// net/ipv4/tcp_ipv4.c
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
	...
	struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,
						       iph->saddr, iph->daddr);
	if (req)
		return tcp_check_req(sk, skb, req, prev, false);
    ...
}

inet_csk_search_req负责在半连接队列里进行查找,找到以后返回一个半连接request_sock对象,然后进入tcp_check_req

// net/ipv4/tcp_minisocks.c
struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
			   struct request_sock *req,
			   struct request_sock **prev,
			   bool fastopen)
{
	...
    // 创建子socket
	child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);
	...
    // 清理半连接队列
	inet_csk_reqsk_queue_unlink(sk, req, prev);
	inet_csk_reqsk_queue_removed(sk, req);
    // 添加全连接队列
	inet_csk_reqsk_queue_add(sk, req, child);
	return child;
    ...
}

创建子socket

先来详细看看创建子socket的过程,icsk_af_ops->syn_recv_sock是一个指针,它指向的是tcp_v4_syn_recv_sock函数

// net/ipv4/tcp_ipv4.c
const struct inet_connection_sock_af_ops ipv4_specific = {
	...
	.conn_request	   = tcp_v4_conn_request,
	.syn_recv_sock	   = tcp_v4_syn_recv_sock,
	...
};

// 这里创建sock内核对象
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
				  struct request_sock *req,
				  struct dst_entry *dst)
{
	...
    // 判断接收队列是不是满了
	if (sk_acceptq_is_full(sk))
		goto exit_overflow;
    // 创建sock并初始化
	newsk = tcp_create_openreq_child(sk, req, skb);
	...
}

注意,在第三次握手这里又继续判断一次全连接队列是否满了,如果满了修改一下计数器就丢弃了。如果队列不满,那么就申请创建新的sock对象

删除半连接队列

把连接请求块从半连接队列中删除

// include/net/inet_connection_sock.h
static inline void inet_csk_reqsk_queue_unlink(struct sock *sk,
					       struct request_sock *req,
					       struct request_sock **prev)
{
	reqsk_queue_unlink(&inet_csk(sk)->icsk_accept_queue, req, prev);
}

reqsk_queue_unlink函数中把连接请求块从半连接队列中删除

添加全连接队列

接着添加新创建的sock对象

// include/net/inet_connection_sock.h
static inline void inet_csk_reqsk_queue_add(struct sock *sk,
					    struct request_sock *req,
					    struct sock *child)
{
	reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue, req, sk, child);
}

在reqsk_queue_add中握手成功的request_sock对象插到全连接队列链表的尾部

// include/net/request_sock.h
static inline void reqsk_queue_add(struct request_sock_queue *queue,
				   struct request_sock *req,
				   struct sock *parent,
				   struct sock *child)
{
	req->sk = child;
	sk_acceptq_added(parent);

	if (queue->rskq_accept_head == NULL)
		queue->rskq_accept_head = req;
	else
		queue->rskq_accept_tail->dl_next = req;

	queue->rskq_accept_tail = req;
	req->dl_next = NULL;
}

设置连接为ESTABLISHED

第三次握手的时候进入tcp_rcv_state_process的路径有点不太一样,是通过子socket进来的。这时的子socket的状态是

// net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
			  const struct tcphdr *th, unsigned int len)
{
	...
		switch (sk->sk_state) {
        // 服务器第三次握手处理
		case TCP_SYN_RECV:
                ...
                // 改变状态为连接
				tcp_set_state(sk, TCP_ESTABLISHED);
				...
		}
	...
}

将连接设置为TCP_ESTABLISHED状态。服务端响应第三次握手ACK所做的工作是把当前半连接对象删除,创建了新的sock后加入全连接队列,最后将新连接状态设置为ESTABLISHED

5)服务端accept
// net/ipv4/inet_connection_sock.c
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err)
{
	...
    // 从全连接队列中获取
	struct request_sock_queue *queue = &icsk->icsk_accept_queue;
	...
	req = reqsk_queue_remove(queue);
	newsk = req->sk;
    ...
	return newsk;
    ...
}

reqsk_queue_remove这个操作很简单,就是从全连接队列的链表里获取一个头元素返回就行了

// include/net/request_sock.h
static inline struct request_sock *reqsk_queue_remove(struct request_sock_queue *queue)
{
	struct request_sock *req = queue->rskq_accept_head;

	WARN_ON(req == NULL);

	queue->rskq_accept_head = req->dl_next;
	if (queue->rskq_accept_head == NULL)
		queue->rskq_accept_tail = NULL;

	return req;
}

所以,accept的重点工作就是从已经建立好的全连接队列中取出一个返回给用户进程

6)连接建立过程总结

三次握手详细过程总结如下图:

深入理解Linux网络笔记(六):深度理解TCP连接建立过程_第5张图片

一条TCP连接需要消耗多长时间。以上几步操作,可以简单划分为两类:

  • 第一类是内核消耗CPU进行接收、发送或者是处理,包括系统调用、软中断和上下文切换。它们的消耗基本都是几微妙左右
  • 第二类是网络传输,当包被从一台机器上发出以后,中间要经过各式各样的网线,各种交换机路由器。所以网络传输的耗时相比本机的CPU处理,就要高得多了。根据网络远近一般在几毫秒到几百毫秒不等

在正常的TCP连接的建立过程中,一般考虑网络延时即可。一个RTT指的是包从一台服务器到另一台服务器的一个来回的延时时间,所以从全局来看,TCP建立连接的网络耗时大约需要三次传输,再加上少许的双方CPU开销,总共大约比1.5被RTT大一点点。不过从客户端视角来看,只要ACK包发出了,内核就认为连接建立成功,可以开始发送数据了。所以如果在客户端打点统计TCP连接建立耗时,只需两次传输耗时——即1个RTT多一点的时间(对于服务端视角来看同理,从SYN包收到开始算,到收到ACK,中间也是一次RTT耗时)

推荐阅读:

4.1 TCP 三次握手与四次挥手面试题

4.4 TCP 半连接队列和全连接队列

内核参数 tcp_syncookies-- 默认开启tcp_syncookies

你可能感兴趣的:(深入学习Linux,Linux网络,Linux内核)