Skip to content

GIL Threads Processes

GIL, потоки, процеси

Що таке потік (Thread)? Що таке процес (Process)?

Потік - це легкий підпроцес, що існує в межах основного процесу і може виконувати паралельні задачі. Кожен потік має свій стек викликів, але поділяє адресний простір, купу та регістри процесора з іншими потоками в рамках процесу. Потоки дозволяють програмі виконувати багатозадачні операції, розділюючи I/O завдання між різними потоками для збільшення продуктивності. У Python, для роботи з потоками можна використовувати модуль threading.

Процес - це незалежний виконуваний екземпляр програми, який має свою власну пам'ять та ресурси (незалежні стеки викликів, купи та регістри процесора) і виконує код незалежно від інших процесів. Кожен процес має власний ідентифікатор процесу (PID) та виконується в окремому розділі пам'яті. У Python, для роботи з процесами можна використовувати модуль multiprocessing. Процеси є корисними для використання багатоядерних систем, оскільки кожен процес може працювати на власному ядрі процесора, що підвищує продуктивність програм.

В чому відмінність потоків від процесів

Потоки історично з'явились пізніше процесів, і в Linux вони реалізовані однією і тією ж структурою даних, відомою як "таск" (Task). Для ядра Лінуксу немає різниці між потоком і процесом - потоки та процеси розглядаються як завдання (tasks).

Основна відмінність полягає у розділенні пам'яті. Процеси є незалежними один від одного, мають окремі адресні простори, ідентифікатори та ресурси. Потоки виконуються у спільному адресному просторі, мають спільний доступ до пам'яті, змінних, завантажених модулів. Можна визначити змінну, і всі потоки її можуть бачити. А процеси такої можливості позбавлені і нам потрібно процеси якось зв'язувати.

Створення та знищення процесів може бути важким процесом, оскільки вони вимагають виділення окремих ресурсів.

Таким чином потоки є більш легкими, між ними швидше перемикається контекст.

Links

Які існують способи синхронізації процесів та потоків ? Як передавати інформацію з одного процесу до іншого?

Синхронізація процесів і потоків у Python досягається через механізми, які забезпечують контроль над доступом до спільних ресурсів, а також дозволяють координувати виконання задач. - Блокування (Locks): threading.Lock або multiprocessing.Lock дозволяє обмежувати доступ до ресурсу лише одному потоку чи процесу в будь-який момент часу. - RLocks (Reentrant Locks): Дозволяють одному і тому ж потоку повторно захоплювати блокування без блокування самого себе. - Семафори (Semaphores): threading.Semaphore дозволяє обмежувати доступ до ресурсу певною кількістю потоків. Для процесів використовується multiprocessing.Semaphore. - Події (Events): threading.Event або multiprocessing.Event використовуються для координації потоків або процесів через сигнали. - Черги (Queues): queue.Queue для потоків і multiprocessing.Queue для процесів дозволяють обмінюватися даними між потоками чи процесами в безпечний спосіб. - Бар'єри (Barriers): threading.Barrier використовується для синхронізації групи потоків, які повинні досягти певного пункту перед продовженням роботи.

Для передачі інформації між процесами в Python найчастіше використовуються наступні способи - Черги (Queues): Черги з модуля multiprocessing дозволяють процесам безпечно обмінюватися даними.

from multiprocessing import Process, Queue

def worker(q):
    q.put("Hello from process")  # Send a message to the queue

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    print(q.get())  # Receive a message from the queue
    p.join()
  • Канали (Pipes): Використовуються для прямого зв’язку між двома процесами.
from multiprocessing import Process, Pipe

def worker(conn):
    conn.send("Hello from worker")  # Send a message through the pipe
    conn.close()

