تضمین‌های ایمنی نخ

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

برای راهنمایی کلی درباره نوشتن کد نخ‌ایمن (thread-safe) در پایتون نخ‌آزاد، به پشتیبانی پایتون از نخ‌بندی آزاد مراجعه کنید.

سطوح ایمنی نخ

مستندات C API از سطوح زیر برای توصیف تضمین‌های ایمنی نخی هر تابع استفاده می‌کند. این سطوح از کم‌ایمن‌ترین تا ایمن‌ترین فهرست شده‌اند.

ناسازگار

تابع یا عملیاتی که حتی با همگام‌سازی خارجی نیز نمی‌توان آن را برای استفاده همزمان ایمن کرد. کد ناسازگار معمولاً به وضعیت سراسری به‌صورت همگام‌نشده دسترسی پیدا می‌کند و باید در طول عمر برنامه تنها از یک نخ فراخوانی شود.

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

سازگار

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

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

ایمن برای اشیاء متمایز

تابع یا عملیاتی که فراخوانی آن از چند نخ بدون همگام‌سازی خارجی امن است، مشروط بر اینکه هر نخ روی یک شیء متفاوت عمل کند. دو نخ می‌توانند تابع را همزمان فراخوانی کنند، اما نباید شیء یکسان (یا اشیایی که وضعیت زیربنایی مشترکی دارند) را به‌عنوان آرگومان ارسال کنند.

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

ایمن روی اشیاء مشترک

تابع یا عملیاتی که برای استفاده همزمان روی همان شیء ایمن است. پیاده‌سازی از همگام‌سازی داخلی (مانند قفل‌های به‌ازای هر شیء یا بخش‌های بحرانی) برای محافظت از وضعیت تغییرپذیر مشترک استفاده می‌کند، بنابراین فراخوان‌کنندگان نیازی به فراهم کردن قفل‌گذاری خودشان ندارند.

مثال: می‌توان PyList_GetItemRef() را از چندین نخ روی همان PyListObject فراخوانی کرد - این تابع از همگام‌سازی داخلی برای متوالی‌کردن دسترسی استفاده می‌کند.

اتمی

تابع یا عملیاتی که نسبت به سایر نخ‌ها به‌صورت اتمی به نظر می‌رسد؛ از دید سایر نخ‌ها به‌صورت آنی اجرا می‌شود. این قوی‌ترین شکل ایمنی نخی است.

مثال: PyMutex_IsLocked() یک خواندن اتمی از وضعیت قفل متقابل (mutex) انجام می‌دهد و می‌تواند از هر نخی در هر زمانی فراخوانی شود.

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

خواندن یک عنصر از یک list اتمی است:

lst[i]   # list.__getitem__

متدهای زیر فهرست را پیمایش می‌کنند و از خواندن‌های اتمی هر آیتم برای انجام وظیفه خود استفاده می‌کنند. این بدان معناست که ممکن است نتایجی برگردانند که تحت تأثیر تغییرات همزمان قرار گرفته‌اند:

item in lst
lst.index(item)
lst.count(item)

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

از این پس، همه‌ی عملیات‌های دیگر با استفاده از per-object lock مسدود می‌شوند.

نوشتن یک آیتم واحد از طریق lst[i] = x برای فراخوانی از چندین نخ ایمن است و فهرست را خراب نمی‌کند.

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

lst1 + lst2    # concatenates two lists into a new list
x * lst        # repeats lst x times into a new list
lst.copy()     # returns a shallow copy of the list

متدهای زیر که تنها روی یک المان عمل می‌کنند و نیازی به جابه‌جایی ندارند، اتمی هستند:

lst.append(x)  # append to the end of the list, no shifting required
lst.pop()      # pop element from the end of the list, no shifting required

متد clear() نیز اتمی است. سایر نخ‌ها نمی‌توانند حذف شدن عناصر را مشاهده کنند.

