多路复用详解

I/O

IO模型

五种模型

对于一个套接字上的输入操作,第一步是等待数据从网络中到达,当数据到达时被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区

Linux 有五种 I/O 模型:

  • 阻塞式 I/O
  • 非阻塞式 I/O
  • I/O 复用(select 和 poll)
  • 信号驱动式 I/O(SIGIO)
  • 异步 I/O(AIO)

五种模型对比:

  • 同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段,非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞
  • 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞
  • 异步 I/O:第二阶段应用进程不会阻塞


阻塞式IO

应用进程通过系统调用 recvfrom 接收数据,会被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。阻塞不意味着整个操作系统都被阻塞,其它应用进程还可以执行,只是当前阻塞进程不消耗 CPU 时间,这种模型的 CPU 利用率会比较高

recvfrom() 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中,把 recvfrom() 当成系统调用


非阻塞式

应用进程通过 recvfrom 调用不停的去和内核交互,直到内核准备好数据。如果没有准备好数据,内核返回一个错误码,过一段时间应用进程再执行 recvfrom 系统调用,在两次发送请求的时间段,进程可以进行其他任务,这种方式称为轮询(polling)

由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低


信号驱动

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,等待数据阶段应用进程是非阻塞的。当内核数据准备就绪时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高


IO 复用

IO 复用模型使用 select 或者 poll 函数等待数据,select 会监听所有注册好的 IO,等待多个套接字中的任何一个变为可读,等待过程会被阻塞,当某个套接字准备好数据变为可读时 select 调用就返回,然后调用 recvfrom 把数据从内核复制到进程中

IO 复用让单个进程具有处理多个 I/O 事件的能力,又被称为 Event Driven I/O,即事件驱动 I/O

如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都要创建一个线程去处理,如果同时有几万个连接,就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小


异步 IO

应用进程执行 aio_read 系统调用会立即返回,给内核传递描述符、缓冲区指针、缓冲区大小等。应用进程可以继续执行不会被阻塞,内核会在所有操作完成之后向应用进程发送信号

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O


多路复用

select
函数

Socket 不是文件,只是一个标识符,但是 Unix 操作系统把所有东西都看作是文件,所以 Socket 说成 file descriptor,也就是 fd

select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。

int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

  • fd_set 使用 bitmap 数组实现,数组大小用 FD_SETSIZE 定义,单进程只能监听少于 FD_SETSIZE 数量的描述符,32 位机默认是 1024 个,64 位机默认是 2048,可以对进行修改,然后重新编译内核
  • fd_set 有三种类型的描述符:readset、writeset、exceptset,对应读、写、异常条件的描述符集合
  • n 是监测的 socket 的最大数量
  • timeout 为超时参数,调用 select 会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout
struct timeval{
    long tv_sec;    //秒
    long tv_usec;   //微秒
}
    • timeout == null:等待无限长的时间
    • tv_sec == 0 && tv_usec == 0:获取后直接返回,不阻塞等待
    • tv_sec != 0 || tv_usec != 0:等待指定时间
  • 方法成功调用返回结果为就绪的文件描述符个数,出错返回结果为 -1,超时返回结果为 0

Linux 提供了一组宏为 fd_set 进行赋值操作:

int FD_ZERO(fd_set *fdset);         // 将一个 fd_set 类型变量的所有值都置为 0
int FD_CLR(int fd, fd_set *fdset);  // 将一个 fd_set 类型变量的 fd 位置为 0
int FD_SET(int fd, fd_set *fdset);  // 将一个 fd_set 类型变量的 fd 位置为 1
int FD_ISSET(int fd, fd_set *fdset);// 判断 fd 位是否被置为 1

示例:

sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr)));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));//绑定连接
listen(sockfd, 5);//监听5个端口
for(i = 0; i < 5; i++) {
    memset(&client, e, sizeof(client));
    addrlen = sizeof(client);
    fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen);
    //将监听的对应的文件描述符fd存入fds:[3,4,5,6,7]
    if(fds[i] > max)
        max = fds[i];
}
while(1) {
    FD_ZERO(&rset);//置为0
    for(i = 0; i < 5; i++) {
        FD_SET(fds[i], &rset);//对应位置1 [0001 1111 00.....]
    }
    print("round again");
    select(max + 1, &rset, NULL, NULL, NULL);//监听
    
    for(i = 0; i <5; i++) {
        if(FD_ISSET(fds[i], &rset)) {//判断监听哪一个端口
            memset(buffer, 0, MAXBUF);
            read(fds[i], buffer, MAXBUF);//进入内核态读数据
            print(buffer);
        }
    }
}

