Python與Javascript相互調用超詳細講解(四)使用PyNode進行Python與Node.js相互調用項(cai)目(keng)實(jing)踐(yan)


PyNode是一個輕量級的Node.js C++擴展包,使用Node.js的N-API寫成的,能在同一個進程里通過底層C/C++的API實現python和javascript的互操作,只需要進行數據類型的轉換,運行效率高。詳細的原理講解可以看我這篇介紹

本文主要簡單記錄一下使用PyNode的一些實踐經驗。

下面提到的內容在我這篇介紹里都涉及了,就不贅述了。
注1:PyNode畢竟只是其作者個人開發的小項目,會或多或少有點問題。如果不想這么麻煩的話,可以使用阿里開源的@pipcook/boa,它內置了一個conda的python環境,連python動態庫的操作給你一手包辦了,應該不會有這么多問題。
注2: 用它的話,最后的主程序還是在Node環境里執行的。要通過C++聯通的原理,python調用javascript,請參考stpyv8。或者先用PyNode寫一個包裝好的JS接口函數(這個接口函數里可以用PyNode調用python程序),參數和返回值都是可以序列化的。然后再通過進程間通信的手段從python里調用javascript,調用這個接口函數。

前提

安裝的前提是裝好兩種語言的runtime。

  • Node.js:沒啥特別的,直接裝就行了。

    Linux系統安裝Node.js可以直接用NVM(Node Version Manager),與python的conda類似。

  • Python:由於PyNode是在Node.js的C++擴展里嵌入了python,因此需要Python的動態/靜態庫
    一般情況下,cpython官方給的安裝版都是由一個動態庫(比如在Linux里會叫:libpython3.x.so),和一個依賴該庫的小可執行文件組成。這樣非常方便其它C++程序嵌入python。
    這種Python通常是通過--enable-shared選項編譯安裝的,可以在你的python環境里運行:
    import sysconfig
    sysconfig.get_config_vars('Py_ENABLE_SHARED')
    
    這個返回[1]就是true,[0]就是false。true代表着是通過--enable-shared選項編譯安裝的。
    還有一種更本質的方法,直接看可執行文件依賴的動態庫。Linux上可以通過ldd命令,Mac OS上可以通過otool -L命令,查看你的python可執行文件的依賴情況:
    # If your python command is "python3", use `which python3`
    # On Linux: 
    ldd `which python`
    # On Mac OS
    otool -L `which python`
    
    出現結果例如:
    # On Linux
    root@7fe6daacb730:/# ldd `which python`
        linux-vdso.so.1 (0x00007ffe41b47000)
        libpython3.9.so.1.0 => /usr/local/lib/libpython3.9.so.1.0 (0x00007fd5feb2c000)  # python library
        libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1 (0x00007fd5feaf2000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fd5fead1000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fd5feacc000)
        libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007fd5feac7000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fd5fe944000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd5fe781000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fd5fef08000)
    
    # On Mac OS
    /your/python/executable/dir/venv/bin/python:
        /Library/Frameworks/Python.framework/Versions/3.9/Python (compatibility version 3.9.0, current version 3.9.0)  # Depends on python shared library
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1292.0.0)
    
    說明你的python有動態庫,那么PyNode的安裝應該不會出現啥問題。

安裝

首先,如果python庫沒啥問題,按項目簡介所說,安裝前首先確認好你需要使用的python環境,如果要用虛擬環境,也先進入虛擬環境

# Install gyp-next
pip install gyp-next

# Install PyNode
npm install @fridgerator/pynode
# or 
yarn add @fridgerator/pynode

那么,如果你的python不依賴動態庫咋辦呢?如果你是Conda環境,那么還是有可能找到動態庫的:

找到你的Python動態庫

適用於Conda環境。一些詳細的log可以參考這個Issue。下面的介紹跟我在這個Issue里的comment基本一樣的。

如果你用的是Conda環境,恭喜你,他們提供的python,舊一點的,可能會遇到GCC版本低問題,而新一點的,已經直接用靜態庫build成可執行文件了,再也不依賴動態庫了:

