关于redis:深入理解redisRedis快的原因和IO多路复用深度解析

1次阅读

共计 7474 个字符,预计需要花费 19 分钟才能阅读完成。

1.Redis 是单线程的还是多线程的?

2.Redis 性能很快的起因

3.Redis 的瓶颈在哪里

4.I/ O 多路复用模型实践

5.I/ O 多路复用模型 JAVA 验证

6.Redis 如何解决并发客户端链接

7.Linux 内核函数 select, poll, epoll 解析

8. 总结

1.Redis 是单线程的还是多线程的?

当咱们在学习和应用 redis 的时候,大家口耳相传的,说的都是 redis 是单线程的 。其实这种说法 并不谨严 ,Redis 的版本有十分多,3.x、4.x、6.x,版本不同架构也是不同的, 不限定版本问是否单线程 不太谨严。
1)版本 3.x,最早版本,也就是大家口口相传的 redis 是 单线程。
2)版本 4.x,严格意义上来说也不是单线程,而是负责客户端解决申请的线程是单线程,然而开始加了点多线程的货色(异步删除
3)最新版本的 6.0.x 后,辞别了刻板印象中的单线程, 而采纳了一种全新的多线程来解决问题。

所以咱们能够得出,Redis 6.0 版本当前,对于整个 redis 来说,是多线程的。

Redis 是单线程到多线程的演变:

在以前,Redis 的网络 IO 和键值对的读写 都是由一个线程来实现的 ,Redis 的申请时包含获 取(Socket 读),解析,执行,内容返回(socket 写)等都是由一个程序串行的主线程解决 ,这就是所谓的 单线程

但此时,Redis 的 瓶颈就呈现了
I/ O 的读写自身是阻塞的,比方当 socket 中有数据的时候,Redis 会通过调用先将数据 从内核态空间拷贝到用户态空间 ,再交给 Redis 调用, 而这个拷贝的过程就是阻塞的 ,当 数据量越大时拷贝所须要的工夫就越多 ,而这些操作都是基于 单线程 实现的。

在 Redis6.0 中新减少了多线程的性能来 进步 I / O 读写性能 ,他的次要实现思路是将 主线程的 IO 读写工作拆分给一组独立的线程去执行 ,这样就能够使多个 socket 的读写并行化了, 采纳 I / O 多路复用技术能够让单个线程高效解决多个连贯申请(尽量减少网络 IO 的工夫耗费),将最耗时的 socket 读取,申请解析,写入独自外包进来,剩下的命令执行任然是由主线程串行执行和内存的数据交互。

联合上图可知,网络 IO 操作就变成多线程化了,其余外围局部依然是线程平安的,是个不错的折中方法。

流程图:

流程简述:
1)主线程负责接管建设链接申请,获取 socket 放入全局期待读取队列
2)主线程解决完读事件后, 将这些连贯调配给这些 IO 线程
3) 主线程阻塞期待 IO 线程读取 socket 结束
4)IO 线程组收集各个 socket 输出进来的命令,收集结束
5)主线解除阻塞,程执行命令, 执行结束后输入后果数据
6)主线程阻塞期待 IO 线程将数据回写 socket 结束
7)解除绑定,清空期待队列

2.Redis 性能很快的起因

1)基于内存操作 ,Redis 的所有数据都存在内存中,因而所有的运算都是内存级别的,所以他的性能比拟高。
2) 数据结构简略 :Redis 的数据结构都是专门设计的,而这些简略的数据结构的查找和操作的工夫大部分复杂度都是 O(1),因而性能比拟强,能够参考我之前写过的这篇文章。深刻了解 redis——redis 经典五种数据类型及底层实现
3) 多路复用和非阻塞 I /O,Redis 应用 I / O 多路复用来监听多个 socket 链接客户端,这样就能够应用一个线程链接来解决多个申请,缩小线程切换带来的开销,同时也防止了 I / O 阻塞操作。
4)主线程为单线程,防止上下文切换,因为是单线程模型,因而防止了不必要的上下文切换和多线程竞争(比方锁),这就省去了多线程切换带来的工夫和性能上的耗费,而且单线程不会导致死锁问题的产生。

3.Redis 的瓶颈在哪里?

说了这么多 redis 的长处,redis 的性能瓶颈到底在哪里

