IO多路复用与Socket编程

393 阅读40分钟

参考:

  1. 理解一下5种IO模型、阻塞IO和非阻塞IO、同步IO和异步IO - 腾讯云开发者社区-腾讯云 (tencent.com)
  2. (49条消息) 一网打尽:面试中的 IO 多路复用高频题!_石杉的架构笔记的博客-CSDN博客
  3. (49条消息) 看这一篇IO多路复用面试专题就够了!最全面最详细的解答!_多路复用 面试题_零尘_oo的博客-CSDN博客
  4. 9.2 I/O 多路复用:select/poll/epoll | 小林coding (xiaolincoding.com)
  5. 图解 | 原来这就是 IO 多路复用 - 闪客sun - 博客园 (cnblogs.com)
  6. (49条消息) 【面试】彻底理解 IO多路复用_Hollis Chuang的博客-CSDN博客
  7. 聊聊对不同I/O模型的理解 (阻塞/非阻塞IO,同步/异步IO) - 知乎 (zhihu.com)
  8. 用大白话解释什么是Socket - 知乎 (zhihu.com)
  9. zhuanlan.zhihu.com/p/100151937 Go 语言使用 net 包实现 Socket 网络编程 - 知乎 (zhihu.com)
  10. gin 源码阅读(1) - gin 与 net/http 的关系 - 掘金 (juejin.cn)
  11. 一文读懂OSI七层模型和TCP/IP五层模型 - 知乎 (zhihu.com) socket编程是什么
  12. 简单理解Socket - 谦行 - 博客园 (cnblogs.com)
  13. 套接字Socket面试题 - 知乎 (zhihu.com)
  14. (46条消息) Socket编程面试题_屠变恶龙之人的博客-CSDN博客
  15. (46条消息) Linux的SOCKET编程详解_hguisu的博客-CSDN博客

IO多路复用

阻塞例子:

accept阻塞:阻塞在简历链接上,需要客户端发起连接三次握手
read阻塞:阻塞在读数据上,从网卡拷贝到内核,从内核缓存区拷贝到用户缓存区 image.png

网络IO多路复用背景:

当我们需要读fd或者写fd的时候,我们可以通过send,recv来对fd进行操作。 出现问题:
如果用户量多的话,不可能在一个循环里面顺序的来遍历fd:

原因1: 阻塞读写会因为没有数据而阻塞在send和recv上导致其他用户无法连接——>读写的操作设置成了非阻塞的 原因2:非堵塞时如果当一个连接有大量的数据收发时,这会在单个用户上耗费大量的时间,而让后面其它的用户数据等待时间变的非常的长

BIO同步阻塞方案:开多个线程来处理不同的fd,但是当用户数量很多的时候,线程数量会很大,操作系统线程的调度和切换所耗费的时间和线程本身所占用的栈的资源过高是一个很大的问题。

NIO同步非阻塞方案:服务器端当accept一个请求后,加入fds集合,每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu

IO多路复用方案:- 服务器端采用单线程通过select/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send,使其能支持更多的并发连接请求。 这个时候我们就需要用到一个系统的组件,它就是IO多路复用函数。这个函数简单的说就是可以让我们将需要关注的fd丢进去,它会返回已经把数据从网卡加载到内核缓冲区(具体过程推荐阅读UNP)的fd挑选出来给我们读写。这样我们读和写的时候就是绝对有数据的。

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

BIO和NIO

BIO(Blocking I/O)和 NIO(Non-blocking I/O)是两种不同的I/O(输入/输出)方案,它们的出现是为了解决不同的问题和应对不同的应用场景:

  1. BIO(Blocking I/O)
    • 问题:BIO主要是为了解决阻塞I/O的问题。在传统的同步阻塞I/O模型中,当一个线程执行一个I/O操作时,如果数据没有准备好或者网络连接没有建立,线程会被阻塞,无法执行其他任务。
    • 解决方案:为每个I/O操作创建一个独立的线程,以便每个操作可以独立执行,不会相互阻塞。但这种方式存在线程创建和管理开销大、资源占用高的问题
    • 使用场景:低并发但简单的应用
  2. NIO(Non-blocking I/O)
    • 问题:NIO主要是为了解决阻塞I/O模型的性能问题。在高并发环境下,使用大量线程会导致系统资源消耗过多,因此需要一种更高效的方式来处理I/O操作。
    • 解决方案:NIO引入了非阻塞I/O操作和选择器(Selector)的概念。非阻塞I/O允许一个线程处理多个I/O操作,而不会被阻塞。选择器可以用于监听多个通道(例如Socket通道),一旦有数据可读或可写,选择器会通知相应的线程来处理,而不需要创建大量线程来处理每个连接。
    • 适用场景:对于高并发或需要高性能的应用,通常会选择NIO或者更高级的异步I/O框架。

