前言
在上一篇文章中,咱们介绍了单线程下多路复用器的使用。
在单线程的环境下,所有的socket都是被线性处理的,如果一个socket处理的时间很长,就会影响之后socket的处理。
为了提高网络IO的效率,必须要将多路复用器在多线程环境下应用起来。
本文首先提供一个简单的单线程多路复用器代码,然后慢慢将它向多线程迭代。
在这个过程中会出现两种不同的多线程解决方案——一个多路复用器版本和多个多路复用器版本。
然后会通过代码来说明哪种方案更好。
单线程下的工作模式
在单线程下的代码很简单,其实在上一篇文章中已经有了,这里把代码粘贴过来。
static ServerSocketChannel server = null;
static Selector selector = null;
static int port = 9090;
public static void main(String[] args) throws IOException {
serviceStart();
}
private static void serviceStart() throws IOException {
initServer();
listen();
}
private static void initServer() throws IOException {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
}
/*
服务端主程序
*/
private static void listen() throws IOException {
while(true) {
if (selector.select(50) > 0) {
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if(key.isAcceptable()) {
acceptHandler(key);
} else if(key.isReadable()) {
readHandler(key);
}
}
}
}
}
/*
服务端acceptHandler
*/
private static void acceptHandler(SelectionKey key) throws IOException {
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel client = channel.accept();
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
client.register(selector, SelectionKey.OP_READ, buffer);
}
/*
服务端readHandler
*/
private static void readHandler(SelectionKey key) throws IOException {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
while(true) {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while(buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
} else if (read < 0) {
client.close();
break;
} else {
break;
}
}
}
/*
服务端writeHandler
*/
private static void writeHandler(SelectionKey key) throws IOException {
System.out.println("write handler...");
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.flip();
while(buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
key.cancel();
client.close();
}
单线程下加入WriteHandler
在上面的代码中,server端的代码逻辑是接收到客户端的信息后直接将信息写回,也就是说read操作和write操作是在同一个函数中完成的,这会造成某个函数执行的时间变长。
更好的解决方式是将这个函数拆成更小的执行单元,并且交给多路复用器进行调度。
那么上面的代码会在下面这几个地方有修改
/*
服务端主程序
*/
private static void listen() throws IOException {
while(true) {
if (selector.select(50) > 0) {
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if(key.isAcceptable()) {
acceptHandler(key);
} else if(key.isReadable()) {
readHandler(key);
} else if (key.isWritable()) {
writeHandler(key)
}
}
}
}
}
/*
服务端readHandler
*/
private static void readHandler(SelectionKey key) throws IOException {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
while(true) {
read = client.read(buffer);
if (read > 0) {
client.register(selector, SelectionKey.OP_WRITE, buffer);
} else if (read < 0) {
client.close();
break;
} else {
break;
}
}
}
/*
服务端writeHandler
*/
private static void writeHandler(SelectionKey key) throws IOException {
System.out.println("write handler...");
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.flip();
while(buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
key.cancel();
client.close();
}
代码分析
上面这段代码的思路就是,在readHandler中并不做回写的操作,而是再向多路复用器注册一个回写的事件。
当多路复用器判定当前环境可以回写的时候,会调用writeHandler进行操作。
多线程下的工作模式
在单线程的环境下,上面的代码运行的很完美。
但是单线程意味着同一时刻只能处理一个socket,如果这个socket特别的费时,就会影响后面socket的处理。
所以为了有效的理由机器资源,用多线程势在必行。
单个多路复用器
在上面的代码中,readHandler和writeHandler是比较耗时的操作,理所当然应该抛到单独的线程中进行处理。
主线程就可以专注于接收新客户端并建立连接。
那么需要改动的代码如下所示:
/*
服务端readHandler
*/
private static void readHandler(SelectionKey key) {
new Thread(()-> {
try {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
while(true) {
read = client.read(buffer);
if (read > 0) {
client.register(selector, SelectionKey.OP_WRITE, buffer);
} else if (read < 0) {
client.close();
break;
} else {
break;
}
}
} catch (Exception e) {
System.out.println(e);
}
}).start();
}
/*
服务端writeHandler
*/
private static void writeHandler(SelectionKey key){
try{
new Thread(() -> {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.flip();
while(buffer.hasRemaining()) {
try {
client.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
buffer.clear();
key.cancel();
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}).start();
} catch (Exception e) {
System.out.println(e);
}
}
上面的代码把费时费力的读取和回写操作都委托给其他线程,主线程只需要集中在接纳新客户端即可。
但实际上,上面的代码是有瑕疵的,问题就出在了多线程打破了多路复用器的线性化处理。
按原本的线性化逻辑
服务器的代码流程如下
-
多路复用器一开始仅监听一个server socket。
-
然后客户端发来握手请求与服务端建立连接,server socket有状态更新。
-
多路复用器监听到server socket的状态有更新后,主程序调用acceptHandler来迎接新客户端。
-
在acceptHandler中,将新客户端对应的socket委托给多路复用器监听。
-
多路复用器如果监听到客户端的socket有状态更新,会调用readHandler进行处理。
-
在readHandler中,会将回写的事件注册进多路复用器,交给多路复用器来处理。
-
多路复用器判断一个socket可写状态只是判断SEND_QUEUE是否有空间,如果有空间,就会调用writeHandler进行写操作。
上面这些步骤由于是在单线程环境下执行的,所以先后顺序的固定的,不会有超出意料的事情发生。
多线程下的逻辑
如果将上面的代码逻辑加入到多线程的环境下,就会由很多意料外的事情发生。
比如,当多路复用器监听到客户端的socket有状态更新,会调用readHandler进行处理。
readHandler会重新开启一个线程,将有状态的socket从多路复用器的监听容器中取出来处理。
但是由于是多线程状态,readHandler重新开启的线程可能还没来得及处理这个socket,主线程就会再调用一次readHandler对这个socket进行处理。
也即是说,同一时刻可能有多个readHandler对socket进行处理,同理,writeHandler也有相同问题。
这就是多线程下的问题,当然也有解决办法,就是在处理这个socket前,在线程中手动地调用
key.cancel()
将这个socket从多路复用器的监听容器中取出,这样就不会重复调用了,事后再决定要不要将这个socket放回。
但这就很麻烦了。
多个多路复用器
上文中虽然实现了多路复用器的多线程版本,但问题显而易见。
每次处理有状态更新的socket都要先将它们从多路复用器的监听容器中取出,这样涉及到太多次的系统调用,用户态与内核态之间的切换严重影响应用程序的性能。
而这一切的罪魁祸首就是,多线程打破了多路复用器的线性处理。*
那么一个可行的思路就是——依然是多个线程,但是每个线程上都有一个多路复用器,每个多路复用器在自己的线程中是线性操作的。
这样就解决了上一个版本的问题,但又利用了多线程的优势。
代码实现
先来看代码实现:
public class MultiSelectorThreads {
public static void main(String[] args) {
SelectorThreadGroup selectorThreadGroup = new SelectorThreadGroup(3);
selectorThreadGroup.bind(9090);
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}
class SelectorThreadGroup {
SelectorThread[] selectorThreads;
AtomicInteger indexCtl = new AtomicInteger(0);
public SelectorThreadGroup(int num) {
this.selectorThreads = new SelectorThread[num];
for(int i = 0; i < num; i++) {
this.selectorThreads[i] = new SelectorThread(this);
new Thread(this.selectorThreads[i]).start();
}
}
public void bind(int port) {
ServerSocketChannel server = null;
try {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
register(server);
} catch (IOException e) {
e.printStackTrace();
}
}
public void register(Channel c) {
try {
int index = indexCtl.incrementAndGet() % selectorThreads.length;
SelectorThread chosenSelector = selectorThreads[index];
chosenSelector.queue.put(c);
chosenSelector.selector.wakeup();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class SelectorThread implements Runnable {
public Selector selector;
public LinkedBlockingQueue<Channel> queue;
private SelectorThreadGroup group;
public SelectorThread(SelectorThreadGroup group) {
try {
selector = Selector.open();
queue = new LinkedBlockingQueue<>();
this.group = group;
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
try {
while(true) {
int num = selector.select(); // blocking
if (num > 0) {
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
while(iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isAcceptable()) {
acceptHandler(key);
} else if (key.isReadable()) {
readHandler(key);
}
}
}
// other task
while (!queue.isEmpty()) {
Channel c = queue.take();
if (c instanceof ServerSocketChannel) {
ServerSocketChannel server = (ServerSocketChannel) c;
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
} else if (c instanceof SocketChannel) {
SocketChannel client = (SocketChannel) c;
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
client.register(selector,SelectionKey.OP_READ, buffer);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
private void acceptHandler(SelectionKey key) {
try {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
group.register(client);
} catch (Exception e) {
e.printStackTrace();
}
}
private void readHandler(SelectionKey key) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
while(true) {
try {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while(buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
} else if (read < 0) {
client.close();
key.cancel();
break;
} else {
break;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
在这套代码中,需要注意两个类SelectorThreadGroup
和SelectorThread
。
就像之前我们说的,这套代码要实现的是多线程中如何应用多路复用器。
解决方案就是在每个线程中都有个独立的多路复用器。
那么SelectorThread就是每一个多路复用器的线程。
SelectorThreadGroup用来管理调度这些线程。
SelectorThreadGroup
多路复用器线程是独立运行的,但是需要有个统合调度它们的地方,这个地方就是SelectorThreadGroup。
这个类中我们需要注意几点
-
通过一个数组来管理所有的SelectorThread,而多路复用器的个数是可以自定义的。
-
在
register
方法中,通过“轮询”的方式将socket分配给不同的多路复用器。需要注意,将socket分配给多路复用器并不是直接在复用器上注册,而是将该socket添加到SelectorThread中的一个容器中,selector会自行去容器中处理这些socket。
-
本类同样负责生成server socket,委托给多路复用器监听的操作放在register方法中执行。
SelectorThread
就像之前我们说的,这套代码要实现的是多线程中如何应用多路复用器。
这就是代码中的SelectorThread。
在这个类中需要注意下面几点:
-
这个类中有个多路复用器的实例
selector
和一个线程安全的容器queue
。 -
selector就是当前线程的多路复用器。
-
当别的线程想向当前线程的多路复用器委托socket的时候,并不是直接调用 类似
socket.register(selector, ...)
的代码,而是通过将该socket放进queue中,让本线程自己来注册。这样做的原因是,
selector.select()
方法是阻塞方法, 所以如果复用器线程被这个方法阻塞住了,唤醒该线程的操作与注册socket的操作会因为不同线程执行顺序的不同而有差异。因此,将要注册的socket放到本线程的一个容器中,让本线程自己实现注册,就不会有线程执行顺序不可控的问题了。
-
复用器线程会一直监听复用器的状态,当复用器监听的socket没有状态更新的时候,整个线程会阻塞在selector.select()方法处。
一旦监听的socket有状态更新,select()方法就会返回,执行下面的代码。
根据socket的类型,调用accptHandler或者readHandler进行处理。
注意,在执行accpetHandler函数中,有一部需要把新的socket委托给某个多路复用器。
这个操作在本线程是完成不了的,需要通过上层代码,也就是SelectorThreadGroup,确定要将这个新socket委托给哪个多路复用器。
-
监听完多路复用器的状态后,就会去检查queue中有没有需要处理的socket,通过不同的socket类型调用不同的handler进行处理。
总结
本文从单线程的多路复用器代码开始,慢慢的向多线程环境演化。
首先尝试了单个多路复用器的多线程版本,发现这种方式打破了多路复用器线性的执行顺序,从而造成了线程间的合作出现问题。
然后提出了多个多路复用器的多线程版本,在这个版本中,每个多路复用器都在单一的线程中工作,这样既保证了多路复用器线性执的执行顺序,又利用了多线程的效率,一举两得。
而最后这种做法,正式netty的雏形。
下一篇文章会对netty进行初步的讲解。