分享 IO 模式和 IO 多路复用

ane · 2018年04月18日 · 最后由 wwwicbd 回复于 2018年04月23日 · 1803 次阅读
本帖已被设为精华帖!

基础知识

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 可以减低无用连接的开销。
共收到 6 条回复
jasl 将本帖设为了精华贴 04月19日 14:02

催更~

加上参考文献更佳

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

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

感谢,通俗易懂

pathbox 回复

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

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

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