NIO的重要概念:Selector

选择器(Selector)监听多个通道的底层逻辑是利用了操作系统的多路复用机制。选择器使用操作系统提供的系统调用来实现高效的多路复用,以便同时监听多个通道的事件,包括读就绪、写就绪、连接就绪等。 选择器(Selector)监听多个通道的底层逻辑并不是简单地维护一个文件描述符(FD)的集合然后遍历。选择器使用操作系统提供的系统调用来实现高效的多路复用,以便同时监听多个通道的事件,包括读就绪、写就绪、连接就绪等。

选择器的工作方式如下:

  1. 注册通道:在使用NIO时,首先需要将需要监听的通道注册到选择器上。通常使用register方法将通道注册到选择器,同时指定要监听的事件类型,例如读、写、连接等。
  2. 阻塞等待事件:一旦通道被注册到选择器后,可以调用选择器的select方法来阻塞等待事件的发生。当有一个或多个通道上的事件发生时,select方法会返回,同时返回一个包含已经就绪的通道信息的集合。
  3. 处理就绪事件:接收到select方法返回的就绪通道集合后,程序可以遍历这个集合,针对每个就绪的通道执行相应的I/O操作。这包括读取数据、写入数据、处理连接等。
  4. 取消注册:在处理完通道的事件后,通常会取消通道的注册,以便不再监听该通道上的事件。

创建了一个选择器 selector,将 ServerSocketChannel 注册到选择器,并关注 OP_ACCEPT 事件。然后,在循环中,我们调用 selector.select() 阻塞等待就绪事件的发生,一旦有事件发生,就会返回已就绪的通道集合。接着,我们遍历已就绪的通道集合,处理相应的事件(OP_ACCEPTOP_READ),然后手动从已就绪集合中移除该事件,以便下次继续监听。

NIO的重要概念:通道

在NIO中,通道(Channel)是一种用于进行输入和输出操作的抽象概念,它代表了一个打开的连接,可以是文件、网络套接字、文件管道等等。通道是NIO中的核心组件之一,它与传统的输入/输出流不同,提供了更灵活的操作方式。

通道的主要作用是进行数据的读取和写入,它可以连接到一个或多个缓冲区,用于传输数据。通道通常与选择器(Selector)一起使用,以实现非阻塞的I/O操作。选择器会监视这些通道上的事件,一旦有事件发生,选择器会通知相应的线程来处理。

和Golang中的通道的区别:

  • NIO主要用于构建高性能的网络应用,通过非阻塞I/O来处理大量的并发连接。它适用于底层的网络编程,需要显式管理通道和事件。道通常与选择器(Selector)一起使用,以实现非阻塞的I/O操作。需要程序员自己管理事件的发生和处理,对于底层网络编程和需要精细控制的场景非常有用,但也可能需要更多的低级编程。
  • Go语言中的通道提供了一种更高级的、基于消息传递的并发编程模型,是用于并发编程的高级抽象,用于在不同的goroutine之间进行通信和同步。通道的主要目的是简化并发编程,让开发者更容易编写并发安全的代码。这个模型更抽象和高级,程序员不需要关心底层的事件处理和线程管理,只需要专注于数据的传递和协作。

阻塞IO

image.png 这是最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。阻塞IO操作只能对单个文件描述符进行操作。

