Redis 的单线程模型一直被人津津乐道,结果到了前段时间的 6.0 版本发布,大家开始说 Redis 变为多线程了,但是还是有人说 Redis 仍然是单线程的,为什么会产生这两种分歧呢?

Redis 仍然是 Client-Server 的架构,所有的操作都需要根据客户端的请求去执行,一般情况下,网络编程中的多线程就是每一个请求,都创建一个线程去处理,可能这里会有线程池来进行复用。

ae-Page-3.png
图一:multiple thread

这样的一个模型可以很好的利用网络,对于每一个请求都可以及时的去处理,这也带来了一些新的问题,就是内存的并发访问,不能让多个线程同时操作同一块内存,解决这个问题的方法有很多,但是降低运行效率是不可避免的,而且还会给系统带来很高的复杂度,所以最开始的 Redis 选择了单线程 + IO 多路复用的模式。

ae-Page-4.png
图二:multiplexing

Epoll

对于 Linux 来说这里的 IO 多路复用机制就是 epoll 了,每个平台有不同的多路复用机制,windows 的叫做 kqueue,redis 为了屏蔽他们底层的不同抽象出了 EventLoop,将各自实现的不同的数据存储到 aeEventState 字段里,这里最重要的是两个数组,一个是 events 存储着所有的 fd,另一个是fired,这里是所有被触发的 fd,表示里面有数据需要读取或者写入。

ae-epoll.png
图三:epoll overview

对于每一个 fired 中的数据,通过 fd 找到其对应的 aeFileEvent,然后调用其对应的处理函数。处理完所有的 firedEvent 之后,就可以再次监听新的事件。这样 redis 就实现了单线程服务多个客户端了。

ae-Page-2.png
图四:proc

  1. int aeProcessEvents(aeEventLoop *eventLoop, int flags)
  2. {
  3. // ...
  4. numevents = aeApiPoll(eventLoop, tvp);
  5. // ...
  6. for (j = 0; j < numevents; j++) {
  7. aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
  8. int mask = eventLoop->fired[j].mask;
  9. int fd = eventLoop->fired[j].fd;
  10. if (!invert && fe->mask & mask & AE_READABLE) {
  11. fe->rfileProc(eventLoop,fd,fe->clientData,mask);
  12. fired++;
  13. fe = &eventLoop->events[fd]; /* Refresh in case of resize. */
  14. }
  15. if (fe->mask & mask & AE_WRITABLE) {
  16. if (!fired || fe->wfileProc != fe->rfileProc) {
  17. fe->wfileProc(eventLoop,fd,fe->clientData,mask);
  18. fired++;
  19. }
  20. }
  21. // ...
  22. }
  23. // ...
  24. return processed;
  25. }

Multithreading

Redis 多线程也并不是说处理线程变成多线程了,Redis 现在遇到的问题并不是计算速度不够,而是网络 I/O 的速度不够。

那这些线程是每次读取的时候就启动一个,然后读取完成之后就销毁了吗?这样做肯定浪费了很多资源,征程情况下,我们应该有一个线程池进行线程的复用。Redis 在启动时会先创建多个线程,然后等待各自的信号,一旦接收到信号就去各自的队列中拿取 Client 进行处理。

这里的 io_threads_list, io_threads_list, io_threads 全部都是全局变量,专门用来存储多线程读取。Server 会先将需要读取的放到 client_pending_read 中,然后在需要读取的时候,将其分配到不同的队列中,等待不同的线程进行读取,对于第一个队列,是主线程负责读取的。读取完成后会持续查看其他的 io_threads_pending,等到它们全为 0 的时候,就是读取完成了。

redis-server-thread_overview.png
图五:multithreading overview

另外对于 IO 来说,是有两种情况的,读还有写,所以需要一个标志位来查看现在到底应该做什么,这个标志位就是 io_threads_op ,它是一个全局变量,每次执行是都会被检查。
ae-threads read.png
图六:multithreading read

对于每一个线程运行的都是下面的函数,就是一直查看自己的 io_threads_pending 有没有值,一旦有值就开始进行处理。

  1. void *IOThreadMain(void *myid) {
  2. while(1) {
  3. for (int j = 0; j < 1000000; j++) {
  4. if (getIOPendingCount(id) != 0) break;
  5. }
  6. // ...
  7. listIter li;
  8. listNode *ln;
  9. listRewind(io_threads_list[id],&li);
  10. while((ln = listNext(&li))) {
  11. client *c = listNodeValue(ln);
  12. if (io_threads_op == IO_THREADS_OP_WRITE) {
  13. writeToClient(c,0);
  14. } else if (io_threads_op == IO_THREADS_OP_READ) {
  15. readQueryFromClient(c->conn);
  16. } else {
  17. serverPanic("io_threads_op value is unknown");
  18. }
  19. }
  20. listEmpty(io_threads_list[id]);
  21. setIOPendingCount(id, 0);
  22. }
  23. }

这里使用循环调用 getIOPendingCount() 是不是不太好?能不能使用信号量进行代替?

Open Multithreading

默认情况下 redis 并不会打开多线程 io,redis 打开多线程 io 必须在配置文件中修改 io_threads 。对于多线程读和多线程写还有一点区别,多线程读必须在配置里打开 io_threads_do_reads,而多线程写还需要 pending 的线程数大于 2 * io_threads

  1. io-threads 4
  2. io-threads-do-reads yes

redis 官方建议如果有 4 个内核,就将 io-threads 设置为 2 或 3,如果有 8 个内核就设置为 6,至少留下一个备用内核,大于 8 个内核的话,性能提升也不会特别多。