目录
1.全连接队列
(1)listen函数参数
下面是网络项目中一个封装的基本函数,我们仅需关心里面的listen函数即可,在此之前,我们需要将端口号和IP绑定到对应的struct file,这样的话当服务端开启监听状态之后,客户端调用connect,访问对应的IP + Port就能定位找到这个监听fd,进而被服务端给accept,双方开始通信。

listen的第二个参数backlog是什么?全连接队列长度 - 1,全连接队列的长度即处于等待队列中的连接数的最大数
在之前的学习中我们知道了,所谓三次握手对应客户端的connect和服务端的accept,四次挥手对应客户端和服务端的close函数,但无论是哪个函数,函数本身不参与握手和挥手的全过程,都是触发握手挥手,交由OS内核自行完成。 那么问题是,对于内核来说,这个过程发生了什么?
(2)建立连接的宏观流程
当有多个客户端来连接服务端时,客户端会调用connect使OS底层触发三次握手,首先第一个问题是,客户端的OS如何找到服务端?connect里面有个参数sockaddr,里面包含IP + Port,IP定位主机,端口号对应进程,因此可以通过这个结构体标识唯一的主机进程。并且,服务端启动后要bind端口号和IP,因此,事实上这个结构体是定位到了唯一的主机上面的唯一的struct file,这个struct file就是监听套接字。
这里需要针对bind的特殊情况做出一些解释,云服务器的公网IP其实是虚拟出来的,事实上当我们connect到云服务器的公网IP时,其实是发给了云服务器提供商,由提供商帮我们路由的,这就是NAPT,和我们家里路由器的公网IP的来源原理是一样的。 因此云服务器其实是内网的,就像家里面的一个设备那样,它有公网IP,但这是经过NAPT由运营商路由器提供的。所以我们不能在内网设备上直接bind公网IP,因为其实这个公网IP并没有配置在我们主机的网卡,是云服务器提供商的。
客户端connect调用之后,OS自动三次握手成功后,服务端OS底层就会建立连接,将这个连接维护在监听套接字的全连接队列里面,如果恰好这个时候服务端调用accept,那么服务端就会捞取这个连接并返回一个专门通信用的套接字fd,同时客户端也会将这个连接从全连接队列里面移除。
总结: 三次握手建立连接的过程与服务端是否调用accept无关,只要服务端处于监听状态,客户端调用connect依旧能够连接上,可以用netstat验证。
(3)backlog
客户端发起第一次握手后状态变成SYN_SENT,服务器发起第二次握手后服务器变成SYN_RCVD,客户端发起第三次握手发送后客户端进入ESTABLISED,服务端接收到第三次握手后变成ESTABLISED。
但我们发现当服务端始终不调用accept时,多个客户端connect之后,一些连接进入了ESTABLISED,而一些客户端的状态一直在SYN_SENT,始终没进入ESTABLISED,即三次握手未完成。这其实是第二次握手没发起,即服务端不回应。 其实,在服务器来不及accept时,也就是压力比较大时,TCP的listen套接字允许用户先三次握手建立连接并暂时保存着,但一次性不能保存太多,最大建立连接数为backlog + 1,已建立的连接暂时放在队列里,这个队列就叫全连接队列。 也就是说,listen的第二个参数backlog表示全连接队列中节点的个数 - 1。
(4)对全连接队列的理解
全连接队列在Linux内核中是一个accept_queue,因为连接本质上就是内核中的结构体的某种数据结构。 连接放在全连接队列中,accept其实是从全连接队列中取出连接,创建struct file、关联并返回一个fd(注意,取出来后这个连接就不在accept_queue里面了),后续我们在应用层用这个fd就可以找到这个连接了。
全连接队列是内核中的一种连接等待队列,这并不意味着服务端只能同时处理backlog + 1个连接,因为只要我们调用accept,连接就会移出队列。 全连接队列里面放的是来不及处理的连接 。从另一个角度上看,全连接队列其实就是生产者消费者模型,一边放一边拿,这个队列相当于一个缓冲区(交易场所),这种设计提高了服务器连接的吞吐量,降低闲置率。
全连接队列长度不宜为0,这会导致服务器吞吐量低。当然也不能太长,用户体验不好,一直处于SYN_SENT状态,对于服务器来说也浪费空间。
2.Linux网络内核设计
(1)设计结构图
后续所有讲解都基于该内核设计结构图。

