IO/网络IO基础全览

目录

不论是本地文件、数据库,还是Socket、远程网络,统统都可以认为是IO(Input/Output),总之是数据的流转存储。任何时候都要考虑数据从哪里来、经过怎样的处理、到哪里去,而这本身就是程序。

本文将从浅入深讲解IO入门必备,所有的概念过一遍也是大有裨益的。

IO基础

从一些基础的知识或常识入手,到逐步的深入理解程序的IO

CPU与外设

极慢的外部设备如键盘、鼠标、打印机等如何让高速的CPU控制操作
在这里插入图片描述

1. 程序控制IO(轮询)

CPU不断地查询外围设备的工作状态,一旦外围设备“准备好”或“不忙”,即可进行数据的传送;主机与外设只能串行工作,主机一个时间段只能与一个外设进行通讯,CPU效率低。

CPU:轮询忙等待

2. 中断

每次IO操作都打扰下CPU,让CPU切换来处理IO

  • 优点:CPU没有轮询检测I/O,只是根据I/O操作去向相应的设备控制器发出一条I/O命令,理论上可以去做其它的事情;

  • 但是有大量数据传输时,CPU基本全程都在等待中断结束:在等待I/O数据的传输处理(CPU要等待中断返回,并没有去做别的事情)

一般类似下图所示
在这里插入图片描述

中断相关知识

外部设备完成工作,产生一个中断,它是通过在分配给它的一条总线信号线上置起信号而产生中断的。该信号主板上的中断控制器芯片检测到,由中断控制器芯片决定做什么。

在总线上置起中断信号,中断信号导致CPU停止当前正在做的工作并且开始做其它的事情。地址线上的数字被用做指向一个成为**中断向量(interrupt vector)**的表格的所用,以便读取一个新的程序计数器。这个程序计数器指向相应的中断服务过程的开始。

中断分类
  • 中断源

中断是指由于某种事件的发生(硬件或者软件的),计算机暂停执行当前的程序,转而执行另一程序,以处理发生的事件,处理完毕后又返回原程序继续作业的过程。中断是处理器一种工作状态的描述。我们把引起中断的原因,或者能够发出中断请求信号的来源统称为中断源。

  • 中断分类:外部中断/内部中断

    1. 外部设备请求中断。一般的外部设备(如键盘、打印机和A / D转换器等)在完成自身的操作后,向CPU发出中断请求,要求CPU为他服务。
    2. 故障强迫中断。计算机在一些关键部位都设有故障自动检测装置。如运算溢出、存储器读出出错、外部设备故障、电源掉电以及其他报警信号等,这些装置的报警信号都能使CPU中断,进行相应的中断处理。由计算机硬件异常或故障引起的中断,也称为内部异常中断。
    3. 实时时钟请求中断。在控制中遇到定时检测和控制,为此常采用一个外部时钟电路(可编程)控制其时间间隔。需要定时时,CPU发出命令使时钟电路开始工作,一旦到达规定时间,时钟电路发出中断请求,由CPU转去完成检测和控制工作。
    4. 数据通道中断。数据通道中断也称直接存储器存取(DMA)操作中断,如磁盘、磁带机或CRT等直接与存储器交换数据所要求的中断。
    5. 程序自愿中断。CPU执行了特殊指令(自陷指令)或由硬件电路引起的中断是程序自愿中断,是指当用户调试程序时,程序自愿中断检查中间结果或寻找错误所在而采用的检查手段,如断点中断和单步中断等。
  • 中断分类:可屏蔽中断和非屏蔽中断

    1. 不可屏蔽中断源一旦提出请求,cpu必须无条件响应,而对于可屏蔽中断源的请求,cpu可以响应,也可以不响应。
    2. cup一般设置两根中断请求输入线:可屏蔽中断请求INTR(Interrupt Require)和不可屏蔽中断请求NMI(Nonmaskable Interrupt)。对于可屏蔽中断,除了受本身的屏蔽位的控制外,还都要受一个总的控制,即CPU标志寄存器中的中断允许标志位IF(Interrupt Flag)的控制,IF位为1,可以得到CPU的响应,否则,得不到响应。IF位可以有用户控制,指令STI或Turbo c的Enable()函数,将IF位置1(开中断),指令CLI或Turbo_c 的Disable()函数,将IF位清0(关中断)。
    3. 可屏蔽中断:CPU关中断,则CU不响应中断;中断屏蔽字,CPU响应优先级高的中断
中断处理过程

在这里插入图片描述

  • 中断向量表用于保存:服务程序的入口地址

  • 中断响应是在:一条执行执行之末;(缺页中断:是在一条指令执行中间,执行执行不下去了;在执行中,不得不去响应中断)

中断隐指令

中断隐指令引导CPU在响应中断信号时随机做出的一系列动作,这些动作是在检测到中断信号后便随即发生的,因而不能由软件来完成,而是由硬件来处理。中断隐指令并不是指令系统中的一条真正的指令,它没有操作码,所以中断隐指令是一种不允许、也不可能为用户使用的特殊指令。其所完成的操作主要有:

  1. 保存现场

为了保证在中断服务程序执行完毕能正确返回原来的程序,必须将原来程序的断点(即程序计数器(PC)的内容)保存起来。断点可以压入堆栈,也可以存入主存的特定单元中。

  1. 暂不允许中断(关中断)

暂不允许中断即关中断。在中断服务程序中,为了保护中断现场(即CPU主要寄存器的内容)期间不被新的中断所打断,必须要关中断,从而保证被中断的程序在中断服务程序执行完毕之后能接着正确地执行下去。并不是所有的计算机都在中断隐指令中由硬件自动地关中断,也有些计算机的这一操作是由软件(中断服务程序)来实现的。但是大部分计算机还是靠硬件来进行相关动作,因为硬件具有更好的可靠性和实时性。

  1. 引出中断服务程序

引出中断服务程序的实质就是取出中断服务程序的入口地址送程序计数器(PC)。对于向量中断和非向量中断,引出中断服务程序的方法是不相同的。

  1. 中断分发

