事件循环相信大家都不陌生,很多同学都知道事件循环是一个"死循环",今天我们看一下这个死循环到底是怎样的。我们先看一个朴素版的事件循环系统。
class EventSystem {
constructor() {
// 任务队列
this.queue = [];
}
// 追加任务
enQueue(func) {
this.queue.push(func);
}
// 事件循环
run() {
while(1) {
while(this.queue.length) {
const func = this.queue.shift();
func();
}
}
}
}
// 新建一个事件循环系统
const eventSystem = new EventSystem();
// 生产任务
eventSystem.enQueue(() => {
console.log('hi');
});
// 启动事件循环
eventSystem.run();
以上代码实现了一个非常朴素的事件循环系统
1 新建一个事件循环系统
2 生产任务
3 启动事件循环系统
但是我们发现当没有任务的时候,事件循环系统陷入了死循环,这无疑浪费了cpu。我们看一下执行以上代码的cpu的情况(我电脑4核,可以看到以上代码对应的进程几乎完全占据了一个cpu,1/4)。
接着我们优化一下这个朴素版的事件循环。
class EventSystem {
constructor() {
// 任务队列
this.queue = [];
// 是否需要停止任务队列
this.stop = 0;
// 超时处理
this.timeoutResolve = null;
}
// 没有任务时,事件循环的睡眠时间
sleep(time) {
return new Promise((resolve) => {
let timer = null;
// 记录resolve,可能在睡眠期间有任务到来,则需要提前唤醒
this.timeoutResolve = () => {
clearTimeout(timer);
timer = null;
this.timeoutResolve = null;
resolve();
};
timer = setTimeout(() => {
if (timer) {
console.log('timeout');
this.timeoutResolve = null;
resolve();
}
}, time);
});
}
// 停止事件循环
setStop() {
this.stop = 1;
this.timeoutResolve && this.timeoutResolve();
}
// 追加任务
enQueue(func) {
this.queue.push(func);
this.timeoutResolve && this.timeoutResolve();
}
// 事件循环
async run() {
while(1 && this.stop === 0) {
while(this.queue.length) {
const func = this.queue.shift();
func();
}
// 没有任务了,一直等待(Math.pow(2, 31) - 1为nodejs中定时器的最大值)
await this.sleep(Math.pow(2, 31) - 1);
}
}
}
// 新建一个事件循环系统
const eventSystem = new EventSystem();
// 生产任务
eventSystem.enQueue(() => {
console.log('hi');
});
// 模拟定时生成一个任务
setTimeout(() => {
eventSystem.enQueue(() => {
console.log('hello');
});
}, 1000);
// 模拟退出事件循环
setTimeout(() => {
eventSystem.setStop();
}, 2000);
// 启动事件循环
eventSystem.run();
上面代码的执行结果如下
1 启动事件循环时输出hi。
2 事件循环进入睡眠,1s时被唤醒,输出hello。
3 2s后退出事件循环。
麻雀虽小五脏俱全,以上代码虽然只是个demo,但是已经具备了事件循环的一些核心概念。
1 事件循环的整体架构是一个while循环
2 定义任务类型和队列,这里只有一种任务类型和一个队列,比如nodejs里有好几种。
3 没有任务的时候怎么处理?进入睡眠,而不是真的是一个死循环。
其中第3点是事件循环系统中非常重要的逻辑。因为事件循环是属于生产者、消费者模式。任务队列中不可能一直都有任务需要处理,这就意味着生产任务可以是一个异步的过程。所以事件循环系统就需要有一种等待的机制。这就会带来两个问题,什么情况下需要等待,什么时候需要退出。这个和具体的业务场景有关,本文实现的事件循环中,没有任务的时候就会一直等待,而不是退出。除非用户手动执行setStop退出。而nodejs中,如果没有actived状态的handle和request并且close阶段没有任务时就会自动退出。另外一个问题就是如何实现等待。这里使用的是setTimeout来模拟睡眠,从而达到等待的效果。但是这时候进程是没有被挂起的,这意味着,我们还可以做其他事情。而在nodejs中,会在poll io阶段,进程会被挂起。我们看看nodejs事件循环的实现。
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// 这里会导致事件循环系统所在进程挂起
uv__io_poll(loop, timeout);
uv__run_check(loop);
uv__run_closing_handles(loop);
r = uv__loop_alive(loop);
}
nodejs的事件循环也是一个while循环,然后在里面执行各个阶段的任务,其中uv__io_poll对应的poll io阶段可能会导致进程挂起。我们看一下uv__io_poll关于等待的逻辑。
nfds = epoll_wait(loop->backend_fd,
events,
ARRAY_SIZE(events),
timeout);
epoll_wait会根据timeout的值决定如果没有就绪事件时,是否需要挂起进程。timeout大于0说明是定时器挂起,timeout等于-1说明是永远挂起。直到有就绪队列。这就是nodejs中关于等待的处理逻辑。这和我们自己实现的事件循环系统是类似的,只不过我们是自己唤醒自己,而nodejs中是被操作系统唤醒,因为我们在js层面无法调用操作系统的系统调用挂起进程。epoll是和文件描述符相关的,如果我们不涉及到文件、网络操作,那么我们又如何实现等待呢?我们从libuv的线程池实现中,找到了另一种实现。libuv的线程池中有多个线程,他们共享一个任务队列,每个子线程里不断从共享的任务队列中获取任务处理(需要加锁)。所以这也是一个事件循环的模型。那么当没有任务可处理的时候,libuv是如何实现等待的呢?
static void worker(void* arg) {
struct uv__work* w;
QUEUE* q;
int is_slow_work;
uv_sem_post((uv_sem_t*) arg);
arg = NULL;
uv_mutex_lock(&mutex);
// 事件循环
for (;;) {
// 没有任务或者某类任务达到阈值
while (QUEUE_EMPTY(&wq) ||
(QUEUE_HEAD(&wq) == &run_slow_work_message &&
QUEUE_NEXT(&run_slow_work_message) == &wq &&
slow_io_work_running >= slow_work_thread_threshold())) {
// 空闲线程数加一
idle_threads += 1;
// 挂起线程,等待唤醒
uv_cond_wait(&cond, &mutex);
idle_threads -= 1;
}
}
}
我们看到libuv使用线程库提供的api实现了线程的挂起。从而实现了等待的逻辑。接下来我们看一下唤醒的逻辑。
static void post(QUEUE* q, enum uv__work_kind kind) {
uv_mutex_lock(&mutex);
QUEUE_INSERT_TAIL(&wq, q);
if (idle_threads > 0)
uv_cond_signal(&cond);
uv_mutex_unlock(&mutex);
}
libuv每次提交新的任务到共享队列时,都会判断是否有空闲线程,如果有则唤醒他。
本文介绍了事件循环的设计和实现中涉及到的一些知识,我们看到事件循环的整体架构是类似的,但是具体实现有很多种方式,这取决于你的业务场景。同时,任务的生产是异步的,所以没有任务的时候的等待机制的设计也就变得很重要,我们不能不断地浪费cpu进行轮询,而是要借助一直挂起,唤醒的机制来实现。