操作系统-IO多路复用
部分内容来源:JavaGuide
快速复习
基本Socket模型:
双方进行网络通信前,需各自创建一个 Socket,相当于客户端和服务器开了个 【口子】
读写数据都通过它
服务端:
- 调用 socket () 函数,创建网络协议为 IPv4、传输协议为 TCP 的 Socket。
- 调用 bind () 函数,给 Socket 绑定 IP 地址和端口
绑定端口目的是内核通过 TCP 头端口号找到应用程序并传递数据
绑定 IP 地址目的是机器多网卡时,内核仅将绑定网卡的包发给程序 - 绑定后调用 listen () 函数监听,对应 TCP 状态图中的 listen,可通过 netstat 命令查看端口是否被监听
- 进入监听状态后,调用 accept () 函数从内核获取客户端连接,无连接时阻塞等待
客户端:
在服务端创建好 Socket 后
客户端调用 connect () 函数发起连接,函数参数指明服务端 IP 地址和端口号,随后开始 TCP 三次握手
在 TCP 连接过程中,服务器内核为每个 Socket 维护两个队列:
- TCP 半连接队列:“还没完全建立” 的连接队列,队列中的连接未完成三次握手,服务端处于 syn_rcvd 状态
- TCP 全连接队列:“已经建立” 的连接队列,队列中的连接完成了三次握手,服务端处于 established 状态
如何服务更多的用户:
TCP Socket调用很简单,但它只能执行一对一通信,因为使用的通信方式是同步阻塞
TCP四元组:本机IP, 本机端口, 对端IP, 对端端口
因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的
所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数
公式计算->单机最大 TCP 连接数约为 2 的 48 次方
但这只是理论,服务器肯定承载不了那么大的连接数
最大连接数主要会受两个方面的限制:
- 文件描述符,一个Socket对应一个文件描述符,文件描述符默认值是 1024,不过我们可以通过 ulimit 增大文件描述符的数目
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
多进程模型:
服务器要支持多个客户端,那么可有使用多进程模型,也就是为每个客户端分配一个进程来处理请求
子进程会复制父进程的文件描述符,所以子进程可以直接使用「已连接Socket」和客户端通信子进程不需要关心「监听Socket」,只需要关心「已连接Socket」
父进程将客户服务交给子进程来处理
因此父进程不需要关心「已连接Socket」,只需要关心「监听Socket」
多线程模型:
进程里的线程可以共享进程的部分资源
比如:文件描述符列表、进程空间、代码、全局数据、堆、共享库等
这些共享资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据
因此同一个进程下的线程上下文切换的开销要比进程小得多
线程池可以解决线程的频繁创建和销毁
我们会把连接好的Socket放到一个队列里面存着,然后线程从队列中获取对应的 Socket 任务
IO多路复用:
一个进程维护多个Socket,把时间拉长来看就是多个请求复用了一个进程,这就是多路复用
进程可以通过一个系统调用函数从内核中获取多个事件
select/poll/epoll 是如何获取网络事件的呢?
- 获取事件时,先把所有连接(文件描述符)传给内核
- 内核返回产生了事件的连接
3。在用户态中再处理这些连接对应的请求即可
为什么系统文件描述符会受限?
文件描述符(FD)是操作系统用来 跟踪和管理打开的文件、Socket、管道等资源 的标识符
但系统会限制单个进程能使用的 FD 数量
原因:
- 防止系统资源被耗尽
- 防止恶意程序打开海量文件和占用所有Scoket的情况发生?
文件描述符的作用:
跟踪和管理打开的文件、Socket、管道等资源
- 统一资源访问:文件描述符将不同的资源(如普通文件、设备、管道、Socket等)抽象为统一的整数标识符,使进程可以通过相同的系统调用(如 read()、write())操作这些资源
- 进程级资源管理:每个进程独立维护自己的文件描述符表,记录当前打开的资源
select:
select
是最古老的多路复用机制之一,它使用一个文件描述符集合来监视多个文件描述符的状态变化
在 Windows 和 Unix-like 系统上都有实现
它有一个限制 即最大监视的文件描述符数量默认情况是 1024
每次调用 select
都需要将文件描述符的集合从用户空间拷贝到内核空间,性能较差
poll:
poll
是对 select
的改进,同样也是使用文件描述符集合来监视多个文件描述符的状态变化
在 Unix-like 系统上实现
与 select
相比,poll
没有最大文件描述符数量的限制
调用 poll
时只需将文件描述符集合的指针传递给内核,无需拷贝,但仍然存在效率问题
select和poll都是遍历文件描诉符来找到资源的
select和poll是基于轮询的,epoll是基于事件驱动的
epoll:
epoll
是 Linux 特有的,是 select
和 poll
的进一步改进,尤其适用于高并发的网络应用。
使用一种基于事件的模型,而不是基于轮询
epoll
使用三个 API:
epoll_create
创建epoll
实例epoll_ctl
添加、修改或删除感兴趣的文件描述符epoll_wait
等待文件描述符上的事件
相比于 select
和 poll
,epoll
可以监视大量的文件描述符,并且性能随着文件描述符数量的增加而不断提高
因为它使用了红黑树或哈希表来存储文件描述符
而不是遍历整个描述符集合
总的来说,epoll
是最好的选择,特别是在高并发的网络编程中,因为它的性能更好,支持更多的文件描述符,并且采用了基于事件的模型,避免了轮询的开销
epoll的水平触发和边缘触发:
水平触发:
当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次
即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次
因此我们程序要保证一次性将内核缓冲区的数据读取完
边缘触发:
当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒
直到内核缓冲区数据被 read 函数读完才结束
边缘触发的目的是告诉我们有数据需要读取
最基本的Socket模型
要想客户端和服务器能在网络中通信,必须使用 Socket 编程
它是进程间通信比较特别的方式,特别之处在于可跨主机间通信
Socket 中文名叫插口(套接字)
双方进行网络通信前,需各自创建一个 Socket,相当于客户端和服务器开了个 【口子】
读写数据都通过它
创建 Socket 时,可指定网络层使用 IPv4 还是 IPv6,传输层使用 TCP 还是 UDP
UDP 的 Socket 编程相对简单,这里只介绍基于 TCP 的 Socket 编程
服务端Socket编程
服务器程序需先运行,等待客户端连接和数据
服务端:
- 调用 socket () 函数,创建网络协议为 IPv4、传输协议为 TCP 的 Socket。
- 调用 bind () 函数,给 Socket 绑定 IP 地址和端口
绑定端口目的是内核通过 TCP 头端口号找到应用程序并传递数据
绑定 IP 地址目的是机器多网卡时,内核仅将绑定网卡的包发给程序 - 绑定后调用 listen () 函数监听,对应 TCP 状态图中的 listen,可通过 netstat 命令查看端口是否被监听
- 进入监听状态后,调用 accept () 函数从内核获取客户端连接,无连接时阻塞等待
客户端:
在服务端创建好 Socket 后
客户端调用 connect () 函数发起连接,函数参数指明服务端 IP 地址和端口号,随后开始 TCP 三次握手
服务端socket(),bind(),listen(),accept()
客户端connnet()发起连接
TCP连接过程
在 TCP 连接过程中,服务器内核为每个 Socket 维护两个队列:
- TCP 半连接队列:“还没完全建立” 的连接队列,队列中的连接未完成三次握手,服务端处于 syn_rcvd 状态
- TCP 全连接队列:“已经建立” 的连接队列,队列中的连接完成了三次握手,服务端处于 established 状态
当 TCP 全连接队列不为空时:
服务端的 accept () 函数从内核的 TCP 全连接队列中取出一个已完成连接的 Socket 返回应用程序,后续用其传输数据
监听的 Socket 和真正用来传数据的 Socket 是两个:
- 监听 Socket
- 已连接 Socket
连接建立后,客户端和服务端通过 read () 和 write () 函数读写数据
图解
如何服务更多的用户
前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信
因为使用的是同步阻塞的方式
当服务器在还没处理完一个客户端时网络 I/O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的
可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。
在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?
相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口
服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。
因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的
所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是**服务端单机最大 TCP 连接数约为 2 的 48 次方
这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:
-文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。
在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?
并发 1 万请求,也就是经典的 C10K 问题,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。
从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。
不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远
多进程模型
进程处理请求
基于最原始的阻塞网络I/O,如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型
也就是为每个客户端分配一个进程来处理请求
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept()函数就会返回一个「已连接Socket」
这时就通过 `fork()` 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候,几乎一模一样
不过,会根据返回值来区分是父进程还是子进程
如果返回值是0,则是子进程
如果返回值是其他的整数,就是父进程
正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接Socket」和客户端通信了。
可以发现,子进程不需要关心「监听Socket」,只需要关心「已连接Socket」
父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接Socket」,只需要关心「监听Socket」
回收资源
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程。随着僵尸进程越多,会慢慢耗尽我们的系统资源
因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源
分别
1.调用 wait()
2.waitpid() 函数
这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源
多线程模型
既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求—多线程模型
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等
这些共享资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据
因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后,通过 `pthread_create()` 函数创建线程
然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的
那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁
所谓的线程池,就是提前创建若干个线程,这样当中新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket」进行处理
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死打也是打不住的
IO多路复用
既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?
答案是有的,那就是 I/O 多路复用技术
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,
种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,
进程可以通过一个系统调用函数从内核中获取多个事件
select/poll/epoll 是如何获取网络事件的呢?
在获取事件时,先把所有连接(文件描述符)传给内核
再由内核返回产生了事件的连接
然后在用户态中再处理这些连接对应的请求即可
select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们
为什么系统文件描述符会受限
文件描述符(FD)是操作系统用来 跟踪和管理打开的文件、Socket、管道等资源 的标识符
但系统会限制单个进程能使用的 FD 数量
原因:
- 防止系统资源被耗尽
- 防止恶意程序打开海量文件和占用所有Scoket的情况发生?
操作系统的文件描述符是用来干嘛的
文件描述符(File Descriptor,简称 fd) 是一个用于访问文件或输入/输出资源的抽象标识符
文件描述符的作用:跟踪和管理打开的文件、Socket、管道等资源
- 统一资源访问
文件描述符将不同的资源(如普通文件、设备、管道、Socket等)抽象为统一的整数标识符,使进程可以通过相同的系统调用(如read()
、write()
)操作这些资源。 - 进程级资源管理
每个进程独立维护自己的文件描述符表,记录当前打开的资源。描述符的值通常是非负整数(如0
、1
、2
……),由内核动态分配
select/poll
select 实现多路复用的方式
将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式
当检查到事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里
然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合
一次是在内核态里,一个次是在用户态里
而且还会发生 2 次「拷贝」文件描述符集合
先从用户空间传入内核空间,由内核修改后,再传出到用户空间中
select 使用固定长度的 BitsMap表示文件描述符集合,而且所支持的文件描述符的个数是有限制的
在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 \(1024\),只能监听 0~1023 的文件描述符
poll实现多路复用的方式
poll 不再用 BitsMap 来存储所关注的文件描述符
取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制
当然还会受到系统文件描述符限制
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合
因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 (O(n)),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上乘,性能的损耗会呈指数级增长
epoll
先复习下epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中while(1) {int n = epoll_wait(...);for(接收到数据的socket){//处理}
}
epoll 通过两个方面,很好解决了 select/poll 的问题
第一点:红黑树保存socket
epoll在内核使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里
红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)
而select/poll内核里没有类似epoll红黑树这种保存所有待检测的socket的数据结构
所以select/poll每次操作时都传入整个socket集合给内核,每次都全量遍历检查
而epoll因为在内核维护了红黑树,可以保存所有待检测的socket,所以只需要传入一个待检测的socket
减少了内核和用户空间大量的数据拷贝和内存分配
第二点:事件驱动机制(回调)
epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件
当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中
当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数
不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率
从下图你可以看到 epoll 相关的接口作用:
epoll的方式即使监听的Socket数量越多的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常多
上限就为系统定义的进程打开的最大文件描述符个数
因而,epoll被称为解决C10K问题的利器
题外话
插个题外话,网上文章不少说,epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。
这是错误的!看过epoll内核源码的都知道,压根就没有使用共享内存这个玩意
你可以从下面这份代码看到,epoll_wait实现的内核代码中调用了__put_user函数,这个函数就是将数据从内核拷贝到用户空间。
边缘触发和水平触发
epoll 支持两种事件触发模式
分别是
边缘触发(edge - triggered,ET)
水平触发(level - triggered,LT)
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取
例子
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;
如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;
而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。
因此,我们会循环从文件描述符读写数据
那么如果文件描述符是阻塞的,没有数据可读时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用
程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率高,因为边缘触发可以减少 epoll_wait 的系统调用次数
系统调用也是有一定的开销的,毕竟也存在上下文的切换
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式
IO多路复用搭配非阻塞IO
另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用
在 Linux 下,select () 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。
也有可能在其他情况下,文件描述符被错误地报告为就绪
因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全
简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况
epoll的水平触发和边缘触发
水平触发
当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次
即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次
因此我们程序要保证一次性将内核缓冲区的数据读取完
边缘触发
当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒
直到内核缓冲区数据被 read 函数读完才结束
边缘触发的目的是告诉我们有数据需要读取