PyNode是一個輕量級的Node.js C++擴充套件包,使用Node.js的N-API寫成的,能在同一個程式裡通過底層C/C++的API實現python和javascript的互操作,只需要進行資料型別的轉換,執行效率高。詳細的原理講解可以看我這篇介紹。
本文主要簡單記錄一下使用PyNode的一些實踐經驗。
前提
安裝的前提是裝好兩種語言的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上可以通過otools -L
命令,檢視你的python可執行檔案的依賴情況:
出現結果例如:# If your python command is "python3", use `which python3` # On Linux: ldd `which python` # On Mac OS otools -L `which python`
說明你的python有動態庫,那麼PyNode的安裝應該不會出現啥問題。# 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庫沒啥問題,按專案簡介所說,安裝前首先確認好你需要使用的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_ValueError
和undefined symbol: PyExc_SystemError
的錯誤。
通常會在Linux上出現,屬於是開啟動態連結的時候出了問題。PyNode提供了一個dlOpen
函式來手動開啟動態連結。
- 首先肯定是要找到你的動態庫,可以使用上文所說的
find_libpython
包來查詢。假設為:/opt/libpath/libpython3.9.so.1.0
- 然後在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單元測試卡住不會結束
表現是:
- 當測試檔案多於
cpu_count - 1
的時候,整個測試程式卡住,不會再進行下一個test suite。 - 如果使用
--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進pynoe的版本,想安裝這個版本的話,可以下載我fork的repo裡的原始碼。
然後在專案根目錄裡npm pack
,會得到一個.tgz
檔案,然後在package.json
裡這麼寫:
"dependencies": {
"@fridgerator/pynode": "file:./pynode/fridgerator-pynode-0.5.2.tgz"
},
路徑就是你的這個檔案相對於package.json
的路徑。