整体大纲
1、阻塞 IO 技术
简称
BIO
,B
就是Blocking
,阻塞的意思。
我想 Java
的 Socket
应该都是用过的,先看一段代码:
public class BioServer {
// 为了方便,正常来说是应该创建原生的 ThreadPoolExecutor
private static ExecutorService executors = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
/**
* 对 1009 端口号进行监听
*/
serverSocket.bind(new InetSocketAddress(1009));
try {
while (true) {
/**
* 等待客户端连接,如果没有客户端连接则会一直堵塞
*/
Socket socket = serverSocket.accept();
executors.execute(new Runnable() {
@Override
public void run() {
while (true){
InputStream inputStream = null;
try {
/**
* 等待数据写入,如果没有数据写入则会一直堵塞
* 底层其实是发出了一个 read 系统调用
*/
inputStream = socket.getInputStream();
byte[] result = new byte[1024];
int len = inputStream.read(result);
if(len != -1){
System.out.println("[response] " + new String(result,0,len));
OutputStream outputStream = socket.getOutputStream();
outputStream.write("response data".getBytes());
outputStream.flush();
}
} catch (IOException e) {
e.printStackTrace();
break;
}
}
}
});
}
}catch (Exception e){
e.printStackTrace();
}
}
}
这段代码的逻辑很简单:
(1)建立一个服务端,并绑定端口号,等待客户端连接
(2)客户端连接上了就可以向服务端发送数据
这里重点关注 serverSocket.accept();
和 socket.getInputStream();
,会在这两个地方发生阻塞情况,这就是 BIO
的体现。
大概流程为:
当客户端连接上了服务端之后,accept
的堵塞状态才会放开,然后进入 read
环节(读取客户端发送过来的网络数据)。
客户端如果一直没有发送数据过来,那么服务端的 read
调用方法就会一直处于堵塞状态,倘若数据通过网络抵达了网卡缓冲区,此时则会将数据从内核态拷贝至用户态,然后返回给 read
调用方。
2、非阻塞 IO 技术
问题:如果客户端没有发送数据,就会导致服务端一直处于堵塞状态,所以出现了非阻塞
IO
技术。
NIO
。
先看一段 NIO
代码:
public class NioSocketServer extends Thread {
ServerSocketChannel serverSocketChannel = null;
Selector selector = null;
SelectionKey selectionKey = null;
public void initServer() throws IOException {
selector = Selector.open();
serverSocketChannel = ServerSocketChannel.open();
// 设置为非阻塞模式,默认 serverSocketChannel 是采用了阻塞模式
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(8888));
selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
}
@Override
public void run() {
while (true) {
try {
// 默认这里会堵塞
int selectKey = selector.select();
if (selectKey > 0) {
//获取到所有的处于就绪状态的channel,selectionKey中包含了channel的信息
Set<SelectionKey> keySet = selector.selectedKeys();
Iterator<SelectionKey> iter = keySet.iterator();
// 对 selectionKey 进行遍历
while (iter.hasNext()) {
SelectionKey selectionKey = iter.next();
// 需要清空,防止下次重复处理
iter.remove();
// 就绪事件,处理连接
if (selectionKey.isAcceptable()) {
accept(selectionKey);
}
// 读事件,处理数据读取
if (selectionKey.isReadable()) {
read(selectionKey);
}
// 写事件,处理写数据
if (selectionKey.isWritable()) {
}
}
}
} catch (IOException e) {
e.printStackTrace();
try {
serverSocketChannel.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
}
public void accept(SelectionKey key) {
try {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("conn is acceptable");
socketChannel.configureBlocking(false);
// 将当前的 channel 交给 selector 对象监管,并且有 selector 对象管理它的读事件
socketChannel.register(selector, SelectionKey.OP_READ);
} catch (IOException e) {
e.printStackTrace();
}
}
public void read(SelectionKey selectionKey) {
try {
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(100);
int len = channel.read(byteBuffer);
if (len > 0) {
byteBuffer.flip();
byte[] byteArray = new byte[byteBuffer.limit()];
byteBuffer.get(byteArray);
System.out.println("NioSocketServer receive from client:" + new String(byteArray,0,len));
selectionKey.interestOps(SelectionKey.OP_READ);
}
} catch (Exception e) {
try {
serverSocketChannel.close();
selectionKey.cancel();
} catch (IOException e1) {
e1.printStackTrace();
}
e.printStackTrace();
}
}
public static void main(String args[]) throws IOException {
NioSocketServer server = new NioSocketServer();
server.initServer();
server.start();
}
}
大概流程:
代码流程:
当 Socket
的服务端启动之后,会对每个 Socket
连接的对象都开启一个线程,然后在一个循环里面去调用 read
函数,此时的 read
函数调用不会进入阻塞状态了,但是还是没有解决根本性问题:每次来请求都要创建一个线程来监听客户端的请求。如果客户端在建立连接之后长时间没有传输数据,此时对于服务端而言就会造成资源浪费的情况。
每次请求都需要建立一个线程,如何优化?
可以将 accept
和 read
分成两个模块来处理,当 accept
函数接收到新的连接(其实本质就是一个文件描述符 fd
)之后,将其放入一个集合,然后会有一个后台任务统一对这个集合中的 fd
遍历执行 read
函数操作。
问题:循环调用 read
方法岂不是循环进行用户态和内核态的切换?
2.1 select 模型
在 Linux
内核中有一个 select
的函数,这个函数的作用是在内核态中对 fd
集合进行遍历,如果对应的 fd
接收到客户端的抵达数据,则会返回给用户态调用方。
注意:用户态在发生 select
系统调用的时候仍然会处于阻塞状态。
- 当网卡没有接收到新的数据时,用户态执行
select
函数会处于阻塞状态,此时内核态中会对fd
集合进行循环遍历,对每个连接的fd
都执行read
操作,判断是否有新的数据抵达。
- 当网卡接收到了新的数据时,则会将数据拷贝至内核态的指定内存块区域,并且返回给调用
select
函数的用户态程序。
总结
一次 select
函数调用,只需要用户态到内核态的一次切换,和内核态的 n 次 fd
的 read
函数调用。
2.2 poll 模型
poll 也是和 select 相似,通过一次系统调用,然后在内核态中对连接的文件描述符集合进行遍历判断,判断是否有就绪状态的连接接收到了数据。
但是 select 函数中只能监听 1024 个文件描述符。而 poll 函数则是去除掉了这块的限制。
2.3 epoll 模型
select
函数主要解决的是用户态和内核态的多次切换问题,把多次切换的过程转移到了内核态中。
select
的不足点:
select
函数在从用户态拷贝 fd 集合传入内核态之后,后续主要关注的点就是哪个 fd 的就绪状态发生了改变。举个应用场景:内核态中已经存在一个 fd 集合,这个集合中的任一 fd 的状态发生变更,则整个 fd 集合都需要返回给到用户态,这个拷贝过程会将状态没有变化的 fd 也返回。select
函数在内核态中依然是通过遍历的方式来判断究竟哪个 fd 已经处于就绪状态。
epoll
对 select
的优化:
-
用户态无需将整份 fd 数据在用户态和内核态之间进行拷贝,只会拷贝发生变化的 fd 数据。
-
内核态中不再是通过循环遍历的方式来判断哪些 fd 处于就绪状态,而是通过异步事件通知的方式告知。
-
内核态会将有数据抵达的 fd 返回到用户态,此时用户态可以减少不必要的遍历操作。
epoll
底层使用了红黑树的数据结构,这种结构可以按照事件类型对 socket
的集合信息进行增删改查,相对高效稳定。
2.4 零拷贝
一般我们的数据如果需要从 IO
读取到堆内存,中间需要经过 Socket
缓冲区,也就是说一个数据会被拷贝两次才能到达它的终点,如果数据量大,就会造成不必要的资源浪费。
零拷贝:当需要接收数据的时候,会在堆内存之外开辟一块内存,数据就直接从 IO 读到了那块内存中去,在
Netty
里面通过ByteBuf
可以直接对这些数据进行直接操作,从而加快了传输速度。