本文以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 。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 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
缺点:
- 两次拷贝:先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
- 需要遍历才能得知就绪的fd
- 可传入的fd数量有限制
2.poll
流程基本和select类似
唯一不同的是,在传入fd的数量上有了突破,用户空间传入的是可自定义大小的pollfd
数组,然后传入内核空间后,用链表形式组织。所以说fd的数量理论上是没有限制的(但实际受限于查询效率的问题,不可能没有限制的)。但仍然没有解决select的问题,拷贝,遍历。
3.epoll
执行流程
epoll_create()
创建epoll实例,返回对应的句柄epfd。具体而言,这个实例包括一个红黑树和一个链表。红黑树用于记录要监听的fd,每个节点就是一个fd。而链表用于记录就绪的fdepoll_ctl()
将要监听的fd添加到红黑树中,当这个fd就绪的时候,内核通过回调函数将该fd添加到链表中epoll_wait()
等待fd就绪,调用该方法会在用户空间创建一个数组,用于接受就绪的fd
改进点
- 红黑树结构增加效率:基于红黑树保存要监听的fd,增删改查效率高
- 减少重复拷贝:每个fd只需要一次
epoll_ctl()
添加到红黑树,无需重复拷贝fd到内核空间(相对于select和poll) - 无需遍历就能得到就绪fd:内核将就绪的fd拷贝到用户空间的数组中,用户空间无需再次遍历,可以直接得到就绪的fd
4.epoll的ET和LT模式
epoll事件的通知机制有两种模式,分别是:
水平触发(levelTrrigered, LT):当有fd就绪时,会重复通知多次,直至数据被处理完。是epoll的默认模式
边缘触发(edgeTrrigered, ET):当有fd就绪时,仅通知一次,如果数据没有被处理完就会丢失
LT模式的缺点:
- 频繁调用
epoll_wait()
会产生很多开销 - 可能会产生“惊群”现象,比如当前剩余没有被处理的数据仅仅一两个线程就能解决,但在这种模式下会唤醒所有线程处理数据,也就是会产生不必要的唤醒
ET模式如何保证数据可以被处理完毕?
采用非阻塞IO一次性把就绪fd处理完。不能采用阻塞IO,如果采用阻塞IO在处理完就绪的fd后会阻塞等待未就绪的fd到就绪的状态,而非阻塞IO在处理完就绪的fd后就会返回错误,代表就绪的fd已经处理完了,剩下的都是未就绪的fd了。
手动把未处理完的fd添加到epoll实例中。(类似手动挡的LT)
前置了解:在拷贝就绪fd到用户空间时,会在就绪链表中移除拷贝的fd,同时会检查当前通知模式时ET还是LT,如果是ET,会永久的移除拷贝的fd,如果是LT,则会检查就绪fd数据有没有被处理完毕,如果没有,会重新把移除的fd添加到链表中。
我们可以调用
epoll_ctl()
手动将未处理完的fd添加到epoll实例上,完了之后红黑树检测到fd就绪,就会重新给这些就绪的fd添加到链表上。
5.Redis网络模型