缓冲I/O和直接I/O

下表列出了缓冲I/O和直接I/O的API接口列表,缓冲I/O是C语言提供的库函数,均以f打头;直接I/O是Linux的系统API,也是C语言编写的,但在原理上两者差异很大。

类型 对应API接口
缓冲I/O fopen,fclose,fseek,fflush,fread,fwrite,fprintf,fscanf...
直接I/O open,close,lseek,fsync,read,write,pread,pwrite...

在阐述二者的原理对比之前,先解释几个关键概念:

  • 应用程序内存:是通常写代码用malloc、new等分配出来的内存。
  • 用户缓冲区:C语言的FILE结构体里面的buffer。
  • 内核缓冲区:Linux操作系统的Page Cache。为了加快磁盘的I/O,Linux系统会把磁盘上的数据以Page为单位缓存在操作系统的内存里。

对于缓冲I/O,每个读写操作会有3次数据拷贝。例如读:磁盘->内核缓冲区->用户缓冲区->应用程序内存。

对于直接I/O,每个读写操作会有2次数据拷贝,跳过了用户级的缓冲。

关于缓冲I/O和直接I/O,有几点需要特别说明:

  • fflush和fsync的区别。fflush只是把数据从用户缓冲区刷到内核缓冲区而已,fsync则是把数据从内核缓冲刷到磁盘里。这意味着无论缓冲I/O,还是直接I/O,如果在写数据之后不调用fsync,此时系统断电重启,最新的部分数据会丢失。
  • 对于直接I/O,也有read/write和pread/pwrite两组不同的API。pread/pwrite在多线程读写同一个文件时很有用。

延伸阅读带缓冲I/O 和不带缓冲I/O的区别与联系

内存映射文件与零拷贝

1.内存映射文件

用户空间不分配物理内存,直接将应用程序的逻辑内存地址映射到Linux操作系统的内核缓冲区,应用程序读写的时候实际读写的是内核缓冲区。此时数据的拷贝次数减少为了1次。

2.零拷贝

当用户需要把文件中的数据发送到网络的时候,使用直接I/O的话会有4次数据拷贝,读进来2次,写回去2次。使用内存映射文件的话会有3次数据拷贝,不再经过用户程序内存,直接在内核空间中从内核缓冲区拷贝到socket缓冲区。

但如果使用零拷贝,在内核缓冲区和socket缓冲区之间不会使用数据拷贝,只是一个地址的映射,底层的网卡驱动程序要读取数据并发送到网络的时候,看似读的是socket缓冲区的数据,实际上直接读的是内核缓冲区中的数据。

在Linux系统中,零拷贝的系统API为:

#include<sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

延伸阅读
内存映射文件原理探索
零拷贝

网络I/O模型

网络I/O模型存在诸多概念,有的是操作系统层面的,有的是应用框架层面的。其中最容易混淆的是阻塞和非阻塞、同步和异步这两对概念。

1.实现层面的网络I/O模型

平时所说的网络I/O模型,主要是“Linux系统”的语境,分为四种。

同步阻塞I/O

这种模型就是系统的read和write函数,在调用的时候会阻塞,直到数据读取完成或写入成功。

同步非阻塞I/O

和同步阻塞I/O的API是一样的,只是打开fd的时候带有O_NONBLOCK参数。当调用read和write函数的时候,如果数据没有准备好,会立即返回,不会阻塞,然后让应用程序不断去轮询。

I/O多路复用

前面两种I/O模型都只能用于简单的客户端开发,对于服务器程序来说,需要处理很多的fd(连接),这两种模型都不可行,所以就有了I/O多路复用。

I/O多路复用是现在Linux系统上最成熟的网络I/O模型,有三种方式:select、poll、epoll,其中epoll的效率最高,也是目前的主流网络I/O模型。

异步I/O

所谓异步I/O,是指读写都是由操作系统来完成的,然后通过回调函数或某种其他通信机制通知应用程序。例如Windows系统的IOCP。

在上层应用的语境里,异步I/O往往指的是编程语言的网络库(asio)或网络框架(Netty)封装出来的概念,在Linux系统上底层还是由epoll这种同步模型来实现的。网络的读写由网络库或框架完成,然后以某种方式通知应用程序。所以对于“异步I/O”一词,需要注意其所在的语境。

分类 I/O模型 具体实现
同步I/O 同步阻塞I/O 阻塞式的read和write函数
同步I/O 同步非阻塞I/O 以O_NONBLOCK参数打开fd,然后执行read和write函数
同步I/O I/O多路复用 Linux中的select、poll、epoll;Java的NIO
异步I/O 异步I/O Windows IOCP;Linux aio

