
序
这篇博客起源于以下面试题,由此开始查资料回顾 setTimeout 到底是如何运行的。希望你可以通过这篇文章了解 setTimeout 运行机制、Event Loop 的概念以及浏览器(Chromium)对 Event Loop 的实现。本篇没有讲到的宏任务和微任务,下次一定。
请修改下面的代码, 使其可以输出0-9,至少写出三种不同的解法
for (var i = 0; i< 10; i++){
setTimeout(() => {
console.log(i);
}, 1000)
}
以下是Chromium 源码地址
Event Loop 是什么?
在维基百科中的定义是 Event Loop是一个程序结构,用于等待和发送消息和事件。
Javascript 是单线程的,即同一时间只能做一件事,这种方式一方面避免了多线程之间 DOM 操作的冲突,另一方面也导致了阻塞的可能性。
如图1,如果线程中有一个任务耗时过长就会导致整个线程的阻塞,大大影响用户体验。
而 Event Loop 就是为了解决单线程阻塞的问题,让整个线程能够高效得运行

在上图中,主线程执行时会产生 堆(heap)与栈(stack),栈中的任务执行是同步的,它们会调用 DOM 操作,ajax 请求,定时器 setTimeout,而这这些请求都会被添加到 消息队列(callback queue)。
只有当栈中的任务全部被执行完毕后,主线程才会去执行消息队列中的任务。
举个简单的例子
console.log(1);
setTimeout(()=>{console.log(2)}, 1000)
console.log(3)
以上的代码与下面的代码结果一致。
setTimeout(()=>{console.log(2)}, 1000)
console.log(1);
console.log(3)
setTimeout 是如何运行的?
setTimeout 的 使用请看 菜鸟教程,需要注意的是,它的第 3 个及后续的参数可以传递给第一个参数(函数),可以用来实现闭包。
setTimeout是一个定时器,可以指定某个函数在多少秒后执行,相比较一般的异步函数添加到消息队列中等待被主线程调用,为了在指定时间被执行,setTimeout 中的函数被添加到了延迟执行队列中。
延迟执行队列在 Chromium 的定义如下
DelayedIncomingQueue delayed_incoming_queue;
当通过 JavaScript调用 setTimeout 设置回调函数的时候,渲染进程会创建一个回调任务,
包含了回调函数、当前发起时间、延迟执行时间,然后将该任务添加到延迟队列中。
关于延迟执行队列的实现放在了最后。
struct DelayTask{
int64 id;
CallBackFunction fn;
int start_time;
int delay_time;
};
DelayTask Task;
Task.fn = sayHello;
Task.start_time = getCurrentTime(); // 获取当前时间
Task.delay_time = 1000;// 设置延迟执行时间
下面就让我们一起来看一下浏览器是如何实现 Event Loop 的吧!
事件循环
任务并不是在线程运行之前就全部安排好的,为了安排在运行过程中加入的任务,需要采取任务循环的机制。

如图3 在进程启动时,便会创建一个类似于 while(true)的循环,每执行一次循环体的过程称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件以及相关的回调函数并执行。
// 示例代码
// GetInput
// 等待用户从键盘输入 1 个数字,并返回。
int GetInput() {
int intput_number = 0;
cout <<"请入 1 个数字";
cin>>input_number;
return input_number
}
// 主线程 (Main Thread)
void MainThread(){
while(true){
int num = GetInput();
print(" 输入数字为:%d",num );
}
}
消息队列
有了事件循环,就可以接受新的任务了,不过此时的任务都来自线程内部,如果此时有外部的 IO 线程,比如鼠标点击事件,ajax 请求该如何处理呢?
通用的模式是消息队列。

如图4,消息队列是一个先进先出的队列数据结构,添加任务需要在队列的尾部,然后在头部取出,有了消息队列后我们的Event Loop 就比较完整了。
如图5,此时我们添加了一个消息队列,IO 线程中产生的新任务会添加到消息队列的尾部,主线程会循环得从消息队列头部中读取任务并执行

// 首先,构造一个队列。
class TaskQueue{
public:
Task takeTask(); // 取出队列头部的一个任务
void pushTask(Task task); // 添加一个任务到队列尾部
};
// 接下来,改造主线程,让主线程从队列中读取任务:
TaskQueue task_queue;
void ProcessTask();
void MainThread(){
while(true){
Task task = task_queue.takeTask();
ProcessTask(task);
}
}
// 在上面的代码中添加了一个消息队列的对象,然后在主线程的循环代码块中,从消息队列中读取一个任务,
//然后执行该任务,主线程就这样一直循环往下执行,因此只要消息队列中有任务,主线程就会去执行。
// 这样改造后,主线程执行的任务都全部从消息队列中获取。
// 所以如果有其他线程想要发送任务让主线程去执行,只需要将任务添加到该消息队列中就可以了,添加任务的代码如下:
Task clickTask;
task_queue.pushTask(clickTask)
延迟执行队列
因为消息队列是先进先出的,所以直接把定时器任务放到消息队列中,不能保证任务在指定时间被执行。为了解决这一问题,浏览器为定时器一类的任务特别安排了延迟执行队列。
void TimerTask(){
// 从 delayed_incoming_queue 中取出已经到期的定时器任务
// 依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
while(true){
// 执行消息队列中的任务
Task task = task_queue.takeTask();
ProcessTask(task);
// 执行延迟队列中的任务
ProcessDelayTask()
if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
break;
}
}
在上面的代码中专门添加了一个ProcessDelayTask 函数,用来处理延迟执行函数,处理完消息队列中的一个任务之后,就会开始执行ProcessDelayTask 函数。该函数会根据发起时间和延迟时间计算出到期的任务,依次执行所有的到期任务。执行完毕后,再进入下一个循环。
参考文章
什么是 Event Loop?
JavaScript 运行机制详解:再谈Event Loop
带你彻底弄懂Event Loop
chromium码仓库
极客时间-浏览器工作原理与实践
chromium浏览器开发系列第三篇:chromium源码目录结构
网友评论