if __name__ == "__main__":
    parent_conn, child_conn = Pipe()
    p = Process(target=worker, args=(child_conn,))
    p.start()
    print(parent_conn.recv())  # Receive a message from the pipe
    p.join()
  • Спільна пам'ять (Shared Memory): Через multiprocessing.Value або multiprocessing.Array можна ділитися даними між процесами.
from multiprocessing import Process, Value

def worker(shared_value):
    shared_value.value += 1  # Modify shared value

if __name__ == "__main__":
    shared_value = Value('i', 0)  # Integer value in shared memory
    processes = [Process(target=worker, args=(shared_value,)) for _ in range(5)]

    for p in processes:
        p.start()
    for p in processes:
        p.join()

    print(shared_value.value)  # Output: 5
  • Менеджери (Managers): Дають змогу створювати об’єкти (списки, словники тощо) для спільного використання між процесами.
from multiprocessing import Manager, Process

def worker(shared_dict):
    shared_dict["key"] = "value"  # Update shared dictionary

if __name__ == "__main__":
    manager = Manager()
    shared_dict = manager.dict()
    p = Process(target=worker, args=(shared_dict,))
    p.start()
    p.join()
    print(shared_dict)  # Output: {'key': 'value'}

Links

Що таке гонка (race condition)? Як з нею боротися?

Гонка (race condition) — це помилка в програмі, яка виникає, коли результат виконання коду залежить від того, як чергуються операції кількох потоків чи процесів, що одночасно звертаються до спільного ресурсу. Такі ситуації можуть призводити до некоректної поведінки програми, непередбачуваних результатів або навіть до аварійного завершення роботи.

Гонка виникає, якщо: - Декілька потоків чи процесів мають спільний доступ до ресурсу (наприклад, змінної або файлу). - Відсутні засоби синхронізації, які гарантують коректний порядок виконання операцій над ресурсом.

import threading  

class Counter:  
    def __init__(self):  
        self.val = 0  

    def change(self):  
        self.val += int(1)  

def work(counter, operationsCount):  
    for _ in range(operationsCount):  
        counter.change()  

def run_threads(counter, threadsCount, operationsPerThreadCount):  
    threads = []  

    for _ in range(threadsCount):  
        t = threading.Thread(target=work, args=(counter, operationsPerThreadCount))  
        t.start()  
        threads.append(t)  

    for t in threads:  
        t.join()  

if __name__ == "__main__":  
    threadsCount = 10  
    operationsPerThreadCount = 1000000  
    expectedCounterValue = threadsCount * operationsPerThreadCount  
    counters = [Counter()]  

    for counter in counters:  
        run_threads(counter, threadsCount, operationsPerThreadCount)  
        print(  
            f"{counter.__class__.__name__}: "  
            f"expected val: {expectedCounterValue}, "  
            f"actual val: {counter.val}"  
        )  # Counter: expected val: 10000000, actual val: 4084157

Як боротися з гонкою - Використовувати блокування (Locks): Блокування дозволяє гарантувати, що лише один потік чи процес може змінювати ресурс у певний момент.

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        with lock:  # Ensure only one thread modifies the counter
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(2)]

for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # Will be always 200000
  • Використовувати атомарні операції: У деяких випадках можна використовувати спеціальні функції або модулі для виконання атомарних операцій. Наприклад, multiprocessing.Value з atomic lock.
  • Семафори: Якщо потрібно контролювати доступ до ресурсу для певної кількості потоків чи процесів.
semaphore = threading.Semaphore(1)  # Maximum one thread in a critical section
  • Queue: Для передачі даних між потоками чи процесами краще використовувати безпечні черги (queue.Queue або multiprocessing.Queue), що автоматично забезпечують синхронізацію.
  • Переосмислення архітектури програми: Уникнення спільного доступу до змінних, використання іммутабельних структур даних та передачі повідомлень замість спільного ресурсу.
  • Глобальний інтерпретаторний замок (GIL): У CPython GIL частково запобігає деяким видам гонок, але його наявність не означає, що гонки не можуть виникнути.