作为服务端开发的话,我们使用severSocket绑定完端口号之后,我们会进行监听该端口,等待accept事件,accept会阻塞当前主线程,当我们收到accept事件时,程序就会拿到一个客户端与当前服务端连接的socket。针对这个socket我们可以进行读写,但是呢…这个socket读写方法都是会阻塞当前线程的。 其底层流程是:

  1. 创建socket接口,号为x,通过bind函数将接口号与端口号进行绑定,
  2. 进行listn监听事件或者是read读事件,且会一直阻塞在该命令,直到有客户端连接或者发送数据。

缺点:

  1. 如果是在单线程环境下,由于是阻塞地获取结果,只能有一个客户端连接。无法处理并发。
  2. 如果是在多线程环境下,需要不断地新建线程来接收客户端,这样会浪费大量的空间。但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销

非阻塞IO

image.png 进程发起IO系统调用后,如果内核缓冲区没有数据,需要到IO设备中读取,进程返回一个错误而不会被阻塞;进程发起IO系统调用后,如果内核缓冲区有数据,内核就会把数据返回进程。

对于上面的阻塞IO模型来说,内核数据没准备好需要进程阻塞的时候,就返回一个错误,以使得进程不被阻塞。

1、典型应用:socket是非阻塞的方式(设置为NONBLOCK)

2、特点:

  • 进程轮询(重复)调用,消耗CPU的资源;
  • 实现难度低、开发应用相对阻塞IO模式较难;
  • 适用并发量较小、且不需要及时响应的网络应用开发;

用户态和内核态的区别:

  • 内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
  • 用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。

当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。 由于需要限制不同的程序之间的访问能力, CPU划分出两个权限等级 -- 用户态和内核态。对于一些核心功能会做很多底层细致的工作消耗系统的物理资源,比如分配物理内存、从父进程拷贝相关信息等、显然不能随便哪个程序就去做。最关键性的权力必须由高特权级的程序来执行,这样才可以做到集中管理,减少有限资源的访问和使用冲突。

用户态切换到内核态的3种方式: a. 系统调用:用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如前例中fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。 b. 异常:当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。 c. 外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

什么是IO多路复用?

image.png O多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程 只使用一个进程来维护多个 Socket。
select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。

Select实现多路复用工作原理and默认监听端口数量:

  1. 已连接socket放到一个文件描述符集合
  2. 调用 select 函数将文件描述符集合拷贝到内核里,内核通过遍历检查是否有网络事件发生,当检查到有事件产生后,将此 Socket 标记为可读或可写。
  3. 把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

2 次「遍历」文件描述符集合,2 次「拷贝」文件描述符集合。

默认监听端口数量:

多路复用操作系统函数select(…)第一遍O(N)未发现就绪socket,后续再有某个socket就绪后,select(…)如何感知的?是不停轮询么?

Select和poll区别:

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,
缺点1:都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n)
缺点2:需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll产生背景和工作原理:

image.png epoll 通过两个方面,很好解决了 select/poll 的问题。

  • 在内核里使用红黑树来跟踪进程所有待检测的文件描述字
    把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里。,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • 事件驱动的机制:
    内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

epoll 水平触发(LT)与 边缘触发(ET)的区别?

水平触发和边缘触发都是事件触发

  • 边缘触发:
    当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次。
    边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
  • 水平触发:
    服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束。
    水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;

边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发

边缘触发模式一般和非阻塞 I/O 搭配使用: 如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,和非阻塞I/O搭配,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK

select、poll、epoll之间的区别和性能比较

  • Linux中IO复用的实现方式主要有select、poll和epoll:
  • Select:注册IO、阻塞扫描,监听的IO最大连接数不能多于FD_SIZE;
  • Poll:原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降;
  • Epoll :事件驱动不阻塞,mmap实现内核与用户空间的消息传递,数量很大,Linux2.6后内核支持;

适用场景:

  • select是最古老的多路复用机制,最早出现在Unix系统上。
    在处理大量文件描述符时性能较差,因为它使用线性扫描的方式查找就绪的文件描述符,时间复杂度是O(n),其中n是文件描述符的数量。
    适用于小规模的并发连接,通常在低并发环境下使用。
  • poll也需要线性扫描所有文件描述符,因此在性能上与select类似。
    select相比,poll提供了更多的事件类型(例如,可读、可写、异常等)。
    在可移植性上比select好,因为poll在几乎所有主要操作系统上都可用。
  • epoll采用了事件驱动的方式,不需要像select和poll一样遍历所有文件描述符。因此,在大规模并发情况下性能明显更好。处理大量并发连接的高性能网络应用。只在Linux系统上可用,不具有跨平台性。

