异步与资源调度 以浏览器事件循环为例

初次发布于我的个人文档

参考:

chromiun官方文档

w3c官方文档

针对一个异步的程序应该如何对它进行资源的调度呢?本文以浏览器为典型范例进行简单介绍。

1.查看浏览器的多进程图景

打开任意一个浏览器这里以edge为例。

然后打开Windows的任务管理器,你看到的可能是这样:

Windows任务管理器截图

事实上,在edge浏览器(其他浏览器也有类似的功能)按shift+esc键能打开浏览器内部的任务管理器,可能长这样:

浏览器任务管理器不管怎么样总之,都可以看到你打开了一个浏览器实际上打开了好多个“进行中的程序”也就是进程。

浏览器作为及其复杂的而又非常常用的程序,不得不使用多进程的方式优化。

而正是因为浏览器使用了多进程,所以有的时候你会发现,某一个网页卡了但是浏览器没有卡死,一个页面卡死了但是另一个页面没有卡死。这是因为他们本来就“不是一个程序”。

多进程是一种充分利用计算机硬件资源的方式,关于多进程、多线程和协程的有关概念以后有时间也许会分享。

总而言之言而总之,浏览器是一个多进程的复杂的应用程序。

浏览器的诸多线程里,最主要的是三个:

  • 浏览器进程负责浏览器界面的展示(不是网页,而是浏览器界面内的什么选项卡啊按钮啊什么的)、用户交互、子进程管理等等

  • 网络进程负责启动多个线程来执行网络任务,也就是收发各种网络请求。

  • 渲染进程才是负责网页渲染的,有时间也会展开说说浏览器是如何渲染网页的。 渲染进程是浏览器最重要也是最繁忙的进程,说他重要是因为我打开浏览器最主要的就是想看页面啊,没渲染进程怎么能行?说他繁忙,可以等以后展开。

2.阻塞和非阻塞、同步和异步

刚刚我们说渲染进程是浏览器最重要也是最繁忙的进程,那么渲染进程是怎么组织资源调度和分配的呢?

比如说,用户点击了一个按钮,我肯定要执行一段代码,但是与此同时可能有一个计时器也刚好到时间了,也想执行一段代码,那怎么办呢?

进一步的,我怎么知道用户点击了一个按钮呢?是不是我得一直监听用户输入啊,那我岂不是要开一个任务一直运行着,那我渲染进程岂不是还得等用户点击按钮用户有输入了才能继续执行?

这种现象被称为阻塞。

也就是这个任务会卡着某一个线程,这个线程要等这个任务完成才能继续执行代码。

我们写的一般的代码是不会卡着线程的,比如什么i++啊之类的,一下子就完成了所以不会阻塞。

那如果我想做一些网络交互啊,磁盘输入输出(input/output,IO)之类的,那时间就长了,主线程就得等这些任务完成才能继续执行了,这就是阻塞了。

再比如,下面的Python代码

1
2
3
a = "hello World"
input("请输入")
print(a)

第二行在等待用户输入,如果用户一直不输入那么程序不会停止也不会运行第三个语句,而是会一直等待用户输入,用术语来讲就是阻塞。

像这种,每个任务都按顺序完成的情况我们称之为同步。

根据刚刚举的例子可以发现,同步的代码可能发生阻塞也可能不发生,我们分别称这两种情况为同步阻塞、同步非阻塞。

结论:同步不一定阻塞

不过比起这个结论更重要的是理解刚刚说的分析的过程。

对于浏览器来说,他确实想执行监听用户的任务,但是又不想阻塞主线程不然用户看到的网页就一卡一卡的。

那该怎么办呢?

那就要使用异步的方式。

这是一个简单的用java(python由于全局解释器锁的存在不太适合当例子了,JavaScript则是运行在浏览器的渲染主线程上也不太适合)实现的异步调用demo。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class CallbackThreadExample {

    // 定义一个回调接口
    interface Callback {
        void onFinish(String result);
    }

    // 实现Runnable接口的类
    static class Task implements Runnable {
        private final Callback callback;

        public Task(Callback callback) {
            this.callback = callback;
        }

        @Override
        public void run() {
            // 执行一些任务
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 任务完成,调用回调函数
            if (callback != null) {
                callback.onFinish("任务完成");
            }
        }
    }

    public static void main(String[] args) {
        // 创建一个回调对象
        Callback callback = result -> System.out.println("回调函数被调用,结果:" + result);

        // 创建并启动新线程
        Thread thread = new Thread(new Task(callback));
        thread.start();

        // 主线程可以继续执行其他任务
        System.out.println("主线程继续执行...");
    }
}