从 cpu 上看:
1)redis 是基于内存的,因而缩小了 cpu 将数据从磁盘复制到内存的工夫
2)redis 是单线程的,因而缩小了多线程切换和复原上下文的工夫
3)redis 是单线程的,因而多核 cpu 和单核 cpu 对于 redis 来说没有太大影响,单个线程的执行应用一个 cpu 即可

综上所述,redis 并没有太多受到 cpu 的限度,所以 cpu 大概率不会成为 redis 的瓶颈。

内存大小和网络 IO 才有可能是 redis 的瓶颈。

所以 Redis应用了 I / O 多路复用模型,来优化 redis。

4.I/ O 多路复用模型实践
在学习 IO 多路复用之前,咱们先明确上面的几个词语的概念:

1)同步:调用者要始终期待调用后果的告诉后,能力继续执行,相似于串行执行程序,执行结束后返回。

2)异步:指被调用者先返回应答让调用者先回去,而后计算调用后果,计算实现后,再以回调的形式告诉给调用方。

同步,异步的侧重点 ,在于 被调用者 ,重点在于取得 调用后果的告诉形式上。

3)阻塞:调用方始终在等,且其它别的事件都不做,以后线程会被挂起,啥都不干

4)非阻塞:调用在收回去之后,调用方先去干别的事件,不会阻塞以后线程,而会立即返回。

阻塞,非阻塞的侧重点 ,在于 调用者在期待音讯的时候的行为 调用者是否干其它的事。

以上的 IO 能够组合成 4 种组合形式:同步阻塞,同步非阻塞,异步阻塞,异步非阻塞

同时也就衍生出了 unix 网络编程中的五种模型:

接下来咱们用代码进行验证

5.I/ O 多路复用模型 JAVA 验证

BIO(阻塞 IO):

咱们来看这样一段代码:

假如咱们当初有一个服务器,监听 6379 端口,让他人来链接,咱们用这样的形式来写代码:

public class RedisServer
{public static void main(String[] args) throws IOException
    {byte[] bytes = new byte[1024];
        // 监听 6379 端口
        ServerSocket serverSocket = new ServerSocket(6379);
        while(true)
        {System.out.println("模仿 RedisServer 启动 -----111 期待连贯");
            Socket socket = serverSocket.accept();
            System.out.println("-----222 胜利连贯");
            System.out.println();}
    }
}

客户端:

public class RedisClient01
{public static void main(String[] args) throws IOException
    {System.out.println("------RedisClient01 start");
        Socket socket = new Socket("127.0.0.1", 6379);

    }
}

下面这个模型存在很大的问题,如果客户端与服务端建设了链接,如果下面这个链接的客户端迟迟不发数据,线程就会始终阻塞在 read()办法上 ,这样其它客户端也不能进行链接, 也就是一个服务器只能解决一个客户端,对客户很不敌对,咱们须要降级:

利用多线程:
只有连贯了一个 socket,操作系统就会调配一个线程来解决 ,这样 read() 办法堵在每个具体线程上而不阻塞主线程。

public class RedisServerBIOMultiThread
{public static void main(String[] args) throws IOException
    {ServerSocket serverSocket = new ServerSocket(6379);

        while(true)
        {Socket socket = serverSocket.accept();// 阻塞 1 , 期待客户端连贯
            // 每次都开一个线程
            new Thread(() -> {
                try {InputStream inputStream = socket.getInputStream();
                    int length = -1;
                    byte[] bytes = new byte[1024];
                    System.out.println("-----333 期待读取");
                    while((length = inputStream.read(bytes)) != -1)// 阻塞 2 , 期待客户端发送数据
                    {System.out.println("-----444 胜利读取"+new String(bytes,0,length));
                        System.out.println("====================");
                        System.out.println();}
                    inputStream.close();
                    socket.close();} catch (IOException e) {e.printStackTrace();
                }
            },Thread.currentThread().getName()).start();

            System.out.println(Thread.currentThread().getName());

        }
    }
}

存在的问题:
每来一个客户端,就要开拓一个线程,在操作系统中用户态不能间接开拓线程,须要调用内核来创立一个线程,其中还波及到用户态的切换(上下文的切换),非常耗费资源。

