简单谈一点linux内核中套接字的bind机制--数据结构以及端口确定

晨曦之光 发布于 2012/04/10 15:01
阅读 1K+
收藏 1

众所周知,创建一个套接字可以bind到一个特定的ip地址和端口,实际上套接字这一概念代表了TCP/IP协议栈的应用层标识,协议栈中的应用层就是通过一个ip地址和一个端口号标识的,当然这仅仅是对于TCP/IP协议族而言,其他的协议族当然也有类似的标识。值得注意的是,在windows和linux上,bind的限制是不同的,在windows上,只要设置了地址重用,那么相同端口和ip地址的不同的套接字都可以成功的bind,更深层次的语义由别的API机制阐释,而在linux上却不是这样,linux可能更加在意网络通信的实际语义,试想有两个侦听相同端口的服务器套接字,远端的客户端如果连接这个地址的这个端口,那么就会带来混乱,到底谁来提供服务呢?但是如果设置了地址重用,并且没有套接字侦听,那么同时bind相同的端口还是可以的,这里的原则就是这些套接字不是服务器,而可能是客户端,对于客户端而言,去连接谁以及向谁发送数据和从哪里接收数据应该由客户端套接字本身来负责,连接和通信对于客户端都是主动的,这在connect中被确定,但是对于服务器套接字就不是这样,服务器套接字被动的接受连接,然后提供服务,不能指望这种被动的行为可以主动确定连接双方的详细信息,连接从远端而来,为了一切确定,则不能有任何歧义,因此对于同一个ip地址只能有一个套接字侦听特定的一个端口,但是linux并没有将事情做绝,对于另外一种套接字,有确定的远端绑定,那么还是可以在同一个端口bind的,最终其实linux实现了很简单的语义,只要不会引起歧义,那么这种操作就是允许的。

本质上,对于TCP/IP协议族,linux在内核中为每一个协议维护了一个全局的hash表,比如tcp,linux内核将所有的tcp套接字通过这一个全局的表做统一的管理,可能的每一个端口号就是一个哈希桶,所有冲突的元素连接成一个线性链表,这也是linux内核中常规的处理方式。tcp协议的这个hash表就是tcp_hashinfo,这是一个inet_hashinfo类型的数据结构:

struct inet_hashinfo {

struct inet_ehash_bucket *ehash;

rwlock_t *ehash_locks;

unsigned int ehash_size;

unsigned int ehash_locks_mask;

struct inet_bind_hashbucket *bhash; //绑定套接字的链表

unsigned int bhash_size;

struct hlist_head listening_hash[INET_LHTABLE_SIZE]; //侦听套接字链表

rwlock_t lhash_lock ____cacheline_aligned;

atomic_t lhash_users;

wait_queue_head_t lhash_wait;

struct kmem_cache *bind_bucket_cachep;

};

在创建一个socket的时候,这个全局的tcp_hashinfo被赋给sock结构,其实内核中每一个协议都有一个这样的hashinfo,同时每一个协议族拥有一个更加上层的容器,总的来说,每一个协议族有一个高层容器,该容器中的每一个协议又有一个底层容器,这个底层容器中存放网络套接字连接。高层容器为inet_protosw类型,对于tcp/ip而言就是inetsw_array,它的定义是inetsw_array[],这里只列举tcp,另外还有udp和raw省略:

static struct inet_protosw inetsw_array[] =

{

{

.type = SOCK_STREAM,

.protocol = IPPROTO_TCP,

.prot = &tcp_prot,

.ops = ∈et_stream_ops,

.capability = -1,

.no_check = 0,

.flags = INET_PROTOSW_PERMANENT |

INET_PROTOSW_ICSK,

},

...//udp和raw

};

而tcp的inet_hashinfo底层容器就在上述高层容器的proc字段tcp_prot中,理解了这些数据结构,我们看一下重要的函数inet_csk_get_port,在这个函数中做了bind的最终判决:

