使用 Cython 和外部多线程 C 库时的未定义行为

Undefined behavior when using Cython and external multithreaded C library

我是 Cython(以及 Python)的新手,当我尝试公开外部多线程库的 C-API 时,我试图了解我哪里做错了到 Python。为了说明我的问题,我将回顾一个假设的 MWE。

假设我有以下目录结构

.
├── app.py
├── c_mylib.pxd
├── cxx
│   ├── CMakeLists.txt
│   ├── include
│   │   └── mylib.h
│   └── src
│       └── reduce_cb.cpp
├── mylib.pyx
└── setup.py

这里cxx包含外部多线程库如下(头文件和实现文件串联):

/* cxx/include/mylib.h */
#ifndef MYLIB_H_
#define MYLIB_H_

#ifdef __cplusplus
extern "C" {
#endif

typedef double (*func_t)(const double *, const double *, void *);
double reduce_cb(const double *, const double *, func_t, void *);

#ifdef __cplusplus
}
#endif

#endif

/* cxx/src/reduce_cb.cpp */
#include <iterator>
#include <mutex>
#include <thread>
#include <vector>

#include "mylib.h"

extern "C" {
double reduce_cb(const double *xb, const double *xe, func_t func, void *data) {
  const auto d = std::distance(xb, xe);
  const auto W = std::thread::hardware_concurrency();
  const auto split = d / W;
  const auto remain = d % W;
  std::vector<std::thread> workers(W);
  double res{0};
  std::mutex lock;
  const double *xb_w{xb};
  const double *xe_w;
  for (unsigned int widx = 0; widx < W; widx++) {
    xe_w = widx < remain ? xb_w + split + 1 : xb_w + split;
    workers[widx] = std::thread(
        [&lock, &res, func, data](const double *xb, const double *xe) {
          const double partial = func(xb, xe, data);
          std::lock_guard<std::mutex> guard(lock);
          res += partial;
        },
        xb_w, xe_w);
    xb_w = xe_w;
  }
  for (auto &worker : workers)
    worker.join();
  return res;
}
}

附带的 cxx/CMakeLists.txt 文件如下:

cmake_minimum_required(VERSION 3.9)

project(dummy LANGUAGES CXX)

add_library(mylib
  include/mylib.h
  src/reduce_cb.cpp
)
target_compile_features(mylib
  PRIVATE
    cxx_std_11
)
target_include_directories(mylib
  PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)
set_target_properties(mylib
  PROPERTIES PUBLIC_HEADER include/mylib.h
)
install(TARGETS mylib
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  PUBLIC_HEADER DESTINATION include
)

对应的Cython文件如下(此时定义和实现文件拼接):

# c_mylib.pxd
cdef extern from "include/mylib.h":
  ctypedef double (*func_t)(const double *, const double *, void *)
  double reduce_cb(const double *, const double *, func_t, void *)

# mylib.pyx
# cython: language_level = 3
cimport c_mylib

cdef double func(const double *xb, const double *xe, void *data):
  cdef int d = (xe - xb)
  func = <object>data
  return func(<double[:d]>xb)

def reduce_cb(double [::1] arr not None, f):
  cdef int d = arr.shape[0]
  data = <void*>f
  return c_mylib.reduce_cb(&arr[0], &arr[0] + d, func, data)

# setup.py
from distutils.core import setup
from distutils.extension import Extension
from Cython.Build import cythonize

setup(
  ext_modules=cythonize([
    Extension("mylib", ["mylib.pyx"], libraries=["mylib"])
  ])
)

构建 C++ 库,构建 Cython 扩展模块并按照说明将其链接到 C++ 库,当我尝试 运行

时出现未定义的行为
import mylib
from numpy import array


def cb(x):
  partial = 0
  for idx in range(x.shape[0]):
    partial += x[idx]
  return partial


arr = array([val + 1 for val in range(100)], "d")
print("sum(arr): ", mylib.reduce_cb(arr, cb))

对于未定义的行为,我的意思是我得到了

  1. SIGSEGV(地址边界错误),
  2. "Fatal Python error: GC object already tracked" 和 SIGABRT,或者,
  3. (很少)正确的结果。

我已经彻底检查了 Cython 的文档(我猜),并且我已经搜索了 SO 和 Google 来解决这个问题,但是我找不到解决这个问题的合适方法。

基本上,我想要一个 C 库,它 unaware of Python 并使用来自多个线程的回调函数,集成在 Python。这是可能吗?我尝试了 nogil 签名和 with gil: 块,如 Cython 的 documentation, but I got compilation errors. Moreover, gc related functionality in Cython seems to be valid only for extension types 中所讨论的,这不适用于我的情况。

我被卡住了,我会很感激任何 hint/help。

当您在没有锁定的情况下使用 Python-objects/functionality 时会发生这种情况。您的关键部分不仅是求和,而且是对函数 func 的调用,即:

workers[widx] = std::thread(
    [&lock, &res, func, data](const double *xb, const double *xe) {
      std::lock_guard<std::mutex> guard(lock);
      const double partial = func(xb, xe, data); // must be guarded
      res += partial;
    },
    xb_w, xe_w);

这首先让并行化毫无意义,不是吗?可能,从 software-engineering 的角度来看,更好的守卫位置是在包装函数 func 中——但我将其放入 worker 中,因为这样可以看到更好的结果。

Python 使用引用计数进行内存管理 - 类似于 std::shared_ptr。然而它并没有像shared_ptr那样细粒度地锁定,它只在更改引用计数器时才锁定,而是使用更粗略的锁——全局解释器锁。结果是,当一个人从 open-MP-thread 或其他未在 Python-interpreter 中注册的线程更改 python-object 的引用计数时,reference-counter 不是 protected/guarded 并且出现竞争条件。您正在观察的是此类竞争条件的可能结果。

GIL 使您的努力或多或少变得不可能:您需要锁定每个可能的调用 python 但比序列化对此功能的调用更重要!