callback被称为回调函数,简单说就是“回头再调用"。

main函数里定义了回调函数 Callback callback = result -> System.out.println("回调函数被调用,结果:" + result);

就是说等时机成熟,你给我调用这句输出语句。

task类定义了一个任务,叫线程睡眠2秒,然后再调用回调函数。(你看,回调函数是不是“回头再调用了”)

很显然,task任务会阻塞主线程2秒,我们不希望这件事发生。所以新开了另一个线程来执行这个任务,这样主线程就可以继续执行了,也就不会阻塞了。

这就是异步非阻塞

创建新线程执行原本会阻塞的任务,利用回调函数给予反馈是异步的一种实现方式。前面我们说同步的程序所有任务会按顺序完成,但这里异步的任务会和主线程同时完成,这就是异步和同步的区别。

3.消息队列

那么浏览器是怎么处理纷繁复杂的异步任务的呢?

熟悉JavaScript的话你会发现,像网络IO,交互,计时器等都是也只能按异步+回调的方式调用。那浏览器会在什么时候执行回调呢?

如果网络IO完成的同时,计时器时间也到了应该先完成哪个?

很快你会发现,这是一个任务生产的速率大于任务消费的速率的情况,这种模型我们一般可以通过排队的方式解决。

你们俩同时想我启用某个任务了是吧,一个任务正在进行另外两个任务也想启动是吧,排队!

队列这种数据结构就是现实里的排队,讲究的是先到先得

在我们这种情况下,用面向对象的术语来说就是需要一个消息队列,当一个任务想执行了,先往消息队列里发一个消息,我想执行某某任务,然后渲染主线程会先服务队首也就是排在最前面的人。

4.事件循环

在浏览器具体实现的时候,又有一些细节需要注意。

我们以谷歌chromiun内核为例,观察chromiun的源码,你会发现在chromiun渲染进程中存在一个死循环(这个循环被W3C称为事件循环在chromiun源码中被称为消息循环),它不断地从消息队列中取任务,当消息队列为空时会休眠,只要队列里有任务就执行队首的任务,是吗?

是也不完全是。

对于一般的简单程序来说也许这样就足够了,但是对浏览器这个复杂的应用程序来说,完全不够!

浏览器中有事件交互、网络IO等诸多异步任务,很显然事件交互的优先级要高一些,也就是当用户点击按钮啊什么的你浏览器必须尽快给出响应,不要让用户觉得卡顿。

但是这样的话就破坏了消息队列“先进先出,先到先得”的特性,又该怎么办呢?

5.微队列和宏队列

动动你的脑瓜子想想,虽然队列里的成员不能有优先级,只能先到先得,但是消息队列不是可以有优先级吗?

我开好几个不同优先级的消息队列不就得了。

如果你看过很多早期的教程或者早期w3c规范,你可能会听说过微队列和宏队列的说法,但是随着浏览器执行任务的复杂,w3c已经不再使用宏队列的说法了。(微队列仍在使用)光靠微队列和宏队列已经不足以支撑现代浏览器的资源调度了。

现代浏览器有微队列、交互队列、延时队列等诸多消息队列。

按照w3c最新的规范,微队列是优先级最高的队列,当渲染主进程完成手头现有的工作后只要微队列有任务在等着,那么他就会执行微队列的任务。这是因为微队列里都是一些支撑浏览器运行的重要任务。交互队列由于和用户体验息息相关所以优先级也比较高。像延时队列这种只关乎计时器的消息队列优先级就比较低,如果微队列的任务和交互队列的任务没有完成,那即使计时器到了,计时器的回调函数也不会被执行。这也是为什么计时器其实不能严格准确精准无误地按照程序员设定的时间执行任务

前面我们说“微队列里都是一些支撑浏览器运行的重要任务”其实也并不完全吧,我们还是有办法把一个函数添加到微队列的,可以通过以下代码

1
Promise.resolve().then(函数)

将函数包装成任务,塞到微队列。

翻看chromiun内核源码你会知道,这些消息队列里存放的不是函数句柄(或者说指向函数的指针)而是一个被包装起来的结构体,所以这里要用“包装成任务”的说法。

一个小网站,用于文档查阅
使用 Hugo 构建
主题 StackJimmy 设计