Linux网络:基于文件的网络架构
Linux网络:基于文件的网络架构
- 网络架构
- TCP全连接队列
网络架构
在Linux
中提供了多种系统调用,完成网络操作。比如TCP
连接的建立,各种报文的收发等等。但是所有的Linux
网络操作,都源于系统调用socket
,
在Linux
的man
手册中,对这个系统调用做了描述,表示返回值是一个file descriptor
文件描述符。后续建立连接、监听连接、收发报文都要基于这个返回值,也就是说:在Linux
中网络被当作一个文件处理,这符合Linux
的一切皆文件理念。
接下来以TCP
为例,讲解Linux
中的网络架构,源码版本为Linux 2.6.26
。
struct tcp_sock
:
TCP
由结构体struct tcp_sock
管理。
struct tcp_sock {
struct inet_connection_sock inet_conn;
u16 tcp_header_len;
u16 xmit_size_goal;
// ...
};
这是TCP
最底层的结构体,每创建一个TCP
连接,底层都会维护一个这样的结构体。它记录了TCP
的部分信息。
tcp_header_len
这个成员记录了 TCP
头部的长度,单位是字节。 TCP
头部长度是可变的,因为它可以包含多个 TCP
选项。这个值用于在发送数据时确定需要预留多少空间给 TCP
头部。
xmit_size_goal
这个成员定义了输出数据包的目标大小。它是发送数据时的一个目标值,用于分段输出数据。这个值可以帮助 TCP
层决定每个数据包应该发送多少数据。
其中最重要的是inet_conn
,它的类型是struct inet_connection_sock
。
struct inet_connection_sock
:
struct inet_connection_sock {
/* inet_sock has to be the first member! */
struct inet_sock icsk_inet;
struct request_sock_queue icsk_accept_queue;
//...
};
在这个结构体中,包含了各种连接的相关信息,比如拥塞控制算法的相关参数,报文的重传次数,退避算法的退避时长等等。
其中icsk_accept_queue
称为全连接队列,这个会在后文专门讲解。
其第一个成员icsk_inet
也是一个结构体,类型为struct inet_sock
。
struct inet_sock
:
struct inet_sock {
/* sk and pinet6 has to be the first two members of inet_sock */
struct sock sk;
#if defined(CONFIG_IPV6) || defined(CONFIG_IPV6_MODULE)
struct ipv6_pinfo *pinet6;
#endif
/* Socket demultiplex comparisons on incoming packets. */
__be32 daddr;
__be32 rcv_saddr;
__be16 dport;
__u16 num;
__be32 saddr;
//...
}
该结构体是 Linux
内核中处理基于 IP 套接字的通用结构体,它包含了 IP 层的套接字信息。
其中#if
到#endif
是一个条件编译信息,如果使用IPv6
协议,就会启用内部的指针pinet6
,指向一个管理IPv6
的结构体。
其还存储了一些IP协议的相关信息,比如daddr
是目标地址,dport
是目标端口,saddr
是源地址等等。
这个结构体的第一个成员sk
,类型为struct sock
。
struct sock
:
struct sock {
/*
* Now struct inet_timewait_sock also uses sock_common, so please just
* don't add nothing before this first member (__sk_common) --acme
*/
struct sock_common __sk_common;
//...
struct sk_buff_head sk_receive_queue;
struct sk_buff_head sk_write_queue;
//...
}
struct sock
结构体是所有类型套接字的通用结构体,包括 TCP
、UDP
和原始套接字等。
其中sk_receive_queue
指向接收缓冲区,用于存储已经接收到,但是未被用户读取走的数据。sk_write_queue
指向发送缓冲区,也就是用户交给操作系统,但是尚未被发送到网络的数据。
至此,已经大致打通了一个套接字管理的四个结构体:
自顶向下为:
struct sock
:套接字的通用结构体struct inet_sock
:管理IP
层的相关信息struct inet_connection_sock
:管理网络连接struct tcp_sock
:管理TCP
连接
但是目前好像还没有看到和文件相关的内容?
其实网络与文件的衔接,是由struct socket
完成的。
struct socket {
socket_state state;
unsigned long flags;
const struct proto_ops *ops;
struct fasync_struct *fasync_list;
struct file *file;
struct sock *sk;
wait_queue_head_t wait;
short type;
};
在 Linux
中,struct socket
结构体是用户空间与内核空间之间的接口,用于实现网络通信。
例如第一个成员state
表示套接字的状态,比如已连接、未连接、正在连接等等。ops
是一个结构体指针,指向的结构体是一个方法集,内部包含套接字的函数指针。
struct proto_ops {
int family;
struct module *owner;
int (*release) (struct socket *sock);
int (*bind) (struct socket *sock,
struct sockaddr *myaddr,
int sockaddr_len);
int (*connect) (struct socket *sock,
struct sockaddr *vaddr,
int sockaddr_len, int flags);
int (*socketpair)(struct socket *sock1,
struct socket *sock2);
int (*accept) (struct socket *sock,
struct socket *newsock, int flags);
int (*listen) (struct socket *sock, int len);
int (*setsockopt)(struct socket *sock, int level,
int optname, char __user *optval, int optlen);
int (*sendmsg) (struct kiocb *iocb, struct socket *sock,
struct msghdr *m, size_t total_len);
int (*recvmsg) (struct kiocb *iocb, struct socket *sock,
struct msghdr *m, size_t total_len,
int flags);
// ...
};
在这个opt
内部,可以看到很多系统调用的身影,比如listen
、bind
、sendmsg
、recvmsg
等等,TCP
中收发信息使用的sendto
、recvfrom
系统调用,就是通过这些指针来实现的。
当使用套接字收发消息的时候,就会通过传入的文件描述符找到struct socket
,进而找到opt
,在opt
内部查找收发消息的函数,完成数据传递。
那么为什么不能直接把这些函数写在struct sock
内部,而是要使用一个struct proto_ops
?
这个struct sock
既可以管理TCP
也可以管理UDP
,这两个协议所使用的函数当然不同。以bind
为例,TCP
的struct sock
调用bind
时,调用的其实是sock->opt->bind()
,UDP
同理也去调用sock->opt->bind()
。只要定义套接字时,opt
这个指针指向不同的struct proto_ops
,那么TCP
与UDP
就会调用到不同的函数。
可以发现,这其实是C
语言的一种多态实现方式,不同对象调用相同的方法,结果不一样。
struct socket
还有一个sk
成员,其类型为struct sock*
,也就是指向之前四个层次的顶层。
在struct socket
中,还有一个和网络看似毫不相关的成员file
,其类型为struct file*
。这是Linux
内核这中,描述一个文件的结构。也就是说struct socket
对上将网络操作转化为文件操作,对下管理网络信息,并提供网络通信所需的函数。
在struct file
中,有一个private_data
成员:
struct file {
//...
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;
//...
};
其类型为void*
,经过条件编译产生。如果一个文件指向网络操作,那么这个private_data
就会启用,并指向struct socket
。
至此,就可以看清Linux
的网络架构全貌了:
当用户调用接口创建套接字,会得到一个文件描述符sockfd
,它指向一个文件。当通过sockfd
操作网络时,会去查找该描述符对应的文件,在文件内部通过private_data
找到struct socket
,这里面包含各种网络操作的具体函数。再往下可以通过sk
找到管理一个连接的struct sock
,这里面包含这个连接的各种信息。
有了操作网络的方法和一个网络连接的相关信息,那么操作网络就可以实现了。
不过从用户的角度出发,用户看不到底层的网络信息,也无法直接接触socket->opt
。也就是说Linux
把网络完全隐藏起来了,对用户只表现为一个文件描述符sockfd
以及文件操作。
TCP全连接队列
最后简单讲解一下TCP
的全连接队列,全连接队列就是inte_connection_sock
下的icsk_accept_queue
。它完全由Linux
内核管理,按理来说用户是无需关心这个内容的,但是在TCP
的listen
函数中,第二个参数backlog
与其相关,所以再此要提一下。
listen
函数声明:
int listen(int sockfd, int backlog);
这个函数用于让一个套接字开始进行TCP
连接的监听,第一个参数传入文件描述符sockfd
,第二个参数是一个int
,这其实和全连接队列相关。
TCP
连接的建立需要经过三次握手,在三次握手的过程中,双方主机要记录当前连接创建到哪一步了,为此Linux
使用了全连接队列
与半连接队列
来管理TCP
连接。
以服务端为例,在三次握手过程中,server
共有四种状态:等待连接、收到SYN
、收到ACK
、用户accept
该连接。
其中accept
由用户完成,剩下的由操作系统自动完成。一个套接字中很可能同时维护了多个TCP
连接,不同连接又可能处于不同的状态,为此套接字使用了两个队列来维护这些状态。
当一个TCP
连接收到SYN
报文后,进入半连接队列,即这个连接创建了一半。当收到ACK
报文后,说明连接创建完毕,此时离开半连接队列,进入全连接队列。
也就是说,全连接队列中存储的是已经完成三次握手,但是还没有被用户accept
的数据。
在一个套接字中,用户能同时处理的TCP
连接是有限的,那么超出能力范围的TCP
连接,就会暂存在全连接队列中,当用户处理完一个连接,再去全连接队列读取出一个连接。
假设服务器同时接收到大量的网络请求,整个操作系统就要创建很多TCP
连接,进行很多次握手,这让本就繁忙的CPU
雪上加霜。因此操作系统不能允许大量的TCP
连接同时建立,就算建立好了连接,用户也不一定会读取这个连接。
因此全连接队列与半连接队列是有长度限制的,如果队列满了之后,操作系统收到新的TCP
连接,操作系统会直接丢弃这个连接请求,或者返回一个RST
报文,表示拒绝这个请求。
listen
第二个参数backlog
用于指定全连接队列的长度,但也不完全取决于这个参数。操作系统也会给出一个全连接队列的最大长度somaxconn
,其在路径/proc/sys/net/core/somaxconn
下。
最终的全连接队列长度会取min(backlog, somaxconn)
。
全连接队列内部的连接,需要通过accept
函数接收,而accept
函数的返回值是一个sockfd
,这其实说明了Linux
中网络系统转化到文件系统的时机。
在TCP
三次握手过程中,会预先创建struct sock
,存储一些网络的相关信息,但是这个时候并不会创建struct file
,因为Linux
操作网络无需通过文件。
当用户accept
一个连接的时候,Linux
得知用户需要操作这个连接,那么就会为这个连接创建struct file
,并完成文件系统到网络系统的映射关系,把文件描述符sockfd
返回给用户。
也就是说,从网络系统到文件系统的映射,是在用户accept
的时候完成的。