Лекция 4. Многопоточность и конкурентность (Concurrency)¶
С появлением многоядерных процессоров стала общеупотребительной практика распределять нагрузку на все доступные ядра. Существует два основных подхода:
- Процессы — независимые программы, не разделяют память. Обмен — через каналы (pipes), сокеты, файлы.
- Потоки — единицы выполнения внутри одного процесса, разделяют память. Обмен данными проще, но усложняется управление синхронизацией.
В Python и Go подходы к конкурентности заметно различаются — рассмотрим оба.
Процессы в Python¶
Модуль subprocess позволяет запускать внешние программы:
import subprocess
# Простой запуск
result = subprocess.run(
["ls", "-la"],
capture_output=True, text=True, check=True,
)
print(result.stdout)
# Запуск с передачей stdin и контролем процесса
pipe = subprocess.Popen(
[sys.executable, "child.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
out, err = pipe.communicate(input=b"word\nfile.txt\n")
Для параллельного выполнения CPU-bound задач — multiprocessing (обходит GIL за счёт отдельных процессов):
from multiprocessing import Pool
def square(n: int) -> int:
return n * n
with Pool(processes=4) as pool:
results = pool.map(square, range(10))
print(results) # [0, 1, 4, 9, ...]
Потоки в Python (threading)¶
Модуль threading предоставляет класс Thread. Можно унаследоваться или передать целевую функцию:
import threading
# Способ 1: через целевую функцию
def worker(name: str):
print(f"Привет от {name}")
t = threading.Thread(target=worker, args=("Bob",))
t.start()
t.join() # ждём завершения
# Способ 2: через наследование
class MyThread(threading.Thread):
def run(self):
print("Я запускаюсь в отдельном потоке")
MyThread().start()
Основные методы класса Thread:
| Метод | Назначение |
|---|---|
start() |
Запускает поток (вызывает run() в отдельном потоке) |
run() |
Действия потока — переопределяется в подклассе |
join([timeout]) |
Ждать завершения потока (опционально с таймаутом) |
is_alive() |
Возвращает True, если поток работает |
daemon (свойство) |
Признак «демона» — фоновый поток, не мешает выходу из программы |
GIL — глобальная блокировка интерпретатора¶
Python слывёт дружелюбным и простым, но есть у него причуды: нельзя просто взять и воспользоваться всеми преимуществами многопоточности. Дорогу преградит GIL (Global Interpreter Lock) — глобальный шлюз, ограничивающий многопоточность на уровне интерпретатора.
GIL — это один на всех мьютекс, гарантирующий, что в каждый момент только один поток исполняет байт-код Python. Технически это сделано для безопасности встроенных типов и упрощения интеграции с C-расширениями.
Последствия:
- CPU-bound задачи не ускоряются от потоков — даже на N ядер скорость остаётся как на одном.
- I/O-bound задачи (сеть, диск) выигрывают: GIL отпускается на время блокирующих операций.
Пример из Understanding Python GIL (Chetan Giridhar):
from datetime import datetime
import threading
def factorial(number):
fact = 1
for n in range(1, number + 1):
fact *= n
return fact
start = datetime.now()
t1 = threading.Thread(target=factorial, args=(100_000,))
t2 = threading.Thread(target=factorial, args=(100_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print("Время:", datetime.now() - start)
На двух «параллельных» потоках расчёт идёт дольше, чем на одном — расходы на переключение контекстов GIL.
Тенденция: PEP 703 и free-threading. Начиная с Python 3.13 в экспериментальном режиме доступна сборка интерпретатора без GIL. Подробнее: https://peps.python.org/pep-0703/.
Когда что использовать в Python¶
- I/O-bound (сеть, диск):
threadingилиasyncio. - CPU-bound (вычисления):
multiprocessingили внешние библиотеки на C/Rust (NumPy, Polars). - Большие параллельные I/O (тысячи соединений):
asyncio.
Очереди и пул потоков¶
При создании большого числа потоков приложение замедляется — каждый поток требует ресурсов ОС. Решение — пул потоков (thread pool) с фиксированным числом потоков, переиспользующих ресурсы.
Стандартный модуль concurrent.futures даёт высокоуровневый интерфейс:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url: str) -> int:
r = requests.get(url, timeout=5)
return r.status_code
urls = [
"https://httpbin.org/get",
"https://github.com",
"https://python.org",
]
with ThreadPoolExecutor(max_workers=3) as pool:
for url, status in zip(urls, pool.map(fetch, urls)):
print(url, status)
Для CPU-bound задач — ProcessPoolExecutor (тот же API, но процессы):
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(factorial, [10000, 20000, 30000]))
Блокировки (Lock)¶
Когда несколько потоков обращаются к общему изменяемому состоянию, нужны блокировки.
import threading
counter = 0
lock = threading.Lock()
def increment(n: int):
global counter
for _ in range(n):
with lock:
counter += 1 # атомарный инкремент
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 400000 — без lock могло бы быть меньше
Дедлок возникает, если два потока удерживают по одному ресурсу и ждут друг друга:
- Поток 1:
lock_A.acquire(), потомlock_B.acquire(). - Поток 2:
lock_B.acquire(), потомlock_A.acquire().
→ Бесконечное ожидание.
Правило: если поток должен взять несколько блокировок — всегда брать их в одном и том же порядке.
Конкурентность в Go: горутины и каналы¶
Go был спроектирован вокруг конкурентности с самого начала. Модель — CSP (Communicating Sequential Processes): «не разделяйте память для коммуникации — коммуницируйте, чтобы разделить память».
Горутины¶
Горутина — лёгкая функция, исполняемая конкурентно. Размер стека — стартует с 2 KB (vs 1–8 MB у потока ОС). Рантайм Go мультиплексирует тысячи горутин на N потоков ОС.
package main
import (
"fmt"
"sync"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("hello") // в отдельной горутине
go say("world") // в отдельной горутине
time.Sleep(time.Second)
}
sync.WaitGroup — ожидание группы горутин¶
Аналог Thread.join():
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(url)
}
wg.Wait()
Каналы¶
Каналы — типизированные конвейеры для обмена значениями между горутинами:
ch := make(chan int) // небуферизированный
ch := make(chan int, 100) // буфер на 100 элементов
ch <- 42 // отправить
v := <-ch // получить
close(ch) // закрыть
// перебор канала до его закрытия
for v := range ch {
fmt.Println(v)
}
Каналы — первоклассные объекты Go; их передают как параметры, возвращают из функций.
select — мультиплексирование каналов¶
select {
case msg := <-ch1:
fmt.Println("из ch1:", msg)
case msg := <-ch2:
fmt.Println("из ch2:", msg)
case <-time.After(time.Second):
fmt.Println("таймаут")
}
Пример: worker pool на горутинах¶
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * 2
fmt.Printf("worker %d обработал %d\n", id, j)
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println("результат:", r)
}
}
sync.Mutex — блокировки в Go¶
Когда без shared memory не обойтись — есть классические мьютексы:
var (
counter int
mu sync.Mutex
)
func increment(n int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < n; i++ {
mu.Lock()
counter++
mu.Unlock()
}
}
Также есть sync.RWMutex (отдельный режим для читателей), sync.Once (однократная инициализация), sync/atomic (атомарные операции).
context.Context — отмена и таймауты¶
В Go для управления жизненным циклом долгих операций используют context.Context:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
// если запрос займёт больше 5 секунд — будет ошибка context.DeadlineExceeded
В Python аналог — asyncio.wait_for(coro, timeout=5.0).
Сравнение моделей конкурентности¶
| Аспект | Python (threading) | Python (asyncio) | Go (goroutines) |
|---|---|---|---|
| Единица | поток ОС | задача (coroutine) | горутина |
| Размер | ~1 MB стек | ~2 KB | ~2 KB |
| Сколько можно | сотни | сотни тысяч | миллионы |
| Параллелизм CPU | ограничен GIL | нет | да (N ядер) |
| Параллелизм I/O | да | да | да |
| Синтаксис | обычные функции | async def / await |
go func() |
| Коммуникация | shared memory + Lock/Queue |
shared memory + asyncio.Queue |
каналы + опционально mutex |
Заключение¶
- Параллельное программирование стало необходимостью из-за роста многоядерных процессоров.
- В Python: для I/O-bound —
threadingилиasyncio; для CPU-bound —multiprocessingили вынос в C/Rust. - В Go: горутины и каналы — идиоматичный и эффективный способ конкурентности по умолчанию.
- В обоих языках критично избегать состояний гонки (race conditions) и дедлоков. Полезные инструменты — race detector (
go test -race) иthreading-аналитика (py-spy).
Контрольные вопросы¶
- Что такое GIL? Какие задачи он не ускоряет в потоках?
- Какие альтернативы threading есть в Python для CPU-bound и I/O-bound задач?
- Что такое горутина? Чем она отличается от потока ОС?
- Что такое канал в Go? Чем буферизированный канал отличается от небуферизированного?
- Что такое дедлок? Как его избежать?
- Зачем нужен
context.Contextв Go? - Сравните
concurrent.futures.ThreadPoolExecutorв Python и worker pool на горутинах в Go.