متد sort() اتمی نیست. سایر نخ‌ها نمی‌توانند وضعیت‌های میانی را در حین مرتب‌سازی مشاهده کنند، اما فهرست در طول مدت مرتب‌سازی خالی به نظر می‌رسد.

عملیات‌های زیر ممکن است به عملیات lock-free اجازه دهند که وضعیت‌های میانی را مشاهده کنند، زیرا چندین المان را به‌صورت درجا تغییر می‌دهند:

lst.insert(idx, item)  # shifts elements
lst.pop(idx)           # idx not at the end of the list, shifts elements
lst *= x               # copies elements in place

متد remove() ممکن است اجازه‌ی تغییرات همزمان را بدهد، زیرا مقایسه‌ی عنصر ممکن است کد پایتون دلخواه را اجرا کند (از طریق __eq__()).

فراخوانی extend() از چندین نخ ایمن است. با این حال، تضمین‌های آن به پیمایش‌پذیری که به آن داده می‌شود بستگی دارد. اگر این پیمایش‌پذیر یک list، tuple، set، frozenset، dict یا شیء نمای دیکشنری باشد (اما نه زیرکلاس‌های آن‌ها)، عملیات extend در برابر تغییرات همزمان روی پیمایش‌پذیر ایمن است. در غیر این صورت، یک پیمایش‌گر ایجاد می‌شود که ممکن است توسط نخ دیگری به‌طور همزمان تغییر داده شود. همین موضوع برای الحاق درجای یک فهرست با سایر پیمایش‌پذیرها هنگام استفاده از lst += iterable نیز صدق می‌کند.

به‌طور مشابه، انتساب به یک اسلایس فهرست با lst[i:j] = iterable برای فراخوانی از چندین نخ امن است، اما iterable تنها زمانی قفل می‌شود که خود نیز یک list باشد (اما نه زیرکلاس‌های آن).

عملیات‌هایی که شامل چندین دسترسی و همچنین تکرار هستند، هرگز اتمی نیستند. برای مثال:

# NOT atomic: read-modify-write
lst[i] = lst[i] + 1

# NOT atomic: check-then-act
if lst:
    item = lst.pop()

# NOT thread-safe: iteration while modifying
for item in lst:
    process(item)  # another thread may modify lst

هنگام اشتراک‌گذاری نمونه‌های list میان نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید.

ایمنی نخی برای اشیاء دیکشنری

ایجاد یک دیکشنری با سازنده‌ی dict زمانی اتمی است که آرگومان آن یک dict یا یک tuple باشد. هنگام استفاده از متد dict.fromkeys()، ایجاد دیکشنری زمانی اتمی است که آرگومان یک dict، tuple، set یا frozenset باشد.

عملیات و توابع زیر بدون قفل و اتمی هستند.

d[key]       # dict.__getitem__
d.get(key)   # dict.get
key in d     # dict.__contains__
len(d)       # dict.__len__

تمام عملیات‌های دیگر از اینجا به بعد، per-object lock را در اختیار دارند.

نوشتن یا حذف یک آیتم واحد برای فراخوانی از چندین نخ ایمن است و دیکشنری را خراب نمی‌کند:

d[key] = value        # write
del d[key]            # delete
d.pop(key)            # remove and return
d.popitem()           # remove and return last item
d.setdefault(key, v)  # insert if missing

این عملیات‌ها ممکن است کلیدها را با استفاده از __eq__() مقایسه کنند، که می‌تواند کد پایتون دلخواه را اجرا کند. در حین چنین مقایسه‌هایی، ممکن است دیکشنری توسط نخ دیگری تغییر داده شود. برای انواع توکار مانند str، int و float، که __eq__() را در C پیاده‌سازی می‌کنند، قفل زیربنایی در حین مقایسه‌ها آزاد نمی‌شود و این موضوع جای نگرانی نیست.

عملیات‌های زیر شیءهای جدید را برمی‌گردانند و در طول مدت عملیات، per-object lock را نگه می‌دارند:

