Redis学习笔记(四)事件与I/O多路复用

214 阅读6分钟

事件与IO多路复用

事件

Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

image.png

文件事件

Redis基于Reactor模式?开发了自己的网络事件处理器,即文件事件处理器:

image.png

文件事件处理器的4个组成部分:

image.png

I/O多路复用

使用缓存(Redis)的用处

(一)高性能

从MySQL查600ms在Redis中可能2ms

(二)高并发

单机并发量在10万左右,但一般不会让单机承受这么高的并发量吧?

(三)高可用No?

这个要看redis在CAP理论中的实践,好像不保证可用性??

Redis是单线程还是多线程

不同的版本,Redis对于单线程和多线程的使用也不同

image.png

在之前的单线程处理中,Redis会遇到的瓶颈就是I/O读写的阻塞,跟不上CPU和内存的处理的速度。 Redis6.0增加的多线程是将主线程的I/O读写任务拆分给一组独立的线程去执行。 每个单个线程都采用I/O多路复用技术,缩减网络I/O的时间。 剩下的命令执行仍然由主线程串行执行。

以下流程图来自segmentfault.com/a/119000004…

image.png

Redis快的原因

Redis快要从它的线程模型、文件实践处理器入手

(1)基于内存操作,所有数据都在内存中。

(2)底层数据结构简单,读写操作的时间复杂度在O(1)或O(lgN)。

(3)I/O多路复用,单个线程可以处理多个连接请求(socket?)。

(4)主线程为单线程,避免上下文切换。

由此也可以看出:内存大小和网络I/O是Redis的瓶颈。

Redis处理并发客户端链接

Redis利用Linux内核函数实现I/O多路复用,下文会提到select、poll、epoll。一个线程可以监视多个链接描述符(文件描述符?),多个连接使用一个阻塞对象(事件队列?)。

image.png

I/O模型

image.png

I/O多路复用模型是I/O模型的一种,在Linux中使用select、poll、epoll实现。

多路复用IO本质上还是需要去轮询文件描述符,当某个文件的数据准备好了之后将其读复制到用户进程缓冲区然后处理。可以用下面的伪代码为例:

while(true){
  for(fd : fds){
     ok,_ := ask(fd)
     if ok{
            copy(fd)
            exec()
     }
  }
}

多路IO复用要做的,是将对文件描述符的轮询(或者监听)的部分交由操作系统来做,然后向用户进程提供API调用,也就是select、poll、epoll这3个函数的调用。这3个函数用于监听指定范围内的文件描述符,并阻塞,直到这些文件描述符对应的socket中有一个或多个的数据准备好了,再返回,由用户进程接手处理

  • select

Linux最初提供的API调用,但性能太差了。这个方法的调用一般如下:

while(1){
        FD_ZERO(&rset);                                                     //将rset所有槽位初始化位0
    for (i = 0; i< 5; i++ ) {
        FD_SET(fds[i],&rset);                                       //将监听的5个文件描述符映射到rset中去
    }
 
    puts("round again");
  
        select(max+1, &rset, NULL, NULL, NULL);     //调用select,阻塞直到有数据进来
 
        for(i=0;i<5;i++) {
            if (FD_ISSET(fds[i], &rset)){                       //判断并处理有数据的文件
                memset(buffer,0,MAXBUF);
                read(fds[i], buffer, MAXBUF);
                puts(buffer);
            }
        }   
}

select函数最重要的参数是前2个:max+1、rset。max是文件描述符数组fds中,最大的那个文件标识符序号,用来告诉select函数应当监听的范围。rset是一个bitmap位图,32位的系统中长度为1024,64位则是2048。

这个API的调用一般是:一,将fds数组的文件描述符映射到rset去,如1、2、5这3个文件描述符对应的socket需要监听,则rset被设值为0010 0110。二,将max + 1 和 rset传入select函数,这里会将用户进程空间的rset拷贝到内核空间去,由os监听0到max+1范围内的文件描述符。三,当有一个或多个文件的数据准备好,如1、2号文件描述符对应的文件,那么rset会被置位为 0000 0110(第5位没数据被抹为0)。

当select函数 返回后,下面将有个循环,判断fds的每个元素映射到rset的那一位的位置是否位1。然后拷贝对应的数据到该进程的缓冲区,处理。

优点:由os一次监听多个文件描述符,而非每个文件描述符都切换到内核态问询一次

缺点:一,rset不可重用,while(1)循环中需要不停初始化。而且rset的大小收限制

二,用户态切换到内核态,需拷贝rset结构体,有一定的开销

三,select返回后仍需要O(n)的循环

  • poll

    和select基本一样,只是采用了新的结构体pollfd代替了rset,解除了大小限制和重用性的问题。

      //pollfd的结构体为:
      struct pollfd{
        int fd;
        short events;
        short revents;      
      }  
        for (i=0;i<5;i++) 
      {
        memset(&client, 0, sizeof (client));
        addrlen = sizeof(client);
        pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
        pollfds[i].events = POLLIN;
      }
    ​
      while(1){
        puts("round again");
        
            poll(pollfds, 5, 50000);
    ​
            for(i=0;i<5;i++) {
                if (pollfds[i].revents & POLLIN){
                    pollfds[i].revents = 0;
                    memset(buffer,0,MAXBUF);
                    read(pollfds[i].fd, buffer, MAXBUF);
                    puts(buffer);
                }
            }
      }
    

    以读数据为例,当os监听到某个文件的数据已经写入到内核缓冲区后,就会把pollfd.revent = 1,返回唤醒用户进程。然后用户进程在读取、处理数据之后,会把这一位重新设置为0。对于pollfds数组,没有硬性的大小限制,在while(1){}中也不用反复初始化。

    当然,那个性能还是挺一般的。

  • epoll

    epoll又采用了新的数据结构epfd去代替数组pollfds,同时使用了mmap内存映射技术使得用户进程和内核进程共享一块内存空间

      struct epoll_event events[5];
      int epfd = epoll_create(10);
      ...
      ...
      for (i=0;i<5;i++) 
      {
        static struct epoll_event ev;
        memset(&client, 0, sizeof (client));
        addrlen = sizeof(client);
        ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
        ev.events = EPOLLIN;
        epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
      }
      
      while(1){
        puts("round again");
        nfds = epoll_wait(epfd, events, 5, 10000);
        
            for(i=0;i<nfds;i++) {
                    memset(buffer,0,MAXBUF);
                    read(events[i].data.fd, buffer, MAXBUF);
                    puts(buffer);
            }
      }
    

    在epoll函数执行过程中,当有数据写入到内核缓冲区,epoll函数不仅会把对应的fd置位,还会将其插入到epfd结构体的头部(可以把epfd理解为一个带有链表的结构体,然后头插法)。当epoll函数返回时,用户进程就只需要O(m)的循环了(假设有m个文件描述符对应的文件数据写入)

    epoll的优点相当于优化了第二点:用户态和内核态切换时不需要拷贝数据的开销;第三点:函数返回后,处理的时间复杂度由O(n)降低为O(m)。

4,Redis的多线程IO

Redis是使用单线程的,但在Redis6.0之后,在指令执行阶段依旧采用单线程,但在IO部分引入了多线程。之前版本的Redis,对于网络IO的处理、k-v的读写采用的是单线程的模式。至于6.0之后这个变动的细节,暂不清楚。

部署在Linux系统上的Java服务,其NIO也是使用上面epoll函数去实现