说说端口号。内核版本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_portinet_csk_bind_conflict 中。

linux允许在以下三种情况下,可以端口复用,即共享同一个本地端口:

  1. 若各个socket绑定到不同接口,可以共享
  2. 若各socket均设置了SO_REUSEPORT(不同编程语言做法不同,但都对应内核sk->sk_reuse),且都不处于TCP_LISTEN状态,可以共享
  3. 若各个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)。

ACTIVE FTP

被动方式对客户端比较友好,客户端总是连接发起的一方;但对服务器来说,防火墙管理会比较麻烦,需要开启2345端口。

PASSIVE FTP

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)呢?随机派送,显然是个糟糕的主意。

最后

放一张手绘的内核里涉及端口的函数调用图,方便以后查阅。

寻宝图