Hỗ trợ tiện ích mở rộng C API để phân luồng miễn phí

Bắt đầu với bản phát hành 3.13, CPython đã hỗ trợ chạy với global interpreter lock (GIL) bị vô hiệu hóa trong cấu hình có tên free threading. Tài liệu này mô tả cách điều chỉnh các tiện ích mở rộng C API để hỗ trợ phân luồng miễn phí.

Xác định bản dựng luồng tự do trong C

CPython C API hiển thị macro Py_GIL_DISABLED: trong bản dựng luồng tự do, nó được xác định là 1 và trong bản dựng thông thường, nó không được xác định. Bạn có thể sử dụng nó để kích hoạt mã chỉ chạy trong bản dựng có luồng miễn phí

#ifdef Py_GIL_DISABLED
/* mã chỉ chạy trong bản dựng có luồng tự do */
#endif

Ghi chú

Trên Windows, macro này không được xác định tự động mà phải được chỉ định cho trình biên dịch khi xây dựng. Hàm sysconfig.get_config_var() có thể được sử dụng để xác định xem trình thông dịch đang chạy hiện tại có xác định macro hay không.

Khởi tạo mô-đun

Các mô-đun mở rộng cần chỉ rõ rằng chúng hỗ trợ chạy khi GIL bị vô hiệu hóa; nếu không, việc nhập tiện ích mở rộng sẽ đưa ra cảnh báo và kích hoạt GIL khi chạy.

Có hai cách để chỉ ra rằng mô-đun mở rộng hỗ trợ chạy khi GIL bị tắt tùy thuộc vào việc tiện ích mở rộng sử dụng khởi tạo nhiều pha hay một pha.

Khởi tạo nhiều pha

Các tiện ích mở rộng sử dụng khởi tạo nhiều pha (tức là PyModuleDef_Init()) nên thêm khe Py_mod_gil trong định nghĩa mô-đun. Nếu tiện ích mở rộng của bạn hỗ trợ các phiên bản CPython cũ hơn, bạn nên bảo vệ vị trí bằng kiểm tra PY_VERSION_HEX.

cấu trúc tĩnh PyModuleDef_Slot module_slots[] = {
    ...
#if PY_VERSION_HEX >= 0x030D0000
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};

cấu trúc tĩnh PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    .m_slots = module_slots,
    ...
};

Khởi tạo một pha

Các tiện ích mở rộng sử dụng khởi tạo một pha (tức là PyModule_Create()) sẽ gọi PyUnstable_Module_SetGIL() để cho biết rằng chúng hỗ trợ chạy khi GIL bị tắt. Hàm này chỉ được xác định trong bản dựng có luồng tự do, vì vậy bạn nên bảo vệ lệnh gọi bằng #ifdef Py_GIL_DISABLED để tránh lỗi biên dịch trong bản dựng thông thường.

cấu trúc tĩnh PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    ...
};

PyMODINIT_FUNC
PyInit_mymodule(void)
{
    PyObject *m = PyModule_Create(&moduledef);
    nếu (m == NULL) {
        trả về NULL;
    }
#ifdef Py_GIL_DISABLED
    PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
    trả lại m;
}

Hướng dẫn chung về API

Hầu hết C API đều an toàn theo luồng, nhưng vẫn có một số trường hợp ngoại lệ.

  • Struct Fields: Truy cập trực tiếp các trường trong đối tượng hoặc cấu trúc API của Python C không an toàn cho luồng nếu trường có thể được sửa đổi đồng thời.

  • Macros: Các macro truy cập như PyList_GET_ITEM, PyList_SET_ITEM và các macro như PySequence_Fast_GET_SIZE sử dụng đối tượng được trả về bởi PySequence_Fast() không thực hiện bất kỳ việc kiểm tra hoặc khóa lỗi nào. Các macro này không an toàn cho luồng nếu đối tượng vùng chứa có thể được sửa đổi đồng thời.

  • Borrowed References: Các hàm C API trả về borrowed references có thể không an toàn cho luồng nếu đối tượng chứa được sửa đổi đồng thời. Xem phần trên borrowed references để biết thêm thông tin.

An toàn chủ đề container

Các vùng chứa như PyListObject, PyDictObjectPySetObject thực hiện khóa bên trong trong bản dựng có luồng tự do. Ví dụ: PyList_Append() sẽ khóa danh sách trước khi thêm một mục.

PyDict_Next

Một ngoại lệ đáng chú ý là PyDict_Next(), không khóa từ điển. Bạn nên sử dụng Py_BEGIN_CRITICAL_SECTION để bảo vệ từ điển trong khi lặp lại nó nếu từ điển có thể được sửa đổi đồng thời

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