Links

Що таке GIL? Які проблеми в нього є?

GIL - Global Interpreter Lock. В будь-який момент виконується лише один потік Python. Глобальний блок інтерпретатора, відомий як GIL, контролює виконання потоків. GIL гарантує, що кожен потік має ексклюзивний доступ до змінних інтерпретатора (і відповідні виклики C-розширень працюють правильно).

GIL — це блокування, яке має бути захоплено перед будь-яким доступом до Python (не так важливо, чи це виконується Python-код або виклики Python C API).

Принцип роботи простий. Потоки утримують GIL, поки вони виконуються. Однак, вони звільняють його при блокуванні для операцій введення-виведення. Кожного разу, коли поток змушений чекати, інші готові до виконання потоки отримують шанс запуститися.

Коли потік починає роботу, він захоплює GIL. Через певний час планувальник процесів вирішує, що поточний потік виконав достатньо роботи і передає управління наступному потоку. Потік №2 бачить, що GIL захоплений, тому він не продовжує роботу, а сам себе переводить у сплячий режим, уступаючи процесор потоку №1.

Але потік не може утримувати GIL нескінченно. До версії Python 3.3 GIL перемикався кожні 100 машинних інструкцій. У пізніших версіях GIL може бути утримано потоком не більше 5 мс. GIL також звільняється, якщо потік здійснює системний виклик, працює з диском або мережею.

Проблема полягає в тому, що через GIL далеко не всі завдання можуть бути вирішені у потоках. Навпаки, їх використання часто знижує продуктивність програми (у випадку CPU-bound завдань). Використання потоків вимагає контролю доступу до загальних ресурсів, таких як словники, файли, підключення до бази даних.

  • GIL спрощує інтеграцію незахищених від багатопотоковості (non thread safe) бібліотек на C. Завдяки GIL у нас є так багато швидких модулів та зв'язків майже до всього.
  • Бібліотекам на C доступний механізм керування GIL. Наприклад, NumPy звільняє його під час тривалих операцій.

На практиці, GIL у Python робить непотрібною ідею використання потоків для паралелізму в обчислювальних задачах. Вони будуть працювати послідовно навіть на багатопроцесорній системі. У випадку CPU-bound задач програма не прискориться, а тільки сповільниться, оскільки тепер потокам доведеться поділити процесорний час навпіл. При цьому операції I/O GIL не сповільнює, оскільки перед системним викликом потік звільняє GIL.

Links

GIL i GC

Кожен об'єкт у Python - це, насамперед, об'єкт, успадкований від базового класу PyObject. Цей самий PyObject містить всього два поля: ob_refcnt - кількість посилань, і ob_type - вказівник на інший об'єкт, тип даного об'єкта.

Нас цікавить перше поле - ob_refcnt. Це лічильник посилань на конкретний екземпляр даного класу. Кожен раз, коли ми зберігаємо об'єкт в новій змінній, масиві і т.д. (тобто коли ми зберігаємо десь посилання на об'єкт), ми збільшуємо лічильник посилань на одиницю. Кожен раз, коли ми припиняємо використовувати змінну, видаляємо об'єкт з масиву і т.д. (тобто коли посилання на об'єкт видаляється), ми зменшуємо лічильник посилань на одиницю. Коли він доходить до 0 - об'єкт більше ніким не використовується і Python його видаляє (в тому числі поміщаючи блок, в якому розташовувався об'єкт, в список порожніх).

На жаль, лічильник посилань піддається проблемам у багатопотоковому середовищі. Стани гонки можуть призводити до некоректності оновлення цього лічильника з різних потоків. Щоб цього уникнути, CPython використовує GIL - Global Interpreter Lock. Кожен раз, коли відбувається робота з пам'яттю, GIL - як глобальний блок - перешкоджає виконанню цих дій одночасно з двох потоків. Він гарантує, що спершу відпрацює один, потім інший.

