This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="14 | NioEndpoint组件Tomcat如何实现非阻塞I/O" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/41/208714cb04bf6bf86c069ae181087741.mp3"></audio>
UNIX系统下的I/O模型有5种同步阻塞I/O、同步非阻塞I/O、I/O多路复用、信号驱动I/O和异步I/O。这些名词我们好像都似曾相识但这些I/O通信模型有什么区别同步和阻塞似乎是一回事到底有什么不同等一下在这之前你是不是应该问自己一个终极问题什么是I/O为什么需要这些I/O模型
所谓的**I/O就是计算机内存与外部设备之间拷贝数据的过程**。我们知道CPU访问内存的速度远远高于外部设备因此CPU是先把外部设备的数据读到内存里然后再进行处理。请考虑一下这个场景当你的程序通过CPU向外部设备发出一个读指令时数据从外部设备拷贝到内存往往需要一段时间这个时候CPU没事干了你的程序是主动把CPU让给别人还是让CPU不停地查数据到了吗数据到了吗……
这就是I/O模型要解决的问题。今天我会先说说各种I/O模型的区别然后重点分析Tomcat的NioEndpoint组件是如何实现非阻塞I/O模型的。
## Java I/O模型
对于一个网络I/O通信过程比如网络数据读取会涉及两个对象一个是调用这个I/O操作的用户线程另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间用户线程不能直接访问内核空间。
当用户线程发起I/O操作后网络数据读取操作会经历两个步骤
- **用户线程等待内核将数据从网卡拷贝到内核空间。**
- **内核将数据从内核空间拷贝到用户空间。**
各种I/O模型的区别就是它们实现这两个步骤的方式是不一样的。
**同步阻塞I/O**用户线程发起read调用后就阻塞了让出CPU。内核等待网卡数据到来把数据从网卡拷贝到内核空间接着把数据拷贝到用户空间再把用户线程叫醒。
<img src="https://static001.geekbang.org/resource/image/99/de/9925741240414d45a3480e976a9eb5de.jpg" alt="">
**同步非阻塞I/O**用户线程不断的发起read调用数据没到内核空间时每次都返回失败直到数据到了内核空间这一次read调用后在等待数据从内核空间拷贝到用户空间这段时间里线程还是阻塞的等数据到了用户空间再把线程叫醒。
<img src="https://static001.geekbang.org/resource/image/f6/b9/f609702b40d7fa873f049e472d1819b9.jpg" alt="">
**I/O多路复用**用户线程的读取操作分成两步了线程先发起select调用目的是问内核数据准备好了吗等内核把数据准备好了用户线程再发起read调用。在等待数据从内核空间拷贝到用户空间这段时间里线程还是阻塞的。那为什么叫I/O多路复用呢因为一次select调用可以向内核查多个数据通道Channel的状态所以叫多路复用。
<img src="https://static001.geekbang.org/resource/image/cd/99/cd2f30b47a690c0fe3b0332203dd3e99.jpg" alt="">
**异步I/O**用户线程发起read调用的同时注册一个回调函数read立即返回等内核将数据准备好后再调用指定的回调函数完成处理。在这个过程中用户线程一直没有阻塞。
<img src="https://static001.geekbang.org/resource/image/aa/c3/aacd28f7f9719ceeb2f649db1a6c06c3.jpg" alt="">
## NioEndpoint组件
Tomcat的NioEndpoint组件实现了I/O多路复用模型接下来我会介绍NioEndpoint的实现原理下一期我会介绍Tomcat如何实现异步I/O模型。
**总体工作流程**
我们知道对于Java的多路复用器的使用无非是两步
<li>
创建一个Selector在它身上注册各种感兴趣的事件然后调用select方法等待感兴趣的事情发生。
</li>
<li>
感兴趣的事情发生了比如可以读了这时便创建一个新的线程从Channel中读数据。
</li>
Tomcat的NioEndpoint组件虽然实现比较复杂但基本原理就是上面两步。我们先来看看它有哪些组件它一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件它们的工作过程如下图所示。
<img src="https://static001.geekbang.org/resource/image/c4/65/c4bbda75005dd5e8519c2bc439359465.jpg" alt="">
LimitLatch是连接控制器它负责控制最大连接数NIO模式下默认是10000达到这个阈值后连接请求被拒绝。
Acceptor跑在一个单独的线程里它在一个死循环里调用accept方法来接收新连接一旦有新的连接请求到来accept方法返回一个Channel对象接着把Channel对象交给Poller去处理。
Poller的本质是一个Selector也跑在单独线程里。Poller在内部维护一个Channel数组它在一个死循环里不断检测Channel的数据就绪状态一旦有Channel可读就生成一个SocketProcessor任务对象扔给Executor去处理。
Executor就是线程池负责运行SocketProcessor任务类SocketProcessor的run方法会调用Http11Processor来读取和解析请求数据。我们知道Http11Processor是应用层协议的封装它会调用容器获得响应再把响应通过Channel写出。
接下来我详细介绍一下各组件的设计特点。
**LimitLatch**
LimitLatch用来控制连接个数当连接数到达最大时阻塞线程直到后续组件处理完一个连接后将连接数减1。请你注意到达最大连接数后操作系统底层还是会接收客户端连接但用户层已经不再接收。LimitLatch的核心代码如下
```
public class LimitLatch {
private class Sync extends AbstractQueuedSynchronizer {
@Override
protected int tryAcquireShared() {
long newCount = count.incrementAndGet();
if (newCount &gt; limit) {
count.decrementAndGet();
return -1;
} else {
return 1;
}
}
@Override
protected boolean tryReleaseShared(int arg) {
count.decrementAndGet();
return true;
}
}
private final Sync sync;
private final AtomicLong count;
private volatile long limit;
//线程调用这个方法来获得接收新连接的许可,线程可能被阻塞
public void countUpOrAwait() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//调用这个方法来释放一个连接许可,那么前面阻塞的线程可能被唤醒
public long countDown() {
sync.releaseShared(0);
long result = getCount();
return result;
}
}
```
从上面的代码我们看到LimitLatch内步定义了内部类Sync而Sync扩展了AQSAQS是Java并发包中的一个核心类它在内部维护一个状态和一个线程队列可以用来**控制线程什么时候挂起,什么时候唤醒**。我们可以扩展它来实现自己的同步器实际上Java并发包里的锁和条件变量等等都是通过AQS来实现的而这里的LimitLatch也不例外。
理解上面的代码时有两个要点:
<li>
用户线程通过调用LimitLatch的countUpOrAwait方法来拿到锁如果暂时无法获取这个线程会被阻塞到AQS的队列中。那AQS怎么知道是阻塞还是不阻塞用户线程呢其实这是由AQS的使用者来决定的也就是内部类Sync来决定的因为Sync类重写了AQS的**tryAcquireShared()方法**。它的实现逻辑是如果当前连接数count小于limit线程能获取锁返回1否则返回-1。
</li>
<li>
如何用户线程被阻塞到了AQS的队列那什么时候唤醒呢同样是由Sync内部类决定Sync重写了AQS的**tryReleaseShared()方法**,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样前面阻塞的线程将会被唤醒。
</li>
其实你会发现AQS就是一个骨架抽象类它帮我们搭了个架子用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由你来决定。我们还注意到当前线程数被定义成原子变量AtomicLong而limit变量用volatile关键字来修饰这些并发编程的实际运用。
**Acceptor**
Acceptor实现了Runnable接口因此可以跑在单独线程里。一个端口号只能对应一个ServerSocketChannel因此这个ServerSocketChannel是在多个Acceptor线程之间共享的它是Endpoint的属性由Endpoint完成初始化和端口绑定。初始化过程如下
```
serverSock = ServerSocketChannel.open();
serverSock.socket().bind(addr,getAcceptCount());
serverSock.configureBlocking(true);
```
从上面的初始化代码我们可以看到两个关键信息:
<li>
bind方法的第二个参数表示操作系统的等待队列长度我在上面提到当应用层面的连接数到达最大值时操作系统可以继续接收连接那么操作系统能继续接收的最大连接数就是这个队列长度可以通过acceptCount参数配置默认是100。
</li>
<li>
ServerSocketChannel被设置成阻塞模式也就是说它是以阻塞的方式接收连接的。
</li>
ServerSocketChannel通过accept()接受新的连接accept()方法返回获得SocketChannel对象然后将SocketChannel对象封装在一个PollerEvent对象中并将PollerEvent对象压入Poller的Queue里这是个典型的“生产者-消费者”模式Acceptor与Poller线程之间通过Queue通信。
**Poller**
Poller本质是一个Selector它内部维护一个Queue这个Queue定义如下
```
private final SynchronizedQueue&lt;PollerEvent&gt; events = new SynchronizedQueue&lt;&gt;();
```
SynchronizedQueue的方法比如offer、poll、size和clear方法都使用了synchronized关键字进行修饰用来保证同一时刻只有一个Acceptor线程对Queue进行读写。同时有多个Poller线程在运行每个Poller线程都有自己的Queue。每个Poller线程可能同时被多个Acceptor线程调用来注册PollerEvent。同样Poller的个数可以通过pollers参数配置。
Poller不断的通过内部的Selector对象向内核查询Channel的状态一旦可读就生成任务类SocketProcessor交给Executor去处理。Poller的另一个重要任务是循环遍历检查自己所管理的SocketChannel是否已经超时如果有超时就关闭这个SocketChannel。
**SocketProcessor**
我们知道Poller会创建SocketProcessor任务类交给线程池处理而SocketProcessor实现了Runnable接口用来定义Executor中线程所执行的任务主要就是调用Http11Processor组件来处理请求。Http11Processor读取Channel的数据来生成ServletRequest对象这里请你注意
Http11Processor并不是直接读取Channel的。这是因为Tomcat支持同步非阻塞I/O模型和异步I/O模型在Java API中相应的Channel类也是不一样的比如有AsynchronousSocketChannel和SocketChannel为了对Http11Processor屏蔽这些差异Tomcat设计了一个包装类叫作SocketWrapperHttp11Processor只调用SocketWrapper的方法去读写数据。
**Executor**
Executor是Tomcat定制版的线程池它负责创建真正干活的工作线程干什么活呢就是执行SocketProcessor的run方法也就是解析请求并通过容器来处理请求最终会调用到我们的Servlet。后面我会用专门的篇幅介绍Tomcat怎么扩展和使用Java原生的线程池。
## 高并发思路
在弄清楚NioEndpoint的实现原理后我们来考虑一个重要的问题怎么把这个过程做到高并发呢
高并发就是能快速地处理大量的请求需要合理设计线程模型让CPU忙起来尽量不要让线程阻塞因为一阻塞CPU就闲下来了。另外就是有多少任务就用相应规模的线程数去处理。我们注意到NioEndpoint要完成三件事情接收连接、检测I/O事件以及处理请求那么最核心的就是把这三件事情分开用不同规模的线程数去处理比如用专门的线程组去跑Acceptor并且Acceptor的个数可以配置用专门的线程组去跑PollerPoller的个数也可以配置最后具体任务的执行也由专门的线程池来处理也可以配置线程池的大小。
## 本期精华
I/O模型是为了解决内存和外部设备速度差异的问题。我们平时说的**阻塞或非阻塞**是指应用程序在**发起I/O操作时是立即返回还是等待**。而**同步和异步**,是指应用程序在与内核通信时,**数据从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发。**
在Tomcat中Endpoint组件的主要工作就是处理I/O而NioEndpoint利用Java NIO API实现了多路复用I/O模型。其中关键的一点是读写数据的线程自己不会阻塞在I/O等待上而是把这个工作交给Selector。同时Tomcat在这个过程中运用到了很多Java并发编程技术比如AQS、原子类、并发容器线程池等都值得我们去细细品味。
## 课后思考
Tomcat的NioEndpoint组件的名字中有NIONIO是非阻塞的意思似乎说的是同步非阻塞I/O模型但是NioEndpoint又是调用Java的的Selector来实现的我们知道Selector指的是I/O多路复用器也就是我们说的I/O多路复用模型这不是矛盾了吗
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="15 | Nio2Endpoint组件Tomcat如何实现异步I/O" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/5e/37b60903f90f52371892e37c6d724b5e.mp3"></audio>
我在专栏上一期里提到了5种I/O模型相应的Java提供了BIO、NIO和NIO.2这些API来实现这些I/O模型。BIO是我们最熟悉的同步阻塞NIO是同步非阻塞那NIO.2又是什么呢NIO已经足够好了为什么还要NIO.2呢?
NIO和NIO.2最大的区别是,一个是同步一个是异步。我在上期提到过,异步最大的特点是,应用程序不需要自己去**触发**数据从内核空间到用户空间的**拷贝**。
为什么是应用程序去“触发”数据的拷贝,而不是直接从内核拷贝数据呢?这是因为应用程序是不能访问内核空间的,因此数据拷贝肯定是由内核来做,关键是谁来触发这个动作。
是内核主动将数据拷贝到用户空间并通知应用程序。还是等待应用程序通过Selector来查询当数据就绪后应用程序再发起一个read调用这时内核再把数据从内核空间拷贝到用户空间。
需要注意的是,数据从内核空间拷贝到用户空间这段时间,应用程序还是阻塞的。所以你会看到异步的效率是高于同步的,因为异步模式下应用程序始终不会被阻塞。下面我以网络数据读取为例,来说明异步模式的工作过程。
首先应用程序在调用read API的同时告诉内核两件事情数据准备好了以后拷贝到哪个Buffer以及调用哪个回调函数去处理这些数据。
之后内核接到这个read指令后等待网卡数据到达数据到了后产生硬件中断内核在中断程序里把数据从网卡拷贝到内核空间接着做TCP/IP协议层面的数据解包和重组再把数据拷贝到应用程序指定的Buffer最后调用应用程序指定的回调函数。
你可能通过下面这张图来回顾一下同步与异步的区别:
<img src="https://static001.geekbang.org/resource/image/75/0d/75e622d376aaba976f87bd5a2642060d.jpg" alt="">
我们可以看到在异步模式下应用程序当了“甩手掌柜”内核则忙前忙后但最大限度提高了I/O通信的效率。Windows的IOCP和Linux内核2.6的AIO都提供了异步I/O的支持Java的NIO.2 API就是对操作系统异步I/O API的封装。
## Java NIO.2回顾
今天我们会重点关注Tomcat是如何实现异步I/O模型的但在这之前我们先来简单回顾下如何用Java的NIO.2 API来编写一个服务端程序。
```
public class Nio2Server {
void listen(){
//1.创建一个线程池
ExecutorService es = Executors.newCachedThreadPool();
//2.创建异步通道群组
AsynchronousChannelGroup tg = AsynchronousChannelGroup.withCachedThreadPool(es, 1);
//3.创建服务端异步通道
AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open(tg);
//4.绑定监听端口
assc.bind(new InetSocketAddress(8080));
//5. 监听连接,传入回调类处理连接请求
assc.accept(this, new AcceptHandler());
}
}
```
上面的代码主要做了5件事情
1. 创建一个线程池,这个线程池用来执行来自内核的回调请求。
1. 创建一个AsynchronousChannelGroup并绑定一个线程池。
1. 创建AsynchronousServerSocketChannel并绑定到AsynchronousChannelGroup。
1. 绑定一个监听端口。
1. 调用accept方法开始监听连接请求同时传入一个回调类去处理连接请求。请你注意accept方法的第一个参数是this对象就是Nio2Server对象本身我在下文还会讲为什么要传入这个参数。
你可能会问为什么需要创建一个线程池呢其实在异步I/O模型里应用程序不知道数据在什么时候到达因此向内核注册回调函数当数据到达时内核就会调用这个回调函数。同时为了提高处理速度会提供一个线程池给内核使用这样不会耽误内核线程的工作内核只需要把工作交给线程池就立即返回了。
我们再来看看处理连接的回调类AcceptHandler是什么样的。
```
//AcceptHandler类实现了CompletionHandler接口的completed方法。它还有两个模板参数第一个是异步通道第二个就是Nio2Server本身
public class AcceptHandler implements CompletionHandler&lt;AsynchronousSocketChannel, Nio2Server&gt; {
//具体处理连接请求的就是completed方法它有两个参数第一个是异步通道第二个就是上面传入的NioServer对象
@Override
public void completed(AsynchronousSocketChannel asc, Nio2Server attachment) {
//调用accept方法继续接收其他客户端的请求
attachment.assc.accept(attachment, this);
//1. 先分配好Buffer告诉内核数据拷贝到哪里去
ByteBuffer buf = ByteBuffer.allocate(1024);
//2. 调用read函数读取数据除了把buf作为参数传入还传入读回调类
channel.read(buf, buf, new ReadHandler(asc));
}
```
我们看到它实现了CompletionHandler接口下面我们先来看看CompletionHandler接口的定义。
```
public interface CompletionHandler&lt;V,A&gt; {
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
}
```
**CompletionHandler接口有两个模板参数V和A分别表示I/O调用的返回值和附件类**。比如accept的返回值就是AsynchronousSocketChannel而附件类由用户自己决定在accept的调用中我们传入了一个Nio2Server。因此AcceptHandler带有了两个模板参数AsynchronousSocketChannel和Nio2Server。
CompletionHandler有两个方法completed和failed分别在I/O操作成功和失败时调用。completed方法有两个参数其实就是前面说的两个模板参数。也就是说Java的NIO.2在调用回调方法时会把返回值和附件类当作参数传给NIO.2的使用者。
下面我们再来看看处理读的回调类ReadHandler长什么样子。
```
public class ReadHandler implements CompletionHandler&lt;Integer, ByteBuffer&gt; {
//读取到消息后的处理
@Override
public void completed(Integer result, ByteBuffer attachment) {
//attachment就是数据调用flip操作其实就是把读的位置移动最前面
attachment.flip();
//读取数据
...
}
void failed(Throwable exc, A attachment){
...
}
}
```
read调用的返回值是一个整型数所以我们回调方法里的第一个参数就是一个整型表示有多少数据被读取到了Buffer中。第二个参数是一个ByteBuffer这是因为我们在调用read方法时把用来存放数据的ByteBuffer当作附件类传进去了所以在回调方法里有ByteBuffer类型的参数我们直接从这个ByteBuffer里获取数据。
## Nio2Endpoint
掌握了Java NIO.2 API的使用以及服务端程序的工作原理之后再来理解Tomcat的异步I/O实现就不难了。我们先通过一张图来看看Nio2Endpoint有哪些组件。
<img src="https://static001.geekbang.org/resource/image/be/e0/be7da29404bda751fc3aa263fb909de0.jpg" alt="">
从图上看总体工作流程跟NioEndpoint是相似的。
LimitLatch是连接控制器它负责控制最大连接数。
Nio2Acceptor扩展了Acceptor用异步I/O的方式来接收连接跑在一个单独的线程里也是一个线程组。Nio2Acceptor接收新的连接后得到一个AsynchronousSocketChannelNio2Acceptor把AsynchronousSocketChannel封装成一个Nio2SocketWrapper并创建一个SocketProcessor任务类交给线程池处理并且SocketProcessor持有Nio2SocketWrapper对象。
Executor在执行SocketProcessor时SocketProcessor的run方法会调用Http11Processor来处理请求Http11Processor会通过Nio2SocketWrapper读取和解析请求数据请求经过容器处理后再把响应通过Nio2SocketWrapper写出。
需要你注意Nio2Endpoint跟NioEndpoint的一个明显不同点是**Nio2Endpoint中没有Poller组件也就是没有Selector。这是为什么呢因为在异步I/O模式下Selector的工作交给内核来做了。**
接下来我详细介绍一下Nio2Endpoint各组件的设计。
**Nio2Acceptor**
和NioEndpint一样Nio2Endpoint的基本思路是用LimitLatch组件来控制连接数但是Nio2Acceptor的监听连接的过程不是在一个死循环里不断地调accept方法而是通过回调函数来完成的。我们来看看它的连接监听方法
```
serverSock.accept(null, this);
```
其实就是调用了accept方法注意它的第二个参数是this表明Nio2Acceptor自己就是处理连接的回调类因此Nio2Acceptor实现了CompletionHandler接口。那么它是如何实现CompletionHandler接口的呢
```
protected class Nio2Acceptor extends Acceptor&lt;AsynchronousSocketChannel&gt;
implements CompletionHandler&lt;AsynchronousSocketChannel, Void&gt; {
@Override
public void completed(AsynchronousSocketChannel socket,
Void attachment) {
if (isRunning() &amp;&amp; !isPaused()) {
if (getMaxConnections() == -1) {
//如果没有连接限制,继续接收新的连接
serverSock.accept(null, this);
} else {
//如果有连接限制就在线程池里跑run方法run方法会检查连接数
getExecutor().execute(this);
}
//处理请求
if (!setSocketOptions(socket)) {
closeSocket(socket);
}
}
}
```
可以看到CompletionHandler的两个模板参数分别是AsynchronousServerSocketChannel和Void我在前面说过第一个参数就是accept方法的返回值第二个参数是附件类由用户自己决定这里为Void。completed方法的处理逻辑比较简单
- 如果没有连接限制继续在本线程中调用accept方法接收新的连接。
- 如果有连接限制就在线程池里跑run方法去接收新的连接。那为什么要跑run方法呢因为在run方法里会检查连接数当连接达到最大数时线程可能会被LimitLatch阻塞。为什么要放在线程池里跑呢这是因为如果放在当前线程里执行completed方法可能被阻塞会导致这个回调方法一直不返回。
接着completed方法会调用setSocketOptions方法在这个方法里会创建Nio2SocketWrapper和SocketProcessor并交给线程池处理。
**Nio2SocketWrapper**
Nio2SocketWrapper的主要作用是封装Channel并提供接口给Http11Processor读写数据。讲到这里你是不是有个疑问Http11Processor是不能阻塞等待数据的按照异步I/O的套路Http11Processor在调用Nio2SocketWrapper的read方法时需要注册回调类read调用会立即返回问题是立即返回后Http11Processor还没有读到数据怎么办呢这个请求的处理不就失败了吗
为了解决这个问题Http11Processor是通过2次read调用来完成数据读取操作的。
- 第一次read调用连接刚刚建立好后Acceptor创建SocketProcessor任务类交给线程池去处理Http11Processor在处理请求的过程中会调用Nio2SocketWrapper的read方法发出第一次读请求同时注册了回调类readCompletionHandler因为数据没读到Http11Processor把当前的Nio2SocketWrapper标记为数据不完整。**接着SocketProcessor线程被回收Http11Processor并没有阻塞等待数据**。这里请注意Http11Processor维护了一个Nio2SocketWrapper列表也就是维护了连接的状态。
- 第二次read调用当数据到达后内核已经把数据拷贝到Http11Processor指定的Buffer里同时回调类readCompletionHandler被调用在这个回调处理方法里会**重新创建一个新的SocketProcessor任务来继续处理这个连接**而这个新的SocketProcessor任务类持有原来那个Nio2SocketWrapper这一次Http11Processor可以通过Nio2SocketWrapper读取数据了因为数据已经到了应用层的Buffer。
这个回调类readCompletionHandler的源码如下最关键的一点是**Nio2SocketWrapper是作为附件类来传递的**,这样在回调函数里能拿到所有的上下文。
```
this.readCompletionHandler = new CompletionHandler&lt;Integer, SocketWrapperBase&lt;Nio2Channel&gt;&gt;() {
public void completed(Integer nBytes, SocketWrapperBase&lt;Nio2Channel&gt; attachment) {
...
//通过附件类SocketWrapper拿到所有的上下文
Nio2SocketWrapper.this.getEndpoint().processSocket(attachment, SocketEvent.OPEN_READ, false);
}
public void failed(Throwable exc, SocketWrapperBase&lt;Nio2Channel&gt; attachment) {
...
}
}
```
## 本期精华
在异步I/O模型里内核做了很多事情它把数据准备好并拷贝到用户空间再通知应用程序去处理也就是调用应用程序注册的回调函数。Java在操作系统 异步IO API的基础上进行了封装提供了Java NIO.2 API而Tomcat的异步I/O模型就是基于Java NIO.2 实现的。
由于NIO和NIO.2的API接口和使用方法完全不同可以想象一个系统中如果已经支持同步I/O要再支持异步I/O改动是比较大的很有可能不得不重新设计组件之间的接口。但是Tomcat通过充分的抽象比如SocketWrapper对Channel的封装再加上Http11Processor的两次read调用巧妙地解决了这个问题使得协议处理器Http11Processor和I/O通信处理器Endpoint之间的接口保持不变。
## 课后思考
我在文章开头介绍Java NIO.2的使用时提到过要创建一个线程池来处理异步I/O的回调那么这个线程池跟Tomcat的工作线程池Executor是同一个吗如果不是它们有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="16 | AprEndpoint组件Tomcat APR提高I/O性能的秘密" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/18/e4df61f44281499c5da7938e1a470e18.mp3"></audio>
我们在使用Tomcat时会在启动日志里看到这样的提示信息
>
The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: ***
这句话的意思就是推荐你去安装APR库可以提高系统性能。那什么是APR呢
APRApache Portable Runtime Libraries是Apache可移植运行时库它是用C语言实现的其目的是向上层应用程序提供一个跨平台的操作系统接口库。Tomcat可以用它来处理包括文件和网络I/O从而提升性能。我在专栏前面提到过Tomcat支持的连接器有NIO、NIO.2和APR。跟NioEndpoint一样AprEndpoint也实现了非阻塞I/O它们的区别是NioEndpoint通过调用Java的NIO API来实现非阻塞I/O而AprEndpoint是通过JNI调用APR本地库而实现非阻塞I/O的。
那同样是非阻塞I/O为什么Tomcat会提示使用APR本地库的性能会更好呢这是因为在某些场景下比如需要频繁与操作系统进行交互Socket网络通信就是这样一个场景特别是如果你的Web应用使用了TLS来加密传输我们知道TLS协议在握手过程中有多次网络交互在这种情况下Java跟C语言程序相比还是有一定的差距而这正是APR的强项。
Tomcat本身是Java编写的为了调用C语言编写的APR需要通过JNI方式来调用。JNIJava Native Interface 是JDK提供的一个编程接口它允许Java程序调用其他语言编写的程序或者代码库其实JDK本身的实现也大量用到JNI技术来调用本地C程序库。
在今天这一期文章首先我会讲AprEndpoint组件的工作过程接着我会在原理的基础上分析APR提升性能的一些秘密。在今天的学习过程中会涉及到一些操作系统的底层原理毫无疑问掌握这些底层知识对于提高你的内功非常有帮助。
## AprEndpoint工作过程
下面我还是通过一张图来帮你理解AprEndpoint的工作过程。
<img src="https://static001.geekbang.org/resource/image/37/93/37117f9fd6ed5523a331ac566a906893.jpg" alt="">
你会发现它跟NioEndpoint的图很像从左到右有LimitLatch、Acceptor、Poller、SocketProcessor和Http11Processor只是Acceptor和Poller的实现和NioEndpoint不同。接下来我分别来讲讲这两个组件。
**Acceptor**
Accpetor的功能就是监听连接接收并建立连接。它的本质就是调用了四个操作系统APISocket、Bind、Listen和Accept。那Java语言如何直接调用C语言API呢答案就是通过JNI。具体来说就是两步先封装一个Java类在里面定义一堆用**native关键字**修饰的方法,像下面这样。
```
public class Socket {
...
//用native修饰这个方法表明这个函数是C语言实现
public static native long create(int family, int type,
int protocol, long cont)
public static native int bind(long sock, long sa);
public static native int listen(long sock, int backlog);
public static native long accept(long sock)
}
```
接着用C代码实现这些方法比如Bind函数就是这样实现的
```
//注意函数的名字要符合JNI规范的要求
JNIEXPORT jint JNICALL
Java_org_apache_tomcat_jni_Socket_bind(JNIEnv *e, jlong sock,jlong sa)
{
jint rv = APR_SUCCESS;
tcn_socket_t *s = (tcn_socket_t *sock;
apr_sockaddr_t *a = (apr_sockaddr_t *) sa;
//调用APR库自己实现的bind函数
rv = (jint)apr_socket_bind(s-&gt;sock, a);
return rv;
}
```
专栏里我就不展开JNI的细节了你可以[扩展阅读](http://jnicookbook.owsiak.org/contents/)获得更多信息和例子。我们要注意的是函数名字要符合JNI的规范以及Java和C语言如何互相传递参数比如在C语言有指针Java没有指针的概念所以在Java中用long类型来表示指针。AprEndpoint的Acceptor组件就是调用了APR实现的四个API。
**Poller**
Acceptor接收到一个新的Socket连接后按照NioEndpoint的实现它会把这个Socket交给Poller去查询I/O事件。AprEndpoint也是这样做的不过AprEndpoint的Poller并不是调用Java NIO里的Selector来查询Socket的状态而是通过JNI调用APR中的poll方法而APR又是调用了操作系统的epoll API来实现的。
这里有个特别的地方是在AprEndpoint中我们可以配置一个叫`deferAccept`的参数它对应的是TCP协议中的`TCP_DEFER_ACCEPT`设置这个参数后当TCP客户端有新的连接请求到达时TCP服务端先不建立连接而是再等等直到客户端有请求数据发过来时再建立连接。这样的好处是服务端不需要用Selector去反复查询请求数据是否就绪。
这是一种TCP协议层的优化不是每个操作系统内核都支持因为Java作为一种跨平台语言需要屏蔽各种操作系统的差异因此并没有把这个参数提供给用户但是对于APR来说它的目的就是尽可能提升性能因此它向用户暴露了这个参数。
## APR提升性能的秘密
APR连接器之所以能提高Tomcat的性能除了APR本身是C程序库之外还有哪些提速的秘密呢
**JVM堆 VS 本地内存**
我们知道Java的类实例一般在JVM堆上分配而Java是通过JNI调用C代码来实现Socket通信的那么C代码在运行过程中需要的内存又是从哪里分配的呢C代码能否直接操作Java堆
为了回答这些问题我先来说说JVM和用户进程的关系。如果你想运行一个Java类文件可以用下面的Java命令来执行。
```
java my.class
```
这个命令行中的`java`其实是**一个可执行程序这个程序会创建JVM来加载和运行你的Java类**。操作系统会创建一个进程来执行这个`java`可执行程序而每个进程都有自己的虚拟地址空间JVM用到的内存包括堆、栈和方法区就是从进程的虚拟地址空间上分配的。请你注意的是JVM内存只是进程空间的一部分除此之外进程空间内还有代码段、数据段、内存映射区、内核空间等。从JVM的角度看JVM内存之外的部分叫作本地内存C程序代码在运行过程中用到的内存就是本地内存中分配的。下面我们通过一张图来理解一下。
<img src="https://static001.geekbang.org/resource/image/83/80/839bfab2636634d47477cbd0920b5980.jpg" alt="">
Tomcat的Endpoint组件在接收网络数据时需要预先分配好一块Buffer所谓的Buffer就是字节数组`byte[]`Java通过JNI调用把这块Buffer的地址传给C代码C代码通过操作系统API读取Socket并把数据填充到这块Buffer。Java NIO API提供了两种Buffer来接收数据HeapByteBuffer和DirectByteBuffer下面的代码演示了如何创建两种Buffer。
```
//分配HeapByteBuffer
ByteBuffer buf = ByteBuffer.allocate(1024);
//分配DirectByteBuffer
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
```
创建好Buffer后直接传给Channel的read或者write函数最终这块Buffer会通过JNI调用传递给C程序。
```
//将buf作为read函数的参数
int bytesRead = socketChannel.read(buf);
```
那HeapByteBuffer和DirectByteBuffer有什么区别呢HeapByteBuffer对象本身在JVM堆上分配并且它持有的字节数组`byte[]`也是在JVM堆上分配。但是如果用**HeapByteBuffer**来接收网络数据,**需要把数据从内核先拷贝到一个临时的本地内存再从临时本地内存拷贝到JVM堆**而不是直接从内核拷贝到JVM堆上。这是为什么呢这是因为数据从内核拷贝到JVM堆的过程中JVM可能会发生GCGC过程中对象可能会被移动也就是说JVM堆上的字节数组可能会被移动这样的话Buffer地址就失效了。如果这中间经过本地内存中转从本地内存到JVM堆的拷贝过程中JVM可以保证不做GC。
如果使用HeapByteBuffer你会发现JVM堆和内核之间多了一层中转而DirectByteBuffer用来解决这个问题DirectByteBuffer对象本身在JVM堆上但是它持有的字节数组不是从JVM堆上分配的而是从本地内存分配的。DirectByteBuffer对象中有个long类型字段address记录着本地内存的地址这样在接收数据的时候直接把这个本地内存地址传递给C程序C程序会将网络数据从内核拷贝到这个本地内存JVM可以直接读取这个本地内存这种方式比HeapByteBuffer少了一次拷贝因此一般来说它的速度会比HeapByteBuffer快好几倍。你可以通过上面的图加深理解。
Tomcat中的AprEndpoint就是通过DirectByteBuffer来接收数据的而NioEndpoint和Nio2Endpoint是通过HeapByteBuffer来接收数据的。你可能会问NioEndpoint和Nio2Endpoint为什么不用DirectByteBuffer呢这是因为本地内存不好管理发生内存泄漏难以定位从稳定性考虑NioEndpoint和Nio2Endpoint没有去冒这个险。
**sendfile**
我们再来考虑另一个网络通信的场景也就是静态文件的处理。浏览器通过Tomcat来获取一个HTML文件而Tomcat的处理逻辑无非是两步
1. 从磁盘读取HTML到内存。
1. 将这段内存的内容通过Socket发送出去。
但是在传统方式下,有很多次的内存拷贝:
- 读取文件时,首先是内核把文件内容读取到内核缓冲区。
- 如果使用HeapByteBuffer文件数据从内核到JVM堆内存需要经过本地内存中转。
- 同样在将文件内容推入网络时从JVM堆到内核缓冲区需要经过本地内存中转。
- 最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。
从下面的图你会发现这个过程有6次内存拷贝并且read和write等系统调用将导致进程从用户态到内核态的切换会耗费大量的CPU和内存资源。
<img src="https://static001.geekbang.org/resource/image/2b/0e/2b902479c36647142ccd413320b3900e.jpg" alt="">
而Tomcat的AprEndpoint通过操作系统层面的sendfile特性解决了这个问题sendfile系统调用方式非常简洁。
```
sendfile(socket, file, len);
```
它带有两个关键参数Socket和文件句柄。将文件从磁盘写入Socket的过程只有两步
第一步:将文件内容读取到内核缓冲区。
第二步数据并没有从内核缓冲区复制到Socket关联的缓冲区只有记录数据位置和长度的描述符被添加到Socket缓冲区中接着把数据直接从内核缓冲区传递给网卡。这个过程你可以看下面的图。
<img src="https://static001.geekbang.org/resource/image/19/00/193df268fccb59a09195810e34080a00.jpg" alt="">
## 本期精华
对于一些需要频繁与操作系统进行交互的场景比如网络通信Java的效率没有C语言高特别是TLS协议握手过程中需要多次网络交互这种情况下使用APR本地库能够显著提升性能。
除此之外APR提升性能的秘密还有通过DirectByteBuffer避免了JVM堆与本地内存之间的内存拷贝通过sendfile特性避免了内核与应用之间的内存拷贝以及用户态和内核态的切换。其实很多高性能网络通信组件比如Netty都是通过DirectByteBuffer来收发网络数据的。由于本地内存难于管理Netty采用了本地内存池技术感兴趣的同学可以深入了解一下。
## 课后思考
为什么不同的操作系统比如Linux和Windows都有自己的Java虚拟机
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="17 | Executor组件Tomcat如何扩展Java线程池" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/90/c82a203e38173d87153b211bfe1d9990.mp3"></audio>
在开发中我们经常会碰到“池”的概念比如数据库连接池、内存池、线程池、常量池等。为什么需要“池”呢程序运行的本质就是通过使用系统资源CPU、内存、网络、磁盘等来完成信息的处理比如在JVM中创建一个对象实例需要消耗CPU和内存资源如果你的程序需要频繁创建大量的对象并且这些对象的存活时间短就意味着需要进行频繁销毁那么很有可能这部分代码会成为性能的瓶颈。
而“池”就是用来解决这个问题的简单来说对象池就是把用过的对象保存起来等下一次需要这种对象的时候直接从对象池中拿出来重复使用避免频繁地创建和销毁。在Java中万物皆对象线程也是一个对象Java线程是对操作系统线程的封装创建Java线程也需要消耗系统资源因此就有了线程池。JDK中提供了线程池的默认实现我们也可以通过扩展Java原生线程池来实现自己的线程池。
同样为了提高处理能力和并发度Web容器一般会把处理请求的工作放到线程池里来执行Tomcat扩展了原生的Java线程池来满足Web容器高并发的需求下面我们就来学习一下Java线程池的原理以及Tomcat是如何扩展Java线程池的。
## Java线程池
简单的说Java线程池里内部维护一个线程数组和一个任务队列当任务处理不过来的时就把任务放到队列里慢慢处理。
**ThreadPoolExecutor**
我们先来看看Java线程池核心类ThreadPoolExecutor的构造函数你需要知道ThreadPoolExecutor是如何使用这些参数的这是理解Java线程工作原理的关键。
```
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue&lt;Runnable&gt; workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
```
每次提交任务时,如果线程数还没达到核心线程数**corePoolSize**,线程池就创建新线程来执行。当线程数达到**corePoolSize**后,新增的任务就放到工作队列**workQueue**里,而线程池中的线程则努力地从**workQueue**里拉活来干也就是调用poll方法来获取任务。
如果任务很多,并且**workQueue**是个有界队列,队列可能会满,此时线程池就会紧急创建新的临时线程来救场,如果总的线程数达到了最大线程数**maximumPoolSize**,则不能再创建新的临时线程了,转而执行拒绝策略**handler**,比如抛出异常或者由调用者线程来执行任务等。
如果高峰过去了线程池比较闲了怎么办临时线程使用poll**keepAliveTime, unit**方法从工作队列中拉活干请注意poll方法设置了超时时间如果超时了仍然两手空空没拉到活表明它太闲了这个线程会被销毁回收。
那还有一个参数**threadFactory**是用来做什么的呢?通过它你可以扩展原生的线程工厂,比如给创建出来的线程取个有意义的名字。
**FixedThreadPool/CachedThreadPool**
Java提供了一些默认的线程池实现比如FixedThreadPool和CachedThreadPool它们的本质就是给ThreadPoolExecutor设置了不同的参数是定制版的ThreadPoolExecutor。
```
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue&lt;Runnable&gt;());
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue&lt;Runnable&gt;());
}
```
从上面的代码你可以看到:
- **FixedThreadPool有固定长度nThreads的线程数组**,忙不过来时会把任务放到无限长的队列里,这是因为**LinkedBlockingQueue默认是一个无界队列**。
- **CachedThreadPool的maximumPoolSize参数值是`Integer.MAX_VALUE`**,因此它对线程个数不做限制,忙不过来时无限创建临时线程,闲下来时再回收。它的任务队列是**SynchronousQueue**表明队列长度为0。
## Tomcat线程池
跟FixedThreadPool/CachedThreadPool一样Tomcat的线程池也是一个定制版的ThreadPoolExecutor。
**定制版的ThreadPoolExecutor**
通过比较FixedThreadPool和CachedThreadPool我们发现它们传给ThreadPoolExecutor的参数有两个关键点
- 是否限制线程个数。
- 是否限制队列长度。
对于Tomcat来说这两个资源都需要限制也就是说要对高并发进行控制否则CPU和内存有资源耗尽的风险。因此Tomcat传入的参数是这样的
```
//定制版的任务队列
taskqueue = new TaskQueue(maxQueueSize);
//定制版的线程工厂
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
//定制版的线程池
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
```
你可以看到其中的两个关键点:
- Tomcat有自己的定制版任务队列和线程工厂并且可以限制任务队列的长度它的最大长度是maxQueueSize。
- Tomcat对线程数也有限制设置了核心线程数minSpareThreads和最大线程池数maxThreads
除了资源限制以外Tomcat线程池还定制自己的任务处理流程。我们知道Java原生线程池的任务处理逻辑比较简单
1. 前corePoolSize个任务时来一个任务就创建一个新线程。
1. 后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
1. 如果总线程数达到maximumPoolSize**执行拒绝策略。**
Tomcat线程池扩展了原生的ThreadPoolExecutor通过重写execute方法实现了自己的任务处理逻辑
1. 前corePoolSize个任务时来一个任务就创建一个新线程。
1. 再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
1. 如果总线程数达到maximumPoolSize**则继续尝试把任务添加到任务队列中去。**
1. **如果缓冲队列也满了,插入失败,执行拒绝策略。**
观察Tomcat线程池和Java原生线程池的区别其实就是在第3步Tomcat在线程总数达到最大数时不是立即执行拒绝策略而是再尝试向任务队列添加任务添加失败后再执行拒绝策略。那具体如何实现呢其实很简单我们来看一下Tomcat线程池的execute方法的核心代码。
```
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
...
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
//调用Java原生线程池的execute去执行任务
super.execute(command);
} catch (RejectedExecutionException rx) {
//如果总线程数达到maximumPoolSizeJava原生线程池执行拒绝策略
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
//继续尝试把任务放到任务队列中去
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
//如果缓冲队列也满了,插入失败,执行拒绝策略。
throw new RejectedExecutionException(&quot;...&quot;);
}
}
}
}
}
```
从这个方法你可以看到Tomcat线程池的execute方法会调用Java原生线程池的execute去执行任务如果总线程数达到maximumPoolSizeJava原生线程池的execute方法会抛出RejectedExecutionException异常但是这个异常会被Tomcat线程池的execute方法捕获到并继续尝试把这个任务放到任务队列中去如果任务队列也满了再执行拒绝策略。
**定制版的任务队列**
细心的你有没有发现在Tomcat线程池的execute方法最开始有这么一行
```
submittedCount.incrementAndGet();
```
这行代码的意思把submittedCount这个原子变量加一并且在任务执行失败抛出拒绝异常时将这个原子变量减一
```
submittedCount.decrementAndGet();
```
其实Tomcat线程池是用这个变量submittedCount来维护已经提交到了线程池但是还没有执行完的任务个数。Tomcat为什么要维护这个变量呢这跟Tomcat的定制版的任务队列有关。Tomcat的任务队列TaskQueue扩展了Java中的LinkedBlockingQueue我们知道LinkedBlockingQueue默认情况下长度是没有限制的除非给它一个capacity。因此Tomcat给了它一个capacityTaskQueue的构造函数中有个整型的参数capacityTaskQueue将capacity传给父类LinkedBlockingQueue的构造函数。
```
public class TaskQueue extends LinkedBlockingQueue&lt;Runnable&gt; {
public TaskQueue(int capacity) {
super(capacity);
}
...
}
```
这个capacity参数是通过Tomcat的maxQueueSize参数来设置的但问题是默认情况下maxQueueSize的值是`Integer.MAX_VALUE`,等于没有限制,这样就带来一个问题:当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。
为了解决这个问题TaskQueue重写了LinkedBlockingQueue的offer方法在合适的时机返回false返回false表示任务添加失败这时线程池会创建新的线程。那什么是合适的时机呢请看下面offer方法的核心源码
```
public class TaskQueue extends LinkedBlockingQueue&lt;Runnable&gt; {
...
@Override
//线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
public boolean offer(Runnable o) {
//如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
if (parent.getPoolSize() == parent.getMaximumPoolSize())
return super.offer(o);
//执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
//表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
if (parent.getSubmittedCount()&lt;=(parent.getPoolSize()))
return super.offer(o);
//2. 如果已提交的任务数大于当前线程数线程不够用了返回false去创建新线程
if (parent.getPoolSize()&lt;parent.getMaximumPoolSize())
return false;
//默认情况下总是把任务添加到任务队列
return super.offer(o);
}
}
```
从上面的代码我们看到只有当前线程数大于核心线程数、小于最大线程数并且已提交的任务个数大于当前线程数时也就是说线程不够用了但是线程数又没达到极限才会去创建新的线程。这就是为什么Tomcat需要维护已提交任务数这个变量它的目的就是**在任务队列的长度无限制的情况下,让线程池有机会创建新的线程**。
当然默认情况下Tomcat的任务队列是没有限制的你可以通过设置maxQueueSize参数来限制任务队列的长度。
## 本期精华
池化的目的是为了避免频繁地创建和销毁对象减少对系统资源的消耗。Java提供了默认的线程池实现我们也可以扩展Java原生的线程池来实现定制自己的线程池Tomcat就是这么做的。Tomcat扩展了Java线程池的核心类ThreadPoolExecutor并重写了它的execute方法定制了自己的任务处理流程。同时Tomcat还实现了定制版的任务队列重写了offer方法使得在任务队列长度无限制的情况下线程池仍然有机会创建新的线程。
## 课后思考
请你再仔细看看Tomcat的定制版任务队列TaskQueue的offer方法它多次调用了getPoolSize方法但是这个方法是有锁的锁会引起线程上下文切换而损耗性能请问这段代码可以如何优化呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="18 | 新特性Tomcat如何支持WebSocket" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/80/3f4d60e4fe811d966731ce2d0783c580.mp3"></audio>
我们知道HTTP协议是“请求-响应”模式,浏览器必须先发请求给服务器,服务器才会响应这个请求。也就是说,服务器不会主动发送数据给浏览器。
对于实时性要求比较的高的应用比如在线游戏、股票基金实时报价和在线协同编辑等浏览器需要实时显示服务器上最新的数据因此出现了Ajax和Comet技术。Ajax本质上还是轮询而Comet是在HTTP长连接的基础上做了一些hack但是它们的实时性不高另外频繁的请求会给服务器带来压力也会浪费网络流量和带宽。于是HTML5推出了WebSocket标准使得浏览器和服务器之间任何一方都可以主动发消息给对方这样服务器有新数据时可以主动推送给浏览器。
今天我会介绍WebSocket的工作原理以及作为服务器端的Tomcat是如何支持WebSocket的。更重要的是希望你在学完之后可以灵活地选用WebSocket技术来解决实际工作中的问题。
## WebSocket工作原理
WebSocket的名字里带有Socket那Socket是什么呢网络上的两个程序通过一个双向链路进行通信这个双向链路的一端称为一个Socket。一个Socket对应一个IP地址和端口号应用程序通常通过Socket向网络发出请求或者应答网络请求。Socket不是协议它其实是对TCP/IP协议层抽象出来的API。
但WebSocket不是一套API跟HTTP协议一样WebSocket也是一个应用层协议。为了跟现有的HTTP协议保持兼容它通过HTTP协议进行一次握手握手之后数据就直接从TCP层的Socket传输就与HTTP协议无关了。浏览器发给服务端的请求会带上跟WebSocket有关的请求头比如`Connection: Upgrade``Upgrade: websocket`
<img src="https://static001.geekbang.org/resource/image/ee/20/eebd74d6f1cbdf6d765adac12ebaed20.jpg" alt="">
如果服务器支持WebSocket同样会在HTTP响应里加上WebSocket相关的HTTP头部。
<img src="https://static001.geekbang.org/resource/image/14/2e/14776cea5251c30c73df754dfbd45a2e.jpg" alt="">
这样WebSocket连接就建立好了接下来WebSocket的数据传输会以frame形式传输会将一条消息分为几个frame按照先后顺序传输出去。这样做的好处有
- 大数据的传输可以分片传输,不用考虑数据大小的问题。
- 和HTTP的chunk一样可以边生成数据边传输提高传输效率。
## Tomcat如何支持WebSocket
在讲Tomcat如何支持WebSocket之前我们先来开发一个简单的聊天室程序需求是用户可以通过浏览器加入聊天室、发送消息聊天室的其他人都可以收到消息。
**WebSocket聊天室程序**
浏览器端JavaScript核心代码如下
```
var Chat = {};
Chat.socket = null;
Chat.connect = (function(host) {
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
//如果支持则创建WebSocket JS类
Chat.socket = new WebSocket(host);
} else if ('MozWebSocket' in window) {
Chat.socket = new MozWebSocket(host);
} else {
Console.log('WebSocket is not supported by this browser.');
return;
}
//回调函数当和服务器的WebSocket连接建立起来后浏览器会回调这个方法
Chat.socket.onopen = function () {
Console.log('Info: WebSocket connection opened.');
document.getElementById('chat').onkeydown = function(event) {
if (event.keyCode == 13) {
Chat.sendMessage();
}
};
};
//回调函数当和服务器的WebSocket连接关闭后浏览器会回调这个方法
Chat.socket.onclose = function () {
document.getElementById('chat').onkeydown = null;
Console.log('Info: WebSocket closed.');
};
//回调函数,当服务器有新消息发送到浏览器,浏览器会回调这个方法
Chat.socket.onmessage = function (message) {
Console.log(message.data);
};
});
```
上面的代码实现逻辑比较清晰就是创建一个WebSocket JavaScript对象然后实现了几个回调方法onopen、onclose和onmessage。当连接建立、关闭和有新消息时浏览器会负责调用这些回调方法。我们再来看服务器端Tomcat的实现代码
```
//Tomcat端的实现类加上@ServerEndpoint注解里面的value是URL路径
@ServerEndpoint(value = &quot;/websocket/chat&quot;)
public class ChatEndpoint {
private static final String GUEST_PREFIX = &quot;Guest&quot;;
//记录当前有多少个用户加入到了聊天室它是static全局变量。为了多线程安全使用原子变量AtomicInteger
private static final AtomicInteger connectionIds = new AtomicInteger(0);
//每个用户用一个CharAnnotation实例来维护请你注意它是一个全局的static变量所以用到了线程安全的CopyOnWriteArraySet
private static final Set&lt;ChatEndpoint&gt; connections =
new CopyOnWriteArraySet&lt;&gt;();
private final String nickname;
private Session session;
public ChatEndpoint() {
nickname = GUEST_PREFIX + connectionIds.getAndIncrement();
}
//新连接到达时Tomcat会创建一个Session并回调这个函数
@OnOpen
public void start(Session session) {
this.session = session;
connections.add(this);
String message = String.format(&quot;* %s %s&quot;, nickname, &quot;has joined.&quot;);
broadcast(message);
}
//浏览器关闭连接时Tomcat会回调这个函数
@OnClose
public void end() {
connections.remove(this);
String message = String.format(&quot;* %s %s&quot;,
nickname, &quot;has disconnected.&quot;);
broadcast(message);
}
//浏览器发送消息到服务器时Tomcat会回调这个函数
@OnMessage
public void incoming(String message) {
// Never trust the client
String filteredMessage = String.format(&quot;%s: %s&quot;,
nickname, HTMLFilter.filter(message.toString()));
broadcast(filteredMessage);
}
//WebSocket连接出错时Tomcat会回调这个函数
@OnError
public void onError(Throwable t) throws Throwable {
log.error(&quot;Chat Error: &quot; + t.toString(), t);
}
//向聊天室中的每个用户广播消息
private static void broadcast(String msg) {
for (ChatAnnotation client : connections) {
try {
synchronized (client) {
client.session.getBasicRemote().sendText(msg);
}
} catch (IOException e) {
...
}
}
}
}
```
根据Java WebSocket规范的规定Java WebSocket应用程序由一系列的WebSocket Endpoint组成。**Endpoint是一个Java对象代表WebSocket连接的一端就好像处理HTTP请求的Servlet一样你可以把它看作是处理WebSocket消息的接口**。跟Servlet不同的地方在于Tomcat会给每一个WebSocket连接创建一个Endpoint实例。你可以通过两种方式定义和实现Endpoint。
第一种方法是编程式的就是编写一个Java类继承`javax.websocket.Endpoint`并实现它的onOpen、onClose和onError方法。这些方法跟Endpoint的生命周期有关Tomcat负责管理Endpoint的生命周期并调用这些方法。并且当浏览器连接到一个Endpoint时Tomcat会给这个连接创建一个唯一的Session`javax.websocket.Session`。Session在WebSocket连接握手成功之后创建并在连接关闭时销毁。当触发Endpoint各个生命周期事件时Tomcat会将当前Session作为参数传给Endpoint的回调方法因此一个Endpoint实例对应一个Session我们通过在Session中添加MessageHandler消息处理器来接收消息MessageHandler中定义了onMessage方法。**在这里Session的本质是对Socket的封装Endpoint通过它与浏览器通信。**
第二种定义Endpoint的方法是注解式的也就是上面的聊天室程序例子中用到的方式即实现一个业务类并给它添加WebSocket相关的注解。首先我们注意到`@ServerEndpoint(value = "/websocket/chat")`注解它表明当前业务类ChatEndpoint是一个实现了WebSocket规范的Endpoint并且注解的value值表明ChatEndpoint映射的URL是`/websocket/chat`。我们还看到ChatEndpoint类中有`@OnOpen``@OnClose``@OnError`和在`@OnMessage`注解的方法,从名字你就知道它们的功能是什么。
对于程序员来说其实我们只需要专注具体的Endpoint的实现比如在上面聊天室的例子中为了方便向所有人群发消息ChatEndpoint在内部使用了一个全局静态的集合CopyOnWriteArraySet来维护所有的ChatEndpoint实例因为每一个ChatEndpoint实例对应一个WebSocket连接也就是代表了一个加入聊天室的用户。**当某个ChatEndpoint实例收到来自浏览器的消息时这个ChatEndpoint会向集合中其他ChatEndpoint实例背后的WebSocket连接推送消息。**
那么这个过程中Tomcat主要做了哪些事情呢简单来说就是两件事情**Endpoint加载和WebSocket请求处理**。下面我分别来详细说说Tomcat是如何做这两件事情的。
**WebSocket加载**
Tomcat的WebSocket加载是通过SCI机制完成的。SCI全称ServletContainerInitializer是Servlet 3.0规范中定义的用来**接收Web应用启动事件的接口**。那为什么要监听Servlet容器的启动事件呢因为这样我们有机会在Web应用启动时做一些初始化工作比如WebSocket需要扫描和加载Endpoint类。SCI的使用也比较简单将实现ServletContainerInitializer接口的类增加HandlesTypes注解并且在注解内指定的一系列类和接口集合。比如Tomcat为了扫描和加载Endpoint而定义的SCI类如下
```
@HandlesTypes({ServerEndpoint.class, ServerApplicationConfig.class, Endpoint.class})
public class WsSci implements ServletContainerInitializer {
public void onStartup(Set&lt;Class&lt;?&gt;&gt; clazzes, ServletContext ctx) throws ServletException {
...
}
}
```
一旦定义好了SCITomcat在启动阶段扫描类时会将HandlesTypes注解中指定的类都扫描出来作为SCI的onStartup方法的参数并调用SCI的onStartup方法。注意到WsSci的HandlesTypes注解中定义了`ServerEndpoint.class``ServerApplicationConfig.class``Endpoint.class`因此在Tomcat的启动阶段会将这些类的类实例注意不是对象实例传递给WsSci的onStartup方法。那么WsSci的onStartup方法又做了什么事呢
它会构造一个WebSocketContainer实例你可以把WebSocketContainer理解成一个专门处理WebSocket请求的**Endpoint容器**。也就是说Tomcat会把扫描到的Endpoint子类和添加了注解`@ServerEndpoint`的类注册到这个容器中并且这个容器还维护了URL到Endpoint的映射关系这样通过请求URL就能找到具体的Endpoint来处理WebSocket请求。
**WebSocket请求处理**
在讲WebSocket请求处理之前我们先来回顾一下Tomcat连接器的组件图。
<img src="https://static001.geekbang.org/resource/image/ea/ed/ea924a53eb834e4b07fce6a559fc37ed.jpg" alt="">
你可以看到Tomcat用ProtocolHandler组件屏蔽应用层协议的差异其中ProtocolHandler中有两个关键组件Endpoint和Processor。需要注意这里的Endpoint跟上文提到的WebSocket中的Endpoint完全是两回事连接器中的Endpoint组件用来处理I/O通信。WebSocket本质就是一个应用层协议因此不能用HttpProcessor来处理WebSocket请求而要用专门Processor来处理而在Tomcat中这样的Processor叫作UpgradeProcessor。
为什么叫UpgradeProcessor呢这是因为Tomcat是将HTTP协议升级成WebSocket协议的我们知道WebSocket是通过HTTP协议来进行握手的因此当WebSocket的握手请求到来时HttpProtocolHandler首先接收到这个请求在处理这个HTTP请求时Tomcat通过一个特殊的Filter判断该当前HTTP请求是否是一个WebSocket Upgrade请求即包含`Upgrade: websocket`的HTTP头信息如果是则在HTTP响应里添加WebSocket相关的响应头信息并进行协议升级。具体来说就是用UpgradeProtocolHandler替换当前的HttpProtocolHandler相应的把当前Socket的Processor替换成UpgradeProcessor同时Tomcat会创建WebSocket Session实例和Endpoint实例并跟当前的WebSocket连接一一对应起来。这个WebSocket连接不会立即关闭并且在请求处理中不再使用原有的HttpProcessor而是用专门的UpgradeProcessorUpgradeProcessor最终会调用相应的Endpoint实例来处理请求。下面我们通过一张图来理解一下。
<img src="https://static001.geekbang.org/resource/image/90/65/90892eab8ab21dac9dda65eed3aa5c65.jpg" alt="">
你可以看到Tomcat对WebSocket请求的处理没有经过Servlet容器而是通过UpgradeProcessor组件直接把请求发到ServerEndpoint实例并且Tomcat的WebSocket实现不需要关注具体I/O模型的细节从而实现了与具体I/O方式的解耦。
## 本期精华
WebSocket技术实现了Tomcat与浏览器的双向通信Tomcat可以主动向浏览器推送数据可以用来实现对数据实时性要求比较高的应用。这需要浏览器和Web服务器同时支持WebSocket标准Tomcat启动时通过SCI技术来扫描和加载WebSocket的处理类ServerEndpoint并且建立起了URL到ServerEndpoint的映射关系。
当第一个WebSocket请求到达时Tomcat将HTTP协议升级成WebSocket协议并将该Socket连接的Processor替换成UpgradeProcessor。这个Socket不会立即关闭对接下来的请求Tomcat通过UpgradeProcessor直接调用相应的ServerEndpoint来处理。
今天我讲了可以通过两种方式来开发WebSocket应用一种是继承`javax.websocket.Endpoint`另一种通过WebSocket相关的注解。其实你还可以通过Spring来实现WebSocket应用有兴趣的话你可以去研究一下Spring WebSocket的原理。
## 课后思考
今天我举的聊天室的例子实现的是群发消息,如果要向某个特定用户发送消息,应该怎么做呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,237 @@
<audio id="audio" title="19 | 比较Jetty的线程策略EatWhatYouKill" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/bb/f9af6500d765605b7560dd8961bae7bb.mp3"></audio>
我在前面的专栏里介绍了Jetty的总体架构设计简单回顾一下Jetty总体上是由一系列Connector、一系列Handler和一个ThreadPool组成它们的关系如下图所示
<img src="https://static001.geekbang.org/resource/image/9b/41/9b0e08e109f41b1c02b9f324c0a71241.jpg" alt="">
相比较Tomcat的连接器Jetty的Connector在设计上有自己的特点。Jetty的Connector支持NIO通信模型我们知道**NIO模型中的主角就是Selector**Jetty在Java原生Selector的基础上封装了自己的Selector叫作ManagedSelector。ManagedSelector在线程策略方面做了大胆尝试将I/O事件的侦测和处理放到同一个线程来处理充分利用了CPU缓存并减少了线程上下文切换的开销。
具体的数字是根据Jetty的官方测试这种名为“EatWhatYouKill”的线程策略将吞吐量提高了8倍。你一定很好奇它是如何实现的吧今天我们就来看一看这背后的原理是什么。
## Selector编程的一般思路
常规的NIO编程思路是将I/O事件的侦测和请求的处理分别用不同的线程处理。具体过程是
启动一个线程在一个死循环里不断地调用select方法检测Channel的I/O状态一旦I/O事件达到比如数据就绪就把该I/O事件以及一些数据包装成一个Runnable将Runnable放到新线程中去处理。
在这个过程中按照职责划分有两个线程在干活一个是I/O事件检测线程另一个是I/O事件处理线程。我们仔细思考一下这两者的关系其实它们是生产者和消费者的关系。I/O事件侦测线程作为生产者负责“生产”I/O事件也就是负责接活儿的老板I/O处理线程是消费者它“消费”并处理I/O事件就是干苦力的员工。把这两个工作用不同的线程来处理好处是它们互不干扰和阻塞对方。
## Jetty中的Selector编程
然而世事无绝对将I/O事件检测和业务处理这两种工作分开的思路也有缺点。当Selector检测读就绪事件时数据已经被拷贝到内核中的缓存了同时CPU的缓存中也有这些数据了我们知道CPU本身的缓存比内存快多了这时当应用程序去读取这些数据时如果用另一个线程去读很有可能这个读线程使用另一个CPU核而不是之前那个检测数据就绪的CPU核这样CPU缓存中的数据就用不上了并且线程切换也需要开销。
因此Jetty的Connector做了一个大胆尝试那就是用**把I/O事件的生产和消费放到同一个线程来处理**如果这两个任务由同一个线程来执行如果执行过程中线程不阻塞操作系统会用同一个CPU核来执行这两个任务这样就能利用CPU缓存了。那具体是如何做的呢我们还是来详细分析一下Connector中的ManagedSelector组件。
**ManagedSelector**
ManagedSelector的本质就是一个Selector负责I/O事件的检测和分发。为了方便使用Jetty在Java原生的Selector上做了一些扩展就变成了ManagedSelector我们先来看看它有哪些成员变量
```
public class ManagedSelector extends ContainerLifeCycle implements Dumpable
{
//原子变量表明当前的ManagedSelector是否已经启动
private final AtomicBoolean _started = new AtomicBoolean(false);
//表明是否阻塞在select调用上
private boolean _selecting = false;
//管理器的引用SelectorManager管理若干ManagedSelector的生命周期
private final SelectorManager _selectorManager;
//ManagedSelector不止一个为它们每人分配一个id
private final int _id;
//关键的执行策略,生产者和消费者是否在同一个线程处理由它决定
private final ExecutionStrategy _strategy;
//Java原生的Selector
private Selector _selector;
//&quot;Selector更新任务&quot;队列
private Deque&lt;SelectorUpdate&gt; _updates = new ArrayDeque&lt;&gt;();
private Deque&lt;SelectorUpdate&gt; _updateable = new ArrayDeque&lt;&gt;();
...
}
```
这些成员变量中其他的都好理解就是“Selector更新任务”队列`_updates`和执行策略`_strategy`可能不是很直观。
**SelectorUpdate接口**
为什么需要一个“Selector更新任务”队列呢对于Selector的用户来说我们对Selector的操作无非是将Channel注册到Selector或者告诉Selector我对什么I/O事件感兴趣那么这些操作其实就是对Selector状态的更新Jetty把这些操作抽象成SelectorUpdate接口。
```
/**
* A selector update to be done when the selector has been woken.
*/
public interface SelectorUpdate
{
void update(Selector selector);
}
```
这意味着如果你不能直接操作ManageSelector中的Selector而是需要向ManagedSelector提交一个任务类这个类需要实现SelectorUpdate接口update方法在update方法里定义你想要对ManagedSelector做的操作。
比如Connector中Endpoint组件对读就绪事件感兴趣它就向ManagedSelector提交了一个内部任务类ManagedSelector.SelectorUpdate
```
_selector.submit(_updateKeyAction);
```
这个`_updateKeyAction`就是一个SelectorUpdate实例它的update方法实现如下
```
private final ManagedSelector.SelectorUpdate _updateKeyAction = new ManagedSelector.SelectorUpdate()
{
@Override
public void update(Selector selector)
{
//这里的updateKey其实就是调用了SelectionKey.interestOps(OP_READ);
updateKey();
}
};
```
我们看到在update方法里调用了SelectionKey类的interestOps方法传入的参数是`OP_READ`意思是现在我对这个Channel上的读就绪事件感兴趣了。
那谁来负责执行这些update方法呢答案是ManagedSelector自己它在一个死循环里拉取这些SelectorUpdate任务类逐个执行。
**Selectable接口**
那I/O事件到达时ManagedSelector怎么知道应该调哪个函数来处理呢其实也是通过一个任务类接口这个接口就是Selectable它返回一个Runnable这个Runnable其实就是I/O事件就绪时相应的处理逻辑。
```
public interface Selectable
{
//当某一个Channel的I/O事件就绪后ManagedSelector会调用的回调函数
Runnable onSelected();
//当所有事件处理完了之后ManagedSelector会调的回调函数我们先忽略。
void updateKey();
}
```
ManagedSelector在检测到某个Channel上的I/O事件就绪时也就是说这个Channel被选中了ManagedSelector调用这个Channel所绑定的附件类的onSelected方法来拿到一个Runnable。
这句话有点绕其实就是ManagedSelector的使用者比如Endpoint组件在向ManagedSelector注册读就绪事件时同时也要告诉ManagedSelector在事件就绪时执行什么任务具体来说就是传入一个附件类这个附件类需要实现Selectable接口。ManagedSelector通过调用这个onSelected拿到一个Runnable然后把Runnable扔给线程池去执行。
那Endpoint的onSelected是如何实现的呢
```
@Override
public Runnable onSelected()
{
int readyOps = _key.readyOps();
boolean fillable = (readyOps &amp; SelectionKey.OP_READ) != 0;
boolean flushable = (readyOps &amp; SelectionKey.OP_WRITE) != 0;
// return task to complete the job
Runnable task= fillable
? (flushable
? _runCompleteWriteFillable
: _runFillable)
: (flushable
? _runCompleteWrite
: null);
return task;
}
```
上面的代码逻辑很简单,就是读事件到了就读,写事件到了就写。
**ExecutionStrategy**
铺垫了这么多终于要上主菜了。前面我主要介绍了ManagedSelector的使用者如何跟ManagedSelector交互也就是如何注册Channel以及I/O事件提供什么样的处理类来处理I/O事件接下来我们来看看ManagedSelector是如何统一管理和维护用户注册的Channel集合。再回到今天开始的讨论ManagedSelector将I/O事件的生产和消费看作是生产者消费者模式为了充分利用CPU缓存生产和消费尽量放到同一个线程处理那这是如何实现的呢Jetty定义了ExecutionStrategy接口
```
public interface ExecutionStrategy
{
//只在HTTP2中用到简单起见我们先忽略这个方法。
public void dispatch();
//实现具体执行策略,任务生产出来后可能由当前线程执行,也可能由新线程来执行
public void produce();
//任务的生产委托给Producer内部接口
public interface Producer
{
//生产一个Runnable(任务)
Runnable produce();
}
}
```
我们看到ExecutionStrategy接口比较简单它将具体任务的生产委托内部接口Producer而在自己的produce方法里来实现具体执行逻辑**也就是生产出来的任务要么由当前线程执行,要么放到新线程中执行**。Jetty提供了一些具体策略实现类ProduceConsume、ProduceExecuteConsume、ExecuteProduceConsume和EatWhatYouKill。它们的区别是
- ProduceConsume任务生产者自己依次生产和执行任务对应到NIO通信模型就是用一个线程来侦测和处理一个ManagedSelector上所有的I/O事件后面的I/O事件要等待前面的I/O事件处理完效率明显不高。通过图来理解图中绿色表示生产一个任务蓝色表示执行这个任务。
<img src="https://static001.geekbang.org/resource/image/23/3e/2394d237e9f7de107bfca736ffd71f3e.jpg" alt="">
- ProduceExecuteConsume任务生产者开启新线程来运行任务这是典型的I/O事件侦测和处理用不同的线程来处理缺点是不能利用CPU缓存并且线程切换成本高。同样我们通过一张图来理解图中的棕色表示线程切换。
<img src="https://static001.geekbang.org/resource/image/7e/6d/7e50ce9ec1bff55bbec777e79271066d.png" alt="">
- ExecuteProduceConsume任务生产者自己运行任务但是该策略可能会新建一个新线程以继续生产和执行任务。这种策略也被称为“吃掉你杀的猎物”它来自狩猎伦理认为一个人不应该杀死他不吃掉的东西对应线程来说不应该生成自己不打算运行的任务。它的优点是能利用CPU缓存但是潜在的问题是如果处理I/O事件的业务代码执行时间过长会导致线程大量阻塞和线程饥饿。
<img src="https://static001.geekbang.org/resource/image/43/b4/43c2dadaf5c323edf057a90ff06a71b4.png" alt="">
- EatWhatYouKill这是Jetty对ExecuteProduceConsume策略的改良在线程池线程充足的情况下等同于ExecuteProduceConsume当系统比较忙线程不够时切换成ProduceExecuteConsume策略。为什么要这么做呢原因是ExecuteProduceConsume是在同一线程执行I/O事件的生产和消费它使用的线程来自Jetty全局的线程池这些线程有可能被业务代码阻塞如果阻塞得多了全局线程池中的线程自然就不够用了最坏的情况是连I/O事件的侦测都没有线程可用了会导致Connector拒绝浏览器请求。于是Jetty做了一个优化在低线程情况下就执行ProduceExecuteConsume策略I/O侦测用专门的线程处理I/O事件的处理扔给线程池处理其实就是放到线程池的队列里慢慢处理。
分析了这几种线程策略我们再来看看Jetty是如何实现ExecutionStrategy接口的。答案其实就是实现Produce接口生产任务一旦任务生产出来ExecutionStrategy会负责执行这个任务。
```
private class SelectorProducer implements ExecutionStrategy.Producer
{
private Set&lt;SelectionKey&gt; _keys = Collections.emptySet();
private Iterator&lt;SelectionKey&gt; _cursor = Collections.emptyIterator();
@Override
public Runnable produce()
{
while (true)
{
//如何Channel集合中有I/O事件就绪调用前面提到的Selectable接口获取Runnable,直接返回给ExecutionStrategy去处理
Runnable task = processSelected();
if (task != null)
return task;
//如果没有I/O事件就绪就干点杂活看看有没有客户提交了更新Selector的任务就是上面提到的SelectorUpdate任务类。
processUpdates();
updateKeys();
//继续执行select方法侦测I/O就绪事件
if (!select())
return null;
}
}
}
```
SelectorProducer是ManagedSelector的内部类SelectorProducer实现了ExecutionStrategy中的Producer接口中的produce方法需要向ExecutionStrategy返回一个Runnable。在这个方法里SelectorProducer主要干了三件事情
1. 如果Channel集合中有I/O事件就绪调用前面提到的Selectable接口获取Runnable直接返回给ExecutionStrategy去处理。
1. 如果没有I/O事件就绪就干点杂活看看有没有客户提交了更新Selector上事件注册的任务也就是上面提到的SelectorUpdate任务类。
1. 干完杂活继续执行select方法侦测I/O就绪事件。
## 本期精华
多线程虽然是提高并发的法宝但并不是说线程越多越好CPU缓存以及线程上下文切换的开销也是需要考虑的。Jetty巧妙设计了EatWhatYouKill的线程策略尽量用同一个线程侦测I/O事件和处理I/O事件充分利用了CPU缓存并减少了线程切换的开销。
## 课后思考
文章提到ManagedSelector的使用者不能直接向它注册I/O事件而是需要向ManagedSelector提交一个SelectorUpdate事件ManagedSelector将这些事件Queue起来由自己来统一处理这样做有什么好处呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,201 @@
<audio id="audio" title="20 | 总结Tomcat和Jetty中的对象池技术" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/d4/748b53277374b2ea9f9ce5b4805341d4.mp3"></audio>
Java对象特别是一个比较大、比较复杂的Java对象它们的创建、初始化和GC都需要耗费CPU和内存资源为了减少这些开销Tomcat和Jetty都使用了对象池技术。所谓的对象池技术就是说一个Java对象用完之后把它保存起来之后再拿出来重复使用省去了对象创建、初始化和GC的过程。对象池技术是典型的以**空间换时间**的思路。
由于维护对象池本身也需要资源的开销不是所有场景都适合用对象池。如果你的Java对象数量很多并且存在的时间比较短对象本身又比较大比较复杂对象初始化的成本比较高这样的场景就适合用对象池技术。比如Tomcat和Jetty处理HTTP请求的场景就符合这个特征请求的数量很多为了处理单个请求需要创建不少的复杂对象比如Tomcat连接器中SocketWrapper和SocketProcessor而且一般来说请求处理的时间比较短一旦请求处理完毕这些对象就需要被销毁因此这个场景适合对象池技术。
## Tomcat的SynchronizedStack
Tomcat用SynchronizedStack类来实现对象池下面我贴出它的关键代码来帮助你理解。
```
public class SynchronizedStack&lt;T&gt; {
//内部维护一个对象数组,用数组实现栈的功能
private Object[] stack;
//这个方法用来归还对象用synchronized进行线程同步
public synchronized boolean push(T obj) {
index++;
if (index == size) {
if (limit == -1 || size &lt; limit) {
expand();//对象不够用了,扩展对象数组
} else {
index--;
return false;
}
}
stack[index] = obj;
return true;
}
//这个方法用来获取对象
public synchronized T pop() {
if (index == -1) {
return null;
}
T result = (T) stack[index];
stack[index--] = null;
return result;
}
//扩展对象数组长度以2倍大小扩展
private void expand() {
int newSize = size * 2;
if (limit != -1 &amp;&amp; newSize &gt; limit) {
newSize = limit;
}
//扩展策略是创建一个数组长度为原来两倍的新数组
Object[] newStack = new Object[newSize];
//将老数组对象引用复制到新数组
System.arraycopy(stack, 0, newStack, 0, size);
//将stack指向新数组老数组可以被GC掉了
stack = newStack;
size = newSize;
}
}
```
这个代码逻辑比较清晰主要是SynchronizedStack内部维护了一个对象数组并且用数组来实现栈的接口push和pop方法这两个方法分别用来归还对象和获取对象。你可能好奇为什么Tomcat使用一个看起来比较简单的SynchronizedStack来做对象容器为什么不使用高级一点的并发容器比如ConcurrentLinkedQueue呢
这是因为SynchronizedStack用数组而不是链表来维护对象可以减少结点维护的内存开销并且它本身只支持扩容不支持缩容也就是说数组对象在使用过程中不会被重新赋值也就不会被GC。这样设计的目的是用最低的内存和GC的代价来实现无界容器同时Tomcat的最大同时请求数是有限制的因此不需要担心对象的数量会无限膨胀。
## Jetty的ByteBufferPool
我们再来看Jetty中的对象池ByteBufferPool它本质是一个ByteBuffer对象池。当Jetty在进行网络数据读写时不需要每次都在JVM堆上分配一块新的Buffer只需在ByteBuffer对象池里拿到一块预先分配好的Buffer这样就避免了频繁的分配内存和释放内存。这种设计你同样可以在高性能通信中间件比如Mina和Netty中看到。ByteBufferPool是一个接口
```
public interface ByteBufferPool
{
public ByteBuffer acquire(int size, boolean direct);
public void release(ByteBuffer buffer);
}
```
接口中的两个方法acquire和release分别用来分配和释放内存并且你可以通过acquire方法的direct参数来指定buffer是从JVM堆上分配还是从本地内存分配。ArrayByteBufferPool是ByteBufferPool的实现类我们先来看看它的成员变量和构造函数
```
public class ArrayByteBufferPool implements ByteBufferPool
{
private final int _min;//最小size的Buffer长度
private final int _maxQueue;//Queue最大长度
//用不同的Bucket(桶)来持有不同size的ByteBuffer对象,同一个桶中的ByteBuffer size是一样的
private final ByteBufferPool.Bucket[] _direct;
private final ByteBufferPool.Bucket[] _indirect;
//ByteBuffer的size增量
private final int _inc;
public ArrayByteBufferPool(int minSize, int increment, int maxSize, int maxQueue)
{
//检查参数值并设置默认值
if (minSize&lt;=0)//ByteBuffer的最小长度
minSize=0;
if (increment&lt;=0)
increment=1024;//默认以1024递增
if (maxSize&lt;=0)
maxSize=64*1024;//ByteBuffer的最大长度默认是64K
//ByteBuffer的最小长度必须小于增量
if (minSize&gt;=increment)
throw new IllegalArgumentException(&quot;minSize &gt;= increment&quot;);
//最大长度必须是增量的整数倍
if ((maxSize%increment)!=0 || increment&gt;=maxSize)
throw new IllegalArgumentException(&quot;increment must be a divisor of maxSize&quot;);
_min=minSize;
_inc=increment;
//创建maxSize/increment个桶,包含直接内存的与heap的
_direct=new ByteBufferPool.Bucket[maxSize/increment];
_indirect=new ByteBufferPool.Bucket[maxSize/increment];
_maxQueue=maxQueue;
int size=0;
for (int i=0;i&lt;_direct.length;i++)
{
size+=_inc;
_direct[i]=new ByteBufferPool.Bucket(this,size,_maxQueue);
_indirect[i]=new ByteBufferPool.Bucket(this,size,_maxQueue);
}
}
}
```
从上面的代码我们看到ByteBufferPool是用不同的桶Bucket来管理不同长度的ByteBuffer因为我们可能需要分配一块1024字节的Buffer也可能需要一块64K字节的Buffer。而桶的内部用一个ConcurrentLinkedDeque来放置ByteBuffer对象的引用。
```
private final Deque&lt;ByteBuffer&gt; _queue = new ConcurrentLinkedDeque&lt;&gt;();
```
你可以通过下面的图再来理解一下:
<img src="https://static001.geekbang.org/resource/image/85/79/852834815eda15e82888ec18a81b5879.png" alt="">
而Buffer的分配和释放过程就是找到相应的桶并对桶中的Deque做出队和入队的操作而不是直接向JVM堆申请和释放内存。
```
//分配Buffer
public ByteBuffer acquire(int size, boolean direct)
{
//找到对应的桶,没有的话创建一个桶
ByteBufferPool.Bucket bucket = bucketFor(size,direct);
if (bucket==null)
return newByteBuffer(size,direct);
//这里其实调用了Deque的poll方法
return bucket.acquire(direct);
}
//释放Buffer
public void release(ByteBuffer buffer)
{
if (buffer!=null)
{
//找到对应的桶
ByteBufferPool.Bucket bucket = bucketFor(buffer.capacity(),buffer.isDirect());
//这里调用了Deque的offerFirst方法
if (bucket!=null)
bucket.release(buffer);
}
}
```
## 对象池的思考
对象池作为全局资源,高并发环境中多个线程可能同时需要获取对象池中的对象,因此多个线程在争抢对象时会因为锁竞争而阻塞, 因此使用对象池有线程同步的开销而不使用对象池则有创建和销毁对象的开销。对于对象池本身的设计来说需要尽量做到无锁化比如Jetty就使用了ConcurrentLinkedDeque。如果你的内存足够大可以考虑用**线程本地ThreadLocal对象池**,这样每个线程都有自己的对象池,线程之间互不干扰。
为了防止对象池的无限膨胀,必须要对池的大小做限制。对象池太小发挥不了作用,对象池太大的话可能有空闲对象,这些空闲对象会一直占用内存,造成内存浪费。这里你需要根据实际情况做一个平衡,因此对象池本身除了应该有自动扩容的功能,还需要考虑自动缩容。
所有的池化技术包括缓存都会面临内存泄露的问题原因是对象池或者缓存的本质是一个Java集合类比如List和Stack这个集合类持有缓存对象的引用只要集合类不被GC缓存对象也不会被GC。维持大量的对象也比较占用内存空间所以必要时我们需要主动清理这些对象。以Java的线程池ThreadPoolExecutor为例它提供了allowCoreThreadTimeOut和setKeepAliveTime两种方法可以在超时后销毁线程我们在实际项目中也可以参考这个策略。
另外在使用对象池时,我这里还有一些小贴士供你参考:
- 对象在用完后,需要调用对象池的方法将对象归还给对象池。
- 对象池中的对象在再次使用时需要重置,否则会产生脏对象,脏对象可能持有上次使用的引用,导致内存泄漏等问题,并且如果脏对象下一次使用时没有被清理,程序在运行过程中会发生意想不到的问题。
- 对象一旦归还给对象池,使用者就不能对它做任何操作了。
- 向对象池请求对象时有可能出现的阻塞、异常或者返回null值这些都需要我们做一些额外的处理来确保程序的正常运行。
## 本期精华
Tomcat和Jetty都用到了对象池技术这是因为处理一次HTTP请求的时间比较短但是这个过程中又需要创建大量复杂对象。
对象池技术可以减少频繁创建和销毁对象带来的成本实现对象的缓存和复用。如果你的系统需要频繁的创建和销毁对象并且对象的创建代价比较大这种情况下一般来说你会观察到GC的压力比较大占用CPU率比较高这个时候你就可以考虑使用对象池了。
还有一种情况是你需要对资源的使用做限制,比如数据库连接,不能无限制地创建数据库连接,因此就有了数据库连接池,你也可以考虑把一些关键的资源池化,对它们进行统一管理,防止滥用。
## 课后思考
请你想想在实际工作中,有哪些场景可以用“池化”技术来优化。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="21 | 总结Tomcat和Jetty的高性能、高并发之道" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/52/87cb1dc01eef875e851955ba5a84b652.mp3"></audio>
高性能程序就是高效的利用CPU、内存、网络和磁盘等资源在短时间内处理大量的请求。那如何衡量“短时间和大量”呢其实就是两个关键指标响应时间和每秒事务处理量TPS
那什么是资源的高效利用呢? 我觉得有两个原则:
1. **减少资源浪费**。比如尽量避免线程阻塞因为一阻塞就会发生线程上下文切换就需要耗费CPU资源再比如网络通信时数据从内核空间拷贝到Java堆内存需要通过本地内存中转。
1. **当某种资源成为瓶颈时,用另一种资源来换取**。比如缓存和对象池技术就是用内存换CPU数据压缩后再传输就是用CPU换网络。
Tomcat和Jetty中用到了大量的高性能、高并发的设计我总结了几点I/O和线程模型、减少系统调用、池化、零拷贝、高效的并发编程。下面我会详细介绍这些设计希望你也可以将这些技术用到实际的工作中去。
## I/O和线程模型
I/O模型的本质就是为了缓解CPU和外设之间的速度差。当线程发起I/O请求时比如读写网络数据网卡数据还没准备好这个线程就会被阻塞让出CPU也就是说发生了线程切换。而线程切换是无用功并且线程被阻塞后它持有内存资源并没有释放阻塞的线程越多消耗的内存就越大因此I/O模型的目标就是尽量减少线程阻塞。Tomcat和Jetty都已经抛弃了传统的同步阻塞I/O采用了非阻塞I/O或者异步I/O目的是业务线程不需要阻塞在I/O等待上。
除了I/O模型线程模型也是影响性能和并发的关键点。Tomcat和Jetty的总体处理原则是
- 连接请求由专门的Acceptor线程组处理。
- I/O事件侦测也由专门的Selector线程组来处理。
- 具体的协议解析和业务处理可能交给线程池Tomcat或者交给Selector线程来处理Jetty
将这些事情分开的好处是解耦并且可以根据实际情况合理设置各部分的线程数。这里请你注意线程数并不是越多越好因为CPU核的个数有限线程太多也处理不过来会导致大量的线程上下文切换。
## 减少系统调用
其实系统调用是非常耗资源的一个过程涉及CPU从用户态切换到内核态的过程因此我们在编写程序的时候要有意识尽量避免系统调用。比如在Tomcat和Jetty中系统调用最多的就是网络通信操作了一个Channel上的write就是系统调用为了降低系统调用的次数最直接的方法就是使用缓冲当输出数据达到一定的大小才flush缓冲区。Tomcat和Jetty的Channel都带有输入输出缓冲区。
还有值得一提的是Tomcat和Jetty在解析HTTP协议数据时 都采取了**延迟解析**的策略HTTP的请求体HTTP Body直到用的时候才解析。也就是说当Tomcat调用Servlet的service方法时只是读取了和解析了HTTP请求头并没有读取HTTP请求体。
直到你的Web应用程序调用了ServletRequest对象的getInputStream方法或者getParameter方法时Tomcat才会去读取和解析HTTP请求体中的数据这意味着如果你的应用程序没有调用上面那两个方法HTTP请求体的数据就不会被读取和解析这样就省掉了一次I/O系统调用。
## 池化、零拷贝
关于池化和零拷贝,我在专栏前面已经详细讲了它们的原理,你可以回过头看看[专栏第20期](http://time.geekbang.org/column/article/103197)和[第16期](http://time.geekbang.org/column/article/101201)。其实池化的本质就是用内存换CPU而零拷贝就是不做无用功减少资源浪费。
## 高效的并发编程
我们知道并发的过程中为了同步多个线程对共享变量的访问需要加锁来实现。而锁的开销是比较大的拿锁的过程本身就是个系统调用如果锁没拿到线程会阻塞又会发生线程上下文切换尤其是大量线程同时竞争一把锁时会浪费大量的系统资源。因此作为程序员要有意识的尽量避免锁的使用比如可以使用原子类CAS或者并发集合来代替。如果万不得已需要用到锁也要尽量缩小锁的范围和锁的强度。接下来我们来看看Tomcat和Jetty如何做到高效的并发编程的。
**缩小锁的范围**
缩小锁的范围其实就是不直接在方法上加synchronized而是使用细粒度的对象锁。
```
protected void startInternal() throws LifecycleException {
setState(LifecycleState.STARTING);
// 锁engine成员变量
if (engine != null) {
synchronized (engine) {
engine.start();
}
}
//锁executors成员变量
synchronized (executors) {
for (Executor executor: executors) {
executor.start();
}
}
mapperListener.start();
//锁connectors成员变量
synchronized (connectorsLock) {
for (Connector connector: connectors) {
// If it has already failed, don't try and start it
if (connector.getState() != LifecycleState.FAILED) {
connector.start();
}
}
}
}
```
比如上面的代码是Tomcat的StandardService组件的启动方法这个启动方法要启动三种子组件Engine、Executors和Connectors。它没有直接在方法上加锁而是用了三把细粒度的锁来分别用来锁三个成员变量。如果直接在方法上加synchronized多个线程执行到这个方法时需要排队而在对象级别上加synchronized多个线程可以并行执行这个方法只是在访问某个成员变量时才需要排队。
**用原子变量和CAS取代锁**
下面的代码是Jetty线程池的启动方法它的主要功能就是根据传入的参数启动相应个数的线程。
```
private boolean startThreads(int threadsToStart)
{
while (threadsToStart &gt; 0 &amp;&amp; isRunning())
{
//获取当前已经启动的线程数,如果已经够了就不需要启动了
int threads = _threadsStarted.get();
if (threads &gt;= _maxThreads)
return false;
//用CAS方法将线程数加一请注意执行失败走continue继续尝试
if (!_threadsStarted.compareAndSet(threads, threads + 1))
continue;
boolean started = false;
try
{
Thread thread = newThread(_runnable);
thread.setDaemon(isDaemon());
thread.setPriority(getThreadsPriority());
thread.setName(_name + &quot;-&quot; + thread.getId());
_threads.add(thread);//_threads并发集合
_lastShrink.set(System.nanoTime());//_lastShrink是原子变量
thread.start();
started = true;
--threadsToStart;
}
finally
{
//如果最终线程启动失败,还需要把线程数减一
if (!started)
_threadsStarted.decrementAndGet();
}
}
return true;
}
```
你可以看到整个函数的实现是一个**while循环**,并且是**无锁**的。`_threadsStarted`表示当前线程池已经启动了多少个线程它是一个原子变量AtomicInteger首先通过它的get方法拿到值如果线程数已经达到最大值直接返回。否则尝试用CAS操作将`_threadsStarted`的值加一如果成功了意味着没有其他线程在改这个值当前线程可以继续往下执行否则走continue分支也就是继续重试直到成功为止。在这里当然你也可以使用锁来实现但是我们的目的是无锁化。
**并发容器的使用**
CopyOnWriteArrayList适用于读多写少的场景比如Tomcat用它来“存放”事件监听器这是因为监听器一般在初始化过程中确定后就基本不会改变当事件触发时需要遍历这个监听器列表所以这个场景符合读多写少的特征。
```
public abstract class LifecycleBase implements Lifecycle {
//事件监听器集合
private final List&lt;LifecycleListener&gt; lifecycleListeners = new CopyOnWriteArrayList&lt;&gt;();
...
}
```
**volatile关键字的使用**
再拿Tomcat中的LifecycleBase作为例子它里面的生命状态就是用volatile关键字修饰的。volatile的目的是为了保证一个线程修改了变量另一个线程能够读到这种变化。对于生命状态来说需要在各个线程中保持是最新的值因此采用了volatile修饰。
```
public abstract class LifecycleBase implements Lifecycle {
//当前组件的生命状态用volatile修饰
private volatile LifecycleState state = LifecycleState.NEW;
}
```
## 本期精华
高性能程序能够高效的利用系统资源首先就是减少资源浪费比如要减少线程的阻塞因为阻塞会导致资源闲置和线程上下文切换Tomcat和Jetty通过合理的I/O模型和线程模型减少了线程的阻塞。
另外系统调用会导致用户态和内核态切换的过程Tomcat和Jetty通过缓存和延迟解析尽量减少系统调用另外还通过零拷贝技术避免多余的数据拷贝。
高效的利用资源还包括另一层含义那就是我们在系统设计的过程中经常会用一种资源换取另一种资源比如Tomcat和Jetty中使用的对象池技术就是用内存换取CPU将数据压缩后再传输就是用CPU换网络。
除此之外高效的并发编程也很重要多线程虽然可以提高并发度也带来了锁的开销因此我们在实际编程过程中要尽量避免使用锁比如可以用原子变量和CAS操作来代替锁。如果实在避免不了用锁也要尽量减少锁的范围和强度比如可以用细粒度的对象锁或者低强度的读写锁。Tomcat和Jetty的代码也很好的实践了这一理念。
## 课后思考
今天的文章提到我们要有意识尽量避免系统调用那你知道有哪些Java API会导致系统调用吗
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,56 @@
<audio id="audio" title="22 | 热点问题答疑2内核如何阻塞与唤醒进程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/14/eac0a1c2a0b89788f2dd4e71f30bf314.mp3"></audio>
在专栏的第三个模块我们学习了Tomcat连接器组件的设计**其中最重要的是各种I/O模型及其实现**。而I/O模型跟操作系统密切相关要彻底理解这些原理我们首先需要弄清楚什么是进程和线程什么是虚拟内存和物理内存什么是用户空间和内核空间线程的阻塞到底意味着什么内核又是如何唤醒用户线程的等等这些问题。可以说掌握这些底层的知识对于你学习Tomcat和Jetty的原理乃至其他各种后端架构都至关重要这些知识可以说是后端开发的“基石”。
在专栏的留言中我也发现很多同学反馈对这些底层的概念很模糊,那今天作为模块的答疑篇,我就来跟你聊聊这些问题。
## 进程和线程
我们先从Linux的进程谈起操作系统要运行一个可执行程序首先要将程序文件加载到内存然后CPU去读取和执行程序指令而一个进程就是“一次程序的运行过程”内核会给每一个进程创建一个名为`task_struct`的数据结构,而内核也是一段程序,系统启动时就被加载到内存中了。
进程在运行过程中要访问内存而物理内存是有限的比如16GB那怎么把有限的内存分给不同的进程使用呢跟CPU的分时共享一样内存也是共享的Linux给每个进程虚拟出一块很大的地址空间比如32位机器上进程的虚拟内存地址空间是4GB从0x00000000到0xFFFFFFFF。但这4GB并不是真实的物理内存而是进程访问到了某个虚拟地址如果这个地址还没有对应的物理内存页就会产生缺页中断分配物理内存MMU内存管理单元会将虚拟地址与物理内存页的映射关系保存在页表中再次访问这个虚拟地址就能找到相应的物理内存页。每个进程的这4GB虚拟地址空间分布如下图所示
<img src="https://static001.geekbang.org/resource/image/d7/86/d78cd0faf850c4efdbe00c63659e0f86.png" alt="">
进程的虚拟地址空间总体分为用户空间和内核空间低地址上的3GB属于用户空间高地址的1GB是内核空间这是基于安全上的考虑用户程序只能访问用户空间内核程序可以访问整个进程空间并且只有内核可以直接访问各种硬件资源比如磁盘和网卡。那用户程序需要访问这些硬件资源该怎么办呢答案是通过系统调用系统调用可以理解为内核实现的函数比如应用程序要通过网卡接收数据会调用Socket的read函数
```
ssize_t read(int fd,void *buf,size_t nbyte)
```
CPU在执行系统调用的过程中会从用户态切换到内核态CPU在用户态下执行用户程序使用的是用户空间的栈访问用户空间的内存当CPU切换到内核态后执行内核代码使用的是内核空间上的栈。
从上面这张图我们看到用户空间从低到高依次是代码区、数据区、堆、共享库与mmap内存映射区、栈、环境变量。其中堆向高地址增长栈向低地址增长。
请注意用户空间上还有一个共享库和mmap映射区Linux提供了内存映射函数mmap 它可将文件内容映射到这个内存区域用户通过读写这段内存从而实现对文件的读取和修改无需通过read/write系统调用来读写文件省去了用户空间和内核空间之间的数据拷贝Java的MappedByteBuffer就是通过它来实现的用户程序用到的系统共享库也是通过mmap映射到了这个区域。
我在开始提到的`task_struct`结构体本身是分配在内核空间,它的`vm_struct`成员变量保存了各内存区域的起始和终止地址,此外`task_struct`中还保存了进程的其他信息比如进程号、打开的文件、创建的Socket以及CPU运行上下文等。
在Linux中线程是一个轻量级的进程轻量级说的是线程只是一个CPU调度单元因此线程有自己的`task_struct`结构体和运行栈区但是线程的其他资源都是跟父进程共用的比如虚拟地址空间、打开的文件和Socket等。
## 阻塞与唤醒
我们知道当用户线程发起一个阻塞式的read调用数据未就绪时线程就会阻塞那阻塞具体是如何实现的呢
Linux内核将线程当作一个进程进行CPU调度内核维护了一个可运行的进程队列所有处于`TASK_RUNNING`状态的进程都会被放入运行队列中,本质是用双向链表将`task_struct`链接起来排队使用CPU时间片时间片用完重新调度CPU。所谓调度就是在可运行进程列表中选择一个进程再从CPU列表中选择一个可用的CPU将进程的上下文恢复到这个CPU的寄存器中然后执行进程上下文指定的下一条指令。
<img src="https://static001.geekbang.org/resource/image/b6/e8/b6794ae547bccdf71c0f6ea4e93012e8.png" alt="">
而阻塞的本质就是将进程的`task_struct`移出运行队列,添加到等待队列,并且将进程的状态的置为`TASK_UNINTERRUPTIBLE`或者`TASK_INTERRUPTIBLE`重新触发一次CPU调度让出CPU。
那线程怎么唤醒呢线程在加入到等待队列的同时向内核注册了一个回调函数告诉内核我在等待这个Socket上的数据如果数据到了就唤醒我。这样当网卡接收到数据时产生硬件中断内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的`task_struct`从等待队列移到运行队列,并且将`task_struct`的状态置为`TASK_RUNNING`这样进程就有机会重新获得CPU时间片。
这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。
<img src="https://static001.geekbang.org/resource/image/2e/b8/2e27945eee139201de846e6a58c031b8.png" alt="">
当read系统调用返回时CPU又从内核态切换到用户态继续执行read调用的下一行代码并且能从用户空间上的Buffer读到数据了。
## 小结
今天我们谈到了一次Socket read系统调用的过程首先CPU在用户态执行应用程序的代码访问进程虚拟地址空间的用户空间read系统调用时CPU从用户态切换到内核态执行内核代码内核检测到Socket上的数据未就绪时将进程的`task_struct`结构体从运行队列中移到等待队列并触发一次CPU调度这时进程会让出CPU当网卡数据到达时内核将数据从内核空间拷贝到用户空间的Buffer接着将进程的`task_struct`结构体重新移到运行队列这样进程就有机会重新获得CPU时间片系统调用返回CPU又从内核态切换到用户态访问用户空间的数据。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。