Tài liệu tham khảo mượn

Một số hàm C API trả về borrowed references. Các API này không an toàn cho luồng nếu đối tượng chứa được sửa đổi đồng thời. Ví dụ: sẽ không an toàn khi sử dụng PyList_GetItem() nếu danh sách có thể được sửa đổi đồng thời.

Bảng sau liệt kê một số API tham chiếu mượn và các API thay thế trả về strong references.

Tham khảo mượn API

Tham khảo mạnh mẽ 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()

không có (xem PyDict_Next)

PyWeakref_GetObject()

PyWeakref_GetRef()

PyWeakref_GET_OBJECT()

PyWeakref_GetRef()

PyImport_AddModule()

PyImport_AddModuleRef()

PyCell_GET()

PyCell_Get()

Không phải tất cả các API trả về các tham chiếu mượn đều có vấn đề. Ví dụ: PyTuple_GetItem() an toàn vì bộ dữ liệu là bất biến. Tương tự, không phải tất cả việc sử dụng các API trên đều có vấn đề. Ví dụ: PyDict_GetItem() thường được sử dụng để phân tích từ điển đối số từ khóa trong lệnh gọi hàm; các từ điển đối số từ khóa đó thực sự là riêng tư (các luồng khác không thể truy cập được), vì vậy việc sử dụng các tham chiếu mượn trong ngữ cảnh đó là an toàn.

Một số hàm này đã được thêm vào Python 3.13. Bạn có thể sử dụng gói pythoncapi-compat để triển khai các hàm này cho các phiên bản Python cũ hơn.

API phân bổ bộ nhớ

Quản lý bộ nhớ của Python C API cung cấp các hàm trong ba allocation domains khác nhau: "raw", "mem" và "object". Để đảm bảo an toàn cho luồng, bản dựng luồng tự do yêu cầu chỉ các đối tượng Python được phân bổ bằng miền đối tượng và tất cả các đối tượng Python được phân bổ bằng miền đó. Điều này khác với các phiên bản Python trước đó, đây chỉ là cách thực hành tốt nhất chứ không phải là một yêu cầu khó.

Ghi chú

Tìm kiếm cách sử dụng PyObject_Malloc() trong tiện ích mở rộng của bạn và kiểm tra xem bộ nhớ được phân bổ có được sử dụng cho các đối tượng Python hay không. Sử dụng PyMem_Malloc() để phân bổ bộ đệm thay vì PyObject_Malloc().

Trạng thái luồng và API GIL

Python cung cấp một tập hợp các hàm và macro để quản lý trạng thái luồng và GIL, chẳng hạn như:

Các chức năng này vẫn nên được sử dụng trong bản dựng luồng tự do để quản lý trạng thái luồng ngay cả khi GIL bị tắt. Ví dụ: nếu bạn tạo một luồng bên ngoài Python, bạn phải gọi PyGILState_Ensure() trước khi gọi vào Python API để đảm bảo rằng luồng đó có trạng thái luồng Python hợp lệ.

Bạn nên tiếp tục gọi PyEval_SaveThread() hoặc Py_BEGIN_ALLOW_THREADS xung quanh các hoạt động chặn, chẳng hạn như I/O hoặc khóa chuyển đổi, để cho phép các luồng khác chạy cyclic garbage collector.

Bảo vệ trạng thái mở rộng nội bộ

Tiện ích mở rộng của bạn có thể có trạng thái nội bộ đã được GIL bảo vệ trước đó. Bạn có thể cần thêm khóa để bảo vệ trạng thái này. Cách tiếp cận sẽ phụ thuộc vào tiện ích mở rộng của bạn, nhưng một số mẫu phổ biến bao gồm:

  • Caches: bộ nhớ đệm chung là nguồn trạng thái chia sẻ phổ biến. Hãy cân nhắc việc sử dụng khóa để bảo vệ bộ nhớ đệm hoặc vô hiệu hóa bộ nhớ đệm đó trong bản dựng có luồng tự do nếu bộ nhớ đệm không quan trọng đối với hiệu suất.

  • Global State: trạng thái toàn cầu có thể cần được bảo vệ bằng khóa hoặc chuyển sang luồng lưu trữ cục bộ. C11 và C++11 cung cấp thread_local hoặc _Thread_local cho thread-local storage.

Phần quan trọng

Trong bản dựng luồng tự do, CPython cung cấp một cơ chế được gọi là "các phần quan trọng" để bảo vệ dữ liệu lẽ ra được GIL bảo vệ. Mặc dù các tác giả tiện ích mở rộng có thể không tương tác trực tiếp với việc triển khai phần quan trọng nội bộ, nhưng việc hiểu hành vi của họ là rất quan trọng khi sử dụng một số hàm C API nhất định hoặc quản lý trạng thái chia sẻ trong bản dựng luồng tự do.

Phần quan trọng là gì?

Về mặt khái niệm, các phần quan trọng hoạt động như một lớp tránh bế tắc được xây dựng trên các mutex đơn giản. Mỗi luồng duy trì một chồng các phần quan trọng đang hoạt động. Khi một luồng cần lấy một khóa được liên kết với một phần quan trọng (ví dụ: ngầm khi gọi hàm C API an toàn cho luồng như PyDict_SetItem() hoặc sử dụng macro một cách rõ ràng), nó sẽ cố gắng lấy được mutex cơ bản.

Sử dụng các phần quan trọng

Các API chính để sử dụng các phần quan trọng là:

Các macro này phải được sử dụng theo cặp phù hợp và phải xuất hiện trong cùng phạm vi C vì chúng thiết lập phạm vi cục bộ mới. Các macro này không hoạt động trong các bản dựng không có luồng tự do, vì vậy chúng có thể được thêm vào mã cần hỗ trợ cả hai loại bản dựng một cách an toàn.

Cách sử dụng phổ biến của phần quan trọng là khóa một đối tượng trong khi truy cập thuộc tính bên trong của nó. Ví dụ: nếu loại tiện ích mở rộng có trường đếm nội bộ, bạn có thể sử dụng phần quan trọng trong khi đọc hoặc ghi trường đó:

// đọc số đếm, trả về tham chiếu mới cho giá trị số đếm nội bộ
kết quả PyObject *;
Py_BEGIN_CRITICAL_SECTION(obj);
kết quả = Py_NewRef(obj->count);
Py_END_CRITICAL_SECTION();
trả về kết quả;

// ghi số đếm, lấy tham chiếu từ new_count
Py_BEGIN_CRITICAL_SECTION(obj);
obj->count = new_count;
Py_END_CRITICAL_SECTION();

Các phần quan trọng hoạt động như thế nào

Không giống như các khóa truyền thống, các phần quan trọng không đảm bảo quyền truy cập độc quyền trong toàn bộ thời gian của chúng. Nếu một luồng chặn trong khi đang giữ một phần quan trọng (ví dụ: bằng cách lấy một khóa khác hoặc thực hiện I/O), thì phần quan trọng đó sẽ tạm thời bị treo—tất cả các khóa đều được giải phóng—và sau đó được tiếp tục lại khi thao tác chặn hoàn tất.

Hành vi này tương tự như những gì xảy ra với GIL khi một chuỗi thực hiện lệnh gọi chặn. Sự khác biệt chính là:

  • Các phần quan trọng hoạt động trên cơ sở từng đối tượng thay vì trên toàn bộ

  • Các phần quan trọng tuân theo kỷ luật ngăn xếp trong mỗi luồng (macro "bắt đầu" và "kết thúc" thực thi điều này vì chúng phải được ghép nối và trong cùng một phạm vi)

  • Các phần quan trọng tự động giải phóng và yêu cầu khóa xung quanh các hoạt động chặn tiềm năng

Tránh bế tắc

Các phần quan trọng giúp tránh bế tắc theo hai cách:

  1. Nếu một luồng cố gắng lấy một khóa đã được giữ bởi một luồng khác, trước tiên nó sẽ tạm dừng tất cả các phần quan trọng đang hoạt động của nó, tạm thời giải phóng các khóa của chúng

  2. Khi thao tác chặn hoàn tất, chỉ phần quan trọng nhất mới được lấy lại trước tiên

Điều này có nghĩa là bạn không thể dựa vào các phần quan trọng lồng nhau để khóa nhiều đối tượng cùng một lúc, vì phần quan trọng bên trong có thể tạm dừng các phần bên ngoài. Thay vào đó, hãy sử dụng Py_BEGIN_CRITICAL_SECTION2 để khóa hai đối tượng cùng một lúc.

Lưu ý rằng các khóa được mô tả ở trên chỉ là các khóa dựa trên PyMutex. Việc triển khai phần quan trọng không biết hoặc ảnh hưởng đến các cơ chế khóa khác có thể đang được sử dụng, chẳng hạn như các mutex POSIX. Cũng lưu ý rằng khi chặn trên bất kỳ PyMutex nào sẽ khiến các phần quan trọng bị tạm dừng, chỉ các mutex là một phần của các phần quan trọng mới được giải phóng. Nếu PyMutex được sử dụng mà không có phần quan trọng, nó sẽ không được giải phóng và do đó không có được khả năng tránh bế tắc tương tự.

Những cân nhắc quan trọng

  • Các phần quan trọng có thể tạm thời giải phóng khóa của chúng, cho phép các luồng khác sửa đổi dữ liệu được bảo vệ. Hãy cẩn thận khi đưa ra các giả định về trạng thái của dữ liệu sau các hoạt động có thể bị chặn.

  • Bởi vì các khóa có thể được giải phóng tạm thời (bị treo), việc nhập một phần quan trọng không đảm bảo quyền truy cập độc quyền vào tài nguyên được bảo vệ trong suốt thời gian của phần đó. Nếu mã trong một phần quan trọng gọi một chức năng khác chặn (ví dụ: lấy một khóa khác, thực hiện chặn I/O), thì tất cả các khóa được giữ bởi luồng thông qua các phần quan trọng sẽ được giải phóng. Điều này tương tự như cách GIL có thể được giải phóng trong khi chặn cuộc gọi.

  • Chỉ (các) khóa được liên kết với phần quan trọng được nhập gần đây nhất (trên cùng) mới được đảm bảo giữ lại tại bất kỳ thời điểm nào. Khóa cho các phần quan trọng bên ngoài, lồng nhau có thể đã bị treo.

  • Bạn có thể khóa đồng thời tối đa hai đối tượng bằng các API này. Nếu cần khóa nhiều đối tượng hơn, bạn sẽ cần phải cơ cấu lại mã của mình.

  • Mặc dù các phần quan trọng sẽ không bị bế tắc nếu bạn cố gắng khóa cùng một đối tượng hai lần nhưng chúng sẽ kém hiệu quả hơn so với các khóa đăng nhập lại được xây dựng có mục đích cho trường hợp sử dụng này.

  • Khi sử dụng Py_BEGIN_CRITICAL_SECTION2, thứ tự của các đối tượng không ảnh hưởng đến tính chính xác (việc triển khai xử lý việc tránh bế tắc), nhưng cách tốt nhất là luôn khóa các đối tượng theo thứ tự nhất quán.

  • Hãy nhớ rằng macro phần quan trọng chủ yếu dùng để bảo vệ quyền truy cập vào Python objects có thể liên quan đến các hoạt động CPython nội bộ dễ gặp phải các tình huống bế tắc được mô tả ở trên. Để bảo vệ trạng thái mở rộng hoàn toàn bên trong, các mutex tiêu chuẩn hoặc các nguyên tắc đồng bộ hóa khác có thể phù hợp hơn.

Tiện ích mở rộng tòa nhà dành cho bản dựng có luồng miễn phí

Các tiện ích mở rộng C API cần được xây dựng riêng cho bản dựng luồng tự do. Các bánh xe, thư viện dùng chung và tệp nhị phân được biểu thị bằng hậu tố t.

  • pypa/manylinux hỗ trợ xây dựng theo luồng tự do, với hậu tố t, chẳng hạn như python3.13t.

  • pypa/cibuildwheel hỗ trợ xây dựng luồng tự do trên Python 3.13 và 3.14. Trên Python 3.14, các bánh xe có luồng tự do sẽ được tạo theo mặc định. Trên Python 3.13, bạn sẽ cần đặt CIBW_ENABLE to cpython-freethreading.

API giới hạn C và ABI ổn định

Bản dựng luồng tự do hiện không hỗ trợ Limited C API hoặc ABI ổn định. Nếu bạn sử dụng setuptools để xây dựng tiện ích mở rộng của mình và hiện đã đặt py_limited_api=True thì bạn có thể sử dụng py_limited_api=not sysconfig.get_config_var("Py_GIL_DISABLED") để chọn không tham gia API bị giới hạn khi xây dựng bằng bản dựng có luồng miễn phí.

Ghi chú

Bạn sẽ cần chế tạo các bánh xe riêng biệt dành riêng cho kiểu xây dựng có ren tự do. Nếu hiện đang sử dụng ABI ổn định, bạn có thể tiếp tục tạo một bánh xe duy nhất cho nhiều phiên bản Python không có luồng tự do.

cửa sổ

Do hạn chế của trình cài đặt Windows chính thức, bạn sẽ cần xác định Py_GIL_DISABLED=1 theo cách thủ công khi xây dựng tiện ích mở rộng từ nguồn.

Xem thêm

Porting Extension Modules to Support Free-Threading: Hướng dẫn chuyển do cộng đồng duy trì dành cho các tác giả tiện ích mở rộng.