在 Web Workers 中使用 Pyodide

use pyodide in worker

上一篇文章我们介绍了借助 Pyodide 如何在前端浏览器中运行 Python 程序的方法,从例子中可以看到当运行计算任务比较密集的代码的时候,前端用户界面会出现停止响应的情况,原因就是浏览器执行 JavaScript 代码所使用的单线程架构,运行时间长的代码会阻塞线程,这种情况可以尝试把部分代码放到 Web Workers 中去执行。

Web Workers 独立于主线程之外并在后台单独开出的一个线中程执行代码,这样可以避免阻塞主线程代码的执行。目前 Web Workers 技术还有一些限制,比如不支持在Web Work 线程中使用 DOM 对象,Pyodide 不支持在多个 Web Workers 以及 Work 和主线程之间共享 Python 解释器、程序包,也不支持 Work 和主线程之间共享全局对象,使用的时候需要注意。

本篇文章介绍如何使用 Web Workers 在单独的线程里画图,然后把图形数据传回主线程,并在主线程里展现图片。下面的部分代码参考并引用自官网的指南。

一、引用 Pyodide

Pyodide 本地安装请参考上一篇文章的说明,本例中的代码使用了模块的方式(也可以不用),所以在插件的主程序中需要做一些特殊处理。

add_action( 'wp_enqueue_scripts', "pyodide_enqueue");
function pyodide_enqueue() {
    wp_enqueue_script( 'pyodide', plugins_url( '/pyodide/pyodide.js', __FILE__ ), array('jquery'));
    wp_enqueue_script( 'main', plugins_url( '/js/main.js', __FILE__ ), array('pyodide'));
}
//对需要模块化的javascript文件做类型替换
add_filter("script_loader_tag", "add_module_to_script", 10, 2);
function add_module_to_script($tag, $handle) {
    if ("main" === $handle) {
        $tag = str_replace('text/javascript', 'module', $tag);
    }
    return $tag;
}

二、新建 WordPress 文章

在文章中插入 “自定义HTML” 区块,输入下面的内容并保存:

<div id="pct"/>

三、主程序

main.js,主要负责 Python 程序代码的建立,worker 运行上下文参数的建立,使用 Web Workers 代理(py-worker.js)创建 worker,提取 worker 返回值,用户界面处理等职能。

import { asyncRun } from "/wp-content/plugins/my-plugin/js/py-worker.js";
//Python代码
const script = `
    import numpy as np
    import scipy.stats as stats
    import matplotlib.pyplot as plt
    import io, base64
    import js
    from js import obj1, obj2
    # matplotlib的一些方法要使用document对象,做下面的处理
    class matplotlib_patch:
        def __init__(self, *args, **kwargs) -> None:
            return
        def __getattr__(self, __name: str):
            return matplotlib_patch

    js.document = matplotlib_patch()
    //输出主程序传递过来的数据
    print(obj1, obj2.to_py())
    # 画图
    t = np.arange(0, 3.1, 0.1)
    s = 1 + np.sin(2 * np.pi * t)
    fig, ax = plt.subplots()
    ax.plot(t, s)
    ax.set(xlabel='time (s)', ylabel='voltage (mV)', title='Draw sin curve')
    ax.grid()
    buf = io.BytesIO()
    fig.savefig(buf, format='png')
    buf.seek(0)
    # 返回图形数据
    'data:image/png;base64,' + base64.b64encode(buf.read()).decode('UTF-8')
`;
//要传入到Work中Python程序所使用的参数
const context = {
  obj1: [1, 2, 3],
  obj2: {a:1, b:2, c:3}
};
async function run_worker() {
  try {
    //调用代理py-worker.js建立worker,运行python程序,并把返回结果显示到界面上
    const { results, error } = await asyncRun(script, context);
    if (results) {
        jQuery(document).ready( function() {
            let div_container = document.getElementById('pct');
            div_container.innerHTML = "<img id='fig' />"
            let img_tag = document.getElementById('fig');
            //把worker生成的图片数据赋值给img元素
            img_tag.src = results;
        });
    } else if (error) {
        console.log("Error: ", error);
    }
  } catch (e) {
        console.log(
            `Error at ${e.filename}, Line: ${e.lineno}, ${e.message}`
        );
  }
}
document.getElementById('pct') && run_worker();

四、Worker 代理程序

py-worker.js,主要负责新建 worker,给 worker 发消息传递主程序建立的 Python 脚本、上下文参数,把 worker 运行 Python 程序的结果返回给主程序。

const pyodideWorker = new Worker("/wp-content/plugins/my-plugin/js/webworker.js");
//保存每个worker相关的Promise运行成功回调函数的引用
const callbacks = {};
//接收worker返回的结果
pyodideWorker.onmessage = (event) => {
  //根据worker返回数据里的Promise id,提取关联的回调函数
  const { id, ...data } = event.data;
  const onSuccess = callbacks[id];
  delete callbacks[id];
  //运行回调函数,把结果返回给主程序
  onSuccess(data);
};
//主程序调用asyncRun创建worker
const asyncRun = (() => {
  let id = 0; // 给每个worker运行的Promise分配唯一标识
  return (script, context) => {
    //产生id的算法
    id = (id + 1) % Number.MAX_SAFE_INTEGER;
    //新建Promise给worker发消息
    return new Promise((onSuccess) => {
      //保存Promise运行成功回调函数引用,获得worker消息后调用该函数
      callbacks[id] = onSuccess;
      //给worker发消息,传递python程序代码、上下文参数和Promise id
      pyodideWorker.postMessage({
        ...context,
        python: script,
        id,
      });
    });
  };
})();
export { asyncRun };

五、Worker 程序

webworker.js,主要负责装载 Python 程序包,接收代理程序消息,提取 Python 程序脚本、上下文参数,运行 Python 程序,给代理程序发消息返回结果。

importScripts("/wp-content/plugins/my-plugin/pyodide/pyodide.js");
//装载Python程序包
async function loadPyodideAndPackages() {
  self.pyodide = await loadPyodide();
  await self.pyodide.loadPackage(["numpy", "pytz"]);
  await self.pyodide.loadPackage(['scipy', "matplotlib"]);
}
let pyodideReadyPromise = loadPyodideAndPackages();
//接收代理程序发来的消息,运行Python程序
self.onmessage = async (event) => {
  //等待实例化pyodide和装载python程序包完成
  await pyodideReadyPromise;
  //提取代理程序传入的主程序建立的参数和本次运行的Promise id
  const { id, python, ...context } = event.data;
  //把参数拷贝到worker自己的运行环境中,这样在Python程序中可以用import引入
  for (const key of Object.keys(context)) {
    self[key] = context[key];
  }
  //调用pyodide运行python程序,发消息给代理程序返回结果
  try {
    await self.pyodide.loadPackagesFromImports(python);
    let results = await self.pyodide.runPythonAsync(python);
    //除了返回Python程序运行结果,还有代理程序传入的本次Promise id
    self.postMessage({ results, id });
  } catch (error) {
    self.postMessage({ error: error.message, id });
  }
};

最后打开 WordPress 中新建的文章,就可以看到图片了。

通过对上面程序模型的介绍,就可以根据自己的需求来编写 Python 程序,并基于代理建立 worker 并运行 Python 程序,再把结果返回给主程序并显示到用户界面上,这个过程没有主线程阻塞。另外,对于包装 Web Worker API,方便 worker 应用,谷歌的 ComlinkWorkbox,AMP 的 worker-dom 都是不错的选择。

发表评论

邮箱地址不会被公开。 必填项已用*标注