V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Mark24
V2EX  ›  Ruby

用 100 行 Ruby 代码模拟 JavaScript 的 Eventloop

  •  
  •   Mark24 · 2022-08-11 19:04:15 +08:00 · 1984 次点击
    这是一个创建于 869 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    大家好,我是 Mark24

    背景

    我们都知道 JavaScript 是单线程的。

    今天看到一个有趣的帖子 www.v2ex.com/t/871848,主要是争论 JavaScript 的优缺点。我看到这个评论觉得很有意思:

    @qrobot:
    
    ....省略....
    
    
    多线程下会消耗以下资源
    
    1. 切换页表全局目录
    2. 切换内核态堆栈
    3. 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
    ip(instruction pointer):指向当前执行指令的下一条指令
    bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
    sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
    cr3:页目录基址寄存器,保存页目录表的物理地址
    ......
    
    4. 刷新 TLB
    5. 系统调度器的代码执行
    
    ....省略.....
    
    

    这位同学列举了多线程切换的时候发生了什么。 这样给了一种很直观的感受,就是多线程切换的时候发生了很多事情,实际上会比单线程(只需要切换函数上下文)要消耗点更多的资源。

    实际上凡是交互的软件,最终都是 单线程模型 + 事件驱动辅助。

    从熟悉的浏览器、游戏、应用程序……都是如此。

    也有多线程实现的。这里Multithreaded toolkits: A failed dream? (2004) 有很多讨论。

    实际上单线程模型是最后的胜出者。

    JavaScript 内部单线程处理任务,主要是有一个 EventLoop 单线程的循环实现。

    我们可以通过 JavaScript 的表现,反推实现一下 EventLoop 。

    EventLoop 实现

    JavaScript 的行为

    我们知道 setTimeout 在 JavaScript 中用来推迟任务。实际上自从 Promise 出现之后,渐渐有两个概念出现在大家的视野里。

    • Macrotask(宏任务)
    • Microtask (微任务)

    setTimeout 属于宏任务,而 promise 的 then 回调属于微任务。

    还有一个就是 JavaScript 在第一次同步执行代码的时候,是宏任务。

    EventLoop 的表现是,除了第一次执行结束之后,如果有更高优先级的 微任务总是先执行微任务,然后再执行宏任务。

    setTimeout 是一个定时器,很特别的是他在会在计时器线程工作,运行时间之后,回调函数会被插入到 宏任务中执行。计时器线程其实不是 JavaScript 虚拟的一部分,他是浏览器的部分。

    Ruby 模拟

    JavaScript 是单线程的。Ruby 是支持多线程的。我们可以用 Ruby 模拟一个 单线程的核心,和单独的计时器线程,这都是很轻松的事情。

    其实我们听到了这个行为 —— 花 1 分钟大概能想到,EventLoop 的工作模型

    • 首先他是一个主循环,这样才能所谓的单线程
    • 其次,既然有两种任务,应该是两种队列
    • 再者,如果第一次同步代码是宏任务,多半可以代码任务也丢到队列里

    我们可以用数组当做 队列。但是 由于存在时间线程,还得用 Thread#Queue 有保障一点。

    大概的模型可以画出来,想这个样:

    Eventloop Model

    ( start)
       |
      init (e.g create TimerThread )
       |
      sync task (e.g read & run code) 
       |                                        
       |
     ------------------>
    |                  |                                    -------------
    |               macro_task  ---  add timer task -->    | TimerThread |
    |   (Eventloop)    |       <--  insertjob result ---    -------------
    |                  |
    |               micro_task
    |                  |
    |                  |
     <-----------------   
       |
       |
      (end)
    
    

    然后我们大概用 100 行不到就可以实现如下:

    需要说明的是:

    1. settimeout 不要用每一个新的线程来模拟,因为一旦多线程,涉及到抢占式回调,其实返回的时间不确定。你的结果是不稳定的。 我们需要单独实现一个计时器线程。

    2. 我们通过行为封装,把两边函数写法对照,这样可以复制

    运行看结果

    result_example

    具体实现

    # https://github.com/Mark24Code/rb_simulate_eventloop
    require 'thread'
    
    class EventLoop
      
      attr_accessor :macro_queue, :micro_queue
      def initialize
        @running = true
        
        @macro_queue = Queue.new
        @micro_queue = Queue.new
    
        @time_thr_task_queue = Queue.new
    
        @timer = Timer.new(@time_thr_task_queue, @macro_queue)
    
        # 计时线程,是一个同步队列
        # 会把定时任务结果塞回宏队列
        @timer_thx = Thread.new do
          @timer.run
        end
      end
    
    
      def before_loop_sync_tasks
        # do sth setting
        @first_task.call
      end
    
      def task(&block)
        # 这里放置第一次同步任务
        # 
        # 外部书写的代码,模拟读取 js
        # 提供内部的 api
        @first_task = -> () { instance_eval(&block) }
      end
    
      def after_loop
        puts "[after_loop] eventloop is quit :D"
      end
    
      def macro_queue_works
        while !@macro_queue.empty?
          job = @macro_queue.shift
          job.call
        end
      end
    
      def micro_queue_works
        while !@micro_queue.empty?
          job = @micro_queue.shift
          job.call
        end
      end
    
      def start
        begin
          before_loop_sync_tasks
    
          while @running
    
            macro_queue_works
    
            micro_queue_works
    
            # avoid CPU 100%
            sleep 0.1
          end
        ensure
          after_loop
        end
      end
    
      # dsl public api
      # inner api
      def macro_task(&block)
        @macro_queue.push(block)
      end
    
      def micro_task(&block)
        @micro_queue.push(block)
      end
    
      def settimeout(time, &block)
        # 模拟定时器线程
        if time == 0
          time = 0.1
        end
    
        # 方案 1: 用独立分散的线程模拟存在问题
        # 抢占的返回顺序不是固定的
        # t = Thread.new do
        #   sleep time
        #   @micro_queue.push(block)
        # end
        ## !!! 这里一定不能阻塞,一旦阻塞就不是单线程模型
        ## 有外循环控制不会结束
        # t.join
    
        # 方案 2: 时间线程也需要单独模拟
        # 建立一个时间任务
        @time_thr_task_queue.push({
          sleep_time: Time.now.to_i + time,
          job: -> () { @micro_queue.push(block) }
        })
    
      end
    end
    
    class Timer
      def initialize(task_queue, macro_queue)
        @task_queue = task_queue
        @macro_queue = macro_queue
      end
      def run
        while (task = @task_queue.shift)
          sleep_time = task[:sleep_time]
          if sleep_time >= Time.now.to_i
            @macro_queue.push(task[:job])
          else
            @task_queue.push(task)
          end
        end
      end
    end
    

    总结

    选择单线程的原因是因为

    • 结果运行的更快
    • 无上下文负担
    • 任务队列清晰而又简单
    • 非 IO 密级任务,可以跑满 CPU

    Nginx 、Redis 内部也实现了单线程模型,来应对大量的请求,提高并发。

    现在我们大概知道了,浏览器、应用、app 、图形界面、游戏……

    他们的背后大概是什么样子。 破除神秘感 +1 :D

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2128 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 00:52 · PVG 08:52 · LAX 16:52 · JFK 19:52
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.