文件描述符与Socket
文件描述符(File Descriptor,简称FD)是操作系统内核用于访问可以进行 I/O 的资源的一个抽象标识符。
Linux 万物皆文件, 在操作系统看来, 一个 Socket 对象就是一个可以 IO 的资源, 发送数据就是对 Socket 进行写操作, 接收数据就是读 Socket;
文件描述符是一个非负整数,代表一个已经打开的文件、管道、网络套接字或其他 I/O 资源;
例如, 当一个程序通过系统调用(如open
、socket
等)打开一个文件或创建一个网络连接时,操作系统会返回一个文件描述符。这个描述符用于标识该文件或连接;
后续, 该程序就可以使用该文件描述符来执行读写操作;
int fd = open("example.txt");
char buffer[100];
ssize_t bytesWritten = write(fd, buffer, bytesRead);
当不再需要访问 IO 资源时,程序应调用close
系统调用关闭文件描述符,以释放系统资源。
文件描述符可以用于标识多种I/O资源,包括:
- 普通文件:磁盘上的常规文件。
- 管道:用于进程间通信的管道。
- 网络套接字:用于网络通信的套接字。
- 设备:如打印机, 扫描仪等设备。
每个进程都有一个文件描述符表,操作系统通过该表管理进程打开的所有文件。
在生产环境下, 高并发系统必须配置同时能打开的文件描述符的最大数量, 来支持超大量的并发连接; 默认情况下, 一个进程能同时打开的文件描述符数量最大为1024; 通过修改 limits.conf 文件, 修改软硬极限值, 比如设置为 100万;
IO 时到底发生了什么
编辑
整个内存空间, 可以分为用户空间和内核空间, 每个用户程序, 都会在内存中分配一块用户空间, 放用户堆栈和代码段;
而操作系统的内核程序, 比如进程调度程序, 比如读写IO设备的程序, 运行在内核空间;
内核空间分为进程共享区域和进程私有区域; 共享区放内核程序的代码段等; 私有其余放每个进程的内核栈;
每个进程在内核态都有自己的内核栈,在执行系统调用时,使用内核栈来保存变量;
操作系统负责管理计算机资源, 系统调用(System Call)是操作系统提供给用户程序的接口, 通过系统调用, 用户程序可以请求操作系统为其提供服务, 例如读取外部设备。
用户程序发起系统调用时, 切换到内核态, 保存用户线程上下文, 然后在用户程序对应的内核栈中, 执行系统调用; 执行完毕后切换回用户态, 返回用户程序;
当前处理器是处于用户态还是内核态, 这一信息保存在CPU的处理器状态寄存器中;
例如, 调用 InputStream 的 read 函数, 最终会到一个 native 方法, 在这个方法中, 发起 read 系统调用
- 往 CPU 的一个特定寄存器中写入当前系统调用的类型号, 表示当前要发起一个 read 调用;
- 把要读取的文件描述符, 要读取的长度等信息保存到相应的寄存器中;
- 执行
int 80
指令, 产生软中断, - 在中断处理程序中
- 保存当前线程上下文到 TCB, 修改 CPU 状态寄存器, 从用户态切换到内核态, 加载内核态上下文;
- 然后根据我们第一步写入的系统调用类型号, 和文件描述符等信息, 执行相应的内核程序
- 将内核空间的缓冲区中的内容拷贝到用户空间的缓冲区中;
- 恢复用户态上下文, 切换回用户态继续执行用户程序;
- 如果内核空间的缓冲区未准备就绪, 根据当前 IO 的类型有不同的处理
- 如果是阻塞式的 IO, 那么将用户线程阻塞, CPU 向 DMA 发送读取命令; 然后就可以切换到其它线程执行了; 当 DMA 读取到内核缓冲区完毕时, 产生一个DMA中断, 中断处理程序中再去唤醒被阻塞的用户线程, 用户线程继续执行系统调用, 在内核态将数据从内核缓冲区拷贝到用户缓冲区;
- 如果是非阻塞IO, 那么系统调用将立即返回一个未准备就绪的标志;
为什么要有内核缓冲区和用户缓冲区? 用户直接去读取设备数据不行吗?
- 首先, 为什么要有缓冲区? 为了缓和CPU和外设的速度差距; 有了缓冲区, 就可以由 DMA 控制器负责拷贝数据, CPU 可以去做别的事; 拷贝好了再发起中断通知 CPU;
- 其次, 为什么要分内核态和用户态, 用户直接去访问外设数据, 是很危险的, 如果程序编写不当, 或者有恶意攻程序, 可以导致计算机不安全, 所以必须经过内核态来访问系统资源;
内核缓冲区有几个? 用户缓冲区有几个?
- 内核缓冲区只有一个
- 每个用户进程都有一个独立的用户缓冲区;
IO方式分类
同步 和 异步的侧重点在发起请求的一方
或者说在于 IO 操作到底由谁来完成;
同步: 我发起一个调用, 内核缓冲区的数据准备完毕后, 我需要等待内核把缓冲区数据拷贝到用户空间中;
异步: 我发起一个调用, 然后我就去做别的事情; 操作系统负责完成IO, 负责把数据拷贝到用户空间缓冲区, 实际处理这个调用的组件通过事件, 回调之类的手段通知我;
阻塞 和 非阻塞的侧重点在处理请求的一方
阻塞式IO: 内核如果发现数据没有准备好, 就阻塞用户线程; 等数据准备好了再唤醒;
非阻塞式IO: 如果数据没有准备好, 立即返回一个表示数据没有准备好的标记; 当前线程可以去做别的事, 也可以轮询;
同步阻塞 BIO
用户进程发起系统调用进行IO时, 如果内核的IO缓冲区没有准备好, 就阻塞用户进程, 准备好了再唤醒用户进程;
Java 中的IO流, 都是阻塞式的;
同步非阻塞 NIO
和 Java 的 NewIO 重名, 但不是一个东西, NewIO 支持非阻塞的IO多用复用模型
用户进程发起系统调用进行IO时, 如果内核的IO缓冲区没有准备好, 直接返回;
如果准备好了, 需要等待你和将数据从内核缓冲区拷贝到用户缓冲区, 拷贝完毕后系统调用返回;
在这个向用户空间拷贝的过程中, 系统调用一直没有返回, 用户程序一直等待, 所以也是同步的;
IO多路复用
基于 select , poll, epoll 的 IO 模型, 既可以设置为阻塞, 也可以设置为非阻塞; 不过无论阻塞还是非阻塞, 都是同步的;
信号驱动IO
异步, 但不彻底;
用户进程提前设置回调函数, 当内核缓冲区数据准备好以后, 通过信号通知进程运行回调函数, 但把数据从内核缓冲区读到用户的缓冲区这个过程, 用户进程还是阻塞的, 同步的;
异步 AIO
异步IO一定是非阻塞的, 阻塞不可能异步;
AIO 是彻底的异步, 用户线程通过系统调用向内核注册某个IO操作, 当IO操作彻底完成, 已经拷贝到用户空间的缓冲区中时, 才去通知用户线程;
无论时等待内核缓冲区准备就绪, 还是将内容从内核缓冲区读到用户缓冲区, 都不会阻塞用户线程;
这显然需要操作系统的支持, 目前, Windows 系统通过 IOCP 实现了真正的异步IO, Linux在2.6版本才引入AIO模型, JDK 对其支持并不完善; 而大多数服务端程序都部署在Linux 服务器上, 所以目前比如 netty, 使用都是 IO 多路复用模型;
IO多路复用
引入
现在, 站在服务器的视角, 假设服务器要处理大量客户端的请求
考虑使用 BIO, 主逻辑如下
ServerSocket serverSocket = new ServerSocket(17770);
while (true) {
Socket client = serverSocket.accept();
阻塞read(client, buffer);
handle(buffer);
}
问题: 如果一个先建立了连接的客户端迟迟不发送请求, 那么Server就被阻塞在 read 函数; 无法再接收建立连接的请求;
如何解决这个问题呢?
我可以给每个建立连接的客户端分配一个线程, 专门负责读取请求处理请求返回结果;
这样的话如果同时大量请求到来, 就需要创建大量线程去处理, 如果有10k个请求到来, 就要分配10k个线程去处理, 单机的操作系统是无法支撑10k个线程的, 这就是 c10k 问题;
如何解决c10k问题呢? 考虑使用同步非阻塞, 一个线程负责监听连接建立请求, 并将建立连接得到的 Socket 保存到一个集合中(比如链表);
另一个线程不断循环遍历 Socket 集合, 非阻塞的方式去读 Socket; 如果发现读成功, 就处理, 否则向后遍历;
也可以做到同一个线程里;
// 线程1
ServerSocket serverSocket = new ServerSocket(17770);
while (true) {
Socket client = serverSocket.accept();
list.add(client);
}
// 线程2
while(true){
Socket client = nextSocket();
if((非阻塞read(client, buffer)) == -1)
continue;
else
handle(buffer);
}
现在, 不会再因为某个 Socket 阻塞而影响对其它 Socket 的处理; 也不需要为每个连接分配一个线程;
但是, 每遍历到一个 Socket, 就需要调用一次 read 函数, 就需要从用户态切换到内核态; 大量的切换状态操作带来额外开销;
并且即使没有任何连接建立, CPU也会一直空转, 浪费CPU资源;
这时就可以使用IO多路复用了;
select, poll, epoll 都是IO多路复用的具体实现;
IO多路复用, 由一个或者几个线程去监控多个网络请求,当检测到有数据准备就绪之后再分配对应的线程去读取数据;
select
一句话总结: 和非阻塞 IO 模型相似, 但是把整个遍历操作转移到内核态进行; 减少了用户态和内核态之间切换的次数;
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
fd_set, 本质上是一个 long 数组实现的位图; 每一位对应一个 fd 文件描述符;
select 会将三个 fd_set 中的文件描述符遍历一遍; 根据是否有遍历到可用 fd 决定阻塞还是返回;
参数:
- readfds:内核检测该集合中的IO资源是否可读。如果检测某个 IO 是否可读,需要手动把文件描述符加入该集合。
- writefds:内核检测该集合中的IO是否可写。同readfds,需要手动加入
- exceptfds:内核检测该集合中的IO是否异常。同readfds,需要手动加入
- nfds:以上三个集合中最大的文件描述符数值 + 1,例如集合是{0,1,4},那么 maxfd 就是 5
- timeout:用户线程调用select的超时时长。
- 设置成NULL,表示如果没有 I/O 事件发生,则将调用 select 的线程阻塞, 有数据可用后再唤醒。
- 设置为非0的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回。
- 设置成 0,表示非阻塞,检测完毕立即返回。
函数返回值:
- 大于0:成功,返回集合中已就绪的IO总个数
- 等于-1:调用失败
- 等于0:没有就绪的IO
到底哪些 IO 资源可用:
- 内核复用了传入的 readfds, writefds 和 exceptfds;
- 将可用的 fd 对应的位置为1, 来作为可用的标记;
优点: 不需要对每个 fd 都进行一次系统调用, 解决用户态和内核态频繁切换的问题;
缺点:
- 单个进程能监听的 fd 数量有限, fd_set 的大小是 1024 位; 最多可以监听 1024 个 fd;
- 每次调用都需要将所有 fd 从用户态拷贝到内核态;
- 返回后, 需要遍历 fd_set 的每一位判断到底是哪些 fd 就绪;
- fd_set 每次调用后都会被修改, 每次调用都要重新设置一遍;
poll
Poll 和 Select 基本类似。和 Select 相比,它使用了不同的方式存储文件描述符,解决文件描述符的个数限制。
传入的是 pollfd 数组, 内核实际上转换为链表存储;
struct pollfd {
int fd; // 文件描述符
short events; // 该文件描述符上要监听的事件, 例如可读, 可写, 有错误;
short revents; // 该文件描述符上发生的si'ji
};
// nfds:描述数组 fds 的大小
// timeout 同select
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
优点: 不需要对每个 fd 都进行一次系统调用, 解决用户态和内核态频繁切换的问题;
缺点:
- 每次调用都需要将所有 fd 从用户态拷贝到内核态;
- 返回后, 需要遍历 fds 判断到底是哪些 fd 就绪;
epoll
编辑
编辑
首先调用 epoll_create
创建一个 epoll
, 获得其文件描述符;
然后将要监听的 fd
和 事件
封装成 epoll_event
, 调用 epoll_ctl
, 添加到 epoll
中;
之后, 就可以调用 epoll_wait 获取就绪的 epoll_event;
epoll_event
中封装了事件的类型
, 对应的文件描述符
; 所以获取到 epoll_event
以后就可以分解出就绪的 fd
; 可以在调用 epoll_wait
的线程中处理, 也可以另起新线程处理;
epoll_create
在内核态创建一个 eventpoll
数据结构; 即一个 epoll
对应一个 eventpoll
;
在 eventpoll
中有三个成员, 分别是
等待队列
: 如果一个用户线程调用 epoll_wait
时就绪列表为空, 这个用户线程将被阻塞, 并保存在 eventpoll
的 等待队列中;
就绪列表
: 保存就绪的 fd
;
红黑树
: 保存所有被监听的未就绪的fd
;
编辑
过程:
- 调用
epoll_create
后在内核态创建一个eventpoll
, 对应一个 epoll 实例, 返回一个文件描述符; - 当一个
epoll_event
被添加到epoll
时, 在内核态进一步封装, 为其指定一个回调函数; - 被添加的
fd
被保存在了内核空间中的eventpoll
的红黑树中, 以后调用epoll_wait
时, 就不需要再重复传相同的fd
了
并且这个插入的时间复杂度时log2n级别的; - 假设目前就绪队列为空, 调用了一次
epoll_wait
, 这时当前用户线程被阻塞, 添加到 epoll 的等待队列中; - 当被监听的一个 fd 就绪时, 触发中断, 在中断处理函数中调用该 fd 对应的回调函数, 该回调函数将就绪的 fd 从红黑树移除, 添加到就绪队列中; 然后唤醒等待队列中的线程;
- 而如果是就绪队列非空时调用
epoll_wait
, 就会返回就绪队列中就绪的fd
;
优点: 相对于select,
- 没有监听 fd 数量的限制;
- 每个 fd 仅在
epoll_ctl
时拷贝一次, 每次调用epoll_wait
时不需要将要监听的所有 fd 拷贝到内核态; - 内核态不需要遍历
fd
来确定哪些fd
就绪; 而是由回调函数自动将就绪的fd
添加到就绪列表; - 用户线程在
epoll_wait
返回之后不需要遍历调用结果来确定到底是哪个 fd 就绪;
缺点:
1. 只有 Linux 实现了epoll, 其它平台没有;
2. epoll的空转bug可以去了解一下;
3. 实现复杂, 在监听的 fd 数量很少的场景下, 性能可能不如 select;
水平触发:
默认工作模式,当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;等到下次调用 epoll_wait 时,会再次通知此事件;
只要读缓冲不空, 或者写缓冲不满, 就一直认为 fd 就绪;
边缘触发:
当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次通知此事件;
只有读写缓冲区的有效数据长度发生变化时, 才认为 fd 就绪;