0.I/O模型和零拷贝

Apache

prefork 模型

worker 模型

event 模型

属于事件驱动模型(epoll),它和 worker 模式很像,最大的区别在于,它解决了 keepalive 场景下,长期被占用的线程的资源浪费问题

优点:单线程响应多请求,占据更少的内存,高并发下表现更优秀,会有一个专门的线程来管理 keep-alive 类型的线程,当有真实请求过来的时候,将请求传递给服务线程,执行完毕后,允许它释放

缺点:没有线程安全控制

一次完整的 I/O

I/O:input / output intput 是写,output 是读,I/O 就是对文件的读写。

I/O 分为 磁盘 I/O 和 网络 I/O,但 Linux 中一切皆文件,网络 I/O 本质是对 socket 文件(socket = ip + port)的读写。

内核空间与用户空间都在内存中,但是是严格隔离的,用户空间的程序要想使用内核空间的数据,要先把数据先从内核空间 copy 到用户空间,然后使用 copy 的这份,反之亦然。

所以每次 读 或 写 操作都要经过两个阶段:

读:

  1. 将数据从文件加载到内核空间(缓冲区),时间较长
  2. 将数据从内核空间(缓冲区)复制到用户空间,时间较短

写:

  1. 将数据从用户空间复制到内核空间(缓冲区),时间较短
  2. 将数据从内核空间(缓冲区)写入到文件中,时间较长

I/O 相关概念理解

同步/异步 关注的是 消息通知机制

同步

阻塞

  1. 用户线程发起 I/O 操作 的请求,然后挂起;
  2. 内核开始处理请求,直到两个阶段都完成,才返回结果(读操作返回读取的内容,写操作返回是否成功);
  3. 用户线程接收到结果 被激活,然后继续工作;

当然 用户线程等待的过程是不消耗 CPU 的。

非阻塞

  1. 用户线程发起 I/O 操作 的请求;
  2. 内核马上返回一个错误(EWOULDBLOCK),然后开始处理请求。
  3. 用户线程接收到错误,然后去处理其他任务,同时定时检查,如果还是返回错误(EWOULDBLOCK)就说明内核还没处理完。
  4. 直到定时检查有结果返回,用户线程才回来继续完成任务

异步

注意:只有同步才有阻塞/非阻塞的说法,异步就是异步(都异步了哪来的阻塞?)不涉及阻塞问题。

  1. 用户线程发起 I/O 操作 的请求;

  2. 内核返回 0,然后开始处理请求

  3. 用户线程接收到 0,然后去处理其他任务;

  4. 内核请求处理完后,通知线程;

    什么时候算处理完?

    • 写操作:完成第二阶段;

    • 读操作:有两种方式 NIO 和 AIO:

      1
      2
      3
      NIO:完成第一阶段就算完成,告诉用户线程”我可以读了“,第二阶段还是要阻塞用户线程。

      AIO:完成第二阶段才算完成,告诉用户线程”我读完了“,是真正的异步,但是因为第二阶段非常快,所以对比NIO提升不大,而且要实现真正的异步 I/O,操作系统需要做大量的工作,目前AIO并不成熟,所以用的不多。

    怎么通知线程?状态、通知或回调。用户线程无需关心具体使用什么方式,

    用户线程一定有个“地方”专门接收通知,对于 NIO,这个”地方“就是下文重点介绍的 select、poll、epoll;对于 AIO 就不是很清楚了

网络 IO 模型

下图是几种常见 I/O 模型的对比:

以 socket.read()为例子:BIO 里用户最关心“我要读”,NIO 里用户最关心”我可以读了”,在 AIO 模型里用户更需要关注的是“读完了”。

阻塞 I/O

最简单的 I/O 模型,用户线程在内核进行 IO 操作时阻塞,每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,apache 的 preforck 使用的是这种模式。

非阻塞 I/O

程序向内核发送请 I/O 求后一直等待内核响应,如果内核处理请求的 IO 操作不能立即返回 IO 结果,进程将不再等待,而且继续处理其他请求,但是仍然需要进程隔一段时间就要查看内核 I/O 是否完成。

上图可知,在设置连接为非阻塞时,当应用进程系统调用 recvfrom 没有数据返回时,内核会立即返回一个 EWOULDBLOCK 错误,而不会一直阻塞到数据准备好。如上图在第四次调用时有一个数据报准备好了,所以这时数据会被复制到 应用进程缓冲区 ,于是 recvfrom 成功返回数据

I/O 多路复用(重点)

参考:https://www.cnblogs.com/flashsun/p/14591563.html

多路复用就是复用线程的意思,实现单线程处理多任务。

