分享 IO 模式和 IO 多路复用

ane · 2018年04月18日 · 最后由 qichunren 回复于 2018年05月25日 · 5337 次阅读
本帖已被设为精华帖!

基础知识

1 用户空间和内核空间

现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。

对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。

核空间:在 liunx 中,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为 “内核空间”。

用户空间: 在 liunx 中,将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为 “用户空间)。

内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态

用户态:每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)

因为每个进程可以通过系统调用进入内核,因此,Linux 内核由系统 内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。

  • 进程寻址空间 0~4G
  • 进程在用户态只能访问 0~3G,只有进入内核态才能访问 3G~4G
  • 进程通过系统调用进入内核态
  • 每个进程虚拟空间的 3G~4G 部分是相同的

1.2 进程上下文切换(进程切换)

为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(也叫调度)。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存当前进程 A 的上下文。 上下文就是内核再次唤醒当前进程时所需要的状态,由一些对象(程序计数器、状态寄存器、用户栈等各种内核数据结构)的值组成。

这些值包括描绘地址空间的页表、包含进程相关信息的进程表、文件表等。

  1. 切换页全局目录以安装一个新的地址空间。
  2. 恢复进程 B 的上下文。

可以理解成一个比较耗资源的过程。

1.3 直接 I/O 和缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,以 write 为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。

IO 模式

对于一次 IO 访问(这回以 read 举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个 read 操作发生时,它会经历两个阶段:

  • 1. 等待数据准备 (Waiting for the data to be ready)
  • 2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux 系统产生了下面五种网络模式的方案:

  • -- 阻塞 I/O(blocking IO)
  • -- 非阻塞 I/O(nonblocking IO)
  • -- I/O 多路复用( IO multiplexing)
  • -- 信号驱动 I/O( signal driven IO)
  • -- 异步 I/O(asynchronous IO)

block I/O 模型(阻塞 I/O)

read 为例:

(1)进程发起 read,进行 recvfrom 系统调用;

(2)内核开始第一阶段,准备数据(从磁盘拷贝到缓冲区),进程请求的数据并不是一下就能准备好;准备数据是要消耗时间的;

(3)与此同时,进程阻塞(进程是自己选择阻塞与否),等待数据 ing;

(4)直到数据从内核拷贝到了用户空间,内核返回结果,进程解除阻塞。

也就是说,内核准备数据和数据从内核拷贝到进程内存地址这两个过程都是阻塞的。

non-block(非阻塞 I/O 模型)

可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

(1)当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好;

(2)那么它并不会 block 用户进程,而是立刻返回一个 error,从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果;

(3)用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call;

(4)那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO 的特点是用户进程在内核准备数据的阶段需要不断的主动询问数据好了没有。

I/O 多路复用

I/O 多路复用实际上就是用 select, poll, epoll 监听多个 io 对象,对一个 IO 端口,两次调用,两次返回,比阻塞 IO 并没有什么优越性;关键是能实现同时对多个 IO 端口进行监听;当 io 对象有变化(有数据)的时候就通知用户进程。现在先来看下 I/O 多路复用的流程:

(1)当用户进程调用了 select,那么整个进程会被 block;

(2)而同时,kernel 会 “监视” 所有 select 负责的 socket;

(3)当任何一个 socket 中的数据准备好了,select 就会返回;

(4)这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select() 函数就可以返回。

这个图和 blocking IO 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking IO 只调用了一个 system call (recvfrom)。 但是,用 select 的优势在于它可以同时处理多个 connection。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用多线程 + 阻塞 IO 的 web server 性能更好,可能延迟还更大。

select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

在 IO multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。

信号驱动 I/O 模型

我们首先开启套接口的信号驱动 I/O 功能,并通过 sigaction 系统调用安装一个信号处理函数。该系统调用立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 recvfrom 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。

无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达期间,进程不被阻塞。主循环可以继续执行,只要不时等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

asynchronous I/O(异步 I/O)

异步 I/O(asynchronous I/O)由 POSIX 规范定义。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到我们自己的缓冲区)完成后通知我们。这种模型与信号驱动模型的主要区别在于:信号驱动 I/O 是由内核通知我们何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知我们 I/O 操作何时完成。

我们调用 aio_read 函数(POSIX 异步 I/O 函数以 aio_或 lio_开头),给内核传递描述字、缓冲区指针、缓冲区大小(与 read 相同的三个参数)、文件偏移(与 lseek 类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,在等待 I/O 完成期间,我们的进程不被阻塞。

(1)用户进程发起 read 操作之后,立刻就可以开始去做其它的事。

(2)而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。

(3)然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。

select/poll/epoll 的区别及其 Ruby 示例

首先前文已述 I/O 多路复用的本质就是用 select/poll/epoll,去监听多个 socket 对象,如果其中的 socket 对象有变化,只要有变化,用户进程就知道了。

select 是不断轮询去监听的 socket,socket 个数有限制,一般为 1024 个;

poll 还是采用轮询方式监听,只不过没有个数限制;

epoll 并不是采用轮询方式去监听了,而是当 socket 有变化时通过回调的方式主动告知用户进程。

阻塞 I/O(blocking IO)

require 'socket'

one_hundred_kb = 1024 * 100

Socket.tcp_server_loop(4481) do |connection|
  begin
    #  readpartial(1) It blocks only if ios has no data immediately available.
    while data = connection.readpartial(one_hundred_kb) do
      puts data
    end
  rescue EOFError # EOFError Raised by some IO operations when reaching the end of file.
  end
  connection.close
end


非阻塞 I/O(nonblocking IO)

IO.select 会阻塞并且轮询 socket

read

require 'socket'

Socket.tcp_server_loop(4481) do |connection|
  loop do
    begin
      puts connection.read_nonblock(4096)
    rescue Errno::EAGAIN
      IO.select([connection])
      retry
    rescue EOFError
      break
    end
  end

  connection.close
end

wirte

require 'socket'

client = TCPSocket.new('localhost',4481)

payload = 'hihihihi' * 10_00

begin
  loop do
    bytes = client.wirte_nonblock(payload)
    break if bytes >= payload.size

    payload.slice!(0,bytes)
    IO.select(nil,[client])
  end
rescue Errno::EAGAIN
  IO.select(nil,[client])
  retry
end

除了非阻塞读写,server 端有非阻塞接受 accept_nonblock。client 有非阻塞连接 connect_nonblock

I/O 多路复用( IO multiplexing)

select 版本

  connections = [<TCPSocket>,<TCPSocket>,<TCPSocket>]
  loop do
    ready = IO.select connections

    readable_connections = ready[0]
    readable_connections.each do |conn|
      begin
        conn.read_nonblock(4096)
      rescue Errno::EAGAIN
      end
    end
  end
``
使用IO.select 可以减低无用连接的开销。
共收到 7 条回复
jasl 将本帖设为了精华贴 04月19日 14:02

催更~

加上参考文献更佳

《UNIX 网络编程卷 1:套接字网络 API》第六章 I/O 复用: select 和 poll 函数。 epoll 应该还减少了内核的拷贝。 等 lz 的 Ruby 示例

不错阿,复习了操作系统里面的一些概念

感谢,通俗易懂

pathbox 回复

其实我是只是想保存的。还没有想发布了 😂

之前读相关内容做的笔记 https://ruby-china.org/topics/34062

8楼 已删除
9楼 已删除

这些偏底层的东西,我觉得还是直接使用 C 代码来练习和理解,会理解得更透彻。使用 Ruby 搞 WEB 开发基本用不到这些。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册