透过 Java 看看 Socket 的那些事
众所周知,在互联网的世界中,当一方想要与另一方进行通信时,大部分情况下,通信双方需要先建立一个连接,也就是我们常说的 TCP 连接。但是 TCP 只是 IETF 发布的一个在传输层的协议,属于概念性的东西,那实现这个协议的事情是谁做的呢?
在日常开发和使用中,我们绝大多数的程序员都只会去关注应用层的东西,很少会去思考,在我的机器上,我的程序是如何和另一台机器上的程序建立连接并进行数据传输的?
因为这些事情,操作系统,也就是内核,已经帮我们封装好了,那么我们今天就一起来看下,内核都帮我们做了什么?
理论
TCP / IP 四层模型
首先,让我们先来回顾下著名的 TCP/IP 四层模型:
从图中可以看出,位于最上边的应用层,其包含了众多的协议,而这些协议,也是我们日常开发和使用中接触最多的。比如,当我们准备搭建一个微服务时,我们会考虑是否让它支持 http 协议或者 https 协议;当我们开发一个发送邮件的功能时,又要考虑到 SMTP 协议,等等。那我们今天的主角:socket 它属于哪一层呢?其实 socket 并不是某一个具体的协议,它是操作系统为我们应用程序提供的接口,当我们需要和内核进行交互时,只需要调用其提供的 API,而不需要考虑具体是如何实现的。
Socket 四元组
知道了 socket 是什么后,让我们来看看它具体长什么样子?官方对其定义,在 RFC 9293 中提到:
翻译过来就是说,一个 socket 需要包含一个地址和一个端口号。那也就意味着,如果两台机器想要进行通讯,那么在客户端要有一个 socket,在服务端也要有一个 socket,双方需要知道彼此的 ip 和端口号后,才能建立连接。所以,一个 socket 连接需要包含自己的 ip, 端口号和对方的 ip, 端口号。而这四个元素,也就是我们常说的 socket 四元组。
实践
在我们对 socket 有了一个基本的了解后,我们用一段代码,来看一下 socket 是如何帮助我们进行进程间的通信的?
public class ServerSocketDemo {
public static void main(String[] args) throws IOException {
try (ServerSocket serverSocket = new ServerSocket(8888)) {
System.out.println("listening 8888");
System.out.println("waiting input...");
System.in.read();
System.out.println("received input");
System.out.println("waiting client...");
Socket socket = serverSocket.accept();
int port = socket.getPort();
System.out.println("remote port is: " + port);
BufferedInputStream bufferedInputStream = new BufferedInputStream(socket.getInputStream());
byte[] buffer = new byte[1024];
int len;
while ((len = bufferedInputStream.read(buffer)) != -1) {
System.out.println(new String(buffer, 0, len));
}
}
}
}
-
在第五行,我们首先开启一个服务端 socket,然后在第九行,利用等待输入阻塞住程序。此时,我们查看进程信息,可以看到我们的系统上已经有了一个进程在监听8888端口,进程号1844
-
然后我们打开一个新的会话窗口,利用 nc 命令模拟客户端建立连接,然后再次查看进程信息,我们发现此时系统上多了两个进程:
- 绿色方框选中的进程:表示站在客户端,也就是 nc 的角度,我和监听8888端口的进程成功的建立了连接。
- 红色方框选中的进程:表示站在服务端的角度,因为我的 java 进程被阻塞住了,我没有办法接收这个连接,但是操作系统,也就是内核帮助我们建立了这个 tcp 连接。
-
此时,我们让 java 进程继续保持阻塞,然后向8888端口发送
hello
字符串。发送后再次查看进程信息,我们会发现那个没有进程号的进程的Recv-Q
列,多了6个字节。而这就是内核在建立连接后帮助 java 进程接收了数据(一个hello
字符串和一个回车换行符)并把数据放到了内核缓冲区。 -
这时候,我们在 java 进程里输入123,让程序继续执行。此时,我们会看到 java 程序会将内核中的信息,也就是队列中的6个字节,读取出来并打印到控制台。而且,那个没有进程号的进程也已经被 java 进程接受了。
-
到这里,我们清楚的看到了,在 TCP/IP 四层模型中,下三层的事情,是内核做的,而我们只实现了应用层的事情,而应用程序和内核之间的交互,就是 socket 做的事情,它帮助了应用程序读取了内核中缓冲的数据。
了解了 socket 的作用后,我们再看一下 socket 具体是如何和内核交互的? strace
命令,可以帮助我们查看进程在和系统交互时都调用了哪些系统指令集,让我们更新下代码,去除不必要的打印和等待输入的代码,然后通过 strace 再次启动 ServerSocketDemo
public class ServerSocketDemo {
public static void main(String[] args) throws IOException {
try (ServerSocket serverSocket = new ServerSocket(8888)) {
System.out.println("listening 8888");
System.out.println("waiting client...");
Socket socket = serverSocket.accept();
int port = socket.getPort();
System.out.println("remote port is: " + port);
BufferedInputStream bufferedInputStream = new BufferedInputStream(socket.getInputStream());
byte[] buffer = new byte[1024];
int len;
while ((len = bufferedInputStream.read(buffer)) != -1) {
System.out.println(new String(buffer, 0, len));
}
}
}
}
启动后,我们会发现,在目录下多出了很多以 socket 开头的文件,其实这些文件就代表每一个线程,包括主线程,jvm 的线程、守护线程等等,查看 socket.2320
这个文件,并搜索 8888
关键字。
查看日志,我们会看到:
- 操作系统首先会创建一个 socket,并用数字4进行标识。
- 调用指令
bind
进行 ip 和端口号绑定。 - 调用指令
listen
进行端口监听。 - 调用指令
aceept
并且阻塞在这里。说明此时,正在等待一个 socket 连接。
接下来,我们再次用 nc 命令建立连接,并观察 socket.2320
这个文件的变化:
我们可以看到,连接建立后,accept 方法接收了 nc 的连接并且等待对方的输入。
紧接着,我们输入123,我们可以看到 socket 接收到了包含换行符的信息,并且向控制台进行了打印。
总结
最开始我们从理论层面,了解了 socket 是什么,有什么作用,然后,用代码演示了,在 api 层面它是如何帮助应用程序和内核进行交互的,最后,我们一起看了下 socket 的具体实现过程。希望通过阅读本文能帮助你更好的理解 socket 这个深藏功与名的接口
(感谢阅读,如有不正确之处,欢迎指教)
转载自:https://juejin.cn/post/7268802459382972474