题主是看redis相关书籍碰到了困惑,那就结合redis源码来回答题主这个问题。
redis源码地址:antirez/redis · GitHub
关于I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll/kqueue之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用同一个线程。
以select和tcp socket为例,所谓可读事件,具体的说是指以下事件:
1 socket内核接收缓冲区中的可用字节数大于或等于其低水位SO_RCVLOWAT;
2 socket通信的对方关闭了连接,这个时候在缓冲区里有个文件结束符EOF,此时读操作将返回0;
3 监听socket的backlog队列有已经完成三次握手的连接请求,可以调用accept;
4 socket上有未处理的错误,此时可以用getsockopt来读取和清除该错误。
所谓可写事件,则是指:
1 socket的内核发送缓冲区的可用字节数大于或等于其低水位SO_SNDLOWAIT;
2 socket的写端被关闭,继续写会收到SIGPIPE信号;
3 非阻塞模式下,connect返回之后,发起连接成功或失败;
4 socket上有未处理的错误,此时可以用getsockopt来读取和清除该错误。
Linux环境下,Redis数据库服务器大部分时间以单进程单线程模式运行(执行持久化BGSAVE任务时会开启子进程),网络部分属于Reactor模式,同步非阻塞模型,即非阻塞的socket文件描述符号加上监控这些描述符的I/O多路复用机制(在Linux下可以使用select/poll/epoll)。服务器运行时主要关注两大类型事件:文件事件和时间事件。文件事件指的是socket文件描述符的读写就绪情况,时间事件分为一次性定时器和周期性定时器。相比nginx和haproxy内置的高精度高性能定时器,redis的定时器机制并不那么先进复杂,它只用了一个链表来管理时间事件,而且目前链表也没有对各个事件的到点时间进行排序,也就是说,每次都要遍历链表检查每个事件是否需要到点执行。个人猜想是因为redis目前并没有太多的定时事件需要管理,redis以数据库服务器角色运行时,定时任务回调函数只有位于redis/src/redis.c下的serverCron函数,所有的定时任务都在这个函数下执行,也就是说,链表里面其实目前就一个节点元素,所以目前也无需实现高性能定时器。
Redis网络事件驱动模型代码:redis/src/目录下的ae.c, ae.h, ae_epoll.c, ae_evport.c, ae_select.c, ae_kqueue.c , ae_evport.c。其中ae.c/ae.h:头文件里定义了描述文件事件和事件时间的结构体, 即aeFileEvent和aeTimeEvent;事件驱动状态结构体aeEventLoop, 这个结构体只有一个名为eventloop的全局变量在整个服务器进程中;事件就绪回调函数指针aeFileProc和aeTimeProc;以及操作事件驱动模型的各种API(aeCreateEventLoop以及之后全部的函数声明)。ae_epoll.c, ae_select.c, ae_keque.c和ae_evport.c封装了select/epoll/kqueue等系统调用,Linux下当然不支持kqueue和evport。至于究竟选择哪一种I/O多路复用技术,在ae.c里有预处理控制,也就是说,这些源文件只有一个能最后被编译。优先选择epoll或者kqueue(FREEBSD和Mac OSX可用),其次是select。
redis事件驱动整体流程:redis服务器main函数位于文件redis/src/redis.c, 事件驱动入口函数位于main函数的倒数第三行:aeMain(server.el); /* 实现代码位于ae.c */
/* Process every pending time event, then every pending file event
* (that may be registered by time event callbacks just processed).
* Without special flags the function sleeps until some file event
* fires, or when the next time event occurs (if any).
*
* If flags is 0, the function does nothing and returns.
* if flags has AE_ALL_EVENTS set, all the kind of events are processed.
* if flags has AE_FILE_EVENTS set, file events are processed.
* if flags has AE_TIME_EVENTS set, time events are processed.
* if flags has AE_DONT_WAIT set the function returns ASAP until all
* the events that's possible to process without to wait are processed.
*
* The function returns the number of events processed. */
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
/* Nothing to do? return ASAP */
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
/* Note that we want call select() even if there are no
* file events to process as long as we want to process time
* events, in order to sleep until the next time event is ready
* to fire. */
if (eventLoop->maxfd != -1 ||
((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
int j;
aeTimeEvent *shortest = NULL;
struct timeval tv, *tvp;
if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
shortest = aeSearchNearestTimer(eventLoop);
if (shortest) {
long now_sec, now_ms;
/* Calculate the time missing for the nearest
* timer to fire. */
aeGetTime(&now_sec, &now_ms);
tvp = &tv;
tvp->tv_sec = shortest->when_sec - now_sec;
if (shortest->when_ms < now_ms) {
tvp->tv_usec = ((shortest->when_ms+1000) - now_ms)*1000;
tvp->tv_sec --;
} else {
tvp->tv_usec = (shortest->when_ms - now_ms)*1000;
}
if (tvp->tv_sec < 0) tvp->tv_sec = 0;
if (tvp->tv_usec < 0) tvp->tv_usec = 0;
} else {
/* If we have to check for events but need to return
* ASAP because of AE_DONT_WAIT we need to set the timeout
* to zero */
if (flags & AE_DONT_WAIT) {
tv.tv_sec = tv.tv_usec = 0;
tvp = &tv;
} else {
/* Otherwise we can block */
tvp = NULL; /* wait forever */
}
}
numevents = aeApiPoll(eventLoop, tvp);
for (j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
int mask = eventLoop->fired[j].mask;
int fd = eventLoop->fired[j].fd;
int rfired = 0;
/* note the fe->mask & mask & ... code: maybe an already processed
* event removed an element that fired and we still didn't
* processed, so we check if the event is still valid. */
if (fe->mask & mask & AE_READABLE) {
rfired = 1;
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
}
if (fe->mask & mask & AE_WRITABLE) {
if (!rfired || fe->wfileProc != fe->rfileProc)
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
}
processed++;
}
}
/* Check time events */
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
return processed; /* return the number of processed file/time events */
}
下面举一个例子,模拟一个tcp服务器处理30个客户socket。
假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:
这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
2. 第二种选择:你 创建30个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
3. 第三种选择,你 站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。
这种就是IO复用模型,Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用 非阻塞模式。
这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是 事件驱动,所谓的reactor模式。
首先,要从你常用的IO操作谈起,比如read和write,通常IO操作都是阻塞I/O的,也就是说当你调用read时,如果没有数据收到,那么线程或者进程就会被挂起,直到收到数据。
&lt;img src="https://i-blog.csdnimg.cn/blog_migrate/d440cb98e2e7a4b4776f9ba325b57336.png" data-rawwidth="500" data-rawheight="278" class="origin_image zh-lightbox-thumb" width="500" data-original="https://pic2.zhimg.com/16ef4bcfbd8319535edeb45f597dfc61_r.png"&gt;(图片来源:http://www.masterraghu.com/subjects/np/introduction/unix_network_programming_v1.3/ch06lev1sec2.htmll)
这样,当服务器需要处理1000个连接的的时候,而且只有很少连接忙碌的,那么会需要1000个线程或进程来处理1000个连接,而1000个线程大部分是被阻塞起来的。由于CPU的核数或超线程数一般都不大,比如4,8,16,32,64,128,比如4个核要跑1000个线程,那么每个线程的时间槽非常短,而线程切换非常频繁。这样是有问题的:
- 线程是有内存开销的,1个线程可能需要512K(或2M)存放栈,那么1000个线程就要512M(或2G)内存。
- 线程的切换,或者说上下文切换是有CPU开销的,当大量时间花在上下文切换的时候,分配给真正的操作的CPU就要少很多。
那么,我们就要引入 非阻塞I/O的概念,非阻塞IO很简单,通过fcntl(POSIX)或ioctl(Unix)设为非阻塞模式,这时,当你调用read时,如果有数据收到,就返回数据,如果没有数据收到,就立刻返回一个错误,如EWOULDBLOCK。这样是不会阻塞线程了,但是你还是要不断的轮询来读取或写入。
&lt;img src="https://i-blog.csdnimg.cn/blog_migrate/76ef014bda11acfcb7f0d5708c9c068c.png" data-rawwidth="500" data-rawheight="278" class="origin_image zh-lightbox-thumb" width="500" data-original="https://pic4.zhimg.com/2cb0550b87ca28336d0411e58b45b013_r.png"&gt;
(图片来源:http://www.masterraghu.com/subjects/np/introduction/unix_network_programming_v1.3/ch06lev1sec2.htmll)
于是,我们需要引入 IO多路复用的概念。多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。
&lt;img src="https://i-blog.csdnimg.cn/blog_migrate/5c7bd1a0419f80c68a5b5128faf58cee.png" data-rawwidth="500" data-rawheight="272" class="origin_image zh-lightbox-thumb" width="500" data-original="https://pic3.zhimg.com/9155e2307879cd7ce515e7a997b9d532_r.png"&gt;
(图片来源:http://www.masterraghu.com/subjects/np/introduction/unix_network_programming_v1.3/ch06lev1sec2.htmll)
这样在处理1000个连接时,只需要1个线程监控就绪状态,对就绪的每个连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。
使用select函数的方式如下图所示:&lt;img src="https://i-blog.csdnimg.cn/blog_migrate/e278df225173274fbdacef6cd9ccdbc0.png" data-rawwidth="475" data-rawheight="467" class="origin_image zh-lightbox-thumb" width="475" data-original="https://pic2.zhimg.com/bf52854bd1dc678de998b77aebaa2311_r.png"&gt;(图片来源: (图片来源: IBM Knowledge Center)
I/O Multiplexing 的命名中 multiplexing 来自于通讯领域:在一个信道上传输多路信号或者数据流的技术[1]。
Linux 下的事件驱动的 I/O 编程之所以需要 multiplexing,是因为对 I/O ready 事件的通知是以一个监听集合为单位完成的。大家在用 select, poll, 和 epoll 时都是先创建这么一个集合,然后统一的 wait,一次 wait 可以获知多个 fd 的 I/O 事件。所以 multiplex 的是监听集合,并非 I/O 本身。实际发生操作的 I/O fd 都是可以并发读写互不干扰的,不存在 multiplexing 的问题。
参考链接:
1.https://zh.wikipedia.org/wiki/%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8
连接很多的时候,不能每个连接一个线程,会耗尽系统内存的。线程也不能阻塞在任何一个连接上,等新的数据来,这样就不能及时响应其他连接发来的数据了;也不能用非阻塞方式,轮询所有的连接,这会浪费掉大量CPU时间;只能告诉系统,我对哪些连接感兴趣,有消息来的时候,通知我处理。
根据平台不同,可能会用到select/devpoll/epoll/kqueue/IOCP。要想了解更多,可以先上网查查它们。
可参考IO模型。
就是自己在用户态通过监听、轮询内核给出的低级IO事件来手动维护每一个IO流(和与之对应的上下文)的状态机,这样一个线程可以并发地交替地操纵多路IO流。其实内核本来也有IO状态机和并发机制(Blocking IO API + Threading),但是很多人怕线程多了context switch开销大,所以倾向于自己在用户态做
这个是编程世界里要性能不要可读性的经典案例直白点说:
select/epoll函数就是多路复用器(multiplexing)
ClientSocket s = accept(ServerSocket); // 阻塞
new Thread(s,function(){
while( have data) {
s.read(); // 阻塞
s.send('xxxx');
}
s.close();
});
核心是一个线程处理一个socket。
2) 现在的event-driven/proactor/reactor模式:ClientSocket s = accept(ServerSocket); // 阻塞
waitting_set.add(s);
while(1) {
set_have_data ds = select(waitting_set); // 多路复用器,
// 多路复用是说多个socket数据通路
// 一个select就可以侦听
// 就好像他们是一个数据通路一样。
foreach(ds as item) {
item.read(); // reactor这里不会阻塞,采用异步io,详情查linux aio
item.send('xxx');
}
item.close();
}
可以看到,第一,一个线程就完成了n个socket的读写,第二,多了select语句。
event-driven/proactor/reactor好处:- 避免大量的线程创建工作,每个线程的创建工作都要消耗系统资源,比如每个线程至少要1M内存来维持其存在,32位的机器,不用thread pool,并发4千就挂了。
- 线程多了难免会更多的发生线程切换(context switch),线程切换要保存线程的stack,register,如果是进程切换,要清cpu的L1 cache,MMU的TLB,使得系统不必要的开销过大。
举个例子:你是一个话务员,你同时监听5个电话,所以当有任意一个电话时,你就得去拿起该电话,当然如果电话没有铃声,那么你就得一个个去查看,这个有没有,那个有没有,如果有铃声的话,那你就该喝茶喝茶,该完手机玩手机,有铃声了再去服务。。。。。。。
不知道说的明白不,但是最好还是先去查查资料吧,毕竟在这个答题框里回答的都很有限,而网上资源是无限的!