作者:范平
上海华瑞银行数字银行开发中心软件工程师
目前负责华瑞银行移动银行、融资中台开发工作。
本篇文章对NIO非阻塞IO在日常web容器中的使用分析,会从IO模型、Java的NIO包、Socket网络访问原理和web容器的常见核心NIO模型Reactor几方面循序渐进的进行一个讲解,阅读本篇文章需要对linux操作系统和网络有一定了解。
1. IO模型
同步阻塞IO
用户线程发起IO请求到IO操作结束,用户线程会被一直挂起,下面是linux recvfrom的函数接口。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
recvfrom函数是阻塞的,其作用是从指定的socket fd中,将数据读入到buffer里;在recvfrom的调用过程中,完成了kenel准备数据和从kernel复制数据到用户空间的过程。
同步非阻塞
此场景中socket设置为非阻塞的(通过fcntl方法),调用recvfrom函数会返回EWOULDBLOCK,暗示kernel仍在准备数据中
用户线程发起IO请求后会立即获得数据是否就绪的状态返回;在查询IO是否就绪的间隔,用户线程可以处理别的任务,其优势在于
使用较少的线程管理多个连接,减少内存管理和上下文切换所带来的开销
线程可以复用
IO复用模型
常用函数:select(), poll(), epoll()
在一个线程中使用select()或 poll()或epoll()函数,来轮询多个fd(e.g. socket)的就绪情况,如果有fd准备就绪,则返回,否则该线程会阻塞等待有fd就绪直到超时。就绪的fd,既可以放在当前现场处理,也可以创建线程池来处理。
这种使用单线程来检测多个fd就绪情况的机制就是多路复用,其优势在于
1)减少了线程数
2)减少了内存开销
3)减少了多线程之间上下文切换的开销
2. Java.NIO简介
Channel
Channel对象提供了对多种fd(socket, file等)写入/读取Buffer的实现
Buffer
主要属性:capacity, limit, position, mark
属性之间的关系:mark <= position <= limit <= capacity
// 构造函数
Buffer(int mark, int pos, int lim, int cap) {
if (cap < 0)
throw new IllegalArgumentException("Negative capacity: " + cap);
this.capacity = cap;
limit(lim);
position(pos);
if (mark >= 0) {
if (mark > pos)
throw new IllegalArgumentException("mark > position: ("
+ mark + " > " + pos + ")");
this.mark = mark;
}
}
Selector
Selector selector = Selector.open(); // 创建selector
channel.configureBlocking(false); // channel需要设置为非阻塞
SelectionKey key = channel.register(selector, SelectionKey.OP_ACCEPT|SelectionKey.OP_CONNE);
selector会轮询已注册的channel,更具注册时登记的SelectionKey判断是否触发对应事件
key | 功能 |
---|---|
OP_ACCEPT | 请求在接受新连接并创建Channel时获得通知 |
OP_CONNECT | 请求在建立一个连接时获得通知 |
OP_READ | 数据已经继续,请求可从Channel中读取数据时获得通知 |
OP_WRITE | 可以继续向Channel中写数据时,请求获取通知 |
// e.g.
socketChannle.register(selector, SelectionKey.OP_ACCEPT|SelectionKey.OP_CONNECT)
方法 | 功能 |
---|---|
open() | 新建一个selector |
keys() | 返回selectionKeys |
select() | 阻塞select操作 |
selectNow() | 非阻塞select操作 |
wakeup() | 在另外一个线程调用wakeup,被阻塞与select方法的线程就会立刻返回 |
3. Socket是如何工作的?
了解socket的工作原理,会有助于我们了解接下来的话题。
在服务端创建一个socketfd(socket函数),绑定该服务的socket地址信息(bind函数),再将该socketfd转化为listenfd(listen函数)。这三个步骤是一般socket编程中所常见的。
当一个connection建立后,会获得一个connfd(accept函数),主进程创建子进程来处理该connfd的请求,伪代码如下:
pid_t pid;
int listenfd, connfd;
listenfd = socket(...); // 创建socket
bind(listenfd, ...); // 将该服务的socket地址信息绑定到listenfd上
listen(listenfd, ...); // 转化为listenfd
for ( ; ; ) {
connfd = accept(listenfd, ...); // 阻塞至连接建立
if ( (pid = fork()) == 0 ) { // 子进程部分
close(listenfd); // 关闭listenfd
/*** 处理请求 ***/
close(connfd); // 子进程关闭connfd,该fd引用计数减1
exit(0); // 关闭子进程
}
close(connfd); // 父进程关闭connfd,该fd应用计数减1
}
}
更近一步去优化这个过程。我们更倾向使用线程的方式来管理和调度。
master线程专注于监听连接请求;buffer用于缓存那些tcp三次握手已经established的socketfd;每个worker线程将其需要处理的socketfd从buffer中取出(移除),调用accept函数生成对应的connfd和clientfd这对fd,即打通了一条两端均为socketfd的网络连接通道。每个work线程通过这条通道来实现对各自client的服务。
4. Reactor模式
Reactor单线程模型
Reactor单线程模型提供了一种解决思路。
步骤一:开启Reactor主线程监听服务端端口;创建serverSocketChannel,并注册该channel到Selector中,关注OP_ACCEPT事件;selector的注册函数返回一个selectionKey, 设置selectionKey的attachment为Acceptor对象;
步骤二:Selector轮询已注册的channel(目前只有一个serverSocketChannel),当listenfd(即serverSocketChannel的endpoint)准备好接收一个新的连接时,selector便会轮询到该channel所绑定的key,这个时候Reactor线程会调起之前绑定在这个key上的attachment(即Acceptor对象)
步骤三:Accept对象将socketChannel注册到selector中,一般在这个阶段关注的事件为OP_READ或OP_WRITE事件,即当读或写就绪时,即可唤起一个线程来处理之
其中Reactor线程专注于listenfd用于监听客户端的connection请求;Acceptor线程被调起来之后会创建connfd(用于服务端和客户端搭建connection)。需要注意的是Acceptor线程是被动创建的,当有serverSocketChannel触发了OP_ACCEPT事件或socketChannel触发了OP_READ/OP_WRITE事件之后,才会由Reactor线程调用。Acceptor线程通过创建线程来处理对应请求,e.g.读取报文 => 处理 => 响应请求。
Reactor线程池模型
Reactor线程池模型和单线程模型的主要区别在于前者将对非IO的处理交给了线程池,其优势在于加快了React线程的处理速度。因为在单线程模型中从socket读取数据之后,必须等处理完成之后,才可以将该channel的interestSet从OP_READ变更为OP_WRITE;而在多线程模型中处理非IO的过程被丢给了thread pool,当处理结束之后由thread pool分配的线程变更interestSet即可,从而缩短了阻塞的时间。
Reactor主从模型
在高并发的场景下,Reactor主从模型可以充分利用cpu核心数提升并发能力。Main Reactor线程专注于监听客户端的连接请求,并通过Main Acceptor线程分发OP_ACCEPT就绪的连接。
比较来看,之前的两个模型都只有一个Selector,所有的OP_ACCEPT, OP_CONNECT, OP_READ和OP_WRITE都归这一个Selector去轮询;而主从模型中,Main Reactor线程持有的Selector中只关注OP_ACCEPT这一种selectionKey,其他SubReactor线程持有的Selector则管理注册到各自的serverChannel的interestSet事件的触发。因此Main Acceptor需要维护各个Sub Reactor与所持有Selector的关系。
总结
Reactor模型将IO的处理和非IO的处理剥离开来,使IO事件可以更加及时得注册和分派,提升了事件驱动的效率。按照网络连接工作的特性:在接受请求阶段,将OP_ACCEPT单独监听并配合selector的使用,使单个线程不用阻塞在accept()中,而是线程直接被分配一个就绪的socketf;在处理请求阶段,IO线程在OP_READ触发后,通过channel将都就绪的socketfd指向的信息读入buffer,将报文信息交给线程池分配的一个线程来处理一些非IO的操作,此时刚才的IO线程又可以去处理别的IO事件了,当处理非IO的线程完成任务后,注册OP_WRITE给selector并由selector指派另一个空闲的IO线程将响应报文写给socketfd。
总体来说Reactor模式为web服务提供了一套按照网络连接、IO操作定制化的多线程时序控制逻辑。