Linux网络模型

在《UNIX网络编程》一书中,总结归纳了5种IO模型:

  • 阻塞IO(Blocking IO)
  • 非阻塞IO(Nonblocking IO)
  • IO多路复用(IO Multiplexing)
  • 信号驱动IO(Signal Driven IO)
  • 异步IO(Asynchronous IO)

阻塞IO

应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。

Blocking_IO

用户去读取数据时,会去先发起recvform一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回ok,整个过程,都是阻塞等待的,这就是阻塞IO

非阻塞IO

非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程拿到error后,再次尝试读取
  • 循环往复,直到数据就绪

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增

Nonblocking_IO

IO多路复用

论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

  • 如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用
  • 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据

而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能表现很差

想要提高IO事件效率,可以使用多线程,或者即时通知(哪个数据就绪,用户进程就读取这个数据)

那么用户进程如何知道内核中数据是否就绪呢?

这个问题的解决依赖于提出的文件描述符。

文件描述符(File Descriptor):简称FD,是一个从0开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)

通过FD,我们的网络模型可以利用一个线程监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源

用IO复用模式,可以确保去读数据的时候,数据是一定存在的,他的效率比原来的阻塞IO和非阻塞IO性能都要高

IO多路复用即线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

  1. select
  2. poll
  3. epoll

select

select是Linux最早是由的I/O多路复用技术。

具体可以描述为:我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据

比如要监听的数据,是1,2,5三个数据,此时会执行select函数,然后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去,此时用户态就知道有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求

seletc模式存在的问题:

  • 需要将整个fd_set用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  • select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  • fd_set监听的fd数量不能超过1024

poll

poll模式对select模式做了简单改进,但性能提升不明显,IO流程:

  1. 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
  2. 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
  3. 内核遍历fd,判断是否就绪
  4. 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
  5. 用户进程判断n是否大于0,大于0则遍历pollfd数组,找到就绪的fd

与select对比:

  • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限

  • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降

epoll

epoll模式是对select和poll的改进,它提供了三个函数:

struct eventpoll {
// ...
struct rb_root rbr; // 一颗红黑树,记录要监听的FD
struct list_head rdlist; // 一个链表,记录就绪的FD
// ...
};

//1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);

// 2.将一个FD添加到epoll的红黑树中,并设置ep_pollL_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd; // epoll实例的句柄
int op; //要执行的操作,包括:ADD、MOD、DEL
int fd; //要监听的FD
struct epoll_event *event; //要监听的事件类型:读、写、异常等
};

// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd; // epoll实例的句柄
struct epoll_event *events; //空event数组,用于接收就绪的FD
int maxevents; //events数组的最大长度
int timeout; // 超时时间,-1用不超时,0不阻塞;大于0为阻塞时间
};

过程

  1. 使用epoll_create创建epoll实例
  2. 紧接着调用epoll_ctl操作,将要监听的数据添加到红黑树上去,并且给每个fd设置一个监听函数,这个函数会在fd数据就绪时触发,把fd数据添加到list_head中去,epoll_ctl中的epfd表明要将监听的fd添加到eventepoll中
  3. 使用epoll_wait等待fd就绪,在用户态创建一个空的events数组,当就绪之后,我们的回调函数会把数据添加到list_head中去,当调用这个函数的时候,会去检查list_head,如果在此过程中,检查到了list_head中有数据会将数据添加到链表中,此时将数据放入到events数组中,并且返回对应的操作的数量
  4. 用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据

epoll中的事件通知模式

当FD有数据可读时,我们调用epoll_wait可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。

假设一个客户端socket对应的FD已经注册到了epoll实例中,客户端socket发送了2kb的数据,服务端调用epoll_wait,得到通知说FD就绪,服务端从FD读取了1kb数据

  • 如果我们采用LT模式,因为FD中仍有1kb数据,调用epoll_wait依然会返回结果,并且得到通知
  • 如果我们采用ET模式,因为已经消费了FD可读事件,后续FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时