(py38) $ ldd `which python`

linux-vdso.so.1 (0x00007ffe03efd000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007faa187be000)
libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007faa185bb000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007faa1821d000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007faa17ffe000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007faa17c0d000)
/lib64/ld-linux-x86-64.so.2 (0x00007faa18d5d000)

不過好消息是,Conda聲稱他們盡管不用,但還是提供了動態庫,所以我們只要找到它就可以了。有一個很好用的python包find_libpython,能很好地找到你的python動態庫:

(py38) $ pip install find_libpython
(py38) $ find_libpython  # or "python -m find_libpython" /xxx/miniconda3/envs/py38/lib/libpython3.8.so.1.0.

然后我們要做的就是把動態庫的目錄路徑作為庫路徑添加到PY_LIBS里(-L)。
但這樣做鏈接的時候能找到庫,后面執行的時候也還是會找不到,一種解決方法是用rpath把這個路徑給他刻煙吸肺,運行的時候也能找到(通過-Wl,-rpath=)。那么加在一起,你的包安裝命令就應該是:

PYTHON_SHARED=/xxx/miniconda3/lib PY_LIBS="$(python ../build_ldflags.py) -L$PYTHON_SHARED -Wl,-rpath=$PYTHON_SHARED" npm install @fridgerator/pynode

使用

沒啥好說的,使用起來很簡單,按項目首頁的Readme做就可以了。列一些使用Tips和可能出問題的地方。

const pynode = require('@fridgerator/pynode')的時候動態鏈接錯誤

Uncaught:
Error: libpython3.9.so.1.0: cannot open shared object file: No such file or directory
    at Object.Module._extensions..node (node:internal/modules/cjs/loader:1168:18)
    at Module.load (node:internal/modules/cjs/loader:989:32)
    at Function.Module._load (node:internal/modules/cjs/loader:829:14)
    at Module.require (node:internal/modules/cjs/loader:1013:19)
    at require (node:internal/modules/cjs/helpers:93:18) {
  code: 'ERR_DLOPEN_FAILED'
}

根據StackOverflow上說,鏈接時能找到庫是一回事,不代表它運行的時候也能找到,運行的時候查找動態庫又有另一套路徑(……)。

驗證方法可以是:
cd進入node_modules/@fri/x/build/Release里,會找到編譯好的PyNode.node
然后,ldd PyNode.node

  linux-vdso.so.1 (0x00007ffef7de3000)
    libpython3.9.so.1.0 => not found  # Failed to find when executing
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f976bd9c000)
    libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f976ba13000)
    libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f976b7fb000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f976b40a000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f976c1bd000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f976b06c000)

就是說,build的時候提供-lpython3.9-L<path_to_shared_library>是明確指定好庫,這樣生成的文件PyNode.node才包含這個庫,換句話說,它才會出現在ldd的list里。

但這個可執行文件運行的時候找共享庫,跟build的時候提供的路徑沒有關系,感覺它像是會在一個給定的路徑集合里找,所以這些路徑集合里沒有libpython3.9.so.1.0,那ldd就會顯示not found。

在linux下,一種解決方案是通過往環境變量LD_LIBRARY_PATH里添加這個動態庫的目錄,例如:如果動態庫的絕對路徑為/opt/libpath/libpython3.9.so.1.0,那么可以在~/.bashrc里添加一行:

export LD_LIBRARY_PATH="/opt/libpath:$LD_LIBRARY_PATH"

這之后再ldd .node文件,就能找到了:

linux-vdso.so.1 (0x00007ffc01562000)
    libpython3.9.so.1.0 => /opt/libpath/libpython3.9.so.1.0 (0x00007f041a327000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f041a123000)
    libstdc++.so.6 => /mnt/asp_test/env/miniconda3/lib/libstdc++.so.6 (0x00007f041a9be000)
    libgcc_s.so.1 => /mnt/asp_test/env/miniconda3/lib/libgcc_s.so.1 (0x00007f041a9aa000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0419d32000)
    libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f0419b2f000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f0419791000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f0419572000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f041a91d000)

