【操作系统】网络管理(零拷贝)

36 阅读7分钟

零拷贝

传统的文件传输

将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

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 毫秒以内,这样 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