d.copy()      # returns a shallow copy of the dictionary
d | other     # merges two dicts into a new dict
d.keys()      # returns a new dict_keys view object
d.values()    # returns a new dict_values view object
d.items()     # returns a new dict_items view object

متد clear() قفل را در طول مدت اجرای خود نگه می‌دارد. نخ‌های دیگر نمی‌توانند حذف عناصر را مشاهده کنند.

عملیات زیر هر دو دیکشنری را قفل می‌کنند. برای update() و |=، این موضوع فقط زمانی صدق می‌کند که عملوند دیگر یک dict باشد که از پیمایش‌گر استاندارد دیکشنری استفاده می‌کند (اما نه زیرکلاس‌هایی که پیمایش را بازنویسی می‌کنند). در مقایسه برابری، این موضوع برای dict و زیرکلاس‌های آن صدق می‌کند:

d.update(other_dict)  # both locked when other_dict is a dict
d |= other_dict       # both locked when other_dict is a dict
d == other_dict       # both locked for dict and subclasses

تمام عملیات‌های مقایسه نیز مقادیر را با استفاده از __eq__() مقایسه می‌کنند، بنابراین برای انواع غیرتوکار ممکن است قفل در حین مقایسه آزاد شود.

fromkeys() هر دو دیکشنری جدید و پیمایش‌پذیر را زمانی که پیمایش‌پذیر دقیقاً یک dict، set یا frozenset باشد (نه زیرکلاس‌ها) قفل می‌کند:

dict.fromkeys(a_dict)      # locks both
dict.fromkeys(a_set)       # locks both
dict.fromkeys(a_frozenset) # locks both

هنگام به‌روزرسانی از یک پیمایش‌پذیر غیر از دیکشنری، فقط دیکشنری هدف قفل می‌شود. ممکن است پیمایش‌پذیر به‌طور همزمان توسط نخ دیگری تغییر داده شود:

d.update(iterable)        # iterable is not a dict: only d locked
d |= iterable             # iterable is not a dict: only d locked
dict.fromkeys(iterable)   # iterable is not a dict/set/frozenset: only result locked

عملیات‌هایی که شامل چندین دسترسی هستند، و همچنین پیمایش، هرگز اتمی نیستند:

# NOT atomic: read-modify-write
d[key] = d[key] + 1

# NOT atomic: check-then-act (TOCTOU)
if key in d:
    del d[key]

# NOT thread-safe: iteration while modifying
for key, value in d.items():
    process(key)  # another thread may modify d

برای جلوگیری از مشکلات زمان بررسی تا زمان استفاده (TOCTOU)، از عملیات اتمی استفاده کنید یا استثناها را مدیریت کنید:

# Use pop() with default instead of check-then-delete
d.pop(key, None)

# Or handle the exception
try:
    del d[key]
except KeyError:
    pass

برای پیمایش ایمن روی دیکشنری‌ای که ممکن است توسط نخ دیگری تغییر داده شود، روی یک نسخه از آن پیمایش کنید:

# Make a copy to iterate safely
for key, value in d.copy().items():
    process(key)

هنگام اشتراک‌گذاری نمونه‌های dict میان نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید.

ایمنی نخی برای اشیای مجموعه

تابع len() بدون قفل و اتمی است.

عملیات خواندن زیر بدون قفل است. این عملیات تغییرات همزمان را مسدود نمی‌کند و ممکن است وضعیت‌های میانیِ عملیاتی را که قفل به‌ازای هر شیء را در اختیار دارند، مشاهده کند:

elem in s    # set.__contains__

این عملیات ممکن است عناصر را با استفاده از __eq__()، که می‌تواند کد پایتون دلخواه را اجرا کند، مقایسه کند. در طول چنین مقایسه‌هایی، ممکن است مجموعه توسط نخ دیگری تغییر کند. برای انواع توکار مانند str، int و float، __eq__() قفل زیرین را در طول مقایسه‌ها آزاد نمی‌کند و این جای نگرانی نیست.