(2)从struct file到struct socket
启动一个服务就是启动了一个进程,一个进程其实就是一个PCB,即task_struct,task_struct里面有struct files_struct,files_struct里面有一个fd_array[N],我们熟知的fd其实就是数组fd_array的下标,用户只要拿着fd就能找到struct file*,进而找到struct file。那么,我们应该如何理解连接呢?
创建套接字时(调用socket函数时),内核中就会新建一个struct socket,struct socket里面有一个struct file*的回指指针,用于回指struct file,struct file里面有一个字段private_data指针,这个指针也指向struct socket,两个结构互相指向。 struct socket是网络socket的入口,由fd -> struct file -> private_data -> struct socket进入。
struct socket里面有一个字段const struct proto_ops* ops,这个struct proto_ops是方法族,里面有各种各样的函数指针,包括release、bind、accept、listen、shutdown、setsockopt、getsockopt、sendmsg、recvmsg、mmap等基本函数指针,上层读写套接字时就可以调用struct socket里面struct proto_ops的不同指针来执行不同方法,这也是C语言实现多态。
在struct file里面有文件操作表,包含对文件的一些操作,这些函数指针其实是根据struct socket里面的方法集设置的,同理,struct file里面的文件操作表是对下层(struct socket)里面的方法进一步封装。struct socket函数方法集里面的函数也是对其更下层的函数的封装。Linux里的文件操作的函数是分层的,每一层负责对下层进行封装,并对上提供接口,最后提供给用户read、write、open、close等函数,我们调用这些接口也是分层向下执行的。
(3)TCP和UDP的继承体系
创建TCP连接,本质上是创建一个内核的结构体的某个数据结构,在这里实际上指的就是struct tcp_sock,这也是内核中真实的TCP套接字。
tcp_sock里面包含序列号信息、连接关联的进程、滑动窗口大小、ssthresh拥塞窗口大小等。
struct tcp_sock的第一个成员变量是另一个结构体struct inet_connection_sock,这个结构体包含很多链接相关信息,包括请求超时时间、重传倒计时、全连接队列(struct request_sock_queue icsk_accept_queue)、MSS(分片相关字段)。
struct inet_connection_sock的第一个成员是struct inet_sock,里面包含各种网络相关的字段,如TTL(在路由器间的最大跳数)、IPv4地址、端口号。
struct inet_sock里面的第一个成员是struct sock,struct sock包含报文相关的信息,是整个嵌套结构最顶层、内侧的一个。
struct sock里面有struct sk_buff_head sk_receive_queue和sk_write_queue,struct sk_buff_head里面有struct sk_buff。每一个报文都对应一个sk_buff结构,sk_buff里面有指针会指向我们的报文,我们通过sk_buff里面的指针移动来管理报文。操作系统管理报文就是管理sk_buff,即sk_receive_queue和sk_write_queue,即发送和接受队列(读缓冲区和写缓冲区也来源于此)。 sk_buff间用双向链表连接起来(next和prev指针)交给struct sk_buff_head管理。当我们写数据时从应用层拷贝下来的数据都会被转换成sk_buff结构并放在发送队列并向下交付,读取数据也是接收队列读取sk_buff。
创建TCP套接字就是创建struct tcp_sock,一旦创建好,四个结构体都有了。struct socket里面有个成员是struct sock* sk,这个指针指向struct tcp_sock里面的struct sock,struct tcp_sock里面一直往第一个成员走就是struct sock(就像白菜芯那样),也就是说struct sock*直接指向了struct tcp_sock、inet_connection_sock、inet_sock、sock四个结构体,未来我们可以强转指针类型访问4个结构体中的任意一个。这种结构体嵌套的方式,使用公共指针指向头部结构体对象并强转类型访问多个结构体的,叫做C语言实现多态! 并且这四个结构体也叫四层继承体系。
UDP套接字同理,就是创建struct udp_sock,udp_sock第一个成员struct inet_sock,struct inet_sock第一个成员struct sock,仅仅没有inet_connection_sock,即连接相关信息,没有全连接队列、连接属性。 struct socket里面的struct sock* 也可以指向UDP套接字,同TCP那样按多态方式来访问。struct socket里面不是有const struct proto_ops* ops函数方法集吗?这个方法集里面有很多基础函数,参数和面向用户的不一样。使用TCP时就使用TCP的函数指针方法,使用UDP就使用UDP对应的方法。但我们的struct socket怎么知道struct sock*指向的是udp_sock还是tcp_sock呢?struct socket里面还有一个字段short type,就是我们调用函数socket传入的网络类型决定的,socket根据类型肯定知道自己什么结构,应该怎么使用struct proto_ops肯定也就知道了,如何访问继承体系那肯定也全部知道了。
怎么用函数来描述上述创建udp_sock、tcp_sock的流程呢?
通过int sock_map_fd(struct socket* sock)函数:我们先把套接字struct socket创建好传入作为参数,之后sock_map_fd函数内部新建一个struct file并用sock_alloc_fd函数获取他的文件描述符fd,sock_map_fd内部还会调用一个函数sock_attach_fd,sock_attach_fd函数要求传入创建的struct file和struct socket的指针(变量名file和sock),之后将sock->file = file(socket回指file),file->private_data = sock(关联)。
(4)网络多层结构
struct socket叫做基类,struct udp_sock和struct tcp_sock叫做子类,实现了多态。struct socket是网络入口,提供统一视角,一般叫做BSD socket - - 通用socket接口。struct socket后面还可以跟一些域间socket、其它通信结构体,而非仅有UDP、TCP。struct udp_sock和struct tcp_sock一般叫做inet_sock网络socket。
因此我们可以总结出多层结构:①虚拟文件层struct file、②struct socket(通用socket层,可继承到网络、本地通信等)、③inet_sock通用网络层(包含tcp_sock、udp_sock)、④struct inet_device网络设备层(描述网卡等硬件交互)。
(5)在内核中理解连接
当服务器和客户端三次握手成功之后,就会去创建struct tcp_sock整个继承体系。 这个连接(struct tcp_sock)就会被链入TCP监听套接字的struct file里面的struct socket里面的tcp_sock里面的inet_connection_sock里面的struct request_sock_queue,也就是全连接队列。
只有监听套接字才关心inet_connection_sock里面的struct request_sock_queue
全连接队列,普通套接字有但不关心。
当服务端调用accept的时候,会创建struct file和struct socket并关联起来,struct socket里面的struct sock*直接指向全连接队列里面已经创建好的struct tcp_sock,之后将该tcp_sock移出全连接队列,返回新的struct file的fd,之后就能进行通信了。所以不管连接还是listen套接字,一切皆文件!
全连接队列究竟长什么样呢?
tcp_sock里面的inet_connection_sock里面有一个成员struct request_sock_queue icsk_accept_queue全连接队列,全连接队列结构体里面是struct request_sock* rskq_accept_head和rskq_accept_tail,request_sock其实是一个单链表,struct request_sock有一个对象struct sock*负责管理指向待建立连接的tcp_sock,这个所谓全连接队列底层其实是struct request_sock的单链表,由rskq_accept_head和rskq_accept_tail维护链表首尾。
(6)半连接队列
半连接队列其实就是三次握手进行中的时候,OS会简单建立一些结构,只要三次握手完成就移到全连接队列,全连接队列维护时间长,半连接队列维护时间可能只有30s - 1min,这只是个临时结构,握手失败就会释放结构断开连接。
(7)sk_buff操作
报文的解包封装都是对sk_buff的指针进行操作(移动data和tail指针),协议栈操作的整个过程中,结构不变指针变,有数据时就插入到tcp_sock或者udp_sock里面sock对应的sk_buff双链表中。
(8)利用网络内核设计理解C语言实现OOP思想
①封装
struct file的文件操作表是对struct socket的函数方法集的进一步封装,这个封装好的函数最终变成文件操作表里面的一个函数指针的具体实现,同时这个函数指针也是面向上层的一个接口。简单来说,这既是对下层的封装,也对上提供服务。 同理,用户使用的read、write接口也是对struct file文件操作表的进一步封装。在文件操作上,Linux内核采用层层封装的方式,上层无需关注下层的具体实现,仅需组织下层已经封装好的方法即可。当用户调用read接口时,执行顺序是用户层 -> struct file -> struct socket -> 更下层接口。
并且sk_buff也是一种封装,用户管理报文变成了对sk_buff这个结构体的管理,用户无需直接操刀内存,而是通过sk_buff结构体对外提供的接口管理,sk_buff内部移动指针完成操作。
②继承
典型的就是TCP四层继承体系,UDP三层继承体系。通过结构体嵌套,类似白菜那样一层套一层,同时用指针指向顶层结构体,我们就可以通过指针强转访问该继承体系下面的任意对象。 当然继承也可以通过指针包含的形式实现,即父类结构体的指针作为子类的成员,这样只要拿到了子类,我们也可以访问整个继承体系。区别就是结构体嵌套的话需要指向基类,指针包含的话需要得到子类。
③多态
在struct file层面上看,多态体现在文件操作表上。在上层看来我们都是管理struct file,但执行文件操作表上的函数时,实际执行的方法却截然不同,这是由private data指向的对象决定的,不同的对象会设置不同的文件操作表,private data也能间接标识不同类型的struct file。
同理,站在struct socket来看,多态同样体现在函数方法集的设置,UDP和TCP对应的read、write等接口不完全一样,这是由struct sock* 指向的类型决定的。那么标识类型的字段就是type,其实也是标识不同种类的struct socket。 但注意,多态实现的核心是针对同一个函数指针有不同的设置,即根据基类的不同子类设置不同函数,而标识其不同子类的就是标识type。但是注意,从根本上说类型type不必须,在运行时多态仅体现在调用同一函数进入不同实现方法上,但在初始设置函数指针时,type这一字段有分类的作用,可以帮我们快速识别类型并找到对应的函数以注册到函数指针上。
多态还体现在TCP / UDP的继承体系上,因为在整个继承体系中,struct sock是基类,这个指针在struct socket里面,可以指向tcp_sock继承体系,也可以指向udp_sock,当socket想要向sk_buff写入传输层协议时,调用socket方法集里面的函数进入的继承体系是不同的,写入的内容也是不同的。
380

被折叠的 条评论
为什么被折叠?



