上一篇文章介绍了网络通信的基本概念,这一篇文章使用java来实现通信
使用协议进行通信
tcp 连接建立以后,就可以基于这个连接通道来发送和接受消息了,TCP、UDP 都是在基于 Socket 概念上为某类应用场景而扩展出的传输协议,那么什么是 socket 呢?socket 是一种抽象层,应用程序通过它来发送和接收数据,就像应用程序打开一个文件句柄,把数据读写到磁盘上一样。使用 socket 可以把应用程序添加到网络中,并与处于同一个网络中的其他应用程序进行通信。不同类型的 Socket 与不同类型的底层协议簇有关联。主要的 socket 类型为流套接字(stream socket)和数据报文套接字(datagram socket)。
- 流套接字(stream socket) 把 TCP 作为端对端协议(底层使用 IP 协议),提供一个可信赖的字节流服务。
- 数据报文套接字 (datagram socket) 使用 UDP 协议(底层同样使用 IP 协议)提供了一种“尽力而为”的数据报文服务。
接下来,我们使用 Java 提供的 API 来展示 TCP 协议的客户端和服务端通信的案例和 UDP 协议的客户端和服务端通信的案例,然后更进一步了解底层的原理
基于 TCP 协议实现通信
实现一个简单的从客户端发送一个消息到服务端的功能
接收端
class Test {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = null;
BufferedReader in = null;
try {
/*TCP 的服务端要先监听一个端口,一般是先调用
bind 函数,给这个 Socket 赋予一个 IP 地址和端
口。为什么需要端口呢?要知道,你写的是一个应用
程序,当一个网络包来的时候,内核要通过 TCP 头里
面的这个端口,来找到你这个应用程序,把包给你。
为什么要 IP 地址呢?有时候,一台机器会有多个网
卡,也就会有多个 IP 地址,你可以选择监听所有的
网卡,也可以选择监听一个网卡,这样,只有发给这
个网卡的包,才会给你。*/
serverSocket = new ServerSocket(8081);
Socket socket = serverSocket.accept();//阻塞等待客户端连接
// 连接建立成功之后,双方开始通过 read 和 write函数来读写数据,就像往一个文件流里面写东西一样。
in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println(in.readLine());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (serverSocket != null) {
serverSocket.close();
}
}
}
}
发送端
public class Test02 {
public static void main(String[] args) {
Socket socket = null;
PrintWriter out = null;
try {
socket = new Socket("127.0.0.1", 8081);
out = new PrintWriter(socket.getOutputStream(), true);
out.println("Hello, xhc");
} catch (IOException e) {
e.printStackTrace();
} finally {
if (out != null) {
out.close();
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
接下来再看一个例子
基于 TCP 实现双向通信对话功能
TCP 是一个全双工协议,数据通信允许数据同时在两个方向上传输,因此全双工是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力。 我们来做一个简单的实现
Server 端
public class Server {
public static void main(String[] args) throws IOException {
try {
//创建一个ServerSocket在端口8081监听客户请求
ServerSocket server = null;
server = new ServerSocket(8081);
Socket socket = null;
try {
// 使用accept() 阻塞等待客户请求
socket = server.accept();
// 有客户请求来则产生一个socket对象,并继续执行
} catch (Exception e) {
System.out.println("Error." + e);
}
String line;
// 由socket对象得到输入流,并构造响应的BufferedReader对象
BufferedReader is = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 由socket对象得到输出流,并构造PrintWriter对象
PrintWriter os = new PrintWriter(socket.getOutputStream());
// 由系统标准输入设备(监听键盘输入)构造BufferedReader对象
BufferedReader sin = new BufferedReader(new InputStreamReader(System.in));
// 在显示器上输出打印从客户端接收到的数据
System.out.println("Client:" + is.readLine());
// 从键盘读取输入的数据
line = sin.readLine();
while (!line.equals("bye")) {
// 如果该字符串为bye,停止循环
os.println(line);// 向客户端传送输入的数据
os.flush();//刷新输出流,使Client能马上接收到该字符串
System.out.println("Server:" + line);
System.out.println("Client:" + is.readLine());
line = sin.readLine();// 继续从键盘读取数据
}
os.close();//关闭Socket输出流
is.close();//关闭Socket输入流
socket.close();//关闭Socket
server.close();//关闭ServerSocket
} catch (Exception e) {
System.out.println("Error:" + e);
}
}
}
Client 端
public class Client {
public static void main(String[] args) {
try {
// 向本级8081端口发出客户端请求
Socket socket = new Socket("127.0.0.1",8081);
// 监听键盘输入
BufferedReader sin = new BufferedReader(new InputStreamReader(System.in));
// 由Socket对象得到输出流,并构造PrintWriter对象
PrintWriter os = new PrintWriter(socket.getOutputStream());
// 由Socket对象得到输入流,并构造BufferedReader对象
BufferedReader is = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String readline;
readline = sin.readLine();
while (!readline.equals("bye")){
os.println(readline);
os.flush();
System.out.println("Client:"+readline);
System.out.println("Server:"+is.readLine());
readline = sin.readLine();
}
os.close();
is.close();
socket.close();
}catch (Exception e){
System.out.println("ERROR"+e);
}
}
}
总结
我们通过一个图来简单描述一下 socket 链接建立以及通信的模型
通过上面这个简单的案例,基本清楚了在 Java 应用程序中如何使用 socket 套接字来建立一 个基于 tcp 协议的通信流程。接下来,我们在来了解一下 tcp 的底层通信过程是什么样的
了解 TCP 协议的通信过程
首先,对于 TCP 通信来说,每个 TCP Socket 的内核中都有一个发送缓冲区和一个接收缓冲区,TCP 的全双工的工作模式及 TCP 的滑动窗口就是依赖于这两个独立的 Buffer 和该 Buffer 的填充状态。
接收缓冲区把数据缓存到内核,若应用进程一直没有调用 Socket 的 read 方法进行读取,那么该数据会一直被缓存在接收缓冲区内。不管进程是否读取 Socket,对端发来的数据都会经过内核接收并缓存到 Socket 的内核接收缓冲区。
read 所要做的工作,就是把内核接收缓冲区中的数据复制到应用层用户的 Buffer 里。 进程调用 Socket 的 send 发送数据的时候,一般情况下是将数据从应用层用户的 Buffer 里复制到 Socket 的内核发送缓冲区,然后 send 就会在上层返回。换句话说,send 返回时,数据不一定会被发送到对端。
前面我们提到,Socket 的接收缓冲区被 TCP 用来缓存网络上收到的数据,一直保存到应用进程读走为止。如果应用进程一直没有读取,那么 Buffer 满了以后,出现的情况是:通知对端 TCP 协议中的窗口关闭,保证 TCP 接收缓冲区不会移除,保证了 TCP 是可靠传输的。如果对 方无视窗口大小发出了超过窗口大小的数据,那么接收方会把这些数据丢弃。
滑动窗口协议
这个过程中涉及到了 TCP 的滑动窗口协议,**滑动窗口(Sliding window)**是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题;发送和接受方都会维护一个数据帧的序列,这个序列被称作窗口
- 发送窗口
就是发送端允许连续发送的幀的序号表。
发送端可以不等待应答而连续发送的最大幀数称为发送窗口的尺寸。 - 接收窗口
接收方允许接收的幀的序号表,凡落在接收窗口内的幀,接收方都必须处理,落在接收窗口外的幀被丢弃。接收方每次允许接收的幀数称为接收窗口的尺寸。
https://media.pearsoncmg.com/aw/ecs_kurose_compnetwork_7/cw/content/interactiveanima tions/selective-repeat-protocol/index.html
阻塞
了解了基本通信原理以后,我们再来思考一个问题,在前面的代码演示中,我们通过 socket.accept 去接收一个客户端请求,accept 是一个阻塞的方法,意味着 TCP 服务器一次只能处理一个客户端请求,当一个客户端向一个已经被其他客户端占用的服务器发送连接请求时,虽然在连接建立后可以向服务端发送数据,但是在服务端处理完之前的请求之前,却不会对新的客户端做出响应,这种类型的服务器称为“迭代服务器”。迭代服务器是按照顺序处理客户端请求,也就是服务端必须要处理完前一个请求才能对下一个客户端的请求进行响应。 但是在实际应用中,我们不能接受这样的处理方式。所以我们需要一种方法可以独立处理每一个连接,并且他们之间不会相互干扰。而 Java 提供的多线程技术刚好满足这个需求,这个机制使得服务器能够方便处理多个客户端的请求。
一个客户端对应一个线程
为每个客户端创建一个线程实际上会存在一些弊端,因为创建一个线程需要占用 CPU 的资 源和内存资源。另外,随着线程数增加,系统资源将会成为瓶颈最终达到一个不可控的状 态,所以我们还可以通过线程池来实现多个客户端请求的功能,因为线程池是可控的
上面这种模型虽然优化了 IO 的处理方式,但是,不管是线程池还是单个线程,线程本身的处 理个数是有限制的,对于操作系统来说,如果线程数太多会造成 CPU 上下文切换的开销。因 此这种方式不能解决根本问题
所以在 Java1.4 以后,引入了 NIO(New IO)
BIO (Blocking IO) 阻塞IO
当客户端的数据从网卡缓冲区复制到内核缓冲区之前,服务端会一直阻塞。以 socket 接口为例, 进程空间中调用 recvfrom,进程从调用 recvfrom 开始到它返回的整段时间内都是被阻塞的, 因此被成为阻塞 IO 模型
NIO(New IO)非阻塞IO
如果我们希望这台服务器能够处理更多的连接,怎么去优化呢? 我们第一时间想到的应该是如何保证这个阻塞变成非阻塞吧。所以就引入了非阻塞 IO 模型, 非阻塞 IO 模型的原理很简单,就是进程空间调用 recvfrom,如果这个时候内核缓冲区没有数据的话,就直接返回一个 EWOULDBLOCK 错误,然后应用程序通过不断轮询来检查这个状态,看内核是不是有数据过来。
I/O 复用模型
非阻塞仍然需要进程不断的轮询重试。能不能实现当数据可读了以后给程序一个通知呢?所以这里引入了一个 IO 多路复用模型,I/O 多路复用的本质是通过一种机制(系统内核缓冲 I/O 数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是 读就绪或写就绪),能够通知程序进行相应的读写操作
什么是 fd ?:在 linux 中,内核把所有的外部设备都当成是一个文件来操作,对一个文件的读 写会调用内核提供的系统命令,返回一个 fd(文件描述符)。而对于一个 socket 的读写也会有相应的文件描述符,成为 socketfd
常见的多路复用方式
常见的 IO 多路复用方式有【select、poll、epoll】,都是 Linux API 提供的 IO 复用方式
- select
进程可以通过把一个或者多个 fd 传递给 select 系统调用,进程会阻塞在 select 操作 上,这样 select 可以帮我们检测多个 fd 是否处于就绪状态。
缺点:
- 由于他能够同时监听多个文件描述符,假如说有 1000 个,这个时候如果其中一个 fd 处于
就绪状态了,那么当前进程需要线性轮询所有的 fd,也就是监听的 fd 越多,性能开销越大。 - 同时,select 在单个进程中能打开的 fd 是有限制的,默认是 1024,对于那些需要支持单机上万的 TCP 连接来说确实有点少
- epoll
linux 还提供了 epoll 的系统调用,epoll 是基于事件驱动方式来代替顺序扫描,因此性能相对来说更高,主要原理是,当被监听的 fd 中,有 fd 就绪时,会告知当前进程具体哪一 个 fd 就绪,那么当前进程只需要去从指定的 fd 上读取数据即可
另外,epoll 所能支持的 fd 上线是操作系统的最大文件句柄,这个数字要远远大于 1024由于 epoll 能够通过事件告知应用进程哪个 fd 是可读的,所以我们也称这种 IO 为异步非阻塞 IO,当然它是伪异步的,因为它还需要去把数据从内核同步复制到用户空间中,真正的异步非阻塞,应该是数据已经完全准备好了,我只需要从用户空间读就行
- poll:不研究
多路复用的好处
I/O 多路复用可以通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。它的最大优势是系统开销小,并且不需要创建新的进程或者线程,降低了系统的资源开销