🌤️ 网络 IO
2022年10月10日
- os
🌤️ 网络 IO
1. 网络 IO 模型
1) 同步阻塞 IO
- 流程:
- 用户进程没有获得数据之前,一直阻塞
- 对应不同的 IO 请求,处理流程:
- 代码实现:
- 服务器端
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接 - 三次握手成功后从阻塞状态恢复
int n = read(connfd, buf); // 阻塞读数据 - 客户端数据到达用户缓冲区后从阻塞态恢复
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
- 客户端
fd = socket();
connect(fd); // 两次握手成功后从阻塞状态恢复
weite(fd, buf);
close(fd);
- 存在的问题:
- 若是 TCP 则提供 一对一 的通信,当服务端还没有处理完一个客户端的网络 IO | 读写发生阻塞时,其他客户端无法与服务端进行连接
- 如何解决:
- 多进程模型
- 主进程负责监听客户端的连接,只关心
监听套接字
, 连接成功建立后,返回连接套接字
;fork()
创建多个子进程负责和对应连接进行通信,子进程只关心连接套接字
- 子进程退出后,需要做好回收工作,防止出现
僵尸进程
,这些 僵尸进程 会交由 一个init
的进程处理,但若是太多,会占用大量内存空间- 注册
信号处理函数
,通过捕捉SIGCHILD
信号,在信号处理函数中调用wait()
|waitpid()
- 注册
- 效率不高,扩展性较差 & 资源占用率高
- 主进程负责监听客户端的连接,只关心
- 多线程模型
- 建立连接后,通过
pthread_create()
创建多个线程,每个线程负责和对应连接进行通信 - 高并发下,可采用线程池技术,一个线程负责多个连接任务
- 建立连接后,通过
- 多进程模型
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
pthread_create(doWork); // 创建一个新的线程
}
void doWork() {
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
- IO 多路复用
- 一个进程 | 线程负责一个客户端连接,开销仍很大,此时采用 IO 多路复用,一个进程负责多个 socket 连接
2) 同步非阻塞 IO
- 流程:
- 用户进程即使没有获得数据,内核也会返回当前结果,用户进程通过轮询查看数据是否准备好
- 代码实现:
- 服务器端
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接 - 三次握手成功后从阻塞状态恢复
fcntl(connfd, F_SETFL, O_NONBLOCK); // 将文件描述符设置为非阻塞
int n = read(connfd, buffer) != SUCCESS); // 非阻塞读数据 - 若没有数据到达内核缓冲区,立即返回 错误值(-1)
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
- 客户端
fd = socket();
connect(fd); // 两次握手成功后从阻塞状态恢复
weite(fd, buf);
close(fd);
3) IO 多路复用
- 流程:
- 一个进程负责多个 socket 连接,当任一连接有数据时,即可返回
- 实现方式
select()
&poll()
&epoll()
4) 信号驱动 IO
5) 异步 IO
- 流程:
- Linux 相关函数:
aio_read()
aio_write()
2. Reactor VS Proactor
1) Reactor 背景
- 如何让服务器服务多个客户端?
- 多进程模型
- 每个连接对应一个进程
- 进程创建、切换、销毁开销大
- 多线程模型
- 切换等开销比进程小,但连接数多时,资源占用也较多
- 多线程 + 线程池
- 每个线程负责每个连接的
read() -> handler() -> send()
过程, - 若在
read()
阻塞,则当前线程会被阻塞,但不影响其他线程- 若一个线程负责多个连接的任务,那么该线程后续所有连接均被阻塞
- 每个线程负责每个连接的
- IO 多路复用
- 通过
select()
&poll()
&epoll()
等函数,线程可通过一个系统调用负责多个连接- 若多个连接没有事件发生,则该线程阻塞
- 若存在事件发生,内核返回,线程从阻塞状态恢复,然后可以处理对应连接的业务逻辑
- 通过
Reactor
- 对 IO 多路复用进行一层封装,形成的模式
Reactor
:负责事件的监听 & 分发
Handler
:负责事件的具体处理逻辑,即read() -> handler() -> send()
过程- 同步非阻塞
- 当有事件发生时,应用进程调用
read() -> handler() -> send()
过程
- 多进程模型
2) Reactor 分类
- 单 Reactor 单进程 | 线程
- 多 Reactor 单进程 | 线程
- 实现复杂 & 无性能优势,一般不采用
- 单 Reactor 多进程 | 线程
- 多 Reactor 多进程 | 线程
单 Reactor 单进程 | 线程
- 流程
- 流程说明
select()
IO 多路复用监听事件dispatch
事件的分发,判断是发给Accpetor
还是Handler
accept()
获取连接,并创建一个Handler
对象处理该连接Handler
对象处理read() -> handler() -> send()
过程
- 优点:
- 一个进程 | 线程中进行,无需考虑并发安全问题
- 实现简单
- 缺点:
- 一个进程 | 线程,无法利用 多核 CPU 的性能
Handler
处理业务逻辑时,整个 进程 | 线程无法处理其他连接,若处理耗时长,则响应延迟大- 不适用于计算机密集型任务,而适用于业务处理非常快的场景
- 应用:
Redis 6.0
之前采用单 Reactor 单进程
模式
单 Reactor 多进程 | 线程
- 流程:
- 流程说明:
select()
IO 多路复用监听事件dispatch
事件的分发,判断是发给Accpetor
还是Handler
accept()
获取连接,并创建一个Handler
对象处理该连接Handler
对象处理read()
过程,将具体业务逻辑交给子进程 | 线程 里的Processor
对象处理Processor
对象 进行具体业务逻辑处理,将结果返回给 主进程 | 线程 的Handler
对象Handler
对象处理send()
过程,将响应结果发给客户端
- 优点:
- 充分利用 多核 CPU 特性,效率提高
- 缺点:
- 存在 线程竞争安全问题
- 一个
Reactor
对象负责所有事件的监听 & 分发,高并发下容易成为性能瓶颈 - 多进程比多线程实现要复杂,需要考虑 父子进程间的双向通信,因此一般很少采用
多 Reactor 多进程 | 线程
- 流程:
- 流程说明:
- 主进程 |线程中的
Reactor
对象通过select()
监听所有连接事件 - Acceptor 对象通过
accept()
获取连接,并将该新连接分配给某个子进程 | 子线程 - 子进程 | 线程中的
SubReactor
对象将 主进程 | 线程中分配的连接加入select()
继续监听,并创建一个Handler
用于连接事件的处理
- 主进程 |线程中的
- 多进程 & 多线程 的区别
- 多进程中,
Acceptor
对象不存在,由子进程负责处理accept()
连接 - 通过 加锁 保证每次只有一个子进程可以调用
accept()
,子进程获取到自己想要的连接后,在自己的子进程中进行处理
- 多进程中,
- 应用
Netty
-多 Reactor 多线程
Nginx
-多 Reactor 多进程
3) Proactor
- 是什么?
- 异步 IO,应用进程传入需要复制到的缓冲区地址;当事件发生时,操作系统将数据复制到用户缓冲区对应地址,无需使用
read()/ write()
- 异步 IO,应用进程传入需要复制到的缓冲区地址;当事件发生时,操作系统将数据复制到用户缓冲区对应地址,无需使用
- 流程
- 存在的问题
- Linux 的 异步 IO 并不完善,不支持 socket IO
- Windows 下由完整的支持 socket IO 的异步编程方案,可以实现该模式
3. select VS poll VS epoll
1) select()
- 流程:
- 将 想要监听的 socket 放到相应
文件描述符集合
中 - 调用
select()
函数,将文件描述符集合复制到
内核
- 内核负责查询这些文件描述符集合中是否有对应 IO 事件产生
- 通过 遍历 的方式
- 当有任一满足条件的 网络 IO 产生后,将该
socket
标记
,将文件描述符集合复制到
用户空间 - 用户态
遍历
文件描述符集合,找出有标记的socket
,进行后续处理逻辑
- 将 想要监听的 socket 放到相应
- select()
// maxfdp - 待测试描述符基数 == 待测试最大描述符 + 1
// readset - 读集合
// writeset - 写集合
// exceptset - 异常集合
// timeout - 超时
int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
- 特点
- 2 次集合遍历 + 2 次数据拷贝
- 支持的文件描述符个数有限,
default = 1024
- 非线程安全
2) poll()
poll()
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd 数据结构如下
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 描述符待检测的事件 */
short revents; /* 事件备份 */
};
- 特点:
- 解决
select()
支持的文件描述符有限 的问题,采用 动态数组 存储文件描述符,但还是会受到系统文件描述符的限制 - 非线程安全
- 解决
3) epoll()
- 相关函数:
// 1. 创建 epoll 实例
// size - 期望内核监控多少个描述符,在高版本中,实现了自动化,该参数已经无效
// return - 该 epoll 实例 唯一 标识
int epoll_create(int size);
// 2. 添加 | 对该 epoll 实例进行修改
// epfd - 1. 步骤中创建的 epoll 实例标识
// op - 选项,标识当前操作是 向 epoll 实例注册 fd 对应的事件,还是删除 fd 对应的事件,亦或者修改 fd 对应的时间
// fd - 注册事件的文件描述符
// event - 注册的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待 内核 IO 事件的分发
// epfd - 1. 步骤中创建的 epoll 实例标识
// events - 注册的事件类型
// maxevents - 最多注册事件
// timeout - 超时
// return - > 0 表示事件的个数; == 0 表示超时时间到;== -1 表示出错
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 底层原理
- 当使用
epoll_ctl()
添加监听实例时,内核采用 红黑树 跟踪所有待检测的文件描述符,将该 文件描述符加入到 RB 树中,- 增删复杂度为 O(logN)
- 且无需负责 用户态 & 内核态 复制所有文件描述符
- 内核通过一个链表 维护就绪事件
- 当某个 socket 事件发生时,将该事件添加到 就绪链表中
- 当调用
epoll_wait()
时,只返回就绪链表中的文件描述符个数,而无需轮询扫描整个 socket 集合
- 当使用
- 事件触发模式:
- 水平触发 [
Level Triggered
]- 当被监控的 socket 文件描述符上有事件发生时,服务器端不断从
epoll_wait()
中苏醒,直到内核缓冲区数据被read()
函数读完 default
- 当被监控的 socket 文件描述符上有事件发生时,服务器端不断从
- 边缘触发 [
Edge Triggered
]- 当被监控的 socket 文件描述符上有事件发生时,服务器端只从
epoll_wait()
中苏醒一次,- 因此需要保证一次性将内核缓冲区的数据读完
- 而我们不清楚数据到底有多少,因此会循环从文件描述符中读取数据,若是该过程中阻塞,没有数据可读,进程会阻塞在
read()
- 因此一般与 非阻塞 IO 搭配使用
- 减少
epoll_wait()
系统调用次数,效率高
- 当被监控的 socket 文件描述符上有事件发生时,服务器端只从
- 水平触发 [
- 特点:
- 线程安全
- 返回到用户态的 socket 均为有数据的,无需再次遍历
- 只有
Linux
系统支持该函数
4. Java IO
1) 概述
- BIO
- 同步阻塞 IO
- 特点:
- 一个 线程 对应 一个 连接,随着 socket 的增多,对 CPU & 内存的压力增大
- 流式读取数据,阻塞,当没有数据 可读 | 可写 时,该线程依旧阻塞,造成资源的浪费
- NIO
- 同步非阻塞 IO
- 特点:
- 一个线程 对应 一个
selector
,每个selector
可以对应多个channel
,每个channel
类似一个 流,通过 轮询 实现一个线程监听多个 连接 的效果 - 数组读写 非阻塞
selector
- 可实现单线程处理多个
channel
,通过向selector
中注册channel
并调用select()
方法实现
- 可实现单线程处理多个
channel
- 类似 流
- 数据 从
channel
读到buffer
,从buffer
写到channel
- Java 中相关
channel
FileChannel
DatagramChannel
SocketChannel
ServerSocketChannel
buffer
- Java 中相关
buffer
ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
FloatBuffer
LongBuffer
- Java 中相关
- 一个线程 对应 一个
- AIO
2) 代码
3) 源码
- 类图:
5. 常见问题
1) 服务端处理网络请求的 IO 过程?
2) C10K 问题
- 是什么?
- 达到 10 k 的并发量
- 需要考虑哪些限制?
- 文件描述符个数
- 一个连接对应一个文件描述符,若文件描述符超范,则新的连接会被抛弃
- 可通过修改
/ect/sysctl.cong
里面的参数修改最大文件描述符数
- 系统内存
- 每个连接均需要拥有自己的发送缓冲区 & 接收缓冲区
- 查看命令,分别表示 最小分配值、默认分配值 & 最大分配值
- 网络带宽
- 文件描述符个数
cat /proc/sys/net/ipv4/tcp_vmem
4096 11274 4299431
cat /proc/sys/ect/ipv4/tcp_rmem
4096 69043 8834513
- 如何解决?
- IO 问题
- 进程分配问题
3) 同步 VS 异步
- 同步
- 数据从 内核空间 复制到 用户空间 的过程 用户线程阻塞
- 异步
- 数据从 内核空间 复制到 用户空间 的过程 用户线程不阻塞
- 用户进行 读 | 写 操作后,立即返回,用户线程不阻塞,由内核完成数据从其他位置 复制到 用户空间 的整个过程,完成后执行回调,通知用户线程