硬件中断处理。在Windows所支持的硬件平台上,外部I/O中断进入到中断控制器的一根线上。该控制器接着在某一根线上中断处理器。处理器一旦被中断,就会询问控制器以获得此中断请求(IRQ)。中断控制器将该IRQ转译成一个中断号,利用该编号作为索引,在一个称为中断分发表(IDT)的结构中找到一个IDT项,并且将控制权传递给恰当的中断分发例程。每个处理器都有单独的IDT,所以,如果合适,不同的处理器可以运行不同的ISR。

3. DMA(Direct Memory Access)

DMA(Direct Memory Access)控制器是一种在系统内部转移数据的独特外设,可以将其视为一种能够通过一组专用总线将内部和外部存储器与每个具有DMA能力的外设连接起来的控制器。不需要依赖于CPU的大量中断,DMA控制器接管了数据读写请求,减少CPU的负担。

在这里插入图片描述

DMA向CPU申请权限,让DMA进行I/O操作;CPU不需要在负责大量的I/O操作而无法处理其它事情了,此处有DMA总线

缓冲区

类似于CPU三级缓存的概念(参考复习:https://doctording.blog.csdn.net/article/details/145303267),IO也需要有缓冲区。

因为计算机访问外部设备或文件,要比直接访问内存慢的多。如果我们每次调用read()方法或者write()方法访问外部的设备或文件,CPU就要花上最多的时间是在等外部设备响应,而不是数据处理。

为此,我们开辟一个内存缓冲区的内存区域,程序每次调用read()方法或write()方法都是读写在这个缓冲区中。当这个缓冲区被装满后,系统才将这个缓冲区的内容一次集中写到外部设备或读取进来给CPU。使用缓冲区可以有效的提高CPU的使用率,能提高整个计算机系统的效率。

对于用户缓存区,定义了如下几种类型

  • 全缓冲

此种类型的缓冲只有在缓冲区满的时候才会调用实际的文件 IO 进入内核态操作。除了涉及到终端设备文件的流,其它文件流默认基本都是全缓冲。

  • 行缓冲

此种类型的缓冲在缓冲区满或者遇到 \n 的时候才会调用实际的文件 IO 进入内核态操作。当流涉及到终端设备的时候就是行缓冲,比如标准输入流和标准输出流。如果对标准输入流或者输出流进行重定向到某个文件的时候,该流就是全缓冲的。

  • 无缓冲

没有缓冲区。直接调用文件 IO 进入内核态操作。标准错误流默认就是无缓冲的。

用户空间和内核空间

用户空间通常是常规进程所在区域,即非特权区域,不能直接访问磁盘硬件设备;通常要通过操作系统系统调用或者驱动程序完成。

用户空间不能直接访问磁盘硬件设备,如下的一些原因这也很容易理解:

  • 权限限制‌:在操作系统中,用户空间和内核空间有不同的权限设置。用户空间运行的应用程序通常只能访问用户空间资源,而无法直接访问内核空间或硬件设备。这是为了系统的稳定性和安全性,防止普通应用程序直接操作硬件可能导致系统崩溃或数据损坏。

  • 设备驱动‌:硬件设备需要通过设备驱动程序进行管理和操作。设备驱动程序运行在内核空间,负责将硬件设备的操作转换为操作系统可以理解的形式。用户空间的应用程序通过调用内核提供的接口来间接操作硬件设备。

  • 抽象层‌:操作系统通过设备驱动程序和文件系统等抽象层将硬件设备映射为文件或设备文件,用户可以通过标准的文件操作函数来访问这些设备。这种方式简化了硬件操作,同时也提供了更好的兼容性和灵活性。

所以这也就引入了内核空间

  • 用户空间的程序不能直接去磁盘空间中读取数据,就由内核空间通过DMA来获取
  • 另外也注意到一般用户空间的内存分页与磁盘空间不会对齐,而内核空间可在中间做一层处理

IO操作的拷贝概念

有了缓存和用户/内核空间的基础,接下来来看不同的IO操作,也是理解常说的拷贝概念

传统IO操作的4次拷贝

回顾传统IO操作流程:

在这里插入图片描述

  • 写:用户态->内核态->DMA: 进行了CPU copy, DMA copy

  • 读:DMA->内核态->用户态: 进行了DMA copy, CPU copy

在这里插入图片描述

减少一个CPU拷贝的mmap

mmap即内存映射,mmap()是由unix/linux操作系统来调用的,它可以将内核缓存中的一块区域与用户缓存中的一块区域形成映射关系,即共享内存,不过在用户缓存中的这块映射区域是堆外内存;其中,文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。

常见为如下:
在这里插入图片描述

  1. 用户进程要读一个磁盘文件,告诉内核进程发起mmap()函数调用,内核将一块内核缓存和用户缓存中的一块堆外内存建立的映射关系,并告诉DMA将这个文件中的数据拷贝到了这块内核缓存中
  2. 用户开始IO,因为磁盘文件已经被DMA拷贝到内核缓存中去了,又被映射到了这块堆外内存,所以就直接在用户缓存里就读到数据了,线程没有上下文切换

  1. 用户线程发起了write()调用,状态由用户态切换为内核态,这时候内核基于CPU拷贝将数据从那块映射着的内核缓存拷贝到socket缓存(这是在内核空间的拷贝)
  2. 然后是DMA将数据从socket缓存拷贝到网卡,最后write()函数调用返回,线程从内核态切换到用户态

所以mmap可总结为

  • DMA拷贝2次
  • CPU拷贝1次(内核空间的内核缓冲区到socket缓冲区)
内存映射文件(memory-mapped file,用户内存到文件系统页的映射)

由一个文件到一块内存的映射;文件的数据就是这块区域内存中对应的数据,读写文件中的数据,即直接对这块内存区域的地址操作,减少了内存复制的环节。

使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,这意味着在对文件进行处理时将不必再为文件申请并分配缓存,所有的文件缓存操作均由系统直接管理,由于取消了将文件数据加载到内存、数据从内存到文件的回写以及释放内存块等步骤,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。

在这里插入图片描述

好处:

  • 用户进程把文件数据当作内存,所以无需发起read()write()系统调用。

  • 当用户进程碰触到映射内存空间,页错误会自动产生,从而将文件数据从磁盘读进内存。如果用户修改了映射内存空间,相关页会自动标记为脏,随后刷新到磁盘,文件得到更新。

  • 操作系统的虚拟内存子系统会对页进行智能高速缓存,自动根据系统负载进行内存管理。

  • 数据总是按页对齐的,无需执行缓冲区拷贝。

  • 大型文件使用映射,无需耗费大量内存,即可进行数据拷贝。

  • 映射文件区域的能力取决于于内存寻址的大小。在32位机器中,你不能访问超过4GB(2 ^ 32)以上的文件。

mmap的流程
1 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

进程在用户空间调用mmap库函数

  • 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
  • 为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
  • 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中
2 调用内核空间的系统调用函数mmap(不同于用户空间的mmap函数),实现文件物理地址和进程虚拟地址的一一映射关系

为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核已打开文件集中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。

  • 通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。

  • 内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。

  • 通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。

注:仅创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

3 进程发起对这块映射空间的访问,引发page fault,实现文件内容到物理内存(主存)的拷贝
  • 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
  • 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
  • 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
  • 之后进程即可对这片内存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程

注意:脏页不能被置换出内存,如果脏页正在被写回,那么会被设置写回标记,这时候该页就被上锁,其他写请求被阻塞直到锁释放。

附:Linux man mmap

man mmap

  • 使用语法
NAME
       mmap, munmap - map or unmap files or devices into memory

SYNOPSIS
       #include <sys/mman.h>

       void *mmap(void *addr, size_t length, int prot, int flags,
                  int fd, off_t offset);
       int munmap(void *addr, size_t length);

零拷贝(无CPU拷贝) sendfile

注意零拷贝是无CPU拷贝,DMA拷贝还是必须的。

  1. sendfile() 系统调用被调用,触发一次上下文切换(用户态 -> 内核态)。
  2. 第一次拷贝 (DMA): DMA 控制器将数据从硬盘拷贝到内核读缓冲区。
  3. 第二次拷贝 (CPU): CPU 直接在内核空间内,将数据从内核读缓冲区拷贝到套接字缓冲区。(关键点:数据没有离开内核态!)
  4. 第三次拷贝 (DMA): DMA 控制器将数据从套接字缓冲区拷贝到网卡。
  5. sendfile() 调用返回,发生第二次上下文切换(内核态 -> 用户态)。

在这里插入图片描述

Java NIO 提供了对 sendfile 的封装,使得 Java 开发者可以轻松利用这一强大的 OS 特性。这个 API 就是 java.nio.channels.FileChannel 的 transferTo() 方法。

当你调用 fileChannel.transferTo(position, count, targetChannel) 时,如果底层操作系统支持 sendfile,JVM 就会使用它来实现高效的文件传输。像 Netty、Kafka、Tomcat 等高性能框架,都在底层大量使用了 transferTo() 来优化文件和网络数据的传输。

特性传统 I/Osendfile (基本)sendfile (DMA Gather)
数据拷贝次数4 次 (2次CPU, 2次DMA)3 次 (1次CPU, 2次DMA)2 次 (全是DMA)
CPU 拷贝有 (两次)有 (一次)
上下文切换4 次2 次2 次
核心优势-减少了用户态/内核态切换和一次CPU拷贝彻底解放CPU,实现了真正的零拷贝

通过避免不必要的数据拷贝和上下文切换,sendfile 极大地降低了系统开销,提升了 I/O 吞吐量,是构建高性能、数据密集型应用不可或缺的利器。

附:用户态/内核态切换代价

在这里插入图片描述

NAME
       sendfile - transfer data between file descriptors

SYNOPSIS
       #include <sys/sendfile.h>

       ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

DESCRIPTION
       sendfile()  copies data between one file descriptor and another.  Because this copying is done within
       the kernel, sendfile() is more efficient than the combination of read(2) and  write(2),  which  would
       require transferring data to and from user space.

I/O模型:同步/阻塞概念

上面谈到了很多底层的IO概念,但是回到面向程序员的IO编程世界,我们首先必须要懂的概念是:阻塞与非阻塞,同步与异步等IO方式。因为这直接决定了程序员要怎么编写代码

阻塞与非阻塞(等待I/O时的状态)

函数或方法(用户线程调用内核I/O操作)的实现方式:

  • 阻塞是指I/O操作需要彻底完成后才返回到用户空间
  • 非阻塞是指I/O操作被调用后立即返回给用户一个状态值,无需等到I/O操作彻底完成。

同步与异步(用户线程与内核的消息交互方式)

  • 同步指用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行;同步有阻塞,非阻塞之分
  • 异步是指用户线程发起I/O请求后仍然可以继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。异步一定是非阻塞的(内核会通过函数回调或者信号机制通知用户进程;类似观察者模式)

用水壶烧水的例子说明同/异步/阻塞/非阻塞

  • 同步阻塞
  1. 点火(发消息)
  2. 搬个小板凳盯着水壶(傻等,眼睛不动),不等到水壶烧开水,坚决不去做别的事情(阻塞)

用户线程的IO处理过程需要等待,中间不能做任何事情,对CPU利用率很低

  • 同步非阻塞
  1. 点火(发消息)
  2. 去看会儿电视,时不时过来(轮询)看水壶烧开水没有(非阻塞);水开后接着处理

用户线程每次IO请求都能立刻返回,但需要通过轮询去判断数据是否返回,会无谓地消耗大量的CPU

  • 异步阻塞(很少发生)
  1. 点火(发消息)
  2. 水壶有个响铃,自动绑定了开水之后的处理程序,这样响铃之后自动处理(异步)
  3. 但是还是可以轮询去看水壶开了没有
  • 异步非阻塞
  1. 点火(发消息), 写好水壶烧开水之后的处理程序
  2. 水壶有个响铃,自动绑定了开水之后的处理程序,这样响铃之后自动处理
  3. 人该干嘛干嘛去,不用管了(不用傻等,不用轮询)

网络IO: Socket

Socket是什么?

Socket是对TCP/IP协议的封装,它的出现只是使得程序员更方便地使用TCP/IP协议栈而已。socket本身并不是协议,它是应用层与TCP/IP协议族通信的中间软件抽象层,是一组调用接口。

使用5元组(客户端ip:port,服务端ip:port,协议) 或者 文件描述符 fd,唯一的表示

附:传输层和网络层的明显区别是:网络层为主机之间提供逻辑通信,而传输层提供端到端的逻辑通信

在这里插入图片描述

socket bind,listen,accept,connect等

比如sock_listen系统调用

  1. 将未链接的套接口转换为被动套接口,指示内核接受向此套接口的连接请求,调用此系统调用后tcp状态机由close转换到listen。
  2. 第二个参数指定了内核为此套接口排队的最大连接个数。关于第二个参数,对于给定的监听套接口,内核要维护两个队列,未连接队列和已连接队列,根据tcp 三路握手过程中三个分节来分隔这两个队列。已完成连接的队列和未完成连接的队列之和不超过backlog。
static int sock_listen(int fd, int backlog)
{
    struct socket *sock;
 
    if (fd < 0 || fd >= NR_OPEN || current->files->fd[fd] == NULL)
        return(-EBADF);
    if (!(sock = sockfd_lookup(fd, NULL)))
        return(-ENOTSOCK);
 
    if (sock->state != SS_UNCONNECTED)
    {
        return(-EINVAL);
    }
 
    if (sock->ops && sock->ops->listen)
        sock->ops->listen(sock, backlog);
    // 设置socket的监听属性,accept函数时用到
    sock->flags |= SO_ACCEPTCON;
    return(0);
}

文件IO

文件系统是安排、解释磁盘数据的一种独特方式,文件系统定义了文件名、路径、文件、文件属性等一系列抽象概念。

当用户进程请求文件数据时,文件系统需要确定数据在磁盘什么位置,然后将相关磁盘分区读取到内存中。

磁盘认识

  • 盘面:盘面类似于光盘的数据存储面,由许多同心圆的磁道组成的盘面。一块硬盘有多个盘面
  • 柱面:垂直方向由多个盘面组成,读取或者写入数据都是垂直方向的从第一个盘面同一磁道一直写入到最后一个盘面,然后数据还没写完的话在切换磁道
  • 磁道:盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道;同一磁道再被划分成多个扇区
  • 扇区:存储数据,每个扇区包括512个字节的数据和一些其他信息(每个扇区是磁盘的最小存储单元)

在这里插入图片描述

磁盘的读写

一次访盘请求(读/写)完成过程由三个动作组成:

  1. 寻道(时间):磁头移动定位到指定磁道
  2. 旋转延迟(时间):等待指定扇区从磁头下旋转经过
  3. 数据传输(时间):数据在磁盘与内存之间的实际传输

因此在磁盘上读取扇区数据(一块数据)所需时间:

Ti/o=tseek + tla + n * twm

  • tseek 为寻道时间
  • tla为旋转时间
  • twm 为传输时间

磁盘随机/顺序访问

  • 随机访问(Random Access)

指的是本次I/O所给出的扇区地址和上次I/O给出扇区地址相差比较大;这样的话,磁头在两次I/O操作之间需要作比较大的移动动作才能重新开始读/写数据

  • 顺序访问(Sequential Access)

如果当次I/O给出的扇区地址与上次I/O结束的扇区地址一致或者是接近的话,那磁头就能很快的开始这次IO操作,这样的多个IO操作称为顺序访问

磁盘预读

磁盘读取的一系列动作,导致其读写很慢;要提高效率,显然要尽量减少磁盘IO,为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会磁盘预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存,这通常是一的整倍数

局部性原理

当一个数据被用到时,其附近的数据也通常会马上被使用。程序运行期间所需要的数据通常比较集中。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间) ,因此对于具有局部性的程序来说,磁盘预读可以提高1/0效率。预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k) ,主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。

