Netty学习前置知识(一)
学习 https://www.bilibili.com/video/BV1py4y1E7oA?p=122&spm_id_from=pageDriver 所做笔记
1.阻塞IO
JDK 1.4 之前: bio 同步阻塞的IO
JDK 1.4 之后: java.nio.xxx —> 解决阻塞问题 Non-Blocking 同步非阻塞的IO
在学习Nio之前我们需要回顾一下传统的阻塞IO,网络连接是怎么干的
- 客户端与服务端建立连接时,服务端会创建一个 ServerSocketChannel 绑定端口和ip,然后调用其 accept()方法阻塞住等待客户端的连接。
- 客户端 socketChannel.connect() 发起连接请求,发起三次握手,客户端线程阻塞住,等待三次握手结束。
- 服务端连接成功,建立好SocketChannel, 接收客户端数据,阻塞在 Channel.read() 方法中。
服务端
// 使用 nio 来理解阻塞模式, 单线程
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
// 2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
// 3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
log.debug("connecting...");
SocketChannel sc = ssc.accept(); // 阻塞方法,线程停止运行
log.debug("connected... {}", sc);
channels.add(sc);
for (SocketChannel channel : channels) {
// 5. 接收客户端发送的数据
log.debug("before read... {}", channel);
channel.read(buffer); // 阻塞方法,线程停止运行
buffer.flip();
debugRead(buffer);
buffer.clear();
log.debug("after read...{}", channel);
}
}
客户端
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
System.out.println("waiting...");
因为这个read() 函数底层是操作系统提供的,是阻塞的,如果客户端一直不发数据,服务端就一直阻塞在read() 函数,不能接受新的连接请求。
根据上图,read() 阻塞,需要等待网卡接收到数据到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。
传统单线程BIO带来的问题
单线程下,阻塞方法之间相互影响。
**相互影响具体例子: **
第一种可能情况:单线程下,connect() 和 read() 方法都会阻塞,第一次客户端连接上服务器之后,阻塞在 read()方法,此时别的客户端连接将不会得到响应
第二种可能情况: 第一次客户端连接后,发送完数据,这时阻塞在connect() 方法,想再发送数据就会被阻塞,只有一个新的客户端连接上之后,才会发出数据
改进方式:多线程
对于数据读取,新开一个线程,这样就不会阻塞住。
这不是真正的非阻塞IO,而是在用户层面上的无奈之举。
多线程的影响:
线程是计算机宝贵的资源。当连接数很多时,可能会导致OOM,而且线程数过多,频繁的上下文切换导致性能降低
如果用连接池的方式的话,虽然可以控制连接数和线程上下文切换带来的性能影响,但是如果连接请求很多,导致请求等待时间过长,用户体验是非常糟糕的。
在用户态层面是解决不了的,还是需要操作系统提供非阻塞IO的支持。
2.非阻塞IO
多路复用
什么是多路复用
之前的阻塞IO因为服务端连接和读取数据时两个方法的相互阻塞,不能使服务端很好的工作,就使用了多线程技术,一个客户端连接其实对应一个线程。
假设操作系统提供了一个非阻塞的IO read() 函数,如果没有数据到达网卡和拷贝到内核缓冲区,立刻返回-1。而且连接也不会阻塞。那么我们能不能实现让一个线程管理多个客户端连接,并且可以和多个客户端进行数据交互。这其实就是IO多路复用。
举一个小例子加深一下理解:模拟一个服务器处理100个客户端请求
假设你是幼儿园的校长,管理100个小孩,小孩可能会想上厕所。
方式一:不断按顺序询问小孩要不要上厕所,要上厕所的就带去上厕所,这个过程中会卡住,其他的小朋友如果要上厕所只能等待老师询问到自己 。 (阻塞IO)
方式二:你学会了分身术(新开线程),在讲台上询问所有小孩,谁要上厕所,有一个小孩要上厕所的话,你就分身一个老师出去带去上厕所。(阻塞io + 多线程技术)
方式三:你在讲台上看着小朋友,谁想上厕所了直接举手,你就带这个小孩去上厕所 。(IO 多路复用)
IO多路复用可以让一个线程管理多个客户端连接。
最简单的实现想法:改进一下前面的代码,NIO其实就已经实现了这种非阻塞方式。
// 使用 nio 来理解非阻塞模式, 单线程
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 非阻塞模式
// 2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
// 3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
SocketChannel sc = ssc.accept(); // 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null
if (sc != null) {
log.debug("connected... {}", sc);
sc.configureBlocking(false); // 非阻塞模式
channels.add(sc);
}
for (SocketChannel channel : channels) {
// 5. 接收客户端发送的数据
int read = channel.read(buffer);// 非阻塞,线程仍然会继续运行,如果没有读到数据,read 返回 0
if (read > 0) {
buffer.flip();
debugRead(buffer);
buffer.clear();
log.debug("after read...{}", channel);
}
}
}
非阻塞模式下,相关方法都会不会让线程暂停
-
在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行
-
SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
-
写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
这种方式好像一个线程就可以做到连接多个客户端,将客户端与服务端数据传输的channel 管理起来,循环读取里面的内容。
而且也可以结合线程池的技术。
但是也存在一些问题:
1. 但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu
2. 数据复制过程中,线程实际还是阻塞的
3. 这里的channel.read() 函数底层会调用一次用户态的read()函数,对于没有数据读取的channel,也会执行一次系统调用,开销非常大
能不能做到让线程在有连接和读取数据时非阻塞然后进行相应的处理,但是又在无连接和无数据读取时阻塞住,不浪费cpu资源?
需要操作系统提供相应的支持。
Linux 提供了select 操作。
select:把文件描述符的数组发给操作系统,操作系统遍历这个文件描述符数组,将准备就绪的文件描述符做上标记,返回标记的数量,用户层再遍历数组,现在已经打好标记了,无需进入内核态了。
Nio 依据这个操作系统的 select () 函数,搞了Selector类来实现。
Selector selector = Selector.open(); // 创建selector
一个selector绑定多个channel, channel 注册到selector 上
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, 绑定事件);
int count = selector.select(); // 阻塞住,直到绑定事件发生,返回发生绑定事件的 channel 数量
绑定的事件类型
-
connect - 客户端连接成功时触发
-
accept - 服务器端成功接受连接时触发
-
read - 数据可读入时触发
-
write - 数据可写出时触发
以服务器处理accecpt事件为例
@Slf4j
public class ChannelDemo6 {
public static void main(String[] args) {
try (ServerSocketChannel channel = ServerSocketChannel.open()) {
channel.bind(new InetSocketAddress(8080));
System.out.println(channel);
Selector selector = Selector.open();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int count = selector.select(); // 阻塞住,直到事件发生
// 获取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 遍历所有事件,逐一处理
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 判断事件类型
if (key.isAcceptable()) {
ServerSocketChannel c = (ServerSocketChannel) key.channel();
// 必须处理
SocketChannel sc = c.accept();
log.debug("{}", sc);
}
// 处理完毕,必须将事件移除
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
好处
- 一个线程配合 selector 就可以监控多个 channel 的事件,事件发生线程才去处理。避免非阻塞模式下所做无用功
- 让这个线程能够被充分利用
- 节约了线程的数量
- 减少了线程上下文切换
一个线程依靠操作系统的select() 方法实现 IO 多路复用,就可以起到很好的效果,再结合一下多线程技术,发挥多核cpu的威力!
分两组选择器
- 单线程配一个选择器,专门处理 accept 事件
- 创建 cpu 核心数的线程,每个线程配一个选择器,轮流处理 read 事件
public class ChannelDemo7 {
public static void main(String[] args) throws IOException {
new BossEventLoop().register();
}
@Slf4j
static class BossEventLoop implements Runnable {
private Selector boss;
private WorkerEventLoop[] workers;
private volatile boolean start = false;
AtomicInteger index = new AtomicInteger();
public void register() throws IOException {
if (!start) {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
boss = Selector.open();
SelectionKey ssckey = ssc.register(boss, 0, null);
ssckey.interestOps(SelectionKey.OP_ACCEPT);
workers = initEventLoops();
new Thread(this, "boss").start();
log.debug("boss start...");
start = true;
}
}
public WorkerEventLoop[] initEventLoops() {
// EventLoop[] eventLoops = new EventLoop[Runtime.getRuntime().availableProcessors()];
WorkerEventLoop[] workerEventLoops = new WorkerEventLoop[2];
for (int i = 0; i < workerEventLoops.length; i++) {
workerEventLoops[i] = new WorkerEventLoop(i);
}
return workerEventLoops;
}
@Override
public void run() {
while (true) {
try {
boss.select();
Iterator<SelectionKey> iter = boss.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isAcceptable()) {
ServerSocketChannel c = (ServerSocketChannel) key.channel();
SocketChannel sc = c.accept();
sc.configureBlocking(false);
log.debug("{} connected", sc.getRemoteAddress());
workers[index.getAndIncrement() % workers.length].register(sc);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
@Slf4j
static class WorkerEventLoop implements Runnable {
private Selector worker;
private volatile boolean start = false;
private int index;
private final ConcurrentLinkedQueue<Runnable> tasks = new ConcurrentLinkedQueue<>();
public WorkerEventLoop(int index) {
this.index = index;
}
public void register(SocketChannel sc) throws IOException {
if (!start) {
worker = Selector.open();
new Thread(this, "worker-" + index).start();
start = true;
}
tasks.add(() -> {
try {
SelectionKey sckey = sc.register(worker, 0, null);
sckey.interestOps(SelectionKey.OP_READ);
worker.selectNow();
} catch (IOException e) {
e.printStackTrace();
}
});
worker.wakeup();
}
@Override
public void run() {
while (true) {
try {
worker.select();
Runnable task = tasks.poll();
if (task != null) {
task.run();
}
Set<SelectionKey> keys = worker.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(128);
try {
int read = sc.read(buffer);
if (read == -1) {
key.cancel();
sc.close();
} else {
buffer.flip();
log.debug("{} message:", sc.getRemoteAddress());
debugAll(buffer);
}
} catch (IOException e) {
e.printStackTrace();
key.cancel();
sc.close();
}
}
iter.remove();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}