NIO 是多路复用的基础,对于读操作,多路复用只能实现第一阶段的异步,无法实现第二阶段的异步。

I/O 多路复用 的实现方式主要有 3 种:select、poll、epoll

select

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建一个线程,负责接收客户端的连接,并把 socket fd 放到 list 里
for {
connfd := accept(listenfd)
fcntl(connfd, F_SETFL, O_NONBLOCK)
fdlist.Add(connfd)
}

// 再创建一个线程,调用select,将list交给操作系统去遍历,找到就绪的 socket fd
// select 接收一个fdList,然后遍历fdList,将就绪的fd标注状态
for {
fds := select(fdlist) // 获取标注状态之后的fdList
for _, fd := fds { // 遍历处理
if fd.Status { // 判断fd是否就绪(可读或可写)
read(fd, buffer) // 从fd读取数据
process(buffer) // 处理读取的数据
close(fd); // 关闭连接
fdList.Delete(fd) // 将处理完的fd删除
}
}
}

动图示例:

缺点:

  1. fdList 最长 1024
  2. 每次调用 select,都要将 fdList 拷贝到内核空间,内核处理完后,再拷贝到用户空间,高并发场景下这样的拷贝消耗的资源是惊人的。
  3. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。
  4. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。

poll

select 的升级版,本质上和 select 没有区别,去掉了 select 只能监听 1024 个文件描述符的限制。

epoll

epoll 针对 select 和 poll 的缺点,做了三点改进:

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

具体,操作系统提供了这三个函数。

1
2
3
int epoll_create(int size);            // 第一步,创建一个 epoll 句柄
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout); // 第三步,类似发起了 select() 调用

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 创建一个线程,负责接收客户端的连接,并把 socket fd 放到 list 里
epfd := epoll_create(n) // 创建epoll
for {
connfd := accept(listenfd)
fcntl(connfd, F_SETFL, O_NONBLOCK)
epoll_ctl(epfd, add, connfd) // 向epoll中添加connfd
}


// 再创建一个线程,调用epoll_wait,找到就绪的 socket fd
for {
fds := epoll_wait() // 获取当前有哪些fd就绪(可读或可写)
for _, fd := fds { // 遍历处理这些fd
read(fd, buffer) // 从fd读取数据
process(buffer) // 处理读取的数据
close(fd); // 关闭连接
epoll_ctl(epfd, delete, fd) // 将处理完的fd删除
}
}

动图示例:

select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 哈希表
IO 效率 每次调用都进行线性遍历,时间复杂度为 O(n) 每次调用都进行线性遍历,时间复杂度为 O(n) 事件通知方式,每当 fd 就绪,系统注册的回调函数就会被调用,将就绪 fd 放到 readyList 里面,时间复杂度 O(1)
最大连接数 1024 无上限 无上限
fd 拷贝 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态 每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态 调用 epoll_ctl 时拷贝进内核并保存,之后每次 epoll_wait 不拷贝

总结

一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 I/O

为了破这个局,程序员在用户态通过多线程来防止主线程卡死。

后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 I/O

但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。

后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 I/O 多路复用

多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的不足。


所以,IO 模型的演进,倒逼着操作系统将更多的功能加到自己的内核而已。

如果你建立了这样的思维,很容易发现网上的一些错误。

比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。

这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。

就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。一个道理。

信号驱动 I/O

不需要轮训,而是让内核在数据就绪时,发送信号通知

缺点:信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知

异步 I/O

异步 IO 与信号驱动 IO 最主要的区别是信号驱动 IO 是由内核通知应用程序何时可以进行 IO 操作,而异步 IO 则是由内核告诉用户线程 IO 操作何时完成。信号驱动 IO 当内核通知触发信号处理程序时,信号处理程序还需要阻塞在从内核空间缓冲区拷贝数据到用户空间缓冲区这个阶段,而异步 IO 直接是在第二个阶段完成后,内核直接通知用户线程可以进行后续操作了

缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O,在 Linux 系统下,Linux 2.6 才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时以 IO 复用模型模式+多线程任务的架构基本可以满足需求

Linux 提供了 AIO 库函数实现异步,但是用的很少。目前有很多开源的异步 IO 库,例如 libevent、libev、libuv 但是这些的底层都是通过 epoll 的多路复用模拟的异步 I/O。

零拷贝

通过尽量避免拷贝操作来缓解 CPU 的压力。零拷贝并没有真正做到“0”拷贝,它更多是一种思想,很多的零拷贝技术都是基于这个思想去做的优化

MMAP ( Memory Mapping )

内存映射,用户空间的内存映射到内核空间

SENDFILE

DMA 辅助的 SENDFILE

需要硬件支持