页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后正常返回,程序继续运行

再次看传统IO操作

在这里插入图片描述

网络IO模型演进

BIO Socket服务端代码

public class BIOServer {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket();
        ss.bind(new InetSocketAddress("127.0.0.1", 8888));
        while(true) {
            Socket s = ss.accept(); //阻塞方法
            // 每个客户端socket都开一个线程处理
            new Thread(() -> {
                handle(s);
            }).start();
        }
    }

    static void handle(Socket s) {
        try {
            byte[] bytes = new byte[1024];
            int len = s.getInputStream().read(bytes); // 阻塞方法
            System.out.println("read data:" + new String(bytes, 0, len));

            s.getOutputStream().write(bytes, 0, len);
            s.getOutputStream().flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
BIO的问题
  1. 阻塞IO,必须使用多线程来处理多个客户端连接
  2. 操作系统clone系统调用创建线程,线程的创建开销比较大
  3. 线程是消耗资源的,如JVM默认1M的线程栈内存
  4. 线程很多的时候,CPU调度,上下文切换频繁
  5. 阻塞IO,会有CPU时间片的浪费
BIO多线程问题

一个线程负责多个文件的读写,如果按照阻塞的方式,那么必须是按照文件1, 文件2, 文件3… 顺序的读取这么多文件; 如果是非阻塞方式, 你可以同时发起成百上千个读操作,然后在那个循环中检查,看看谁的数据准备好了,就读取谁的,效率就高了。

socket编程:一个socket连接来了,就创建一个新的线程或者从线程池分配一个线程去处理这个连接,显然线程数不能太多,线程的切换也是个开销;所以让一个线程管理成百上千个sockcet连接,就像管理多个文件一样,这样就不用做线程切换了。

  • 使用多线程的本质

    1. 充分利用多核(线程是CPU调度的基本单位;进程是系统进行资源分配的基本单位)
    2. 当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。(现在的多线程一般都使用线程池,这可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,每一个连接线程可以专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题)
  • 多线程缺点,总结如下

    1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个轻量级进程,创建和销毁都是重量级的系统函数调用
    2. 线程本身占用较大内存,比如Java的线程栈一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半
    3. 线程的切换成本是很高的,操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高CPU使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态
    4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高且外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,并激活大量阻塞线程从而使系统负载压力突然过大
    5. 多线程的引入,可能会引入线程安全问题,系统的设计会比较复杂

结论:当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。

C10K问题

BIO中因为阻塞IO导致不得不使用多线程来对接处理所有用户的Socket。那么就会出现C10K问题: 用户空间需要遍历n个socket fd,然后再切换到内核空间,内核进行系统调用检查fd,但实际上可能只有m(m << n)个fd是准备好数据的;

  1. fd拷贝到内核复杂度是O(n),会发生O(n)次系统调用
  2. 内核检查fd是否准备好的复杂度是O(n)

有没有办法一个线程就能处理所有的客户端连接呢,答案是有,IO多路复用

即比如可以把所有fd放到一个集合(select)中,一次性的拷贝到内核处理,那么复杂度就是O(1),即一次系统调用,所有文件描述符都能交给内核。

IO多路复用

IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出Cpu。多路是指网络连接,复用指的是同一个线程

在这里插入图片描述

select函数

select是通过将需要监听的文件描述符加入相应的文件描述符集合(readset、writeset,exceptset),将用户态的文件描述符复制到内核中, 由内核负责监视相应的文件描述符是否就绪。即内核逐一遍历这些fd,判断是否就绪。

目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,但select有如下的一些局限:

  • select监控的文件描述符fd有上限
  • 每次调用都需要手动的设置文件描述符集合,使用非常不便
  • 每次调用都要把文件描述符从用户态拷贝到内核态,开销比较大
  • 当就绪的文件描述符好后,需要循环遍历来进行判断,效率不高

linux select服务端例子代码

int main()
{
    int server_sockfd, client_sockfd;
    int server_len, client_len;
    struct sockaddr_in server_address;
    struct sockaddr_in client_address;
    int result;
    fd_set readfds, testfds;
    server_sockfd = socket(AF_INET, SOCK_STREAM, 0);//建立服务器端socket
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = htonl(INADDR_ANY);
    server_address.sin_port = htons(8888);
    server_len = sizeof(server_address);
    bind(server_sockfd, (struct sockaddr *)&server_address, server_len);
    listen(server_sockfd, 5); //监听队列最多容纳5个
    FD_ZERO(&readfds);
    FD_SET(server_sockfd, &readfds);//将服务器端socket加入到集合中
    while(1)
    {
        char ch;
        int fd;
        int nread;
        testfds = readfds;//将需要监视的描述符集copy到select查询队列中,select会对其修改,所以一定要分开使用变量
        printf("server waiting\n");

        /*无限期阻塞,并测试文件描述符变动 */
        result = select(FD_SETSIZE, &testfds, (fd_set *)0,(fd_set *)0, (struct timeval *) 0); //FD_SETSIZE:系统默认的最大文件描述符
        if(result < 1)
        {
            perror("server5");
            exit(1);
        }

        /*扫描所有的文件描述符*/
        for(fd = 0; fd < FD_SETSIZE; fd++)
        {
            /*找到相关文件描述符*/
            if(FD_ISSET(fd,&testfds))
            {
              /*判断是否为服务器套接字,是则表示为客户请求连接。*/
                if(fd == server_sockfd)
                {
                    client_len = sizeof(client_address);
                    client_sockfd = accept(server_sockfd,
                    (struct sockaddr *)&client_address, &client_len);
                    FD_SET(client_sockfd, &readfds);//将客户端socket加入到集合中
                    printf("adding client on fd %d\n", client_sockfd);
                }
                /*客户端socket中有数据请求时*/
                else
                {
                    ioctl(fd, FIONREAD, &nread);//取得数据量交给nread

                    /*客户数据请求完毕,关闭套接字,从集合中清除相应描述符 */
                    if(nread == 0)
                    {
                        close(fd);
                        FD_CLR(fd, &readfds); //去掉关闭的fd
                        printf("removing client on fd %d\n", fd);
                    }
                    /*处理客户数据请求*/
                    else
                    {
                        read(fd, &ch, 1);
                        sleep(5);
                        printf("serving client on fd %d\n", fd);
                        ch++;
                        write(fd, &ch, 1);
                    }
                }
            }
        }
    }

    return 0;
}
epoll
  • 在内核区域开辟一块共享区域,有一个fd就加入进去,不用拷贝了;随着fd的增多,这个集合也增多了;不像select那样,每次传递重复的fd集合

  • 不需要遍历所有fd,基于事件驱动(中断),哪个fd准备好了,就发出通知事件,这些事件放到一个事件区域中,用户态自己去等待其中的事件 (网卡会发出硬件中断,CPU读到网卡数据,写入到epoll中内核的事件区域(基于事件))

在这里插入图片描述

单机TCP连接数能达到多少?

  • client最大tcp连接数

client每次发起tcp连接请求时,除非绑定端口,通常会让系统选取一个空闲的本地端口(local port),该端口是独占的,不能和其他tcp连接共享。tcp端口的数据类型是unsigned short,因此本地端口个数最大只有65536,端口0有特殊含义,不能使用,这样可用端口最多只有65535,所以在全部作为client端的情况下,最大tcp连接数为65536-1=65535,这些连接可以连到不同的server ip。

实际可用连接数受操作系统文件描述符限制,例如Linux系统可通过调整fs.nr_open参数突破默认限制。 ‌

  • server最大tcp连接数

server通常固定在某个本地端口上监听,等待client的连接请求。不考虑地址重用(unix的SO_REUSEADDR选项)的情况下,即使server端有多个ip,本地监听端口也是独占的,因此server端tcp连接4元组中只有remote ip(也就是client ip)和remote port(客户端port)是可变的,因此最大tcp连接为客户端ip数×客户端port数,对IPV4,不考虑ip地址分类等因素,最大tcp连接数约为2的32次方(ip数)×2的16次方(port数),也就是server端单机最大tcp连接数约为2的48次方。

通过调整系统参数(如tcp_rmem、tcp_wmem)和增加内存,服务器可支持超过10万并发连接。

  • 实际的tcp连接数

在实际环境中,受到机器资源、操作系统等的限制,特别是sever端,其最大并发tcp连接数远不能达到理论上限。在unix/linux下限制连接数的主要因素是内存和允许的文件描述符个数(每个tcp连接都要占用一定内存,每个socket就是一个文件描述符),另外1024以下的端口通常为保留端口。在默认2.6内核配置下,经过试验,每个socket占用内存在15~20k之间

对server端,通过增加内存、修改最大文件描述符个数等参数,单机最大并发TCP连接数超过10万,甚至100万是没问题的,国外 Urban Airship 公司在产品环境中已做到 50 万并发 。在实际应用中,对大规模网络应用,还需要考虑C10K 问题。

另外还包括一些限制

  • 文件句柄限制,即操作系统对可以打开的最大文件数的限制
  • 端口号范围限制,操作系统上端口号1024以下是系统保留的,从1024-65535是用户使用的。由于每个TCP连接都要占一个端口号,所以我们最多可以有65535-1024=64511个并发连接
  • 线程数,JVM中其大小可以通过启动JVM参数-Xss来指定,默认值为1MB左右。

NIO(同步非阻塞)

在这里插入图片描述

NIO相关名词概念
  • Channels and Buffers(通道和缓冲区)
    标准的IO基于字节流字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。

  • Selectors(选择器)
    Java NIO引入了选择器的概念,选择器可用于监听多个通道的事件(比如:可读、连接打开、数据到达)。因此,单个的线程可以监听多个数据通道。

  • Non-blocking IO(非阻塞IO)
    Java NIO可以让你非阻塞的使用IO,例如:当线程从通道读取数据到缓冲区时,线程还是可以进行其它事情。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。

Channel 是什么?

java.nio.channels.Channel 是一个接口,它代表了与能够执行 I/O 操作的实体(如文件、网络套接字)之间的一个开放连接。

核心特点:

  1. 双向性 (通常): 与传统的 Stream(流)总是单向的(要么输入,要么输出)不同,Channel 通常是双向的。例如,一个 SocketChannel 既可以读取数据(read()),也可以写入数据(write())。当然,也有单向的 Channel,比如 FileChannel 只能从文件中读取或写入,但它提供了 read 和 write 两种操作。
  2. 与 Buffer 配合工作: 这是 NIO 的核心设计!Channel 不直接与数据打交道,它所有的 I/O 操作都必须通过一个 Buffer (缓冲区) 来进行。
    • 读取数据: 数据从 Channel 被读入到 Buffer 中。(channel.read(buffer))
    • 写入数据: 数据从 Buffer 被写入到 Channel 中。(channel.write(buffer))
      这就像从隧道运送货物,你不能直接把货物扔进隧道,而是必须先把货物装上卡车(Buffer),然后让卡车在隧道(Channel)中行驶。
  3. 支持异步 I/O (Asynchronous I/O): Channel 可以被置于非阻塞模式 (Non-blocking Mode)。在非阻塞模式下,当你发起一个 I/O 操作时(如 read()),如果当前没有数据可读,它会立即返回,而不会像传统 I/O 那样阻塞线程。这使得一个单独的线程可以管理多个 Channel,极大地提升了 I/O 密集型应用的性能和伸缩性。这是实现高性能网络服务器(如 Netty)的基石。
  4. 可以被选择器 (Selector) 监控: 处于非阻塞模式的 Channel 可以被注册到一个 Selector上。Selector 就像一个“事件通知中心”,可以同时监控多个 Channel 的 I/O 事件(如连接就绪、可读、可写)。当某个 Channel 准备好进行 I/O 操作时,Selector 就会通知你,然后你再去处理那个 Channel。这就是所谓的 I/O 多路复用 (I/O Multiplexing)。
特性NIO Channel传统 I/O Stream
方向性双向 (通常可以读和写)单向 (要么 Input,要么 Output)
数据交互通过 Buffer 进行直接读写字节/字符数组
工作模式支持阻塞 (Blocking) 和 非阻塞 (Non-blocking)只有阻塞 (Blocking)
核心优势高性能,高伸缩性,支持 I/O 多路复用简单,易于使用,适合低并发场景
适用场景高并发网络服务器、需要高性能文件操作简单的文件读写、控制台 I/O、低并发网络应用

四个核心特性:双向性与 Buffer 交互可非阻塞可被 Selector 监控

常用的Channel(通道)方法
  1. read(ByteBuffer):从 Channel 中读取数据到 ByteBuffer 中。如果 Channel 中没有可读数据,则会阻塞等待直到有数据可读。
  2. write(ByteBuffer):将数据写入到 Channel 中。如果 Channel 中没有可写空间,则会阻塞等待直到有可写空间。
  3. read(ByteBuffer, long):从 Channel 中读取数据到 ByteBuffer 中,并设置读取超时时间。如果超时时间到了还没有读取到数据,则会抛出 TimeoutException 异常。
  4. write(ByteBuffer, long):将数据写入到 Channel 中,并设置写入超时时间。如果超时时间到了还没有写入完成,则会抛出 TimeoutException 异常。
  5. flush():将 Channel 中的缓冲区数据刷新到底层设备中,如果没有数据需要刷新,则会立即返回。
  6. register(SelectionKey, int):将 Channel 注册到 Selector 上,并设置注册的事件类型和操作。可以通过 Selector 监听 Channel 上的事件,当有事件发生时,Selector 就会通知相应的线程进行处理。
  7. configureBlocking(boolean):设置 Channel 是否为阻塞模式。如果为阻塞模式,则在读取或写入数据时会一直阻塞等待,直到有数据可读或写入完成;如果为非阻塞模式,则在读取或写入数据时会立即返回,如果没有数据可读或写入完成,则会返回 -1。
  8. socket():获取底层的 Socket 对象。
  9. isConnected():判断 Channel 是否已经连接到了远程主机。
  10. isWritable():判断 Channel 是否可以写入数据。
  11. isReadable():判断 Channel 是否可以读取数据。
  12. isOpen():检查 Channel 是否已经打开。
  13. getRemoteAddress():获取 Channel 对应的远程地址。
  14. getLocalAddress():获取 Channel 对应的本地地址。
    ————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/qq_33807380/article/details/134190775

Buffers(缓冲区)

在 Java NIO 中,缓冲区(Buffer) 是核心组件之一,所有数据的读写操作都必须通过缓冲区完成。缓冲区本质上是一个 固定大小的数据容器,用于存储特定类型的数据(如字节、字符、整数等),并与 通道(Channel) 配合实现高效的 I/O 操作。

使用流程:

// 1. 分配缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);

// 2. 写入数据(写入模式)
buffer.put("Hello, NIO!".getBytes());

// 3. 切换到读取模式
buffer.flip();

// 4. 读取数据(读取模式)
byte[] dest = new byte[buffer.remaining()];
buffer.get(dest);
System.out.println(new String(dest));

// 5. 清空缓冲区(准备下一次写入)
buffer.clear();

缓冲区类型

缓冲区类型数据类型
ByteBufferbyte
CharBufferchar
ShortBuffershort
IntBufferint
LongBufferlong
FloatBufferfloat
DoubleBufferdouble

缓冲区的四个核心属性

0 <= Mark <= Position <= Limit <= Capacity

属性描述
Capacity(容量)缓冲区的最大存储能力(不可变)。
Position(位置)当前读写的位置(从 0 开始,最大不超过 Limit)。
Limit(限制)缓冲区中当前可操作数据的终点(下一个不可读/写的位置)。
Mark(标记)可选的标记位置,用于记录某个特定的 Position 值。

缓冲区的常用方法

方法描述
allocate(int capacity)创建一个堆缓冲区(存储在 JVM 堆中)。
wrap(data[])用现有数组创建缓冲区。
put(…)向缓冲区写入数据。
flip()切换到读取模式。
get(…)从缓冲区读取数据。
clear()清空缓冲区(重置为写入模式)。
compact()压缩缓冲区(保留未读取数据)。
mark() / reset()设置/回到标记位置。
Java selector例子

NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。

  • NIO Server
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;


public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
        ssc.configureBlocking(false);

        System.out.println("server started, listening on :" + ssc.getLocalAddress());
        Selector selector = Selector.open();
        // selector 注册感兴趣的事情:例如连接事件
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while(true) {
            // 阻塞
            selector.select();
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> it = keys.iterator();
            while(it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                // 处理这个事件
                handle(key);
            }
        }

    }

    private static void handle(SelectionKey key) {
        if(key.isAcceptable()) {
            try {
                ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
                SocketChannel sc = ssc.accept();
                sc.configureBlocking(false);
                //new Client
                //
                //String hostIP = ((InetSocketAddress)sc.getRemoteAddress()).getHostString();

			/*
			log.info("client " + hostIP + " trying  to connect");
			for(int i=0; i<clients.size(); i++) {
				String clientHostIP = clients.get(i).clientAddress.getHostString();
				if(hostIP.equals(clientHostIP)) {
					log.info("this client has already connected! is he alvie " + clients.get(i).live);
					sc.close();
					return;
				}
			}*/

                sc.register(key.selector(), SelectionKey.OP_READ );
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
            }
        } else if (key.isReadable()) { //flip
            SocketChannel sc = null;
            try {
                sc = (SocketChannel)key.channel();
                ByteBuffer buffer = ByteBuffer.allocate(512);
                buffer.clear();
                int len = sc.read(buffer);

                if(len != -1) {
                    System.out.println(new String(buffer.array(), 0, len));
                }

                ByteBuffer bufferToWrite = ByteBuffer.wrap("HelloClient".getBytes());
                sc.write(bufferToWrite);
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if(sc != null) {
                    try {
                        sc.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

NIO reactor模型

在这里插入图片描述

  • Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应
  • Handlers:处理程序执行 I/O 事件要完成的实际事件

The reactor design pattern is an event handling pattern for handling service requests delivered concurrently to a service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handlers.

(基于事件驱动,有一个Service Handler,处理一个或多个并发输入源,同步的分发给不同的Request Handlers)

  1. 基于事件驱动-> selector(支持对多个socketChannel的监听)
  2. 统一的事件分派中心-> eventDispatch
  3. 事件处理服务-> read & write
单 Reactor 单线程

优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。

缺点:性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。

单 Reactor 多线程

优点:可以充分利用多核 CPU 的处理能力。

缺点:多线程数据共享和访问比较复杂;Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。

主从 Reactor 多线程

在这里插入图片描述

  • 主反应器 ( MainReactor ) : 运行在独立的 Reactor 主线程中 , 该线程中只负责与客户端的连接请求 ;

  • 从反应器 ( SubReactor ) : 运行在独立的 Reactor 子线程中 , 该线程中负责与客户端的读写操作 ; 在该子线程中 , 从反应器 ( Reactor ) 监听多个客户端的请求事件 , 如果监听到客户端的数据发送事件 , 将对应的业务逻辑转发给 处理器 ( Handler 进行处理 ) ;

  • 服务器端 处理者 ( Handler ) : Handler 只负责响应业务处理的请求事件 , 不处理具体的与客户端交互的业务逻辑 , 因此不会长时间阻塞 , 其调用 read 方法读取客户端数据后 , 将业务逻辑交给 线程池 ( Worker ) 处理相关业务逻辑 , 处理完毕后 , 将结果返回 , Handler 将该结果写出到客户端 ;

  • 服务器端 线程池 ( Worker ) : 接收 处理者 ( Handler ) 的请求 , 为将请求对应业务逻辑操作 , 分配给某个独立线程完成 , 执行完成后的结果再次返回给 处理者 ( Handler ) ,( Handler 读取客户端数据 -> Worker 线程池分配线程执行业务处理操作 -> Handler 将结果回送给客户端 )

优点:父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。

父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。

这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持。

AIO(异步非阻塞)

本质上还是 IO 多路复用模型, 将原来的同步方法会阻塞等待接口返回,变成现在可以异步等待返回结果;

附1:BIO代码

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class BIOServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8088);
        System.out.println("Server started on port 8080");

        while (true) {
            // 1. 阻塞方法,需要while(true) 判断链接进来
            final Socket clientSocket = serverSocket.accept();
            new Thread(() -> {
                try {
                    // 2. 读写都是阻塞的,所以是单开线程里面执行
                    BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                    PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
                    String message = in.readLine();
                    System.out.println("Received: " + message);
                    out.println("Hello from server");
                    clientSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

附2:NIO代码

import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.*;

public class NIOServer {
    public static void main(String[] args) throws Exception {
        Selector selector = Selector.open();
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false);
        serverChannel.bind(new InetSocketAddress(8089));
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        ByteBuffer buffer = ByteBuffer.allocate(1024);

        while (true) {
            // Selector在调用select()后会阻塞,等待事件触发。
            selector.select();
            /*
            keys 是一个 Set<SelectionKey> 集合,表示当前 Selector 中所有 已就绪的 Channel 事件。
            每个 SelectionKey 对应一个 Channel,并记录该 Channel 触发的事件类型(如连接、读、写)。
             */
            Set<SelectionKey> keys = selector.selectedKeys();

            // 遍历就绪的 Channel 事件(不用开线程处理每一个连接)
            Iterator<SelectionKey> it = keys.iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();

                if (key.isAcceptable()) {
                    // 连接成功,注册读事件
                    ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    sc.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    // 数据可读,则读取数据
                    SocketChannel sc = (SocketChannel) key.channel();
                    buffer.clear();
                    int read = sc.read(buffer);
                    if (read == -1) {
                        sc.close();
                    } else {
                        buffer.flip();
                        System.out.println("Received: " + Charset.defaultCharset().decode(buffer));
                    }
                }
            }
        }
    }
}

附3:reactor主从代码

  • MainReactor
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

public class MainReactor implements Runnable {

    private final ServerSocketChannel serverSocketChannel;
    private final Selector selector;
    private final SubReactor[] subReactors;
    private final AtomicInteger next = new AtomicInteger(0); // 用于轮询选择 SubReactor

    public MainReactor(int port, SubReactor[] subReactors) throws IOException {
        this.subReactors = subReactors;
        selector = Selector.open();
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.socket().bind(new InetSocketAddress(port));
        serverSocketChannel.configureBlocking(false); // 设置为非阻塞
        // 将 serverSocketChannel 注册到 selector 上,只关心 ACCEPT 事件
        SelectionKey sk = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        sk.attach(new Acceptor()); // 附加一个 Acceptor 处理器
    }

    @Override
    public void run() {
        try {
            while (!Thread.interrupted()) {
                selector.select(); // 阻塞,直到有事件就绪
                Set<SelectionKey> selected = selector.selectedKeys();
                Iterator<SelectionKey> it = selected.iterator();
                while (it.hasNext()) {
                    SelectionKey key = it.next();
                    // 分发事件
                    dispatch(key);
                }
                selected.clear();
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }

    private void dispatch(SelectionKey key) {
        // 通过 key 获取附加的处理器,并调用其 run 方法
        Runnable handler = (Runnable) key.attachment();
        if (handler != null) {
            handler.run();
        }
    }

    /**
     * Acceptor 处理器,专门用于处理连接事件
     */
    class Acceptor implements Runnable {
        @Override
        public void run() {
            try {
                // 接收客户端连接
                SocketChannel clientChannel = serverSocketChannel.accept();
                if (clientChannel != null) {
                    System.out.println(Thread.currentThread().getName() + ": Accepted connection from " + clientChannel.getRemoteAddress());

                    // 选择一个 SubReactor 来处理这个连接
                    // 使用轮询(Round-Robin)策略
                    SubReactor subReactor = subReactors[next.getAndIncrement() % subReactors.length];

                    // 将连接的后续 I/O 处理注册到选定的 SubReactor 上
                    subReactor.register(clientChannel);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

  • SubReactor
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;

public class SubReactor implements Runnable {
    private final Selector selector;
    private final ConcurrentLinkedQueue<SocketChannel> newConnections = new ConcurrentLinkedQueue<>();
    private volatile boolean isRunning = true;

    public SubReactor() throws IOException {
        this.selector = Selector.open();
    }

    // 主 Reactor 调用此方法来注册新的连接
    public void register(SocketChannel clientChannel) throws IOException {
        if (clientChannel != null) {
            newConnections.offer(clientChannel);
            // 唤醒 selector,使其立即处理新的注册请求
            selector.wakeup();
        }
    }

    @Override
    public void run() {
        while (isRunning && !Thread.interrupted()) {
            try {
                // 处理新接入的连接注册
                processNewConnections();

                selector.select(1000); // 阻塞或超时

                Set<SelectionKey> selected = selector.selectedKeys();
                Iterator<SelectionKey> it = selected.iterator();
                while (it.hasNext()) {
                    SelectionKey key = it.next();
                    it.remove(); // 必须移除,否则会重复处理

                    // 读写处理
                    if (key.isReadable()) {
                        handleRead(key);
                    } else if (key.isWritable()) {
                        handleWrite(key);
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void processNewConnections() throws IOException {
        SocketChannel clientChannel;
        while ((clientChannel = newConnections.poll()) != null) {
            clientChannel.configureBlocking(false);
            // 将新的连接注册到自己的 selector 上,并监听 READ 事件
            clientChannel.register(this.selector, SelectionKey.OP_READ);
            System.out.println(Thread.currentThread().getName() + ": Registered new client " + clientChannel.getRemoteAddress());
        }
    }

    private void handleRead(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        int bytesRead = clientChannel.read(buffer);

        if (bytesRead > 0) {
            buffer.flip();
            byte[] bytes = new byte[buffer.remaining()];
            buffer.get(bytes);
            String message = new String(bytes).trim();
            System.out.println(Thread.currentThread().getName() + ": Received from client: " + message);

            // 业务处理:简单地将收到的消息转为大写并回写
            String response = "ECHO: " + message.toUpperCase();

            // 将响应附加到 key 上,并注册 WRITE 事件
            key.attach(response);
            key.interestOps(SelectionKey.OP_WRITE);

        } else if (bytesRead < 0) {
            // 客户端关闭连接
            System.out.println(Thread.currentThread().getName() + ": Client disconnected: " + clientChannel.getRemoteAddress());
            System.out.println();
            key.cancel();
            clientChannel.close();
        }
    }

    private void handleWrite(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        String response = (String) key.attachment();

        if (response != null) {
            ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
            clientChannel.write(buffer);
            System.out.println(Thread.currentThread().getName() + ": Sent to client: " + response);
            System.out.println();

            // 写完后,可以切换回读状态,等待客户端的下一次请求
            key.attach(null); // 清除附加对象
            key.interestOps(SelectionKey.OP_READ);
        }
    }
}

  • 测试代码
import java.io.IOException;

public class MasterSubReactor {

    public static void main(String[] args) throws IOException {
        try {
            // 确定从 Reactor 的数量,通常设置为 CPU 核心数
            int subReactorCount = Runtime.getRuntime().availableProcessors();

            // 1. 创建并启动 SubReactor
            SubReactor[] subReactors = new SubReactor[subReactorCount];
            for (int i = 0; i < subReactorCount; i++) {
                subReactors[i] = new SubReactor();
                new Thread(subReactors[i], "SubReactor-" + i).start();
            }

            // 2. 创建并启动 MainReactor,并将 SubReactor 组传递给它
            MainReactor mainReactor = new MainReactor(8089, subReactors);
            new Thread(mainReactor, "MainReactor").start();

            System.out.println("NIO Server started on port 8080");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }


}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值