网络模型与 IO 多路复用

news/2024/7/7 21:00:05

  • 一、基础概念
    • 1. socket
    • 2. FD:file descriptor**
    • 3. 内核态和用户态
  • 二、 IO 多路复用
    • 1. 常见的IO模型
    • 2. 同步和异步
    • 3. 阻塞和非阻塞
  • 三、 阻塞IO
  • 四、非阻塞 IO
    • 1、针对 read 函数造成的阻塞
    • 2、针对 accept函数造成的阻塞
    • 3、 select 模型
    • 4、poll模型
    • 5、epoll模型

一、基础概念

1. socket

  socket也称作“套接字”,用于描述IP地址和端口,是一个通信链路的描述符。应用程序通常通过“套接字”向对端发出请求或者应答网络请求。

  socket是连接运行在网络上的两个程序之间的通信端点。通信的两端都有socket,它是一个通道,数据在两个socket之间进行传输。socket把复杂的TCP/IP协议族或者UDP/IP协议族隐藏在socket接口后面,对程序员来说,只要用好socket相关的函数,就可以完成数据通信。

在这里插入图片描述

2. FD:file descriptor**

​   Linux 系统中,把一切都看做是文件(一切皆文件),当进程打开现有文件或创建新文件时,内核向进程 返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文 件,所有执行I/O操作的系统调用都会通过文件描述符。

​   一个进程能够同时打开多个文件,对应需要多个文件描述符,所以需要用一个文件描述符表对文件描述符 进行管理;通常默认大小为1024,也即能容纳1024个文件描述符;

​   在 soket 通信中,当我们调用内核函数创建 socket 后,内核返回给我们的是 socket 对应的文件描述符( fd),所以我们对 socket 的操作基本都是通过 fd 来进行。这个文件描述符 fd 可能是就绪的状态即大于 1 的整 数,也可能是未就绪的状态-1。就绪状态表示客户端或服务端发送的全部数据通过网卡进入到内核缓冲区buf, 这时调用 系统read( )就可以将数据从内核区拷贝到用户缓冲区。

  见阻塞 IO 图。

3. 内核态和用户态

​   线程是操作系统调度CPU的最小单元,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、 堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些 线程在同时执行。线程的实现可以分为两类:用户级线程,内核线线程。(java线程就是内核级线程)

​   虚拟内存被操作系统划分成两块:内核空间和用户空间,内核空间是内核代码运行的地方,用户空间是用 户程序代码运行的地方。当进程运行在内核空间时就处于内核态,当进程运行在用户空间时就处于用户态,为 了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。说起这个概念就是因为线程上下文切换的 概念。虽然线程上下文切换比进程切换成本要低但是,线程切换也是很影响性能的。线程上下文切换就涉及用 户态到内核态的转换。

二、 IO 多路复用

  服务器端编程经常需要构造高性能的IO模型,

1. 常见的IO模型

  • 同步阻塞IO(Blocking IO):即传统的IO模型。

  • 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。

  • IO多路复用(IO Multiplexing):即经典的Reactor模式(并非23种设计模式之一),有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。

​  Reactor模式称为反应器模式或应答者模式,是基于事件驱动的设计模式,拥有一个或多个并发输入 源,有一个服务处理器和多个请求处理器,服务处理器会同步的将输入的请求事件以多路复用的方式分发 给相应的请求处理器。 Reactor设计模式是一种为处理并发服务请求,并将请求提交到一个或多个服务处 理程序的事件设计模式。

  • 异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

2. 同步和异步

  同步和异步的概念描述的是用户线程与内核的交互方式,主体是线程:

​   同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;

​   而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后通知用户线程,或者调用用户线程注册的回调函数。

​   同步异步的主体是用户线程。

3. 阻塞和非阻塞

   阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式,主体是IO:

​   阻塞是指IO操作需要彻底完成后才返回到用户空间;

​   而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