参考视频:【教程】IO多路复用底层原理全解,Java面试大厂必问!_哔哩哔哩_bilibili


流程

select 调用流程图:

  1. 使用 copy_from_user 从用户空间拷贝 fd_set 到内核空间,进程阻塞
  2. 注册回调函数 _pollwait
  3. 遍历所有 fd,调用其对应的 poll 方法判断当前请求是否准备就绪,对于 socket,这个 poll 方法是 sock_poll,sock_poll 根据情况会调用到 tcp_poll、udp_poll 或者 datagram_poll,以 tcp_poll 为例,其核心实现就是 _pollwait
  4. _pollwait 把 current(调用 select 的进程)挂到设备的等待队列,不同设备有不同的等待队列,对于 tcp_poll ,其等待队列是 sk → sk_sleep(把进程挂到等待队列中并不代表进程已经睡眠),在设备收到消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时 current 便被唤醒,进入就绪队列
  5. poll 方法返回时会返回一个描述读写操作是否就绪的 mask 掩码,根据这个 mask 掩码给 fd_set 赋值
  6. 如果遍历完所有的 fd,还没有返回一个可读写的 mask 掩码,则会调用 schedule_timeout 让 current 进程进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程,如果超过一定的超时时间(schedule_timeout)没有其他线程唤醒,则调用 select 的进程会重新被唤醒获得 CPU,进而重新遍历 fd,判断有没有就绪的 fd
  7. 把 fd_set 从内核空间拷贝到用户空间,阻塞进程继续执行

参考文章:https://www.cnblogs.com/anker/p/3265058.html

其他流程图:Linux select原理图| ProcessOn免费在线作图,在线流程图,在线思维导图


poll

poll 的功能与 select 类似,也是等待一组描述符中的一个成为就绪状态

int poll(struct pollfd *fds, unsigned int nfds, int timeout);

poll 中的描述符是 pollfd 类型的数组,pollfd 的定义如下:

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

select 和 poll 对比:

  • select 会修改描述符,而 poll 不会
  • select 的描述符类型使用数组实现,有描述符的限制;而 poll 使用链表实现,没有描述符数量的限制
  • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高
  • select 和 poll 速度都比较慢,每次调用都需要将全部描述符数组 fd 从应用进程缓冲区复制到内核缓冲区,同时每次都需要在内核遍历传递进来的所有 fd ,这个开销在 fd 很多时会很大
  • 几乎所有的系统都支持 select,但是只有比较新的系统支持 poll
  • select 和 poll 的时间复杂度 O(n),对 socket 进行扫描时是线性扫描,即采用轮询的方法,效率较低,因为并不知道具体是哪个 socket 具有事件,所以随着 fd 数量的增加会造成遍历速度慢的线性下降性能问题
  • poll 还有一个特点是水平触发,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd
  • 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定

参考文章:CS-Notes/notes/Socket.md at master · CyC2018/CS-Notes · GitHub


epoll
函数

epoll 使用事件的就绪通知方式,通过 epoll_ctl() 向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,一旦该 fd 就绪,内核通过 callback 回调函数将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件就绪的描述符

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • epall_create:一个系统函数,函数将在内核空间内创建一个 epoll 数据结构,可以理解为 epoll 结构空间,返回值为 epoll 的文件描述符编号,以后有 client 连接时,向该 epoll 结构中添加监听,所以 epoll 使用一个文件描述符管理多个描述符
  • epall_ctl:epoll 的事件注册函数,select 函数是调用时指定需要监听的描述符和事件,epoll 先将用户感兴趣的描述符事件注册到 epoll 空间。此函数是非阻塞函数,用来增删改 epoll 空间内的描述符,参数解释:
    • epfd:epoll 结构的进程 fd 编号,函数将依靠该编号找到对应的 epoll 结构
    • op:表示当前请求类型,有三个宏定义:
      • EPOLL_CTL_ADD:注册新的 fd 到 epfd 中
      • EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件
      • EPOLL_CTI_DEL:从 epfd 中删除一个 fd
    • fd:需要监听的文件描述符,一般指 socket_fd
    • event:告诉内核对该 fd 资源感兴趣的事件,epoll_event 的结构:
