Linux中TCP流程详解

x33g5p2x  于2021-10-01 转载在 Linux  
字(3.1k)|赞(0)|评价(0)|浏览(393)

文章知识来源

本文是对张彦飞(飞哥)的文章的总结,用于学习和技术交流。

飞哥的github:开发内功修炼Github

飞哥的知乎专栏:开发内功修炼知乎专栏

服务端建立

TCP是一个基于客户端-服务器的网络模型,首先要建立服务端。

Linux中会首先建立一个socket对象,socket中主要是维护了两个链表:
1、半连接队列
服务器收到客户端第一次握手的信息的时候,内核会为其生成一个request_sock对象,用来记录对应的客户端握手信息,并将request_sock对象加入到半连接队列中。

半连接队列其实是一个哈希表,因为需要快速的查找和删除。

2、全连接队列
服务器收到客户端第三次握手的信息的时候,内核会从半连接队列中找出对应的request_sock对象,将其从半连接队列中移除,加入到全连接队列中。

因为request_sock要从半连接队列中查找并移除,所以半连接队列采用了哈希表的组织方式,能够实现快速的查找和删除。

accept()

当线程调用accept(int fd)的时候,如果socket对应的全连接队列为空,会将对应的线程阻塞,然后将其挂载到socket对应的等待列表中。如果不为空,会取出全连接队列中的一个socket。

网卡和内核的初始化

网络数据包的收发是依赖于网卡的,将数据从网卡转移到内存中,是依赖于内核的。接下来我们就捋一下其中的逻辑流程。

网卡设备会初始化,会向Linux注册对应的驱动程序。

内核会创建线程ksoftirqd专门用来处理网络数据,ksoftirgd线程的个数和CPU的数量保持一致。

具体流程如下:
1、 当数据传输到网卡的时候,网卡会通过DMA将数据传输到内核空间中,然后向CPU发起一个硬中断,CPU会执行对应的中断程序,对应的硬中断程序会接着发起一个软中断,会唤醒ksoftirqd线程工作。

为什么要引入软中断呢?
因为网络数据的处理还是比较麻烦的,如果一直占用CPU中断的话,会导致其他实时请求的延迟,比如鼠标之类的。所以就引入了一个软中断,通过一个线程的循环运行来解决网络数据的处理。

2、 CPU会关闭网卡的中断,也就是CPU不再理会网卡发送的中断信号,因为ksoftirqd线程已经被唤醒运行了,网卡直接通过DMA把数据传输到内核空间就可以了。要不然网卡会频繁的引起CPU的中断,影响执行效率。

3、 ksoftirqd线程会开始一个循环,循环结束的标志就是内存空间中没有网络数据了。ksoftirqd线程会调用网卡的驱动程序一个一个的读取内存中的数据。为什么需要驱动呢?因为网卡中的数据格式只有网卡自己知道。网卡的驱动程序会将数据转化成内核能够理解的数据。

4、 驱动将数据转化完成之后,ksoftirgd会调用对应的协议栈函数,首先是ip_rcv(),会对数据包中的信息进行检验,如果不符合要求,就将数据包丢弃。

5、 如果满足IP层要求,就继续调用tcp_rcv()。tcp_rcv()函数会根据数据包的具体情况进行不同的处理。比如第一次握手、第三次握手、普通数据等信息。

客户端第一次握手

当服务器收到客户端的第一次握手请求的时候,会根据数据包中的目的端口号来找到对应的socket,然后根据数据包中的源IP地址和源端口号生成一个request_socket对象,并将其加入到socket的半连接队列中。

上述操作都是内核线程ksoftirqd操作的。

客户端第三次握手

当服务器收到客户端的第三次握手请求的时候,
1、 首先根据端口号找到对应的socket对象

2、 根据数据包中的源IP地址和源端口号来找到socket的半连接队列中对应的request_socket,并将其从半连接队列中取出,将其加入到全连接队列中。

3、 这是代表着一个连接已经建成了,会唤醒socket对应的线程,然后线程会继续执行accept()方法,accept()中会从全连接队列中取出一个request_socket,也就是一个socket对象。

4、 如上图所示,得到socket对象后,线程一般会调用其recvmsg()函数来获取socket接收的信息。

socket对象中有以下几个重要属性:

1、接收队列: socket接受的对应的数据包
2、等待队列: 当有线程调用recvmsg()后,如果对应的接收队列为空,就会将线程阻塞,然后将其加入到等待队列中。
3、sendmsg(): 对应的发送函数
4、recvmsg(): 对应的接收函数

普通数据接收

当建立完连接之后,客户端就可以同服务器之间传递消息了。

当数据到达服务端的时候,同样会进入到tcp_rcv()函数,tcp_rcv()函数一看是一个普通数据包,就会提取出对应的源IP地址和源端口号,找到对应的socket。

然后将数据包挂到socket的接收队列中,然后唤醒socket等待队列中对应的线程,线程会继续执行对应的recvmsg(),recvmsg()中会从接收队列中取出数据包,当接收队列为空的时候,会再次阻塞,被加入到socket对应的等待队列中,就这样不断的周而复始。

epoll 接收数据

因为调用recvmsg()是阻塞的,所以一般要为每一个socket都开辟一个线程。

一个大的服务器会同时接收成千上万个连接,也就是要开辟成千上万个线程。线程的切换是十分耗费资源的,涉及到上下文切换以及内核态与用户态之间的切换。

所以Linux中引入了epoll,通过单线程来同时接收多个socket的数据。

1、 创建eventepoll
Linux会创建一个eventpoll对象,他包括了三个主要属性:
rdlist
一个链表,用来存放就绪的epitem

rbr
红黑树,用来存放被监视的epitem,epitem中包含了event_poll对象和socket的信息

wq
等待队列,调用了epoll_wait()的线程,也就是等待接收数据的线程

2、创建socket

epoll下,同样会为每个连接创建一个socket。
还会为每个socket创建一个epitem。epitem中包含了对应的eventpoll对象,以及当前socket的指针!然后会把epitem放入到eventpoll对应的rbr中。

并且socket对应的等待队列不是某个线程了,而是一个ep_poll_callback()函数+epitem。

3、epoll_wait()

当一个进程调用epoll_wait()的时候,会从event_poll中的rdlist中获取对应的epitem。再通过epitem中的socket指针获取到对应的socket,然后就可以访问对应的接收队列中的数据了,就可以处理对应的数据了。

如果rdlist为空,就将线程阻塞,将其加入到event_poll中的wq等待队列中。

4、数据来了
当数据来了的时候,数据会通过tcp_recv()函数,将数据包加入到socket的接收队列中,然后执行socket的等待队列中的逻辑,之前socket的等待队列中放入了ep_poll_callback()以及epitem。

那么就会执行ep_poll_callback(),并将epitem作为参数传入,ep_poll_callback()就是event_poll的回调函数,通过epitem,可以找到对应的event_poll对象,会将epitem放到event_poll对象中的rdlist中,然后唤醒event_poll中的等待线程,线程被唤醒后,就会继续执行epoll_wait()方法,会取出rdlist中的epitem,通过epitem中的socket指针,获取到socket对应的接收队列中的数据,然后进行处理。

所以就通过回调函数,就实现了单线程同时读取多个socket的数据了。

相关文章