目录
☀️0.前言
之前介绍了 socket 网络编程的基础,也就是如何使用这些函数,这些函数包括 socket()、bind()、listen()、accept()、recv()、send() 等,他们被称为 Posix API,是标准 API 接口函数,这些函数与编程语言无关,只要是在 linux 平台下,涉及到网络的都是用的这一套接口。
今天来详细讲述这些函数的原理以及注意事项,会给出简化后的实现代码,这些代码并不需要掌握,和 linux 内核中的代码也相差甚远,但可以帮助大家更好的理解这些函数。
🌤️1.文件描述符(fd)
首先来聊一聊文件描述符的本质是什么,我们都知道 linux 下一切皆文件,所有的东西都用文件描述符来管理,文件描述符是个 int 类型的整数,从 0 开始,依次递增,我们用之前的 Reactor 代码来测试一下,用三个客户端来连接,看看分配的文件描述符值的变化。
可以看到 listenfd 为 4,给连接客户端分配的 fd 分别为 5,6,7,确实是依次递增的,那 fd 为 0,1,2,3 的又是分配给了谁呢?这些其实是系统默认分配的,通过查看 /dev/fd/ 中的内容
可以看到 fd 为 0 的是 stdin(标准输入), fd 为 1 的是 stdout(标准输出),fd 为 2 的是 stderr(标准错误输出),令我感受神奇的是 fd 为 3 的居然也被某个进程分配出去了,一般情况下应该是从 3 开始的,就不再深究了。
有个有趣的问题是当 close(fd) 的时候,该 fd 过就多才可以重新使用,比如在上面三个客户端的fd分别为 5,6,7,当我断开 fd 为 5 的连接时,要经过多久下个连接的客户端才可以重新使用 5 这个 fd,我当前的测试表明断开连接后,该 fd 可以立刻被新的连接所使用,在有些版本中,可能需要等待 time_wait 时长,一般为 60s。
🌤️2.socket()函数详解
socket 的中文意思是插座,为什么 socket 会和插座有关系呢?socket()函数其实做的工作就是分配 fd 以及 TCP 控制块(TCB),所以可以认为“插”就是“fd”,“座”就是“tcb”。
简化的 socket() 函数如下所示:
主要工作就是分配 fd,创建并初始化 TCB,创建缓冲区和同步机制,并将新创建的 TCP 连接块加入到全局或线程本地的 TCB 集合中,比较好理解。
🌤️3.bind()函数详解
bind() 函数就是将 IP 和端口绑定到 TCB上,实际上是 set 过程,需要注意的是,服务端必须要进行绑定操作,客户端是否 bind() 都可以,如果没有 bind(),在 connect() 的时候会自动随机生成一个端口,但也有例外,在P2P应用场景下,客户端也需要 bind(),后面会详细介绍。
简化的 bind() 函数如下所示:
🌤️4.listen()和accept()函数详解
这两个函数比较重要,先来说监听 listen() 函数,listen 监听之后。可以通过 netstat 看到 io 的状态,用 netstat -anop | grep 2000 命令,得到结果如下:
listen 监听之后,客户端就可以正常连接,并且会产生新的连接状态,用 NetAssist 连接,再次查看端口 2000:
可以看到多了一个 ESTABLISHED 状态,客户端可以发数据,但服务器接收不到。此时如果起多个客户端连接,都可以连接成功,端口情况如下(这里用三个 NetAssist 连接):
简化的 listen() 函数如下所示:
那 accept() 函数的作用是什么呢?首先要明确一点,在服务端监听成功,客户端调用 connect() 之后,发生三次握手,三次握手是由内核协议栈自动完成的,并不是发生在某个具体的函数之中,当连接建立好后,accept() 函数从中取出并分配 fd。
下面结合这三次握手来进一步阐述,首先来看看 TCP 三次握手过程:
当客户端调用 connect() 函数时,三次握手开始,客户端给服务器发 SYN 包,并携带随机的序列号(用随机的是为了防止黑客截获数据包后知道该数据包的位置),随后服务端给客户端发送 ACK 确认,也携带随机的序列号,ack=x+1,表示 x+1 之前的都受到了,最后客户端给服务端发ACK 确认,三次握手完成。
图中半连接队列(syn-queue)存放那些已经接收到客户端 SYN 请求但还未完成三次握手的连接,全连接队列(accept-queue)存放那些已经完成了三次握手,可以被 accept() 函数取出并分配fd的连接。
关于这两个队列的数量,一个有趣的问题就是 listen() 的第二个参数 backlog 到底指的是什么数量,listen() 函数历史悠久,从上个世纪 80 年代就出现了,有很多的历史版本,从最开始表示半连接队列的数量,到表示两个队列数量之和,再到现在表示全连接队列的数量。一开始表示半连接队列的数量主要是为了防止 SYN 泛洪,限制只发送 SYN 包的客户端数量,但随着防火墙等前置安全操作的出现,这个参数显得很鸡肋。
简化后的 accept() 函数如下所示:
相信有了上述分析,accept() 函数还是很好理解的,核心就是从全连接队列中获取一个已完成三次握手的连接控制块并分配 fd。有一个小问题是第三次握手的数据包,如何从半连接队列查找匹配的节点?答案是五元组,五元组是源IP,目的IP,源端口,目的端口和协议,通过五元组即可完成查找。
🌤️5.recv()和send()函数详解
之前的那些 API 都与建立连接相关,下面要讲述的 recv() 和 send() 则与传输数据有关,首先来看一下具体是怎么传输数据的
首先要明确的一点是,调用 send() 只是把数据从应用发到内核(协议栈)的缓冲区中,至于协议栈何时发,发多少,有没有发出去,应用都控制不了,比如图中所示,调用了三次 send(),第一次 1200,第二次 200,第三 300,协议栈很可能就只发了两次,分别为 1500 和 200(图中是理想情况,不考虑协议层额外加的数据头),调用 recv() 也是同理。可以用如下命令查看系统的读写缓冲区大小
这些数据的单位为字节(Bytes),第一个值为最小值,第二个值为默认值,第三个值为最大值,并且这些数据都是可以修改的,以便于提升网络速度或者满足其他需求。
初次之外,还有一个数据发送长度的限制是 MTU(Maximum Transmission Unit),用 ifconfig 查看:
MTU 指的是网络接口传输数据包的最大字节大小。在网络通信中,数据是分成数据包发送的,而MTU 规定了一个数据包的最大尺寸。如果发送的数据包超过这个值,数据将会被分段,该值也是可以更改的。
这之中还有一个有趣的问题,下面的两种 recv() 接收形式,哪种效率更高?
或许大部分朋友会认为第一种效率更高,其实两者是差不多的,因为这些系统函数调用的速度都非常快(相比较于真正的接收数据操作来说)。
在一开始的那张图中,可以看到中间的网络层如果选用 TCP 的话,还涉及到滑动窗口,慢启动,可靠传输这些,如果有背过相关八股的同学对这些内容是比较熟悉的,这些内容还是比较复杂的,问题在于这些东西对于程序员来说是透明的,很难应用在实际的代码中,理论性很强,以后有机会的话会更新这部分内容。
简化后的 send() 函数如下所示:
主要是创建一个 TCP 数据分片,填写五元组等 TCP 信息,并将分片放入发送缓冲区中。
简化后的 recv() 函数如下所示:
主要功能是从接收缓冲区中获取 TCP 数据分片,处理数据并将其拷贝到用户缓冲区中,如果数据量超过用户缓冲区大小,会将未处理的数据保留在分片中并重新放回接收缓冲区以便后续处理。
🌤️6.close()函数详解
与 close() 函数对应的是 TCP 四次挥手部分,我们首先来看一下四次挥手的相关内容:
三次握手一般由客户端发起,而四次挥手则没有这个限制,客户端和服务端都可以主动发起,这里假设还是由客户端主动发起,close() 函数的作用是回收 fd,发FIN包,所以本质上close()函数也是send(),只不过是发了一个特殊的包。
简化后的 close() 函数如下所示:
最后来看看 TCP 状态迁移图:
图中几乎所有的状态上述讨论都有涉及,之所以之前都没有着重强调这个状态迁移,是因为我觉得比较简单,大家如果了解了三次握手和四次挥手的过程,这些状态都可以轻松了解,这张图上有个特殊的状态:CLOSING,该状态只有在特定的情况下才会出现,就是客户端先收到服务端的FIN,然后才收到服务端的 ack。
还有一个特殊的情况是双方几乎同时调用 close(),这种情况叫做同时关闭,该情况下客户端和服务器都会几乎同时发送一个 FIN 包,两者各自收到对方的 FIN 包后,都会回复一个 ACK 确认包,双方都会各自进入 TIME-WAIT 状态,确保对方收到了最后一个 ACK,以防数据丢失,按照上述流程,也可正常关闭。
🪐7.结束语
socket网络编程一般都是CS模型,即客户端服务器模型,其实也可以去中心化,用P2P模型,双方都各为服务器和客户端,这样原有的三次握手就变成了六次握手,或许可以打开网络编程的新思路。
本人目前还是个在校生,还比较小白,也刚刚开始写 CSDN 博客不久,可能写的也不是很好,如果有任何疑问或者发现我有哪里写的不对的地方,欢迎大家留言告诉我!我都会一一改正的
如果觉得文章对你有帮助的话,还请点赞,关注,收藏支持小占!