​   阻塞非阻塞的主体是IO。

三、 阻塞IO

  为了方便理解,以下所有代码都是伪代码,知道其表达的意思即可。

  服务端为了处理客户端的连接和请求的数据,写了如下代码。

服务端												 客户端
listenfd = socket();   打开一个网络通信端口
bind(listenfd);        绑定
listen(listenfd);      监听
while(1) { 											 fd = socket();
  connfd = accept(listenfd);   阻塞建立连接			 connect(fd);
          											 write(fd,buf);
  int n = read(connfd, buf);  阻塞读数据               closed(fd);
  doSomeThing(buf);  利用读到的数据做些什么
  close(connfd);     关闭连接,循环等待下一个连接
}

  服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。

  read函数包括两个阶段:
在这里插入图片描述

  这就是传统的阻塞 IO。如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。

四、非阻塞 IO

  为了解决上面的问题,我们需要对 accept函数和 read 函数进行改造。

1、针对 read 函数造成的阻塞

​   这个 read 函数的效果是,如果没有数据到达内核缓冲区时,即第一阶段未完成,立刻返回一个错误值-1,而不是阻塞地等待。

​   操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。

服务端																						       
listenfd = socket();   			       打开一个网络通信端口
bind(listenfd);        				   绑定
listen(listenfd);      				   监听
while(1) { 																						
  connfd = accept(listenfd);   	       阻塞建立连接		  
  fcntl(connfd, F_SETFL, O_NONBLOCK);  将文件描述符设置为非阻塞										
  int n = read(connfd, buffer); 	   如果 fd 未就绪,调用 read 会立即返回-1,处理下																					 一个连接
  doSomeThing(buf);  										
  close(connfd);     											
}

  如果 fd 未就绪,调用 read 会立即返回-1,如果就绪,就会阻塞式的 read。

2、针对 accept函数造成的阻塞

​   有一种聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。

while(1) { 
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create(doWork);  // 创建一个新的线程
}
void doWork() { 
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

  这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。

  为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。

  当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。

服务端																						       
listenfd = socket();   						打开一个网络通信端口
bind(listenfd);        					    绑定
listen(listenfd);      						监听
fdlist; 

while(1) { 																						
  connfd = accept(listenfd);   			    阻塞建立连接		  
  fcntl(connfd, F_SETFL, O_NONBLOCK);    	将文件描述符设置为非阻塞
  fdlist.add(connfd);   											
}

新线程去处理
while(1) { 																						
  for(fd <-- fdlist) { 
    if(read(fd) != -1) { 
      doSomeThing();
    }
    close(fd);
    //移除此 fd
  }     											
}

  这样,我们就成功用一个线程处理了多个客户端连接。

​   但这和我们用多线程去将阻塞 IO 改造,看起来是 一样的,这种遍历方式也是我们用户层的小把戏,每次遍 历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。

  使用 while 循环不断地做系统调用,是不合理的。每次只传给 read 函数一个文件描述符,传一次调用一次。

  我们每次传给 read 函数一批文件描述符到内核,由内核层去遍历,这个问题才能真正解决。

3、 select 模型

​   select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让 操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理。不过,当 select 函数返回后,用 户依然需要遍历刚刚提交给操作系统的 list。只不过,操作系统会将准备就绪的文件描述符做上标识,用户 层将不会再有无意义的系统调用开销。

在这里插入图片描述

服务端																						       
listenfd = socket();   						打开一个网络通信端口
bind(listenfd);        						绑定
listen(listenfd);      						监听
fdlist;

首先一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里。
while(1) { 																						
  connfd = accept(listenfd);   			    阻塞建立连接		  
  fcntl(connfd, F_SETFL, O_NONBLOCK);    	将文件描述符设置为非阻塞
  fdlist.add(connfd);   											
}

while(1) { 
  // 把一堆文件描述符 list 传给 select 函数
  // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
  nready = select(list);
    // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) { 
    if(fd != -1) { 
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) 
        break;
    }
  }
}

  存在问题:

  • select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)

  • select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)

  • select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

