Socket与IO
I/O模型
概述
- 输入操作包括两个阶段1.等待数据准备好2.从内核向进程复制数据
- 比如Socket套接字的输入操作,第一步等级数据从网络中送达,数据到达后被复制到内核中的缓冲区。第二部,数据从内核缓冲区复制到应用进程缓冲区。
- UNIX有五种I/O模型
- 阻塞I/O
- 非阻塞I/O
- I/O复用:select和poll
- 信号驱动式I/O:SIGIO
- 异步I/O:AIO
阻塞I/O
-
概述:应用进程被阻塞,直到数据从内核缓冲区复制到引用缓冲区才返回,注意这个阻塞的进程是不会响应中断的。如果想提高吞吐量,必须通过多线程。
-
同步阻塞
非阻塞I/O
-
概述:应用进程发出需要I/O数据的请求然后就做别的事去了,内核在等待数据,进程不断轮询(polling)来判断内核缓冲区数据是否准备完毕。因为要不断轮询,因此消耗大量CPU时间。
-
同步非阻塞
I/O复用
-
概述:如何让单线程获得处理多个I/O的能力呢?事件驱动I/O,和非阻塞相比优点是可以等待多个描述符。非阻塞是一个线程等待一个描述符,通过反复轮询确认内核是否准备好缓冲区数据,IO复用是一个线程等待多个描述符,只要有描述符就返回相应的IO数据。 方法有select,poll和epoll,在使用这些方法的时候是阻塞的。相较于多线程技术,IO复用开销更小,redis就使用了这种方式。
-
对比阻塞式IO,IO复用模型还需要多调用一次select,然后在select中阻塞,因此易用性上略显不足。但是IO复用最大的亮点是监听多个文件描述符,大大减小了阻塞线程的个数。
-
同步非阻塞(select层面上阻塞,但是应用进程不阻塞,即IO是非阻塞的(比如说read方法是不阻塞的,但是select是阻塞的),select是阻塞的)
信号驱动I/O
-
概述:开启套接字的信号驱动式IO功能,通过sigaction系统调用安装一个信号处理函数,当内核接收到数据时,sigaction这个异步方法会被执行,给引用线程发送SIGIO信号,然后应用线程从内核中读取数据。
-
同步非阻塞
异步I/O
-
概述:应用进程调用一个方法(aio_read)后就返回,等内核缓冲区的数据准备好后,内核将数据复制到应用进程缓冲区,完成这一步就通知应用程序。和信号驱动IO的区别是,信号驱动IO是由内核通知应用线程可以开始从内核缓冲区中复制了,而异步直接是把数据已经放进了应用进程的缓冲区里。
-
异步非阻塞
五大I/O模型比较
- 同步IO:将内核缓冲区数据复制到应用进程缓冲区这个操作是应用进程做的,就是同步。
- 异步IO:内核执行缓存区复制,就是异步.
- 阻塞:read方法阻塞
- 非阻塞:read方法立即返回
I/O复用
概述IO复用
- 在不用I/O复用的情况下,如何处理多请求:
- 主线程循环执行accept,等待客户端连接。
- 当客户端connect后,新建一个线程用于处理请求。当然也可以交给线程池去处理。
- 在用I/O服用的情况下。
- 主线程执行select,select中注册多个文件描述符,轮询这些文件描述符,如果发现connect,可以根据不同的策略执行,比如在select当前线程执行,新建线程或者交给线程池处理。
- 区别:如果有10000并发连接数,但是只有100个活动请求,如果用方案1就要创建100000个线程处理,如果是方案2就可以用1个线程处理,或者用少量线程处理。
select
int select(int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- maxfdp1表示待测试的描述符个数,也就是要轮询的数量,从0开始到maxfdp1-1。readset,writeset和exceptset指定我们要内核测试读,写和异常的描述符。这个应该相当于是向select进程中注册事件。
- 宏命令:FD_ISSET(int fd,fd_set *fdset)检查集合中指定的文件描述符是否可以读写。
- timeout参数告知内核等待所指定描述符中的任何一个就绪可花多少事件。可以指定s或者ms。该参数可以指定为NULL即永远等待下去,timeout等待一段固定时间,0根本不等待。****
- select线程遍历所有的readfds,writefds和exceptfds。重要的是了解fd_set的数据结构,他是类似bitmap的,FD_ZERO(&set)则set为00000000,若注册fd=1,fd=2择优set为00000011,若还有fd=5则00010011。 遍历结束后,会改写set的值,然后再遍历所有的fd,看fd对应的位是否为1,那对应的fd就是已经准备就绪的,因此要有两次遍历。
- 缺点
- 单个进程打开的fd有限值,FD_SETSIZE默认为1024,64位为2048。cat /proc/sys/fs/file-max
- fd_set返回时会被内核修改,因此每次调用都要重新修改,不够优雅
- 两次遍历,内核遍历一次查询注册的fd是否准备好缓冲区数据,用户再遍历一次查看自己的fd是否准备好,效率较低
- 需要维护存放大量fd的数据结构(xxxset),在内核态和用户态传递该结构开销大。
poll
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
nfds表示maxfdp1,就是待测试的描述符个数。fds结构是变程度数组,代替了之前所有的set,这个fds中定义了很多事件。- 与select的区别
- 没有最大连接数的限制。- poll的timeout事件精度是ms,select的pselect方法精度可以到us,一般的是s和ms。
- polldf中有一个events和一个revents,前者表示等待的事件,后者表示实际发生的事件,用户修改前者,内核修改后者,解决了每次调用时都要重新修改fd_set的问题,更加优雅
- select的timeout为null表示无线等待,poll是-1.
- poll可以监听更多事件,比如POLLIN,POLLOUT,POLLERR,POLLMSG等,在poll.h中有定义。
- select几乎所有系统都支持。
- 水平触发特点,当通知程序fd就绪后,如果本次未被处理,那下次poll的时候会再次通知同个fd已经就绪。水平触发的意思是,只要内核缓冲区就数据就通知fd,边沿触发的意思是,只有内核缓冲区接收到新数据请求才通知fd,区别是如果客户端没有接收完毕数据,水平触发第二次仍会通知,但是边沿触发要等到新的数据到来(中断)才通知。 select也支持水平触发
epoll
-
int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- epoll_create在内核专属epoll的高速cache区建立红黑树和就绪链表,用户传入的句柄将被放入红黑树。(全部传入,第一次拷贝) select监控的fd在用户态,然后拷贝fd到内核态,内核态处理完set后再返回用户,轮询N次复制N次,但是epoll只复制一次,之后就等待回调。
- epoll_ctl执行add动作时,将fd放入红黑树,向内核注册该fd的回调函数。当fd可读可写时调用回调函数,回调函数将fd放入就绪链表。
- epoll_wait是阻塞的,监控就绪链表,如果就绪链表有文件句柄,则表示该文件句柄可读可写,并返回到用户态。(这里只有少量拷贝)
- 由于内核不修改fd的位,因此只需要第一次传入就可以重复监控,直到epoll_ctl删除。
-
epoll是linux系统独有的,具有水平触发和边沿触发两种,上面已经解释过了。边沿触发的好处是有效减少了触发次数。
问题: -
为什么要用红黑树?红黑树用于存放socket,当ctl中添加的时候,可以通过红黑树快速查找是否存在,如果用线性表就是O(n)时间复杂度了。
-
为什么epoll看上去是异步调用,但是还说叫同步IO?因为从微观来看,epoll是异步的,但是对于epoll_wait方法是阻塞的,根据判断标准应用进程实现的缓存区拷贝,因此是同步。
Java中的NIO
组件1:Buffer
- NIO是面向缓冲区的,本质上是一块内存区域,核心属性是capacity,limit和position。capacity指的是当前缓冲区最大字节数,limit在写入时等于capacity,在读取时等于能读取的最大数据量,positon代表当前指针,读了多少就向后移动多少。
- 关键方法
ByteBuffer buffer = ByteBuffer.allocate(1024)
,分配容量为1024字节的缓冲区。channel.read(buffer)
从channel中读数据并写入bufferbuffer.put(byte[])
向buffer中写入字节channel.write(buffer)
向channel中写数据,即从buffer中读数据- flip 从写模式转换为读模式
- rewind,将position置为0,这样可以重复读取缓冲区数据
- clear和compact clear全部清除,compact保留还未读的缓冲区部分
- mark,reset mark可以标记当前position,然后reset回到标记的mark
组件2:Channel
- 有几个重要的实现
- FileChannel,这个类无法非阻塞读写
- DatagramChannel 用于UDP的读写
- SocketChannel 用于TCP的数据读写,客户端实现
- ServerSocketChannel 服务器实现,但是为了和客户端对接,因此还需要用SocketChannel接收和发送给客户端数据,ServerSocketChannel通过accept方法会返回一个SocketChannel对象,用语和客户端之间进行读写。
- 可以通过configureBlocking(false)设置为非阻塞,默认为阻塞。当设置为非阻塞的时候要注意,比如你channel.read(buffer),但是别人没发过来,因为是非阻塞,就直接返回执行后面的代码了,因此需要用while。这也引出了下面的问题:
- 如果用阻塞I/O,需要多线程(浪费内存),如果用非阻塞I/O,需要不断重试(耗费CPU)。Selector的出现解决了这尴尬的问题,非阻塞模式下,通过Selector,我们的线程只为已就绪的通道工作,不用盲目的重试了。比如,当所有通道都没有数据到达时,也就没有Read事件发生,我们的线程会在select()方法处被挂起,从而让出了CPU资源。
组件3:Selector
- 可以用较少的线程处理更多的通道。
- 要将channel注册到selector中,
ServerSocketChannel ssc = ServerSocketChannel.open();ssc.register(selector, SelectionKey.OP_ACCEPT);
事件可以选择如下的:Connect,Accept,Write,Read。SelectionKey.OP_CONNECT,OP_ACEPT,OP_READ,OP_WRITE。 - 一个SelectionKey表示某个通道对象和某个selector之间的注册关系。关键方法如下
- interestOps,获得key感兴趣的事件,返回值是一个掩码,和OP_XXX按位与后可以得到状态。
- readyOps,返回一个掩码,可以用于判断哪些状态ready了。但是一般用isWritable,isConnetable,isAcceptable等方法直接判断,这下方法内部就是用的readOops。
- key.channel(),key.selector()可以获取key上关联的通道和复用器。
- attach方法可以在key上附着一个对象,比如附着一个Buffer。
int nReady = selector.select();
可能会阻塞在此处,这也是遵循IO复用模型的。select()方法返回的int值表示有多少通道已经就绪,是自上次调用select()方法后有多少通道变成就绪状态。之前在select()调用时进入就绪的通道不会在本次调用中被记入,而在前一次select()调用进入就绪但现在已经不在处于就绪的通道也不会被记入。本质上用的是linux中epoll的边沿触发。Set<SelectionKey> keys = selector.selectedKeys();
可以获得所有已注册的健,然后用迭代器遍历这些健,判断状态,根据状态处理。
联系实际:I/O
基于Tomcat9.0.21
Tomcat的IO模型
- BIO:一个连接对应一个线程。(在tomcat8.5后被淘汰)
- NIO:一个请求对应一个线程。(多个长连接,有连接,不一定有请求,如果用BIO就会有大量闲置线程。)
- APR:Apache Portable Runtime。Apache可移植运行库,以JNI的形式调用阿帕奇HTTP服务器的核心动态链接库,调用系统底层的IO接口。(不通过java的nio包)
-
NIO概述:Tomcat的NIO是基于I/O复用模型。
可以发现,tomcat6以后利用java对read request body与write response使用了BIO,对read request headers和wait for next request实现了NIO。Tomcat8.5后全面取消BIO
- 为什么要这样设置?nio在网络不好的时候,处理http request head的时候根本不会不浪费socketProcesser线程去等待读(bio是阻塞的,nio直接继续丢到poller池轮询,就绪了再分配socketProcesser线程处理)。另外bio在处理长连接的时候天生就弱势,必须要浪费一个socketProcesser等待读下一次http请求,指导超时才会释放socket(然而tomcat一般默认200个socketProcesser线程,bio超过75%长连接后面就直接当短连接处理就知道bio对长连接处理多弱),而nio是poller处理TCP连接,有数据就绪就分配到线程池处理http;单线程优势啊,几乎无内核用户态切换,不浪费cpu。
-
(Tomcat6.x)执行过程:客户端连接到达 -> nio接收连接 -> nio使用轮询方式读取文本并且解析HTTP协议(单线程) -> 生成ServletRequest、ServletResponse,取出请求的Servlet -> 从线程池取出线程,并在该线程执行这个Servlet -> 把ServletResponse的内容发送到客户端连接 -> 关闭连接。
组件与框架概述
-
以下内容参考:
-
server对应多个service,service中具有connectors(多个connector)和一个contaioner,在tomcat9.x源码中,connector和engine持有service的引用,但是engine和connector之间并没有引用关系。
图中server表示tomcat启动的服务,service表示webapp,每个webapp都包括n组engine和connector,connector负责接收用户请求,engine是逻辑容器。host为主机名,因为虚拟主机的存在,因此host也可能有多个,context就是web.xml转化而来的,wrapper就是servlet。 -
组件详细介绍
- server 读取server.xml中的配置文件,将配置文件加载到Server的实现类StandardServer中。若关闭server,将关闭全部组件。包含方法有获取端口,地址,持有多个service引用。
- service Service接口实现类standardService,逻辑上持有Connector和Container。并且持有向server的引用。service和server是相互关联的,server向外提供访问这些service的接口
- connector,负责处理客户端发来的连接,默认协议有http,https和AJP。主要作用为根据不同请求解析客户端请求,将解析好的请求转发给connector关联的engine容器,即container。这个组件就是IO模型应用的地方,本质上是为了解决请求等待的问题。100000个长连接,不可能开那么多线程,那就开少量的selector线程轮询,将请求放到线程池中执行即可。
以下四个都继承自Container接口,都属于Container
manger用于管理session,resources对每个webaap对应部署结构的封装,loader对每个webapp自有的classloader的封装,mapper封装了请求资源uri和相对应处理wrapper容器的映射关系。
- 以上组件中的cluster用于tomcat管理,Realm实现用户权限管理,pipeline和value利用职责链模式处理pipeline上的各个value。
生命周期、启动、停止
-
tomcat中所有组件都继承LifeCycle接口,lifecycle的抽象实现类中有initt方法,如果当前状态为NEW,那么就将状态转换为INITIALIZING状态并进行初始化,初始化完毕后状态为INITIALIZED。
//LifeCycleBase.java //init方法 @Override public final synchronized void init() throws LifecycleException { if (!state.equals(LifecycleState.NEW)) { invalidTransition(Lifecycle.BEFORE_INIT_EVENT); } try { setStateInternal(LifecycleState.INITIALIZING, null, false); initInternal(); setStateInternal(LifecycleState.INITIALIZED, null, false); } catch (Throwable t) { handleSubClassException(t, "lifecycleBase.initFail", toString()); } } ``
-
以StandardServer为例,他的initInternal方法中调用类加载器加载jar包,这是双亲委派的,然后遍历services组件并init。service组件对其中的connectors和engine还有Executors进行init。
//StandardServer.java //initInternal方法 for (int i = 0; i < services.length; i++) { services[i].init(); }
-
init之后是start,以StandardServer为例,让内部的services调用start,以此类推。
//LifeCycleBase.java //start方法节选 try { setStateInternal(LifecycleState.STARTING_PREP, null, false); startInternal(); if (state.equals(LifecycleState.FAILED)) { // This is a 'controlled' failure. The component put itself into the // FAILED state so call stop() to complete the clean-up. stop(); } else if (!state.equals(LifecycleState.STARTING)) { // Shouldn't be necessary but acts as a check that sub-classes are // doing what they are supposed to. invalidTransition(Lifecycle.AFTER_START_EVENT); } else { setStateInternal(LifecycleState.STARTED, null, false); } }
-
小结:每个组件的生命周期是统一的,init->start->stop->destory,这是由LifeCycleBase统一通知的,每个组件单独实现initInternal、startInternal、stopInternal、destoryInternal,使用方法前先判断LifeCycle枚举类状态,然后根据状态执行不同的生命周期方法,执行完成后改变状态。
-
观察者设计模式。为什么要用观察者设计模式呢?多个观察者对象同时监听一个主体对象,当主体对象作出某种操作时,主体对象会通知观察者,然后观察者作出相应动作。当一个对象改变的同时需要改变其他对象时,可以采用观察者模式,易于扩展且降低耦合。 所以狼来了的故事里,小孩是通知者,也就是subject,大人们是监听者,在大话设计模式里,前台是通知者,同事们是监听者。
//监听者(观察者) //public class EngineConfig implements LifecycleListener @Override public void lifecycleEvent(LifecycleEvent event) { // Identify the engine we are associated with try { engine = (Engine) event.getLifecycle(); } catch (ClassCastException e) { log.error(sm.getString("engineConfig.cce", event.getLifecycle()), e); return; } // Process the event that has occurred if (event.getType().equals(Lifecycle.START_EVENT)) start(); else if (event.getType().equals(Lifecycle.STOP_EVENT)) stop(); } //被监听者(被观察者) server组件 protected void startInternal() throws LifecycleException { fireLifecycleEvent(CONFIGURE_START_EVENT, null); setState(LifecycleState.STARTING); //当被监听者start的时候,监听器会通知所有 protected void fireLifecycleEvent(String type, Object data) { LifecycleEvent event = new LifecycleEvent(this, type, data); for (LifecycleListener listener : lifecycleListeners) { listener.lifecycleEvent(event); } }
-
EngineConfig,HostConfig,ContextConfig都是通过监听器来实现的,达到了配置逻辑和容器的解耦,修改配置逻辑不需要改动容器,修改容器也不用改动配置逻辑。
-
当我们运行tomcat/bin/startup的时候发生了什么?我们相当于调用Bootstrap类的main方法,并传入一个“start”参数,Bootstrap会根据这个字符串反射调用方法。
//Bootstrap.java public static void main(String args[]) { synchronized (daemonLock) { if (daemon == null) { // Don't set daemon until init() has completed Bootstrap bootstrap = new Bootstrap(); try { bootstrap.init(); } catch (Throwable t) { handleThrowable(t); t.printStackTrace(); return; } daemon = bootstrap; } else { // When running as a service the call to stop will be on a new // thread so make sure the correct class loader is used to // prevent a range of class not found exceptions. Thread.currentThread().setContextClassLoader(daemon.catalinaLoader); } } try { String command = "start"; if (args.length > 0) { command = args[args.length - 1]; } if (command.equals("startd")) { args[args.length - 1] = "start"; daemon.load(args); daemon.start(); } else if (command.equals("stopd")) { args[args.length - 1] = "stop"; daemon.stop(); } else if (command.equals("start")) { daemon.setAwait(true); daemon.load(args); daemon.start(); if (null == daemon.getServer()) { System.exit(1); } } else if (command.equals("stop")) { daemon.stopServer(args); } else if (command.equals("configtest")) { daemon.load(args); if (null == daemon.getServer()) { System.exit(1); } System.exit(0); } else { log.warn("Bootstrap: command \"" + command + "\" does not exist."); } } catch (Throwable t) { // Unwrap the Exception for clearer error reporting if (t instanceof InvocationTargetException && t.getCause() != null) { t = t.getCause(); } handleThrowable(t); t.printStackTrace(); System.exit(1); } }
-
在这个这个类中还值得关注一点类加载器。根据我之前的了解,除了webapp的类加载器默认子加载器优先外,其他都还是执行双亲委派的。
private void initClassLoaders() { try { commonLoader = createClassLoader("common", null); if (commonLoader == null) { // no config file, default to this loader - we might be in a 'single' env. commonLoader = this.getClass().getClassLoader(); } //commonLoader是catalinaLoader和sharedLoader的父加载器。根据上面的英文注释,默认情况下这三个加载器的加载范围是同一个,通过查阅配置文件也可以得知,只有common有加载路径,shared和catalina都是没有加载路径的。 catalinaLoader = createClassLoader("server", commonLoader); sharedLoader = createClassLoader("shared", commonLoader); } catch (Throwable t) { handleThrowable(t); log.error("Class loader creation threw exception", t); System.exit(1); } }
catalina.properties关于类加载器配置如下:
common.loader=" c a t a l i n a . b a s e / l i b " , " {catalina.base}/lib"," catalina.base/lib","{catalina.base}/lib/.jar"," c a t a l i n a . h o m e / l i b " , " {catalina.home}/lib"," catalina.home/lib","{catalina.home}/lib/.jar"
server.loader=
shared.loader=源码中解析路径的时候还要做${字符判断的原因就在这里。
-
上文中看到了catalina_base和catalina_home,有什么区别呢?home是安装目录,base是工作目录。home主要包括bin和lib,base包括conf,logs,temps,webapps,work和shared。所以IDEA运行tomcat其实是为每个webapp都复制了一份catalina_base(work,logs,conf),但是公用同一个tomcat的bin和lib,同一个版本但是可以多实例的运行,部署难度降低,不然你就要安装多个tomcat。然后还要再conf->catalina->localhost中放一个工程.xml以表示工程位置在哪里。
-
总结
启动过程为:main方法接收到参数start交给bootstrap类,boostrap类委托给catalina类,catalina类中initserver,一旦开始组件的初始化就一发不可收拾了,就链式将该组件下的所有组件init。然后执行boostrap的start,委托给catalina进行start,然后server先start,之后所有组件start。
请求过程
- tomcat9.x的IO模型
- nio模式是基于Java nio包实现的,能提供非阻塞I/O操作,拥有比传统I/O操作(bio)更好的并发运行性能。在Tomcat9中是默认模式。
- apr模式(Apache Portable Runtime/Apache可移植运行时),是Apache HTTP服务器的支持库。可以简单地理解为Tomcat将以JNI的形式调用Apache HTTP服务器的核心动态链接库来处理文件读取或网络传输操作,从而大大地提高了对静态文件的处理性能。
- 概述
- 启动顺序
- Connector组件的构造方法通过反射创建Http11NioProtocol类实例,这个实例内部有一个NioEndpoint(负责接收请求)和一个ConnectorHandler(负责处理请求)。NioEndpoint和ConnectorHandler在创建Http11NioProtocol的时候被创建出来。
- NioEndPoint包含三个组件:Acceptor(负责监听请求)、Poller(接收监听到的请求socket)、SocketProcessor(Worker,处理socket,本质上委托给ConnectionHandler处理)。
Acceptor
-
Acceptor在NioEndpoint中被创建出来,Acceptor这个类本身是实现Runnable的,因此是一个线程。在9.0.21版本中,这里是直接运行的,在9.x的早期版本中应该还有一个acceptors保存了所有的acceptor。
AbstractNioEndPoint.java protected void startAcceptorThread() { acceptor = new Acceptor<>(this); String threadName = getName() + "-Acceptor"; acceptor.setThreadName(threadName); Thread t = new Thread(acceptor, threadName); t.setPriority(getAcceptorThreadPriority()); t.setDaemon(getDaemon()); t.start(); } //public class Acceptor<U> implements Runnable
在Acceptor.java -> run()中,
socket = endpoint.serverSocketAccept();
这里的endpoint.serverSocketAccept本质上是调用的serverSocketChannel.accept。再看下一段代码,NioEndpoint->initServerSocket()中,serverSock.configureBlocking(true);
说明是阻塞的,因此Acceptor在监听连接的时候是阻塞的。当有请求过来时,endpoint.setSocketOptions(socket)
,这里是将socket包装起来生成secketWrapper(设置socket发送、接收缓存大小,心跳检测),将SocketChannel包装成NioChannel,调用poller池中的register方法提交给poller。public class NioEndpoint { protected boolean setSocketOptions(SocketChannel socket) { // Process the connection try { // Disable blocking, polling will be used socket.configureBlocking(false); Socket sock = socket.socket(); socketProperties.setProperties(sock); NioChannel channel = null; if (nioChannels != null) { channel = nioChannels.pop(); } if (channel == null) { SocketBufferHandler bufhandler = new SocketBufferHandler( socketProperties.getAppReadBufSize(), socketProperties.getAppWriteBufSize(), socketProperties.getDirectBuffer()); //根据是否是HTTPS协议返回不同的channel的包装类 if (isSSLEnabled()) { channel = new SecureNioChannel(socket, bufhandler, selectorPool, this); } else { channel = new NioChannel(socket, bufhandler); } } else { channel.setIOChannel(socket); channel.reset(); } NioSocketWrapper socketWrapper = new NioSocketWrapper(channel, this); channel.setSocketWrapper(socketWrapper); socketWrapper.setReadTimeout(getConnectionTimeout()); socketWrapper.setWriteTimeout(getConnectionTimeout()); socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests()); socketWrapper.setSecure(isSSLEnabled()); poller.register(channel, socketWrapper); return true; } catch (Throwable t) { ExceptionUtils.handleThrowable(t); try { log.error(sm.getString("endpoint.socketOptionsError"), t); } catch (Throwable tt) { ExceptionUtils.handleThrowable(tt); } } // Tell to close the socket return false; } }
Poller和PollerEvent
public class Poller implements Runnable
,而且每个poller都持有一个selector。那PollerEvent呢?这个类的run方法中进行socketChannel.register(selector,OP_XX)。Poller的run方法中调用events()方法,这个方法就是遍历一个元素的PollerEvent的队列,然后依次run,即依次register。这样每个poller上就注册了全部socketChannel,任何一个socketChannel发来请求的时候,poller都能通过select获得。根据之前JAVA NIO的那一节,select结束阻塞后,可以通过Set keys = selector.SelectedKeys获得selector上所有selectionkey,每个key都可以获得selector和channel。换而言之,poller可以通过select获得所有因为请求而触发的事件,而每个事件都对应着一个NioSocketChannel,这个channel可以读取请求的数据(因为持有SocketWrapper引用)。下一步就是交给SocketProcessor。
SocketProcessor和ConnectionHandler
- 这个小组件主要任务是吧NioSocket这个Channel的socket交给ConnectionHandler这个组件去处理。ConnectionHandler主要是解析http协议,并封装成request和response对象交给CoyoteAdapter。
小结
- 9.0.21版本IO模型的特点为,acceptor和poller都是单线程的,早期版本acceptor是有数组的,9.x早期版本poller也是有数组的,但是9.0.21都换成了单线程,且不交给线程池运行,这两个线程是从EndPoint.startInternal开始就创建并运行的。Acceptor组件负责以阻塞的方式监听连接,监听到后交给NioEndpoint封装NioSocketChannel和SocketWrapper,poller负责创建PointEvent实例(NioSocketChannel是核心)并加入events这个队列中。P.S.虽然PointEvent也是Runnable的实现类,但是他从来没有当做线程执行过,而是在poller的select前,遍历events并全部run一遍来保证注册所有事件。select后,遍历所有的SelectionKey,然后找到对应的NioSocketChannel,并取出socket,交给SocketProcessor。SocketProcessor先尝试从缓存中拿,缓存中不够的话会new一个,缓存的目的是为了避免反复的生成对象和GC,然后交给线程池去执行。这个线程池的阻塞队列是TaskQueue extends LinkedBlockingQueue,核心池为10,最大池为200。但是值得注意的是,虽然acceptor是单线程的,但是一个service有多个connector,因此用于接收客户端连接还是有多个NIO线程,不同的connector的SocketProcessor应该是交给不同线程池去运行,因为无论是executor还是acceptor都是属于某一个connector组件的。
- socketprocessor交给connectionHandler的process方法,处理socket。代码中是socketprocessor的run方法中调用doRun方法,doRun方法中先getHandler,然后connectionHandler.process()->AbstractProcessorLight.process(socketWrapper)->Http11Processor.service()
- reactor模式:事件驱动,有多个并发请求,有一个(或多个)Selector响应请求,并分发给其他线程处理。
Container
-
概览
-
书接上回,SocketProcessor.doRun()->ConnectionHandler.process()->Http11Processor.process()->Http11Processor.service(socketWrapper)->Adapter.service(request,)。根据这个执行链,到了CoytoteAdapter,此时已经封装好了request和response,并且作为参数传到了adapter的service中。这里的request和response还不是servlet中的,但是有转换关系,具体来说,adapter中解析出来的request和response比servlet中的更强
-
PipeLine之间使用valve连接,valve之间又通过单链表的形式组织而成。
PipeLine之间就是职责链,通过父子节点的方式连接。这里有个疑问,父节点如何找到自己的子节点呢?Tomcat的解决方案是,Engine与Host之间被一个Valve连接,这个Valve用于指明下一个子节点是谁,Engine有StrandEngineVavle,关系为:
上图所有的Value都拼错了,应该是Valve。这些Valve可以用户自己写,然后配置在xml中即可,相当于起到了拦截器的作用。Valve的组织形式是单链表。
(1)每个Pipeline都有特定的Valve,而且是在管道的最后一个执行,这个Valve叫做BasicValve,BasicValve是不可删除的;(2)在上层容器的管道的BasicValve中会调用下层容器的管道。
-
每个Valve的作用:
- StandardEngineValve:传递到Host容器中
- AccessLogValve:Host容器1号阀门,用于记录日志
- ErrorReportValve:Host容器2号阀门用于记录异常并封装到response中
- StandardHostValve:从request中找到映射的context容器(说明1个host有多个context),更新session的访问时间
- StandardContextValve:禁止对WEB-INF/META-INF目录下资源的重定向,从request中获取Wrapper(Mapper组件将request映射到正确的servlet)
- StandardServerWrapperValve:调用StandardWrapper的loadServlet方法生成servlet,调用ApplicationFilterFactory生成filter链条。
-
在Wrapper组件中,每个wrapper对应这样一个servlet和一条filterChain。每个请求会通过Mapper组件映射到一个Wrapper中,然后这个请求进入StandardWrapperValve中,先用filterChain进行处理,然后执行service方法。
Mapper
- Mapper组件由service管理,具有MappedHost,MappedContext,MappedWrapper。我们熟知的servlet的映射匹配方式,比如精确匹配,通配符匹配,扩展名匹配就是通过wrapperContext中不同的数组实现的。
- CoyoteAdapter中,之前说到用service方法处理request和response,在9.0.21版本中,service()方法会调用postParseRequest()方法,在此方法中,
connector.getService().getMapper().map(serverName, decodedURI, version, request.getMappingData());
来找到对应的host,context和wrapper,用于之后BasicValve向下一层容器派发,这些都是保存在request中的。
Redis的IO模型
- Redis中两类事件,一是文件事件比如客户端发送get请求,二是时间事件,服务器定时或周期性的执行,比如RDB持久化。后一个事件通过fork一个IO线程去做,前一个事件通过IO多路复用机制,将事件派发给不同的handler处理。
- MySQL的IO模型还是阻塞的,用的是线程池模型,一个连接一个线程,因为mysql的瓶颈主要在与硬盘的IO交互。
- 为什么MySQL之类的数据库不采用NIO呢?简单来说就是因为现在DB的线程池技术成熟了(JDBC就是BIO的),如果再加入NIO就会使得代码特别复杂,但是收益不大。