🌤️ 网络 IO

吞佛童子2022年10月10日
  • os
  • 网络IO
大约 28 分钟

🌤️ 网络 IO

1. 网络 IO 模型

1) 同步阻塞 IO

  1. 流程
    • img_10.png
    • 用户进程没有获得数据之前,一直阻塞
  2. 对应不同的 IO 请求,处理流程:
    • img_11.png
  3. 代码实现
    • 服务器端
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);
  1. 存在的问题
    • 若是 TCP 则提供 一对一 的通信,当服务端还没有处理完一个客户端的网络 IO | 读写发生阻塞时,其他客户端无法与服务端进行连接
  2. 如何解决
    • 多进程模型
      • 主进程负责监听客户端的连接,只关心 监听套接字, 连接成功建立后,返回 连接套接字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

  1. 流程
    • img_13.png
    • 用户进程即使没有获得数据,内核也会返回当前结果,用户进程通过轮询查看数据是否准备好
  2. 代码实现
    • 服务器端
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 多路复用

  1. 流程:
    • img_14.png
    • 一个进程负责多个 socket 连接,当任一连接有数据时,即可返回
    • 实现方式 select() & poll() & epoll()

4) 信号驱动 IO

5) 异步 IO

  1. 流程:
    • img.png
  2. 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 单进程 | 线程

  1. 流程
    • img_15.png
  2. 流程说明
    • select() IO 多路复用监听事件
    • dispatch 事件的分发,判断是发给 Accpetor 还是 Handler
    • accept() 获取连接,并创建一个 Handler 对象处理该连接
    • Handler 对象处理read() -> handler() -> send() 过程
  3. 优点:
    • 一个进程 | 线程中进行,无需考虑并发安全问题
    • 实现简单
  4. 缺点:
    • 一个进程 | 线程,无法利用 多核 CPU 的性能
    • Handler 处理业务逻辑时,整个 进程 | 线程无法处理其他连接,若处理耗时长,则响应延迟大
    • 不适用于计算机密集型任务,而适用于业务处理非常快的场景
  5. 应用:
    • Redis 6.0 之前采用 单 Reactor 单进程 模式

单 Reactor 多进程 | 线程

  1. 流程
    • img_16.png
  2. 流程说明
    • select() IO 多路复用监听事件
    • dispatch 事件的分发,判断是发给 Accpetor 还是 Handler
    • accept() 获取连接,并创建一个 Handler 对象处理该连接
    • Handler 对象处理 read()过程,将具体业务逻辑交给子进程 | 线程 里的 Processor 对象处理
    • Processor 对象 进行具体业务逻辑处理,将结果返回给 主进程 | 线程 的Handler 对象
    • Handler 对象处理 send() 过程,将响应结果发给客户端
  3. 优点
    • 充分利用 多核 CPU 特性,效率提高
  4. 缺点
    • 存在 线程竞争安全问题
    • 一个 Reactor 对象负责所有事件的监听 & 分发,高并发下容易成为性能瓶颈
    • 多进程比多线程实现要复杂,需要考虑 父子进程间的双向通信,因此一般很少采用

多 Reactor 多进程 | 线程

  1. 流程
    • img_17.png
  2. 流程说明
    • 主进程 |线程中的 Reactor 对象通过 select() 监听所有连接事件
    • Acceptor 对象通过 accept() 获取连接,并将该新连接分配给某个子进程 | 子线程
    • 子进程 | 线程中的 SubReactor 对象将 主进程 | 线程中分配的连接加入 select() 继续监听,并创建一个 Handler 用于连接事件的处理
  3. 多进程 & 多线程 的区别
    • 多进程中,Acceptor 对象不存在,由子进程负责处理 accept() 连接
    • 通过 加锁 保证每次只有一个子进程可以调用 accept(),子进程获取到自己想要的连接后,在自己的子进程中进行处理
  4. 应用
    • Netty - 多 Reactor 多线程
    • Nginx - 多 Reactor 多进程

3) Proactor

  1. 是什么?
    • 异步 IO,应用进程传入需要复制到的缓冲区地址;当事件发生时,操作系统将数据复制到用户缓冲区对应地址,无需使用 read()/ write()
  2. 流程
    • img_18.png
  3. 存在的问题
    • Linux 的 异步 IO 并不完善,不支持 socket IO
    • Windows 下由完整的支持 socket IO 的异步编程方案,可以实现该模式

3. select VS poll VS epoll