struct epoll_event {
    _uint32_t events;   /*epoll events*/
    epoll_data_t data;  /*user data variable*/
}

events 可以是以下几个宏集合:EPOLLIN、EPOLOUT、EPOLLPRI、EPOLLERR、EPOLLHUP(挂断)、EPOLET(边缘触发)、EPOLLONESHOT(只监听一次,事件触发后自动清除该 fd,从 epoll 列表)

  • epoll_wait:等待事件的产生,类似于 select() 调用,返回值为本次就绪的 fd 个数,直接从就绪链表获取,时间复杂度 O(1)
    • epfd:指定感兴趣的 epoll 事件列表
    • events:指向一个 epoll_event 结构数组,当函数返回时,内核会把就绪状态的数据拷贝到该数组
    • maxevents:标明 epoll_event 数组最多能接收的数据量,即本次操作最多能获取多少就绪数据
    • timeout:单位为毫秒
      • 0:表示立即返回,非阻塞调用
      • -1:阻塞调用,直到有用户感兴趣的事件就绪为止
      • 大于 0:阻塞调用,阻塞指定时间内如果有事件就绪则提前返回,否则等待指定时间后返回

epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger):

  • LT 模式:当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程,是默认的一种模式,并且同时支持 Blocking 和 No-Blocking
  • ET 模式:通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高;只支持 No-Blocking,以避免由于一个 fd 的阻塞读/阻塞写操作把处理多个文件描述符的任务饥饿
// 创建 epoll 描述符,每个应用程序只需要一个,用于监控所有套接字
int pollingfd = epoll_create(0xCAFE);
if ( pollingfd < 0 )// report error
// 初始化 epoll 结构
struct epoll_event ev = { 0 };

// 将连接类实例与事件相关联,可以关联任何想要的东西
ev.data.ptr = pConnection1;

// 监视输入,并且在事件发生后不自动重新准备描述符
ev.events = EPOLLIN | EPOLLONESHOT;
// 将描述符添加到监控列表中,即使另一个线程在epoll_wait中等待,描述符将被正确添加
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, pConnection1->getSocket(), &ev) != 0 )
    // report error

// 最多等待 20 个事件
struct epoll_event pevents[20];

// 等待10秒,检索20个并存入epoll_event数组
int ready = epoll_wait(pollingfd, pevents, 20, 10000);
// 检查epoll是否成功
if ( ret == -1)// report error and abort
else if ( ret == 0)// timeout; no event detected
else
{
    for (int i = 0; i < ready; i+ )
    {
        if ( pevents[i].events & EPOLLIN )
        {
            // 获取连接指针
            Connection * c = (Connection*) pevents[i].data.ptr;
            c->handleReadEvent();
         }
    }
}

流程图:https://gitee.com/seazean/images/blob/master/Java/IO-epoll%E5%8E%9F%E7%90%86%E5%9B%BE.jpg

参考视频:【教程】IO多路复用底层原理全解,Java面试大厂必问!_哔哩哔哩_bilibili


特点

epoll 的特点:

  • epoll 仅适用于 Linux 系统
  • epoll 使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表(个人理解成哑元节点)
  • 没有最大描述符数量(并发连接)的限制,打开 fd 的上限远大于1024(1G 内存能监听约 10 万个端口)
  • epoll 的时间复杂度 O(1),epoll 理解为 event poll,不同于忙轮询和无差别轮询,调用 epoll_wait 只是轮询就绪链表。当监听列表有设备就绪时调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll_wait 中阻塞的进程,所以 epoll 实际上是事件驱动(每个事件关联上fd)的,降低了 system call 的时间复杂度
  • epoll 内核中根据每个 fd 上的 callback 函数来实现,只有活跃的 socket 才会主动调用 callback,所以使用 epoll 没有前面两者的线性下降的性能问题,效率提高
  • epoll 注册新的事件是注册到到内核中 epoll 句柄中,不需要每次调用 epoll_wait 时重复拷贝,对比前面两种只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,也可以利用 mmap() 文件映射内存加速与内核空间的消息传递(只是可以用,并没有用)
  • 前面两者要把 current 往设备等待队列中挂一次,epoll 也只把 current 往等待队列上挂一次,但是这里的等待队列并不是设备等待队列,只是一个 epoll 内部定义的等待队列,这样可以节省开销
  • epoll 对多线程编程更有友好,一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符,也不会产生像 select 和 poll 的不确定情况

