现在的网络编程几乎都是 socket 编程,不理解 socket 本质,很多知识是无法串联起来的,今天我们就一起揭开 socket 的神秘面纱,探究一下 socket 到底是什么。
1.初识 socket
首先我们以 TCP 编程为例整体感受一下 socket 的存在,在 TCP 通信编程的过程中,我们的编程思路如下:
只要按顺序实现了这些关于 socket 的调用,我们就能完成客户端和服务端之间的通信,这大概就是我们对 socket 的第一印象了,那 socket 具体指的是什么呢?
其实 socket 是在应用层和传输层之间的一个抽象层,它把复杂的 TCP/IP 协议抽象为了几个简单的接口供应用层调用,就能实现网络中通信,画个图来具体感受一下网络架构:
如图所示,socket 是由操作系统内核进行实现的,然后暴露接口给我们使用,作为应用层编程很难感知到 socket 真正的实现逻辑,但想要理解网络 IO 就必须揭开 socket 的神秘面纱。
好的,对 socket 有了一个整体的认知以后,我们看一下百度百科给出的官方定义:
套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。
本节小节:
2.socket 起源和本质
阅读本节内容可以回答的问题:假设端口号共 65535 个都可以使用,一个客户端能与一个服务端(固定IP+端口)最多建立多少个 socket?
socket 起源于 Unix,提到 Unix 就不得不提到有名的哲学思想:“一切皆文件”。
socket 也遵从 Unix 一切皆文件的思想,是 Unix/Linux 中的一种特殊文件资源,以 “打开-读/写-关闭” 模式的实现。
那些关于 socket 封装好的接口其实就是对文件进行操作(读写,打开,关闭),服务端和客户端各自维护一个这样的文件,在双端建立连接后,内核会帮我们建立 socket 文件,可以通过向自己的文件写入和读取数据的方式与对方进行通信。双端的通信过程就如下图所示:
两端的通信更具体一点的话就是两个进程之间的通信,我们知道两个进程如果需要进行通信最基本的一个前提是:能够唯一的标识一个进程。
在本地进程通讯中我们可以使用 PID(进程ID) 来唯一标识一个进程,但 PID 只在本地唯一,网络中两个进程的 PID 冲突概率很大,而我们知道 IP 层的 ip 地址可以唯一标识主机,而 TCP 层协议中的端口号可以唯一标识主机的一个进程,这样我们可以利用 “ip 地址+协议端口号” 唯一标识网络中的一个进程。
因此,两个进程的唯一标识就组成了一个四元组:ip:port - ip:port,这就是 socket 文件的唯一标识,(四元组中有一项不同,则 socket 不同),而对于我们应用层程序来说 socket 就是一个文件描述符,缩写为:FD。我们简单的再画一张图理解一下 socket 建立的过程。
这里通过一个简单的例子带大家实际感受一下 socket 的存在(linux 环境 TCP 连接):
COMMAND PID USER FD TYPE DEVICE OFFSET NODE NAME
nc 1904 root 3u IPv4 39179 0t0 TCP node01:37732->111.206.208.133:http (ESTABLISHED)
本节小结:
3.socket 实战
阅读本节可回答的问题:服务端执行到了 listen 接口,但没有调用 accept 接口,能与客户端完成三次握手建立连接吗?客户端能给此时的服务端发送数据吗?
这里以 TCP 为例进行实战,真实感受一下 socket 的使用过程。
3.1 流程讲解
3.1.1 建立链接
提到 TCP 就必须得聊一聊“三次握手”的概念了:
- 全连接队列:又称 Accept 队列(底层结构:双向链表,先进先出),包含了服务端所有完成了三次握手,但是还未被应用调用 accept 取走的连接队列。此时的 socket 处于 ESTABLISHED 状态。如果 accept 阻塞不被调用,那么很快全连接就可以被占满,此时再来客户端连接将超时,server 会舍弃 client 发送过来的 SYN,客户端一直重试,最终超时断开。
- 半连接队列:又称 SNY 队列(底层结构:Hash表,易于查找),服务端收到客户端的 SYN 包,回复 SYN+ACK 但是还没有收到客户端 ACK 情况下,会将连接信息放入半连接队列。
3.1.2 传输数据
socket 内部结构中存在一个“接收缓冲区” 和 “发送缓冲区”(底层是个链表)
- man send
- man recv
3.2 linux + java 实战
这里使用 java 实现多线程的 BIO 模型进行实战,代码很简单,截图中对重要的地方进行了解读,值得一提的地方就是程序中多了一行阻塞:System.in.read(); 只有每次从终端读取一行数据才能继续往下执行,否则将阻塞在 accept() 函数之前,使得 accept 得不到调用。
- 属于客户端的 socket 已经建立,且被 nc 进程持有,可以随时使用(本地启动的客户端)
- 可以发现还有一个 “-” 持有的 socket 是服务端与客户端建立的连接(内核已经完成了与客户端的TCP三次握手,并建立连接,但没有任何进程持有,所以用 “-” 代替)-- 暂时称之为 socket-a
为了看的更加明显,使用 ss -na 找到我们的 socket,可以看到 Recv-Q 值为1,就是 socket-a,而 Send-Q 表示的是全连接队列总长度(max)
为了演示 Recv-Q 中存储的数据是什么,我们按上边步骤重来一遍,这次启动两个客户端对服务端进行连接,服务端依然阻塞在 accept 之前,可以看到 Recv-Q 值为2,有两个 “-” 持有的 socket
本节小节:
下篇文章继续讲述:socket 的实现原理