1) select()

  1. 流程
    • 将 想要监听的 socket 放到相应 文件描述符集合
    • 调用 select() 函数,将文件描述符集合 复制到 内核
    • 内核负责查询这些文件描述符集合中是否有对应 IO 事件产生
      • 通过 遍历 的方式
    • 当有任一满足条件的 网络 IO 产生后,将该 socket 标记,将文件描述符集合 复制到 用户空间
    • 用户态 遍历 文件描述符集合,找出有标记的 socket,进行后续处理逻辑
  2. select()
// maxfdp - 待测试描述符基数 == 待测试最大描述符 + 1
// readset - 读集合
// writeset - 写集合
// exceptset - 异常集合
// timeout - 超时
int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
  1. 特点
    • 2 次集合遍历 + 2 次数据拷贝
    • 支持的文件描述符个数有限,default = 1024
    • 非线程安全

2) poll()

  1. poll()
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

// pollfd 数据结构如下
struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 描述符待检测的事件 */
    short revents;    /* 事件备份 */
};
  1. 特点:
    • 解决 select() 支持的文件描述符有限 的问题,采用 动态数组 存储文件描述符,但还是会受到系统文件描述符的限制
    • 非线程安全

3) epoll()

  1. 相关函数:
// 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);
  1. 底层原理
    • 当使用 epoll_ctl() 添加监听实例时,内核采用 红黑树 跟踪所有待检测的文件描述符,将该 文件描述符加入到 RB 树中,
      • 增删复杂度为 O(logN)
      • 且无需负责 用户态 & 内核态 复制所有文件描述符
    • 内核通过一个链表 维护就绪事件
      • 当某个 socket 事件发生时,将该事件添加到 就绪链表中
      • 当调用 epoll_wait() 时,只返回就绪链表中的文件描述符个数,而无需轮询扫描整个 socket 集合
  2. 事件触发模式
    • 水平触发 [Level Triggered]
      • 当被监控的 socket 文件描述符上有事件发生时,服务器端不断从 epoll_wait() 中苏醒,直到内核缓冲区数据被 read() 函数读完
      • default
    • 边缘触发 [Edge Triggered]
      • 当被监控的 socket 文件描述符上有事件发生时,服务器端只从 epoll_wait() 中苏醒一次
        • 因此需要保证一次性将内核缓冲区的数据读完
        • 而我们不清楚数据到底有多少,因此会循环从文件描述符中读取数据,若是该过程中阻塞,没有数据可读,进程会阻塞在 read()
        • 因此一般与 非阻塞 IO 搭配使用
      • 减少 epoll_wait() 系统调用次数,效率高
  3. 特点:
  • 线程安全
  • 返回到用户态的 socket 均为有数据的,无需再次遍历
  • 只有 Linux 系统支持该函数

4. Java IO

1) 概述

  1. BIO
    • 同步阻塞 IO
    • img_1.png
    • 特点:
      • 一个 线程 对应 一个 连接,随着 socket 的增多,对 CPU & 内存的压力增大
      • 流式读取数据,阻塞,当没有数据 可读 | 可写 时,该线程依旧阻塞,造成资源的浪费
  2. NIO
    • 同步非阻塞 IO
    • img_2.png
    • 特点:
      • 一个线程 对应 一个 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
  3. AIO

2) 代码

3) 源码

  • 类图
    • img.png

5. 常见问题

1) 服务端处理网络请求的 IO 过程?

  • img_12.png

2) C10K 问题

  1. 是什么?
    • 达到 10 k 的并发量
  2. 需要考虑哪些限制?
    • 文件描述符个数
      • 一个连接对应一个文件描述符,若文件描述符超范,则新的连接会被抛弃
      • 可通过修改 /ect/sysctl.cong 里面的参数修改最大文件描述符数
    • 系统内存
      • 每个连接均需要拥有自己的发送缓冲区 & 接收缓冲区
      • 查看命令,分别表示 最小分配值、默认分配值 & 最大分配值
    • 网络带宽
cat /proc/sys/net/ipv4/tcp_vmem
4096 11274 4299431
cat /proc/sys/ect/ipv4/tcp_rmem
4096 69043 8834513
  1. 如何解决?
    • IO 问题
    • 进程分配问题

3) 同步 VS 异步

  1. 同步
    • 数据从 内核空间 复制到 用户空间 的过程 用户线程阻塞
  2. 异步
    • 数据从 内核空间 复制到 用户空间 的过程 用户线程不阻塞
    • 用户进行 读 | 写 操作后,立即返回,用户线程不阻塞,由内核完成数据从其他位置 复制到 用户空间 的整个过程,完成后执行回调,通知用户线程
上次编辑于: 2022/10/10 下午8:43:48
贡献者: liuxianzhishou