int inet_csk_get_port(struct sock *sk, unsigned short snum)

{

struct inet_hashinfo *hashinfo = sk->sk_prot->h.hashinfo; //得到该sock对应协议族的全局的底层容器

struct inet_bind_hashbucket *head;

struct hlist_node *node;

struct inet_bind_bucket *tb;

int ret;

struct net *net = sock_net(sk);

local_bh_disable();

if (!snum) { //这种情况就是随机绑定一个没有使用的端口

int remaining, rover, low, high;

inet_get_local_port_range(&low, &high);

remaining = (high - low) + 1; //一般就是1到65535,就是我们常用的端口号范围,当然也可以自己配置

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 (tb->ib_net == net && tb->port == rover) //不考虑重复的

goto next;

break; //如果一个桶遍历过了,没有冲突的,那么就需要在下面建立一个inet_bind_bucket

next:

spin_unlock(&head->lock);

if (++rover > high) //回环机制,如果到了最高的端口那么就回环到最低的重新开始直到起点

rover = low;

} while (--remaining > 0); //在每一个端口的哈希链表上轮询,初始化为一个随机的端口号,然后从此开始遍历一圈,如果到了最高的high端口号,那么从low开始

ret = 1;

if (remaining <= 0) //只有一次机会,如果没有合适的,那么直接返回错误

goto fail;

snum = rover; //如果找了了一个干净的链表,那么接下来要做权衡

} else {

head = &hashinfo->bhash[inet_bhashfn(net, snum, hashinfo->bhash_size)];

spin_lock(&head->lock);

inet_bind_bucket_for_each(tb, node, &head->chain)

if (tb->ib_net == net && tb->port == snum)

goto tb_found;

}

tb = NULL;

goto tb_not_found;

tb_found:

if (!hlist_empty(&tb->owners)) {

if (tb->fastreuse > 0 && sk->sk_reuse && sk->sk_state != TCP_LISTEN) { //如果仅仅是绑定而当前sock不是侦听状态的话,那么就成功了

goto success;

} else { //否则要做进一步的验证,有冲突要处理

ret = 1;

if (inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb))

goto fail_unlock;

}

}

tb_not_found:

ret = 1; //如果找不到就用一个回调函数来做最后的判决,linux内核将此机制作为回调函数我觉得很不错,毕竟这只是一个策略而不是机制,策略就应该由各自的协议栈实现

if (!tb && (tb = inet_bind_bucket_create(hashinfo->bind_bucket_cachep, net, head, snum)) == NULL)

goto fail_unlock;

if (hlist_empty(&tb->owners)) { //fastreuse很重要,只要有一个此桶中的sock不是地址重用的,那么就不能考虑和这个sock共同使用一个端口

if (sk->sk_reuse && sk->sk_state != TCP_LISTEN)

tb->fastreuse = 1;

else

tb->fastreuse = 0;

} else if (tb->fastreuse && (!sk->sk_reuse || sk->sk_state == TCP_LISTEN)) //任何情况下不能和监听套接字共用一个端口

tb->fastreuse = 0;

success:

if (!inet_csk(sk)->icsk_bind_hash)

inet_bind_hash(sk, tb, snum); //最后将信息加入到全局的容器中

WARN_ON(inet_csk(sk)->icsk_bind_hash != tb);

ret = 0;

...//返回处理,开锁

}

这个函数中一个很重要的函数就是bind_conflict回调函数,该函数负责处理冲突,也就是说一旦查找到的inet_bind_bucket和当前的sock有冲突,那么就有必要去化解一下僵局,这也表明事情不是不可化解的,linux规定在以下的情况下可以重用端口:1.绑定不同网络接口的可以使用同一个端口;2.每一个设置了地址重用的并且都不处于listen状态的所有的套接字可以使用一个端口,这意味着它们都是主动外出的套接字,目的由它们自己掌握;3.即便在1和2都不满足的情况下,使用不同源地址的服务器套接字也可以使用同一个端口。对于一般的tcp协议,该处理冲突的回调函数就是inet_csk_bind_conflict:

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和sk2不同,因为同一个套接字在bind和listen中都会调用get_port回调函数

