پشتیبانی افزونه‌های C API از نخ‌بندی آزاد

از نسخه‌ی 3.13 به بعد، CPython از اجرا در پیکربندی‌ای به نام قفل مفسر سراسری پشتیبانی می‌کند که در آن نخ‌آزاد (GIL) غیرفعال است. این سند نحوه‌ی سازگار کردن افزونه‌های C API برای پشتیبانی از نخ‌آزاد را توضیح می‌دهد.

شناسایی ساخت نخ‌آزادی در C

API زبان C در CPython ماکروی Py_GIL_DISABLED را در معرض قرار می‌دهد: در ساخت نخ‌آزاد (free-threaded build) به مقدار 1 تعریف شده است و در ساخت معمولی تعریف نشده است. می‌توانید از آن برای فعال کردن کدی استفاده کنید که فقط در ساخت نخ‌آزاد اجرا می‌شود:

#ifdef Py_GIL_DISABLED
/* code that only runs in the free-threaded build */
#endif

توجه

در ویندوز، این ماکرو به‌طور خودکار تعریف نمی‌شود، بلکه باید هنگام ساخت به کامپایلر اعلام شود. برای تشخیص این‌که آیا این ماکرو در مفسر در حال اجرای فعلی تعریف شده است یا نه، می‌توانید از تابع sysconfig.get_config_var() استفاده کنید.

مقداردهی اولیه ماژول

ماژول‌های توسعه‌ای باید به‌صراحت اعلام کنند که از اجرا با GIL غیرفعال پشتیبانی می‌کنند؛ در غیر این صورت، ایمپورت کردن ماژول توسعه‌ای باعث پرتاب یک هشدار می‌شود و GIL را در ران‌تایم فعال می‌کند.

بسته به اینکه یک ماژول توسعه از مقداردهی اولیه‌ی چندمرحله‌ای یا تک‌مرحله‌ای استفاده کند، دو روش برای نشان دادن پشتیبانی آن از اجرا با GIL غیرفعال وجود دارد.

مقداردهی اولیه‌ی چندمرحله‌ای

افزونه‌هایی که از مقداردهی اولیه چندمرحله‌ای (یعنی PyModuleDef_Init()) استفاده می‌کنند، باید یک جایگاه Py_mod_gil در تعریف ماژول اضافه کنند. اگر افزونه‌ی شما از نسخه‌های قدیمی‌تر CPython پشتیبانی می‌کند، باید این جایگاه را با یک بررسی PY_VERSION_HEX محافظت کنید.

static struct PyModuleDef_Slot module_slots[] = {
    ...
#if PY_VERSION_HEX >= 0x030D0000
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    .m_slots = module_slots,
    ...
};

مقداردهی اولیه‌ی تک‌مرحله‌ای

افزونه‌هایی که از راه‌اندازی تک‌مرحله‌ای (یعنی PyModule_Create()) استفاده می‌کنند، باید PyUnstable_Module_SetGIL() را فراخوانی کنند تا نشان دهند که از اجرا با GIL غیرفعال پشتیبانی می‌کنند. این تابع فقط در ساخت نخ‌آزاد شده است، بنابراین باید فراخوانی را با #ifdef Py_GIL_DISABLED محافظت کنید تا از خطاهای کامپایل در ساخت معمولی جلوگیری شود.

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    ...
};

PyMODINIT_FUNC
PyInit_mymodule(void)
{
    PyObject *m = PyModule_Create(&moduledef);
    if (m == NULL) {
        return NULL;
    }
#ifdef Py_GIL_DISABLED
    PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
    return m;
}

دستورالعمل‌های کلی API

بیشتر C API نخ‌ایمن است، اما استثناهایی وجود دارد.

  • فیلدهای ساختار: دسترسی مستقیم به فیلدها در اشیاء یا ساختارهای Python C API، اگر فیلد ممکن است به‌طور همزمان تغییر کند، نخ‌ایمن نیست.

  • ماکروها: ماکروهای دسترسی‌گر مانند PyList_GET_ITEM، PyList_SET_ITEM و ماکروهایی مانند PySequence_Fast_GET_SIZE که از شیء برگردانده‌شده توسط PySequence_Fast() استفاده می‌کنند، هیچ‌گونه بررسی خطا یا قفل‌گذاری‌ای انجام نمی‌دهند. این ماکروها نخ‌ایمن (thread-safe) نیستند اگر ممکن باشد شیء نگهدارنده همزمان تغییر داده شود.

  • ارجاع‌های امانتی: توابع C API که ارجاع‌های امانتی را برمی‌گردانند، ممکن است در صورتی که شیء دربردارنده به‌طور همزمان تغییر کند، ایمن در برابر نخ نباشند. برای اطلاعات بیشتر، بخش ارجاع‌های امانتی را ببینید.

ایمنی نخ در ظرف

ظرف‌هایی مانند PyListObject، PyDictObject و PySetObject در ساخت نخ‌آزادقفل‌گذاری داخلی انجام می‌دهند. برای مثال، PyList_Append() پیش از افزودن یک آیتم، فهرست را قفل می‌کند.

PyDict_Next

یک استثنای قابل‌توجه PyDict_Next() است، که دیکشنری را قفل نمی‌کند. اگر ممکن است دیکشنری به‌صورت همزمان تغییر داده شود، شما باید برای محافظت از دیکشنری در حین پیمایش آن از Py_BEGIN_CRITICAL_SECTION استفاده کنید:

Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
    ...
}
Py_END_CRITICAL_SECTION();

ارجاع‌های امانتی

برخی از توابع C API، ارجاع‌های امانتی را بازمی‌گردانند. این APIها در صورتی که شیء حاوی آن‌ها به‌طور همزمان تغییر کند، نخ‌ایمن نیستند. برای مثال، اگر ممکن است فهرست به‌طور همزمان تغییر کند، استفاده از PyList_GetItem() ایمن نیست.

جدول زیر برخی APIهای ارجاع امانتی (borrowed reference) و جایگزین‌های آن‌ها را که ارجاع‌های قوی را برمی‌گردانند، فهرست می‌کند.

API ارجاع امانتی

API ارجاع قوی

PyList_GetItem()

PyList_GetItemRef()

PyList_GET_ITEM()

PyList_GetItemRef()

PyDict_GetItem()

PyDict_GetItemRef()

PyDict_GetItemWithError()

PyDict_GetItemRef()

PyDict_GetItemString()

PyDict_GetItemStringRef()

PyDict_SetDefault()

PyDict_SetDefaultRef()

PyDict_Next()

هیچ (به PyDict_Next رجوع کنید)

PyWeakref_GetObject()

PyWeakref_GetRef()

PyWeakref_GET_OBJECT()

PyWeakref_GetRef()

PyImport_AddModule()

PyImport_AddModuleRef()

PyCell_GET()

PyCell_Get()

همه‌ی APIهایی که ارجاع‌های امانتی (borrowed references) را برمی‌گردانند، مشکل‌ساز نیستند. برای مثال، PyTuple_GetItem() ایمن است، زیرا تاپل‌ها تغییرناپذیر هستند. به‌طور مشابه، همه‌ی استفاده‌های APIهای بالا مشکل‌ساز نیستند. برای مثال، PyDict_GetItem() اغلب برای تجزیه‌ی دیکشنری‌های آرگومان کلیدواژه‌ای در فراخوانی‌های تابع استفاده می‌شود؛ آن دیکشنری‌های آرگومان کلیدواژه‌ای عملاً خصوصی هستند (برای نخ‌های دیگر قابل دسترسی نیستند)، بنابراین استفاده از ارجاع‌های امانتی در آن زمینه ایمن است.

برخی از این توابع در پایتون 3.13 افزوده شده‌اند. شما می‌توانید از بسته‌ی pythoncapi-compat برای فراهم کردن پیاده‌سازی این توابع برای نسخه‌های قدیمی‌تر پایتون استفاده کنید.

APIهای تخصیص حافظه

C API مدیریت حافظه‌ی پایتون، توابعی را در سه دامنه‌ی تخصیص متفاوت فراهم می‌کند: "raw"، "mem" و "object". برای ایمنی نخ‌ها، ساخت free-threaded ایجاب می‌کند که تنها اشیای پایتون با استفاده از دامنه‌ی object تخصیص داده شوند و همه‌ی اشیای پایتون نیز با استفاده از آن دامنه تخصیص داده شوند. این با نسخه‌های پیشین پایتون تفاوت دارد؛ در آن نسخه‌ها، این فقط یک بهترین روش بود و نه یک الزام سخت.

توجه

موارد استفاده از PyObject_Malloc() را در افزونه خود جستجو کنید و بررسی کنید که حافظه تخصیص‌یافته برای اشیای پایتون استفاده می‌شود. برای تخصیص بافرها به جای PyObject_Malloc() از PyMem_Malloc() استفاده کنید.

وضعیت نخ و APIهای GIL

پایتون مجموعه‌ای از توابع و ماکروها را برای مدیریت وضعیت نخ و GIL فراهم می‌کند، مانند:

همچنان باید از این توابع در ساخت با نخ‌های آزاد (free-threaded build) برای مدیریت وضعیت نخ استفاده شود، حتی زمانی که GIL غیرفعال باشد. برای مثال، اگر نخی خارج از پایتون ایجاد می‌کنید، باید پیش از فراخوانی API پایتون، PyGILState_Ensure() را فراخوانی کنید تا اطمینان حاصل شود که آن نخ دارای وضعیت نخ پایتون معتبری است.

شما باید همچنان PyEval_SaveThread() یا Py_BEGIN_ALLOW_THREADS را در اطراف عملیات مسدودکننده، مانند I/O یا کسب قفل، فراخوانی کنید تا نخ‌های دیگر بتوانند زباله‌روبی چرخه‌ای را اجرا کنند.

محافظت از وضعیت داخلی افزونه

ممکن است افزونه شما وضعیت داخلی داشته باشد که پیش‌تر توسط GIL محافظت می‌شد. ممکن است لازم باشد برای محافظت از این وضعیت، قفل‌گذاری اضافه کنید. رویکرد مورد استفاده به افزونه شما بستگی دارد، اما برخی الگوهای رایج عبارت‌اند از:

  • نهانگاه‌ها: نهانگاه‌های سراسری منبع رایجی از وضعیت مشترک هستند. اگر نهانگاه برای کارایی حیاتی نیست، استفاده از یک قفل برای محافظت از نهانگاه یا غیرفعال کردن آن در ساخت نخ‌آزاد را در نظر بگیرید.

  • وضعیت سراسری: ممکن است لازم باشد وضعیت سراسری با یک قفل محافظت شود یا به فضای ذخیره‌سازی محلی نخ منتقل شود. C11 و C++11 برای فضای ذخیره‌سازی محلی نخ، thread_local یا _Thread_local را ارائه می‌کنند.

بخش‌های بحرانی

در ساخت نخ‌آزاد (free-threaded build)، CPython برای محافظت از داده‌هایی که در غیر این صورت توسط GIL محافظت می‌شدند، سازوکاری به نام «بخش‌های بحرانی» فراهم می‌کند. اگرچه ممکن است نویسندگان افزونه‌ها مستقیماً با پیاده‌سازی داخلی بخش‌های بحرانی تعامل نداشته باشند، درک رفتار آن‌ها هنگام استفاده از برخی از توابع C API یا مدیریت وضعیت مشترک در ساخت نخ‌آزاد بسیار مهم است.

بخش‌های بحرانی چیستند؟

از نظر مفهومی، بخش‌های بحرانی به‌عنوان لایه‌ای برای اجتناب از بن‌بست عمل می‌کنند که بر روی قفل‌های متقابل ساده (mutex) ساخته شده‌اند. هر نخ، پشته‌ای از بخش‌های بحرانی فعال را نگه می‌دارد. هرگاه نخی نیاز به کسب قفلی مرتبط با یک بخش بحرانی داشته باشد (برای مثال، به‌صورت ضمنی هنگام فراخوانی تابعی ایمن برای نخ از API C مانند PyDict_SetItem()، یا به‌صورت صریح با استفاده از ماکروها)، تلاش می‌کند قفل متقابل زیربنایی را کسب کند.

استفاده از بخش‌های بحرانی

APIهای اصلی برای استفاده از بخش‌های بحرانی عبارتند از:

این ماکروها باید به‌صورت جفت‌های متناظر استفاده شوند و در یک محدوده C یکسان ظاهر شوند، زیرا آن‌ها یک محدوده محلی جدید ایجاد می‌کنند. این ماکروها در ساخت‌های non-free-threaded بدون عملیات هستند، بنابراین می‌توان آن‌ها را با اطمینان به کدی که نیاز به پشتیبانی از هر دو نوع ساخت دارد، اضافه کرد.

یک استفاده رایج از بخش بحرانی این است که هنگام دسترسی به یک ویژگی داخلی یک شیء، آن شیء را قفل کنید. برای مثال، اگر یک نوع توسعه‌ای دارای یک فیلد شمارش داخلی باشد، می‌توانید هنگام خواندن یا نوشتن آن فیلد از یک بخش بحرانی استفاده کنید:

// خواندن count، یک ارجاع جدید به مقدار داخلی count برمی‌گرداند
PyObject *result;
Py_BEGIN_CRITICAL_SECTION(obj);
result = Py_NewRef(obj->count);
Py_END_CRITICAL_SECTION();
return result;

// نوشتن count، ارجاع new_count را مصرف می‌کند
Py_BEGIN_CRITICAL_SECTION(obj);
obj->count = new_count;
Py_END_CRITICAL_SECTION();

نحوه کار بخش‌های بحرانی

برخلاف قفل‌های سنتی، بخش‌های بحرانی دسترسی انحصاری را در تمام مدت خود تضمین نمی‌کنند. اگر نخی در حالی که یک بخش بحرانی را در اختیار دارد مسدود شود (مثلاً با به دست آوردن قفلی دیگر یا انجام I/O)، آن بخش بحرانی به‌طور موقت معلق می‌شود—همه قفل‌ها آزاد می‌شوند—و سپس هنگامی که عملیات مسدودکننده کامل شود، از سر گرفته می‌شود.

این رفتار مشابه اتفاقی است که با GIL هنگامی رخ می‌دهد که یک نخ یک فراخوانی مسدودکننده انجام می‌دهد. تفاوت‌های اصلی عبارتند از:

  • بخش‌های بحرانی به‌ازای هر شیء عمل می‌کنند، نه به‌صورت سراسری

  • بخش‌های بحرانی در هر نخ از یک نظم پشته‌ای پیروی می‌کنند (ماکروهای «begin» و «end» این موضوع را اعمال می‌کنند، زیرا باید به‌صورت جفت و در یک محدوده یکسان استفاده شوند)

  • بخش‌های بحرانی به‌طور خودکار قفل‌ها را پیرامون عملیات‌های مسدودکننده‌ی احتمالی آزاد کرده و دوباره تصاحب می‌کنند

اجتناب از بن‌بست

بخش‌های بحرانی به دو روش در جلوگیری از بن‌بست‌ها کمک می‌کنند:

  1. اگر یک نخ تلاش کند قفلی را که از قبل در اختیار نخ دیگری است به دست آورد، ابتدا تمام بخش‌های بحرانی فعال خود را معلق می‌کند و قفل‌های آن‌ها را به‌طور موقت آزاد می‌کند

  2. هنگامی که عملیات مسدودکننده کامل می‌شود، ابتدا تنها بالاترین بخش بحرانی دوباره به دست می‌آید

این بدان معناست که نمی‌توانید برای قفل کردن چندین شیء به‌طور همزمان، به بخش‌های بحرانی تودرتو اتکا کنید، زیرا بخش بحرانی داخلی ممکن است بخش‌های بحرانی بیرونی را معلق کند. در عوض، برای قفل کردن همزمان دو شیء از Py_BEGIN_CRITICAL_SECTION2 استفاده کنید.

توجه داشته باشید که قفل‌های توصیف‌شده در بالا فقط قفل‌های مبتنی بر PyMutex هستند. پیاده‌سازی بخش بحرانی از سایر سازوکارهای قفل‌سازی که ممکن است در حال استفاده باشند، مانند قفل‌های متقابل POSIX، آگاه نیست و آن‌ها را تحت تأثیر قرار نمی‌دهد. همچنین توجه داشته باشید که هرچند مسدود شدن در انتظار هر PyMutex باعث تعلیق بخش‌های بحرانی می‌شود، فقط قفل‌های متقابلی که بخشی از بخش‌های بحرانی هستند آزاد می‌شوند. اگر PyMutex بدون یک بخش بحرانی استفاده شود، آزاد نخواهد شد و بنابراین از همان اجتناب از بن‌بست بهره‌مند نمی‌شود.

ملاحظات مهم

  • بخش‌های بحرانی ممکن است قفل‌های خود را به‌طور موقت آزاد کنند و به نخ‌های دیگر اجازه دهند داده‌های محافظت‌شده را تغییر دهند. هنگام فرض‌سازی درباره وضعیت داده‌ها پس از عملیاتی که ممکن است مسدودکننده باشند، احتیاط کنید.

  • چون قفل‌ها می‌توانند به‌طور موقت آزاد (معلق) شوند، ورود به یک بخش بحرانی، دسترسی انحصاری به منبع محافظت‌شده را در تمام مدت آن بخش تضمین نمی‌کند. اگر کد درون یک بخش بحرانی تابع دیگری را فراخوانی کند که مسدودکننده است (برای مثال، قفل دیگری را به دست می‌آورد یا ورودی/خروجی مسدودکننده انجام می‌دهد)، تمام قفل‌هایی که نخ از طریق بخش‌های بحرانی در اختیار دارد آزاد خواهند شد. این شبیه به حالتی است که GIL می‌تواند در حین فراخوانی‌های مسدودکننده آزاد شود.

  • تنها قفل(ها)ی مرتبط با آخرین بخش بحرانی واردشده (بالاترین) تضمین می‌شود که در هر لحظه نگه‌داشته شده باشند. قفل‌های بخش‌های بحرانی بیرونی و تودرتو ممکن است معلق شده باشند.

  • شما می‌توانید با این APIها حداکثر ۲ شیء را به‌طور همزمان قفل کنید. اگر نیاز دارید اشیاء بیشتری را قفل کنید، باید ساختار کد خود را بازآرایی کنید.

  • اگرچه بخش‌های بحرانی در صورت تلاش برای قفل کردن دوباره‌ی یک شیء دچار بن‌بست نمی‌شوند، اما برای این مورد استفاده، کارآمدی کمتری نسبت به قفل‌های بازورودپذیر (reentrant locks) دارند که برای همین منظور ساخته شده‌اند.

  • هنگام استفاده از Py_BEGIN_CRITICAL_SECTION2، ترتیب شیء‌ها بر صحت تأثیر نمی‌گذارد (پیاده‌سازی اجتناب از بن‌بست را مدیریت می‌کند)، اما بهتر است همیشه شیء‌ها را با ترتیبی ثابت قفل کنید.

  • به یاد داشته باشید که ماکروهای بخش بحرانی عمدتاً برای محافظت از دسترسی به اشیای پایتون به کار می‌روند؛ این اشیاء ممکن است در عملیات داخلی CPython که مستعد سناریوهای بن‌بست توصیف‌شده در بالا هستند، دخیل باشند. برای محافظت از وضعیت کاملاً داخلی افزونه، ممکن است قفل‌های متقابل استاندارد یا دیگر سازوکارهای همگام‌سازی مناسب‌تر باشند.

ساخت افزونه‌ها برای ساخت نخ‌آزاد (Free-Threaded Build)

افزونه‌های C API باید به‌طور خاص برای ساخت نخ‌آزادی ساخته شوند. wheelها، کتابخانه‌های مشترک و دودویی‌ها با پسوند t مشخص می‌شوند.

  • pypa/manylinux از ساخت نخ‌آزاد (free-threaded) با پسوند t، مانند python3.14t، پشتیبانی می‌کند.

  • pypa/cibuildwheel از ساخت wheelها برای نسخه‌ی ساخت نخ‌آزادی پایتون 3.14 و جدیدتر پشتیبانی می‌کند.

API محدود C و ABI پایدار

ساخت نخ‌آزادی (free-threaded build) در حال حاضر از Limited C API یا ABI پایدار پشتیبانی نمی‌کند. اگر برای ساخت افزونه خود از setuptools استفاده می‌کنید و در حال حاضر py_limited_api=True را تنظیم کرده‌اید، می‌توانید برای خارج شدن از API محدود هنگام ساخت با ساخت نخ‌آزادی، از py_limited_api=not sysconfig.get_config_var("Py_GIL_DISABLED") استفاده کنید.

توجه

شما باید wheelهای جداگانه‌ای را به‌طور خاص برای ساخت نخ‌آزاد بسازید. اگر در حال حاضر از ABI پایدار استفاده می‌کنید، می‌توانید به ساخت یک wheel واحد برای چندین نسخه‌ی پایتون غیرنخ‌آزاد (non-free-threaded) ادامه دهید.

ویندوز

به دلیل محدودیتی در نصب‌کننده رسمی ویندوز، هنگام ساخت افزونه‌ها از منبع، باید به‌صورت دستی Py_GIL_DISABLED=1 را تعریف کنید.

همچنین ملاحظه نمائید

پورت ماژول‌های توسعه‌ای برای پشتیبانی از Free-Threading: یک راهنمای پورت نگهداری‌شده توسط کامیونیتی برای نویسندگان افزونه.