Перейти к содержанию

Лекция 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 ..., называется итерируемым. Списки, строки, файловые объекты — все итерируемые.

lst = [1, 2, 3]
for i in lst:
    print(i)
# 1
# 2
# 3

Итерируемые объекты удобны, но если последовательность большая — все её значения должны храниться в памяти. Это не всегда приемлемо.

Генераторы

Генератор — итерируемый объект, который не хранит все значения в памяти, а генерирует их «на лету» — по мере запроса:

gen = (x * x for x in range(3))  # генераторное выражение (parentheses, не brackets)
for i in gen:
    print(i)
# 0
# 1
# 4

Генератор можно использовать только один раз. После того как все значения выданы, попытка получить следующее приведёт к исключению StopIteration:

gen = (x * x for x in range(3))
next(gen)  # 0
next(gen)  # 1
next(gen)  # 4
next(gen)  # 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?
  • В чём отличие компонента от объекта?
  • Перечислите особенности компонентно-ориентированного программирования.