TCP实现之:TCP三次握手
前言
关于TCP三次握手的原理和过程咱就不讲了哈,感兴趣的可以参考一下我的另一篇文档:TCP原理之:TCP数据传输,这里就简单分析一下内核对于TCP三次握手以及fastopen和syn_cookie等机制的实现。
一、常规TCP建链
1.1 基本原理
TCP连接的建立过程如下图所示:
- 首先,客户端调用connect系统调用进行建链的发起,此时内核会创建一个套接口,发送SYN报文,并将其置为SYN_SEND状态;
- 服务端收到SYN报文后,会创建一个
struct request_sock
类型的套接口,并响应一个SYN+ACK报文。这种套接口不具有收发报文的能力,因为其没有发包和收包队列; - 客户端收到SYN的确认报文后,会将套接口转换到ESTABLISHED状态,并响应一个ACK报文给服务端,作为收到的SYN报文的响应;
- 服务端收到ACK后,会销毁原来的
struct request_sock
套接口,并创建个新的struct tcp_sock
套接口,并将状态置位ESTABLISHED,此时双方都具有了报文收发的能力。
1.2 内核实现
NONO→SYN_SEND
首先,先从TCP连接的发起,即客户端说起。TCP连接的建立一般都是通过connect系统调用来实现的,该系统调用首先会调用__inet_stream_connect函数。这个函数主要是对当前套接口状态合法性进行检查,只有当前处于UNCONNECT状态,才会尝试进行TCP建链。它会调用tcp_v4_connect函数来进行TCP建链的发起。这个函数是建链前的准备函数,所做的工作包括以下几方面:
- 根据用户传递进来的地址和端口信息,进行路由查找和检查。对于检查不通过的情况,直接返回错误。
- 进行TCP套接口的调整,包括将套接口目的地址和目的端口设置成用户传递进来的;将套接口的状态设置成SYN_SEND(此时还没有发送SYN呢)。
- 进行端口的绑定。一个TCP连接需要与本地的某个端口绑定才行,即需要设置源端口。对于用户没有主动调用bind系统调用进行绑定的情况(主要情况),内核会从当前可用端口中随机挑一个未使用的,并加入到bind哈希表中。
- 进行序列号的初始化,计算出初始序列值。
- 判断是否使用延迟连接,这个在启用了fastopen并且当前cookie缓存有效的情况下会走延迟连接,这个下面会讲。
- 调用tcp_connect进行syn报文的创建和发送。
tcp_v4_connect执行完后,__inet_stream_connect会在当前套接口上进行等待,直到其超时,或者状态变为非SYN_SEND和SYN_RECV。这里他的状态是有可能变成SYN_RECV的,对于“同时打开”的场景,这个下面会讲到。整个流程的函数调用关系如下图所示:
LISTEN→SYN_RECV
在内核收到SYN一个报文时,会进行套接口匹配。这个匹配过程还是有点复杂的,这个在下面的哈希表探索章节会进行详细介绍,这里只讨论当前存在一个匹配的处于LISTEN状态的套接口的情况。不考虑cookie、tw和快速打开等情况的话,整个SYN报文的处理流程还是比较检查的,包括:
- 根据报文,找到当前处于listen状态的套接口sk。
- 检查套接口的accept队列和backlog队列是否满了。其中,accept队列里存放的是已经成功建链,但是还没有被用户通过accept系统调用取走的套接口,这种套接口有个上限,就是sk在进行listen系统调用传递进来的backlog值。backlog队列存放的是处于半连接(SYN_RECV)状态的套接口,其数量由两部分限制,一部分是backlog值,这是用户级别的限制;一部分是sysctl_max_syn_backlog,这是系统级别的限制。
- 进行
struct tcp_request_sock
请求块的分配和初始化。由于struct tcp_sock
所占的资源比request
类型的要大得多,因此在请求建链阶段内核会分配这种套接口,等到连接真正建立后才会切换到正常的sock。需要注意,这种request类型的套接口没有数据收发的能力。 - 序列号的初始化,初始序列号是通过一个复杂的加密算法算出来的,并不是随机取的。
- 将req类型的套接字放到哈希表中,等待第三次握手报文到达的时候进行匹配。
- 响应SYN+ACK报文给客户端。
SYN_SEND→ESTABLISHED
当客户端接收到对端发送过来的SYN+ACK报文后,前面的流程与上文差不多,到后面会根据套接口的状态走到tcp_rcv_synsent_state_process→tcp_rcv_synsent_state_process
来进行处理,所以这里重点关注后面的那个函数即可。这个函数所做的事情主要包括以下几方面:
- 进行状态合法性检查,包括收到的报文的标志位的检查等。如果收到了一个异常的ACK(ACK序列号不对),或者ACK是好的,但是上一个RST报文,那么对连接进行重置;其他异常情况,会直接忽略报文,比如RST报文没有置位ACK位、没有置位SYN等。
- 处理常规的TCP状态迁移,将报文状态从SYN_SEND迁移到ESTABLISHED状态, 同时对报文中的SYN进行响应(发送ACK);
- 处理fastopen的情况,参考tcp_rcv_fastopen_synack的里的介绍;
- 处理同时打开的情况,即如果报文是一个纯SYN报文,那么当前是同时打开。
同时打开
上面的由SYN_SEND→ESTABLISHED
的逻辑比较简单,但是在tcp_rcv_synsent_state_process函数里还处理了另外一种情况,即同时打开。同时打开值得是两端同时发起建链请求,这种情况下是没有所谓的客户端和服务端的,其状态变更图如下所示:
TCP同时打开操作,状态变化为:SYN_SEND → SYN_RECV → ESTABLISHED,两边的动作一致,分别为:发送SYN、收到SYN发送SYN+ACK报文、收到SYN+ACK。可以看出比传统的三次握手相比,产生了四次握手。同时打开的使用场景还是比较小的,但是作为一种协议中支持的场景,内核还是对其做了实现。
二、快速打开
2.1 基本原理
常规的TCP建链过程中,至少需要三次握手,相当于在发生数据传输之前要至少等待一个RTT(报文来回时间)。这对于经常需要进行建链和断链的场景是非常不利的,比如HTTP协议,每次发送HTTP请求都会进行一次TCP连接。为了减少TCP建链过程中所引起的延迟,快速打开(fastopen)协议被提了出来。快速打开可以看做是TCP的一种附加模式,其在实现时是兼容TCP协议的,其核心思想如下图所示:
- 首先,在客户端第一次进行建链请求的时候,会在SYN报文的选项中携带fastopen的cookie请求信息。
- 服务端如果检查到SYN报文中携带cookie请求,那么会根据当前客户端的IP计算出一个cookie值,并放到SYN+ACK报文的选项中返回给客户端。
- 客户端收到SYN+ACK报文后,发现其中存在cookie数据,那么其会将cookie数据存储到路由缓存中。
至此,cookie信息就成功的生成并且保存下来了,从这个流程里看,TCP建链过程与之前并没有什么区别。真正发挥作用的是在下面的过程中。
在下一次进行TCP建链的时候,如果使用的是connect系统调用进行连接,那么内核会检查当前是否启用了fastopen功能,并且路由缓存中是否存在有效的cookie数据。如果是的话,那么这里就不会进行TCP的建链,而是等到sengmsg发送数据的时候才进行建链。
在进行建链的时候,内核会把数据放到SYN报文里,并且将cookie信息放到TCP选项中,发送给服务端。服务端在接收到这样的报文后,会先验证cookie是否有效,验证通过后直接创建struct tcp_sock
,而不是之前的request套接口。此时的套接口虽然处于SYN_RECV状态,但是已经具有了数据收发的功能。随后,内核会将套接口放到accept队列中,并通知用户新的连接到达,并且会将报文数据放到套接口的接收缓冲区中。随后的流程,就很明了了。
2.2 内核实现
客户端行为
这里简单说一下快速打开过程中内核函数调用的整个流程吧。总体来说,客户端fastopen的启用方式有两种:
- 给套接口设置TCP_FASTOPEN选项,然后正常的进行tcp套接口的connect、sengmsg等操作。这种方式与原有的tcp使用方式相兼容,只需要额外进行个选项启用即可。这种情况下,内核在connect的时候会根据当前cookie是否有效来做出不同的行为。如果cookie有效,那么不会进行tcp连接,而是将
defer_connect
(延迟连接)置位,等到sengmsg
的时候,带着用户数据一起建链;如果cookie无效的话,那么这里会进行正常的TCP连接,只不过这里在发送SYN报文的时候会将cookie请求放进去。 - 在发送第一个数据的时候,给sendmsg设置MSG_FASTOPEN标志。这种方式的话,直接就不需要connect系统调用,省去了一次系统调用的开销。但是需要注意,这个标志只有在连接未建立的时候,即第一次发送数据的时候才能设置,因为如果设置了这个标志,内核就会走到下面的
tcp_sendmsg_fastopen
流程,就会进行使用SYN报文发送。所以这里需要业务上做一点调整,将第一次的sengmsg
作为connect
使用。
客户端在进行TCP快速打开的时候的代码流程图如下图所示,里面详细描述了整个内核实现的函数调用关系,有兴趣的可以探索一下。
服务端行为
对于服务端的行为,就相对比较简单了。正如前面所说的,内核会先对其cookie有效性进行检查,检查通过后则会创建一个SYN_RECV状态的tcp_sock放入到accept队列等待用户accept。SYN的请求处理函数为tcp_conn_request,我们就从这个函数讲起。这部分的内容与常规TCP建链有重叠,这里就只讲fastopen情况下的处理逻辑,其处理过程如下图所示:
上图中,只有未触发SYN_COOKIE,才会进行fastopen模式的检查。这是合理的,因为一旦触发SYN_COOKIE,说明当前处于建链压力中,此时应该保守一点,使用SYN_COOKIE的方式来避免内存和资源的消耗。
三、SYN_COOKIE
呼,写的有点累了,SYN_COOKIE这里就简单介绍一下吧。从上面的TCP正常建链可以看出,如果客户端发送大量的SYN报文,而不响应SYN+ACK报文,那么短时间内将会使得服务端出现大量的半连接套接口。这些套接口显然是request_sock类型的,但是也会占用资源,而且会导致服务端半连接队列满,使得正常的客户端无法建链。这个就是所谓的SYN泛红攻击,而SYN_COOKIE机制就是为了应对这种场景,其原理如下:
从原理图中可以看出,其在实现的过程中并不需要客户端的支持,因为它是把cookie信息放到了时间戳选项中,而时间戳是会被客户端放到ACK报文里的。其核心在于,不在内核中保存SYN_RECV状态的套接口,从而避免了资源占用的问题。
四、TCP哈希表探索
4.1 存储结构
这里探索以下TCP中是如何对各种类型的套接口进行哈希维护的。首先来看一下inet_hashinfo
这个结构体,这个结构体是协议相关的,即每个协议对应一个实例,因此TCP协议也有一个这么个东西,其维护了TCP所有的套接口,定义如下:
/**
* 这个结构体用于保存协议相关的所有hash表,包括listen表、bind表、建链表等。
*/
struct inet_hashinfo {
/*
* 已连接的套接口的hash表,这里的套接口都是经过精确的套接口属性散列到这个
* hash表里的。该hash中保存了连接、半连接、半关闭状态下的套接口,也就是
* 除了listen和close状态下的,都在这里面了。
*/
struct inet_ehash_bucket *ehash;
spinlock_t *ehash_locks;
unsigned int ehash_mask;
unsigned int ehash_locks_mask;
/* 用来分配inet_bind_bucket的缓存 */
struct kmem_cache *bind_bucket_cachep;
/* 端口绑定的HASH表 */
struct inet_bind_hashbucket *bhash;
unsigned int bhash_size;
/*
* 所有处于listen状态的hash表,通过sk->icsk_listen_portaddr_node链接。
* 该hash表是通过源地址和端口来进行hash的。
*/
unsigned int lhash2_mask;
struct inet_listen_hashbucket *lhash2;
/* 这个hash表存放的是所有listen状态的套接口,通过sk->sk_node来链接。
* 该hash是根据源端口来进行hash的。
*/
struct inet_listen_hashbucket listening_hash[INET_LHTABLE_SIZE]
____cacheline_aligned_in_smp;
};
可以看出它存了三个哈希表:
- EHASH表,这里的套接口都是通过四元组来进行HASH的,即TCP的源地址、目的地址、源端口、目的端口,因此只要存在四元组的TCP套接口都会在这个表里面,这个表属于一种精确匹配的哈希表。
- BIND_HASH表,绑定HASH表。这个表主要是用来对本地端口绑定做管理的,只要是存在本地端口的套接口都会在这个表里,这意味着基本上所有的套接口都在这个表里。
- LHASH2表,这个HASH表是管理listen状态的套接口的,通过源地址和端口来HASH。
- LISTENING_HASH表。这个也是管理listen状态的哈希表的,只不过这里是通过端口来HASH。可能会有人问,为啥要有两个listen套接口的哈希表,这个是用于优化套接口查找的,下面会介绍。
4.2 哈希表查找
可能有人会有疑问,同一个报文其实有多个套接口可以匹配,内核是如何精准匹配到那个套接口的呢?这是因为内核在进行套接口查找的时候有个优先级和查找顺序,简单来说就是内核先从ehash表中查找找不到的话再从lhash表中查找,这一点从套接口的查找函数__inet_lookup
也可以看的出来:
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)
{
u16 hnum = ntohs(dport);
struct sock *sk;
/* 对于连接了的sock,查找比较简单,直接根据属性进行匹配即可 */
sk = __inet_lookup_established(net, hashinfo, saddr, sport,
daddr, hnum, dif, sdif);
*refcounted = true;
if (sk)
return sk;
*refcounted = false;
/* 从lhash中进行sock的查找,查找过程比较复杂,需要进行分值比较来
* 选取最优的套接口
*/
return __inet_lookup_listener(net, hashinfo, skb, doff, saddr,
sport, daddr, hnum, dif, sdif);
}
ehash表的查找比较简单,因为它是通过四元组精确匹配查找的,因此查找出来的套接口肯定是唯一的,其查找函数为__inet_lookup_established
,这个函数比较简单,就不再赘述,这里重点讲一下LISTEN状态下的套接口的查找过程。因为listen套接口存在不确定性,比如一个监听8080端口的套接口可能监听127.0.0.1:8080,也可能监听0:8080(即监听所有本地地址的8080端口),因此在查找监听端口的时候需要进行多次匹配,这里有些人可能就明白了为啥会有上面的LISTENING_HASH哈希表。
总的来说,其查找过程如下:
- 先从LISTENING_HASH表中进行哈希查找,如果哈希到的bucket的长度小于10或者LHASH表中没有元素的话,那么就直接跳到第四步;
- 从LHASH表中进行查找,查找的时候使用目的地址+目的端口进行哈希,如果获取到的bucket长度比上面的那个长,那么跳到第四步;否则,调用inet_lhash2_lookup直接在这个bucket中进行查找;
- 如果上面的bucket中没有找到目的套接口,那么使用INADDR_ANY+目的端口再次进行哈希,然后再把获得到的bucket与1中的进行长度对比,比他长的话调到第四步;否则,调用inet_lhash2_lookup直接在这个bucket中进行查找;
- 遍历bucket中的套接口进行分值计算,并将得分最高的那个套接口作为目的套接口。这里的查找逻辑下面在inet_lhash2_lookup中进行具体分析。
struct sock *__inet_lookup_listener(struct net *net,
struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif, const int sdif)
{
unsigned int hash = inet_lhashfn(net, hnum);
struct inet_listen_hashbucket *ilb = &hashinfo->listening_hash[hash];
bool exact_dif = inet_exact_dif_match(net, skb);
struct inet_listen_hashbucket *ilb2;
struct sock *sk, *result = NULL;
struct hlist_nulls_node *node;
int score, hiscore = 0;
unsigned int hash2;
u32 phash = 0;
/* 如果ilb的数量小于10,或者lhash2不存在,那么直接从ilb中查找 */
if (ilb->count <= 10 || !hashinfo->lhash2)
goto port_lookup;
/* Too many sk in the ilb bucket (which is hashed by port alone).
* Try lhash2 (which is hashed by port and addr) instead.
*/
/* 如果ilb2的长度比ilb长,那么就从短的(ilb)中查找 */
hash2 = ipv4_portaddr_hash(net, daddr, hnum);
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
if (ilb2->count > ilb->count)
goto port_lookup;
/*
* 否则,从ilb2进行套接口的查找。这里的inet_lhash2_lookup与下面
* 循环中的查找逻辑一致。
*/
result = inet_lhash2_lookup(net, ilb2, skb, doff,
saddr, sport, daddr, hnum,
dif, sdif);
if (result)
goto done;
/*
* 先根据目的地址+端口查找,找不到再通过INADDR_ANY+端口查找。
*/
hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
if (ilb2->count > ilb->count)
goto port_lookup;
result = inet_lhash2_lookup(net, ilb2, skb, doff,
saddr, sport, daddr, hnum,
dif, sdif);
goto done;
port_lookup:
/*
* 这个循环里的查找逻辑与inet_lhash2_lookup完全一致,可以替换成
* inet_lhash2_lookup(net, ilb, skb, doff,
saddr, sport, daddr, hnum,
dif, sdif)
*
* 这里可能是为了减少一次函数调用,重新写了一遍,不知道意义何在。
*/
sk_nulls_for_each_rcu(sk, node, &ilb->nulls_head) {
score = compute_score(sk, net, hnum, daddr,
dif, sdif, exact_dif);
if (score > hiscore) {
if (sk->sk_reuseport) {
phash = inet_ehashfn(net, daddr, hnum,
saddr, sport);
result = reuseport_select_sock(sk, phash,
skb, doff);
if (result)
goto done;
}
result = sk;
hiscore = score;
}
}
done:
if (unlikely(IS_ERR(result)))
return NULL;
return result;
}
inet_lhash2_lookup函数的定义如下。这个函数用于将同一个桶中的套接口进行筛选,挑一个最符合要求的。为什么需要这么个过程呢?因为被哈希到同一个桶中的套接口有可能会存在多个满足报文的接收条件,所以需要在这里挑一个优先级最高的来进行处理。
这里需要注意端口重用的情况,这个端口重用的具体机制下面讲绑定的时候会讲。端口重用使得多个程序可以对同一个TCP端口进行监听,比如程序A和程序B同时对本地192.168.1.1:8080这个端口进行bind和listen,这是可以的。那么问题来了,如果客户端需要建链,那么是交给哪个程序处理呢?这个是靠下面的reuseport_select_sock函数来实现的,它会从A和B中交替式的进行挑选,即第一次的客户端建链交给A处理,第二次交给B…。使用这种方式,在某些场景下能够实现负载均衡的目的。
static struct sock *inet_lhash2_lookup(struct net *net,
struct inet_listen_hashbucket *ilb2,
struct sk_buff *skb, int doff,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif, const int sdif)
{
bool exact_dif = inet_exact_dif_match(net, skb);
struct inet_connection_sock *icsk;
struct sock *sk, *result = NULL;
int score, hiscore = 0;
u32 phash = 0;
inet_lhash2_for_each_icsk_rcu(icsk, &ilb2->head) {
sk = (struct sock *)icsk;
/**
* 从链表中找出一个最匹配的sk,通过分值来判断。如果sk指定了地址,但是与daddr
* 不匹配,那么直接返回失败。
*/
score = compute_score(sk, net, hnum, daddr,
dif, sdif, exact_dif);
if (score > hiscore) {
/*
* 如果找到的sk启动了端口重用,那么以一种负载均衡的方式,从所有
* 端口重用的套接口中挑一个listen的套接口。
*/
if (sk->sk_reuseport) {
phash = inet_ehashfn(net, daddr, hnum,
saddr, sport);
result = reuseport_select_sock(sk, phash,
skb, doff);
if (result)
return result;
}
result = sk;
hiscore = score;
}
}
return result;
}
套接口选取的优先级总体来说可以通过“匹配度越高分值越高”的原则来理解,其实现如下。但是有个原则,即存在不匹配的条件,那么不会进行选择。
static inline int compute_score(struct sock *sk, struct net *net,
const unsigned short hnum, const __be32 daddr,
const int dif, const int sdif, bool exact_dif)
{
int score = -1;
struct inet_sock *inet = inet_sk(sk);
/* 该记分方式的标准为:优先选择绑定了地址、绑定了网口、绑定了CPU的套接口。 */
if (net_eq(sock_net(sk), net) && inet->inet_num == hnum &&
!ipv6_only_sock(sk)) {
__be32 rcv_saddr = inet->inet_rcv_saddr;
score = sk->sk_family == PF_INET ? 2 : 1;
if (rcv_saddr) {
/* 这个套接口绑定了地址,但是不是我们的地址,不要 */
if (rcv_saddr != daddr)
return -1;
/* 地址匹配,是个加分项 */
score += 4;
}
if (sk->sk_bound_dev_if || exact_dif) {
bool dev_match = (sk->sk_bound_dev_if == dif ||
sk->sk_bound_dev_if == sdif);
/* 绑定了网口,但是不是报文的网口,直接不要 */
if (!dev_match)
return -1;
/* 网口匹配,是个加分项 */
if (sk->sk_bound_dev_if)
score += 4;
}
/*
* 绑定CPU检测,这个不是硬性要求,但是会优选当前CPU上绑定的套接口。
* 这种绑定CPU的方式是为了提高效率,节省了CPU迁移的开销。
*/
if (READ_ONCE(sk->sk_incoming_cpu) == raw_smp_processor_id())
score++;
}
return score;
}
4.3 端口绑定
TCP协议中的端口绑定可能发生在两个阶段:(1)调用bind系统调用进行显式绑定的时候;(2)调用connect进行主动建链的时候。前者是用户主动将套接口进行端口绑定,而后者是内核随机分配一个可用的端口进行绑定。
为什么要进行绑定呢?因为无论是作为客户端进行主动建链,还是作为服务端要对本地端口进行监听,都需要确定本端的端口才能进行。这个确定本地端口的过程,就是端口绑定。首先,我们来看一下主动调用bind进行端口绑定的时候内核做了什么工作,此处的处理函数为__inet_bind
:
int __inet_bind(struct sock *sk, struct sockaddr *uaddr, int addr_len,
bool force_bind_address_no_port, bool with_lock)
{
[...]
snum = ntohs(addr->sin_port);
err = -EACCES;
/*
* 端口权限检查,对于没有CAP_NET_BIND_SERVICE权限的用户禁止绑定低端口
* (小于1024的端口,可通过sysctl_ip_prot_sock配置)
*/
if (snum && snum < inet_prot_sock(net) &&
!ns_capable(net->user_ns, CAP_NET_BIND_SERVICE))
goto out;
if (with_lock)
lock_sock(sk);
/* 不能重复进行绑定操作 */
err = -EINVAL;
if (sk->sk_state != TCP_CLOSE || inet->inet_num)
goto out_release_sock;
inet->inet_rcv_saddr = inet->inet_saddr = addr->sin_addr.s_addr;
if (chk_addr_ret == RTN_MULTICAST || chk_addr_ret == RTN_BROADCAST)
inet->inet_saddr = 0; /* Use device */
/*
* 如果指定了端口(绑定特定的端口)或者未指定(随机分配)并且没有启用
* bind_address_no_port选项,那么调用当前协议的get_port方法进行
* 端口的绑定操作。对于TCP协议,此处为inet_csk_get_port()。
*/
if (snum || !(inet->bind_address_no_port ||
force_bind_address_no_port)) {
if (sk->sk_prot->get_port(sk, snum)) {
inet->inet_saddr = inet->inet_rcv_saddr = 0;
err = -EADDRINUSE;
goto out_release_sock;
}
/* 调用eBPF在此处的钩子。 */
err = BPF_CGROUP_RUN_PROG_INET4_POST_BIND(sk);
if (err) {
inet->inet_saddr = inet->inet_rcv_saddr = 0;
goto out_release_sock;
}
}
if (inet->inet_rcv_saddr)
sk->sk_userlocks |= SOCK_BINDADDR_LOCK;
if (snum)
sk->sk_userlocks |= SOCK_BINDPORT_LOCK;
inet->inet_sport = htons(inet->inet_num);
inet->inet_daddr = 0;
inet->inet_dport = 0;
sk_dst_reset(sk);
err = 0;
out_release_sock:
if (with_lock)
release_sock(sk);
out:
return err;
}
上面的函数进行了一些合法性检查和一些变量的初始化,真正进行端口绑定的函数为inet_csk_get_port()
,下面的注释对这个函数的处理过程做了详细的解释。
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
bool reuse = sk->sk_reuse && sk->sk_state != TCP_LISTEN;
struct inet_hashinfo *hinfo = sk->sk_prot->h.hashinfo;
int ret = 1, port = snum;
struct inet_bind_hashbucket *head;
struct net *net = sock_net(sk);
struct inet_bind_bucket *tb = NULL;
/*
* 该函数的总体思路为:如果没有指定要绑定的端口,那么调用
* inet_csk_find_open_port来从空闲端口中随机分配一个;否则,从BHASH表中
* 进行查找,检测是否与现有绑定冲突。不冲突的话,将其绑定到BHASH表中。
*/
if (!port) {
head = inet_csk_find_open_port(sk, &tb, &port);
if (!head)
return ret;
if (!tb)
goto tb_not_found;
goto success;
}
/*
* 通过HASH算法,找到对应的桶(bucket)。由于端口与套接口并不是一对一的关系,
* 所以这个桶里存放的并不是套接口,而是另一个结构体:inet_bind_bucket。
*
* 这个结构体起到了对端口进行管理的功能,比如当前端口是否是可重用端口、所属的
* 网络命名空间,并且其又使用了一个HASH表将使用本端口的套接口管理了起来。
*/
head = &hinfo->bhash[inet_bhashfn(net, port,
hinfo->bhash_size)];
spin_lock_bh(&head->lock);
/*
* 遍历桶,找到port对应的inet_bind_bucket。如果没有找到,那么说明这个端口
* 还没有被使用过,那么下面会为这个端口重新分配一个inet_bind_bucket。
*/
inet_bind_bucket_for_each(tb, &head->chain)
if (net_eq(ib_net(tb), net) && tb->port == port)
goto tb_found;
tb_not_found:
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep,
net, head, port);
if (!tb)
goto fail_unlock;
tb_found:
/*
* tb->owners存储的是使用port的套接口,如果为空的话,就不需要进行什么检测了
* 否则,需要进行一系列的检查。
*/
if (!hlist_empty(&tb->owners)) {
/*
* 如果指定了可以地址重用,并且类型是强制重用,那么不进行任何检测,
* 直接强行绑定。这部分可以不用太关注,这是TCP_REPAIR部分的功能。
*/
if (sk->sk_reuse == SK_FORCE_REUSE)
goto success;
/*
* 这里进行地址重用和端口重用检测,如果通过的话,那么直接进行绑定
* 关于端口重用和地址重用部分的检测规则,在inet_csk_bind_conflict
* 函数中有详细的介绍。
*/
if ((tb->fastreuse > 0 && reuse) ||
sk_reuseport_match(tb, sk))
goto success;
/* 进行完整的端口冲突检测。 */
if (inet_csk_bind_conflict(sk, tb, true, true))
goto fail_unlock;
}
success:
/* 这里会根据sk的属性对tb上的端口重用和地址重用等信息进行更新。 */
inet_csk_update_fastreuse(tb, sk);
if (!inet_csk(sk)->icsk_bind_hash)
/* 如果这个套接口没有被绑定到tb->woner上,就将其绑定 */
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;
}
可以看出来,进行端口绑定的核心在于进行端口的冲突检测,即inet_csk_bind_conflict()
函数的实现。这个函数在进行冲突检测时的原则基本上可以概括为:
/*
* sock绑定的时候冲突检测,在以下情况下认为sock不冲突:
*
* 1、两个都绑定了网口,并且绑定的不是一个网口;
* 2、地址复用:两个套接口都启用了地址复用,且没有套接口处于listen状态下。
* 当存在listen状态下的端口时,地址复用不起作用,此时禁止绑定。
* 3、端口复用:两个套接口都启用了端口复用功能,且两个
* 套接口属于同一个用户,或者某一个处于TCP_TIME_WAIT状态。
* 对于UDP,它会均匀的将报文分发给所有的套接口;
* 对于都启用了端口复用的listen状态的TCP,它会均匀地分发accept给所有
* listen套接口。
* 4、两个目的地址不同,且都不是0(0认为匹配所有的地址)。
*
* 其他情况下,绑定端口相同都会认为冲突。
*/
这里需要解释以下地址重用和端口重用的意义。
根据我的理解,地址重用是为了让客户端通过一个本地端口与多个服务器建链。比如客户端A与服务器B存在一条TCP连接:192.168.1.8:5678 → 192.168.1.6:45389
,那么当它向使用本地端口5678建立一条与C服务器的链路就不行了,因为绑定5678端口的时候会冲突。然而TCP连接是通过四元组来识别的,因此完全可以再建立一条TCP链路:192.168.1.8:5678 → 192.168.1.10:67321
,这是符合标准协议的。地址重用就是用于这个目的,设置地址重用后,A就能使用5678端口与C建链。然而,A不能监听本地的5678端口,因为这个与上面地址重用的意图相冲突。
端口重用就比较简单了,就是规定启用了端口重用的端口,可以任意绑定。为了安全考虑,进行端口重用的程序必须属于同一个用户。由于TCP_TIME_WAIT作为一种等待释放的TCP套接口,在进行端口重用冲突检测时,会将其认为是不冲突的。端口重用的场景就比较多了,比如一种场景就是上面所说的用于TCP传输的负载均衡。同时,端口重用还具有地址重用的功能,可以理解端口重用的生效范围覆盖了地址重用。
4.4 端口监听
端口监听的逻辑就比较简单了,其所做的事情主要包括两方面:进行端口的绑定(冲突检测)和哈希表的更新,这个从下面的代码里就可以看出来。对于未绑定端口的套接口,这里是为了随机分配一个本地端口绑定,正如上面所述。而对于已经调用bind绑定的套接口,这里get_port
调用的意义在于再次进行冲突检测,但是不会把套接口加入到bhash表中,因为之前已经加入过了。
明明绑定的时候已经检测过了,为什么这里还要再次检测冲突呢?那是由于此时套接口的状态发生了变化,由之前的CLOSE状态变为了LISTEN状态,那么冲突检测的情况就发生了变化,需要重新检查。比如,套接口A和B启用了地址重用绑定了端口8080,然后A企图调用listen系统调用进行监听,那么这里就会发生冲突,因为地址重用的端口中不能用LISTEN状态的套接口。
int inet_csk_listen_start(struct sock *sk, int backlog)
{
struct inet_connection_sock *icsk = inet_csk(sk);
struct inet_sock *inet = inet_sk(sk);
int err = -EADDRINUSE;
/**
* TCP调用listen系统调用的处理函数。这里可以看出,listen的时候会
* 再次调用sk_prot->get_port进行端口冲突检查以及绑定。
*
* 这是因为,listen之前可能没有调用bind,此时这里的作用就是随机分
* 配一个监听端口。如果之前已经进行过bind,那么这里相当于在此检查
* 有没有冲突,防止竟态问题。
*/
reqsk_queue_alloc(&icsk->icsk_accept_queue);
sk->sk_max_ack_backlog = backlog;
sk->sk_ack_backlog = 0;
inet_csk_delack_init(sk);
inet_sk_state_store(sk, TCP_LISTEN);
if (!sk->sk_prot->get_port(sk, inet->inet_num)) {
inet->inet_sport = htons(inet->inet_num);
sk_dst_reset(sk);
/*
* 调用inet_hash,将套接口放到哈希表,开始监听。对于端口重用
* 的情况,这里还会进行reuseport相关数据的更新以及冲突的检测。
*/
err = sk->sk_prot->hash(sk);
if (likely(!err))
return 0;
}
inet_sk_set_state(sk, TCP_CLOSE);
return err;
}
冲突检测通过后,就会调用inet_hash函数将本套接口添加到lhash和listen_hash表中,开始真正意义上的端口监听。