Как работает планировщик потоков в Python и какова роль GIL

Ответ

Планировщик потоков в Python (CPython) — это стандартный планировщик операционной системы, но его работа тесно связана с Global Interpreter Lock (GIL).

Что такое GIL?

GIL (Global Interpreter Lock) — это мьютекс (глобальная блокировка), который защищает доступ к объектам Python, позволяя только одному потоку выполнять байт-код Python в любой момент времени в рамках одного процесса. Он не является планировщиком, а лишь ограничивает его.

Почему он существует?

GIL упрощает управление памятью в CPython (автоматический подсчет ссылок становится потокобезопасным) и облегчает интеграцию с C-библиотеками, которые не являются потокобезопасными.

Механизм переключения потоков:

Поток, владеющий GIL, освобождает его, и планировщик передает управление другому потоку в следующих случаях:

  1. Операции ввода-вывода (I/O): При ожидании ответа от сети, диска или базы данных поток освобождает GIL, позволяя другим потокам выполняться.
  2. Явное освобождение: Вызов time.sleep().
  3. По истечении интервала: Поток принудительно освобождает GIL после выполнения определенного количества инструкций байт-кода (интервал можно получить через sys.getswitchinterval()). Это предотвращает «зависание» CPU-bound потоков.
import threading
import time

# Эта функция будет выполняться в потоке
def io_bound_task():
    print(f"{threading.current_thread().name} начал I/O операцию...")
    # Во время sleep() поток освобождает GIL, давая работать другим
    time.sleep(1)
    print(f"{threading.current_thread().name} завершил I/O операцию.")

# Создаем и запускаем два потока
thread1 = threading.Thread(target=io_bound_task, name="Поток 1")
thread2 = threading.Thread(target=io_bound_task, name="Поток 2")

thread1.start()
thread2.start()

thread1.join()
thread2.join()
# Вывод покажет, что потоки выполняются псевдо-параллельно

Вывод:

  • Для I/O-bound задач (ожидание сети, диска) threading эффективен, так как GIL освобождается во время ожидания.
  • Для CPU-bound задач (математические вычисления) threading не дает прироста производительности. Для настоящего параллелизма на многоядерных системах следует использовать модуль multiprocessing, где каждый процесс получает собственный интерпретатор и собственный GIL.

Ответ 18+ 🔞

Давай разберемся с этим зверем по имени GIL, а то народ путается, как в тёмной комнате хуй с пальцем.

Представь, что CPython — это один большой, важный сортир в тюрьме строгого режима. А GIL (Global Interpreter Lock) — это здоровенный вертухай с дубинкой у двери. Его правило простое, блядь: «В сортире может сидеть только один зек за раз!» Все остальные потоки — это очередь из обоссавшихся арестантов, которые ждут своей очереди на толчок.

Зачем этот ёбаный вертухай вообще нужен?

А затем, ёпта, чтобы внутри не началась резня. Управление памятью в Python (этот вечный подсчёт, кто на кого ссылается) — штука нежная. Если пустить туда сразу всех — они друг другу мозги поотрывают, ссылки перепутают и всё похерится. Плюс, куча старых С-библиотек, с которыми Python дружит, написана такими же кончеными интровертами, которые в панике, если на них смотрят больше одного потока одновременно. GIL их успокаивает.

Как происходит смена караула, или Кого вертухай пускает в сортир дальше?

Тот поток, который сейчас владеет GIL (сидит на толчке), должен его отпустить. А происходит это в трёх основных случаях, блядь:

  1. Когда делаешь дела не в сортире (I/O). Понял? Читаешь из сети, пишешь в файл, ждёшь ответа от базы — это как выйти из сортира помыть руки. Естественно, вертухай тут же впихивает в кабинку следующего.
  2. Когда сам скажешь «Я закончил!». Вызвал time.sleep(0.1) — это как громко дёрнул ручку и сказал «Всё, блядь!». Выходи, дай другим.
  3. Когда вертухаю надоест ждать (тиковый интервал). Это главная фишка, ёбта! Поток не может захватить сортир навечно. После выполнения какого-то количества мелких инструкций (тиков) вертухай его вышвыривает пинком под жопу, даже если тот не закончил! Справедливость, мать её. Интервал этого пинка можно посмотреть: sys.getswitchinterval().

Вот, смотри, как это выглядит в коде, когда потоки вежливо меняются у сортира:

import threading
import time

def io_bound_task():
    print(f"{threading.current_thread().name} зашёл в сортир...")
    time.sleep(1)  # <-- Вот тут он вышел помыть руки, и GIL ушёл другому!
    print(f"{threading.current_thread().name} вышел, довольный.")

# Создаём очередь
thread1 = threading.Thread(target=io_bound_task, name="Поток Вася")
thread2 = threading.Thread(target=io_bound_task, name="Поток Петя")

thread1.start()
thread2.start()

thread1.join()
thread2.join()
# Вывод покажет, что они заходили и выходили по очереди, делая вид параллельности.

Итог, ёпта, простой:

  • Если твоя задача — много ждать (I/O-bound): threading — отличная тема. Пока один поток ждёт ответа от сервера, другой может работать. Сортир не простаивает.
  • Если твоя задача — много считать (CPU-bound): Забудь, блядь. Это как если бы все зеки захотели не просто посрать, а каждый начал решать в уме интегралы, сидя на толчке. Они будут сменять друг друга, но считать-то будет всё равно только один в единицу времени! Производительность не вырастет, только накладные расходы на переключения.

Для настоящей, честной работы на всех ядрах процессора надо не в одном сортире очередь занимать, а построить отдельный сортир на каждый поток! Это и есть модуль multiprocessing. У каждого процесса — свой интерпретатор, свой GIL и своя, блядь, уборная. Вот тогда они и правда могут работать одновременно, не мешая друг другу.