只看了服务端的线程结构,客户端还没看,不知道是不是一样,所以这篇就叫服务端线程模型吧,以后看了客户端再修改。术语和第二篇一样,不重复写了。
1. Netty与Reactor的关系
Netty是Reactor模型的实现,有关Reactor有一张经典的图:
通过前面的分析,大概能知道:
① main reactor即NioServerBoss,由它的selector负责监听端口,注册连接事件,并处理accept,充当上面的acceptor的作用,main reactor用到的线程池这个图中没画出来,这个boss线程池发挥的作用不是很重要,主要是IOT。
② sub reactor即NioWorker,也是由它的selector充当reactor的作用,构造NioServerSocketChannelFactory时传入的executor即上图的thread pool,worker threads由executor负责管理生命周期。
③ queued tasks所属的队列即NioServerBoss和NioWorker的taskQueue,selector和taskQueue是protected的,即每个NioServerBoss和NioWorker都会持有一个。
④ 通过第三点,可以看出Netty实际的结构是一个main reactor,多个sub reactor(由线程池线程数目配置决定)。
2. worker与IOT的关系
worker---taskQueue/selector---IOT
一个worker持有一个taskQueue和selector,而IOT的主要逻辑就是processTaskQueue和process,即分别处理poll taskQueue,执行任务,以及操作selector的selection key,对触发的事件进行处理。所以它们之间实际上是通过taskQueue和selector产生联系的。并且每个NioWorker一个taskQueue和selector,可以避免多个worker争用同一个taskQueue和selector的情况,也就避免了做同步(虽然selector是线程安全的,但是selection key不是,如果多个worker共用则无法避免做同步)
3. 线程启动流程
① 前面2篇提到在new NioServerSocketChannelFactory时,启动了boss和IOT。通过看源码,IOT实际上是在每次new NioWorker时,通过传入的work executor,启动了一个IOT,然后该IOT开始循环处理taskQueue
和selector,当一组NioWorker构造完成时,一组IOT也一起启动了。
② worker构造完成后,当NioServerBoss accept到新连接请求时,boss需要负责构造新channel,并分配给某一个worker。具体做法是accept时,从WorkerPool中取一个worker来new channel,取的时候是通过模算法来取的:
public E nextWorker() {
return (E) workers[Math.abs(workerIndex.getAndIncrement() % workers.length)];
}
之前看到这里的时候,忽略了取模操作,所以感觉好像channel和worker的关系是一对一,即一个channel一个线程,当时看到这里怎么也没想通,因为明明使用的是nio模式,不可能一对一的,后来细看才发现原来看丢了。。。。
取模操作产生的效果就是在new channel时,依次从数组里取worker,然后将channel绑定到该worker上,所以实际上,channel和worker的关系是多对一,channel是以worker分组的,一个worker负责一组channel的IO。
netty使用多个sub reactor去分别分组处理channel,我理解的这样设计的好处:
1) 首先就是前面提到的不用同步,避免多个IOT争用一个sub reactor。
2) 可扩展性更灵活,因为如果用多个IOT,一个taskQueue或selector,必然会用竞争,当服务端需要更多IOT来处理高并发的时候,IOT越多,竞争越激烈,竞争的开销远远超过多IOT带来的性能提升,所以虽然理论上多IOT,单taskQueue或selector也具备扩展性,但是实际情况下并没有可操作性。
③ 获取worker后,使用worker,acceptedSocket和NioServerSocketChannel去new NioAcceptedSocketChannel,将它们绑定到新channel(该channel可以理解为与物理连接对应的、位于netty层的逻辑连接)。然后投递一个注册interest事件的任务到worker.taskQueue上,由IOT负责poll并执行。
4. 由线程模型带来的特性
① messageReceived太耗时造成IOT无响应
服务端select到OP_READ后,读数据,然后fireMessageReceived,因此messageReceived方法是IOT调用的,当其中的操作太耗时时(比如大量发送消息,IOT会逐条加锁channel.writeLock,循环发送消息,发完才返回进入下一个IOT循环),IOT忙于处理messageReceived,不能响应select,导致所有绑定到该IOT的channel都无响应,所以耗时的操作必须另起业务线程。
② 业务线程(用户线程,UT)大量发送消息
因为另起了业务线程,IOT可以及时返回以进行下一次processTaskQueue和process。由于写的数据和写的操作分离的设计(writeFromUserCode中先offer到writeBufferQueue,再投递channel.writeTask给IOT),造成当UT投递writeTask和offer数据的速度大于IOT的发送速度时(比如,绑定到同一IOT的channel太多,IOT忙于处理其它channel时),此时假设已有n条数据offer到了writeBufferQueue,当前正在执行第i个任务(i+1<n),第i+1个任务仍在taskQueue中(因为任务i还未处理完),使得writeTaskInTaskQueue未被置false(该标志表示可以继续投递writeTask,仅限writeTask,其它任务类型不清楚),i+1以后的任务无法投递进去,但是此时writeBufferQueue中已有n条数据,即相当于后面的写操作的数据先于写任务被offer,写的数据和写的任务并非一一对应,同时被投递的。在这种情况下,任务i会一直poll writeBufferQueue然后发送,直到writeBufferQueue为空(AbstractNioWorker.write0就是这样实现的),造成任务i”帮忙”把后面任务的数据都发了。然后执行任务i+1:加锁channel.writeLock--->poll writeBufferQueue为空,退出循环--->fireWriteComplete--->解锁channel.writeLock,该步骤实际上并无任何数据发送,但白白多锁了一次writeLock(还fire了一次complete事件,fire一次应该关系不大,暂不考虑)。并且任务i+2也会同样多加锁一次,直到任务n+1(在投递成功的writeTask数赶上offer的数据之前,这种状况会一直持续,在任务i执行完后,可能会有n以后的数据offer进去,这些新增的数据终究会在某些任务中被发送,但中间的任务应该始终还是会有些被“浪费”掉,可能呈现出间歇性的特征;如果要完全不浪费,即一条数据由对应的写任务发送,应该是在UT和IOT处理速度比较平衡的情况下才会发生吧)。在极端情况下,可能任务1就把后面所有任务的数据都发了,这样的话相当于后面的任务全都“废了”,并且前面的写任务“帮忙”发的数据条数越多,IOT不响应selector的概率越大(因为IOT被困在write0的循环里了,直到queue取空才会退出)。
这种情况是我在调试的时候发现的,然后手工干预线程调度,进一步确定的情况,但是在运行环境中会不会存在这种情况,我还不是很确定,目前想到的就是UT入队速度远大于IOT的处理速度时,这种情况我只能猜测是在IOT绑定的channel特别多时可能会发生(如果是linux,selector能处理的连接数有限制,只能是单个进程能打开的文件描述符个数上限值,默认好像是1024),不知道并发达到多大的时候会出现这种情况,不知道1024够不够资格去触发。。。所以大家看了第二点,帮我想想有没哪里没分析对。
我又想了一下:其实也不算白白多加一次锁和多fire一次,在理想情况下,write多少次就应该锁多少次writeLock,如果连这个都想省掉,是不是太夸张了,不知道这算不算一个问题,或者说有没有优化的必要和可能性,求指点啊。。。
本人辛苦分析、码字,请尊重他人劳动成果,转载不注明出处的诅咒你当一辈子一线搬砖工,嘿嘿~
欢迎讨论、指正~~
下篇预告:客户端连接流程分析