网络通信IO的演变过程(二)(一个门外汉的理解)
2.NIO
当与别人谈论NIO时,一定要弄清楚别人说的NIO是指哪个含义?
NIO有2种含义:
1、NonBlocking IO,基于操作系统谈
2、Java New IO,基于Java谈
我们这里主要说的是NonBlocking IO
NonBlocking IO
基于上一篇文章https://www.cnblogs.com/1626ace/p/13193435.html,我了解到BIO的特点就是需要一个线程去处理一个客户端的连接。
因为系统调用accept和recvfrom是阻塞的,这是最大的问题。
随着技术的发展,Linux内核在2.6.27之后,支持非阻塞IO了。
Show me the code:
public class TestNio {
public static void main(String[] args) throws Exception {
LinkedList<SocketChannel> clients = new LinkedList<>();
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress(9090));
ss.configureBlocking(false); //设置这个socket系统调用为非阻塞的。
while(true) {
//接受客户端区域
//不停的接收客户端的连接
Thread.sleep(1000);
SocketChannel client = ss.accept(); //本方法因为设置了非阻塞,所以这里不会阻塞,如果JVM返回null,代表没收到连接;底层的返回是-1
if(client == null) {
System.out.println("null...");
} else {
client.configureBlocking(false);
int port = client.socket().getPort();
System.out.println("client---port:" + port);
clients.add(client);
}
ByteBuffer buffer = ByteBuffer.allocate(4096);
//数据读取区域
//循环已经连接进来的client能否读取数据
for(SocketChannel c : clients) {
int num = c.read(buffer);
if (num > 0) {
buffer.flip();
byte[] aaa = new byte[buffer.limit()];
buffer.get(aaa);
String b = new String(aaa);
System.out.println(c.socket().getPort() + ":" + b);
buffer.clear();
}
}
}
}
}
主要特点:
NonBlockingIO主要是在accept()和read()俩方法不阻塞了。
这就是最大的区别。当客户端不连接,或者不输入,那么方法立即返回,得到-1或null.
一个线程在NIO模型里面就可以处理很多很多的客户端线程,不会存在浪费线程内存的情况。
底层系统调用伪代码:
socket()=3 返回文件描述符3
bind(3, 9090) 把socket和9090端口绑定起来
listen(3) 监听FD 3
while(true) {
accept(3) 有两种返回:5 或者 -1. 返回5表示有客户端连接上来了,客户端的socket文件描述符为5.返回-1表示没有客户端连入。
5.nonBlocking = true; 把客户端文件描述符(file descriptor)5的socket设置为非阻塞
for(Client client : allClient) {
read(client); 读取客户端,因为设置了客户端的socket也为非阻塞,所以这里依然不会阻塞住
}
}
这个nio的strace这里就不赘述了,可以参照BIO来做测试。
NIO的优势:规避了多线程问题。因为它可以一个线程处理很多的客户端连接与输入。
NIO的劣势:假设有一万个客户端连入,只有一个客户端有输入,那么服务端需要循环一万次,并且每次都需要调用recvFrom()系统调用,才知道这一个客户端在输入,然后获取它的输入。因为系统调用的开销会存在用户态到内核态的转换,CPU利用率不会高。所以需要解决。
这里问题的关键是这个循环【for(Client client : allClient) 】的复杂度是O(n),每次循环只能问一个客户端是否发来数据。
所以需要解决这个循环的复杂度。
这个解决方案就是“多路复用”。
简单理解多路复用:
基于上述问题的描述,手里拿着10000个客户端,我把这10000个客户端全部传给内核,内核返回告诉我这里面有3个客户端准备好了,可以读取数据了,那么我单独循环这3个客户端去读取数据。
时间复杂度瞬间变为:询问O(1) + 读取O(M),M为已准备发送数据的客户端。
周老师讲课笔记图:
3.多路复用器
为啥叫多路复用呢?
我乱写的:基于NIO的弊端,每次问内核10000次【一次问一条路】,实在太慢,那么一次就问内核10000个客户端【一次问多条路】,一条路复用了以前的一万条路。所以叫做多路复用。
我们的多路复用器讨论分为2类,第一类是poll/select,第二类是epoll
3.1 poll/select
随着Linux内核的发展,内核支持多路复用,下面简单写一个伪代码表示底层系统调用过程:
底层系统调用伪代码:
socket()=3 返回文件描述符3
bind(3, 9090) 把socket和9090端口绑定起来
listen(3) 监听FD 3
while(true) {
select(fds);或poll(fds); //把所有的fd全部全给内核,内核帮我们遍历,返回给我们那些状态变化了的FDs(file descriptor),时间复杂度为O(1)
accept(fd); //接受那些准备连接的客户端
recvFrom(fd); //读取那些准备写入的客户端
}
周老师讲课笔记图:
select和poll的劣势老师已经说得很清楚:
1、每次select这个系统调用都需要传递所有的fds,浪费,损失效率
2、每次select这个系统调用执行时,都需要遍历所有的fd
基于此,epoll出现来解决它~
3.2 epoll
Linux系统就是用epoll,这个多路复用器是用得最多的。
在使用多路复用器的时候,程序关注的是IO状态。
epoll基本理论
解决select和poll的两个问题:
1.在内核开辟空间,使每次系统调用的时候,不传递全量的fds
2.通过epoll_ctl,让内核在cpu工作时,把IO状态变化的fd放入到ready区,用户程序一旦调用epoll_wait,则直接获取到那些fd。最优的时间复杂度变为O(1)
Linux操作系统中的epoll系统调用API:
~# man 2 epoll_create //在内核空间创建一个空间epfd,以便存储需要监听的fd
~# man 2 epoll_ctl //往上面创建的这个epfd中放入需要监听的fd,并设置关注IO变化的事件
~# man 2 epoll_wait //用户调用内核,直接拿走IO状态改变了的fd
epoll_create:
EPOLL_CREATE(2) Linux Programmer’s Manual EPOLL_CREATE(2)
NAME
epoll_create, epoll_create1 - open an epoll file descriptor
SYNOPSIS
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
DESCRIPTION
epoll_create() creates an epoll "instance", requesting the kernel to allocate an event backing store
dimensioned for size descriptors. The size is not the maximum size of the backing store but just a
hint to the kernel about how to dimension internal structures. (Nowadays, size is ignored; see NOTES
below.)
epoll_ctl:
EPOLL_CTL(2) Linux Programmer’s Manual EPOLL_CTL(2)
NAME
epoll_ctl - control interface for an epoll descriptor
SYNOPSIS
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
DESCRIPTION
This system call performs control operations on the epoll instance referred to by the file descriptor
epfd. It requests that the operation op be performed for the target file descriptor, fd.
Valid values for the op argument are :
EPOLL_CTL_ADD
Register the target file descriptor fd on the epoll instance referred to by the file descrip-
tor epfd and associate the event event with the internal file linked to fd.
EPOLL_CTL_MOD
Change the event event associated with the target file descriptor fd.
EPOLL_CTL_DEL
Remove (deregister) the target file descriptor fd from the epoll instance referred to by epfd.
The event is ignored and can be NULL (but see BUGS below).
epoll_wait
EPOLL_WAIT(2) Linux Programmer’s Manual EPOLL_WAIT(2)
NAME
epoll_wait, epoll_pwait - wait for an I/O event on an epoll file descriptor
SYNOPSIS
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
DESCRIPTION
The epoll_wait() system call waits for events on the epoll instance referred to by the file descrip-
tor epfd. The memory area pointed to by events will contain the events that will be available for
the caller. Up to maxevents are returned by epoll_wait(). The maxevents argument must be greater
than zero.
epoll底层调用伪代码
socket()=3 返回socket文件描述符3
bind(3, 9090) 把socket和9090端口绑定起来
listen(3) 监听FD 3
epoll_create() = 7 通过epoll创建内核空间的fd存储空间:假设为7
epoll_ctl(7, ADD, 3, accept); 往空间7里添加对socket3,关注事件为accept。即客户端一旦连接到3这个socket,这个fd就被选中。
epoll_wait(); 用户程序调用内核(APP调用),获取哪些IO状态发生了变化。本方法是阻塞方法,但是可以传参代表超时时间,如阻塞500毫秒,超时返回-1.
epoll周老师笔记
epoll对应于java
public class SocketMultiplexingSingleThread {
private ServerSocketChannel server = null;
private Selector selector = null;
int port = 9090;
public void initServer() {
try {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
//实例server 约等于 listen状态的fd3
//得到的这个selector是JVM抽象的多路复用器,具体是select,poll,还是epoll 根据不同系统决定
//open等价于底层的epoll_create,在内核空间开辟了空间,准备存放所有的文件描述符fd
//假设这里epoll_create 返回的是 fd7, fd7代表的就是内核里面存放fd的空间,他自己也是一个fd,即fd7
selector = Selector.open();
//对select、poll来说,JVM开辟了空间,fd传进去
//对epoll,epoll_ctl(fd7, EPOLL_CTL_ADD, fd3, EPOLLIN) EPOLLIN既可能是客户端连接,也可能是数据到达
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (Exception e ) {
}
}
public void start() {
initServer();
try {
while(true) {
Set<SelectionKey> keys = selector.keys();
/**
* 调用多路复用器
* selector.select(500)
* 对select、poll来说,==内核的系统调用select(fd3) 、poll(fd3)
* 对epoll来说,==内核的系统调用epoll_wait(500)
*/
while(selector.select(500) > 0) {
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
//
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if(key.isAcceptable()) {
} else if(key.isReadable()) {
}
}
}
}
} catch (Exception e ) {
}
}
}
如果有兴趣,可以自己把代码贴进虚拟机,使用strace追踪底层调用。是非常清晰的。这里不再截屏。
4.Netty入门
基于3.2里epoll(JAVA)示例代码,其中的对象Selector selector
是需要同时处理客户端的连接和客户端数据的读取的。
假设有1000个客户端,
我们完全可以创建3个selector,
selector1 只处理接受客户端的连接。然后把连接依次,往下面2个selector里扔。
selector2 接受客户端的输入。只关注epoll_wait.
selector3 接受客户端的输入。只关注epoll_wait.
然后把selector1、2、3分别扔到3个线程里面去处理。
这就是netty的入门原理:(下面是netty官网的hello world)
https://netty.io/wiki/user-guide-for-4.x.html
public void run() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(5);
EventLoopGroup workerGroup = new NioEventLoopGroup(5);
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new DiscardServerHandler());
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);
// Bind and start to accept incoming connections.
ChannelFuture f = b.bind(port).sync();
// Wait until the server socket is closed.
// In this example, this does not happen, but you can do that to gracefully
// shut down your server.
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
- EventLoopGroup bossGroup = new NioEventLoopGroup(5); 代表的是启动5个线程来处理;
- EventLoopGroup workerGroup = new NioEventLoopGroup(5); 代表的是workerGroup来处理客户端的输入。
- b.group(bossGroup, workerGroup) 代表的是bossGroup来处理接受客户端的连接,workerGroup处理客户端输入。如果两个组分工处理,bossGroup的线程数取决于需要监听一个端口,new NioEventLoopGroup(5)不代表写了5就启动5个线程。
- 也可以这样:b.group(bossGroup, bossGroup)这样的话不是分工处理,启动的boss线程既处理客户端连接又处理客户端输入。
Netty这一小块说得很可能有问题,自己还没学过。所以请勿信任本文。我只是给自己此时的记忆做个记录。以后自会改正。
总结
至此,网络IO的演变过程超级粗略的学习完毕。虽然不是特深入,但是对自己理解BIO到epoll非常有帮助。
接下来希望趁热把Netty入个门。