对于开发者来说,I/O是绕不过去的一个基本问题。从文件I/O到网络I/O,存在着各式各样的概念和I/O模型,所以这里首先把涉及I/O的各种概念和原理理清。
是通过写代码用malloc/free、new/delete等分配出来的内存。
C语言的FILE结构体里面的buffer。
Linux操作系统的Page Cache。为了加快磁盘的I/O,Linux系统会把磁盘上的数据以Page为单位缓存在操作系统的内存里,这里的Page是Linux系统定义的一个逻辑概念,一个Page一般为4K。
对于缓冲I/O,一个读操作会有3次数据拷贝,一个写操作,有反向的3次数据拷贝;
读:磁盘->内核缓冲->用户缓冲区->应用程序内存;
写:应用程序内存->用户缓冲区->内核缓冲区->磁盘。
对于直接I/O,一个读操作,会有2次数据拷贝,一个写操作,有反向的2次数据拷贝;
读:磁盘->内核缓冲->应用程序内存;
写:应用程序内存->内核缓冲->磁盘。
所以,所谓的直接I/O,其中直接是指没有用户级的缓冲,但操作系统本身的缓冲还是有的,两者的原理对比如下图:
关于缓冲I/O和直接I/O,有几点需要特别说明:
相比直接I/O,内存映射文件往前更进了一步。如下下图所示,当用户空间不在有物理内存,直接拿应用程序内存地址映射到Linux操作系统的内核缓冲区,应用程序虽然读写的是自己的内存,但这个内存只是一个“逻辑地址”,实际读写的是内核缓冲区!
数据拷贝次数从缓冲I/O的3次,到直接I/O的两次,再到内存映射文件,变成了1次。
读:磁盘->内核缓冲区。
写:内核缓冲区->磁盘。
在Linux系统中,内存映射文件对应的API是:
void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);
在java中,用MappedByteBuffer类可以实现同样的目的。
零拷贝(Zero Copy)是提升I/O效率的又一利器,熟悉Kafka实现原理的工程师应该知道,在消费消息的时候利用了零拷贝技术。当用户需要把文件中的数据发送到网络的时候,如果不用零拷贝,来看怎么实现。
fd1 = 打开的文件描述符
fd2 = 打开的socket描述符
buffer = 应用程序内存
read(fd1, buffer...); // 先把数据从文件中读出来
write(fd2, buffer...); // 在通过网络发出去
如下图所示,整个过程有4次数据拷贝,读进来两次,写回去两次。
磁盘->内核缓冲区->应用程序内存->Socket缓冲区->网络
fd1 = 打开的文件描述符
fd2 = 打开的socker描述符
buffer = 应用程序内存
mmap(fd1, buffer...); // 先把磁盘数据映射到buffer上
write(fd2, buffer...); // 再通过网络发出去
如上图所示,整个过程会有3次数据拷贝,不再经过应用程序内存,直接在内核空间中从内核缓冲区拷贝到Socket缓冲区。
但如果用零拷贝,可能连内核缓冲区到Socket缓冲区的拷贝也省略了。
如下图,内核缓冲区和Socket缓冲区之间并没做数据拷贝,只是一个地址映射,底层的网卡驱动程序要读取数据并发送到网络的时候,看似读的是Socket缓冲区的数据,但实际上直接读的是内核缓冲区的数据。
在这里需要分清“映射”和“拷贝”的区别。拷贝是把数据从一块内存中复制到另一块内存里;映射相当于只是持有了数据的一个的引用(或者叫地址),数据本身只有一份。
在这里,我们看到虽然叫零拷贝,实际上是2次数据拷贝,1次是磁盘到内核缓冲区,1次是内核缓冲区到网络。之所以叫零拷贝,是从内存的角度来看的,数据在内存中没有发生过数据拷贝,只是内存和I/O之间传输。
对于把文件发生到网络的这个场景,直接I/O、内存映射文件、零拷贝对于的数据拷贝分别是4、3、2,内存拷贝次数分别是2、1、0次。
在linux系统中,零拷贝的系统API为:
sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
其中out_fd传入是socket描述符,in_fd传入的是文件描述符。
在Java中,对应的是:
FileChannel.transferTo(long position, long count, WritableByteChannel target);
网络I/O模型存在诸多概念,有的是操作系统层面的,有的是应用框架层面的,这些概念往往容易混淆,本章将对网络I/O模型进行一次系统的梳理。在高并发部分,将把底层和上层放在一起,对“异步化”做全面的探讨。
说到网络I/O模型,大家往往会混淆阻塞和非阻塞、同步和异步这两对概念,最常见的概念混淆有三个:
前面两种I/O都只能用于简单的客户端开发。但对于服务器程序来说,需要处理很多的fd(连接数可以达到几十万甚至百万)。如果使用同步阻塞I/O,要处理这么多的fd需要开非常多的线程,每个线程处理一个fd;如果用同步非阻塞I/O,要应用程序轮询这么大规模的fd。这两种办法都不行,所以就有了I/O多路复用。
在Linux系统中,有三种I/O多路复用的办法:select、poll、epoll,他们的原理有一定差异,后面会专门分析。这里先以select为例介绍其用法:
int select(int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, ...);
该函数是阻塞调用,一次性把所有的fd传进去,当有fd可读或者可写之后,该函数会返回,返回结果也在这个函数的参数里面,告知应用程序哪些fd上面可读或者可写,然后下一步应用程序调用read和write函数进行数据读写。
I/O多路复用是现在Linux系统上最成熟的网络I/O模型,在三种方式中,epoll的效率最高,所以目前主流的网络模型都是epoll。
第四种模型:异步I/O。
熟悉Windows系统开发的人会知道Windows系统的IOCP,这是一种真正意义上的异步I/O。所谓异步I/O,是指读写都是由操作系统完成的,然后通过回调函数或者某种其他通信机制通知应用程序。
这样说可能还不太容易理解,下面看一个异步I/O的例子:C++中的asio网络库。asio是一个跨平台的C++网络库,也是boost的一部分,在Linux系统上封装的是epoll,在windows系统上封装的是IOCP。asio的接口是完全异步的,如下面的样例代码所示:
asio::async_read(socket_,
asio::buffer(read_msg_.data(), chat_message::header_length),
boost::bind(&chat_session::handler_read_header, shared_from_this(),
asio::placeholders::error));
asio::async_write(socket_,
asio::buffer(write_msgs_.front().data(),write_msgs_.front().length()),
boost::bind(&chat_session::handler_write, shared_from_this(),
asio::placeholders::error));
async_read/async_write函数传进去的参数主要有三个:
除了上文所说的四种I/O模型,大家还会经常听到Reactor模式和Passivity模式。它是网络框架的两种模式,无论操作系统的网络I/O模型的设计,还是上层网络框架的网络I/O模型的设计,用于都是这两种设计模式之一。
因为epoll是Linux服务器开放的主流网络I/O模型,Java NIO在linux平台也是基于epoll实现的,下面对epoll连同select、poll进行介绍。
int select(int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
关于此函数,有几点说明:
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
int fd;
// 每个fd,两个bit数组,一个进去,一个出来的
short events;
short revent;
}
通过上面的函数会发现,select、poll每次调用都需要应用程序把fd的数组传进去,这个fd的数组每次都要在用户态和内核态直接传递,影响效率。为此,epoll设计了“逻辑上的epfd”。epfd是一个数字,把fd数组关联到上面,然后每次向内核传递的是epfd这个数字。
// 创建一个epoll的句柄,size用来告诉内核监听的数目一共有多少。其中的size并不要求是准确的数据,只是告诉内核,计划监听多少个fd。实际通过epoll_ctl添加的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过程分成三个步骤:
epoll里面有两种模式,LT(水平触发)和ET(边缘触发)。水平触发又称条件触发,边缘触发又称状态触发。
水平触发:读缓冲区只要不为空,就会一直触发读事件;写缓冲区只要不满,就好一直触发写事件。
边缘触发:读缓冲区的状态,从空转为非空的时候触发一次;写缓冲区的状态,从满转为非满的时候触发一次。比如用户发送一个大文件,把写缓冲区塞满了,之后缓存区可以写了,就好发生一次从满到不满的切换。
关于LT和ET,有两个要注意的问题:
在服务器的编程中,epoll编程的三个步骤是由不同的线程负责的,即服务器编程的1+N+M模型。
如下图,整个服务器有1+N+M个线程,一个监听线程,N个I/O线程,M个Worker线程。N的个数通常等于CPU核数,M的个数根据上层决定,通常有几百个。
用Java的人通常写的是“单进程多线程”的程序;而用C++的人,可能写的是“单进程多线程”、“单进程单线程”、“多进程多线程”的程序(这里主要指Linux系统上的服务器程序)。之所以会有这样的差异,是因为Java程序并不直接运行在Linux系统上,而是运行在JVM之上。而一个JVM实例是一个Linux进程,每个JVM都是一个独立的“沙盒”,JVM之间相互独立,互不通信。所以Java程序只能在这一个进程里面,开多个线程实现并发。而C++直接运行在Linux系统上,可以直接利用Linux系统提供的强大的进程间通信机制(IPC),很容易创建多个进程,并实现进程间的通信。
“多进程多线程”是“单进程多线程”和“多进程单线程”的组合体,其原理并没有差异,所以接下来只讨论“单进程多线程”和“多进程单线程”两种编程模型,对比“多进程”和“多线程”的关键差异。
对于客户端程序,有UI交互界面,多线程不可避免,这类程序不在讨论之列。本节注意讨论的是服务器端的程序。
这里所说的“多”线程,是指运行几百个业务线程的服务器程序。如果是4核CPU,运行4个线程,本质上仍是单线程。之所以要开多线程,是因为服务器端的程序往往是I/O密集型的应用。举个极端点的例子,假设程序没有任何I/O(磁盘I/O或网络I/O),纯粹的CPU计算,如同一个最简单的、空的死循环,只需要一个线程就可以把一个CPU的核占满。
所以,多线程主要是应对I/O密集型的应用。多线程能带来两方面的好处:
除了多线程,线程间的同步机制也非常复杂,在此只列举线程间的常用同步机制:
既然多线程可以实现并发,那为什么还要设计多进程呢?因为线程存在的两个问题,一是线程间内存共享,要加线程锁;而加锁后会导致并发效率下降,同时复杂的加锁机制也将增加编码的难道;二是过多的线程造成线程间的上下文切换,导致效率低下。
在并发编程领域,一直有一个很重要的设计原则:“不要通过共享内存来实现通信,而应通过通信实现共享内存。”这句话不太好理解,换成通俗的说法就是:“尽可能通过消息通信,而不是共享内存来实现进程或者线程之间的同步。”
进程是资源分配的基本单位,进程间不共享资源,通过管道或者Socket方式通信(当然也可以共享内存),这种通信方式天生符合上面的并发设计原则。而对于大多线程,大家习惯于共享内存,然后通过各种加锁来实现同步。虽然在多线程领域也有这种思想的实现,比如Akka框架,但流行程度仍然不够。
除锁的问题之外,多进程还带来另外两个好处:一是减少了多线程在不同CPU核切换的开销;另外多进程相互独立,意味着其中一个崩溃后,其他进程可以继续运行,这对程序的可靠性很有帮助。
多进程模型的典型例子是Nginx。Nginx有一个Master进程,N个Worker进程,每个Worker进程对应一个CPU核,每个进程都是单线程的。Master进程不接收请求,负责管理功能;各个Worker进程间相互独立,并行地接收客户端的请求,也不需要向多线程那样在不同的CPU核间切换。
有了多进程后,在每个进程内部,可能是单线程,也可能是多线程,这往往取决与I/O。
比如Redis就是单进程单线程的模型(这里说的单线程模型,不是指整个Redis服务器只有一个线程,而是指接收并处理客户端请求的线程只有一个)。之所以单线程可以支持,是因为在请求接收的地方用的是epoll的I/O多路复用,在请求处理的地方又完全是内存操作,没有磁盘或者网络I/O,所以只需单线程就足够了。要利用多核也很简单,开多个Redis实例就可以了。
但对于I/O密集型的应用,要提高I/O效率,则需要下面几种办法:
多协程除锁的问题之外,还要一个问题是线程太多,切换的开销很大。虽然线程切换的开销比进程切换的开销小很多,但还是不够。以常用的Tomcat服务器为例,在通常配置的机器上最多也只能开几百个线程。如果再多,则线程切换的开销太大,并发效率反而会下降,这意味着Tomcat最多只能并发地处理几百个请求。但如果是协程的话,可以开几万个!协程相比线程,有两个关键特点:
虽然多线程的编程模型功能强大,应用也很普及,但始终绕不开锁的问题。为了提升锁的效率,前辈大师们想了诸多办法,在多线程中设计了无锁数据结构。下面就来探讨一下无锁数据结构及其背后的原理。
内存屏障的两个核心点:
如果是多线程写,则内存屏障也不够用了,这时要用到CAS。CAS是在CPU层面提供的一个硬件原子指令,实现对一个值的Compare和Set两个操作的原子化。
下面展示了JDK6中,CAS函数的源代码,unsafe类的compareAndSwapInt是一个本地方法。
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSWapInt(this, valueOffset, expect, update);
}
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
在不同的JDK版本中,不同操作系统上面,该本地方法的实现有差异,此处不再进一步展开。
基于CAS,上层可以实现乐观锁、无锁队列、无锁链表。乐观锁会在后面讲述数据库的“丢失更新”问题时详细阐述。
无锁队列也是一个比较深入的话题,有不少的论文和文章都讨论过无锁队列的实现问题。
下面介绍JDK的JUC源码中使用的无锁队列的实现方法:
基于单向链表,维护一头一尾两个引用:head和tail。入队,就是在队列的尾部追加节点,多个线程通过CAS互斥的操作tail;出队,就是移除队列的头部节点,多个线程通过CAS互斥的操作head。
至于无锁链表,比无锁队列的实现要复杂一个等级。因为无锁队列只是操作头和尾,而无锁链表可以操作中间节点,有线程要插入节点,有线程要删除节点,要安全的实现并发并非易事,本文就不展开了。
《软件架构设计:大型网站技术架构与业务架构融合之道》