本文将会在Linux环境中让服务端与客户端通过BIO的网络模型进行通信,并且演示整个通信细节。
命令介绍
在分之前会先介绍几个命令,并说明在本次分析中的作用。
1、lsof -p pid
我们用losf命令主要是为了查看系统为某个进程分配的文件描述符的信息。
-p:列出指定进程号所打开的文件。
2、netstat -natp
显示协议统计信息和当前 TCP/IP网络连接。
-n:以数字形式显示地址和端口号。
-a:显示所有连接和监听端口。
-t:显示TCP传输协议的连线状况。
-p:显示正在使用Socket的程序识别码和程序名称。
3、strace -ff -o
常用来跟踪进程执行时的系统调用和所接收的信号。,包括参数,返回值,执行消耗的时间。
-ff:如果提供-o filename,则所有进程的跟踪结果输出到相应的filename.pid中,pid是各进程的进程号。
-o:后面跟filename,将strace的输出写入文件filename中。
-p:跟踪指定的进程pid。
4、tcpdump -S -nn -i eth0 port 9090
根据使用者的定义对网络上的数据包进行截获的包分析工具。
-S:用绝对而非相对数值列出TCP关联数。
-n:不把主机的网络地址转换成名字。
-i:使用指定的网络截面送出数据包。
TCP连接常见状态
演示中会看到如下状态中的部分状态信息,可先行了解。
LISTEN
侦听来自远方的TCP端口的连接请求
SYN-SENT
在发送连接请求后等待匹配的连接请求
SYN-RECEIVED
在收到和发送一个连接请求后等待对方对连接请求的确认
ESTABLISHED
代表一个打开的连接
FIN-WAIT-1
等待远程TCP连接中断请求,或先前的连接中断请求的确认
FIN-WAIT-2
从远程TCP等待连接中断请求
CLOSE-WAIT
等待从本地用户发来的连接中断请求
CLOSING
等待远程TCP对连接中断的确认
LAST-ACK
等待原来的发向远程TCP的连接中断请求的确认
TIME-WAIT
等待足够的时间以确保远程TCP接收到连接中断请求的确认
CLOSED
没有任何连接状态
代码部分
服务端代码
服务端首先通过ServerSocket(9090),创建一个socket,之后循环接收客户端连接,但为了演示效果,每次接收新的连接之前,先通过System.in.read()方法阻塞住,当按下任意键时,来到serverSocket.accept()方法,监听客户端的请求到来。
当接收到客户端请求后,会立刻开启一个新的线程,并将请求交由新的线程去处理,主线程则继续循环等待新的客户端请求到来。
新创建出来的线程则通过bufferedReader.readLine()方法,接收客户端发送的数据并在打印输出之后,循环等待数据的到来。
整个过程会看到accept、readLine两个方法都会产生阻塞(当然System.in.read()的read方法也会阻塞,但只是为了效果演示,真正处理时也不需要调用这个方法。)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerSocketBIO {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(9090);
System.out.println("--server start--");
while (true) {
//在accept之前,先阻塞住直到按下任意键,方便分析调用流程
System.in.read();
Socket client = serverSocket.accept();
System.out.println("---accept client ---");
new Thread(() -> {
InputStream inputStream;
try {
inputStream = client.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while (true) {
System.out.println("*wait client send data...*");
String readLine = bufferedReader.readLine();
if (readLine.equals("quit")) {
System.out.println("---client down...---");
bufferedReader.close();
inputStream.close();
client.close();
break;
} else {
System.out.println("recv client data:" + readLine);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端代码
客户端代码就非常简单,请求连接服务端后,将数据通过socket发送出去即可。
import java.io.*;
import java.net.Socket;
public class SocketCli {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("10.0.0.101", 9090);
System.out.println("client start...");
OutputStream outputStream = socket.getOutputStream();
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream));
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
String readLine = reader.readLine();
if (readLine.equals("quit")) {
bufferedWriter.write(readLine);
bufferedWriter.newLine();
bufferedWriter.flush();
System.exit(-1);
} else {
bufferedWriter.write(readLine);
bufferedWriter.newLine();
bufferedWriter.flush();
}
}
}
}
追踪分析
开启tcpdump抓取数据包
启动服务端
查看strace输出的out文件,分析如下:
1、服务端创建ServerSocket时,会通过调用socket函数完成,并得到一个FD=5。
2、通过bind函数,将5绑定到9090端口上。
3、通过listen函数,开启监听。
4、通过write打印输出。
5、最终阻塞在read函数中,等待任意键输入(阻塞在这行代码System.in.read())。
查看为服务端分配的文件描述符,FD5对应一个TCP连接,并且状态为LISTEN。
启动一个客户端。
注意此时服务端并没有通过accept接收客户端的请求,但是在TCP层面双方已经完成了三次握手(意味着已经可以进行数据传输了)。
服务端建立了连接,因为没有accept,所以也没分配PID,但是状态已经为ESTABLISHED了。
再去查询客户端,客户端也建立连接,并分配了PID,状态为ESTABLISHED。
服务端键入任意键,表示接收了客户端请求。(意味着调用了serverSocket.accept())
1、通过阻塞式函数poll,等待FD=5的文件描述符就绪,如果没有客户端请求到来,则会一直阻塞在这个方法上(也就是serverSocket.accept(),这也是一个阻塞点)。
2、调用accept函数,创建一个与10.0.0.101建立连接的socket,并返回一个引用这个socket新的FD=6。
3、accept之后,代码中是直接创建了一个新的线程处理,所以当java中调用new Thread时,实际上在linux中通过clone这个函数完成的,并返回了新的线程pid,5667。
4、之后继续阻塞在read函数(也就是回到了代码中的System.in.read()),等待任意键输入。
此时再来看服务端打开的文件信息时,多了一条FD=6的TCP信息。
之前未分配PID,现在也已经完成了分配。
再来查看strace跟踪到的5667这个新创建出来的线程,阻塞在了recvfrom函数中,也是bufferedReader.readLine()这行代码,等待socket中的消息到来。
现在让客户端发送一点数据
服务端可以正常接收
5667线程接收到输出后,继续等待新的数据到来。
现在让我们再启动一个客户端,服务端正常接收连接。
服务端依然通过clone函数,创建一个新的线程, 并且pid为5674。
服务端进程中又多了一条FD=7的TCP信息。
正常建立了连接。
新的线程同样等待数据到来。
最后我们让一个客户端下线,观察netstat,此时下线的客户端与服务端的连接状态为TIME_WAIT。
分配的FD=6也没了。
最后再证实一下accept方法会阻塞。
重启服务端,直接按下任意键,此时并没有任何客户端的连接请求。
可以看到请求阻塞在了poll函数中,对于的就是serverSocket.accept()代码。
总结
当服务端创建一个socket并绑定到9090端口上时,系统会为服务端进程分配一个FD并专门用来监听客户端的请求,当有客户端连接时,即使服务端没有accept,也会完成三次握手并建立连接,只不过对于服务端来说此时建立的连接并没有分配到某个具体的PID上,一旦服务端调用accept接收客户端的连接后,就会创建一个新的FD,专门用来处理服务端与客户端数据的交互。
通过演示我们也能看到在传统BIO模式下,服务端的accept和read都会导致线程阻塞,所以我们让主线程专门用来监听客户端的请求,把监听到的请求全部交给一个新的线程去处理,这样实现了一个服务端能同时接收多个客户端的需求,但是你始终不能无限的创建线程,它始终会有瓶颈,所以之后也就出现了NIO,多路复用等IO模型,在这些模型下可以完成一个线程同时处理多个客户端的请求。
至此也就完成了最简单的BIO模式下的系统调用分析,大家可以参考文章,自己进行实验。
感谢
最后感谢各位老铁:点赞、收藏、评论