解决办法:
1)应用线程池,在链接少的状况下能够应用,然而在用户量大的状况下,你不晓得线程池要多大,太大了内存可能不够,也不可行。
2)NIO 形式,这就引出了咱们的 NIO

NIO(非阻塞 IO):
在 NIO 模式中,一切都是 非阻塞 的:
accept()办法是非阻塞的,如果没有客户端连贯,就返回 error
read()办法是非阻塞的,如果 read()办法读取不到数据就返回 error,如果读取到数据时只阻塞 read()办法读数据的工夫

在 NIO 模式中,只有一个过程:
当一个客户端与服务器进行链接,这个 socket 就会退出到一个数组中,隔一段时间遍历一次,这样一个线程就能解决多个客户端的链接和数据了。

public class RedisServerNIO
{
    // 将所有 socket 退出到这个数组中
    static ArrayList<SocketChannel> socketList = new ArrayList<>();
    static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    public static void main(String[] args) throws IOException
    {System.out.println("---------RedisServerNIO 启动期待中......");
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
        serverSocket.configureBlocking(false);// 设置为非阻塞模式

        while (true)
        {
            // 遍历所有数组
            for (SocketChannel element : socketList)
            {int read = element.read(byteBuffer);
                if(read > 0)
                {System.out.println("----- 读取数据:"+read);
                    byteBuffer.flip();
                    byte[] bytes = new byte[read];
                    byteBuffer.get(bytes);
                    System.out.println(new String(bytes));
                    byteBuffer.clear();}
            }

            SocketChannel socketChannel = serverSocket.accept();
            if(socketChannel != null)
            {System.out.println("----- 胜利连贯:");
                socketChannel.configureBlocking(false);// 设置为非阻塞模式
                socketList.add(socketChannel);
                System.out.println("-----socketList size:"+socketList.size());
            }
        }
    }
}

NIO 胜利解决了 BIO 须要开启多线程的问题,NIO 中一个线程就能解决多个 socket,但还是存在两个问题:
问题一:对 cpu 不敌对
如果客户端十分多的话,每次遍历都要十分多的 socket,很多都是有效遍历

问题二:线程上下文切换
这个遍历是在用户态进行的,用户态判断 socket 是否有数据还是调用内核的 read()办法实现的,这就会波及到用户态和内核态的切换,每遍历一个就要切换一次,开销十分大。

长处 不会阻塞在内核的期待数据过程,每次发动的 IO 申请能够立即返回,不必阻塞期待,实时性好。

毛病 会一直询问内核,占用大量 cpu 工夫,资源利用率低。

改良:让 linux 内核搞定上述需要 ,咱们将一批文件描述符通过一次零碎调用传给内核,由内核层去遍历,能力真正解决这个问题,IO 多路复用应运而生 就是将上述工作间接放进 Linux 内核,不再两态转换。

6.Redis 如何解决并发客户端链接

咱们先来看看redis 是如何解决这么多客户端连贯的

Redis 利用 linux 内核函数(下文会解说)来实现 IO 多路复用,将 连贯信息和事件放到队列中,队列再放到事件派发器,事件派发器把事件分给事件处理器。

因为 Redis主线程是跑在单线程外面的 ,所有操作都得按程序执行,然而因为 读写操作期待用户的数据输入都是阻塞的,IO 在个别的状况下不能间接返回 ,这回 导致某一文件的 IO 阻塞导致整个过程无奈对其它客户端提供服务,而IO 多路复用就是解决这个问题的

所谓的 I / O 多路复用,就是通过一种机制,让一个线程能够监督多个链接描述符 ,一旦某个描述符 就绪 (个别都是 读就绪 或者 写就绪 ),就可能告诉程序进行相应的读写操作。这种机制的应用须要 select、poll、epoll 来配合。 多个连贯共用一个阻塞对象,应用程序只须要在一个阻塞对象上期待,无需阻塞期待所有连贯。当某条连贯有新的数据能够解决时,操作系统告诉应用程序,线程从阻塞状态返回,开始进行业务解决。

它的概念是:多个 Socket 链接复用一根网线,这个性能是在内核 + 驱动层实现的。

IO 多路复用,也叫 I /O multiplexing,这外面的 multiplexing 指的其实是在 单个线程通过记录跟踪每一个 sock(I/ O 流)的状态来 同时治理多个 IO 流,目标是尽量多的进步服务器的吞吐能力。