Як реалізовано багатопотоковість у Python. Якими модулями

Багатопотоковість реалізована за допомогою модуля Threading. Це нативні POSIX-потоки. Такі потоки виконуються на рівні операційної системи, а не віртуальною машиною.

Links

Життєвий цикл треда

  • Спочатку створюємо клас, який успадковує від класу Thread з модуля threading. В ньому перевизначаємо метод run .
  • Створюємо екземпляр (instance) цього класу, викликаємо start(), який готує тред до виконання. Переводимо тред у стан виконання
  • Можна викликати різні методи, наприклад, sleep() і join(), які переводять тред у режим очікування
  • Коли режим очікування чи виконання припиняється, інші треди, які очікують, готуються до виконання
  • Після завершення виконання тред зупиняється
import random  
import time  
from threading import Thread  

class MyThread(Thread):  
    def __init__(self, name):  
        super().__init__()  
        self.name = name  

    def run(self):  
        amount = random.randint(3, 15)  
        time.sleep(amount)  
        msg = f"{self.name} is running"        print(msg)  


def create_threads():  
    for i in range(5):  
        name = f"Thread #{i + 1}"  
        my_thread = MyThread(name)  
        my_thread.start()  


if __name__ == "__main__":  
    create_threads()

Для чого використовують ThreadPool? Як виділяються та повертаються потоки в ThreadPool?

ThreadPool — це пул потоків, який використовується для ефективного управління багатопоточністю. Він дозволяє обмежити кількість одночасно активних потоків, повторно використовувати вже створені потоки та знижувати накладні витрати на створення й знищення потоків.

Використання ThreadPool дозволяє знизити витрати на створення/знищення потоків, оскільки замість створення нового потоку для кожного завдання, потоки використовуються повторно. Також він дозволяє обмежити кількість потоків, що одночасно виконують завдання, запобігаючи перевантаженню системи.

Механізм роботи ThreadPool - Ініціалізація: - Пул створює певну кількість потоків (N) під час ініціалізації. Ці потоки знаходяться в стані очікування завдань. - Розмір пулу визначає максимальну кількість потоків, які можуть одночасно виконувати завдання. - Постановка завдання: - Завдання (функція чи об’єкт, що викликається) додається в чергу завдань пулу. - Потік із пулу бере завдання з черги і починає його виконувати. - Виконання завдань: - Кожен потік із пулу виконує одне завдання за раз. - Після завершення виконання потік не завершується, а повертається в пул, де очікує наступне завдання. - Закриття пулу: - Коли всі завдання завершені, і пул більше не потрібен, його закривають, що завершує всі потоки.

from concurrent.futures import ThreadPoolExecutor
import time


def task(name):
    print(f"Starting task {name}")
    time.sleep(2)  # Simulate a long-running operation
    print(f"Task {name} completed")
    return f"Result of {name}"


with ThreadPoolExecutor(max_workers=3) as executor:  # Create a ThreadPool with 3 threads
    futures = [executor.submit(task, i) for i in range(5)]
    for future in futures:
        print(future.result())  # Blocks until the task is completed

Як працює thread local?

Thread-local (локальні для потоку) змінні — це механізм, який дозволяє зберігати дані, специфічні для кожного потоку. Це означає, що кожен потік має свій власний набір значень цих змінних, незалежний від інших потоків. У Python це реалізовано через клас threading.local. Python створює окремий простір для кожного потоку, де зберігаються всі атрибути, додані до об'єкта threading.local. Цей простір зв'язується з ідентифікатором потоку (thread ID) і зникає разом із завершенням потоку.

threading.local працює наступним чином - Ізоляція даних: Кожен потік має окремий простір для зберігання значень thread-local змінних. Значення, встановлене в одному потоці, недоступне іншому. - Thread-local змінні доступні так само, як звичайні атрибути об'єкта.

