本章主要内容:
- 线程模型的知识
- EventLoop
- 并发
- 任务执行器
- 任务定时执行
- 理解什么是线程并且有一定的使用经验。如果还没使用过线程的,最好去了解一下线程的基础,再来学习本章
- 理解多线程应用,包括线程安全方面的知识
- 最好理解并使用过java.util.concurrent包里面的ExecutorService和ScheduledExecutorService
一、线程模型
在这一小节,我们会学习什么是线程模型,Netty4使用的是什么线程模型,Netty4之前的版本使用的是什么样的线程模型。会更好地理解不同线程模型之间的优缺点。
其实仔细想一想,大家在实际生活中其实都使用了线程模型。为了更好的理解什么是线程模型,我们使用一个现实生活中的例子进行类比。
比如大家有一个饭店,厨房要做好饭菜然后送到顾客桌子上。一个顾客进来点了菜,你就需要告诉厨房要做什么菜。你的厨房可以使用不同的方式处理这些订单-每一种方式类似一个线程模型如何执行任务。
- 一个厨师一次只做一道菜,这类似单线程模型,也就是一个时间点只有一个任务在执行。这个任务完成了,再去执行下一个任务
- 多个厨师,哪个厨师没事做就去做客人点的菜。这类似与多线程模型,多个线程执行多个任务,也就是说那些任务可以同时执行
- 多个厨师,但是进行了分组,这组炒菜,这组煮面,那组蒸米饭。这也是多线程模型,不过有额外的限制。虽然多个任务也是同时执行,但是不同类型的任务分在了不同的组执行,如炒菜,面条,米饭
在学习更底层的知识之前,我们先通过其他大部分应用的做法来理解线程模型。
大多数现代应用程序都会使用多个线程来分发工作,因此可以有效的利用系统的资源。在早起的Java版本中,如果需要并行的处理任务,是通过创建线程来做到的。
但很快大家发现这种方式并不完美,因为创建线程然后回收资源是有不小的系统开销的。然后到了Java 5有了线程池技术,使用了同一个接口Executor。Java 5提供了很多线程池的实现,虽然它们的内部结构都不一样,但是它们的思想是一样的。当需要多线程执行任务时,线程池创建线程并尽可能重用线程。这样就可以尽可能降低创建与销毁线程的系统开销。
下图展示了线程池如何使用多线程执行任务的。任务提交之后由空闲线程去执行,任务执行完成之后,就会释放掉线程。
线程池的技术,解决了线程创建与销毁的系统开销,因为对于每一个新任务,它不需要去创建线程,只需要使用线程池中的空闲线程。但这也只解决了问题的一半,后面再详细讲解。
为什么不在所有情况下都使用多线程呢?毕竟现在有了ExecutorService这种线程池技术帮我们降低了创建回收线程的系统资源开销。
使用多线程除了创建回收线程的系统开销,还会带来其他的副作用,如资源管理,上下文切换等。随着线程数量和任务数量的增加,这些副作用就会越大。刚使用多线程的时候可能不会出现什么问题,但是到真正在大型系统中使用多线程就可能出现很大的问题。
除了上面说的这些技术限制和问题外,使用多线程技术另一个大问题就是项目或框架的维护成本。可以说应用的并发特性会导致项目复杂很多。总结起来就是很简单的一句话:编写多线程应用是一个很难的工作。我们能做什么来解决这些问题呢?因为实际项目中很多地方都需要多线程场景。我们来看看Netty是如何解决这些问题的。
二、EventLoop
EventLoop这个名字取的很形象。意思就是事件执行在一个循环中直到被终止。这种设计很适合网络框架,因为它需要在一个循环中为指定的连接执行事件逻辑。这并不是Netty新发明的,其他项目和框架很早以前就使用了这种设计。
Netty的事件循环代表是EventLoop接口。EventLoop继承了EventExecutor,EventExecutor继承了ScheduledExecutorService,也就是任务可以直接交给EventLoop来执行。EventExecutor的继承关系图如下。
因为EventLoop会指定给Channel,所以Channel的事件就可以交给EventLoop执行,类似通常使用ExecutorService执行多线程任务。
2.1、使用EventLoop
下面的代码展示了如何使用指定给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使用线程池时再去关闭线程池,否则则会出现一些不明确的影响。
2.2、Netty4的IO操作
上一小节实现的线程模型非常强大,Netty就是使用它处理IO事件的,也就是触发Socket上的读写操作。这些读写操作是Java和底层操作系统提供的网络API的一部分。
下图展示了Netty的进出数据操作是如何在EventLoop上下文中执行的。如果执行线程已经绑定到EventLoop则操作就会直接执行;如果没有则会放到队列里,待EventLoop准备好后再执行。
具体处理什么事件取决于事件的性质。通常是将网络栈中的数据读或传输到应用中,其他时候则是方向相反的同样操作,例如将应用中的数据传输到网络栈中用来发送给对端。但并不只限用于这种类型,重要的是它的逻辑是比较通用和灵活的,可以用来处理各种各样的情况。
另外就是Netty并不是一直使用上面说的EventLoop线程模型。下一小节就会介绍Netty3使用的线程模型。这样也能帮助我们更容易理解Netty4的线程模型为什么更好。
2.3、Netty3的IO操作
Netty3的线程模型和Netty4的有些不同。Netty3只保证读操作事件在IO线程中执行。写操作事件是通过调用线程来处理的。听起来这也是个好主意但结果证实这很容易出错。因为它需要同步ChannelHandler来处理事件,因为它不保证同一时间只有一个线程去操作。一个Channel在同一时间写多次数据就会出现这种问题,例如,在同时在不同线程调用Channel.write(..)方法。
除了需要同步ChannelHandler的副作用,Netty3的线程模型另外一个副作用就是处理发送数据事件时会触发收到消息事件。这是很有可能的,例如,当你使用Channel.write(..)方法时出现了异常。这个时候,exceptionCaught事件就会产生并被触发。咋一看这貌似也不是个问题,不过exceptionCaught设计的是一个收到消息事件,这样就会出现问题。实际上问题就是你的代码在调用的业务线程里面执行,但是exceptioncaught事件要交给工作线程去处理。通常如果能正确处理也没什么问题,但是如果忘记交给工作线程,就会导致线程模型失效,可能会带来很多竞争条件,使用一个线程去处理收到消息事件就不再可行了。
当然Netty3的线程模型也就是优点的,在某些情况它有更低的延迟,毕竟读写操作是分在了不同线程,但是它带来的复杂性远远大于它的优点。事实上,大多数应用程序也不能明显看出延迟的差异,因为这还依赖一些其他因素:
- 网络速度
- 实际的I/O线程是否繁忙
- 上下文切换
- 锁
2.4、Netty线程模型详情
三、延迟执行的调度任务
3.1、Java API执行调度任务
方法 | 描述 |
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是很容易发起一个调度任务的。
3.2、使用EventLoop发起调度任务
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文档。
3.3、调度任务的内部实现
- 创建一个调度任务
- 将调度任务插入EventLoop的任务执行队列
- 任务需要执行时由EventLoop去检查
- 检查通过则任务会立即执行并将任务从队列中移除
- 检查不通过等待下次再执行
四、I/O线程分配详情
五、总结