epoll读到一半又有新事件来了怎么办?

在使用 epoll 进行事件驱动编程时,可能会出现这样的情况:当一个文件描述符(Socket)变得可读,程序开始读取数据,但读取的数据量不足以处理完整个消息,也就是说还没读完,该socket还是可读状态。同时,又有其他新的事件到达(例如另一个连接的数据已准备好)。,事件驱动,这个正在读的socket还是可读状态,会再被读一次。   避免在主进程epoll再次监听到同一个可读事件,可以把对应的描述符设置为EPOLL_ONESHOT,效果是监听到一次事件后就将对应的描述符从监听集合中移除,也就不会再被追踪到。读完之后可以再把对应的描述符重新手动加上。 为了避免重复监听相同的可读事件,你可以使用 EPOLLONESHOT 标志。EPOLLONESHOT 标志告诉 epoll 一旦某个文件描述符变为可读(或可写),它只会通知一次,然后不再监听该文件描述符,直到你显式重新将它添加到 epoll 中。

如果不使用EPOLLONESHOT导致问题: 根据业务场景来,导致问题就是说这个可读事件被重复触发监听了,但是水平模式也会导致重复监听相同的可读事件啊。 水平模式也好。EPOLLONESHOT也好。至少只是socket事件通知的一种方式,具体结合业务需求

那客户端是怎么发起连接的呢?

客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。

当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。

注意,监听的 Socket 和真正用来传数据的 Socket 是两个:

一个叫作监听 Socket; 一个叫作已连接 Socket;

连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据

服务器单机理论最大能连接多少个客户端

相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口。 服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数。 对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方

受到限制:

文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;

nginx/redis 所使用的IO模型是什么?

多进程模型

image.png 服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了, 可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。

进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

多线程模型

image.png 既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。

线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。

存在问题:频繁创建和销毁线程导致的系统开销
解决:线程池,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。

需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。

C10K问题

如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?

并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。 从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。 不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。

新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的

前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。
可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。

JAVA中的非阻塞IO——NIO

NIO三大核心部分:Channel(通道),Buffer(缓冲区),Selector(选择器)

NIO是面向缓冲区的,或者面向块编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区内前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞的高伸缩性网络

使一个线程从某通道发送或者读取数据,但是它仅能得到目前可用的数据,如果目前没有可用的数据时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可读取之前,该线程可以继续做其他事情。非阻塞就是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。

Selector(选择器)的作用是循环监听多个客户端连接通道,如果通道中没有数据即客户端没有请求时它可以去处理别的通道或者做其他的事情,如果通道中有数据他就会选择这个通道然后进行处理,这就做到了一个线程处理多个连接。

NIO是可以做到用一个线程处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或100个线程来处理。不像BIO一样需要分配10000个线程来处理。

【面试】彻底理解 IO多路复用_程序员caspar的博客-CSDN博客

OSI七层模型:

image.png

应用层:为应用程序或用户请求提供各种请求服务。 OSI参考模型最高层,也是最靠近用户的一层,为计算机用户、各种应用程序以及网络提供接口,也为用户直接提供各种网络服务。

表示层:数据编码、格式转换、数据加密。 提供各种用于应用层数据的编码和转换功能,确保一个系统的应用层发送的数据能被另一个系统的应用层识别。如果必要,该层可提供一种标准表示形式,用于将计算机内部的多种数据格式转换成通信中采用的标准表示形式。数据压缩和加密也是表示层可提供的转换功能之一。

会话层:创建、管理和维护会话。 接收来自传输层的数据,负责建立、管理和终止表示层实体之间的通信会话,支持它们之间的数据交换。该层的通信由不同设备中的应用程序之间的服务请求和响应组成。

传输层:数据通信。 建立主机端到端的链接,为会话层和网络层提供端到端可靠的和透明的数据传输服务,确保数据能完整的传输到网络层。

