Кейс (Python): почему этот код не даёт ожидаемого результата при многопоточности и как можно добиться реального параллелизма — поясните роль GIL и альтернативы (многопроцессность, asyncio, multiprocessing): import threading\ncounter = 0\ndef inc():\n global counter\n for _ in range(1000000):\n counter += 1\nthreads = [threading.Thread(target=inc) for _ in range(4)]\nfor t in threads: t.start()\nfor t in threads: t.join()\nprint(counter)
Коротко — две причины и несколько решений. Проблема - Ваш код создаёт по 444 потока, каждый делает по 100000010000001000000 инкрементов, ожидаемый итог 4⋅1000000=40000004\cdot1000000=40000004⋅1000000=4000000. Но реально вы получите меньше из‑за race condition: операция `counter += 1` не атомарна (выполняется как load, increment, store) и потоки могут прерывать друг друга, теряя обновления. - Дополнительно в CPython есть GIL (Global Interpreter Lock): в любой момент только один поток выполняет байткод Python. GIL не делает инкремент атомарным — он только обеспечивает, что байткод выполняется по одному потоку, но интерливинг между инструкциями даёт потерю обновлений; и для CPU‑bound задач он препятствует реальному параллелизму потоков. Как добиться корректности и/или реального параллелизма 1) Синхронизация (правильно, но без ускорения) - Гарантирует правильный результат, но не даёт параллельного ускорения для CPU‑bound кода: code: import threading counter = 0 lock = threading.Lock() def inc(): global counter for _ in range(1000000): with lock: counter += 1 threads = [threading.Thread(target=inc) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(counter) 2) Многопроцессность — реальный параллелизм для CPU‑bound - Каждый процесс имеет свой интерпретатор и свой GIL, поэтому процессы могут выполняться на разных CPU‑ядрах. - Пример с multiprocessing (каждый процесс возвращает свой счётчик, затем суммируем): code: from multiprocessing import Pool def work(n): c = 0 for _ in range(n): c += 1 return c if __name__ == '__main__': N = 1000000 procs = 4 with Pool(procs) as p: parts = p.map(work, [N]*procs) print(sum(parts)) # ожидаемо 4*1000000 - Можно использовать concurrent.futures.ProcessPoolExecutor аналогично. 3) asyncio - Подходит для I/O‑bound задач (конкурентность, но не параллелизм CPU). Для CPU‑bound тасков надо отдавать их в Executor: `loop.run_in_executor()` или использовать ProcessPoolExecutor. 4) Альтернативы для ускорения CPU‑bound в многопоточном коде - Использовать библиотеки/расширения, которые освобождают GIL (напр., части C/C++ в numpy, Cython с освобождением GIL, Numba) или реализуют атомарные операции в C. - Использовать multiprocessing.Value/Array или SharedMemory для совместного состояния между процессами (с синхронизацией). Резюме - GIL мешает потокам CPython давать параллелизм для CPU‑bound задач; операция `+=` не атомарна, поэтому и возникают потерянные инкременты. - Для корректности используйте Lock; для реального параллельного ускорения — multiprocessing / ProcessPoolExecutor или перенос горячих участков в код, который освобождает GIL. Для I/O‑bound задач — threading или asyncio.
Проблема
- Ваш код создаёт по 444 потока, каждый делает по 100000010000001000000 инкрементов, ожидаемый итог 4⋅1000000=40000004\cdot1000000=40000004⋅1000000=4000000. Но реально вы получите меньше из‑за race condition: операция `counter += 1` не атомарна (выполняется как load, increment, store) и потоки могут прерывать друг друга, теряя обновления.
- Дополнительно в CPython есть GIL (Global Interpreter Lock): в любой момент только один поток выполняет байткод Python. GIL не делает инкремент атомарным — он только обеспечивает, что байткод выполняется по одному потоку, но интерливинг между инструкциями даёт потерю обновлений; и для CPU‑bound задач он препятствует реальному параллелизму потоков.
Как добиться корректности и/или реального параллелизма
1) Синхронизация (правильно, но без ускорения)
- Гарантирует правильный результат, но не даёт параллельного ускорения для CPU‑bound кода:
code:
import threading
counter = 0
lock = threading.Lock()
def inc():
global counter
for _ in range(1000000):
with lock:
counter += 1
threads = [threading.Thread(target=inc) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)
2) Многопроцессность — реальный параллелизм для CPU‑bound
- Каждый процесс имеет свой интерпретатор и свой GIL, поэтому процессы могут выполняться на разных CPU‑ядрах.
- Пример с multiprocessing (каждый процесс возвращает свой счётчик, затем суммируем):
code:
from multiprocessing import Pool
def work(n):
c = 0
for _ in range(n):
c += 1
return c
if __name__ == '__main__':
N = 1000000
procs = 4
with Pool(procs) as p:
parts = p.map(work, [N]*procs)
print(sum(parts)) # ожидаемо 4*1000000
- Можно использовать concurrent.futures.ProcessPoolExecutor аналогично.
3) asyncio
- Подходит для I/O‑bound задач (конкурентность, но не параллелизм CPU). Для CPU‑bound тасков надо отдавать их в Executor: `loop.run_in_executor()` или использовать ProcessPoolExecutor.
4) Альтернативы для ускорения CPU‑bound в многопоточном коде
- Использовать библиотеки/расширения, которые освобождают GIL (напр., части C/C++ в numpy, Cython с освобождением GIL, Numba) или реализуют атомарные операции в C.
- Использовать multiprocessing.Value/Array или SharedMemory для совместного состояния между процессами (с синхронизацией).
Резюме
- GIL мешает потокам CPython давать параллелизм для CPU‑bound задач; операция `+=` не атомарна, поэтому и возникают потерянные инкременты.
- Для корректности используйте Lock; для реального параллельного ускорения — multiprocessing / ProcessPoolExecutor или перенос горячих участков в код, который освобождает GIL. Для I/O‑bound задач — threading или asyncio.