#43. Coroutines, asyncio
Асинхронне програмування. Coroutines. Asyncio.
Асинхронність – це концепція програмування, яка дає змогу виконувати завдання незалежно одне від одного, без очікування завершення попередніх завдань. Замість того, щоб блокувати виконання програми в очікуванні, асинхронність дозволяє перемикатися між різними завданнями, не перериваючи виконання основного потоку.
Асинхронне програмування – це вид паралельного програмування, в якому якась одиниця роботи може виконуватися окремо від основного потоку виконання програми. Коли робота завершується, основний потік отримує повідомлення про завершення робочого потоку або про помилку. У такого підходу є безліч переваг, таких як підвищення продуктивності застосунків та підвищення швидкості відгуку.
За останні кілька років асинхронне програмування привернула до себе пильну увагу, і на те є причини. Попри те, що цей вид програмування може бути складніше традиційного послідовного виконання, він набагато ефективніший.
Наприклад, замість того, щоб чекати завершення HTTP-запиту перед продовженням виконання, ви можете відправити запит і виконувати іншу роботу, яка чекає своєї черги, за допомогою асинхронних співпрограм в Python.
Коли в програмуванні Python застосовується асинхронність
- Міжмережеві операції. Під час виконання операцій введення-виведення, таких як запити до баз даних або мережеві запити, асинхронність дає змогу виконувати безліч таких операцій паралельно, мінімізуючи час очікування.
- Паралельні обчислення. Асинхронність дає змогу виконувати різні обчислення одночасно, що особливо корисно під час опрацювання великих обсягів даних або виконання складних обчислень.
- Користувацький інтерфейс. У програмуванні з графічним інтерфейсом асинхронність дає змогу виконувати тривалі операції (наприклад, завантаження даних із мережі) без блокування користувацького інтерфейсу.
Ітератори
У багатьох сучасних мовах програмування використовують такі сутності як ітератори. Основне їхнє призначення - це спрощення навігації по елементах об’єкта, який, як правило, являє собою деяку колекцію (список, словник)
тощо). Мова Python у цьому випадку не виняток і в ній теж є підтримка ітераторів. Ітератор являє собою об’єкт перераховувач, який для даного об’єкта видає наступний елемент або кидає виняток, якщо елементів
більше немає.
Основне місце використання ітераторів - це цикл for. Якщо ви перебираєте елементи в деякому списку або символи в рядку за допомогою циклу for, то фактично це означає, що при кожній ітерації циклу відбувається звернення до ітератору, що міститься в рядку/списку з вимогою видати наступний елемент, якщо елементів в об’єкті більше немає, то ітератор генерує виняток StopIteration, що обробляється в рамках циклу for непомітно для користувача.
Наведемо кілька прикладів, які допоможуть краще зрозуміти цю концепцію. Для початку виведемо елементи довільного списку на екран.
num_list = [1, 2, 3, 4, 5]
for i in num_list:
print(i)
1
2
3
4
5
Як уже було сказано, об’єкти, елементи яких можна перебирати в циклі for, містять у собі об’єкт ітератор, для того, щоб його отримати, необхідно використовувати функцію iter(), а для вилучення наступного елемента з ітератора - функцію next().
itr = iter(num_list)
print(next(itr))
1
print(next(itr))
2
print(next(itr))
3
print(next(itr))
4
print(next(itr))
5
print(next(itr))
# Traceback(most recent call last):
# File "<pyshell#12>", line1, in < module > print(next(itr))
# StopIteration
Як видно з наведеного вище прикладу, виклик функції next(itr) щоразу повертає наступний елемент зі списку, а коли ці елементи закінчуються, генерується виняток StopIteration.
Послідовності та ітеровані об’єкти
По суті, вся різниця між послідовностями та ітерованими об’єктами (Не ітераторами), полягає в тому, що в послідовностях елементи впорядковані.
Таким чином, послідовностями є списки, кортежі і навіть рядки.
numbers = [1, 2, 3, 4, 5]
letters = ('a', 'b', 'c')
characters = 'douisthebestsiteever'
numbers[1]
# 2
letters[2]
#'c'
characters[11]
# 't'
characters[0:4]
# 'doui'
Ітеровані об’єкти ж, навпаки, не впорядковані, але, тим не менш, можуть бути використані там, де потрібна ітерація:
цикл for, вирази-генератори, спискові включення (list comprehensions) - як приклади.
# Can't be indexed
unordered_numbers = {1, 2, 3}
unordered_numbers[1]
# Traceback(most recent call last):
# File "<stdin>", line 1, in < module >
# TypeError: 'set' object is not subscriptable
users = {'males': 23, 'females': 32}
users[1]
# Traceback(most recent call last):
# File "<stdin>", line 1, in < module >
# KeyError: 1
# Can be used as sequence
[number ** 2 for number in unordered_numbers]
[1, 4, 9]
for user in users:
print(user)
males
females
Послідовність - завжди ітерований об’єкт, ітерований об’єкт не завжди послідовність.
Як ми могли переконатися, цикл for не використовує індекси. Замість цього він використовує так звані ітератори.
iterator
Ітератор — це поведінковий патерн проектування, що дає змогу послідовно обходити елементи колекціі, не розкриваючи їхньої внутрішньої структури.
Фактично, ітератор є об’єктом, який є результатом виклику методу __iter__ об’єкта, що ітерується. Його основне завдання полягає у відстеженні наступного елемента у послідовності.
Отримати ітератор ми можемо з будь-якого ітерованого об’єкта.
Щоб зробити це явно, потрібно викликати метод iter():
set_of_numbers = {1, 2, 3}
list_of_numbers = [1, 2, 3]
string_of_numbers = '123'
iter(set_of_numbers)
# < set_iterator object at 0x7fb192fa0480 >
iter(list_of_numbers)
# < list_iterator object at 0x7fb193030780 >
iter(string_of_numbers)
# < str_iterator object at 0x7fb19303d320 >
Щоб отримати наступний об’єкт з ітератора, потрібно викликати метод next():
iterator = iter('123')
next(iterator)
'1'
next(iterator)
'2'
next(iterator)
'3'
next(iterator)
# Traceback(most recent call last):
# File "<pyshell#12>", line1, in < module > print(next(itr))
# StopIteration
Як працює for
Цикл for викликає метод iter() і до отриманого об’єкта застосовує метод next(), поки не зустріне виняток StopIteration.
Це називається протокол ітерації. Насправді він застосовується не тільки в циклі for, а й у генераторному виразі і навіть при розпакуванні та “зірочці”:
coordinates = [1, 2, 3]
x, y, z = coordinates
numbers = [1, 2, 3, 4, 5]
a, b, *rest = numbers
print(rest)
# [3, 4, 5]
У разі, якщо ми передаємо в iter() ітератор, то отримуємо той самий ітератор:
numbers = [1,2,3,4,5]
iter1 = iter(numbers)
iter2 = iter(iter1)
next(iter1)
# 1
next(iter2)
# 2
iter1 is iter2
# True
Підсумуємо.
Ітерований об'єкт (iterable) - це об’єкт, який здатний повертати елементи по одному (можна ітерувати).
Ітератор (iterator) - це сутність, породжувана функцією iter, за допомогою якої відбувається ітерування ітерованого об`єкта.
Ітератор не має індексів і може бути використаний тільки один раз.
Ітератори всюди
Ми вже бачили багато ітераторів у Python. Я вже згадував про те, що генератори - це теж ітератори. Багато вбудованих функції є ітераторами.
Так, наприклад, enumerate():
numbers = [1,2,3]
enumerate_var = enumerate(numbers)
enumerate_var
# <enumerate object at 0x7ff975dfdd80>
next(enumerate_var)
# (0, 1)
А також zip():
letters = ['a','b','c']
z = zip(letters, numbers)
z
# <zip object at 0x7ff975e00588>
next(z)
# ('a', 1)
І навіть open():
f = open('foo.txt')
next(f)
# 'bar\n'
next(f)
# 'baz\n'
У Python дуже багато ітераторів, і, як уже згадувалося вище, вони відкладають виконання роботи до того моменту, як ми запитуємо наступний елемент за допомогою next(). Так зване “ліниве” виконання.
Створення своїх ітераторів
Якщо потрібно обійти елементи всередині об’єкта вашого власного класу, необхідно побудувати свій ітератор. Створимо клас, об’єкт якого буде ітератором, що видає певну кількість одиниць, яку користувач задає під час
створенні об’єкта. Такий клас міститиме конструктор, що приймає на вхід кількість одиниць і метод __next__(), без нього екземпляри цього класу не будуть ітераторами.
class SimpleIterator:
def __init__(self, limit):
self.limit = limit
self.counter = 0
def __next__(self):
if self.counter < self.limit:
self.counter += 1
return 1
raise StopIteration
s_iter1 = SimpleIterator(3)
print(next(s_iter1))
print(next(s_iter1))
print(next(s_iter1))
print(next(s_iter1))
У нашому прикладі при четвертому виклику функції next() буде викинуто виняток StopIteration. Якщо ми хочемо, щоб із даним об’єктом можна було працювати в циклі for, то в клас SimpleIterator потрібно додати метод __iter__(), який повертає ітератор, у цьому випадку цей метод має повертати self.
class SimpleIterator:
def __iter__(self):
return self
def __init__(self, limit):
self.limit = limit
self.counter = 0
def __next__(self):
if self.counter < self.limit:
self.counter += 1
return 1
raise StopIteration
s_iter2 = SimpleIterator(5)
for i in s_iter2:
print(i)
Вираз ітератора
Об’єкт створений за допомогою list comprehension теж є ітератором.
iterator = [i for i in range(10)]
Генератори
Генератори — дуже потужний механізм в Python початково створений для спрощення написання ітераторів, але за допомогою якого можна вирішувати і деякі інші задачі, зокрема написання асинхроного коду.
Термін генератор (generator), в залежності від контекста, може означати або функцію-генератор, або ітератор генератора.
Функція-генератор (generator function) — це функція, яка повертає спеціальний ітератор генератора (generator iterator) або (інша назва) об’єкт-генератор (generator object).
Така функція характеризується наявністю ключового слова yield. Якщо в функції присутнє ключове слово yield, тоді Python створить не об’єкт функції, а об’єкт функції-генератора.
Коли викликається функція-генератор, її код, на відміну звичайним функціям, насправді не виконується. Замість цього функція-генератор повертає спеціальний об’єкт генератора, який також є ітератором. А вже коли виконується ітерування по цьому об’єкту, вже тоді виконується код функції-генератора, причому виконується як би “порціями”.
Код функції генератора виконується доти, доки не зустрінеться ключове слово yield. Інструкція yield як би ставить на паузу виконання і заморожує стан функції-генератора і повертає чергове значення генератора (або ж ітератора, у данному випадку це одне й те ж саме). Після наступного виклика __next__() функція-генератор продовжує своє виконання з того місця, де його було призупинено.
Коли виконання функції-генератора завершується (за допомогою ключового слова return або ж при досягненні кінця функції), піднімається виняток StopIteration.
Перевага використання генераторів для створення ітераторів полягає у тому, що магічні методи __iter__ і __next__ для генераторів створюються автоматично. Ми у функції-генераторі просто описуємо логіку (алгоритм) для отримання чергового значення.
Давайте розглянемо на прикладі. Створимо найпростіший генератор:
def gen():
yield 'Hello'
yield 'world'
Спершу може здатись, що це звичайнісінька собі функція:
>>> gen
<function gen at 0x0000017E2A663E18>
>>>
Але якщо ми викличемо таку функцію, то вона поверне об’єкт генератор:
>>> g = gen()
>>> g
<generator object gen at 0x0000017E2A72D308>
>>>
У цього об’єкта є метод __iter__(), отже це ітерабельний об’єкт:
>>> g.__iter__
<method-wrapper '__iter__' of generator object at 0x0000017E2A72D308>
>>>
Крім того є і метод __next__(), отже це і ітератор також:
>>> g.__next__
<method-wrapper '__next__' of generator object at 0x0000017E2A72D308>
>>>
Окей, тоді давайте проітеруємось по ньому:
>>> next(g)
'Hello'
>>> next(g)
'world'
>>> next(g)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
>>>
Проітеруємось за допомогою for:
>>> for i in gen(): print(i)
...
Hello
world
>>>
Отже, Генератор — це функція, що повертає ітератор, який під час ітерації генерує послідовність значень. Генератори корисні, коли нам потрібно отримати велику послідовність значень, але ми не хочемо зберігати їх всі в пам’яті відразу.
Генератори - це теж ітератори.
Return vs Yield
Ключове слово return - це фінальна інструкція у функції. Вона надає спосіб для повернення значення. При повернення весь локальний стек очищається. І новий виклик почнеться з першої інструкції.
Ключове слово yield ж зберігає стан між викликами. Виконання триває з моменту, де управління було передано в область, що викликає, тобто, відразу після останньої інструкції yield.
Генератор vs. Функція
Далі перераховано основні відмінності між генератором і звичайною функцією.
Генератор використовує yield для відправлення значення користувачеві, а у функції для цього є return;
-
При використанні генератора може бути більше одного виклику
yield; -
Виклик
yieldзупиняє виконання і повертає ітератор, аreturnзавжди виконується останнім; -
Виклик методу
next()призводить до виконання функції генератора; -
Локальні змінні та стани зберігаються між послідовними викликами методу
next(); -
Кожен додатковий виклик
next()викликає винятокStopIteration, якщо немає наступних елементів для обробки.
Далі приклад функції генератора з кількома yield.
def testGen():
x = 2
print('Перший yield')
yield x
x *= 1
print('Другий yield')
yield x
x *= 1
print('Останній yield')
yield x
# Виклик генератора
iter = testGen()
# Виклик першого yield
next(iter)
# Виклик другого yield
next(iter)
# Виклик останнього yield
next(iter)
Висновок:
Перший yield
Другий yield
Останній yield
Генератори теж реалізують протокол ітератора:
Якщо генератор зустрічає return, то в цей момент генерується виняток StopIteration
Якщо функція завершується без return, то після останнього рядка викликається return без параметрів, що і викличе StopIteration у наступному прикладі:
>>> def custom_range(number):
... index = 0
... while index < number:
... yield index
... index += 1
...
>>> range_of_four = custom_range(4)
>>> next(range_of_four)
0
>>> next(range_of_four)
1
>>> next(range_of_four)
2
>>> next(range_of_four)
3
>>> next(range_of_four)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Коли використовувати генератор?
Є багато ситуацій, коли генератор виявляється корисним. Ось деякі з них:
-
Генератори допомагають обробляти великі обсяги даних. Вони дають змогу виконувати так звані ледачі обчислення.
-
Подібним чином відбувається потокове оброблення. Генератори можна встановлювати один за одним і використовувати їх як Unix-канали.
-
Генератори дають змогу налаштувати одночасне виконання.
-
Вони часто використовуються для читання великих файлів. Це робить код чистішим і компактнішим, розділяючи процес на дрібніші сутності.
-
Генератори особливо корисні для веб-скрапінгу та збільшення ефективності пошуку. Вони дають змогу отримати одну сторінку, виконати якусь операцію і рухатися до наступної. Цей підхід набагато ефективніший, ніж отримання всіх сторінок одразу і використання окремого циклу для їх обробки.
Навіщо використовувати генератори?
Генератори надають різні переваги для програмістів і розширюють особливості, які проявляються під час виконання.
Зручні для програмістів
Генератор здається складною концепцією, але його легко використовувати в програмах. Це хороша альтернатива ітераторам.
Розглянемо наступний приклад реалізації арифметичної прогресії за допомогою класу ітератора.
Створення арифметичної прогресії за допомогою класу ітератора:
class AP:
def __init__(self, a1, d, size):
self.ele = a1
self.diff = d
self.len = size
self.count = 0
def __iter__(self):
return self
def __next__(self):
if self.count >= self.len:
raise StopIteration
elif self.count == 0:
self.count += 1
return self.ele
else:
self.count += 1
self.ele += self.diff
return self.ele
for ele in AP(1, 2, 10):
print(ele)
Ту саму логіку куди простіше написати за допомогою генератора.
Генерація арифметичної прогресії за допомогою функції генератора:
def ap(a1, d, size):
count = 1
while count <= size:
yield a1
a1 += d
count += 1
for ele in ap(1, 2, 10):
print(ele)
Економія пам’яті
Якщо використовувати звичайну функцію для повернення списку, то вона сформує цілу послідовність у пам’яті перед відправленням. Це призведе до використання великої кількості пам’яті, що неефективно.
Генератор же використовує набагато менше пам’яті за рахунок обробки одного елемента за раз.
Обробка великих даних
Генератори корисні при обробці особливо великих обсягів даних, наприклад, Big Data. Вони працюють як нескінченний потік даних.
Такі обсяги не можна зберігати в пам’яті. Але генератор, що видає по одному елементу за раз, являє собою цей нескінченний потік.
Наступний код теоретично може видати всі прості числа.
Знайдемо всі прості числа за допомогою генератора:
def find_prime():
num = 1
while True:
if num > 1:
for i in range(2, num):
if not num % i:
break
else:
yield num
num += 1
for ele in find_prime():
print(ele)
Послідовність генераторів
За допомогою генераторів можна створити послідовність різних операцій. Це чистіший спосіб поділу обов’язків
між усіма компонентами і подальшої інтеграції їх для отримання потрібного результату.
Ланцюжок кількох операцій з використанням pipeline генератора:
def find_prime():
num = 1
while num < 100:
if num > 1:
for i in range(2, num):
if not num % i:
break
else:
yield num
num += 1
def find_even_prime(seq):
for num in seq:
if not num % 2:
yield num
a_pipeline = find_even_prime(find_prime())
for a_ele in a_pipeline:
print(a_ele)
У прикладі вище пов’язані дві функції. Перша знаходить усі прості числа від 1 до 100, а друга - вибирає парні.
yield from
yield from - це синтаксис, запроваджений у Python 3.3, який дає змогу делегувати частину операцій генератору. Він корисний при роботі з вкладеними ітерованими об’єктами, такими як списки в списку.
# звичайний yield
def numbers_range(n):
for i in range(n):
yield i
# yield from
def numbers_range(n):
yield from range(n)
Тепер давайте розглянемо приклад використання yield from:
def flatten(nested_list):
for sublist in nested_list:
for item in sublist:
yield item
nested_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(list(flatten(nested_list)))
Наведений вище код перетворює вкладений список на плоский список. Однак, це можна зробити простіше за допомогою yield from:
def flatten(nested_list):
for sublist in nested_list:
yield from sublist
nested_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(list(flatten(nested_list)))
yield from приймає як параметр ітератор.
Нагадую, генератор - це теж ітератор.
А значить yield from може приймати інший генератор:
def subgenerator():
yield 'World'
def generator():
yield 'Hello,'
yield from subgenerator() # Запитуємо значення з функції subgenerator()
yield '!'
for i in generator():
print(i, end=' ')
# Вивід
Hello, World !
Цю найважливішу властивість ми і будемо використовувати далі.
Генераторні вирази та особливості генераторів
У разі використання виразу-генератора ми не зберігаємо значення, а значить, що ми можемо використовувати його тільки 1 раз:
gen = (x for x in range(0, 100 * 10000))
100 in gen
True
100 in gen
False
Корутини
А тепер про те, заради чого це, власне, затівалося. Виявляється, генератор може не тільки повертати значення, а й приймати їх на вхід.
Про стандарт можна почитати тут PEP 342.
Пропоную відразу почати з прикладу. Напишемо просту реалізацію генератора, який може складати два аргументи, зберігати історію результатів і виводити історію.
def calc():
history = []
while True:
x = yield
if x == 'h':
print(history)
continue
print(x)
history.append(x)
c = calc()
next(c) # Необхідна ініціація. Можна написати c.send(None)
c.send(1) # Виведе 1
c.send(100) # Виведе 100
c.send(666) # Виведе 666
c.send('h') # Виведе [1, 100, 666]
c.close() # Закриваємо генератор, дані зітруться, генератор необхідно буде створювати заново.
Приклад із передачею більш ніж одного параметра
def calc():
history = []
while True:
x, y = (yield)
if x == 'h':
print(history)
continue
result = x + y
print(result)
history.append(result)
c = calc()
next(c) # Необхідна ініціація. Можна написати c.send(None)
c.send((1, 2)) # Виведе 3
c.send((100, 30)) # Виведе 130
c.send((666, 0)) # Виведе 666
c.send(('h', 0)) # Виведе [3, 130, 666]
c.close() # Закриваємо генератор, дані зітруться, генератор необхідно буде створювати заново.
send, throw, close
У Python 2.5 додали в генератори можливість надсилати дані та exception.
-
send- передача даних у корутину.send(None)- рівносильноnext. -
throw- передача винятку в корутину. Наприклад,GeneratorExitдля виходу з корутини. -
close- для “закриття” корутини та очищення локальної пам’яті корутини.
Корутина як декоратор
Тобто ми створили генератор, проініціалізували його і подаємо йому вхідні дані. У свою чергу він ці дані обробляє і зберігає свій стан між викликами доти, доки ми його не закрили. Після кожного виклику генератор повертає керування туди, звідки його викликали. Цю найважливішу властивість генераторів ми і будемо використовувати.
Тепер, коли ми розібралися із загальним принципом роботи, давайте тепер позбавимо себе необхідності щоразу руками ініціалізувати генератор. Вирішимо це типовим для Python чином, за допомогою декоратора.
def coroutine(f):
def wrap(*args, **kwargs):
gen = f(*args, **kwargs)
gen.send(None)
return gen
return wrap
@coroutine
def calc():
history = []
while True:
x, y = (yield)
if x == 'h':
print(history)
continue
result = x + y
print(result)
history.append(result)
І тепер, власне, до визгачення
Співпрограма або співпроцедура (англ. Coroutine) - програмний модуль, особливим чином організований для забезпечення взаємодії з іншими модулями за принципом кооперативної багатозадачності: модуль призупиняється в певній точці, зберігаючи повний стан (включно зі стеком викликів і лічильником команд), і передає управління іншому, той, своєю чергою, виконує завдання і передає управління назад, зберігаючи свої стек і лічильник.
Subroutine - підпрограма або функція (а ще procedure, sub-process).
Asyncio
Починаючи з Python 3.4, існує новий модуль asyncio, який вводить API для узагальненого асинхронного програмування. Ми можемо використовувати корутини з цим модулем для простого і зрозумілого виконання асинхронного коду.
Ми можемо використовувати корутини разом із модулем asyncio для простого виконання асинхронних операцій. Приклад із офіційної документації:
Важливе зауваження! asyncio дуже швидко розвивається і змінюється, те, що працювало ще вчора, сьогодні може бути deprecated і видалене.
import asyncio
import datetime
import random
@asyncio.coroutine
def display_date(num, loop):
end_time = loop.time() + 50.0
while True:
print(f"Loop: {num} Time: {datetime.datetime.now()}")
if (loop.time() + 1.0) >= end_time:
break
yield from asyncio.sleep(random.randint(0, 5))
loop = asyncio.get_event_loop()
asyncio.ensure_future(display_date(1, loop))
asyncio.ensure_future(display_date(2, loop))
loop.run_forever()
Ми створили функцію display_date(num, loop), яка приймає два аргументи, перший - номер, а другий - цикл подій, після чого наша корутина друкує поточний час. Після чого використовується ключове слово yield from для очікування результату виконання asyncio.sleep, яка є корутиною, що виконується через зазначену кількість секунд (пауза виконання), ми у своєму коді передаємо в цю функцію випадкову кількість секунд. Після чого ми використовуємо asyncio.ensure_future для планування виконання корутини в циклі подій. Після чого ми вказуємо, що цикл подій має працювати нескінченно довго.
Якщо ми подивимося на виведення програми, то побачимо, що дві функції виконуються одночасно. Коли ми використовуємо yield from, цикл обробки подій знає, що він буде якийсь час зайнятий, тому він призупиняє виконання функції та запускає іншу. Таким чином, дві функції працюють одночасно (але не паралельно, оскільки цикл обробки подій є однопотоковим).
Варто зазначити, що yield from - це синтаксичний цукор для
for x in asyncio.sleep(random.randint(0, 5)):
yield x
який робить код чистішим і простішим.
Підтримка співпрограм на основі генератора (@asyncio.coroutine) :
Застаріло з версії 3.8, буде видалено у версії 3.11.
Вбудовані корутини
@asyncio.coroutine
def old_style_coroutine():
yield from asyncio.sleep(1)
async def main():
await old_style_coroutine()
Пам’ятаєте, ми все ще використовуємо функції на основі генератора? У Python 3.5 ми отримали нові вбудовані корутини, які використовують синтаксис async / await. Попередня функція може бути написана так:
import asyncio
import datetime
import random
async def display_date(num, loop):
end_time = loop.time() + 10.0
while True:
print(f"Loop: {num} Time: {datetime.datetime.now()}")
if (loop.time() + 1.0) >= end_time:
break
await asyncio.sleep(random.randint(0, 5))
loop = asyncio.get_event_loop()
asyncio.ensure_future(display_date(1, loop))
asyncio.ensure_future(display_date(2, loop))
loop.run_forever()
Фактично змінені були тільки рядки 6 і 12, для визначення вбудованої корутини визначення функції позначається ключовим словом async, а замість yield from використовується await.
Корутини на генераторах і вбудовані корутини
Функціонально немає жодної різниці між корутинами на генераторах і вбудованими корутинами, крім відмінності в синтаксисі.
Крім того, не допускається змішування їхніх синтаксисів. Тобто не можна використовувати await всередині корутин на генераторах або yield / yeild from всередині вбудованих корутин.
Незважаючи на відмінності, ми можемо організовувати взаємодії між ними. Нам просто потрібно додати декоратор @types.coroutine до старих генераторів. Тоді ми можемо використовувати старий генератор із вбудованих корутин і навпаки.
Приклад для Python 3.6:
import asyncio
import datetime
import random
import types
@types.coroutine
def my_sleep_func():
yield from asyncio.sleep(random.randint(0, 5))
async def display_date(num, loop):
end_time = loop.time() + 50.0
while True:
print(f"Loop: {num} Time: {datetime.datetime.now()}")
if (loop.time() + 1.0) >= end_time:
break
await my_sleep_func()
loop = asyncio.get_event_loop()
asyncio.ensure_future(display_date(1, loop))
asyncio.ensure_future(display_date(2, loop))
loop.run_forever()
# Output:
# Loop: 1 Time: 2023-08-14 16:26:35.231695
# Loop: 2 Time: 2023-08-14 16:26:35.231792
# Loop: 2 Time: 2023-08-14 16:26:37.233039
# Loop: 2 Time: 2023-08-14 16:26:38.234310
# Loop: 1 Time: 2023-08-14 16:26:40.232999
# Loop: 1 Time: 2023-08-14 16:26:40.233097
# ...
Event Loop
Asyncio — це бібліотека для асинхронного програмування в Python. Вона дозволяє писати асинхронний код, який працює ефективніше, ніж синхронний код, особливо при роботі з мережевими додатками. Asyncio базується на концепції event loop, яка керує виконанням асинхронних корутин.
Як працює asyncio
Asyncio використовує концепцію event loop для керування виконанням асинхронного коду. Event loop — це безкінечний цикл, який очікує на події та виконує функції, пов’язані з цими подіями.
У циклі event loop виконуються корутини — це функції, які можуть бути призупинені та відновлені в процесі виконання. Корутини дозволяють асинхронно виконувати завдання та призупиняти виконання, якщо потрібно дочекатися результатів інших завдань.
Цей цикл зазвичай чекає поки щось відбудеться і потім реагує на подію. Такими подіями можуть бути ввід, вивід (I/O) чи системні події. Також для asyncio існує декілька реалізацій циклу подій.
Якщо коротко, то цикл подій працює так: “коли відбудеться подія А, відреагувати функцією В”.
Наприклад, сервер: він чекає поки хтось не попросить ресурс (такий як веб-сторінка) і потім його віддає. Якщо сайт не дуже популярний, то сервер більшість часу буде перебувати в стані спокою, але коли він отримає запит, то повинен відреагувати. Така реакція називається перехопленням подій (event handling). Коли користувач запитує веб-сторінку, сервер шукає обробника чи декілька обробників цієї події і викликає їх. Коли перехоплювачі закінчать свою роботу, вони повинні повернути управління циклу подій. Щоб реалізувати це в Python asyncio використовує співпрограми (coroutines).
Ще одним терміном, що використовується в asyncio є future. Future це об’єкт, що відповідає результату роботи, що ще не виконана. Ваш цикл подій може спостерігати за ним і чекати поки робота не буде завершена. Основне призначення Future полягає в забезпеченні зв’язку між кодом який виконується в loop-і та кодом зовні який в змозі отримати результат по закінченню виконання кода.
Task — є нащадком Future, а також є основною частиною (ядром), що пов’язує loop із coroutine. Додатковою властивістю до Future є те, що Task наче обгортка для coroutine яка розуміється на призупиненні та продовженні виконання coroutine.
Loop, run, create_task, gather, etc.
loop
loop - один набір подій, до версії Python 3.7 будь-які корутини запускалися виключно всередині loop
Давайте розглянемо приклад, де окрема корутина обчислює факторіал послідовно (спочатку 2, потім 3, потім 4 і т. д.) і робить паузу на одну секунду перед наступним обчисленням:
import asyncio
async def factorial(name, number):
f = 1
for i in range(2, number + 1):
print(f"Task {name}: Compute factorial({i})...")
await asyncio.sleep(1)
f *= i
print(f"Task {name}: factorial({number}) = {f}")
loop = asyncio.get_event_loop()
loop.run_until_complete(factorial('A', 4))
Зверніть увагу, цей код працюватиме на Python 3.6+
run
Те ж саме для Python 3.7+ матиме такий вигляд:
import asyncio
async def factorial(name, number):
f = 1
for i in range(2, number + 1):
print(f"Task {name}: Compute factorial({i})...")
await asyncio.sleep(1)
f *= i
print(f"Task {name}: factorial({number}) = {f}")
asyncio.run(factorial('A', 4)) # Добавлено в 3.7
# Output:
# Task A: Compute factorial(2)...
# Task A: Compute factorial(3)...
# Task A: Compute factorial(4)...
# Task A: factorial(4) = 24
create_tasks
Розглянемо код, у якому основна корутина запускає дві інші.
import asyncio
import time
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
print(f"Started at {time.strftime('%X')}")
await say_after(1, 'hello,')
await say_after(2, 'world')
print(f"finished at {time.strftime('%X')}")
asyncio.run(main())
# Output:
# Started at 16:28:52
# hello,
# world
# finished at 16:28:55
Чи зобов’язані ми задавати параметри там, де і запускаємо корутину? Ні, ми можемо зробити це через create_task.
import asyncio
import time
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
task1 = asyncio.create_task(
say_after(1, 'hello,'))
task2 = asyncio.create_task(
say_after(2, 'world'))
print(f"started at {time.strftime('%X')}")
# Зачекайте, доки обидва завдання не будуть виконані (має минути близько 2 секунд.)
await task1
await task2
asyncio.run(main())
Спроба запустити асинхронний метод синхронно не приведе ні до чого, це просто не буде працювати.
import asyncio
async def nested():
return 42
async def main():
# Нічого не станеться, якщо ми просто викличемо "nested()".
# Об'єкт корутини створений, але не await,
# так що *не буде працювати взагалі*.
nested()
# Зробимо по-іншому зараз і дочекаємося його:
print(await nested()) # виведе "42".
asyncio.run(main())
gather
Що якщо нам необхідно запустити асинхронно кілька однакових завдань з різними параметрами? Нам допоможе gather.
Повернемося до коду з факторіалами:
import asyncio
async def factorial(name, number):
f = 1
for i in range(2, number + 1):
print(f"Task {name}: Compute factorial({i})...")
await asyncio.sleep(1)
f *= i
print(f"Task {name}: factorial({number}) = {f}")
async def main():
# Запланувати дерево викликів *конкурентно*:
await asyncio.gather(
factorial("A", 2),
factorial("B", 3),
factorial("C", 4),
)
asyncio.run(main())
# Output:
# Task A: Compute factorial(2)...
# Task B: Compute factorial(2)...
# Task C: Compute factorial(2)...
# Task A: factorial(2) = 2
# Task B: Compute factorial(3)...
# Task C: Compute factorial(3)...
# Task B: factorial(3) = 6
# Task C: Compute factorial(4)...
# Task C: factorial(4) = 24
Зверніть увагу, якщо вам необхідно повернути значення, ви вільно можете використовувати return, де це необхідно.
import asyncio
async def factorial(name, number):
f = 1
for i in range(2, number + 1):
print(f"Task {name}: Compute factorial({i})...")
await asyncio.sleep(1)
f *= i
print(f"Task {name}: factorial({number}) = {f}")
return f
async def main():
# Запланувати дерево викликів *конкурентно*:
res = await asyncio.gather(
factorial("A", 4),
factorial("B", 3),
factorial("C", 2),
)
print(res)
asyncio.run(main())
Ви можете бути впевнені в тому, що у змінну res результати прийдуть саме в тому порядку, в якому ви їх запросили, у прикладі результат завжди буде [24, 6, 2], жодної несподіванки.
Aiohttp
Як ми пам’ятаємо, одна з основних переваг використання асинхронності - це можливість надсилання паралельних HTTP запитів, не чекаючи результатів інших. На жаль, при використанні корутин разом із класичним requests запити будуть виконані синхронно, оскільки самі запити не є awaitable об’єктами, і результат буде таким самим, як якби ви використовували звичайний sleep, а не асинхронними, сусідні корутини чекатимуть на інші. Щоб такого не було, існує спеціальний пакет aiohttp, його необхідно встановлювати через pip:
pip install aiohttp
Після чого необхідно створити асинхронний клієнт, і можна виконувати запити.
import aiohttp
import asyncio
async def main():
async with aiohttp.ClientSession() as session:
async with session.get('https://httpbin.org/#/HTTP_Methods/get_get') as resp:
print(resp.status)
print(await resp.text())
asyncio.run(main())
# Output:
# 200
# <!DOCTYPE html>
# <html lang="en">
#
# <head>
# <meta charset="UTF-8">
# <title>httpbin.org</title>
# <link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700"
# rel="stylesheet">
# ...
Multiprocessing VS Multihreading VS Asyncio
**
**
if io_bound:
if io_very_slow:
print("Use Asyncio")
else:
print("Use Threads")
else:
print("Multi Processing")
- CPU Bound => Multi Processing
- I/O Bound, Fast I/O, Limited Number of Connections => Multi Threading
- I/O Bound, Slow I/O, Many connections => Asyncio
Література
Домашнє завдання:
Об’єднавши знання, можна приступати до практики і зробити ті самі завдання, що й на минулому занятті, але тепер за допомогою корутин.
- Написати функцію, яка робитиме запити на
https://google.com,https://amazon.com,https://microsoft.com.
Оцінити час виконання.
- 1.1 зробити по 5 запитів на кожен сайт, отримати час.
- Написати функцію, яка зводить числа 2, 3 і 5, у 1000000 ступінь. Оцінити час виконання, зробити висновки.