网络层:IP选址及路由选择。 通过路由选择算法,为报文或通信子网选择最适当的路径。控制数据链路层与传输层之间的信息转发,建立、维持和终止网络的连接。数据链路层的数据在这一层被转换为数据包,然后通过路径选择、分段组合、顺序、进/出路由等控制,将信息从一个网络设备传送到另一个网络设备。

数据链路层:提供介质访问和链路管理。 接收来自物理层的位流形式的数据,封装成帧,传送到网络层;将网络层的数据帧,拆装为位流形式的数据转发到物理层;负责建立和管理节点间的链路,通过各种控制协议,将有差错的物理信道变为无差错的、能可靠传输数据帧的数据链路。

物理层:管理通信设备和网络媒体之间的互联互通。 传输介质为数据链路层提供物理连接,实现比特流的透明传输。实现相邻计算机节点之间比特流的透明传送,屏蔽具体传输介质和物理设备的差异。

TCP/IP体系结构:

image.png ,发送端想要发送数据到接收端。首先应用层准备好要发送的数据,然后给了传输层,传输层的主要作用就是为发送端和接收端提供可靠的连接服务,传输层将数据处理完后就给了网络层。网络层的功能就是管理网络,其中一个核心的功能就是路径的选择(路由),从发送端到接收端有很多条路,网络层就负责管理下一步数据应该到哪个路由器。选择好了路径之后,数据就来到了数据链路层,这一层就是负责将数据从一个路由器送到另一个路由器。然后就是物理层了,可以简单的理解,物理层就是网线一类的最基础的设备。

IO多路复用编程

Socket

套接字(socket)是在应用层和传输层之间的抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将I/O插入到网络中,并与网络中的其他应用程序进行通信。

Socket不算是一个协议,它是应用层与传输层间的一个抽象层。它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用,以实现进程在网络中通信 socket是应用层与传输层的一个抽象,将复杂的TCP/IP协议隐藏在Socket接口之后,只对应用层暴露简单的接口

socket是一种特殊的文件,它也有文件描述符,进程可以打开一个socket,并且像处理文件一样对它进行read()和write()操作,而不必关心数据是怎么在网络上传输的

socket是一个tcp连接的两端

网络套接字是IP地址与端口的组合。socket是一个抽象层,它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用已实现进程在网络中通信。网络中的进程通过socket来通信。

socket就像是一个电话或者邮箱(邮政的信箱)。当你想要发送消息的时候,拨通电话或者将信息塞到邮箱里,socket内核会自动完成将数据传给对方的这个过程。 基于socket我们可以选择使用TCP或UDP协议进行通信。

socket如何唯一标识一个进程

socket基于tcp协议实现,网络层的ip地址唯一标识一台主机,而传输层的协议+端口号可以唯一标识绑定到这个端口的进程.
两个进程如果需要进行通讯最基本的一个前提能能够唯一的标示一个进程。能够唯一标示网络中的进程后,它们就可以利用socket进行通信了。
本地进程通信方法: 通过PID唯一标识一个进程 网络进程通信方法: 通过三元组(ip地址,协议,接口)唯一标识网络进程 网络层:“ip地址”唯一标识一台网络中的主机 传输层:“协议+端口”唯一标识主机中的应用程序(进程)

通信双方如何进行端口绑定

通常服务端启动时会绑定一个端口提供服务,而客户端在发起连接请求时会被随机分配一个端口号

对套接字编程的理解协议如何

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

服务器和客户端通过socket进行交互。服务器需要绑定在本机的某个端口号上,客户端需要声明自己连接哪个地址的哪个端口,这样服务器和客户端就能连接了。

根据连接启动的方式以及本地套接字要连接的目标,套接字之间的连接过程可以分为三个步骤:服务器监听,客户端请求,连接确认。
(1)服务器监听:是服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。
(2)客户端请求:是指由客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。
(3)连接确认:是指当服务器端套接字监听到或者说接收到客户端套接字的连接请求,它就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

socket是对TCP/IP协议的封装和应用。在TCP/IP协议中,TCP协议通过三次握手建立一个可靠的连接。

与HTTP对比

