2021SC@SDUSC
SelectionKey
1)SelectionKey,表示 Selector 和网络通道的注册关系, 共四种:
int OP_ACCEPT:有新的网络连接可以 accept,值为 16 int OP_CONNECT:代表连接已经建立,值为 8
int OP_READ:代表读操作,值为 1
int OP_WRITE:代表写操作,值为 4
源码中:
public static final int OP_READ = 1 << 0; public static final int OP_WRITE = 1 << 2; public static final int OP_CONNECT = 1 << 3; public static final int OP_ACCEPT = 1 << 4;
2)SelectionKey 相关方法
ServerSocketChannel
1)ServerSocketChannel 在服务器端监听新的客户端 Socket 连接
2)相关方法如下
SocketChannel
SocketChannel实现的接口更多,更重要的是对于数据的读写,而ServerSocketChannel是有一个连接来了,生成一个SocketChannel,分工不同。
1)SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者把通道里的数据读到缓冲区。
2)相关方法如下
NIO 网络编程应用实例-群聊系统
实例要求:
1)编写一个 NIO 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
2)实现多人群聊
3)服务器端:可以监测用户上线,离线,并实现消息转发功能
4)客户端:通过 channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
5)目的:进一步理解 NIO 非阻塞网络编程机制
6)示意图分析和代码
1.先编写服务器
1.1服务器启动并监听6667,先启动服务器端
1.2服务器接收客户端消息,并实现转发【转发注意要排除自己处理上线和离线】
2.编写客户器
2.1连接服务器
2.2发送消息
2.3接收服务器消息
服务器端
package com.shandonguniversity.nio.groupchat;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
public class GroupChatServer {
//定义属性
private Selector selector;
private ServerSocketChannel listenChannel;//用于监听
private static final int PORT = 6667;
//构造器
//初始化工作
public GroupChatServer() {
try {
//得到选择器
selector = Selector.open();
//ServerSocketChannel
listenChannel = ServerSocketChannel.open();
//绑定端口
listenChannel.socket().bind(new InetSocketAddress(PORT));
//设置非阻塞模式
listenChannel.configureBlocking(false);
//将该listenChannel 注册到selector
listenChannel.register(selector, SelectionKey.OP_ACCEPT);
}catch (IOException e) {
e.printStackTrace();
}
}
//监听
public void listen() {
System.out.println("监听线程: " + Thread.currentThread().getName());
try {
//循环处理
while (true) {
int count = selector.select();
if(count > 0) {//有事件处理
//遍历得到selectionKey 集合
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
//取出selectionkey
SelectionKey key = iterator.next();
//监听到accept
if(key.isAcceptable()) {
SocketChannel sc = listenChannel.accept();
sc.configureBlocking(false);
//将该 sc 注册到seletor
sc.register(selector, SelectionKey.OP_READ);
//提示
System.out.println(sc.getRemoteAddress() + " 上线 ");
}
if(key.isReadable()) { //通道发送read事件,即通道是可读的状态
//处理读 (专门写方法,读取客户端消息)
readData(key);
}
//当前的key 删除,防止重复处理
iterator.remove();
}
} else {
System.out.println("等待....");
}
}
}catch (Exception e) {
e.printStackTrace();
}finally {
//发生异常处理....
}
}
//读取客户端消息,从相关联的通道读取,
//通过获得SelectionKey key,反向读取对应的channel
private void readData(SelectionKey key) {
//取到关联的channle
SocketChannel channel = null;
try {
//得到channel,强转为SocketChannel
channel = (SocketChannel) key.channel();
//创建buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
int count = channel.read(buffer);
//根据count的值做处理
if(count > 0) {
//把缓存区的数据转成字符串
String msg = new String(buffer.array());
//输出该消息
System.out.println("from 客户端: " + msg);
//向其它的客户端转发消息(去掉自己), 专门写一个方法来处理
sendInfoToOtherClients(msg, channel);
}
}catch (IOException e) {
try {
System.out.println(channel.getRemoteAddress() + " 离线了..");
//取消注册
key.cancel();
//关闭通道
channel.close();
}catch (IOException e2) {
e2.printStackTrace();;
}
}
}
//转发消息给其它客户(通道)
private void sendInfoToOtherClients(String msg, SocketChannel self ) throws IOException{
System.out.println("服务器转发消息中...");
System.out.println("服务器转发数据给客户端线程: " + Thread.currentThread().getName());
//遍历 所有注册到selector 上的 SocketChannel,并排除 self
for(SelectionKey key: selector.keys()) {
//通过 key 取出对应的 SocketChannel
Channel targetChannel = key.channel();
//排除自己
if(targetChannel instanceof SocketChannel && targetChannel != self) {
//转型
SocketChannel dest = (SocketChannel)targetChannel;
//将msg 存储到buffer
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
//将buffer 的数据写入 通道
dest.write(buffer);
}
}
}
public static void main(String[] args) {
//创建服务器对象
GroupChatServer groupChatServer = new GroupChatServer();
groupChatServer.listen();
}
}
//可以写一个Handler
class MyHandler {
public void readData() {
}
public void sendInfoToOtherClients(){
}
}
客户端
package com.shandonguniversity.nio.groupchat;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;
public class GroupChatClient {
//定义相关的属性
private final String HOST = "127.0.0.1"; // 服务器的ip
private final int PORT = 6667; //服务器端口
private Selector selector;
private SocketChannel socketChannel;
private String username;
//构造器, 完成初始化工作
public GroupChatClient() throws IOException {
selector = Selector.open();
//连接服务器
socketChannel = socketChannel.open(new InetSocketAddress("127.0.0.1", PORT));
//设置非阻塞
socketChannel.configureBlocking(false);
//将channel 注册到selector
socketChannel.register(selector, SelectionKey.OP_READ);
//得到username
username = socketChannel.getLocalAddress().toString().substring(1);
System.out.println(username + " is ok...");
}
//向服务器发送消息
public void sendInfo(String info) {
info = username + "说:" + info;
try {
//把消息发送过去
socketChannel.write(ByteBuffer.wrap(info.getBytes()));
}catch (IOException e) {
e.printStackTrace();
}
}
//读取从服务器端回复的消息
public void readInfo() {
try {
int readChannels = selector.select();
if(readChannels > 0) {//有可以用的通道
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if(key.isReadable()) {
//得到相关的通道
SocketChannel sc = (SocketChannel) key.channel();
//得到一个Buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
//读取
sc.read(buffer);
//把读到的缓冲区的数据转成字符串
String msg = new String(buffer.array());
System.out.println(msg.trim());//去掉头尾空格
}
}
iterator.remove(); //删除当前的selectionKey, 防止重复操作
} else {
//System.out.println("没有可以用的通道...");
}
}catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
//启动我们客户端
GroupChatClient chatClient = new GroupChatClient();
//启动一个线程, 每隔3秒,读取从服务器发送数据
new Thread() {
public void run() {
while (true) {
chatClient.readInfo();
try {
Thread.currentThread().sleep(3000);
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
//发送数据给服务器端
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String s = scanner.nextLine();
chatClient.sendInfo(s);
}
}
}
NIO 与零拷贝
1.零拷贝基本介绍
1)零拷贝是网络编程的关键,很多性能优化(比如文件传输)都离不开。
2)在 Java 程序中,常用的零拷贝有 mmap(内存映射) 和 sendFile方法。那么,他们在 OS 里,到底是怎么样的一个的设计?我们分析 mmap 和 sendFile 这两个零拷贝
3)另外我们看下 NIO 中如何使用零拷贝
2.传统IO 数据读写劣势
Java 传统 IO 和 网络编程的一段代码
初学 Java 时,我们在学习 IO 和 网络编程时,会使用以下代码:
byte[] arr字节数组大小和文件大小保持一致,通过raf的read方法把文件的数据读入到arr字节数组,拿到ServerSocket,通过socket得到输出流对象,实际上就是文件读写的过程。
3.传统IO 模型
DMA: direct memory access 直接内存拷贝(不使用 CPU)
蓝色是用户态,粉色是内核态
我们会调用 read 方法读取 index.html 的内容—— 变成字节数组,然后调用 write 方法,将 index.html 字节流写到 socket 中,那么,我们调用这两个方法,在 OS 底层发生了什么呢?我这里借鉴了一张其他文字的图片,尝试解释这个过程。
上图中,上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作。下面说说他们的步骤:
1.read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。
2.发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
3.发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
4.第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
5.write 方法返回,再次从内核态切换到用户态。
先把硬件上的数据进行DMA拷贝到内核缓存(kernel buffer),然后CPU拷贝到用户缓存(user buffer),数据在用户缓存进行修改,再用CPU拷贝到socket buffer,再用DMA拷贝到协议栈(protocol engine)。共经过4次拷贝,3次切换。拷贝次数很多,代价很高。于是就有了memory map(内存映射)优化。
如你所见,复制拷贝操作太多了。如何优化这些流程?
4.mmap 优 化
1)mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。
2)mmap 示意图
如上图,user buffer 和 kernel buffer 共享 index.html。如果你想把硬盘的 index.html 传输到网络中,再也不用拷贝到用户空间,再从用户空间拷贝到 Socket 缓冲区。
现在,你只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。
先把硬件上的数据进行DMA拷贝到内核缓存(kernel buffer),内核缓存(kernel buffer)和用户缓存(user buffer)共享数据,数据可以在内核缓存进行修改,再通过CPU拷贝到socket buffer,再用DMA拷贝到协议栈(protocol engine)。拷贝次数减少为3次,但切换次数(上边蓝粉转化)仍为3次。
5.sendFile 优 化
1)Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换
2)示意图和小结
如上图,我们进行 sendFile 系统调用时,数据被 DMA 引擎从文件复制到内核缓冲区,然后调用,然后掉一共 write 方法时,从内核缓冲区进入到 Socket,这时,是没有上下文切换的,因为在一个用户空间。
最后,数据从 Socket 缓冲区进入到协议栈。
此时,数据经过了 3 次拷贝,2 次上下文切换。
那么,还能不能再继续优化呢? 例如直接从内核缓冲区拷贝到网络协议栈?
3)提示:零拷贝从操作系统角度,是没有 cpu 拷贝
4)Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,实现了零拷贝,直接拷贝到协议栈, 从而再一次减少了数据拷贝。具体如下图和小结:
现在,index.html 要从文件进入到网络协议栈,只需 2 次拷贝:第一次使用 DMA 引擎从文件拷贝到内核缓冲区,第二次从内核缓冲区将数据拷贝到网络协议栈;内核缓存区只会拷贝一些 offset 和 length 信息到 SocketBuffer,基本无消耗。
等一下,不是说零拷贝吗?为什么还是要 2 次拷贝?
答:首先我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据,sendFile 2.1 版本实际上有 2 份数据,算不上零拷贝)。例如我们刚开始的例子,内核缓存区和 Socket 缓冲区的数据就是重复的。
而零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
共经过2次拷贝,2次切换。
5)这里其实有 一次 cpu 拷贝
kernel buffer -> socket buffer
但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略
零拷贝的再次理解
1)我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。
2)零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
mmap 和 sendFile 的 区 别
1)mmap 适合小数据量读写,sendFile 适合大文件传输。
2)mmap 需要 3 次上下文切换,3 次数据拷贝;sendFile 需要 2次上下文切换,最少 2 次数据拷贝。
3)sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
在这个选择上:rocketMQ 在消费消息时,使用了 mmap。kafka 使用了 sendFile。