说说内核协议栈的端口号
by 伊布
说说端口号。内核版本2.6.32。
bind固定端口号
服务端通常bind指定端口号,例如ssh 22(tcp), ftp 21(tcp), dhcp 67(udp), dns 53(udp)。以下面这个例子来说。
package main
import (
"fmt"
"net"
)
func listen1(str string) {
addr := fmt.Sprintf("%s:5432", str)
ln, err := net.Listen("tcp", addr)
if err != nil {
fmt.Printf("%v", err)
}
for {
conn, err := ln.Accept()
if err != nil {
// handle error
}
fmt.Printf("TCP request from %s", conn.RemoteAddr().String())
conn.Write([]byte("hahah"))
conn.Close()
}
}
func main() {
listen1("0.0.0.0")
}
go run listen2.go
执行后会监听5432端口。
$ netstat -antp|grep 5432
tcp6 0 0 :::5432 :::* LISTEN 6558/listen2
kernel中的处理主要是在 inet_csk_get_port
和 inet_csk_bind_conflict
中。
linux允许在以下三种情况下,可以端口复用,即共享同一个本地端口:
- 若各个socket绑定到不同接口,可以共享
- 若各socket均设置了SO_REUSEPORT(不同编程语言做法不同,但都对应内核sk->sk_reuse),且都不处于TCP_LISTEN状态,可以共享
- 若各个socket均监听特定地址,且地址均不相同,可以共享
注意第三点。在有些多租户的环境中,不同租户的server可以监听不同地址+相同端口号,从而为租户提供x.x.x.x:5432的连接。可以将上面代码中的main改为:
func main() {
go listen1("10.84.1.138")
listen1("172.17.0.1")
}
当然前提是本地得有这两个地址。run一下可以看到有2个监听。
$ netstat -antp|grep 5432
tcp 0 0 10.84.1.138:5432 0.0.0.0:* LISTEN 25918/listen2
tcp 0 0 172.17.0.1:5432 0.0.0.0:* LISTEN 25918/listen2
上述检查在 inet_csk_bind_conflict
中实现。其中*tb上挂着若干个相同端口号(hash后的冲突链)的socket,从而会对所有该端口号的socket遍历,做地址是否相同的检查。还是贴下代码,方便阅读。
int inet_csk_bind_conflict(const struct sock *sk,
const struct inet_bind_bucket *tb)
{
const __be32 sk_rcv_saddr = inet_rcv_saddr(sk);
struct sock *sk2;
struct hlist_node *node;
int reuse = sk->sk_reuse;
sk_for_each_bound(sk2, node, &tb->owners) {
if (sk != sk2 &&
!inet_v6_ipv6only(sk2) &&
(!sk->sk_bound_dev_if ||
!sk2->sk_bound_dev_if ||
sk->sk_bound_dev_if == sk2->sk_bound_dev_if)) {
if (!reuse || !sk2->sk_reuse ||
sk2->sk_state == TCP_LISTEN) {
const __be32 sk2_rcv_saddr = inet_rcv_saddr(sk2);
if (!sk2_rcv_saddr || !sk_rcv_saddr ||
sk2_rcv_saddr == sk_rcv_saddr)
break;
}
}
}
return node != NULL;
}
bind 0 端口号
有些应用,其角色为server,但监听的端口号并不是固定的;如果在进程启动的时候由进程随机指定一个端口号bind,可能出现端口号冲突的情况。为了解决这个问题,此时进程可以选择 bind 0 端口,由操作系统来分配一个空闲的端口号给进程监听,端口号范围是32768 ~ 61000。
将上面的addr := fmt.Sprintf("%s:5432", str)
的端口号5432改为addr := fmt.Sprintf("%s:0", str)
,再run一下:
$ netstat -antp|grep listen2
tcp6 0 0 :::42017 :::* LISTEN 7035/listen2
多次run,监听的本地端口号不一样。具体选端口号的实现在inet_csk_get_port
中。简单说一下。
先在 32768 ~ 61000 之间随机选一个端口。
inet_get_local_port_range(&low, &high);
remaining = (high - low) + 1;
smallest_rover = rover = net_random() % remaining + low;
随机选的不稳,所以要检查一下,免得重复。
do {
head = &hashinfo->bhash[inet_bhashfn(net, rover,
hashinfo->bhash_size)];
spin_lock(&head->lock);
inet_bind_bucket_for_each(tb, node, &head->chain)
if (ib_net(tb) == net && tb->port == rover) {
//do reuse check
}
break;
next:
spin_unlock(&head->lock);
if (++rover > high)
rover = low;
} while (--remaining > 0);
找到了,很开心,存起来。
success:
if (!inet_csk(sk)->icsk_bind_hash)
inet_bind_hash(sk, tb, snum);
connect时选择本地端口号
当进程以客户端身份连接服务端时,通常不需要关心本地端口号是多少,由kernel分配即可。connect选本地端口号的实现在函数 __inet_hash_connect
中。这个逻辑跟 inet_csk_get_port
有点类似,但更快速,只要有人碰了这个端口就快速跳过。代码就不贴了,有兴趣直接去看 __inet_hash_connect
吧。
fastreuse
在走读 bind 0端口,和 connect 随机选择端口代码的时候,会注意到内核里有一个fastreuse的标记。这是用来干啥的呢?
在linux-2.6.32.69/include/net/inet_hashtables.h
中有这样的描述。
/* There are a few simple rules, which allow for local port reuse by
* an application. In essence:
*
* 1) Sockets bound to different interfaces may share a local port.
* Failing that, goto test 2.
* 2) If all sockets have sk->sk_reuse set, and none of them are in
* TCP_LISTEN state, the port may be shared.
* Failing that, goto test 3.
* 3) If all sockets are bound to a specific inet_sk(sk)->rcv_saddr local
* address, and none of them are the same, the port may be
* shared.
* Failing this, the port cannot be shared.
*
* The interesting point, is test #2. This is what an FTP server does
* all day. To optimize this case we use a specific flag bit defined
* below. As we add sockets to a bind bucket list, we perform a
* check of: (newsk->sk_reuse && (newsk->sk_state != TCP_LISTEN))
* As long as all sockets added to a bind bucket pass this test,
* the flag bit will be set.
* The resulting situation is that tcp_v[46]_verify_bind() can just check
* for this flag bit, if it is set and the socket trying to bind has
* sk->sk_reuse set, we don't even have to walk the owners list at all,
* we return that it is ok to bind this socket to the requested local port.
*
* Sounds like a lot of work, but it is worth it. In a more naive
* implementation (ie. current FreeBSD etc.) the entire list of ports
* must be walked for each data port opened by an ftp server. Needless
* to say, this does not scale at all. With a couple thousand FTP
* users logged onto your box, isn't it nice to know that new data
* ports are created in O(1) time? I thought so. ;-) -DaveM
*/
1/2/3就是前述三种可以端口复用的情况,然后阐述了设计fastreuse这个标记的原因。
以FTP为例。
FTP的通道分为控制通道和数据通道;根据数据通道连接发起方角色的不同,又分为主动方式和被动方式(相对FTP服务器来说)。
- 主动方式:FTP服务器主动发起数据通道的连接。FTP服务器作为主动发起连接的一方,绑定本地的20端口号,连接FTP客户端在控制通道中通告的数据通道监听端口。
- 被动方式:FTP客户端主动发起数据通道的连接,连接FTP服务器的>1024的某个端口。
主动方式对服务器的管理比较方便,服务器侧的防火墙只要保证20/21端口开启就可以了;但对客户端来说可能会比较麻烦,需要客户端防火墙开启本地监听端口(图示的1501)。
被动方式对客户端比较友好,客户端总是连接发起的一方;但对服务器来说,防火墙管理会比较麻烦,需要开启2345端口。
FTP主动方式中,会用到fastreuse。
主动方式下,FTP服务器在connect FTP客户端监听的端口(1501)前,会先bind本地的端口号20,这样收到FTP客户端的应答时,报文不会被防火墙丢掉。第一个用户的连接大概是这样的:
ftp_client1_ip:1500 ----> ftp_server_ip:21
ftp_client1_ip:1501 <---- ftp_server_ip:20
那么第二个用户登录以后呢?
FTP服务器还是会bind port 20,此时会不会报port in use呢?根据前面讲的第二点,若各socket均设置了SO_REUSEPORT,且socket状态不是LISTEN时,允许复用。所以,第二个用户登录以后,连接可能是:
ftp_client1_ip:1500 ----> ftp_server_ip:21
ftp_client1_ip:1501 <---- ftp_server_ip:20
ftp_client2_ip:1500 ----> ftp_server_ip:21
ftp_client2_ip:1501 <---- ftp_server_ip:20
那么有成千上万个用户登录进来以后呢?在FTP服务器端就会有成千上万个socket,绑定了本地port 20。
每个新用户登录进来的时候,FTP服务器都需要检查下port 20是否被占用,若占用是否允许复用。BSD的实现是,老老实实去匹配整个list(again,成千上万),检查list上每个socket(inpcb)是否设置了SO_REUSEPORT,效率显然是不高的。
linux在这里做了个快速路径。当且仅当bind bucket在port 20冲突链上挂的所有owner socket都是 (newsk->sk_reuse && (newsk->sk_state != TCP_LISTEN))
的,那么就设置该冲突链 tb->fastreuse 为1。
struct inet_bind_bucket {
#ifdef CONFIG_NET_NS
struct net *ib_net;
#endif
unsigned short port;
signed short fastreuse;
int num_owners;
struct hlist_node node;
struct hlist_head owners;
};
下次有socket bind port 20时,只要检查该端口对应冲突连的tb->fastreuse 为1,就可以跳过冲突链遍历,直接goto success。如果不满足条件,则要走inet_csk_bind_conflict
,慢慢去遍历吧亲。
tb_found:
if (!hlist_empty(&tb->owners)) {
if (tb->fastreuse > 0 &&
sk->sk_reuse && sk->sk_state != TCP_LISTEN &&
smallest_size == -1) {
goto success;
} else {
ret = 1;
if (inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb)) {
if (sk->sk_reuse && sk->sk_state != TCP_LISTEN &&
smallest_size != -1 && --attempts >= 0) {
spin_unlock(&head->lock);
goto again;
}
goto fail_unlock;
}
}
BTW, 之前我对REUSEPORT的理解是有问题的。从字面意义上来看,似乎只要设置了端口复用,就可以放心去listen+bind相同端口号了,但从上面说明可知,显然socket处于LISTEN状态是不能端口复用的。这样设计是正确的,因为上送本机的报文,在匹配LISTEN socket的时候,如果能够匹配多个,如何保证报文送给合适的人选(LISTEN socket)呢?随机派送,显然是个糟糕的主意。
最后
放一张手绘的内核里涉及端口的函数调用图,方便以后查阅。
Subscribe via RSS