Введение в

Конкурентность и Параллелизм

Игорь Кальницкий

@ikalnitsky

Конкурентность

... о работе с несколькими задачами одновременно.

Параллелизм

... о выполнении нескольких вычислений одновременно.

Конкурентность

Время

Вычислитель #1

t + 0

Задача А

t + 1

Задача Б

t + 2

Задача А

t + 3

Задача А

t + 4

Задача В

  • Задачи исполняются друг за дружкой, по чуть-чуть и не обязательно до конца.

Параллелизм

Время

Вычислитель #1

Вычислитель #2

t + 0

Задача А

Задача А

t + 1

Задача Б

t + 2

Задача В

t + 3

Задача Г

Задача Г

t + 4

Задача Д

  • Задачи исполняются последовательно, друг за дружкой и до конца.
  • Любая задача может исполняться параллельно, если расчитана на использование дополнительных вычислителей.
  • Если задача не расчитана на использование дополнительного вычислителя, то он будет простаивать.
  • Фактически имеем дело с "data parallelism".

Конкурентность + параллелизм = ?

Время

Вычислитель #1

Вычислитель #2

t + 0

Задача А

Задача Б

t + 1

Задача В

Задача Б

t + 2

Задача A

Задача В

t + 3

Задача Б

Задача А

t + 4

Задача Г

  • Каждая задача является независимым потоком управления, который может исполняться на любом доступном вычислителе.

Потоки

from threading import Thread

def sqr(x):
    return x * x

t = Thread(target=sqr, args=(42, ))
t.start()
t.join()
  • Нельзя получить результат работы sqr(42).
  • Удобно использовать для создания фоновых задач.
from concurrent.futures import ThreadPoolExecutor

def sqr(x):
    return x * x

with ThreadPoolExecutor(max_workers=3) as executor:
    result = list(executor.map(sqr, [1, 2, 3, 4, 5]))
  • Нельзя организовать фоновую работу.
  • Удобно использовать для распределения задач на выполнение и получения результата.

А как же GIL?

Global Interpreter Lock (GIL)

from concurrent.futures import ThreadPoolExecutor

def sqr(x):
    return x * x

with ThreadPoolExecutor(max_workers=3) as executor:
    result = list(executor.map(sqr, [1, 2, 3, 4, 5]))

Процессы

import os
os.fork()
from multiprocessing import Process

def sqr(x):
    return x * x

p = Process(target=sqr, args=(42, ))
p.start()
p.join()
  • Так же как и в случае с threading.Thread, нельзя получить результат работы sqr(42).
  • Удобно использовать для создания фоновых задач, которые не должны быть ограничены GIL и должны иметь возможность исполняться параллельно.
from concurrent.futures import ProcessPoolExecutor

def sqr(x):
    return x * x

with ProcessPoolExecutor(max_workers=3) as executor:
    result = list(executor.map(sqr, [1, 2, 3, 4, 5]))
  • В отличии от версии с потоками, вычисление может выполняться параллельно при доступной аппаратной составляющей.
  • Так как создание процесса дорогостоящая операция, отдавать не трудоемкие задачи на выполнение имеет мало смысла.

Процессы или Потоки?

Характеристика

Потоки

Процессы

Ограничение GIL

да

нет

Время создания

меньше

больше [1]

Потребление памяти

меньше

больше [1]

Время коммуникации

меньше

дольше

#1:

На большинстве *nix платформах доступна оптимизация copy-on-write, за счет чего создание процессов может происходить немногим медленнее потоков, а потребление памяти - немногим больше потоков. Тем не менее, в случае CPython потребление памяти будет расти достаточно быстро.

Зеленые потоки

import eventlet; eventlet.monkey_patch()

from threading import Thread

def sqr(x):
    return x * x

Thread(target=sqr, args=(42, )).start()
  • Event Loop использует технологии ядра для массовой обработки сокетов. Например, под Linux используется epoll для ожидание события на одном из сокетов.
  • Когда событие пришло, управление передается зеленому потоку, который связан с этим сокетом.

Зеленые потоки или Потоки?

Зеленые потоки

Потоки

Подходят только для I/O приложений.

Подходят для любого рода приложений.

Экономят системные ресурсы.

Хотя и не будут исполняться параллельно.

asyncio

import asyncio

async def sqr(x):
    return await send_sqr(x)

loop = asyncio.get_event_loop()
result = loop.run_until_complete(sqr(42))

Зеленые потоки или asyncio?

Зеленые потоки

asyncio

Совместимы с обычным многопоточным кодом.

Требует использование специального синтаксиса, несовместимого с классическим кодом.

Благодаря костылям, работают со многими библиотеками и драйверами к БД.

Требует специальных версий библиотек и драйверов к БД.

Существует мнение, что асинхронное программирование работает быстрее для I/O приложений за счет отсутствия необходимости переходить в kernel space для переключения между потоками.

Эхо-сервер на Потоках

from socket import *
from threading import Thread

def echo_server(conn):
    while True:
        data = conn.recv(100)
        if not data: break
        conn.send(data)

s = socket(AF_INET, SOCK_STREAM)
s.bind(('127.0.0.1', 5000))
s.listen(10000)

while True:
    conn, addr = s.accept()
    Thread(target=echo_server, args=(conn, )).start()

Эхо-сервер на Корутинах

import asyncio

async def echo_server(reader, writer):
    while True:
        data = await reader.read(100)
        if not data: break
        writer.write(data)

loop = asyncio.get_event_loop()
coro = asyncio.start_server(
    echo_server, '127.0.0.1', 5000, loop=loop)
server = loop.run_until_complete(coro)

loop.run_forever()

Результат

Эхо-Сервер

на Потоках

на Корутинах

100 клиентов

~ 63000 запросов/с

~ 25000 запросов/с

1000 клиентов

~ 56000 запросов/с

~ 19000 запросов/с

5000 клиентов

~ 2700 запросов/с [2]

~ 19000 запросов/с

#2:

Сервер не дошел до обслуживания 5000 клиентов. Исследование показало, что при большом количестве потоков на многоядерной системе происходит «Война за GIL». Времени на главный поток почти не выделяется, поэтому установить соединение с новыми клиентами не удается.

Тем не менее, ограничив исполнение сервера одним CPU ядром, можно получить результат около 58500 запросов/с.

o_O

Исследование

(на 100 клиентах)

Эхо-Сервер

CPU

linux perf

на Потоках

~ 292%

  • ~ 75% времени - linux kernel
  • ~ 17% времени - python3.5
  • ~ 7% времени - libpthread

на Корутинах

~ 94%

  • ~ 28% времени - linux kernel
  • ~ 67% времени - python3.5
  • ~ 5% времени - libc

Python медленный :'(

Зато потоки могут параллельно исполняться :)

По процессу на каждое ядро!

Эхо-Сервер

на Потоках

на Корутинах

на Корутинах и процессах

100 клиентов

~ 63000 запр./с

~ 25000 запр./с

~ 42000 запр./с

1000 клиентов

~ 56000 запр./с

~ 19000 запр./с

~ 40000 запр./с

5000 клиентов

~ 2700 запр./с

~ 19000 запр./с

~ 34000 запр./c

Хм.. А что в реальном мире?

Результат

Эхо-Сервер

Кол-во клиентов

Пропускная способность на локальном хосте

Пропускная способность в сети

на Потоках

1000

~ 63000 запросов/с

~ 7200 запросов/с

на Корутинах

1000

~ 25000 запросов/с

~ 7200 запросов/с

Что надо усвоить?

Что надо усвоить?

Материалы по теме

Вопросы?

(спасибо за внимание)