参考文章:聊聊IO多路复用之select、poll、epoll详解 - 简书

参考文章:https://www.cnblogs.com/anker/p/3265058.html


应用

应用场景:

  • select 应用场景:
    • select 的 timeout 参数精度为微秒,poll 和 epoll 为毫秒,因此 select 适用实时性要求比较高的场景,比如核反应堆的控制
    • select 可移植性更好,几乎被所有主流平台所支持
  • poll 应用场景:poll 没有最大描述符数量的限制,适用于平台支持并且对实时性要求不高的情况
  • epoll 应用场景:
    • 运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接
    • 需要同时监控小于 1000 个描述符,没必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势
    • 需要监控的描述符状态变化多,而且是非常短暂的,就没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,每次对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率,并且 epoll 的描述符存储在内核,不容易调试

参考文章:CS-Notes/notes/Socket.md at master · CyC2018/CS-Notes · GitHub


系统调用

内核态

用户空间:用户代码、用户堆栈

内核空间:内核代码、内核调度程序、进程描述符(内核堆栈、thread_info 进程描述符)

  • 进程描述符和用户的进程是一一对应的
  • SYS_API 系统调用:如 read、write,系统调用就是 0X80 中断
  • 进程描述符 pd:进程从用户态切换到内核态时,需要保存用户态时的上下文信息在 PCB 中
  • 线程上下文:用户程序基地址,程序计数器、cpu cache、寄存器等,方便程序切回用户态时恢复现场
  • 内核堆栈:系统调用函数也是要创建变量的,这些变量在内核堆栈上分配


80中断

在用户程序中调用操作系统提供的核心态级别的子功能,为了系统安全需要进行用户态和内核态转换,状态的转换需要进行 CPU 中断,中断分为硬中断和软中断:

  • 硬中断:如网络传输中,数据到达网卡后,网卡经过一系列操作后发起硬件中断
  • 软中断:如程序运行过程中本身产生的一些中断
    • 发起 0X80 中断
    • 程序执行碰到除 0 异常

系统调用 system_call 函数所对应的中断指令编号是 0X80(十进制是 8×16=128),而该指令编号对应的就是系统调用程序的入口,所以称系统调用为 80 中断

系统调用的流程:

  • 在 CPU 寄存器里存一个系统调用号,表示哪个系统函数,比如 read
  • 将 CPU 的临时数据都保存到 thread_info 中
  • 执行 80 中断处理程序,找到刚刚存的系统调用号(read),先检查缓存中有没有对应的数据,没有就去磁盘中加载到内核缓冲区,然后从内核缓冲区拷贝到用户空间
  • 最后恢复到用户态,通过 thread_info 恢复现场,用户态继续执行

参考视频:【教程】IO多路复用底层原理全解,Java面试大厂必问!_哔哩哔哩_bilibili


零拷贝

DMA

DMA (Direct Memory Access) :直接存储器访问,让外部设备不通过 CPU 直接与系统内存交换数据的接口技术

作用:可以解决批量数据的输入/输出问题,使数据的传送速度取决于存储器和外设的工作速度

把内存数据传输到网卡然后发送:

  • 没有 DMA:CPU 读内存数据到 CPU 高速缓存,再写到网卡,这样就把 CPU 的速度拉低到和网卡一个速度
  • 使用 DMA:把数据读到 Socket 内核缓存区(CPU 复制),CPU 分配给 DMA 开始异步操作,DMA 读取 Socket 缓冲区到 DMA 缓冲区,然后写到网卡。DMA 执行完后中断(就是通知) CPU,这时 Socket 内核缓冲区为空,CPU 从用户态切换到内核态,执行中断处理程序,将需要使用 Socket 缓冲区的阻塞进程移到就绪队列

一个完整的 DMA 传输过程必须经历 DMA 请求、DMA 响应、DMA 传输、DMA 结束四个步骤:

DMA 方式是一种完全由硬件进行信息传送的控制方式,通常系统总线由 CPU 管理,在 DMA 方式中,CPU 的主存控制信号被禁止使用,CPU 把总线(地址总线、数据总线、控制总线)让出来由 DMA 控制器接管,用来控制传送的字节数、判断 DMA 是否结束、以及发出 DMA 结束信号,所以 DMA 控制器必须有以下功能:

  • 接受外设发出的 DMA 请求,并向 CPU 发出总线接管请求
  • 当 CPU 发出允许接管信号后,进入 DMA 操作周期
  • 确定传送数据的主存单元地址及长度,并自动修改主存地址计数和传送长度计数
  • 规定数据在主存和外设间的传送方向,发出读写等控制信号,执行数据传送操作
  • 判断 DMA 传送是否结束,发出 DMA 结束信号,使 CPU 恢复正常工作状态(中断)