7.Linux 内核函数 select, poll, epoll 解析

select, poll, epoll 都是 I / O 多路复用的具体的实现。

select 办法:

Linux 官网源码:

select 函数监督的文件描述符分 3 类,别离是 readfds、writefds 和 exceptfds,将用户传入的数组拷贝到内核空间,调用 select 函数 后会进行阻塞 ,直到有描述符就绪(有数据 可读,可写或者有 except)或者超时(timeout 指定等待时间,如果立刻返回设为 null 即可), 函数返回

当 select 函数返回后,能够通过遍历 fdset,来找到就绪的描述符。

从代码中能够看出,select 零碎调用后,返回了一个地位后的 &ret,这样用户态只须要很简略地进行二进制比拟,就能很快晓得哪些 socket 须要 read 数据,无效进步了效率。

长处:
select 其实就是把 NIO 中 用户态要遍历的 fd 数组 (咱们的每一个 socket 链接,装置进 ArrayList 外面的那个) 拷贝到了内核态 ,让内核态来遍历,因为 用户态判断 socket 是否有数据还是要调用内核态的 ,所有拷贝到内核态后,这样遍历判断的时候就 不必始终用用户态和内核态频繁切换了。

毛病:
1)bitmap 最大 1024 位 ,一个过程最多 只能解决 1024 个客户端
2)&rset 不可重用,每次 socket 有数据就相应的位 会被置位
3) 文件描述符数组拷贝到了内核态,任然有开销 。select 调用须要传入 fd 数组,须要拷贝一份到内核, 高并发场景下这样的拷贝耗费资源是惊人的。(可优化为不复制)
4)select 并没有告诉用户态哪一个 socket 用户有数据,仍须要 O(n)的遍历。select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户本人遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做有效的遍历)

咱们本人模仿写的是,RedisServerNIO.java, 只不过将它内核化了。

poll 办法:

看一下 linux 内核源码

长处:

1)poll 应用 pollfd 数组来代替 select 中的 bitmap,数组中没有 1024 的限度,去掉了 select 只能监听 1024 个文件描述符的限度。

2)当 pollfds 数组中有事件产生,相应 revents 置置为 1,遍历的时候又置位回零,实现了 pollfd 数组的重用

毛病:
1、pollfds 数组拷贝到了内核态,依然有开销
2、poll 并没有告诉用户态哪一个 socket 有数据, 依然须要 O(n)的遍历

epoll 办法:

epoll 操作过程须要三个接口:

epoll_create:创立一个 epoll 句柄

epoll_ctl:向内核增加、批改或删除要监控的文件描述符

epoll_wait:相似发动了 select() 调用

epoll 是只轮询那些真正收回了事件的流 ,并且只顺次程序的解决就绪的流,这种做法就 防止了大量的无用操作。采纳多路 I/O 复用技术能够让单个线程高效的解决多个连贯申请(尽量减少网络 IO 的工夫耗费),且 Redis 在内存中操作数据的速度十分快,也就是说内存内的操作不会成为影响 Redis 性能的瓶颈

为什么 redis 肯定要部署在 Linux 机器上能力施展出该性能?
因为只有 linux 有 epoll 函数,其它零碎会主动降级成 select 函数。

三个办法比照:

select poll epoll
操作形式 遍历 遍历 回调
数据结构 bitmap 数组 红黑树
最大连接数 1024(x86)或者 2048(x64) 无下限 无下限
最大反对文件描述符数 有最大值限度 65535 65535
fd 拷贝 每次调用,都须要把 fd 联合从用户态拷贝到内核态 每次调用,都须要把 fd 联合从用户态拷贝到内核态 只有首次调用的时候拷贝
工作效率 每次都要遍历所有文件描述符,工夫复杂度 O(n) 每次都要遍历所有文件描述符,工夫复杂度 O(n) 每次只用遍历须要遍历的文件描述符,工夫复杂度 O(1)

8. 总结

Redis 快的一部分起因在于多路复用,多路复用快的起因是零碎提供了这样的调用,使得原来的 while 循环里的屡次零碎调用变成了,一次零碎调用 + 内核层遍历这些文件描述符。

正文完
 0