از این نقطه به بعد، تمام عملیات‌های دیگر قفل به‌ازای هر شیء را نگه می‌دارند.

افزودن یا حذف یک المان منفرد را می‌توان به‌صورت امن از چند نخ فراخوانی کرد و مجموعه خراب نمی‌شود:

s.add(elem)      # add element
s.remove(elem)   # remove element, raise if missing
s.discard(elem)  # remove element if present
s.pop()          # remove and return arbitrary element

این عملیات‌ها همچنین عناصر را مقایسه می‌کنند، بنابراین همان ملاحظات __eq__() بالا اعمال می‌شوند.

متد copy() یک شیء جدید بازمی‌گرداند و قفل به‌ازای هر شیء را در طول این مدت نگه می‌دارد تا همیشه اتمی باشد.

متد clear() قفل را در طول مدت اجرای خود نگه می‌دارد. سایر نخ‌ها نمی‌توانند عناصر در حال حذف را مشاهده کنند.

عملیات‌های زیر فقط set یا frozenset را به‌عنوان عملوند می‌پذیرند و همیشه هر دو شیء را قفل می‌کنند:

s |= other                   # other must be set/frozenset
s &= other                   # other must be set/frozenset
s -= other                   # other must be set/frozenset
s ^= other                   # other must be set/frozenset
s & other                    # other must be set/frozenset
s | other                    # other must be set/frozenset
s - other                    # other must be set/frozenset
s ^ other                    # other must be set/frozenset

set.update()، set.union()، set.intersection() و set.difference() می‌توانند چندین پیمایش‌پذیر را به‌عنوان آرگومان بگیرند. همه‌ی آن‌ها تمام پیمایش‌پذیرهای داده‌شده را پیمایش می‌کنند و کار زیر را انجام می‌دهند:

set.symmetric_difference() تلاش می‌کند هر دو شیء را قفل کند.

گونه‌های به‌روزرسانی متدهای بالا نیز تفاوت‌هایی با یکدیگر دارند:

متدهای زیر همیشه تلاش می‌کنند هر دو شیء را قفل کنند:

s.isdisjoint(other)          # both locked
s.issubset(other)            # both locked
s.issuperset(other)          # both locked

عملیات‌هایی که شامل چندین دسترسی هستند، و همچنین پیمایش، هرگز اتمی نیستند:

# NOT atomic: check-then-act
if elem in s:
      s.remove(elem)

# NOT thread-safe: iteration while modifying
for elem in s:
      process(elem)  # another thread may modify s

هنگام اشتراک‌گذاری نمونه‌های set بین نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید. برای اطلاعات بیشتر پشتیبانی پایتون از نخ‌بندی آزاد را ببینید.

ایمنی نخی برای اشیاء bytearray

تابع len() بدون قفل و اتمی است.

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

ba + other    # may observe concurrent writes
ba == other   # may observe concurrent writes
ba < other    # may observe concurrent writes

از این نقطه به بعد، تمام عملیات‌های دیگر قفل به‌ازای هر شیء را نگه می‌دارند.

خواندن یک عنصر یا اسلایس برای فراخوانی از چندین نخ امن است:

ba[i]        # bytearray.__getitem__
ba[i:j]      # slice

فراخوانی عملیات زیر از چندین نخ ایمن است و bytearray را خراب نمی‌کند:

ba[i] = x         # write single byte
ba[i:j] = values  # write slice
ba.append(x)      # append single byte
ba.extend(other)  # extend with iterable
ba.insert(i, x)   # insert single byte
ba.pop()          # remove and return last byte
ba.pop(i)         # remove and return byte at index
ba.remove(x)      # remove first occurrence
ba.reverse()      # reverse in place
ba.clear()        # remove all bytes

