Лекция 2. Событийно-управляемая модель и компонентно-ориентированный подход (Event-Driven Programming)¶
Событийно-управляемая модель программирования¶
Событийно-ориентированное программирование (event-driven programming) — парадигма, в которой выполнение программы определяется событиями: действиями пользователя (клавиатура, мышь), сообщениями других программ и потоков, событиями операционной системы (например, поступлением сетевого пакета).
Событийно-ориентированное программирование можно также определить как способ построения компьютерной программы, при котором в коде (как правило, в головной функции) явным образом выделяется главный цикл приложения (event loop), тело которого состоит из двух частей: выборки события и обработки события.
Где применяется¶
- при построении пользовательских интерфейсов (в том числе графических);
- при создании серверных приложений — если по тем или иным причинам нежелательно порождение обслуживающих процессов;
- при программировании игр, в которых осуществляется управление множеством объектов.
Применение в серверных приложениях¶
Событийно-ориентированное программирование применяется в серверах для решения проблемы масштабирования на 10 000+ одновременных соединений (так называемая C10K problem). В серверах «один поток на соединение» проблемы возникают по следующим причинам:
- слишком велики накладные расходы на структуры данных ОС, необходимые для описания одной задачи (сегмент состояния задачи, стек);
- слишком велики накладные расходы на переключение контекстов потоков.
Альтернатива — один поток обслуживает много соединений, переключаясь между ними при возникновении событий I/O. Так устроены nginx, Node.js, Tornado, Twisted, asyncio.
Генераторы и yield в Python¶
Прежде чем разбирать корутины (coroutines), нужно понять генераторы.
Итерируемые объекты (iterables)¶
Объект, по которому можно проходить циклом for ... in ..., называется итерируемым. Списки, строки, файловые объекты — все итерируемые.
Итерируемые объекты удобны, но если последовательность большая — все её значения должны храниться в памяти. Это не всегда приемлемо.
Генераторы¶
Генератор — итерируемый объект, который не хранит все значения в памяти, а генерирует их «на лету» — по мере запроса:
gen = (x * x for x in range(3)) # генераторное выражение (parentheses, не brackets)
for i in gen:
print(i)
# 0
# 1
# 4
Генератор можно использовать только один раз. После того как все значения выданы, попытка получить следующее приведёт к исключению StopIteration:
В цикле for это исключение перехватывается и интерпретируется как конец цикла.
Ключевое слово yield¶
yield — ключевое слово, похожее на return. Разница: функция при этом начинает возвращать генератор, а не значение.
def generator():
for i in (1, 2, 3):
yield i
g = generator()
print(g) # <generator object generator at 0x...>
for i in g:
print(i)
# 1
# 2
# 3
Когда вы вызываете функцию с yield, её тело не выполняется сразу. Вместо этого возвращается объект-генератор. Код тела выполняется только при каждой итерации — при вызове next() или в цикле for. При каждом yield выполнение приостанавливается, возвращая управление вызывающему коду.
Реальная польза — когда функция должна возвращать большой объём данных, но использовать их нужно только один раз (или по одному):
def read_huge_file(path):
with open(path) as f:
for line in f:
yield line.strip()
# не загружаем весь файл в память — обрабатываем построчно
for line in read_huge_file("data.txt"):
process(line)
Двусторонняя коммуникация: send()¶
В расширенных генераторах yield — это выражение, возвращающее значение. Через метод send() можно передать значение в работающий генератор:
def gen_factory():
state = None
while True:
print("state:", state)
state = yield state
gen = gen_factory()
next(gen) # state: None (запуск генератора)
gen.send("OK") # state: OK → возвращает "OK"
gen.send("STOP") # state: STOP → возвращает "STOP"
Это основа для построения корутин (coroutines).
Модуль itertools¶
Стандартная библиотека Python предоставляет модуль itertools с эффективными итераторами:
import itertools
horses = [1, 2, 3]
races = itertools.permutations(horses)
print(list(races))
# [(1, 2, 3), (1, 3, 2), (2, 1, 3), (2, 3, 1), (3, 1, 2), (3, 2, 1)]
С itertools можно: клонировать итератор (tee), цепочкой объединять итераторы (chain), группировать (groupby), генерировать сочетания и перестановки. Применение list() к генератору вычисляет все его значения и создаёт список.
Понимание механики итерации¶
- Итерируемый объект реализует метод
__iter__()— возвращающий итератор. - Итератор реализует метод
__next__()— возвращающий следующее значение или вызывающийStopIteration.
class CountDown:
def __init__(self, start):
self.start = start
def __iter__(self):
return self # объект сам является итератором
def __next__(self):
if self.start <= 0:
raise StopIteration
self.start -= 1
return self.start + 1
for n in CountDown(3):
print(n)
# 3
# 2
# 1
Реализация событийной парадигмы через сопрограммы¶
С помощью расширенных генераторов Python можно сделать собственную реализацию кооперативной многозадачности — без потоков ОС, без callback-ов, в одном потоке.
Что такое сопрограмма¶
В нашем определении сопрограмма обладает следующими характеристиками:
- выполняется совместно с другими в одном потоке;
- выполнение может прерываться для ожидания определённого события;
- выполнение может возобновиться после получения ожидаемого события;
- может вернуть результат по завершении.
Эффект от использования такой парадигмы существен для задач, реагирующих на неравномерно поступающие события: сетевые серверы, пользовательские интерфейсы, игры. Не подойдёт для задач с долгими вычислениями — пока выполняющаяся сопрограмма не прервалась на ожидание события, все остальные находятся в состоянии останова, даже если ожидаемое ими событие уже произошло.
Пример: периодические приветствия¶
Задача: передавать привет Петрову раз в две секунды, Иванову — раз в три секунды, всему миру — раз в пять секунд. «В лоб» так не сделать:
from time import sleep
def hello(name, timeout):
while True:
sleep(timeout)
print(f"Привет, {name}!")
hello("Петров", 2.0) # бесконечный цикл — Иванов никогда не получит привет
hello("Иванов", 3.0)
hello("Мир", 5.0)
С сопрограммами:
@coroutine
def hello(name, timeout):
while True:
yield from sleep(timeout)
print(f"Привет, {name}!")
hello("Петров", 2.0)
hello("Иванов", 3.0)
hello("Мир", 5.0)
run() # запуск диспетчера событий
Линейный код без callback-ов. Реализация декоратора coroutine, функции sleep и run см. ниже.
Современная альтернатива — asyncio¶
В реальной разработке вместо самописных сопрограмм используют стандартный модуль asyncio (со специальным синтаксисом async def / await, начиная с Python 3.5):
import asyncio
async def hello(name, timeout):
while True:
await asyncio.sleep(timeout)
print(f"Привет, {name}!")
async def main():
await asyncio.gather(
hello("Петров", 2.0),
hello("Иванов", 3.0),
hello("Мир", 5.0),
)
asyncio.run(main())
async def создаёт корутину; await приостанавливает её до завершения другой корутины; asyncio.gather запускает несколько параллельно; asyncio.run — точка входа в event loop.
Аналог в Go: горутины и каналы¶
Go решает ту же задачу через горутины (goroutines) — лёгкие потоки исполнения, управляемые рантаймом Go (M:N модель), и каналы (channels) для коммуникации:
package main
import (
"fmt"
"time"
)
func hello(name string, timeout time.Duration) {
for {
time.Sleep(timeout)
fmt.Printf("Привет, %s!\n", name)
}
}
func main() {
go hello("Петров", 2*time.Second)
go hello("Иванов", 3*time.Second)
go hello("Мир", 5*time.Second)
select {} // ждём вечно
}
Запуск горутины — оператор go. Никакого декоратора и явного event loop — рантайм Go сам управляет планированием. Это основное преимущество Go для серверной разработки: модель concurrency by communicating (CSP) делает параллелизм идиоматичным.
Компонентно-ориентированный подход¶
ООП хорошо себя зарекомендовал, но при разработке сложных систем — особенно моделирующих процессы окружающего мира — программист сталкивается с рядом проблем, для решения которых нужен принципиально иной подход.
Недостатки чистого ООП в сложных системах¶
- Недостаточный контроль безопасности. В языках без GC программист может забыть освободить память или освободить её дважды.
- Взаимозависимость объектов и проблема хрупкого базового класса. При изменении базового класса могут возникнуть ошибки в наследниках. Ради небольшого изменения функциональности подчас приходится перестраивать архитектуру всей системы.
- Зависимость от языка программирования и платформы. Объекты, написанные на разных языках, могут оказаться несовместимы.
- Высокие требования к квалификации программиста. Сложные системы из большого числа взаимодействующих объектов требуют опыта.
- Сложность контроля жизненного цикла объекта. Жизненный цикл начинается с конструктора и заканчивается деструктором. После уничтожения объекта вся информация о его состоянии теряется. Для сохранения состояния программист должен сам реализовать CRUD-функциональность.
Компонент vs объект¶
Компонентно-ориентированное программирование (КОП) направлено прежде всего на повышение надёжности коммерческих бизнес-систем. Суть КОП — возможность контролировать взаимодействие проектируемых и выполняемых модулей на предмет согласованности информационных структур. Идеи КОП воплощены в Java, Ada, C#; прямым применением — Modula-2, Oberon, Oberon-2, Компонентный Паскаль.
Компонент функционирует в среде как часть единой системы — фреймворка. Фреймворк предоставляет унифицированный способ доступа к ресурсам среды в рамках компонентной модели.
Под средой обычно понимается среда выполнения — вычислительное окружение, необходимое для выполнения программы. Компонент взаимодействует и с операционной системой / виртуальной машиной, и с пользователем, БД, периферийными устройствами, используя возможности фреймворка.
Если жизненный цикл объекта в ООП известен заранее, то компоненты, являющиеся частью сложной системы, могут инициировать взаимодействие друг с другом «по требованию». На этапе написания компонент ограничен возможностями компонентной модели — поэтому компоненты должны уметь динамически обрабатывать события и обмениваться информацией о своих свойствах и умениях.
Особенности КОП¶
- Интроспективность — способность компонентов к самоописанию.
- Модульность и разграничение уровней доступа.
- Автоматическая обработка исключительных ситуаций.
- Автоматическое управление памятью.
- Позднее связывание и динамический контроль типов.
- Обработка событий — основа взаимодействия в среде.
- Персистентность — способность компонентов сохранять и восстанавливать состояние.
- Простота повторного использования.
Современные параллели¶
КОП в чистом виде сейчас редко обсуждается под этим названием, но его идеи широко применяются:
- Контейнеры и микросервисы (Docker, Kubernetes) — каждый сервис как изолированный «компонент»;
- Plugin-системы (VS Code, Eclipse, IntelliJ) — компоненты, динамически загружаемые в фреймворк;
- Web Components в браузере — переиспользуемые HTML-элементы со своим стилем и логикой;
- Компонентные UI-фреймворки — React, Vue, Angular (Web), SwiftUI, Jetpack Compose (mobile).
Контрольные вопросы¶
- Что такое событийно-ориентированное программирование? Где оно применяется?
- В чём отличие модели «один поток на соединение» от событийной модели сервера?
- Что такое генератор в Python? Зачем нужно ключевое слово
yield? - Что такое корутина? В чём отличие
async defот обычной функции? - Что такое горутина в Go? Как она соотносится с корутиной в Python?
- В чём отличие компонента от объекта?
- Перечислите особенности компонентно-ориентированного программирования.