传统的BIO网络编程模型(线程池)
传统的网络编程模型,以Tomcat为代表,服务端的ServerSocketChannel个数取决于线程池中线程的数量,如图所示。
这种编程模型,客户端的连接数受限于线程池中线程的数量。
Netty的网络编程模型
按照场景去理解Netty
服务端的工作流程:
- 阶段一:NioServerSocketChannel,实际是ServerSocketChannel注册到Selector多路复用器任务。
- 这个阶段会涉及两个线程:
- 主程序线程,通常是main:创建注册Selector的任务
- boss组中的nioEventLoop线程:执行上述注册任务,即register0。
- register0干了什么事?
-
k = ((AbstractSelector)sel).register(this, ops, att);
-
回调在AbstractBootstrap类中,向NioServerSocketChannel中的pipeline所添加的ChannelInitializer的initChannel方法。在回调过程中,创建能接收客户端连接的对象任务。
2.1 注意: 这里为什么是回调?因为,p.addLast(new ChannelInitializer<Channel>() {...}
是创建对象时机,在main线程环境中;而回调initChannel方法,是执行时机,是在nioEventLoop线程环境中,这属于异步回调。
2.2 探究一下这里使用回调的必要性以及恰当性,或者说为什么没有在用到的时候再创建ChannelInitializer该对象? 要解释这个问题,就要说Netty设计的初衷:简化JDK的NIO编程,向使用者屏蔽底层的实现细节。而简化的方式,就是将向使用者提供参数方式,而Netty将这些参数应用到NIO编程的流程中。这里,为了将参数应用到流程中,就需要提前创建一些对象,比如ChannelInitializer,但是使用是在未来使用这些对象。而解决这个流程,显然使用回调的方式。
2.3 题外话,从4.0版本到4.1版本中,通过Netty处理回调的方式,不难发现,**优雅的代码都是不断通过重构演化而来。回调作为一个单独的过程,而不再是依附于channelRegister。
Netty4.0:
Netty4.1:
-
协调异步操作。
safeSetSuccess(promise);
以NioServerSocketChannel注册为例分析。
main线程和boss线程共同操作DefaultChannelPromise对象,不同的是main线程是读取被boss线程更新后的共享状态变量result,因此,你会发现result用volatile修饰。
这里是怎么协调的?
3.1 我们知道,Netty创建任务跟执行任务大多是异步的,即在不同的线程环境中。
3.2 Nio编程大体流程是,先将Channel注册到Selector上,然后再进行Channel的端口绑定。
3.3 当main线程向bossGrope的loop实例中新增一个任务后,如果boss组的该实例没有线程,就开始启动一个线程,由新启动的线程执行该任务。此时,main线程会继续往下执行。
由于线程不一致,会出现以下典型的case:
i :表示if (channelFuture.isDone())
s:表示safeSetSuccess(promise)
a : 表示addListener中的isDone()
之后解释时,都用标识代替。
case 1: 如果新线程启动很快且在main线程执行到i
之前,新线程已经开始执行到register0,且执行完s
。此时,main线程开始执行i
,结果为true(已经被新线程通过s
给共享变量result赋值完),直接doBind0
case 2: 如果新线程启动慢或者其他情况导致,并没有执行到s
,此时,main线程开始执行i
,结果为false,此时就需要向共享对象regFuture添加操作完成监听器,以便新线程通过s
能够通知到main线程,添加绑定端口任务。
case 3: 假如新线程没执行到s
,此时main线程执行到i
,main线程开始走添加操作完成监听器。假设,这时,新线程执行完s
但此时的main线程还没向共享对象regFuture=DefaultChannelPromise添加监听器。换句话说,新线程执行完s
也并没通知到main线程,因为此时的共享对象中的listeners并没有由main触发添加的监听器。所以,在addListener中,还需要一个额外的操作a
来完成自己触发操作完成的动作。 -
注册事件
- 这个阶段会涉及两个线程:
二:执行main线程所创建的boss组能接收客户端连接的对象任务
当第一阶段的任务执行完后,执行创建接收客户端连接的对象任务
三:当二中的任务执行完后,执行channel绑定端口任务。
在这一阶段,实际绑定完端口后,创建能将注册Channel时的操作位由0变更为16的任务。
@Override
public final void bind(final SocketAddress localAddress, final ChannelPromise promise) {
assertEventLoop();
if (!promise.setUncancellable() || !ensureOpen(promise)) {
return;
}
// See: https://github.com/netty/netty/issues/576
if (Boolean.TRUE.equals(config().getOption(ChannelOption.SO_BROADCAST)) &&
localAddress instanceof InetSocketAddress &&
!((InetSocketAddress) localAddress).getAddress().isAnyLocalAddress() &&
!PlatformDependent.isWindows() && !PlatformDependent.maybeSuperUser()) {
// Warn a user about the fact that a non-root user can't receive a
// broadcast packet on *nix if the socket is bound on non-wildcard address.
logger.warn(
"A non-root user can't receive a broadcast packet if the socket " +
"is not bound to a wildcard address; binding to a non-wildcard " +
"address (" + localAddress + ") anyway as requested.");
}
boolean wasActive = isActive();
try {
doBind(localAddress);
} catch (Throwable t) {
safeSetFailure(promise, t);
closeIfClosed();
return;
}
if (!wasActive && isActive()) {
invokeLater(new Runnable() {
@Override
public void run() {
// 触发操作位变更
pipeline.fireChannelActive();
}
});
}
safeSetSuccess(promise);
}
四:执行变更channel操作位任务
当执行完操作位变更的任务后,此时服务端具备了能接收客户端连接请求,TCP通道的建立。
JDK原生的NIO编程
Netty在原生NIO编程的改进
- 线程与服务类(NioEventLoop)分开。这里说的分开指的是,NioEventLoop本身并不是一个线程(依据就是没有extend Thread),但是它持有线程。
- 这种设计方式,比之前单纯继承一个Thread,稍微要复杂点。因为每次使用该实例时,都要判断当前线程是否是该实例所持有的线程。 而且,还要保证实例中的操作都是顺序的,不能受线程的影响,为此,Netty为每个实例增加了一个任务队列,所有顺序的操作转变成任务队列以及线程间的通信(共享变量【状态变量】,一个写,一个读,事件通知等)。
- 那么,这种设计方式有什么好处?将线程的作用域缩小到操作动作,方法级别上,而不再是整个实例类,充分利用多核CPU。
- Netty对于我们来说,之所以使用简单,是因为内部统一了对象。客户端套接字的编程跟服务端套接字的编程做了抽象统一,比如AbstractNioChannel,因此,我们会发现,服务端编程和客户端编程基本相同。
- 为了实现这种统一,Netty都做了哪些事情?
- 套接字统一。我们知道jdk提供了ServerSocket和Socket这两种套接字,与之对应的ServerSocketChannel和SocketChannel,这是两套体系,即客户端编程和服务端编程。而Netty通过AbstractNioChannel的SelectableChannel将ServerSocketChannel和SocketChannel做到了有机统一,套接字模型统一。更重要的是,这里说明“少用继承,多用组合”的原则很重要,让功能更强大,这里的功能是面向使用人员,在开发服务端或者客户端编程时,十分简单,即传参就可以。
- 流程统一。NioServerSocketChannel注册到Selector上跟NioSocketChannel注册到Selector上的过程一样,Netty将这个过程进行了统一,即AbstractUnsafe中的register0。(题外话,有些小伙伴一直纠结方法命名的问题,这里说一下。在doRegister()的上一级方法的命名是register0,而register0方法的上一级是register方法。)
- 操作即任务,任务统一。Netty将所有的操作或者动作转换成可执行的任务。
- 为了实现这种统一,Netty都做了哪些事情?