Thread-local використовуються щоб - Зберігати тимчасові дані, специфічні для потоку (наприклад, ідентифікатор користувача, контекст запиту). - Уникнути передавання додаткових параметрів через функції. - Локалізувати стан в багатопоточних додатках, зберігаючи читабельність коду.

В django ORM та алхімії thread-local використовується для того, щоб для кожного треда зберігати свій конекшн в БД.

import threading


local_data = threading.local()  # Create a thread-local storage object

def process_data():
    print(f"Thread {threading.current_thread().name} has value: {local_data.value}")

def worker(value):
    local_data.value = value  # Assign a thread-specific value
    process_data()

threads = [threading.Thread(target=worker, args=(i,), name=f"Thread-{i}") for i in range(3)]

for t in threads:
    t.start()
for t in threads:
    t.join()
Thread Thread-0 has value: 0
Thread Thread-1 has value: 1
Thread Thread-2 has value: 2

Links

Які завдання добре паралеляться, а які погано

Summary

Добре паралеляться IO-bound, погано - CPU-bound.

Добре паралеляться завдання, які виконують тривалу операцію вводу-виводу. Коли потік чекає на завершення сокета або диску, інтерпретатор переключає цей потік і запускає наступний. Це означає, що не буде простою через очікування. З іншого боку, якщо в одному потоці здійснювати мережеві дзвінки (у циклі), то кожного разу доведеться чекати на відповідь.

Проте, якщо потім потрібно обробити отримані дані в потоці, то буде виконуватися лише один потік. Це не тільки не додасть швидкодії, але й сповільнить програму через переключення на інші потоки.

Завдання, пов'язані з мережею, добре підходять для потоків. Наприклад, завантаження ста URL-адрес. Отримані дані потрібно обробляти поза потоками.

Що таке блокуючі операції

У контексті асинхронного програмування, блокуючі операції - це операції, які затримують виконання коду та очікують на результат. Під час блокування інші задачі не можуть виконуватись, оскільки весь потік виконання зупиняється.

Основні приклади блокуючих операцій включають: - Операції затримки (наприклад, time.sleep()): Ці операції призводять до призупинки виконання коду на задану тривалість. Протягом цього часу інші задачі не можуть продовжувати своє виконання. - Очікування блокування ресурсів: Це включає ситуації, коли код намагається отримати доступ до ресурсів, які зайняті іншими задачами. Коли ресурс заблокований, виконання потоку зупиняється, поки ресурс не стане доступним.

Асинхронний код дозволяє уникнути блокування під час таких операцій, дозволяючи іншим задачам продовжувати виконання.

Потрібно обчислити 100 рівнянь. Робити це у потоках або ні

Ні, оскільки у цій задачі немає операцій вводу-виводу. Інтерпретатор лише витратить додатковий час на переключення потоків. Складні математичні завдання краще винести в окремі процеси або використовувати фреймворк для розподілених завдань, такий як Celery, або підключати C-бібліотеки.

Що таке грінлети. Загальне поняття. Приклади реалізацій

Грінлети - Greenlet - Green thread - Зелені потоки - Легкі потоки всередині віртуальної машини. Вони можуть називатися корутинами, субпроцесами, акторами та іншими, залежно від платформи.

Операційна система їх не бачить. З точки зору операційної системи, запущений лише один процес віртуальної машини, а те, що відбувається всередині, не відомо. Управління такими потоками відбувається самою віртуальною машиною: вона створює їх, виконує, координує доступ до ресурсів.

Приклади: корутини в мовах Go і Lua, легкі процеси в Erlang, модуль greenlet для Python. Модуль gevent використовує грінлети.

Що таке системний виклик fork?

