برای سالها برخی معتقد بودند که «بهخاطر GIL، همروندی (concurrency) در پایتون واقعی نیست». اما پایتون از نسخهی ۳.۱۳ امکان غیرفعالکردن GIL را فراهم کرده و تا حد زیادی به این بحثها پایان داده است. در این بخش شما را با مفهوم GIL، دلیل وجودش و اینکه کِی برایتان مهم میشود آشنا میکنم.
GIL چیست؟
عبارت Global Interpreter Lock (بهاختصار GIL) مکانیزمی است که پایتون با آن مانع از اجرای موازی (parallel) تردها میشود. بهعبارت دیگر، در هر لحظه فقط به یکی از تردهای یک پردازه (process) اجازهی اجرا میدهد. میتوانید GIL را مثل یک «میکروفون» در یک جلسه تصور کنید: افراد زیادی (تردها) حاضرند، اما در هر لحظه فقط کسی که میکروفون دستش است میتواند صحبت کند و بقیه باید منتظر بمانند تا نوبت به آنها برسد.
حالا شاید بپرسید: اگر در حالت چندتردی هم در هر لحظه فقط یک ترد اجرا میشود، پس چه فرقی با تکتردی دارد؟ مگر در تکتردی هم در هر لحظه یک ترد اجرا نمیشود؟
فرقِ چندتردی با تکتردی چیست؟ (جابهجایی سریع بین تردها)
پاسخ در «جابهجایی» نهفته است. حتی با وجود GIL، پایتون میتواند بهسرعت بین تردها سوییچ کند؛ بهویژه وقتی یک ترد منتظر چیزی است (مثل دانلود از شبکه)، پایتون میکروفون را به ترد دیگری میدهد. همین جابهجاییِ سریع، ارزش افزوده ایجاد میکند.
فرض کنید میخواهید یک Download Manager بنویسید که همزمان با دانلود، درصد پیشرفت را هم نمایش دهد. این برنامه دو کار دارد که باید همروند اجرا شوند:
- ترد اول: دانلود بخشی از فایل
- ترد دوم: بهروزرسانی درصد پیشرفت
- ترد اول: دانلود بخش بعدی فایل
- ترد دوم: بهروزرسانی دوبارهی درصد پیشرفت
ساخت این برنامه بهصورت تکتردی دشوار است، چون برای نمایش درصد پیشرفت باید مدام از دانلود «دست بکشیم» و برگردیم. در حالت چندتردی همین جابهجایی خودکار بین تردها کار را طبیعی و روان میکند. نمونهی سادهی این ایده:
import time
from threading import Thread
progress = 0
def download():
global progress
for chunk in range(1, 6):
time.sleep(0.5) # simulate downloading one chunk (I/O)
progress = chunk * 20
def show_progress():
while progress < 100:
print(f"progress: {progress}%")
time.sleep(0.3)
print("progress: 100% - done")
t1 = Thread(target=download)
t2 = Thread(target=show_progress)
t1.start()
t2.start()
t1.join()
t2.join()آیا پایتون واقعاً چندتردی نبود؟ و چرا GIL ساخته شد؟
برخی میگفتند با وجود GIL، پایتون واقعاً چندتردی نیست؛ چون چندتردیِ واقعی یعنی چند ترد در «یک لحظه» و بهصورت موازی اجرا شوند. شاید بپرسید چرا سازندگان پایتون از ابتدا آن را بدون GIL نساختند تا این محدودیت وجود نداشته باشد؟
پاسخ در یک کلمه است: سادگی. یکی از دلایلی که پایتون را به یکی از پرکاربردترین زبانها تبدیل کرده، سادگی آن است. GIL مدیریت حافظه و تردها را در داخل مفسر بسیار سادهتر میکند و از دستهای از باگهای پیچیدهی همزمانی جلوگیری میکند. سازندگان پایتون برای پرهیز از این پیچیدگی GIL را طراحی کردند، اما برخی بدون توجه به این زمینه، صرفاً آن را بهعنوان یک «محدودیت» پایتون معرفی کردند.
امروزه GIL همچنان در پایتون وجود دارد و بهصورت پیشفرض فعال است، اما از نسخهی ۳.۱۳ امکان غیرفعالکردن آن (در نسخهی موسوم به free-threaded) فراهم شده است. توجه کنید که غیرفعالکردن GIL بسیار خطرناک است و توصیه نمیشود، چون در آن حالت مدیریت تردها و جلوگیری از تداخل کاملاً بر عهدهی خود برنامهنویس است و به دانش عمیقی در برنامهنویسی چندتردی نیاز دارد.
با وجود GIL چرا هنوز قفلگذاری لازم است؟
یک تصور اشتباه رایج این است که «چون GIL هست، پس دیگر نیازی به قفل نداریم». اما اینطور نیست. عملیات سادهای مثل n = n + 1 در پایتون در سه مرحله اجرا میشود:
- بارگذاری: مقدار فعلی
nاز حافظه خوانده میشود. - افزودن: یک واحد به مقدار خواندهشده اضافه میشود.
- ذخیره: مقدار جدید در
nنوشته میشود.
هیچ تضمینی نیست که این سه مرحله پشتسرهم اجرا شوند. ممکن است پایتون پس از مرحلهی دوم، میکروفون را به ترد دیگری بدهد که همان n را دستکاری کند؛ در نتیجه منطق برنامه خراب میشود و مقدار نهایی n با چیزی که انتظار داریم فرق میکند. یعنی GIL از «اجرای موازی» جلوگیری میکند، اما از «جابهجایی وسطِ یک عملیات چندمرحلهای» جلوگیری نمیکند.
from threading import Thread, Lock
n = 0
lock = Lock()
def add():
global n
for _ in range(100000):
with lock: # make the 3 steps atomic (all-or-nothing)
n = n + 1
def subtract():
global n
for _ in range(100000):
with lock:
n = n - 1
t1 = Thread(target=add)
t2 = Thread(target=subtract)
t1.start(); t2.start()
t1.join(); t2.join()
print(n) # reliably 0 thanks to the lockبا قفلکردن این سه مرحله، آنها را بهصورت اتمیک (هر سه با هم) اجرا میکنیم و مطمئن میشویم که پایتون وسط کار بین تردها جابهجا نمیشود. (جزئیات قفلگذاری را در درس کلاس Lock و RLock و مفهوم رقابت را در شرایط رقابتی توضیح دادهام.)
GIL کِی برایتان مهم میشود؟ (I/O-bound در برابر CPU-bound)
مهمترین درسِ عملیِ GIL این است که بدانید نوع کارتان چیست:
- کارهای I/O-bound (دانلود، درخواست شبکه، خواندن/نوشتن فایل، کوئری دیتابیس): بیشترِ وقتْ صرف «انتظار» میشود. اینجا GIL تقریباً مشکلی ایجاد نمیکند، چون ترد در حال انتظار میکروفون را آزاد میکند و ترد دیگر کار میکند. پس
threadingواقعاً سرعت را بالا میبرد. - کارهای CPU-bound (محاسبات سنگین، پردازش تصویر، رمزنگاری): پردازنده تماموقت درگیر است. اینجا GIL مانع میشود و چند ترد سریعتر از یک ترد نمیشوند؛ برای موازیسازی واقعی باید از
multiprocessingاستفاده کنید که هر پردازه GIL مستقل خودش را دارد.
مثال – کار I/O-bound (threading سود میدهد)
import time
from threading import Thread
def fetch(url):
time.sleep(2) # mostly waiting for the network (I/O)
urls = ['a', 'b', 'c', 'd']
start = time.time()
threads = [Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()
print("I/O-bound with threads: %.1fs" % (time.time() - start)) # ~2s, not ~8sبا اینکه GIL هست، چون هر ترد بیشترِ وقت منتظر شبکه است، چهار دانلود عملاً همزمان پیش میروند و کل کار بهجای ۸ ثانیه حدود ۲ ثانیه طول میکشد.
مثال – کار CPU-bound (threading سود نمیدهد؛ multiprocessing لازم است)
import time
from threading import Thread
def heavy():
s = 0
for i in range(20_000_000): # pure CPU work
s += i
# with threads the GIL prevents real parallelism -> no speedup
start = time.time()
t1 = Thread(target=heavy); t2 = Thread(target=heavy)
t1.start(); t2.start(); t1.join(); t2.join()
print("CPU-bound with threads: %.2fs" % (time.time() - start))برای این نوع کار، تردها کمکی نمیکنند. راهحل، استفاده از پردازهها بهجای تردهاست تا هر پردازه روی یک هستهی جداگانه و با GIL مستقل خودش اجرا شود:
import time
from multiprocessing import Process
def heavy():
s = 0
for i in range(20_000_000):
s += i
if __name__ == '__main__':
start = time.time()
p1 = Process(target=heavy); p2 = Process(target=heavy)
p1.start(); p2.start(); p1.join(); p2.join()
print("CPU-bound with processes: %.2fs" % (time.time() - start)) # real parallelismخلاصه
- GIL باعث میشود در هر لحظه فقط یک ترد از یک پردازه اجرا شود؛ اما پایتون بهسرعت بین تردها جابهجا میشود و همین برای کارهای I/O ارزشمند است.
- GIL برای «سادگی» و مدیریت آسانترِ حافظه در مفسر ساخته شد. از پایتون ۳.۱۳ میتوان آن را غیرفعال کرد، اما این کار خطرناک است و توصیه نمیشود.
- وجود GIL نیاز به قفلگذاری را از بین نمیبرد، چون عملیات چندمرحلهای مثل
n = n + 1همچنان میتواند وسط کار قطع شود. - برای کارهای I/O-bound از
threadingو برای کارهای CPU-bound ازmultiprocessingاستفاده کنید.