另一種方法是用rpath,就像上面針對Conda環境安裝的那樣,把動態庫的路徑同時也加到-Wl,-rpath=里。

ImportError: math.cpython-39-x86_64-linux-gnu.so: undefined symbol: PyFloat_Type

或者類似undefined symbol: PyExc_ValueErrorundefined symbol: PyExc_SystemError的錯誤。

通常會在Linux上出現,屬於是打開動態鏈接的時候出了問題。PyNode提供了一個dlOpen函數來手動打開動態鏈接。

  1. 首先肯定是要找到你的動態庫,可以使用上文所說的find_libpython包來查找。假設為:
    /opt/libpath/libpython3.9.so.1.0
    
  2. 然后在Node.js里:
    const pynode = require('@fridgerator/pynode');
    pynode.dlOpen('/opt/libpath/libpython3.9.so.1.0');
    

在Node.js里運行Python的multiprocessing

Node.js通過C API起了一個python解釋器,這種情況下本進程的可執行程序其實是node。換句話說,sys.argv[0]sys.executable都是指向node的可執行程序。

而python環境中開新的多進程同時運行,會獲取當前的可執行程序(excutable),發現竟然不是python,那multiprocessing就讀不懂這個可執行程序的信息,比如說找不到main從而報錯。

查看源代碼后發現了multiprocessing.spawn里的這么一段:

有一個接口set_executable,可以重新設置可執行程序。所以通過PyNode去調用使用了multiprocessing的python程序可以這么做:重新設置executable為你的python可執行程序

比如,我用了virtualenv,我的可執行程序就是:/my/project/path/venv/bin/python

const pynode = require('@fridgerator/pynode');
pynode.startInterpreter();
const sp = pynode.import('multiprocessing.spawn');
sp.get('set_executable').call('/my/project/path/venv/bin/python');
// Run the multiprocessing python code

需要注意的是,這種補丁操作只適用於純Python的multiprocessing。如果你的某個子進程混入了一些node.js的代碼,那么會報錯。還沒搞懂具體原理,我猜想原因可能是,子進程是通過python可執行程序起的,找不到node環境。

Jest單元測試卡住不會結束

表現是:

  1. 當測試文件多於cpu_count - 1的時候,整個測試程序卡住,不會再進行下一個test suite。
  2. 如果使用--runInBand選項禁止多線程執行單元測試,會Segment Fault

起因是segment fault了,而Jest不知道為啥在多線程跑單元測試的時候(一般一個文件一個線程,開線程的數量是cpu_count - 1),在某些OS里遇到segment fault當前線程就會卡死,所以總共會堅持cpu_count - 1個test suite,然后才卡死。如果文件數據少於cpu_count - 1的話,會順利運行完。

經過研究,segment fault的原因是:StartInterpreter不可以重復調用2次,因為它里面有一個函數執行的時候需要Python的全局進程鎖(GIL),但StartInterpreter在第二次調用的時候不調用Py_Initialize,因此也不重新獲取GIL,導致segment fault了。

而Jest的每個測試的環境都是全新的,不管是把const pynode = require('@fridgerator/pynode');放到單獨一個文件里require,還是用一個全局變量標記是否已經start Interpreter都無法阻止StartInterpreter被調用2次導致segment fault。

解決辦法就是不用Jest 修改源碼,不管怎樣在調用那個函數前都獲取一下GIL(PR)。
這個PR還沒被merge進pynode的主分支,想安裝這個版本的話,可以下載我fork的repo里的源碼

然后在項目根目錄里npm pack,會得到一個.tgz文件,然后在package.json里這么寫:

"dependencies": {
    "@fridgerator/pynode": "file:./pynode/fridgerator-pynode-0.5.2.tgz"
},

路徑就是你的這個文件相對於package.json的路徑。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM