منابع ترجمه · Python Built-ins · فصل ۵

تضمین‌های امنیت نخ (مرجع درون‌ساخت‌های پایتون)

۱۳ دقیقه مطالعه · به‌روزرسانی ۳ آبان ۱۴۰۵

منبع اصلی: Python Built-ins, Chapter 5: Thread Safety Guarantees — Python Software Foundation

💡 این صفحه مثل یک نوت‌بوک Jupyter است: متغیر و تابعی که در یک سلول تعریف شود، در سلول‌های بعدی همین صفحه در دسترس است. سلول‌ها را به ترتیب اجرا کنید، یا با «⏮ تا اینجا» همهٔ سلول‌های قبلیِ اجرانشده را یک‌جا اجرا کنید.

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

برای راهنمایی کلی دربارهٔ نوشتن کد امن‌برای‌نخ در پایتون free-threaded، پایتون free-threaded را ببینید.

سطوح امنیت نخ

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

ناسازگار (Incompatible)

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

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

سازگار (Compatible)

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

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

امن روی اشیای متمایز (Safe on distinct objects)

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

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

امن روی اشیای مشترک (Safe on shared objects)

تابع یا عملیاتی که برای استفادهٔ همروند روی همان شیء امن است. پیاده‌سازی از همگام‌سازی داخلی (مانند قفل‌های هر-شیء (per-object locks) یا بخش‌های بحرانی (critical sections)) برای محافظت از حالت تغییرپذیر مشترک استفاده می‌کند، بنابراین فراخوانندگان لازم نیست قفل‌گذاری خودشان را بدهند.

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

اتمی (Atomic)

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

مثال: PyMutex_IsLocked() یک خواندن اتمی از حالت mutex انجام می‌دهد و می‌توان آن را در هر زمان از هر نخی فراخوانی کرد.

امنیت نخ برای اشیای list

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

lst[i]   # list.__getitem__

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

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

همهٔ عملیات بالا از گرفتن قفل‌های هر-شیء اجتناب می‌کنند. تغییرات همروند را مسدود نمی‌کنند. عملیات دیگری که قفلی را نگه می‌دارند مانع از مشاهدهٔ حالت‌های میانی توسط این‌ها نمی‌شوند.

همهٔ عملیات دیگر از اینجا به بعد با استفاده از قفل هر-شیء مسدود می‌شوند.

نوشتن یک عنصر واحد از طریق 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

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

متد list.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

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

list.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 وقتی آرگومانش 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__

همهٔ عملیات دیگر از اینجا به بعد قفل هر-شیء را نگه می‌دارند.

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

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 پیاده‌سازی می‌کنند، قفل زیربنایی در طول مقایسه‌ها آزاد نمی‌شود و این نگرانی وجود ندارد.

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

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

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

عملیات زیر هر دو دیکشنری را قفل می‌کنند. برای dict.update() و |=، این فقط وقتی اعمال می‌شود که عملوند دیگر dictی باشد که از تکرارگر استاندارد 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__() مقایسه می‌کنند، بنابراین برای نوع‌های غیردرون‌ساخت ممکن است قفل در طول مقایسه آزاد شود.

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

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

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

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 بین نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید.

امنیت نخ برای اشیای set

تابع 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__() مثل بالا اعمال می‌شود.

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

متد set.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.update() و set.union() فقط وقتی هر دو شیء را قفل می‌کنند که عملوند دیگر set، frozenset یا dict باشد.
  • set.intersection() و set.difference() همیشه می‌کوشند همهٔ اشیا را قفل کنند.

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

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

  • set.difference_update() و set.intersection_update() می‌کوشند همهٔ اشیا را یکی‌یکی قفل کنند.
  • set.symmetric_difference_update() آرگومان‌ها را فقط در صورتی قفل می‌کند که از نوع set، frozenset یا dict باشد.

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

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 بین نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید. برای اطلاعات بیشتر پایتون free-threaded را ببینید.

امنیت نخ برای اشیای 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  # both locked

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

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

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

x in ba       # bytearray.__contains__

همهٔ متدهای دیگر bytearray (مانند bytearray.find()، bytearray.replace()، bytearray.split()، bytearray.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 بین نخ‌ها، همگام‌سازی خارجی را در نظر بگیرید. برای اطلاعات بیشتر پایتون free-threaded را ببینید.

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

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

پیاده‌سازی memoryview از عملیات اتمی برای ردیابی صادرات (exports) خودش در build آزاد از نخ استفاده می‌کند. ساختن و آزاد کردن (release) یک memoryview امن برای نخ است. دسترسی به ویژگی‌ها (مثلاً shape، format) فیلدهایی را می‌خواند که در طول عمر memoryview تغییرناپذیرند، بنابراین خواندن‌های همروند تا زمانی که memoryview آزاد نشده باشد امن‌اند.

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

  • برای اشیای تغییرناپذیر مانند bytes، خواندن‌های همروند از طریق چند memoryview امن است.
  • برای اشیای تغییرپذیر مانند bytearray، خواندن و نوشتن همان ناحیهٔ حافظه از چند نخ بدون همگام‌سازی خارجی امن نیست و ممکن است به خرابی داده بیانجامد. توجه کنید که حتی memoryviewهای فقط‌خواندنی از اشیای تغییرپذیر اگر شیء زیربنایی از نخ دیگری تغییر کند مانع data race نمی‌شوند.
# 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 پرتاب می‌کند. این بدون توجه به نخ‌دهی اعمال می‌شود.