BIO

传统的 I/O 操作进行了 4 次用户空间与内核空间的上下文切换,以及 4 次数据拷贝:

  • JVM 发出 read 系统调用,OS 上下文切换到内核模式(切换 1)并将数据从网卡或硬盘等设备通过 DMA 读取到内核空间缓冲区(拷贝 1),内核缓冲区实际上是磁盘高速缓存(PageCache)
  • OS 内核将数据复制到用户空间缓冲区(拷贝 2),然后 read 系统调用返回,又会导致一次内核空间到用户空间的上下文切换(切换 2)
  • JVM 处理代码逻辑并发送 write() 系统调用,OS 上下文切换到内核模式(切换3)并从用户空间缓冲区复制数据到内核空间缓冲区(拷贝3)
  • 将内核空间缓冲区中的数据写到 hardware(拷贝4),write 系统调用返回,导致内核空间到用户空间的再次上下文切换(切换4)

流程图中的箭头反过来也成立,可以从网卡获取数据

read 调用图示:read、write 都是系统调用指令


mmap

mmap(Memory Mapped Files)内存映射加 write 实现零拷贝,零拷贝就是没有数据从内核空间复制到用户空间

用户空间和内核空间都使用内存,所以可以共享同一块物理内存地址,省去用户态和内核态之间的拷贝。写网卡时,共享空间的内容拷贝到 Socket 缓冲区,然后交给 DMA 发送到网卡,只需要 3 次复制

进行了 4 次用户空间与内核空间的上下文切换,以及 3 次数据拷贝(2 次 DMA,一次 CPU 复制):

  • 发出 mmap 系统调用,DMA 拷贝到内核缓冲区,映射到共享缓冲区;mmap 系统调用返回,无需拷贝
  • 发出 write 系统调用,将数据从内核缓冲区拷贝到内核 Socket 缓冲区;write 系统调用返回,DMA 将内核空间 Socket 缓冲区中的数据传递到协议引擎

原理:利用操作系统的 Page 来实现文件到物理内存的直接映射,完成映射后对物理内存的操作会被同步到硬盘上

缺点:不可靠,写到 mmap 中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用 flush 的时候才把数据真正的写到硬盘

Java NIO 提供了 MappedByteBuffer 类可以用来实现 mmap 内存映射,MappedByteBuffer 类对象只能通过调用 FileChannel.map() 获取


sendfile

sendfile 实现零拷贝,打开文件的文件描述符 fd 和 socket 的 fd 传递给 sendfile,然后经过 3 次复制和 2 次用户态和内核态的切换

原理:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,由于和用户态完全无关,就减少了两次上下文切换

说明:零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送

sendfile2.4 之后,sendfile 实现了更简单的方式,文件到达内核缓冲区后,不必再将数据全部复制到 socket buffer 缓冲区,而是只将记录数据位置和长度相关等描述符信息保存到 socket buffer,DMA 根据 Socket 缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(2 次复制 2 次切换)

Java NIO 对 sendfile 的支持是 FileChannel.transferTo()/transferFrom(),把磁盘文件读取 OS 内核缓冲区后的 fileChannel,直接转给 socketChannel 发送,底层就是 sendfile

参考文章:【网络通信】4、零拷贝、DMA、系统调用、消息队列原理_网络编程 dma-CSDN博客


BIO

Inet

一个 InetAddress 类的对象就代表一个 IP 地址对象

成员方法:

  • static InetAddress getLocalHost():获得本地主机 IP 地址对象
  • static InetAddress getByName(String host):根据 IP 地址字符串或主机名获得对应的 IP 地址对象
  • String getHostName():获取主机名
  • String getHostAddress():获得 IP 地址字符串
