این کد را اجرا کنید و حدس بزنید چه چاپ میشود. یک dict معمولی میسازیم، پنج پردازش راه میاندازیم و هرکدام یک کلید به آن اضافه میکند:
import multiprocessing
def add_item(data, worker_id):
data[worker_id] = worker_id * worker_id # writes into the child's OWN copy
if __name__ == '__main__':
data = {} # a plain dict
procs = [multiprocessing.Process(target=add_item, args=(data, i)) for i in range(5)]
for p in procs: p.start()
for p in procs: p.join()
print(data) # prints: {}
خروجی یک دیکشنریِ خالی است: {}. همهی تغییرات ناپدید شدند! چرا؟ چون برخلافِ تردها که حافظه را با هم شریکند، هر پردازش فضای حافظهٔ جدا و مستقل خودش را دارد. وقتی پردازشِ فرزند ساخته میشود، یک نسخهٔ جداگانه از data دریافت میکند؛ پس هر پردازش فقط نسخهٔ خودش را تغییر میدهد و پردازشِ اصلی چیزی نمیبیند.
راهحل: اشتراکگذاری داده با Manager و حافظه مشترک
برای حلِ این مشکل، ماژول multiprocessing ابزاری به نام Manager دارد. Manager یک پردازشِ جداگانه راه میاندازد که دادهٔ مشترک را در خود نگه میدارد؛ بقیهٔ پردازشها برای خواندن یا تغییرِ آن، در پسزمینه با همین پردازش پیام رد و بدل میکنند (یعنی یک proxy). پس هر تغییری که یک پردازش اعمال کند، بقیه هم میبینند.
def add_item(data, worker_id):
data[worker_id] = worker_id * worker_id # goes through the manager proxy
if __name__ == '__main__':
manager = multiprocessing.Manager()
data = manager.dict() # a SHARED dict, not a plain one
procs = [multiprocessing.Process(target=add_item, args=(data, i)) for i in range(5)]
for p in procs: p.start()
for p in procs: p.join()
print(dict(data)) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
فقط یک خط عوض شد — manager.dict() بهجای {} — و حالا هر پنج پردازش روی همان دادهٔ واقعی کار میکنند. از همین شیء manager میتوانید انواعِ دیگری هم بسازید: list، dict، Value، Array، و حتی Lock.
مثال واقعی: شمارندهٔ مشترک و دامِ race condition
فرض کنید چند پردازش، تعدادِ درخواستهای پردازششده را در یک شمارندهٔ مشترک جمع میزنند. درست مثلِ درسِ threading، «مشترک بودن» به تنهایی امن نیست:
def increment(shared, lock, worker_id):
with lock: # make read-modify-write atomic
shared['counter'] += 1 # unsafe without the lock
if __name__ == '__main__':
manager = multiprocessing.Manager()
shared = manager.dict()
shared['counter'] = 0
lock = manager.Lock() # a lock the children can share too
procs = [multiprocessing.Process(target=increment, args=(shared, lock, i))
for i in range(50)]
for p in procs: p.start()
for p in procs: p.join()
print(shared['counter']) # reliably 50
چرا Lock لازم است؟ چون shared['counter'] += 1 یک عملِ سهمرحلهای است: خواندن، افزایش، نوشتن. اگر دو پردازش همزمان مقدار را بخوانند، هر دو روی عددِ قدیمی کار میکنند و یک افزایش گم میشود — همان race conditionی که اول دوره دیدیم. Manager داده را «مشترک» میکند، اما «اتمی بودن» را نه؛ آن را Lock فراهم میکند.
گزینهٔ سریعتر: Value و Array
برای دادهٔ عددی و ساده، راهِ سبکتری هم هست که نیازی به پردازشِ جداگانهٔ Manager ندارد: Value و Array. این دو مستقیماً روی حافظهٔ مشترکِ خام (shared memory) کار میکنند و معمولاً سریعتر از Manager هستند، اما فقط انواعِ ساده را نگه میدارند.
counter = multiprocessing.Value('i', 0) # 'i' = signed int, initial value 0
with counter.get_lock(): # Value has its own built-in lock
counter.value += 1
arr = multiprocessing.Array('i', [0, 0, 0]) # a shared array of 3 ints
کاراکترِ 'i' نوعِ داده (عددِ صحیح) را مشخص میکند و مقدار از طریقِ .value خوانده و نوشته میشود. توجه کنید که Value یک قفلِ درونی هم دارد که با get_lock() در دسترس است.
کدام را کی انتخاب کنیم؟
Manager انعطافپذیر است و ساختارهای پیچیده (dict و list تودرتو) را پشتیبانی میکند، اما چون هر دسترسی یک پیامِ شبکهای بینِ پردازشهاست، کندتر است. Value/Array سریعترند ولی فقط برای اعداد و آرایههای ساده. دامِ رایج: فراموش کردنِ Lock. اشتراکگذاری خودبهخود عملیات را امن نمیکند، و نتیجهی فراموشی، اعدادِ اشتباهِ تصادفی است که دیباگِ آن دردسرساز است.
جمعبندی
- پردازشها حافظه را شریک نمیشوند؛ یک dict یا list معمولی بینِ پردازشها مشترک نیست و تغییرات گم میشوند.
- برای دادهٔ مشترکِ واقعی از
Manager(انعطافپذیر) یا برای اعدادِ ساده ازValue/Array(سریع) استفاده کنید. - اشتراکگذاری مساویِ ایمنی نیست؛ هر جا چند پردازش همزمان داده را تغییر میدهند، با Lock از race condition جلوگیری کنید.
- Value یک قفلِ درونی دارد (
get_lock())؛ برای Manager باید Lock را خودتان بسازید.