🔭 嗨,您好 👋 我是 vnjohn,在互联网企业担任 Java 开发,CSDN 优质创作者
📖 推荐专栏:Spring、MySQL、Nacos、Java,后续其他专栏会持续优化更新迭代
🌲文章所在专栏:网络 I/O
🤔 我当前正在学习微服务领域、云原生领域、消息中间件等架构、原理知识
💬 向我询问任何您想要的东西,ID:vnjohn
🔥觉得博主文章写的还 OK,能够帮助到您的,感谢三连支持博客🙏
😄 代词: vnjohn
⚡ 有趣的事实:音乐、跑步、电影、游戏
目录
- 前言
- 阻塞式 I/O 模型
- 图解分析
- 源码实践
- Socket 服务端代码
- Socket 客户端代码
- 流程说明
- 命令简要解析
- strace
- socket
- bind
- listen
- accept
- 总结
前言
Unix/Linux 下可用的 I/O 模型有以下五种:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select、poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O
在 Linux 中操作内核时,所有的无非三种操作,分别是输入、输出、报错输出
0-输入
1-输出
2-报错输出
一个输入操作通常包括两个不同的阶段:
- 等待数据准备好
- 从内核向进程复制数据
对于一个套接字(Socket)的输入操作,第一步通常涉及等待数据从网络中;当所等待分组到达时,它被复制到内核中的某个缓冲区,第二步就是把数据从内核缓冲区复制到应用进程缓冲区
阻塞式 I/O 模型
最流行的 I/O 模型是阻塞式 I/O (Blocking I/O) 模型,在默认的不加任何附加值的情况下,所有的套接字都是阻塞的,以数据报套接字作为例子,如下:
数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有
recvfrom 函数被视为系统调用,区分应用空间、内核空间,无论它如何实现,一般都会从在应用进程空间中运行切换到在内核空间中运行,一段时间之后再切换回来
进程调用 recvfrom 其系统调用直到数据到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误是系统调用被信号中断
进程从调用 recvfrom 开始到它返回的整段时间内是被阻塞的,recvfrom 成功返回后,应用进程开始处理数据报
图解分析
查询 TCP、Socket 网络条目信息:netstat -natp
- 当有新的连接进来时,主线程负责执行 accept 连接客户端,clone 出一个线程去 accept/read,等待其他客户端连接时是阻塞的,读取客户端数据也是阻塞的
- BIO 采用的处理方式:主线程阻塞去等待客户端连接,为每个客户端分配一个子线程去阻塞读取数据
在本文中,会涉及到一些函数操作,所有的函数大致操作流程如下图:
源码实践
Socket 服务端代码
package org.vnjohn.bio.server;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @author vnjohn
* @since 2023/11/25
*/
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket server = new ServerSocket(8090);
System.out.println("step1: new ServerSocket(8090)");
while (true) {
Socket client = server.accept();
System.out.println("step2:client\t" + client.getPort());
new Thread(new Runnable() {
Socket socket;
public Runnable setSocket(Socket socket) {
this.socket = socket;
return this;
}
@Override
public void run() {
try {
InputStream inputStream = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
while (true) {
System.out.println(reader.readLine());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}.setSocket(client)).start();
}
}
}
Socket 客户端代码
package org.vnjohn.bio.client;
import java.io.*;
import java.net.Socket;
/**
* @author vnjohn
* @since 2023/11/25
*/
public class SocketClient {
public static void main(String[] args) {
try {
Socket client = new Socket("172.16.249.10", 9090);
client.setSendBufferSize(20);
// false 优化,true 不优化
client.setTcpNoDelay(true);
client.setOOBInline(false);
OutputStream out = client.getOutputStream();
InputStream in = System.in;
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
while (true) {
String line = reader.readLine();
if (line != null) {
byte[] bb = line.getBytes();
for (byte b : bb) {
out.write(b);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
流程说明
172.16.249.10 是之前作为 node1 节点所在 IP
将以上两个 java 源文件上传到 node1 虚拟节点上,所在目录:/opt/java
1、在虚拟节点上安装好 Java 环境
2、将源文件所在的 package 包名,通过 vim 命令将 package 包名删除首行.
3、将 Java 源文件进行编译为 .class 文件 > javac SocketServer.java、javac SocketClient.java
1、追踪应用程序与操作系统中的交互信息
cd /opt/java
strace -ff -o out java SocketServer
执行该追踪命令以后,会在 /opt/java 下生成几个 out 前缀文件,所有的 out 前缀所对应的后缀是所属的进程 pid 号
通过 jps 命令查看当前所运行的 SocketServer 所占用的 pid 进程,它能够对应上所输出的文件.
但实际上生成的与操作系统交互信息都不会在这个文件中,它会 clone 一个子进程去负责 accept
2、通过 vim 命令,查看对应的 out.28979
所输出的内容
结合以上输出的内容,我们重点是要关注 out.28980
文件的内容
在此处,能够发生输出的文件中出现了核心的三个网络相关函数调用,分别是:socket、bind、listen,在后一节会简要的介绍这些函数的作用
3、通过我们能构建的 node2 节点:172.16.249.11,来充当 Socket 客户端的角色,看它与服务端建立连接以后,在 out.28980
文件中会出现什么内容
首先是在 node2 节点通过 java 命令直接运行该 Java 程序
随即观察 node1 节点所开启的服务端窗口会出现双方建立连接成功的系统输出
当前 node1 服务端为其客户端分配了一个 32900 端口,进行后续两者之间的通信
out.28980
文件的内容如下:
通过 accept 系统调用为其客户端分配了一个 32900 端口,IP:172.16.249.11,分配的 socketfd 文件描述符为 6
4、如何观察进程的所有文件描述符信息
通过命令:ls -l /proc/28980/fd
28980 是对应的 pid 进程号
Server Accept:分配的 fd 为 5
Client 建立连接成功:分配的 fd 为 6
通过命令:netstat -natp 查询 Socket/TCP 网络信息
命令简要解析
当然,要学习 Linux 中内核一些核心参数命令的使用,可以借助 man pages 帮助文档来进行阅读
man pages:yum install man
pthread man pages:yum -y install man-pages
strace
Linux 中 strace 命令能够很方便的帮助到你追踪到一个程序所执行的系统调用信息
查看 strace 使用文档:man strace
在最简单的情况下,strace 运行指定的命令直到退出,它拦截并记录进程所调用的系统调用、进程所接收的信号
每个系统调用的名称,它的参数和返回值都会被打印到标准错误或者用-o 参数
选项输出到指定的文件中
它有很多的参数选项,如下:
- -a column:对齐特定列中的返回值(默认列 40)
- -i:在系统调用时打印指令指针
- -o filename:将跟踪输出写入文件的文件名中,而不是写入到 stderr 标准错误;如果同时提供了 -ff 选项,则使用 pid 文件的形式通过管道的方式进行传输写入
- -A:以追加的模式打开 -o 选项中提供的文件
- -q:抑制有关附加、分离等信息,当输出被重定向到文件并且直接运行命令而不是附加命令时,会发生这种情况
- -qq:如果给出两次,则抑制有关进程退出状态的消息
- -r:在进行每个系统调用时打印一个相对时间戳,记录了连续系统调用开始的时间差
- -s strsize:指定要打印的最大字符串的大小(默认为 32)
- -t:用挂钟时间作为每一行跟踪的前缀
- -tt:若给出两次,打印的时间将包括微妙
- -ttt:若给定三次,则打印的时间将包括微妙,并且前导部分将作为自 epoch 以来的秒数打印
- -T:显示花费在系统调用上的时间,这将记录每个系统调用开始和结束之间的时间差
- -x:以十六进制字符串格式打印所有的非 ascii 字符串
- -xx:以十六进制字符串格式打印所有字符串
- -X format:设置命名变量和标志的打印格式,支持的格式值有:
raw:未经解码的原始数字输出
abbrev:输出一个命名的常量或一组标志,而不是找到的原始数字,这是默认的字符行为
verbose:输出原始值和解码后的字符串
- -y:打印与文件描述符参数关联的路径
- -yy:打印与套接字文件描述符相关的协议特定信息,以及与设备文件描述符相关的块/字符设备号
还有一些统计指标的参数选项,可以查看帮助文档进行使用.
socket
查看 socket 命令帮助文档:man 2 socket
int socket(int domain, int type, int protocol);
包裹函数:Socket() 创建用于通信的端点并返回套接字描述符
实践部分:socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5
bind
查看 bind 命令帮助文档:man 2 bind
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
当使用 socket 创建套接字时,它存在于名称空间中(地址族)中,但没有给它分配地址
bind() 将 addr 指定的地址分配给文件描述符 sockfd 引用的套接字,Addrlen 指定 addr 指向的地址结构大小(以字节为单位)
在传统上,bind 此操作称为 “为套接字分配名称”
实践部分:
bind(5, {sa_family=AF_INET6, sin6_port=htons(8090), inet_pton(AF_INET6, “::”, &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
5:原始套接字 sockfd
AF_INET6:协议类型
8090:原始套接字端口号
listen
查看 listen 命令帮助文档:man 2 listen
int listen(int sockfd, int backlog);
将 sockfd 引用的套接字标记为被动套接字,也就是说,将使用 accept(2) 来接受传入的连接请求
sockfd 参数是一个文件描述符,它引用 SOCK_STREAM 或 SOCK_SEQPACKET 类型的套接字
backlog 参数定义 sockfd 挂起链接队列可能增长到的最大长度,若一个连接请求在队列已满时到达,客户端可能会收到一个带有 ECONNREFUSED 指示的错误,或者,如果底层协议支持重传(TCP),请求可能会被忽略,以便稍后重试连接成功
实践部分:listen(5, 50)
监听此文件描述符,并为其分配一个长度为 50 的链接队列,队列满了以后,会有
SYN_RECV
状态的网络条目出现
accept
查看 accept 命令帮助文档:man 2 accept
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept() 系统调用用于基于连接的套接字类型(SOCK_STREAM、SOCK_SEQPACKET)它提取了侦听套接字 sockfd 挂起链接队列上的第一个连接请求将创建一个新连接套接字,并返回一个引用该套接字的新文件描述符;新创建的套接字不在监听范围内状态。原始套接字 sockfd 不受此调用的影响
实践部分:
accept(5, {sa_family=AF_INET6, sin6_port=htons(32900), inet_pton(AF_INET6, “::ffff:172.16.249.11”, &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 6
5:原始套接字 sockfd
AF_INET6:协议类型
172.16.249.11:新 sockfd 文件描述符所在地址
6:新套接字 sockfd
总结
该篇博文主要介绍的是 I/O 模型中的阻塞 I/O -> BIO,简要分析了 BIO 流程图及相关系统函数调用,通过实践代码的方式来分析阻塞 I/O 在系统调用中所涉及到的流程,最后,介绍了相关联的系统函数:strace、socket、bind、listen、accept,希望能够得到你的支持,感谢三连
四元组唯一:源 IP、源端口、目标 IP、目标端口
🌟🌟🌟愿你我都能够在寒冬中相互取暖,互相成长,只有不断积累、沉淀自己,后面有机会自然能破冰而行!
博文放在 网络 I/O 专栏里,欢迎订阅,会持续更新!
如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!
推荐专栏:Spring、MySQL,订阅一波不再迷路
大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!