4、poll模型

​   它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

5、epoll模型

​   针对select 模型的三个问题进行了改进。

  • ​ 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。

  • ​ 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。

  • ​ 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

​   具体,操作系统提供了这三个函数。

//第一步,创建一个 epoll 句柄
int epoll_create(int size);
//第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//第三步,类似发起了 select() 调用
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

参考文章https://www.dandelioncloud.cn/article/details/1615702819904651265


http://lihuaxi.xjx100.cn/news/1075824.html

相关文章

RUST 每日一省:生命周期作用域

生命周期 一个变量的生命周期就是它从创建到销毁的整个过程。 作用域 我们声明的每个变量都有作用域。作用域其实是变量和值存在的环境。作用域是由一对花括号表示的。例如&#xff0c;使用块表达式会创建一个作用域&#xff0c;即任何以花括号开头和结尾的表达式。此…

关于业务缓存的存储结构

问&#xff1a;为什么不直接缓存 [账号id->权限列表]的关系&#xff0c;而是 [账号id -> 角色id -> 权限列表]&#xff1f; 答&#xff1a;[账号id->权限列表]的缓存方式虽然更加直接粗暴&#xff0c;却有一个严重的问题&#xff1a; 通常我们系统的权限架构是RBA…

Reid之网络的定义代码详解

这篇文章是yolov5_reid的扩展。针对reid网络定义代码的详解&#xff0c;有助于大家的理解&#xff0c;同时也方便网络方面的改进。 数据集的预处理代码可以参考我另一篇文章&#xff1a;Reid数据集处理 本篇文章Reid的网络将以Resnet50为例。因此需要大家对Resnet代码有一定的…

Clickhouse分布式表引擎(Distributed)写入核心原理解析

Clickhouse分布式表引擎&#xff08;Distributed&#xff09;写入核心原理解析 Clickhouse分布式表引擎&#xff08;Distributed&#xff09;写入核心原理解析Clickhouse分布式表引擎&#xff08;Distributed&#xff09;查询核心原理解析 Distributed表引擎是分布式表的代名…

【接口自动化测试】月薪12k必会技术,从0到1学习接口自动化测试,6个操作安排的明明白白

导读&#xff1a;在所有的开发测试中&#xff0c;接口测试是必不可少的一项。有效且覆盖完整的接口测试&#xff0c;不仅能保障新功能的开发质量&#xff0c;还能让开发在修改功能逻辑的时候有回归的能力&#xff0c;同时也是能优雅地进行重构的前提。编写接口测试要遵守哪些原…

selenium定位网络元素

这里主要写的是新版本 1、通过元素的id属性来定位元素 ele driver.find_element(By.ID, “IamID”) # 类型为 WebElement 类 1 2、 通过元素的name属性来定位元素 driver.find_element(By.NAME, “first”).send_keys(“通过元素的name属性来定位元素”) 1 2 3、通过元素的…

01 【Sass的安装使用】

1.介绍 1.1 CSS预处理技术&#xff0c;及种类介绍 什么是css预处理技术 CSS 预处理器定义了一种新的语言&#xff0c;其基本思想是&#xff0c;用一种专门的编程语言&#xff0c;为 CSS 增加了一些编程的特性&#xff0c;将 CSS 作为目标生成文件&#xff0c;然后开发者就只…

Hilt 和协程助力启动框架搭建:解决代码混乱和初始化策略问题

关于Hilt的使用&#xff0c;目前已经比较普及了&#xff0c;想必大家已经知道。今天说的是一个如何利用Hilt来做一个启动框架的故事。 是否经历过大型项目的启动优化&#xff0c;一遍过去无任何效果&#xff0c;第二遍过去好几处报错&#xff0c;第三遍过去启动不了&#xff0…