抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

本文以IO多路复用为核心,详细地介绍了从Socket模型——>TCP连接——>IO模型——>IO多路复用——>Redis线程模型,一文尽量讲解清楚。

本文内容主要来自小林coding/黑马

1.最基本的Socket模型

要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,Socket是进程通信的一种方式,其特殊在可以跨主机通信

Socket中文译插口,类似这客户端和服务端搞了一个插口,然后中间连个线就可以通信了。

创建Socket时可以指定网络协议IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。UDP的Socket编程相对简单,这里以TCP的为例。

1.TCP建立的过程

服务端要先启动,然后等待客户端来建立连接。

服务端首先调用 socket() 函数,指定网络协议和传输协议,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口

  • 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序
  • 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们;

绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,对应 TCP 状态图中的 listen ,服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。

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

连接

在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:

  • TCP 半连接队列,这个队列都是没有完成三次握手的连接
  •  TCP 全连接队列,这个队列都是完成了三次握手的连接

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

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

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

2.如何服务更多的用户?

前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 /0 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。

所以现在要对这个IO模型改进,以支持更多的客户端。

这里引入一个问题,服务器单机最多能连接多少客户端?

TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口

服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。 因此服务器的本地 IP 和端口是固定的, 于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的, 所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数

对于 IPv4,客户端的 IP 数最多为 2 的 32 次方, 客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方

但是实际上连接不了那么多,受到两个方面的限制:

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

3.IO多路复用

为每个请求分配一个进程/线程的方式不合适,因此引入 I/O 多路复用技术,它只使用一个进程来维护多个 Socket 。

IO多路复用

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内, 这样 1 秒内就可以处理上千个请求,把时间拉长来看, 多个请求复用了一个进程,这就是多路复用。这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。

select/poll/epoll 内核提供给用户态的多路复用系统调用, 进程可以通过一个系统调用函数从内核中获取多个事件

select/poll/epoll 是如何获取网络事件的呢?在获取事件时, 先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接, 然后在用户态中再处理这些连接对应的请求即可。

4.select/poll

1.select

select 实现多路复用的方式是,将已连接的 Socket(一个Socket连接对应一个文件描述符fd) 都放到一个文件描述符集合(fd_set),然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,返回给用户空间的是就绪的事件个数,接着再把整个文件描述符集合拷贝回用户态里(实际上是覆盖原来的fd_set),然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里, 一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合, 所支持的文件描述符的个数是有限制的,默认最大值为 1024

缺点:

  1. 两次拷贝:先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
  2. 需要遍历才能得知就绪的fd
  3. 可传入的fd数量有限制

2.poll

流程基本和select类似

唯一不同的是,在传入fd的数量上有了突破,用户空间传入的是可自定义大小的pollfd数组,然后传入内核空间后,用链表形式组织。所以说fd的数量理论上是没有限制的(但实际受限于查询效率的问题,不可能没有限制的)。但仍然没有解决select的问题,拷贝,遍历。

3.epoll

Epoll

执行流程

  1. epoll_create()创建epoll实例,返回对应的句柄epfd。具体而言,这个实例包括一个红黑树和一个链表。红黑树用于记录要监听的fd,每个节点就是一个fd。而链表用于记录就绪的fd
  2. epoll_ctl()将要监听的fd添加到红黑树中,当这个fd就绪的时候,内核通过回调函数将该fd添加到链表中
  3. epoll_wait()等待fd就绪,调用该方法会在用户空间创建一个数组,用于接受就绪的fd

改进点

  1. 红黑树结构增加效率:基于红黑树保存要监听的fd,增删改查效率高
  2. 减少重复拷贝:每个fd只需要一次epoll_ctl()添加到红黑树,无需重复拷贝fd到内核空间(相对于select和poll)
  3. 无需遍历就能得到就绪fd:内核将就绪的fd拷贝到用户空间的数组中,用户空间无需再次遍历,可以直接得到就绪的fd

4.epoll的ET和LT模式

epoll事件的通知机制有两种模式,分别是:

水平触发(levelTrrigered, LT):当有fd就绪时,会重复通知多次,直至数据被处理完。是epoll的默认模式

边缘触发(edgeTrrigered, ET):当有fd就绪时,仅通知一次,如果数据没有被处理完就会丢失

LT模式的缺点:

  1. 频繁调用epoll_wait()会产生很多开销
  2. 可能会产生“惊群”现象,比如当前剩余没有被处理的数据仅仅一两个线程就能解决,但在这种模式下会唤醒所有线程处理数据,也就是会产生不必要的唤醒

ET模式如何保证数据可以被处理完毕?

  1. 采用非阻塞IO一次性把就绪fd处理完。不能采用阻塞IO,如果采用阻塞IO在处理完就绪的fd后会阻塞等待未就绪的fd到就绪的状态,而非阻塞IO在处理完就绪的fd后就会返回错误,代表就绪的fd已经处理完了,剩下的都是未就绪的fd了。

  2. 手动把未处理完的fd添加到epoll实例中。(类似手动挡的LT)

    前置了解:在拷贝就绪fd到用户空间时,会在就绪链表中移除拷贝的fd,同时会检查当前通知模式时ET还是LT,如果是ET,会永久的移除拷贝的fd,如果是LT,则会检查就绪fd数据有没有被处理完毕,如果没有,会重新把移除的fd添加到链表中。

    我们可以调用epoll_ctl()手动将未处理完的fd添加到epoll实例上,完了之后红黑树检测到fd就绪,就会重新给这些就绪的fd添加到链表上。

5.Redis网络模型

评论