Для створення процесів у Linux можна використовувати два системні виклики: - clone(). Це основна функція для створення дочірніх процесів. За допомогою прапорців розробник вказує, які структури батьківського процесу повинні бути спільними з дочірнім. Базово використовується для створення потоків (мають спільний простір адрес, файлові дескриптори, обробники сигналів). - fork(). Ця функція використовується для створення процесів (які мають власний простір адрес), але під капотом викликає clone() з певним набором прапорців. Він створює точну копію батьківського процесу, включаючи його пам'ять, змінні та стан. Отриманий новий процес називається дочірнім, а процес, який його створив, - батьківським.

Після виклику fork(), обидва процеси (батьківський та дочірній) продовжують виконувати однаковий код, але вони мають свої власні примірники змінних та інших ресурсів. Процеси використовують механізм Copy-on-Write для ефективного управління пам'яттю, тобто спільна пам'ять копіюється лише тоді, коли один з процесів намагається змінити її вміст.

Для яких завдань варто використовувати потоки, для яких - процеси, а для яких - асинхронність?

Summary

  • CPU-залежна задача => Multi Processing
  • I/O-залежна, швидкий I/O => Multi Threading
  • I/O-залежна, повільний I/O => Asyncio (робота з вебсокетами)

Уявімо, що ми пишемо HTTP або WebSocket сервер, який обробляє кожне з'єднання в окремому потоці.

Тут можна створити 100, а можливо, навіть 500 потоків, щоб обробити необхідну кількість одночасних з'єднань. Для коротких запитів це може працювати і дозволити витримувати навантаження 5000 RPS на найбільш доступному DO інстансі за п'ять доларів - це досить непогано. Якщо менше, то можливо вам не потрібні жодні AsyncIO/Tornado/Twisted.

Але що, якщо їх кількість наближається до нескінченності, наприклад, це великий чат з багатьма каналами, кількість одночасних учасників в якому необмежена. У такій ситуації краще не створювати стільки потоків, щоб вистачило кожному користувачеві.

Як уже зазначалося, поки GIL захоплений одним потоком, інші потоки не будуть працювати. Планувальник операційної системи про цей GIL нічого не знає і все одно буде віддавати процесор заблокованим потокам. Цей потік, звичайно, побачить, що GIL захоплений і одразу засне, але на перемикання контексту процесора буде витрачатися дорогоцінний час.

Перемикання контексту - взагалі витратна для процесора операція, яка потребує скидання регістрів, кешу та таблиці відображення сторінок пам'яті. Чим більше потоків запущено, тим більше процесор робить марні перемикання на потоки, що заблоковані GIL, перед тим, як дістатися до того самого, який утримує цей GIL. Не дуже ефективно.

Є також співпрограми - те, що пропонує AsyncIO і Tornado. Їх також називають корутинами або просто потоками на рівні користувача, які використовувалася ще в часи, коли були операційні системи без підтримки багатозадачності.

На відміну від потоків, співпрограми виконують лише корисну роботу, а перемикання між ними відбувається лише у той момент, коли співпрограма очікує завершення якоїсь зовнішньої операції.

Як і у випадку з потоками, асинхронні фреймворки використовуються для I/O-bound задач - таких як мережеві виклики, взаємодія з базами даних, робота з файлами. Для CPU-bound задач, які вимагають багато обчислень, такий підхід може бути неефективним, оскільки співпрограми теж працюють в одному потоці через GIL.

Ще одна найважливіша особливість корутину полягає в тому, що вони легковажні. Вони «легші» за потоки. Тобто швидше запускаються і використовують менше пам'яті. По суті, корутини – це особливий різновид функцій, а от потоки представлені Python-об'єктами та пов'язані з потоками в операційній системі, з якими ці об'єкти мають взаємодіяти. Переключення між потоками відбувається тоді, коли заманеться операційній системі. У випадку з asyncio - переключення може відбутись на await, що дає більше контролю над програмою.

У силу цього в Python-програмі містяться тисячі потоків. А якщо говорити про корутини, то їх уже можуть бути десятки або сотні тисячі, і всі вони будуть розміщені в одному потоці.

Links