本章主要内容:
在这一小节,我们会学习什么是线程模型,Netty4使用的是什么线程模型,Netty4之前的版本使用的是什么样的线程模型。会更好地理解不同线程模型之间的优缺点。
其实仔细想一想,大家在实际生活中其实都使用了线程模型。为了更好的理解什么是线程模型,我们使用一个现实生活中的例子进行类比。
比如大家有一个饭店,厨房要做好饭菜然后送到顾客桌子上。一个顾客进来点了菜,你就需要告诉厨房要做什么菜。你的厨房可以使用不同的方式处理这些订单-每一种方式类似一个线程模型如何执行任务。
线程池的技术,解决了线程创建与销毁的系统开销,因为对于每一个新任务,它不需要去创建线程,只需要使用线程池中的空闲线程。但这也只解决了问题的一半,后面再详细讲解。
为什么不在所有情况下都使用多线程呢?毕竟现在有了ExecutorService这种线程池技术帮我们降低了创建回收线程的系统资源开销。
使用多线程除了创建回收线程的系统开销,还会带来其他的副作用,如资源管理,上下文切换等。随着线程数量和任务数量的增加,这些副作用就会越大。刚使用多线程的时候可能不会出现什么问题,但是到真正在大型系统中使用多线程就可能出现很大的问题。
除了上面说的这些技术限制和问题外,使用多线程技术另一个大问题就是项目或框架的维护成本。可以说应用的并发特性会导致项目复杂很多。总结起来就是很简单的一句话:编写多线程应用是一个很难的工作。我们能做什么来解决这些问题呢?因为实际项目中很多地方都需要多线程场景。我们来看看Netty是如何解决这些问题的。
EventLoop这个名字取的很形象。意思就是事件执行在一个循环中直到被终止。这种设计很适合网络框架,因为它需要在一个循环中为指定的连接执行事件逻辑。这并不是Netty新发明的,其他项目和框架很早以前就使用了这种设计。
Netty的事件循环代表是EventLoop接口。EventLoop继承了EventExecutor,EventExecutor继承了ScheduledExecutorService,也就是任务可以直接交给EventLoop来执行。EventExecutor的继承关系图如下。
因为EventLoop会指定给Channel,所以Channel的事件就可以交给EventLoop执行,类似通常使用ExecutorService执行多线程任务。
下面的代码展示了如何使用指定给Channel的EventLoop执行任务。
Channel ch = ...
Future> future = ch.eventLoop().execute(new Runnable() {
@Override
public void run() {
System.out.println("Run in the EventLoop");
}
});
直接使用
EventLoop的execute方法就可以执行线程任务了,并且不需要担心一些同步问题。因为一个Channel相关的任务都会在同一个线程里执行。这就是Netty需要的线程模型。
可以利用返回的Future来检查任务是否执行完成。
Channel channel = ...
Future>future = channel.eventLoop().submit(…);
if (future.isDone()) {
System.out.println("Task complete");
} else {
System.out.println("Task not complete yet");
}
还可以通过检查任务是否在
EventLoop中来确定任务是否将被直接执行,如下。
Channel ch = ...
if (ch.eventLoop().inEventLoop()) {
System.out.println("In the EventLoop");
} else {
System.out.println("Outside the EventLoop");
}
只有在确定没有其他
EventLoop使用线程池时再去关闭线程池,否则则会出现一些不明确的影响。
上一小节实现的线程模型非常强大,Netty就是使用它处理IO事件的,也就是触发Socket上的读写操作。这些读写操作是Java和底层操作系统提供的网络API的一部分。
下图展示了Netty的进出数据操作是如何在EventLoop上下文中执行的。如果执行线程已经绑定到EventLoop则操作就会直接执行;如果没有则会放到队列里,待EventLoop准备好后再执行。
具体处理什么事件取决于事件的性质。通常是将网络栈中的数据读或传输到应用中,其他时候则是方向相反的同样操作,例如将应用中的数据传输到网络栈中用来发送给对端。但并不只限用于这种类型,重要的是它的逻辑是比较通用和灵活的,可以用来处理各种各样的情况。
另外就是Netty并不是一直使用上面说的EventLoop线程模型。下一小节就会介绍Netty3使用的线程模型。这样也能帮助我们更容易理解Netty4的线程模型为什么更好。
Netty3的线程模型和Netty4的有些不同。Netty3只保证读操作事件在IO线程中执行。写操作事件是通过调用线程来处理的。听起来这也是个好主意但结果证实这很容易出错。因为它需要同步ChannelHandler来处理事件,因为它不保证同一时间只有一个线程去操作。一个Channel在同一时间写多次数据就会出现这种问题,例如,在同时在不同线程调用Channel.write(..)方法。
除了需要同步ChannelHandler的副作用,Netty3的线程模型另外一个副作用就是处理发送数据事件时会触发收到消息事件。这是很有可能的,例如,当你使用Channel.write(..)方法时出现了异常。这个时候,exceptionCaught事件就会产生并被触发。咋一看这貌似也不是个问题,不过exceptionCaught设计的是一个收到消息事件,这样就会出现问题。实际上问题就是你的代码在调用的业务线程里面执行,但是exceptioncaught事件要交给工作线程去处理。通常如果能正确处理也没什么问题,但是如果忘记交给工作线程,就会导致线程模型失效,可能会带来很多竞争条件,使用一个线程去处理收到消息事件就不再可行了。
当然Netty3的线程模型也就是优点的,在某些情况它有更低的延迟,毕竟读写操作是分在了不同线程,但是它带来的复杂性远远大于它的优点。事实上,大多数应用程序也不能明显看出延迟的差异,因为这还依赖一些其他因素:
方法 |
描述 |
newScheduledThreadPool(int corePoolSize) |
|
newScheduledThreadPool(int corePoolSize,ThreadFactorythreadFactory) |
创建指定线程数量的调度任务执行器 |
newSingleThreadScheduledExecutor() |
|
newSingleThreadScheduledExecutor(ThreadFactorythreadFactory) |
创建单个线程的调度任务执行器 |
ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
ScheduledFuture> future = executor.schedule((Runnable) () -> {
System.out.println("Now it is 60 seconds later");
}, 60, TimeUnit.SECONDS);
executor.shutdown();
可以看到使用
ScheduledExecutorService是很容易发起一个调度任务的。
Channel ch = ...
ScheduledFuture> future = ch.eventLoop().schedule(() -> System.out.println("Now its 60 seconds later"),
60, TimeUnit.SECONDS);
如上面的代码所示,60秒后将由
EventLoop去执行。
Channel ch = ...
ScheduledFuture> future = ch.eventLoop()
.scheduleAtFixedRate((Runnable) () -> System.out.println("Run every 60 seconds"),
60, 60, TimeUnit.SECONDS);
如果想取消任务,可以利用返回的
ScheduledFuture。ScheduledFuture提供了一些方法用于取消调度任务或者检查调度任务的状态。
ScheduledFuture> future = ch.eventLoop().scheduleAtFixedRate(..);
future.cancel(false);
更多
ScheduledExecutorService的相关API可以去查看JDK文档。