GIL لعنتی پایتون چیست؟

امیرحسین بیگدلو 4 ماه قبل

# Global Interpreter Lock در پایتون چیست؟

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

 

Global Interpreter Lock یا GIL، یک قفل هست که اجازه می دهد فقط یک thread کنترل مفسر پایتون رو به دست بگیره. تا اینجا که بد نبود :)

 

این یعنی که فقط یک thread می‌تونه در هر زمان در حالت اجرا باشه. تأثیر GIL برای توسعه دهندگانی که برنامه های تک thread را اجرا می کنند قابل مشاهده نیست و بیشتر زمانی مهم میشود که در حال برنامه نویسی multithread یا cpu-bound باشید.

 

از آنجایی که GIL اجازه می دهد تنها یک رشته در یک زمان اجرا شود، حتی در یک معماری چند رشته ای، به عنوان یک ویژگی "بدنام" پایتون شهرت پیدا کرده است.

 

در این مقاله خواهید آموخت که GIL چگونه بر عملکرد برنامه های پایتون شما تأثیر می گذارد و چگونه می توانید تأثیر آن را بر روی کد خود کاهش دهید.

 

دوره پیشنهادی: دوره آموزش Multi Threading در پایتون

 

# GIL در پایتون چه مشکلی را حل کرد؟

پایتون از reference counting برای مدیریت حافظه استفاده می کند. یعنی آبجکت‌هایی که در پایتون ایجاد میشوند دارای یک متغیر هستند که مشخص میکند در کد، چند بار به آن آبجکت اشاره شده است. هنگامی که این تعداد به صفر برسد، حافظه اشغال شده توسط شی آزاد می شود.

 

بیایید به یک مثال کد مختصر نگاه کنیم تا نحوه عملکرد reference counting را نشان دهیم:

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

 

در مثال بالا، ما یک لیست خالی داریم که در 3 بخش به آن اشاره شده. متغیر‌های a و b و یک بار هم به عنوان آرگومان به متد ارسال شده. پس تعداد مراجعه به لیست خالی برابر با 3 است.

 

برگردیم به GIL:

مشکل این بود که این متغیر reference count نیاز به محافظت از شرایط خاصی را دارد که در آن دو thread میخواهند به طور همزمان مقدار آن را تغییر دهند. مثلا دو thread بخواهند متغیر a را تغییر دهند. اگر این اتفاق بیفتد، باعث مشکلاتی عجیبی میشود مثلا مقدار نادرستی در متغیر a قرار میگیرد یا حافظه متغیر a خالی شود، در حالی که هنوز مراجعه‌ای به آن در کد وجود دارد.

 

این متغیر reference count را می توان با افزودن قفل به تمام ساختارهای داده که در threadها به اشتراک گذاشته شده اند ایمن نگه داشت تا به طور ناسازگار اصلاح نشوند.

 

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

 

GIL یک قفل برای خود مفسر پایتون است که یک قانون اضافه می کند که اجرای هر کد پایتون مستلزم دستیابی به قفل مفسر است. این از deadlock جلوگیری می کند (زیرا فقط یک قفل وجود دارد). اما این راه حل باعث میشود برنامه‌های چندرشته‌ای عملا تک‌رشته شوند.

 

جالب است بدانید که زبان‌های دیگری نیز مانند Ruby از Gil استفاده میکنند اما gil تنها راه حل این مشکل نیست. برخی از زبانها با استفاده از روشهای دیگری، مانند garbage collection استفاده میکنند.

 

مقاله پیشنهادی: چرا از rabbitmq استفاده کنیم؟

 

# چرا GIL به عنوان راه حل انتخاب شد؟

در اینجا شاید برای شما سوال باشد که اگر GIL تا این حد مشکل ساز است، چرا توسط سازندگان پایتون انتخاب شد؟ آیا سازندگان پایتون، مشکل روانی داشتند؟

 

خب، به قول لری هاستینگز، تصمیم طراحی GIL یکی از مواردی است که باعث محبوبیت پایتون شده است.

 

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

 

در آن زمان کتابخانه‌های زیادی به زبان C وجود داشت که پایتون میخواست از آنها استفاده کند. این برنامه‌های C نیاز به مدیریت حافظه thread-safe داشتند که GIL اینکار را انجام میداد.

 

پیاده سازی GIL ساده بود و به راحتی به پایتون اضافه شد. این برنامه عملکرد برنامه های تک رشته‌ای را افزایش داد زیرا تنها یک قفل باید مدیریت میشد.

 

ادغام کتابخانه های C که thread-safe نبودند آسان تر شد. و این افزونه های C یکی از دلایلی بود که پایتون به راحتی توسط جوامع مختلف پذیرفته شد.

 

همانطور که می بینید، GIL یک راه حل عملی برای مشکل سختی بود که توسعه دهندگان CPython در اوایل زندگی پایتون با آن روبرو بودند.

 

