IO多路复用与零拷贝
协议原理:HTTP为例
有许多服务器,比如http服务器,ftp服务器,websocket服务器等等。
举例http服务器原理:
- 创建一个ServerSocket,监听并绑定一个端口
- 一系列客户端来请求这个端口
- 服务器使用Accept,获得一个来自客户端的Socket连接对象
- 启动一个新线程处理连接 :
- 读Socket,得到字节流
- 解码协议,得到Http请求对象
- 处理Http请求,得到一个结果,封装成一个HttpResponse对象
- 编码协议,将结果序列化字节流 写Socket,将字节流发给客户端
- 继续循环步骤3
http服务器之所以叫http服务器是因为编码解码协议都是用的http协议,如果协议是redis协议,websocket协议那么就是redis服务器,websocket服务器。
BIO与NIO
NIO是由操作系统提供的系统调用。是IO多路复用的代名词。早期这个操作系统调用的名字叫select,后来逐步演化成了
- epoll(Linux操作系统)
- kqueque(MAC操作系统)
要讲NIO得先讲BIO
BIO-blocking IO(同步阻塞模式)
BIO全称叫Blocking IO,为同步阻塞模式
BIO同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时如果数据没有准备号会被阻塞。
伪代码如下
{
// 阻塞,直到有数据
read(socket, buffer);
process(buffer);
}
BIO通讯方式的特点
- 一个线程负责连接,多线程则为每一个接入开启一个线程。
- 一个请求一个应答。
- 请求之后应答之前客户端会一直等待(阻塞)。
如果使用BIO模型使用到的http服务器,会出现下面三个阻塞:
- 客户端监听(Listen)时,Accept是阻塞的,只有新连接来了,Accept才会返回,主线程才能继
- 读写socket时,Read是阻塞的,只有请求消息来了,Read才能返回,子线程才能继续处理
- 读写socket时,Write是阻塞的,只有客户端把消息收了,Write才能返回,子线程才能继续读取下一个请求
传统的BIO模式下,从头到尾的所有线程都是阻塞的,这些线程就干等着,占用系统的资源,什么事也不干。
NIO-non-blocking IO(非阻塞模式)
NIO实际上采用了Reactor 编程模型的思想,Reactor之所以能高效是采用了采用了分而治之和事件驱动的设计。
分而治之
Reactor 模式将处理过程分为多个小任务,每个任务执行一个非阻塞的操作,任务通常由一个 IO 事件触发执行。
BIO 线程是以 read->decode->process->encode->send 的顺序串行处理,NIO 将其分成了三个执行单元:读取、业务处理和发送。处理过程如下
- 读取:如果无数据可读,线程返回线程池;发生读IO事件,申请一个线程处理读取,读取结束后处理业务
- 业务处理:线程同步处理完业务后,生成响应内容并编码,返回线程池
- 发送:发生写IO事件,申请一个线程进行发送
可以看出一个明显的区别就是,一次请求的处理过程是由多个不同的线程完成的,感觉和指令的串行执行和并行执行有点类似。分而治之的关键在于非阻塞,这样就能充分利用线程,压榨 CPU,提高系统的吞吐能力。
事件驱动
通常比其他模型更高效,它使用的资源更少,不用针对每个请求启用一条线程,减少了上下文切换,减少阻塞。但任务调度可能会慢,必须手动将事件和处理动作绑定。
通常编程比较困难,它必须为服务设计多个逻辑状态,以便跟踪和中断恢复,这也是在非阻塞编程中有大量状态机运用的原因。
NIO 中总共设计了 4 种事件,每个事件发生都会调度关联的任务,分别是:
- OP_ACCEPT: 服务端监听到了一个连接,准备接收
- OP_CONNECT: 客户端与服务器连接建立成功
- OP_READ: 读事件就绪,通道有数据可读
- OP_WRITE: 写事件就绪,可以向通道写入数据
采用事件机制用一个线程把Accept,读写操作,请求处理的逻辑全干了。如果什么事都没得做,它也不会死循环,它会将线程休眠起来,直到下一个事件来了再继续干活,这样的一个线程称之为NIO线程。
用伪代码表示:
while true {
events = takeEvents(fds) // 获取事件,如果没有事件,线程就休眠
for event in events {
if event.isAcceptable {
doAccept() // 新链接来了
} elif event.isReadable {
request = doRead() // 读消息
if request.isComplete() {
doProcess()
}
} elif event.isWriteable {
doWrite() // 写消息
}
}
}
Reactor 模式
Reactor 是一种设计模式,wikipedia 对其定义如下:
Reactor 是一个或多个输入事件的处理模式,用于处理并发传递给服务处理程序的服务请求。服务处理程序判断传入请求发生的事件,并将它们同步的分派给关联的请求处理程序。
Reactor 模式按照职责不同,通常可以把线程分为 Reactor 线程、IO 线程和业务线程:
- Reactor 线程:轮询通知发生IO的通道,并分派合适的 Handler 处理
- IO 线程:执行实际的读写操作
- 业务线程:执行应用程序的业务逻辑
单线程模式
单线程版本就是用一个线程完成事件的通知、实际的 I/O 操作和业务处理。Reactor 作用就是要迅速的触发 Handler ,显然 Handler 处理的过程会导致 Reactor 变慢,此时可以将 非 IO 操作从 Reactor 线程中分离。
多线程版本
多线程版本将业务处理和 I/O 操作进行分离,Reactor 线程只关注事件分发和实际的 IO 操作,业务处理如协议的编解码都分配给线程池处理。可能会有这样的情况发生,业务处理很快,大部分的 Reactor 线程都在处理 IO,导致 CPU 闲置,降低了响应速度。
主从 Reactor 版本
主从 Reactor 版本设计了一个 主Reactor 用于处理连接接收事件,多个 从Reactor 处理实际的 I/O,分工合作,匹配 CPU 和 IO 速率。
什么是TCP 粘包/拆包 ?
基本原理就是不断从 TCP 缓冲区中读取数据,每次读取完都需要判断是否是一个完整的数据包 如果当前读取的数据不足以拼接成一个完整的业务数据包,那就保留该数据,继续从 TCP 缓冲区中读取,直到得到一个完整的数据包。如果当前读到的数据加上已经读取的数据足够拼接成一个数据包,那就将已经读取的数据拼接上本次读取的数据,构成一个完整的业务数据包传递到业务逻辑,多余的数据仍然保留,以便和下次读到的数据尝试拼接。
拆包方法:
- 固定长度
- 行拆包器
- 分隔符拆包器
- 基于长度域拆包
零拷贝
传统意义的拷贝是在发送数据的时候,传统的实现方式是:
- File.read(bytes)
- Socket.send(bytes)
这种方式需要四次数据拷贝和四次上下文切换:
- 数据从磁盘读取到内核的read buffer
- 数据从内核缓冲区拷贝到用户缓冲区
- 数据从用户缓冲区拷贝到内核的socket buffer
- 数据从内核的socket buffer拷贝到网卡接口(硬件)的缓冲区
明显上面的第二步和第三步是没有必要的,如何省去这两步需要底层操作系统支持。