特性 LT 模式 ET 模式
触发条件 只要条件满足,持续通知。 只有状态变化时通知一次。
通知频率
性能 较低,适合低并发场景。 较高,适合高并发场景。
编程复杂度 简单,无需担心事件丢失。 复杂,需要确保一次性处理完数据。
适用场景 简单的 I/O 操作或初学者。 高并发、高性能的网络服务器。

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

流程:

  1. 用户进程调用sigaction,注册信号处理函数
  2. 内核返回成功,开始监听FD
  3. 用户进程不阻塞等待,可以执行其它业务
  4. 当内核数据就绪后,回调用户进程的SIGIO处理函数
  5. 收到SIGIO回调信号后,用户进程调用recvfrom,读取
  6. 内核将数据拷贝到用户空间
  7. 用户进程处理数据

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

异步IO

这种方式,不仅仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞

他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态

asyncIO

异步IO避免了同步I/O中的等待时间,提高了CPU和I/O设备的利用率,但是这种方式对内核的负载很大,在高并发场景下可能会因为内存占用过多出现崩溃现象。如果要使用必须要做并发访问的限流

Redis网络模型

Redis到底是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那么答案是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较久的任务,例如异步删除命令unlink
  • Redis v6.0 :在核心网络模型中引入多线程,进一步提高对多核cpu的利用率

因此,对于Redis的核心网络模型,在Redis6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能会降低

Redis单线程网络模型执行流程

  1. 监听端口:Redis服务器开始监听指定的端口,等待客户端连接。
  2. 接收客户端连接:当有客户端请求连接到Redis服务器时,服务器会接受连接请求,并创建一个客户端套接字,用于与客户端通信。
  3. 接收命令:一旦客户端与服务器建立连接,客户端可以发送命令请求到服务器。Redis服务器通过套接字接收到客户端发送的命令。
  4. 命令解析:服务器会对接收到的命令进行解析,以确定客户端请求的具体操作。
  5. 执行命令:根据解析的结果,服务器会执行相应的命令操作。由于Redis使用单线程模型,每个命令都会按顺序依次执行,不会并发执行。
  6. 数据读写:在执行命令期间,如果需要读取或修改数据,服务器会从内存中读取数据或将修改后的数据写回内存。
  7. 命令回复:执行完命令后,服务器会将执行结果封装为响应,并通过套接字发送回客户端。
  8. 关闭连接:命令执行完成后,服务器会关闭与客户端的连接,等待下一个连接请求

redis_netModel

多线程改进

由于影响网络模型速率的是IO操作,所以可以在命令请求处理器的请求数据写入部分使用多线程和将数据写入buf或reply部分使用多线程来提高速度

Redis通信协议

Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):

  1. 客户端(client)向服务端(server)发送一条命令
  2. 服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。

而在Redis中采用的是RESP(Redis Serialization Protocol)协议:

  1. Redis 1.2版本引入了RESP协议
  2. Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2
  3. Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性–客户端缓存

但目前,默认使用的依然是RESP2协议。

在RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:

  1. 单行字符串:首字节是 '+' ,后面跟上单行字符串,以CRLF( "\r\n" )结尾。例如返回”OK”: "+OK\r\n"
  2. 错误(Errors):首字节是 '-' ,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"
  3. 数值:首字节是 ':' ,后面跟上数字格式的字符串,以CRLF结尾。例如:":10\r\n"
  4. 多行字符串:首字节是 '$' ,表示二进制安全的字符串,最大支持512MB:
    1. 如果大小为0,则代表空字符串:"$0\r\n\r\n"
    2. 如果大小为-1,则代表不存在:"$-1\r\n"
  5. 数组:首字节是 '*', 后面跟上数组元素个数,再跟上元素,元素数据类型不限,如:

*3\r\n

:10\r\n

$5\r\nhello\r\n

*2\r\n$3\r\nage\r\n:10\r\n