مقاله پیشنهادی: تفاوت بین ماژول، پکیج، لایبرری و فریمورک در پایتون

 

# تاثیر GIL بر برنامه‌های multi-thread

وقتی به یک برنامه معمولی پایتون - یا هر برنامه رایانه ای در این زمینه نگاه می کنید - بین برنامه هایی که از نظر عملکرد به CPU متصل هستند(CPU-bound) و آنهایی که به ورودی/خروجی متصل هستند(I/O-bound) تفاوت وجود دارد.

 

برنامه های متصل به CPU برنامه هایی هستند که فعالیت CPU را به حد نهایت خود می رسانند. این شامل برنامه هایی است که محاسبات ریاضی مانند ضرب ماتریس، جستجو، پردازش تصویر و غیره را انجام می دهند.

 

برنامه های متصل به ورودی/خروجی برنامه هایی هستند که زمان خود را در انتظار ورودی/خروجی می گذرانند که می تواند از طرف کاربر، فایل، پایگاه داده، شبکه و غیره به دست آید. در این حالت که منبع ممکن است قبل از آماده کردن ورودی/خروجی نیاز به پردازش خود داشته باشد، به عنوان مثال، کاربر میخواهد در مورد آنچه در یک ورودی وارد می کند فکر کند یا پاسخ پایگاه داده طولانی خواهد بود.

 

بیایید به یک برنامه ساده متصل به CPU که شمارش معکوس را انجام می دهد نگاهی بیندازیم:

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

 

اجرای این کد بر روی سیستم من با 4 هسته خروجی زیر را ارائه می دهد:

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

 

حالا من کد را کمی تغییر دادم تا با استفاده از دو thread به صورت موازی، شمارش معکوس را انجام دهم:

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

 

و وقتی دوباره آن را اجرا کردم:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

 

همانطور که می بینید، تکمیل هر دو نسخه تقریباً به یک زمان نیاز دارد. در نسخه چند رشته‌ای، GIL از اجرای موازی threadهای متصل به CPU جلوگیری کرد.

 

GIL تأثیر چندانی بر عملکرد برنامه های چند threadی متصل به ورودی/خروجی ندارد زیرا قفل بین threadها در حالی که منتظر ورودی/خروجی هستند به اشتراک گذاشته می شود.

 

اما برنامه ای که threadهای آن کاملاً متصل به CPU است، به عنوان مثال، برنامه ای که یک تصویر را در قسمت هایی با استفاده از threadها پردازش می کند، نه تنها به دلیل قفل شدن یک thread واحد می شود، بلکه زمان اجرای آن نیز افزایش می یابد، همانطور که در مثال بالا مشاهده می شود، در مقایسه با سناریویی که در آن نوشته شده بود که کاملاً تک رشته ای باشد.

 

این افزایش سرعت نتیجه بدست آوردن و آزادسازی سربارهای اضافه شده توسط قفل است.

 

مقاله پیشنهادی: استفاده از عملگر and در پایتون

 

# چرا هنوز GIL برداشته نشده است؟

توسعه دهندگان پایتون در این مورد شکایات زیادی دریافت می کنند اما زبانی به محبوبیت پایتون نمی تواند تغییری به اندازه حذف GIL بدون ایجاد مشکلات ناسازگاری با نسخه‌های قبل ایجاد کند.

 

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

 

البته، راه حل های دیگری نیز برای مشکل وجود دارد که GIL آنها را حل می کند، اما برخی از آنها عملکرد برنامه های single-thread و multi-thread متصل به ورودی/خروجی را کاهش می دهد و برخی از آنها بسیار مشکل هستند. پس از همه، شما نمی خواهید برنامه های پایتون موجود شما پس از انتشار نسخه جدید کندتر اجرا شوند، درست است؟

 

مقاله پیشنهادی: 11 کتابخانه پایتونی که هر برنامه نویسی باید بداند

 

# چرا GIL در پایتون 3 حذف نشد؟

پایتون 3 فرصتی برای شروع بسیاری از ویژگی ها از ابتدا داشت و در این فرایند، برخی از برنامه های C موجود را خراب کرد که پس از آن نیاز به تغییرات داشت و برای کار با پایتون 3 منتقل شد. این دلیلی بود که نسخه های اولیه پذیرش پایتون 3 توسط جامعه کندتر بود.

 

اما چرا GIL در کنار آن حذف نشد؟

 

حذف GIL پایتون 3 را در مقایسه با پایتون 2 در عملکرد single-thread کندتر می کرد و می توانید تصور کنید که چه چیزی منجر به آن می شد. شما نمی توانید با مزایای عملکرد تک رشته‌ای GIL بحث کنید. بنابراین نتیجه این می شود که پایتون 3 هنوز GIL را دارد.

 

مطالب مشابه



مونگارد