延伸阅读聊聊Linux 五种IO模型

2.Linux系统的I/O多路复用
1.select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • fd_set是一个bit数组,每1位表示一个fd是否有读事件或者写事件发生。
  • 返回结果还在readfds和writefds里面,操作系统会重置所有的bit位,告知应用程序到底哪个fd上有事件,应用程序需要从0到maxfds - 1遍历所有的fd,然后执行相应的read/write操作。
  • 每次select调用返回后,下一次调用select之前,需要重新维护readfds和writefds。
2.poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);

struct pollfd {
    int fd;         /* file descriptor */
    short events;   /* requested events to watch */
    short revents;  /* returned events witnessed */
};

select和poll每次调用都需要应用程序把fd的数组传进去,这个fd数组每次都要在用户态和内核态之间传递,影响效率。为此,epoll设计了“逻辑上的epfd”。epfd是个数组,把fd数组关联到上面,然后每次向内核传递的是epfd这个数字。

3.epoll
/*创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多少。其中的size并不要求是准确的数字,
只是告诉内核计划监听多少个fd。实际通过epoll_ctrl添加的fd数目可能大于这个值。*/
int epoll_create(int size);
/*将一个fd增/删/改到epfd里,对应的事件也即读写。*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*maxevents也是可以自定义的。假如有100个fd,而maxevents只设置为64,则其他fd上的事件会在下次调用
epoll_wait时返回。*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

整个epoll的过程分成三个步骤:
(1)事件注册。通过函数epoll_ctl实现。对于服务器而言,是accept、read、write三种事件;对于客户端而言,是connect、read、write三种事件。
(2)轮询事件是否就绪。通过函数epoll_wait实现。有事件发生,函数返回。
(3)事件就绪,执行实际的I/O操作。通过函数accept/read/write实现。

4.epoll的LT和ET

epoll里面有两种模式:LT(水平触发)和ET(边缘触发)。

  • 水平触发:读缓冲区只要不为空,就会一直触发读事件;写缓冲区只要不满,就会一直触发写事件。
  • 边缘触发:读缓冲区的状态,从空转为非空的时候触发一次;写缓冲区的状态,从满转为非满的时候触发一次。

关于LT和ET,有两个要注意的问题:

  1. 对于LT模式,要避免“写的死循环”问题:写缓冲区为满的概率很小,即“写的条件”基本上会一直满足,所以当用户注册了写事件却没有数据要写时,它会一直触发,因此在LT模式下写完数据一定要取消事件。
  2. 对于ET模式,要避免“short read”问题:例如用户收到100个字节,它触发1次,但用户只读了50 个字节,剩下的50个字节不读,它也不会再次触发。因此在ET模式下,一定要把读缓冲区的数据一次性读完。

在实际开发中,大家一般倾向于LT模式,这也是默认的模式。因为ET容易漏事件,一次触发如果没有处理好,就没有第二次了。虽然LT重复触发可能有少许的性能损耗,但是更安全。

延伸阅读
聊聊IO多路复用之select、poll、epoll详解
epoll原理详解及epoll反应堆模型
此文若说不清Epoll原理,那就过来掐死我!

3.服务器编程的1+N+M模型

在服务器的编程中,epoll编程的三个步骤是由不同的线程负责的,即服务器编程的1+N+M模型。

比如,一个服务器有1个监听线程,N个I/O线程,M个Worker线程。N的个数通常等于CPU核数,M的个数根据业务层决定,通常有几百个。

  • 监听线程:负责accept事件的注册和处理。和每一个发起连接请求的客户端建立socket连接,然后把socket连接移交给I/O线程,完成任务,继续监听新的客户端。
  • I/O线程:负责每个socket连接上面的read/write事件的注册和实际的socket的读写。把读到的Request放入Request队列,交由Worker线程处理。
  • Worker线程:业务线程,没有socket读写操作。对Request队列进行处理,生成Response队列然后写入,由I/O线程再回复给客户端。

I/O线程只负责read/write事件的注册和监听,执行了epoll过程中的前两个阶段,第三个阶段是在Worker线程里面做的。I/O线程监听到一个socket连接上有读事件,于是把socket连接移交给Worker线程,Worker线程读出数据,处理完业务逻辑,直接返回给客户端。之所以可以这么做,是因为I/O线程已经检测到读事件就绪,所以Worker线程读的时候不会等待。I/O线程和Worker线程之间交互,不再需要一来一回两个队列,直接是一个socket集合。

对于编写服务器程序,无论用epoll,Java NIO,或者基于Netty等网络框架,大体都是按照1+N+M的思路来做,根据业务需求做相应更改。

进程、线程和协程

1.为什么要用多线程

这里说的多线程,是指运行很多个业务线程的服务器程序。如果是4核CPU,运行4个线程,本质上仍是单线程。之所以要使用多线程,是因为服务器端的程序往往是I/O密集型的应用。多线程能带来两方面的好处:

(1)提高CPU利用率。当一个线程发生I/O时,会把该线程从CPU上调度下来,把其他的线程调度上去继续执行。
(2)提高I/O吞吐。典型的场景是,应用程序连接的MySQL或者Redis,它们提供的都是同步接口,一次只能处理一个请求。要想并发,办法是通过连接池和多线程,实现每个线程使用一个连接。好比在客户端和服务器之间开了多条通道,并行传输数据。

多线程的一个重要话题是同步,线程间的同步机制非常复杂,常用的有:

  • 锁(悲观锁,乐观锁,互斥锁、读写锁、自旋锁、公平锁、非公平锁等)。
  • Wait与Signal。
  • Condition。

基于这些基本机制,可以封装出各式各样的、便于应用层使用的同步机制,比如信号量、Future、线程池,还可以封装出各式各样的线程安全的数据结构,比如阻塞队列、并发HashMap等。

2.多进程

既然多线程可以实现并发,那为什么要设计多进程呢?因为多线程存在两个问题,一是线程间共享内存,要加线程锁,而加锁后会导致并发效率下降,同时复杂的加锁机制也将增加编码的难度;二是过多的线程会造成线程间的上下文切换,导致效率低下。

在并发编程领域,一直有一个很重要的设计原则:“不要通过共享内存来实现通信,而要通过通信来实现共享内存。”。也即是说,尽可能通过消息通信,而不是共享内存来实现进程或线程之间的同步

进程是操作系统分配资源的最小单位,进程间不共享资源,通过管道和socket等方式通信,天生符合上面的并发设计原则。而对于多线程,人们习惯于共享内存,然后通过加各种锁来实现同步。除锁的问题外,多进程还带来另外两个好处:一是减少了多线程在不同的CPU核间切换的开销;二是进程间互相独立,其中一个崩溃后,不影响其他进程的运行,对程序的可靠性很有帮助。

多进程模型的典型例子是Nginx。Nginx有一个Master进程,N个Worker进程,每个Worker进程对应一个CPU核,每个进程都是单线程的。Master进程不接收请求,负责管理功能;各个Worker进程间相互独立,并行地接收客户端的请求,也不需要像多线程那样在不同的CPU核间切换。

对于I/O密集型的应用,多进程模型要想提高I/O效率,需要以下几种办法:
(1)异步I/O。利用epoll或者真正的异步I/O异步化之后,请求可以Pipeline处理,就不需要多线程了。
(2)多线程。I/O不支持异步,就只能在每个进程里面开多个线程,每个线程都是同步地调用I/O,实际上是用多线程模拟了异步I/O。
(3)多协程。

3.多协程

多线程的一个问题是线程太多时切换的开销很大。以常用的Tomcat服务器为例,在通常配置的机器上最多也只能开几百个线程。再多的话线程切换的开销太大,并发效率反而会下降。但如果是协程的话,可以多达几万个。协程相比线程,有两个关键特点:

  • 更好地利用CPU:线程的调度是由操作系统完成的,应用程序干预不了,协程可以由应用程序自己调度。
  • 更好地利用内存:协程的堆栈大小不是固定的,用多少申请多少,内存利用率更高。

现代的编程语言像Go、Rust,原生就有协程的支持,但偏传统的Java、C++等语言没有原生支持。有一些第三方的方案,但普及程度还比较低,开发者还是习惯多线程的开发模型。

下表总结了多线程、多进程、多协程编程模型的对比。

编程模型 优势 不足
多线程 方法成熟,功能强大,在操作系统、语言和框架层面都有很好的的支持 线程同步的锁,不仅影响并发效率,也加大了编程的复杂度;线程数太多时,线程切换开销太大
多进程 避免了锁的问题,进程间相互独立运行,并发效率高 需要操作系统提供强大的IPC机制,像Java一类的虚拟机语言就支持不了;需要有异步I/O,否则还要在多进程的基础上再开多线程。因为进程切换的开销比线程更大,进程数量更少,通常和CPU核数是一个数量级
多协程 可以自主调度,并且并发度更高;堆栈大小不固定,内存利用率更高 不够成熟,Java、C++等传统语言原生不支持,普及度不够

延伸阅读
进程、线程和协程之间的区别和联系
异步、并发、协程原理

无锁(内存屏障与CAS)


与昊
222 声望634 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道