public class InetAddressDemo {
    public static void main(String[] args) throws Exception {
        // 1.获取本机地址对象
        InetAddress ip = InetAddress.getLocalHost();
        System.out.println(ip.getHostName());//DESKTOP-NNMBHQR
        System.out.println(ip.getHostAddress());//192.168.11.1
        // 2.获取域名ip对象
        InetAddress ip2 = InetAddress.getByName("www.baidu.com");
        System.out.println(ip2.getHostName());//www.baidu.com
        System.out.println(ip2.getHostAddress());//14.215.177.38
        // 3.获取公网IP对象。
        InetAddress ip3 = InetAddress.getByName("182.61.200.6");
        System.out.println(ip3.getHostName());//182.61.200.6
        System.out.println(ip3.getHostAddress());//182.61.200.6
        
        // 4.判断是否能通: ping  5s之前测试是否可通
        System.out.println(ip2.isReachable(5000)); // ping百度
    }
}


UDP

基本介绍

UDP(User Datagram Protocol)协议的特点:

  • 面向无连接的协议,发送端只管发送,不确认对方是否能收到,速度快,但是不可靠,会丢失数据
  • 尽最大努力交付,没有拥塞控制
  • 基于数据包进行数据传输,发送数据的包的大小限制 64KB 以内
  • 支持一对一、一对多、多对一、多对多的交互通信

UDP 协议的使用场景:在线视频、网络语音、电话


实现UDP

UDP 协议相关的两个类:

  • DatagramPacket(数据包对象):用来封装要发送或要接收的数据,比如:集装箱
  • DatagramSocket(发送对象):用来发送或接收数据包,比如:码头

DatagramPacket

  • DatagramPacket 类:public new DatagramPacket(byte[] buf, int length, InetAddress address, int port):创建发送端数据包对象
    • buf:要发送的内容,字节数组
    • length:要发送内容的长度,单位是字节
    • address:接收端的IP地址对象
    • port:接收端的端口号
  • public new DatagramPacket(byte[] buf, int length):创建接收端的数据包对象
    • buf:用来存储接收到内容
    • length:能够接收内容的长度
  • DatagramPacket 类常用方法:
    • public int getLength():获得实际接收到的字节个数
    • public byte[] getData():返回数据缓冲区

DatagramSocket

  • DatagramSocket 类构造方法:
    • protected DatagramSocket():创建发送端的 Socket 对象,系统会随机分配一个端口号
    • protected DatagramSocket(int port):创建接收端的 Socket 对象并指定端口号
  • DatagramSocket 类成员方法:
    • public void send(DatagramPacket dp):发送数据包
    • public void receive(DatagramPacket p):接收数据包
    • public void close():关闭数据报套接字
public class UDPClientDemo {
    public static void main(String[] args) throws Exception {
        System.out.println("===启动客户端===");
        // 1.创建一个集装箱对象,用于封装需要发送的数据包!
        byte[] buffer = "我学Java".getBytes();
        DatagramPacket packet = new DatagramPacket(buffer,bubffer.length,InetAddress.getLoclHost,8000);
        // 2.创建一个码头对象
        DatagramSocket socket = new DatagramSocket();
        // 3.开始发送数据包对象
        socket.send(packet);
        socket.close();
    }
}
public class UDPServerDemo{
    public static void main(String[] args) throws Exception {
        System.out.println("==启动服务端程序==");
        // 1.创建一个接收客户都端的数据包对象(集装箱)
        byte[] buffer = new byte[1024*64];
        DatagramPacket packet = new DatagramPacket(buffer, bubffer.length);
        // 2.创建一个接收端的码头对象
        DatagramSocket socket = new DatagramSocket(8000);
        // 3.开始接收
        socket.receive(packet);
        // 4.从集装箱中获取本次读取的数据量
        int len = packet.getLength();
        // 5.输出数据
        // String rs = new String(socket.getData(), 0, len)
        String rs = new String(buffer , 0 , len);
        System.out.println(rs);
        // 6.服务端还可以获取发来信息的客户端的IP和端口。
        String ip = packet.getAddress().getHostAdress();
        int port = packet.getPort();
        socket.close();
    }
}


通讯方式

UDP 通信方式:

  • 单播:用于两个主机之间的端对端通信
  • 组播:用于对一组特定的主机进行通信IP : 224.0.1.0Socket 对象 : MulticastSocket
  • 广播:用于一个主机对整个局域网上所有主机上的数据通信IP : 255.255.255.255Socket 对象 : DatagramSocket


TCP

基本介绍

TCP/IP (Transfer Control Protocol) 协议,传输控制协议