socket连接
socket不属于协议范畴,而是一个调用接口(API),是对TCP/IP协议的封装。实现服务器与客户端之间的物理连接,并进行数据传输。Socket处于网络协议的传输层,主要有TCP/UDP两个协议。
socket连接是长连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉;但是由于各种环境因素可能会是连接断开,比如:服务器端或客户端主机宕机了、网络故障,或者两者之间长时间没有数据传输,网络防火墙可能会断开该连接以释放网络资源。所以当一个socket连接中没有数据的传输,那么为了维持连接需要发送心跳消息。
socket传输的数据可自定义,为字节级,数据量小,可以加密,数据安全性高,适合Client/Server之间信息实时交互。

http连接
HTTP是基于TCP/IP协议的应用层协议,定义的是传输数据的内容的规范。
HTTP是基于请求-响应形式并且是短连接,即客户端向服务器端发送一次请求,服务器端响应后连接即会断掉。
HTTP是无状态的协议,针对其无状态特性,在实际应用中又需要有状态的形式,因此一般会通过session/cookie技术来解决此问题。
HTTP的传输速度慢,数据包大,数据传输安全性差,如实现实时交互,服务器性能压力大。

socket通信流程:

把Socket编程理解为对TCP协议的具体实现。 socket是"打开—读/写—关闭"模式的实现,以使用TCP协议通讯的socket为例,其交互流程如下:

image.png
服务器根据地址类型(ipv4,ipv6)、socket类型、协议创建socket
服务器为socket绑定ip地址和端口号
服务器socket监听端口号请求,随时准备接收客户端发来的连接,这时候服务器的socket并没有被打开
客户端创建socket
客户端打开socket,根据服务器ip地址和端口号试图连接服务器socket
服务器socket接收到客户端socket请求,被动打开,开始接收客户端请求,直到客户端返回连接信息。这时候socket进入阻塞状态,所谓阻塞即accept()方法一直到客户端返回连接信息后才返回,开始接收下一个客户端谅解请求
客户端连接成功,向服务器发送连接状态信息
服务器accept方法返回,连接成功
客户端向socket写入信息
服务器读取信息
客户端关闭
服务器端关闭

Socket编程代码

