零拷贝
传统的文件传输
将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
发生了 4 次用户态与内核态的上下文切换,4 次数据拷贝
Why?
一次数据传输性能太低,需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
优化文件传输的性能
- 要想减少上下文切换到次数,就要减少系统调用的次数
- 用户缓冲区没有必要存在:用户空间我们并不会对数据「再加工」
如何实现零拷贝?
-
mmap + write:mmap() 替换 read() 系统调用
-
buf = mmap(file, len); write(sockfd, buf, len);
- mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,减少一次数据拷贝,write()操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中
- 还需要三次数据拷贝和四次上下文切换
-
-
sendfile:两次变一次
-
#include <sys/socket.h> ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
- 需要三次数据拷贝和两次上下文切换
- 如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术:避免缓冲区拷贝到socket
-
$ ethtool -k eth0 | grep scatter-gather scatter-gather: on
- 使用零拷贝技术的项目:Kafka、Nginx
-
PageCache
「内核缓冲区」实际上是磁盘高速缓存(PageCache)
Why?
磁盘速度慢,把读写磁盘替换成读写内存,相当于加了一块速度快的缓冲区。
How?
- PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。
- 读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
- 预读:多读取后面一部分
大文件传输
- 缓存 I/O:使用pagecache
- 直接 I/O:绕开 PageCache
在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。
-
阻塞的问题,可以用异步 I/O 来解决
- 异步 I/O 就意味着要绕开 PageCache。
在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
-
直接 I/O 应用场景:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存:MySQL
- 传输大文件的时
I/O 多路复用
Why?
Socket tcp 只能一对一通信,因为使用同步阻塞,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。
服务器单机理论最大连接数:
最大 TCP 连接数 = 客户端 IP 数×客户端端口数=2^32*2^16
How?
-
多进程模型:为每个客户端分配一个进程来处理请求。
- 主进程负责监听客户的连接,与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,创建一个子进程进行通信
进程间上下文切换的“包袱”很重,不支持10C
-
多线程模型:服务器与客户端 TCP 完成连接后,创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信。
- 频繁创建和销毁线程,系统开销大
- 线程池:避免线程的频繁创建和销毁
维护 1 万个线程,还是扛不住啊
I/O 多路复用
- 一个线程任一时刻只能处理一个请求,但是处理每个请求的事件耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,也叫时分多路复用。
-
select/poll:将已连接的 Socket 都放到一个文件描述符集合,select 函数将文件描述符集合拷贝到内核,内核遍历文件描述符集合,检查到有事件产生后,将此 Socket 标记为可读或可写, 把整个文件描述符集合拷贝回用户态里,用户态再通过遍历的方法找到可读或可写的 Socket,再对其处理。
- 2 次「遍历」文件描述符集合、2 次「拷贝」文件描述符集合
- select 使用固定长度的 BitsMap,表示文件描述符集合,FD_SETSIZE 限制默认最大值为 1024,只能监听 0~1023 的文件描述符。
- poll用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
-
epoll:解决 C10K 问题的利器
- 红黑树来跟踪进程所有待检测的文件描述字
- 内核里维护了一个链表来记录就绪事件
-
两种事件触发模式:
- 边缘触发(edge-triggered,ET):可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,和非阻塞 I/O 搭配使用
- 水平触发(level-triggered,LT):服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,select/poll 只有水平触发模式
Reactor 和 Proactor
Why?
I/O 多路复用面向过程的方式写代码的,这样的开发的效率不高。
How?
Reactor 模式:来了一个事件,Reactor 就有相对应的反应/响应。是非阻塞同步网络模式。
也叫Dispatcher模式,I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。
组成部分:
- Reactor:监听和分发事件,事件类型包含连接事件、读写事件;
- 处理资源池:处理事件,如 read -> 业务逻辑 -> send;
灵活在于:
- Reactor 的数量可以只有一个,也可以有多个;
- 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;
4 种方案选择:
-
单 Reactor 单进程 / 线程;C语言单 Reactor 单进程,Java单 Reactor 单线程
- 无法充分利用 多核 CPU 的性能
- Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;
-
单 Reactor 多进程 / 线程;
- 能够充分利用多核 CPU 的能
- 一个 Reactor 对象承担所有事件的监听和响应,在面对瞬间高并发的场景时,容易成为性能瓶颈。
-
多 Reactor 单进程 / 线程;没有性能优势,不使用。
-
多 Reactor 多进程 / 线程:Netty 和 Memcache
-
Reactor 对象:监听和分发事件;
-
Acceptor 对象:获取连接;
-
Handler 对象:处理业务;
-
Handler 对象:只负责数据的接收和发送
-
Processor 对象:业务处理
Proactor:异步网络模式
- Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。
- Proactor Initiator :创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
- Asynchronous Operation Processor :处理注册请求,并处理 I/O 操作;
- Asynchronous Operation Processor :完成 I/O 操作后通知 Proactor;
- Proactor:根据不同的事件类型回调不同的 Handler 进行业务处理;
- Handler:完成业务处理;
Linux 下的异步 I/O 是不完善的,Windows 的 IOCP,是由操作系统级别实现的异步 I/O