TCP/IP 协议的特点:

  • 面向连接的协议,提供可靠交互,速度慢
  • 点对点的全双工通信
  • 通过三次握手建立连接,连接成功形成数据传输通道;通过四次挥手断开连接
  • 基于字节流进行数据传输,传输数据大小没有限制

TCP 协议的使用场景:文件上传和下载、邮件发送和接收、远程登录

注意:TCP 不会为没有数据的 ACK 超时重传

推荐阅读:面试官,不要再问我三次握手和四次挥手 | 猿人谷


Socket

TCP 通信也叫 Socket 网络编程,只要代码基于 Socket 开发,底层就是基于了可靠传输的 TCP 通信

双向通信:Java Socket 是全双工的,在任意时刻,线路上存在 A -> B 和 B -> A 的双向信号传输,即使是阻塞 IO,读和写也是可以同时进行的,只要分别采用读线程和写线程即可,读不会阻塞写、写也不会阻塞读

TCP 协议相关的类:

  • Socket:一个该类的对象就代表一个客户端程序。
  • ServerSocket:一个该类的对象就代表一个服务器端程序。

Socket 类:

  • 构造方法:
    • Socket(InetAddress address,int port):创建流套接字并将其连接到指定 IP 指定端口号
    • Socket(String host, int port):根据 IP 地址字符串和端口号创建客户端 Socket 对象注意事项:执行该方法,就会立即连接指定的服务器,连接成功,则表示三次握手通过,反之抛出异常
  • 常用 API:
    • OutputStream getOutputStream():获得字节输出流对象
    • InputStream getInputStream():获得字节输入流对象
    • void shutdownInput():停止接受
    • void shutdownOutput():停止发送数据,终止通信
    • SocketAddress getRemoteSocketAddress():返回套接字连接到的端点的地址,未连接返回 null

ServerSocket 类:

  • 构造方法:public ServerSocket(int port)
  • 常用 API:public Socket accept(),阻塞等待接收一个客户端的 Socket 管道连接请求,连接成功返回一个 Socket 对象三次握手后 TCP 连接建立成功,服务器内核会把连接从 SYN 半连接队列(一次握手时在服务端建立的队列)中移出,移入 accept 全连接队列,等待进程调用 accept 函数时把连接取出。如果进程不能及时调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃

相当于客户端和服务器建立一个数据管道(虚连接,不是真正的物理连接),管道一般不用 close


实现TCP
开发流程

客户端的开发流程:

  1. 客户端要请求于服务端的 Socket 管道连接
  2. 从 Socket 通信管道中得到一个字节输出流
  3. 通过字节输出流给服务端写出数据

服务端的开发流程:

  1. 用 ServerSocket 注册端口
  2. 接收客户端的 Socket 管道连接
  3. 从 Socket 通信管道中得到一个字节输入流
  4. 从字节输入流中读取客户端发来的数据

  • 如果输出缓冲区空间不够存放主机发送的数据,则会被阻塞,输入缓冲区同理
  • 缓冲区不属于应用程序,属于内核
  • TCP 从输出缓冲区读取数据会加锁阻塞线程


实现通信

需求一:客户端发送一行数据,服务端接收一行数据

public class ClientDemo {
    public static void main(String[] args) throws Exception {
        // 1.客户端要请求于服务端的socket管道连接。
        Socket socket = new Socket("127.0.0.1", 8080);
        // 2.从socket通信管道中得到一个字节输出流
        OutputStream os = socket.getOutputStream();
        // 3.把低级的字节输出流包装成高级的打印流。
        PrintStream ps = new PrintStream(os);
        // 4.开始发消息出去
        ps.println("我是客户端");
        ps.flush();//一般不关闭IO流
        System.out.println("客户端发送完毕~~~~");
    }
}
public class ServerDemo{
    public static void main(String[] args) throws Exception {
        System.out.println("----服务端启动----");
        // 1.注册端口: public ServerSocket(int port)
        ServerSocket serverSocket = new ServerSocket(8080);
        // 2.开始等待接收客户端的Socket管道连接。
        Socket socket = serverSocket.accept();
        // 3.从socket通信管道中得到一个字节输入流。
        InputStream is = socket.getInputStream();
        // 4.把字节输入流转换成字符输入流
        BufferedReader br = new BufferedReader(new InputStreamReader(is));
        // 6.按照行读取消息 。
        String line;
        if((line = br.readLine()) != null){
            System.out.println(line);
        }
    }
}

