高并发IO的底层原理
一、IO读写基础原理
用户程序进行IO的读写,会用到底层的read&write两大系统调用。无论是调用操作系统的read,还是write,都会涉及缓冲区。调用read,是把数据从内核缓冲区复制到进程缓冲区;而write,是把数据从进程缓冲区复制到内核缓冲区。也就是说,上层程序的IO操作,实际上不是物理设备级别的读写,而是缓存的复制。数据在内核缓冲区和物理设备(如磁盘)之间的交换,是由操作系统内核(Kernel)来完成的。
内核缓冲区与进程缓冲区
外部设备的直接读写,涉及操作系统的中断。发生系统中断时,需要保存之前的进程数据和状态等信息,而结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少这种底层系统的时间损耗、性能损耗,于是出现了内存缓冲区。每个用户程序(进程),有自己独立的缓冲区,叫作进程缓冲区。
典型的网络IO系统调用流程,如图:
如果是在Java服务器端,完成一次socket请求和响应,完整的流程如下:
- 客户端请求:Linux通过网卡读取客户端的请求数据,将数据读取到内核缓冲区。
- 获取请求数据:Java服务器通过read系统调用,从Linux内核缓冲区读取数据,再送入Java进程缓冲区。
- 服务器端业务处理:Java服务器在自己的用户空间中处理客户端的请求。
- 服务器端返回数据:Java服务器完成处理后,构建好的响应数据,将这些数据从用户缓冲区写入内核缓冲区。这里用到的是write系统调用。
- 发送给客户端:Linux内核通过网络IO,将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议,会将数据发送给目标客户端。
二、四种主要的IO模型
阻塞与非阻塞:阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间执行用户的操作。阻塞指的是用户空间程序的执行状态。
同步与异步:同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则反过来,是指系统内核是主动发起IO请求的一方(通知或回调),用户空间的线程是被动接受方。
1. 同步阻塞IO(Blocking IO)
在Java中,默认创建的socket都是同步阻塞的。在阻塞式IO模型中,Java应用程序从IO系统调用开始,直到系统调用返回,在这段时间内,Java进程是阻塞的。返回成功后,应用进程开始处理用户空间的缓存区数据。
同步阻塞IO流程,如图:
阻塞IO特点:在内核进行IO执行的两个阶段(等待数据和复制数据),用户线程都被阻塞了。
阻塞IO优点:应用的程序开发非常简单;在阻塞等待数据期间,用户线程挂起,基本不会占用CPU资源。
阻塞IO缺点:一般情况下,会为每个连接配备一个独立的线程;反过来说,就是一个线程维护一个连接的IO操作。在并发量小的情况下,这样做没有什么问题。但是,当在高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上阻塞IO模型在高并发应用场景下是不可用的。
2. 同步非阻塞IO(Non-blocking IO)
这里的NIO并非Java的NIO(New IO),Java的NIO,对应的不是四种基础IO模型中的NIO(None Blocking IO)模型,而是另外的一种模型,叫作IO多路复用模型(IO Multiplexing)。
在NIO模型中,应用程序一旦开始IO系统调用,会出现以下两种情况:
1. 在内核缓冲区中没有数据的情况下,系统调用会立即返回,返回一个调用失败的信息。
2. 在内核缓冲区中有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。
同步非阻塞IO流程,如图(图源公众号“码农翻身”,侵删):
同步非阻塞IO特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。一般不单独使用,在其他模型中应用。
同步非阻塞IO优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
同步非阻塞IO缺点:不断地轮询内核,这将占用大量的CPU时间,效率低下。
3. IO多路复用(IO Multiplexing)
解决同步非阻塞IO模型中轮询等待的问题
在IO多路复用模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的系统调用为select/epoll系统调用。通过该系统调用,单个应用程序的线程,可以不断地轮询成百上千的socket连接,一旦某个socket就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统调用。
IO多路复用模型流程,如图:
- IO多路复用模型特点:涉及两种系统调用(SystemCall),一种是select/epoll(就绪查询),一种是IO操作。要使用IO多路复用模型,操作系统必须支持提供多路分离的系统调用select/epoll。负责select/epoll状态查询调用的线程,需要不断地进行select/epoll轮询,查找出达到IO操作就绪的socket连接。对于注册在选择器(Java中对应的选择器类是Selector类)上的每一个可以查询的socket连接,一般都设置成为同步非阻塞模型。
- IO多路复用模型优点:一个选择器查询线程可以同时处理成千上万个连接(Connection)。系统不必创建和维护大量线程,减小系统开销。Java的NIO(New IO)技术和Netty框架,使用的就是IO多路复用模型。在Linux系统上,使用的是epoll系统调用。
- IO多路复用模型缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。
select:
select可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理(图源公众号“码农翻身”,侵删):
- 首先一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里。
- 然后,另一个线程不再自己遍历,而是调用 select,将这批文件描述符 list 交给操作系统去遍历。
- 不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。
可以看出几个问题:
1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
epoll:
解决了 select 的上述问题:
内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
原理如图(图源公众号“码农翻身”,侵删):
多路复用快的原因:操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。
4. 异步IO(Asynchronous IO)
彻底解除线程的阻塞
对于异步IO,当用户线程发起read系统调用后,立刻就可以做其他的事,不阻塞。内核开始准备数据,并复制数据到用户缓冲区。复制完成后,内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。
异步IO模型流程,如图:
- 异步IO模型特点:在内核等待数据和复制数据的两个阶段,用户线程都不阻塞。用户线程需要接收内核的IO操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。正因为如此,异步IO有的时候也被称为信号驱动IO。
- 异步IO模型缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。
就目前而言,Windows系统下通过IOCP实现了真正的异步IO。而在Linux系统下,异步IO模型在2.6版本才引入,目前并不完善,其底层实现仍使用epoll,与IO多路复用相同,因此在性能上没有明显的优势。大多数高并发服务器端程序,都是基于Linux系统的。因而,目前这类高并发网络应用程序的开发,大多采用IO多路复用模型。
三、系统配置最大句柄
在生产环境Linux系统中,基本上都需要解除文件句柄数的限制。因为Linux的系统默认值为1024,也就是说,一个进程最多可以接受1024个socket连接。
文件句柄
也叫文件描述符,在Linux系统中,文件可分为:普通文件、目录文件、链接文件和设备文件。文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,它是一个非负整数(通常是小整数),用于指代被打开的文件。所有的IO系统调用,包括socket的读写调用,都是通过文件描述符完成的。在Linux下,可以通过ulimit -n
看到单个进程能够打开的最大文件句柄数量。Linux的系统默认值为1024。
对于高并发的应用,面临的并发连接数往往是十万级、百万级、千万级、甚至像腾讯QQ一样的上亿级。当单个进程打开的文件句柄数量,超过了系统配置上限值时,会发出“Socket/File:Can’t open so many files”的错误提示。
配置
可以通过ulimit -n 1000000
修改句柄数(最大值是1048576 ),但是ulimit命令仅对当前用户有效,如果想永久保存配置,可以编辑/etc/rc.local开机启动文件,添加如下内容:
1 | ulimit -SHn 1000000 |
增加-S和-H两个命令选项。选项-S表示软性极限值,-H表示硬性极限值。硬性极限(需要root权限)是实际的限制,就是最大可以是100万,不能再多了。软性极限是系统警告(Warning)的极限值,超过这个极限值,内核会发出警告。
终极解除Linux系统的最大文件打开数量的限制,可以编辑/etc/security/limits.conf,添加如下内容:
1 | soft nofile 1000000 |
soft nofile表示软性极限,hard nofile表示硬性极限。不可以设置unlimited。
If the images or anything used in the blog infringe your copyright, please contact me to delete them. Thank you!