netty学习之路(1)---IO
读完本文你将获得
ByteBuffer工作原理
buffer作为nio的三大组件之一,常见的有
*
ByteBuffer
DirectByteBuffer
HeapByteBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
ByteBuffer有三个需要关注的属性
capacity
作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
position
当你写数据到Buffer中时,position表示当前能写的位置。初始的position值为0。当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.
当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
limit
在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。写模式下,limit等于Buffer的capacity。
当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)
直接内存
HeapByteBuffer
DirectByteBuffer
上面提到 ByteBuffer有两个实现类HeapByteBuffer与DirectByteBuffer
儿在jvm内存结构中,有一块叫做直接内存,在此可以看到,这两者区别便是前者直接分配在heap区域,后者则是直接用堆外内存,绝大多数nio框架采用的都是DirectByteBuffer
因为如果用haep的话,其实还有一个Java 堆和Native 堆中来回复制数据的操作。因为需要从heap中flush数据到直接内存。
零拷贝
在linux内核升级至2.4版本之后。便可以实现零拷贝,所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm内存,我们所有的拷贝都发生在操作系统。(这里先简单介绍一波=======万能的OS啊。。。)
- java 调用 transferTo 方法后,使用 mmap()代替read(),write()将数据数据映射到用户空间内核缓冲区
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
- 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 cpu
零拷贝其实并不适合大文件的传输,但是在传输大文件时,因为文件本身很大,PageCache被大文件占满,导致其他热点文件无法使用PageCache,这样会降低磁盘读写性能。所以在传输大文件时避免使用零拷贝。可以使用异步IO的方式传输大文件
网络编程代码实现–selector->reactor代码实现
阻塞模式
public static void blockNio() throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocate(2);
List<SocketChannel> socketChannelList = new ArrayList<>();
//nio 的阻塞模式
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8888));
while (true) {
//建立连接 ---- 这里是阻塞的
log.info("等待建立连接");
SocketChannel accept = ssc.accept();
socketChannelList.add(accept);
log.info("已经建立连接+socketChannelList数量=====" + socketChannelList.size());
//看看有多少个客户端,进行遍历
for (SocketChannel socketChannel : socketChannelList) {
//读取数据---- 这里是阻塞的
while (true) { // 循环读取客户端发送过来的数据
if (socketChannel.read(byteBuffer) == -1) { // 客户端关闭了输出之后,阻塞的 client.read(buf) 会立即返回 -1,此时 buf 中没有内容
socketChannel.close(); // 关闭通道
System.out.println("Client closed the connection.");
break;
}
byteBuffer.flip(); // 切换到读模式
while (byteBuffer.position() < byteBuffer.limit()) {
System.out.print((char) byteBuffer.get()); // 一个字符一个字符打印出来
}
byteBuffer.clear(); // 切换到写模式
}
}
}
}
这是阻塞模式下server逻辑,这里一共有两个阻塞点。
- ssc.accept();建立连接时会阻塞
- socketChannel.read() read时候也会阻塞
- 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,线程相当于闲置,因为我们会想到,可以使用多线程来解决这个问题,但是如果使用线程池,必须考虑到问题的是,如果是长连接,很容易将线程池打满,因此风险极大。
非阻塞阻塞
public static void noBlockNio() throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocate(2);
List<SocketChannel> socketChannelList = new CopyOnWriteArrayList<>();
//nio 的非阻塞模式
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8888));
while (true) {
//建立连接 ---- 这里是阻塞的
// log.info("等待建立连接");//可以试着开启这个日志,验证非阻塞的效果
SocketChannel accept = ssc.accept();
if (accept != null) {
log.info("新连接建立:{}", accept);
accept.configureBlocking(false);//....
socketChannelList.add(accept);
log.info("已经建立连接+socketChannelList数量=====" + socketChannelList.size());
}
//看看有多少个客户端,进行遍历
for (SocketChannel socketChannel : socketChannelList) {
//读取数据---- 这里是阻塞的
while (true) { // 循环读取客户端发送过来的数据
if (socketChannel.read(byteBuffer) == 0) { // 非阻塞模式下,不会阻塞
System.out.println("读取数据完成");
break;
}
if (socketChannel.read(byteBuffer) == -1) { // 非阻塞模式下,不会阻塞
socketChannel.close(); // 关闭通道
socketChannelList.remove(socketChannel);
System.out.println("Client closed the connection.");
break;
}
byteBuffer.flip(); // 切换到读模式
while (byteBuffer.position() < byteBuffer.limit()) {
System.out.print((char) byteBuffer.get()); // 打印字符
}
byteBuffer.clear(); // 切换到写模式
}
}
}
}
- 非阻塞模式下,相关方法都会不会让线程暂停
- 在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行 *
- SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
- 写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
- 但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu
- 数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)
单线程selector模式(io多路复用)
到这里我们就只能站在内核的层面来解决这个问题了,我们需要内核给我们提供一个巡逻官来帮我们管理channle事件并告诉我们何时处理事件,IO复用可以通过 select、poll、epoll来实现。这三者区别与实现原理放后面再说。
我们先看看server的代码
public static void selectorNio() throws IOException {
// 1. 创建 selector, 管理多个 channel
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
// 2. 建立 selector 和 channel 的联系(注册)
// SelectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
SelectionKey sscKey = ssc.register(selector, 0, null);
// key 只关注 accept 事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
ssc.bind(new InetSocketAddress(8888));
while (true) {
// select 在事件未处理时,它不会阻塞, 事件发生后要么处理,要么取消
selector.select();
Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); // accept, read
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 处理key 时,要从 selectedKeys 集合中删除,否则下次处理就会有问题
iter.remove();
if (key.isAcceptable()) { // 如果是 accept
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
SelectionKey scKey = sc.register(selector, 0, null);
scKey.interestOps(SelectionKey.OP_READ);
log.debug("{}", sc);
log.debug("scKey:{}", scKey);
} else if (key.isReadable()) { // 如果是 read
try {
SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channel
ByteBuffer buffer = ByteBuffer.allocate(4);
int read = channel.read(buffer); // 如果是正常断开,read 的方法的返回值是 -1
if (read == -1) {
key.cancel();
} else {
buffer.flip();
// debugAll(buffer);
System.out.println(Charset.defaultCharset().decode(buffer));
}
} catch (IOException e) {
e.printStackTrace();
key.cancel(); // 因为客户端断开了,因此需要将 key 取消(从 selector 的 keys 集合中真正删除 key)
}
}
}
}
}
核心:一个线程配合selector就可以监控多个channel的事件,在非阻塞的基础上,相当于多了一个巡逻官,事件触发了便开始通知。
这样既节约了线程的数量,又减少了线程上下文切换。
我们这里思考三个个问题
1.这里是单线程的selector,也就是说同一时间段只能处理一个client,也就是说这玩意根本不是并发的。
2.这里其实并不能能够管理handler对event的绑定,因为建立连接与绑定事件的操作偶合在一起了,并没有进行分发。
3.所以io多路复用是阻塞还是非阻塞,异步还是非异步。
reactor模式
reactor模式的应用十分广泛,redis与与本次学习的netty都用到了reactor模式。
单线程reactor模式
reator代码
package cn.itcast.nio.single;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
@Slf4j
public class ReactorIO implements Runnable
{
private Selector selector;
private ServerSocketChannel serverSocket;
private volatile boolean started;
ReactorIO(int port) throws IOException
{ //Reactor初始化
selector = Selector.open();
serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(port));
//非阻塞
serverSocket.configureBlocking(false);
//分步处理,第一步,接收accept事件
SelectionKey sk =
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
//attach callback object, Acceptor
sk.attach(new Acceptor());
}
public void run()
{
try
{
while (!Thread.interrupted())
{
//阻塞
log.info("select阻塞");
selector.select();
log.info("select开始处理事件");
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
dispatch(key);
// 处理key 时,要从 selectedKeys 集合中删除,否则下次处理就会有问题
iter.remove();
}
log.info("-====");
}
} catch (IOException ex){
}
}
void dispatch(SelectionKey k)
{
log.info("dispatch 分发事件");
Runnable r = (Runnable) (k.attachment());
//调用之前注册的callback对象
if (r != null)
{
r.run();
}
}
// inner class
class Acceptor implements Runnable
{
public void run()
{
try
{
log.info("处理连接事件");
SocketChannel channel = serverSocket.accept();
if (channel != null)
new Handler(selector, channel);
} catch (IOException ex){
}
}
}
}
hanler代码
public class NioClientHandle implements Runnable{
private String host;
private int port;
private volatile boolean started;
private Selector selector;
private SocketChannel socketChannel;
public NioClientHandle(String ip, int port) {
this.host = ip;
this.port = port;
try {
/*创建选择器*/
this.selector = Selector.open();
/*打开监听通道*/
socketChannel = SocketChannel.open();
/*如果为 true,则此通道将被置于阻塞模式;
* 如果为 false,则此通道将被置于非阻塞模式
* 缺省为true*/
socketChannel.configureBlocking(false);
started = true;
} catch (IOException e) {
e.printStackTrace();
System.exit(-1);
}
}
public void stop(){
started = false;
}
@Override
public void run() {
//连接服务器
try {
doConnect();
} catch (IOException e) {
e.printStackTrace();
System.exit(-1);
}
/*循环遍历selector*/
while(started){
try {
/*阻塞方法,当至少一个注册的事件发生的时候就会继续*/
selector.select();
/*获取当前有哪些事件可以使用*/
Set<SelectionKey> keys = selector.selectedKeys();
/*转换为迭代器*/
Iterator<SelectionKey> it = keys.iterator();
SelectionKey key = null;
while(it.hasNext()){
key = it.next();
/*我们必须首先将处理过的 SelectionKey 从选定的键集合中删除。
如果我们没有删除处理过的键,那么它仍然会在事件集合中以一个激活
的键出现,这会导致我们尝试再次处理它。*/
it.remove();
try {
handleInput(key);
} catch (Exception e) {
if(key!=null){
key.cancel();
if(key.channel()!=null){
key.channel().close();
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
System.exit(-1);
}
}
if(selector!=null){
try {
selector.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/*具体的事件处理方法*/
private void handleInput(SelectionKey key) throws IOException {
if(key.isValid()){
/*获得关心当前事件的channel*/
SocketChannel sc =(SocketChannel)key.channel();
/*处理连接就绪事件
* 但是三次握手未必就成功了,所以需要等待握手完成和判断握手是否成功*/
if(key.isConnectable()){
/*finishConnect的主要作用就是确认通道连接已建立,
方便后续IO操作(读写)不会因连接没建立而
导致NotYetConnectedException异常。*/
if(sc.finishConnect()){
/*连接既然已经建立,当然就需要注册读事件,
写事件一般是不需要注册的。*/
socketChannel.register(selector,SelectionKey.OP_READ);
}else System.exit(-1);
}
/*处理读事件,也就是当前有数据可读*/
if(key.isReadable()){
/*创建ByteBuffer,并开辟一个1k的缓冲区*/
ByteBuffer buffer = ByteBuffer.allocate(1024);
//ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
/*将通道的数据读取到缓冲区,read方法返回读取到的字节数*/
int readBytes = sc.read(buffer);
if(readBytes>0){
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
String result = new String(bytes,"UTF-8");
System.out.println("客户端收到消息:"+result);
}
/*链路已经关闭,释放资源*/
else if(readBytes<0){
key.cancel();
sc.close();
}
}
}
}
/*进行连接*/
private void doConnect() throws IOException {
/*如果此通道处于非阻塞模式,则调用此方法将启动非阻塞连接操作。
如果连接马上建立成功,则此方法返回true。
否则,此方法返回false,
因此我们必须关注连接就绪事件,
并通过调用finishConnect方法完成连接操作。*/
if(socketChannel.connect(new InetSocketAddress(host,port))){
/*连接成功,关注读事件*/
socketChannel.register(selector,SelectionKey.OP_READ);
}
else{
socketChannel.register(selector,SelectionKey.OP_CONNECT);
}
}
/*写数据对外暴露的API*/
public void sendMsg(String msg) throws IOException {
doWrite(socketChannel,msg);
}
private void doWrite(SocketChannel sc,String request) throws IOException {
byte[] bytes = request.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
writeBuffer.put(bytes);
writeBuffer.flip();
sc.write(writeBuffer);
}
}
- 单线程的Reactor是一个线程对象,由selector来进行监听各个channle事件,然后注册然后有dispatch来进行统一分发
- 如果是Acceptor事件,Reactor监听到了该ACCEPT事件的发生并将该ACCEPT事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的READ事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该连接的READ事件了。
- 当Reactor监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过SocketChannel的read()方法读取数据,此时read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
多线程reactor模式
与单线程Reactor模式不同的是,添加了一个工作者线程池吧,本质上建立连接,处理read事件和连接事件还是单线程来处理的。知识将将非I/O操作从Reactor线程中移出转交给工作者线程池来执行。这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。
①通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程产生的巨大开销。
②另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
③通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态。同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。
改进后的代码
@Slf4j
public class MoreHandler implements Runnable
{
final SocketChannel channel;
final SelectionKey selectionKey;
ByteBuffer input = ByteBuffer.allocate(1024);
ByteBuffer output = ByteBuffer.allocate(1024);
static final int READING = 0, SENDING = 1;
int state = READING;
ExecutorService pool = Executors.newFixedThreadPool(2);
static final int PROCESSING = 3;
MoreHandler(Selector selector, SocketChannel c) throws IOException
{
channel = c;
c.configureBlocking(false);
// Optionally try first read now
selectionKey = channel.register(selector, 0);
//将Handler作为callback对象
selectionKey.attach(this);
//第二步,注册Read就绪事件
selectionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
public void run()
{
//连接服务器
try {
doConnect();
} catch (IOException e) {
e.printStackTrace();
System.exit(-1);
}
/*循环遍历selector*/
while(started){
try {
if(STate=3){
//异步线程执行
doread(socketChannel);
}else {
doWrite(socketChannel,msg);
}
}
}
synchronized void read() throws IOException
{
// ...
channel.read(input);
if (inputIsComplete())
{
state = PROCESSING;
//使用线程pool异步执行
pool.execute(new Processer());
}
}
/*进行连接*/
private void doConnect() throws IOException {
/*如果此通道处于非阻塞模式,则调用此方法将启动非阻塞连接操作。
如果连接马上建立成功,则此方法返回true。
否则,此方法返回false,
因此我们必须关注连接就绪事件,
并通过调用finishConnect方法完成连接操作。*/
if(socketChannel.connect(new InetSocketAddress(host,port))){
/*连接成功,关注读事件*/
socketChannel.register(selector,SelectionKey.OP_READ);
}
else{
socketChannel.register(selector,SelectionKey.OP_CONNECT);
}
}
/*写数据对外暴露的API*/
public void sendMsg(String msg) throws IOException {
doWrite(socketChannel,msg);
}
private void doWrite(SocketChannel sc,String request) throws IOException {
byte[] bytes = request.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
writeBuffer.put(bytes);
writeBuffer.flip();
state = PROCESSING;
//使用线程pool异步执行
pool.execute(new Processer());
}
主从Reactor
Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。
mainReactor可以只有一个,但subReactor一般会有多个。mainReactor线程主要负责接收客户端的连接请求,然后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通信。
1.注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。启动mainReactor的事件循环。
2.客户端向服务器端发起一个连接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个SocketChannel传递给subReactor线程池。
3. subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。当然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操作。Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的循环逻辑。④当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的I/O的write操作还是会被提交回subReactor线程来完成。
小结
这里来思考一下,为什么我们需要用reactor这种设计模式呢?我在这里有以下四种理由
- 管理方便,我们可以看到我们所以处理所有连接由reactor来进行分发,这里在很大程度下减少了并发问题,以及线程上下午切换消耗资源
- 响应快,虽然Reactor本身依然是同步的,但是之后对io操作可以由线程池完成,响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
- 设计模式不能解决硬件问题,nio还是需要OS内核的升级来完成,所以当前也知道seleor,poll,epoll支持这种交互。
- 多Reactor线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor线程来完成。mainReactor完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给subReactor线程来完成与客户端的通信,这样一来就不会因为read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。