需求二:客户端可以反复发送数据,服务端可以反复数据

public class ClientDemo {
    public static void main(String[] args) throws Exception {
        // 1.客户端要请求于服务端的socket管道连接。
        Socket socket = new Socket("127.0.0.1",8080);
        // 2.从socket通信管道中得到一个字节输出流
        OutputStream os = socket.getOutputStream();
        // 3.把低级的字节输出流包装成高级的打印流。
        PrintStream ps = new PrintStream(os);
        // 4.开始发消息出去
         while(true){
            Scanner sc = new Scanner(System.in);
            System.out.print("请说:");
            ps.println(sc.nextLine());
            ps.flush();
        }
    }
}
public class ServerDemo{
    public static void main(String[] args) throws Exception {
        System.out.println("----服务端启动----");
        // 1.注册端口: public ServerSocket(int port)
        ServerSocket serverSocket = new ServerSocket(8080);
        // 2.开始等待接收客户端的Socket管道连接。
        Socket socket = serverSocket.accept();
        // 3.从socket通信管道中得到一个字节输入流。
        InputStream is = socket.getInputStream();
        // 4.把字节输入流转换成字符输入流
        BufferedReader br = new BufferedReader(new InputStreamReader(is));
        // 6.按照行读取消息 。
        String line;
        while((line = br.readLine()) != null){
            System.out.println(line);
        }
    }
}

需求三:实现一个服务端可以同时接收多个客户端的消息

public class ClientDemo {
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("127.0.0.1",8080);
        OutputStream os = new socket.getOutputStream();
        PrintStream ps = new PrintStream(os);
        while(true){
            Scanner sc = new Scanner(System.in);
            System.out.print("请说:");
            ps.println(sc.nextLine());
            ps.flush();
        }
    }
}
public class ServerDemo{
    public static void main(String[] args) throws Exception {
        System.out.println("----服务端启动----");
        ServerSocket serverSocket = new ServerSocket(8080);
        while(true){
            // 开始等待接收客户端的Socket管道连接。
             Socket socket = serverSocket.accept();
            // 每接收到一个客户端必须为这个客户端管道分配一个独立的线程来处理与之通信。
            new ServerReaderThread(socket).start();
        }
    }
}
class ServerReaderThread extends Thread{
    privat Socket socket;
    public ServerReaderThread(Socket socket){this.socket = socket;}
    @Override
    public void run() {
        try(InputStream is = socket.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(is))
           ){
            String line;
            while((line = br.readLine()) != null){
                sout(socket.getRemoteSocketAddress() + ":" + line);
            }
        }catch(Exception e){
            sout(socket.getRemoteSocketAddress() + "下线了~~~~~~");
        }
    }
}


伪异步

一个客户端要一个线程,并发越高系统瘫痪的越快,可以在服务端引入线程池,使用线程池来处理与客户端的消息通信

  • 优势:不会引起系统的死机,可以控制并发线程的数量
  • 劣势:同时可以并发的线程将受到限制
public class BIOServer {
    public static void main(String[] args) throws Exception {
        //线程池机制
        //创建一个线程池,如果有客户端连接,就创建一个线程,与之通讯(单独写一个方法)
        ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
        //创建ServerSocket
        ServerSocket serverSocket = new ServerSocket(6666);
        System.out.println("服务器启动了");
        while (true) {
            System.out.println("线程名字 = " + Thread.currentThread().getName());
            //监听,等待客户端连接
            System.out.println("等待连接....");
            final Socket socket = serverSocket.accept();
            System.out.println("连接到一个客户端");
            //创建一个线程,与之通讯
            newCachedThreadPool.execute(new Runnable() {
                public void run() {
                    //可以和客户端通讯
                    handler(socket);
                }
            });
        }
    }

    //编写一个handler方法,和客户端通讯
    public static void handler(Socket socket) {
        try {
            System.out.println("线程名字 = " + Thread.currentThread().getName());
            byte[] bytes = new byte[1024];
            //通过socket获取输入流
            InputStream inputStream = socket.getInputStream();
            int len;
            //循环的读取客户端发送的数据
            while ((len = inputStream.read(bytes)) != -1) {
                System.out.println("线程名字 = " + Thread.currentThread().getName());
                //输出客户端发送的数据
                System.out.println(new String(bytes, 0, read));
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println("关闭和client的连接");
            try {
                socket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

  • 14
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值