انتساب اسلایسی هنگامی که values یک bytearray باشد، هر دو شیء را قفل می‌کند:

ba[i:j] = other_bytearray  # هر دو قفل هستند

عملیات زیر اشیای جدید را برمی‌گردانند و قفل به‌ازای هر شیء را در طول مدت آن نگه می‌دارند:

ba.copy()     # returns a shallow copy
ba * n        # repeat into new bytearray

آزمون عضویت، قفل را در طول مدت خود نگه می‌دارد:

x in ba       # bytearray.__contains__

تمام متدهای دیگر bytearray (مانند find()، replace()، split()، decode() و غیره) در طول اجرای خود، قفل به‌ازای هر شیء را نگه می‌دارند.

عملیات‌هایی که شامل چندین دسترسی هستند، و همچنین پیمایش، هرگز اتمی نیستند:

# NOT atomic: check-then-act
if x in ba:
    ba.remove(x)

# NOT thread-safe: iteration while modifying
for byte in ba:
    process(byte)  # another thread may modify ba

برای پیمایش امن روی bytearray که ممکن است توسط نخ دیگری تغییر کند، روی یک کپی پیمایش کنید:

# Make a copy to iterate safely
for byte in ba.copy():
    process(byte)

هنگام اشتراک‌گذاری نمونه‌های bytearray بین نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید. برای اطلاعات بیشتر پشتیبانی پایتون از نخ‌بندی آزاد را ببینید.

ایمنی نخ برای اشیای memoryview

اشیای memoryview دسترسی به داده‌های داخلی یک شیء زیربنایی را بدون کپی کردن فراهم می‌کنند. ایمنی نخی هم به خود memoryview و هم به اکسپورتکننده‌ی بافر زیربنایی بستگی دارد.

پیاده‌سازی memoryview از عملیات اتمی برای پیگیری اکسپورت‌های خود در free-threaded build استفاده می‌کند. ایجاد و آزادسازی یک memoryview نخ‌ایمن (thread-safe) هستند. دسترسی به ویژگی‌ها (برای مثال، shape، format) فیلدهایی را می‌خواند که در طول عمر memoryview تغییرناپذیر هستند، بنابراین خواندن‌های همزمان تا زمانی که memoryview آزاد نشده باشد، ایمن هستند.

با این حال، داده‌ی واقعی که از طریق memoryview به آن دسترسی دارید، متعلق به شیء زیربنایی است. دسترسی همزمان به این داده تنها در صورتی امن است که شیء زیربنایی از آن پشتیبانی کند:

  • برای اشیاء تغییرناپذیر مانند bytes، خواندن‌های همزمان از طریق چندین نمای حافظه (memoryview) ایمن هستند.

  • برای اشیاء تغییرپذیر مانند bytearray، خواندن و نوشتن یک ناحیه‌ی حافظه‌ی یکسان توسط چند نخ بدون همگام‌سازی خارجی ایمن نیست و ممکن است به خرابی داده منجر شود. توجه داشته باشید که حتی memoryviewهای فقط‌خواندنی از اشیاء تغییرپذیر نیز در صورتی که شیء زیرین توسط نخ دیگری تغییر کند، از رقابت‌های داده‌ای جلوگیری نمی‌کنند.

# NOT safe: concurrent writes to the same buffer
data = bytearray(1000)
view = memoryview(data)
# Thread 1: view[0:500] = b'x' * 500
# Thread 2: view[0:500] = b'y' * 500
# Safe: use a lock for concurrent access
import threading
lock = threading.Lock()
data = bytearray(1000)
view = memoryview(data)

with lock:
    view[0:500] = b'x' * 500

تغییر اندازه یا تخصیص مجدد شیء زیرین (مانند فراخوانی bytearray.resize()) در حالی که یک memoryview اکسپورت شده باشد، BufferError را پرتاب می‌کند. این امر صرف‌نظر از نخ‌بندی اعمال می‌شود.