(!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;

}

最后看一下侦听套接字的设计,其实它和fork的原理几乎是如出一辙,侦听套接字在调用listen之后就会变成一个服务器套接字来等待客户端的连接,之后它调用accept套接字来实际接受远端的连接(实际可以接收本端的连接,只不过在网络的意义上区分开来),侦听套接字本身并不变,每接受一个连接,它就会返回一个新的套接字,这个新的套接字将会用来处理和远端客户套接字的通信,这里侦听套接字可以类比为父进程,而返回的与客户端通信的处理套接字可以类比为子进程,和fork的方式一样,经过accept调用并且该调用在非阻塞方式下返回之后,我们得到两个套接字,一个就是侦听套接字,该套接字并没有因为调用accept而被改变,另一个就是新的处理套接字,该处理套接字就好像从侦听套接字中分身分出来的一样,这一点和fork的语义特别相似,在fork中,父进程就相当于服务器进程,进程的作用就是完成一项任务,每接受一项新的任务,父进程就会fork一个子进程来完成这项新的任务,同时父进程继续等待接受新的任务,这两个概念的设计如出一辙,无论哪一个都是工作者,从这些设计可以看到设计者们当初是怎么对待计算机的,网络和计算机的概念其实本来就没有什么区别,我们最终之需要在意进程或者说我们实际需要计算机帮我们完成的任务就可以了,我烦微软就是因为它为最终用户展现了很多“计算机”的信息,比如网络接口,比如c盘,d盘等等。

在内核中,bind的实现和listen的实现都要调用get_port回调函数函数,对于inet的tcp而言就是inet_csk_get_port,bind的语义就是将一个套接字和一个标识符联系起来,就是说给与一个套接字一个合法的应用层身份,而listen的语义是为远端套接字提供服务,来一个连接就会为这个连接新建一个套接字,服务者首先要有自己的身份,因此listen之前必须首先进行bind调用。为了安全并且简单化处理,已经bind过的套接字不能再次bind,已经listen的套接字不能再次listen,这些都是在内核进行判定的,也就是在inet_bind和inet_listen中调用get_port回调函数之前进行判定的,linux的网络之所以可以简单的实现这些,就是因为它为每一个套接字维护了不同的状态,就好像进程的runnable,running,sleeping等等一样,协议栈代码的作用就将套接字在这些不同的状态之间进行切换,正如进程管理器的作用是维护并切换进程的不同状态一样。一旦bind成功或者listen成功,该套接字就会加入到全局的对应协议族的底层容器中然后方便的被网络协议栈的代码所管理。这些工作的大部分在inet_bind_bucket_create函数和inet_bind_hash函数中完成:

struct inet_bind_bucket *inet_bind_bucket_create(struct kmem_cache *cachep, struct net *net, struct inet_bind_hashbucket *head, const unsigned short snum)

{ //创建一个inet_bind_bucket,注意,创建它的slab分配器是属于协议族的容器的

struct inet_bind_bucket *tb = kmem_cache_alloc(cachep, GFP_ATOMIC);

if (tb != NULL) {

tb->ib_net = hold_net(net);

tb->port = snum; //将端口号赋给这个新建的inet_bind_bucket

tb->fastreuse = 0;

INIT_HLIST_HEAD(&tb->owners); //初始化该inet_bind_bucket的所有者链表,以备共享

hlist_add_head(&tb->node, &head->chain); //加入冲突链表

}

return tb;

}

void inet_bind_hash(struct sock *sk, struct inet_bind_bucket *tb, const unsigned short snum)

{

inet_sk(sk)->num = snum; //将端口号赋给该套接字,在进行bind的时候会检查这个字段,如果不为0就不再二次bind

sk_add_bind_node(sk, &tb->owners); //将套接字加入一个inet_bind_bucket的所有者链表

inet_csk(sk)->icsk_bind_hash = tb; //指明inet_bind_bucket的所有者

}