参考:C++ Socket编程(基础) - MaxLij - 博客园 (cnblogs.com)
服务端Server:
流程:socket()创建套接字——> bind()绑定地址和端口——> Listen监听端口请求——> Accept()接收请求——> send/recv

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(){
    // 创建socket
    int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    // AF_INET :   表示使用 IPv4 地址		可选参数
    // SOCK_STREAM 表示使用面向连接的数据传输方式,
    // IPPROTO_TCP 表示使用 TCP 协议
     
    // blind绑定IP地址和端口
    struct sockaddr_in serv_addr; memset(&serv_addr, 0 , sizeof(serv_addr));
    serv_addr.sin_family = AF_INET; // 使用IPv4地址
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 具体的IP地址
    serv_addr.sin_port = htons(1234); // 端口
    bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    
    // listen进入监听状态
    listen(serv_sock, 20);
    
    /* accept堵塞接收客户端请求
        struct sockaddr_in clnt_addr;
        int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr,sizeof(clnt_addr));
    
        // write发送数据
        char str[] = "Hello World!";
        write(clnt_sock, str, sizeof(str));
    
        // 接收数据
        char msg[1024]; memset(msg, 0, sizeof(msg));
        nrecvSize = read(clnt_sock, msg, 1024));
        if(nrecvSize > 0) printf("recvMsg:%s", msg);
    
        close(clnt_sock);
        close(serv_sock);
    
    */
    
   // IO多路复用监听
   // 创建epoll实例
   int epollFd = epoll_create1(0);
   
   // 添加监听套接字到epoll实例中
   struct epoll_event event; 
   event.events = EPOLLIN;  // 设置事件类型为只读
   event.data.fd = serv_sock ;
   epoll_ctl(epollFd, EPOLL_CTL_ADD, serv_sock, &event)
   
   // 主循环
   vector<int> clientSockets;
   while(true){
       // 堵塞等待直到有事件发生,-1表示一直等待直到有事件发生。
       struct epoll_event events[10];
       int numEvents = epoll_wait(epollFd, events, 10, -1);
       // 遍历事件
       for(int i=0; i<numEvents; ++i){
           // 有新连接请求发生
           if(events[i].data.fd == serv_sock){ 
               int clientSocket = accept(listenSocket, nullptr, nullptr);
               // 将新的客户端套接字添加到 epoll 实例中,水平触发
                event.events = EPOLLIN; // EPOLLET 表示边缘触发
                event.data.fd = clientSocket;
                if (epoll_ctl(epollFd, EPOLL_CTL_ADD, clientSocket, &event) == -1) {
                    perror("Epoll control failed");
                    close(clientSocket);
                }
                clientSockets.push_back(clientSocket);
           // 有客户端套接字上有数据可读
           }else{                              
               char buffer[1024];
               if(read(events[i].data.fdm buffer, sizeof(buffer))<=0){
                   close(events[i].data.fd);//客户端关闭连接
                   epoll_ctl(epollFd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);//从epoll实例中移出该套接字
                   auto it = std::find(clientSockets.begin(), clientSockets.end(), events[i].data.fd);
                   if (it != clientSockets.end()) { clientSockets.erase(it);}
            // 无新请求也无可读数据,则处理数据
            }else{                             
                cout << "Received data from " << events[i].data.fd << ": " << buffer << std::endl;
           }
       }
   }
   
   // 关闭监听套接字和客户端套接字
   closs(listenSocket);
   for(int clientSocket : clientSockets){
       close(clientSocket);
   }
   return 0;
}

接收端receiver:
创建socket——> connect连接——> read/write

#include <stdio.h>
#include <string.h>
#include <stdlib.h>  // malloc
#include <unisted.h> // close
#include <arpa/inet.h>  // 处理网络地址函数inet_addr
#include <sys/socket.h>  //
#include <netinet/in.h> // Internet地址族相关定义

int main(){
    //创建socket
    int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    
    // connect
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(1234);
    connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    
    // read
    char buffer[40];
    read(sock, buffer, sizeof(buffer)-1);
   
    printf("Message form server: %s\n", buffer);
   
    //关闭套接字
    close(sock);


    return 0;
}

socket文件描述符状态

文件描述符是一个整数,用于标识一个打开的文件或I/O设备。在Socket通信中,文件描述符是用于标识一个打开的网络套接字,它是通过调用socket()、accept()、connect()等函数返回的。文件描述符是操作系统用来管理文件和设备的一种机制,用于在应用程序和操作系统之间进行通信。在Socket通信中,应用程序可以使用文件描述符来读取和写入数据,以及进行其他操作,如设置套接字选项、关闭套接字等。

在使用socket进行网络编程时,socket文件描述符上的状态可以分为以下几种: LISTENING:表示socket正在监听客户端的连接请求。 ESTABLISHED:表示socket已经和客户端建立了连接,并且可以进行数据传输。 SYN_SENT:表示socket已经向服务器发送了连接请求,并等待服务器的确认。 SYN_RECEIVED:表示socket已经收到了客户端的连接请求,并发送了确认信息。 FIN_WAIT_1:表示socket已经发送了关闭请求,并等待服务器的确认。 FIN_WAIT_2:表示socket已经收到了服务器的确认,但仍在等待服务器的关闭请求。 TIME_WAIT:表示socket已经发送了关闭请求,并等待一段时间,以便确保服务器已经收到了关闭请求。 CLOSING:表示socket正在关闭过程中,但仍有未发送的数据。 CLOSE_WAIT:表示socket已经收到了服务器的关闭请求,但仍有未处理完的数据。 LAST_ACK:表示socket已经收到了服务器的关闭请求,并发送了确认信息,等待服务器的最后一次确认。 以上是常见的socket文件描述符状态,不同的状态对应着不同的网络连接状态。当我们进行网络编程时,需要根据不同的状态进行相应的处理,以确保网络连接的稳定和可靠。

socket与webSocket区别:

Socket是应用层与传输层的一个抽象,将复杂的TCP/IP协议隐藏在Socket接口之后,只对用户暴露简单的接口。

而WebScoket是应用层协议,它也是基于TCP实现,同时借助了HTTP协议建立连接。

WebSocket连接过程:
1678763343972.png