什么是socket
本文介绍的是以tpc协议传输的socket,在了解socket之前先简单了解一下什么是tcp协议,tcp协议是一种基于网络传输的规范网络通信协议,Socket是tcp协议的一种抽象。在网络中双方想通信就必须遵守同一套协议。
就好比两个人要沟通一个人说的语言必须是另一个人能听得懂得,语言就是两个人通信的一种协议,至于要用中文还是其他语言那是协议的抽象。换成代码来说协议就是接口
用于定义规范,socket就是这个接口的实现类实现接口中定义的方法并对外提供调用能力。
interface TCP {
void 三次挥手建立连接()
void 数据发送()
void 四次挥手断开连接()
}
class Socket implements TCP{
//实现具体方法
}
socket网络编程模型
在了解bio模型时需要先注重三个类
- java.net.ServerSocket (服务端socket包装了SocksSocketImpl)
- java.net.SocksSocketImpl (核心类,tcp套接字实现了基类的socket通信方式)
- java.net.Socket (客户端socket包装了SocksSocketImpl)
为了方便理解这三个类,大家可以想像一个画面。某一天你外交游玩捡到了一个神秘的空心小盒子,盒子里面有一张A4纸,纸上有这个盒子的使用说明书:
- 用笔在这个什么盒子上面写上一个任意数字,按下盒子上的开始按钮
- 找一个普通的空心盒子,在空心盒子上写上刚才在神秘盒子上的数字和神秘盒子此时的坐标,此时使用神秘盒子上同材料的纸放入该普通盒子内
- 在普通盒子内的纸上写下内容
- 神秘盒子的纸上会出现该内容
- 纸的材料可以替换不过两个盒子内需要存放同材料的纸
神秘盒子对应的就是ServerSocket(服务端socket),盒子上写的随机数字就是端口号,盒子里默认是A4纸对应的是SocksSocketImpl(tcp协议实现的套接字)
普通盒子在写上坐标和数字之后对应的就是Socket(客户端Socket)。后文的叙述将一步步解释传输的原理,并且使用这个故事中的名词。
bio 模型
bio服务端代码示例
//代码1 创建服务端socket
ServerSocket socket = new ServerSocket();
//代码2 使用服务端socket绑定监听的端口号
socket.bind(new InetSocketAddress(9999));
//代码3 操作服务端socket接受客户端socket信息
Socket accept = socket.accept();
代码1对应的就是你捡到了一个神秘盒子,并且盒子内放了一张A4纸,即在使用无参方法构造一个服务端socket时,构造方法会默认为
服务端socket创建一个内置的SocksSocketImpl。源码如下
package java.net;
//无参构造
public ServerSocket() throws IOException {
setImpl();
}
private void setImpl() {
if (factory != null) {
impl = factory.createSocketImpl();
checkOldImpl();
} else {
//使用无参构造默认走这个判断分支创建tcpSocket
impl = new SocksSocketImpl();
}
if (impl != null)
impl.setServerSocket(this);
}
代码2调用服务端socket的bind方法对应的就是在神秘盒子上写下随机数字,该数字对应的就是需要监听的端口号。
对应java源码为
package java.net;
public void bind(SocketAddress endpoint, int backlog) throws IOException {
// 忽略代码。。。
getImpl().bind(epoint.getAddress(), epoint.getPort());
getImpl().listen(backlog);
// 忽略代码...
}
###将服务端socket通信代码进行拆分后可得
- serveSocket.bind()
- SocksSocketImpl.bind()
- 操作系统.socket()
- 操作系统.bind()
- SocksSocketImpl.listen()
- 操作系统.listen()
- SocksSocketImpl.bind()
- serveSocket.accept()
- SocksSocketImpl.accept()
- 操作系统.accept()
- SocksSocketImpl.accept()
###接下来从最小单元化开始逐个分析。
操作系统.socket()方法对应的代码
int socket(int protofamily, int type, int protocol);
- protofamily:是指要使用的地址协议(IPV4\IPV6等)。在创建服务端socket的时候如果默认使用端口号创建
即 new InetSocketAddress(port) 使用地址协议是看本地系统是否支持ipv6
package java.net.InetAddress$InetAddressImplFactory
static InetAddressImpl create() {
return InetAddress.loadImpl(isIPv6Supported() ?
"Inet6AddressImpl" : "Inet4AddressImpl");
}
- type:Socket类型,如果不具体指定服务端Socket工厂的话默认创建的是TcpSocket(SocksSocket)
- protocol:支持的协议(tcp/udp…),对应Socket类型
- 返回值:返回的是一个int类型,代表着**SocketFd**,这个socketFd很重要贯穿全文。那么什么是SocketFd
简单描述一下,大家可以想像网络通信现在替换为在数据库中通信,我是数据库的建立者,我创建了一张表。如果你要和我通信
首先你需要和我建立连接,在建立连接后我会在数据库中插入一条记录,拿着这条记录的主键id告诉你把你要说的话往这个主键id里面去写
这样我就能够知道和某个人具体的通信内容。socketFd及代表着维护网络信息文件的索引。
操作系统.bind()方法对应的代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:即调用socket()方法返回的描述符。
- *addr:socket协议的结构体指针,不同的协议使用不同的结构。
- addrlen:地址的长度
将代码打在serveSocket调用socketImpl的listen方法上,即观察在操作系统调用socket(),bind()
后该端口会有什么变化。
打开命令行输入以下指令
$ lsof -i: port
- 此时该端口上已经建立了一个tpc连接,并且该状态为CLOSED。该状态标识当前tcp连接是关闭状态
- 图片中的FD代表的就是socket的文件描述符,在linux系统中可以到 /proc/{pid}/fd 目录查看,该fd主要负责监听(
socekt根据职能划分为两种类型:
第一种为监听型即客户端主动创建的socket,只起到监听的能力。
第二种为连接型在获取任意一个客户端连接后创建出的socekt。该类型socket主要负责数据通信
操作系统.listen()方法对应的代码
int listen(int sockfd, int backlog);
- sockfd:表示需要监听的socket,即使用socket()返回值
- backlog:表示连接队列的最大数量。
在调用完listen方法后可以在命令行查看到tcp连接此时的状态为listen状态如下
表示可以接受客户端连接。此时该监听套接字下维护了两个队列
- 未完成3次握手的队列
- 完成3次握手的队列,上述的backlog值就代表该队列的长度大小 (该队列很重要,后文会描述到)
操作系统.accept()方法对应的代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd:表示需要监听的socket,即使用socket()返回值
- *addr: 客户端地址结构体
- *addrlen: *addr参数所占的字节数大小
在我们依次执行完socket(),bind(),listen(),accept()方法后使用以下命令创建一个客户端连接
$ telnet localhost prot
查看当前socket端口下的连接情况如下图,可以看到客户端和服务端会分别创建一条tcp连接,状态为ESTABLISHED表示TCP连接建立完成如下图
那么我们来思考几个问题
- 网上常说的同步,异步,阻塞,非阻塞到底是描述啥?
- accept方法又是怎么读取数据的?
- 输入流中的read方法为什么会阻塞?
- 不调用accept是否能接收到网络数据?
问题一解决:首先要知道问题一内容描述都是争对于IO操作的概念
- 同步:用户空间告知操作系统内核要进行IO操作
- 异步:操作系统内核主动告知用户空间执行IO操作
- 阻塞:用户空间IO操作必须等待内核空间将数据拷贝到用户空间后才能读取
- 非阻塞:用户空间IO操作无需等待内核空间将数据拷贝到用户空间后才能读取,只针对于内核空间缓冲区中没有数据情况下,如果有数据的话还是阻塞等待数据拷贝到用户空间后才能读取
日常代码中实现阻塞无非几种方法,第一种是由于线程未获取锁导致的线程挂起阻塞。第二种就是使用wait,notify机制控制线程阻塞。第三种是特定的循环,因为条件没达到所以阻塞。
首先在bio模型中使用accept创建的socket默认是阻塞模型并且这里的阻塞是针对于进程而言并非我们日常中将线程挂起(在客户端阻塞时使用jstack指令观察线程即可发现线程状态是RUNABLE)。
阻塞模型抽象为简单代码如下:
Queue queue; //上述描述的创建一个监听套接字后会维护一个tcp三次握手后的连接队列
if(队列中为空){
if(当前监听socket非阻塞){
//代码 : 返回错误标识
}
for(;;){
//代码:内核sleep阻塞
//代码:判断超时处理...
}
}
//代码:从队列中获取一个连接并创建一个对应的sockSocket返回
问题二:首先先简单了解一下缓冲区,客户端与服务端建立连接后,会创建2条tcp连接。其中客户端->服务端的tcp连接中的端口号是随机创建
其次,服务端->客户端的tcp连接中的端口号是复制的服务端监听套接字的端口号。
每条数据传输的tcp连接中操作系统内核会为每条连接创建2个缓冲区,一个读缓冲区
一个写缓冲区。这两个缓冲区都是由内核控制写入读取并且涉及到了缓冲区就意味着每次读写都有大小限制,一旦超过缓冲区大小就会进行多次的读写操作会导致tcp半包问题,
如果多次写入,且写入的字节数小于缓冲区会导致tcp粘包问题。启动一个客户端,一个服务端。打开命令行执行以下指令观察socket缓冲区
$ netstat -na | head -2;netstat -na | grep port
- Recv-Q: 对应的就是相对的读取缓冲区内的字节数
- Send-Q: 对应的就是相对的写缓冲区内的字节数
此时将断点打在服务端读取socket输入流上,并且客户端发送一条数据。再次使用上述命令观察
此时会发现服务端->客户端这条tcp链上的读缓存区内有3个字节数据,即刚才客户端发送的数据内容。放开客户端读取的断点并执行后会发现该读缓冲区中的内容被清空
即socket在调用read方法后会清空读缓冲区。同理写缓冲区也是一样。因此一次服务端读取数据的时序为下:
三次握手建立连接-> 客户端发送数据-> 网络数据传输…->数据抵达服务端内核缓冲区-> 服务端发出读取指令->服务端操作系统拷贝读缓冲区内容到用户空间-> 服务端从用户空间获取数据并读取
问题三:read方法阻塞是因为网络IO和磁盘IO有一个本质的区别就是在磁盘IO中想要读取某个文件的输入流那么一次就可以读取完毕,即能确定需要读取的文件大小。而网络IO不同因为一次连接
不代表只能发送一次数据,因此只要客户端的输出流不关闭,并且服务端中socket读缓冲区中没有内容的话代码就会阻塞在read方法上
问题四:将服务端中accept代码注释后在此使用指令查看。此时客户端发送了数据后,服务端的读缓冲区中已经能收到值,说明在客户端连接上服务端后不需要accept数据
依旧能抵达服务端的内核的缓冲区。