inet_sock结构体特别有面向对象的意思,看看它的定义,第一个字段就是struct sock,说明sock结构是inet_sock的基类,套接字可以支持多个协议族,显然这个基类就需要一些子类来扩展套接字的含义,对于inet来说就是inet_sock,端口号这个概念也仅仅对于inet才是有意义的,inet_sock扩展了很多概念。linux对于套接字的组织形式非常好,实际上真正的套接字是sock结构,socket结构以及系统调用只是为了接口的方便以及空间的优化,在unix中提出了一切皆文件的哲学,文件也就成了unix以及后来的linux中操作系统提供给用户的接口,套接字为了迎合这一哲学就采用了socket和sock两层结构来定义套接字,其中socket又称为BSD socket,主要和文件系统接口,看看inode结构就会明白,而sock才是真正的套接字。前面说过,sock仅仅是一个基类,主要是在协议栈代码管理套接字的时候有用,它使得一个实体成为了套接字,实现了机制,而不同协议的sock的子类比如inet_sock才是真正实现策略的实体,比如端口号的概念以及管理。在linux内核中这样的例子非常多,比如说device结构体抽象了设备的概念参与设备管理,然而也就仅仅如此,不同的设备会继承这个类似基类的device结构体已实现自己独特的设备特性,可见C语言写成的linux内核处处都有新思想,C语言是十分灵活的,用它几乎可以做任何你想做的事情。

好了继续说上面的那两个函数,第一个函数inet_bind_bucket_create的作用是将新创建的inet_bind_bucket连接入inet协议的底层哈希容器中的冲突链表,注意哈希桶并不是端口号的索引,而是通过端口号的索引计算出来的,如果用端口号也可以,但是太大了,65535个桶会导致效率低下的,哈希的好处就在于将大量的数据散列到有限的桶中,好的哈希使得散列很均匀,这样就提高了整体查找效率,其实哈希也是一种折中,对于大量的数据会导致大量冲突,此时哈希有效地缩小了查找的范围,想要一下子找到的话就别用哈希,用数组得了,但是那样会有很大的空间消耗。哈希有一点碰运气的意思,比如冲突链表就是“碰运气”的一种实现,如果恰好冲突链表的第一个元素就是我们寻找的元素,那么我们就省去了很多时间,即使冲突链表的最后一个是我们需要的元素,我们起码也节省了按照遍历的方式定位到这个哈希桶的时间。将新创建的inet_bind_bucket连入冲突链表之后,这个inet_bind_bucket就被管理了,它代表了一个可以应用层标识,想让一切动起来光有实体不行,还有有主体使用该实体才可以,使用者就是在后一个函数inet_bind_hash中被加入的,实际上前一个inet_bind_bucket连入哈希被全局管理,后一个sock连入inet_bind_bucket被底层容器本身管理,这是一个事物的多个方面,这个事物就是inet_bind_bucket

struct inet_bind_bucket {

struct net *ib_net;

unsigned short port;

signed short fastreuse; //一旦有一个不重用地址的sock被加入,这个字段将会被设置为0;

struct hlist_node node; //链入本节点的管理者

struct hlist_head owners; //保留所有的使用者

};

它一方面是协议容器冲突链表的成员,另一方面是所有共享该应用层标识的sock的容器,linux的网络协议栈利用这种机制十分高效的管理着网络,形象化的思考,linux网络协议栈的实现就是一张多维的图,这张图看似复杂实际上十分有条理,图的每一个部分都彼此保持连通性,从一点可以到任何一点,然而有那么一种可能必须经过很多别的节点,安放过滤行为钩子十分简便同时又不会影响性能,因此linux的网络十分安全和高效。


原文链接:http://blog.csdn.net/dog250/article/details/5303572
加载中
返回顶部
顶部