Использование паттерна SchedulableObject для выделения бизнес-логики в отдельный поток

fcc138bff4954a92ad408a51e7170205.jpg

Интерфейс мобильного приложения — это лицо продукта. Чем более отзывчив интерфейс, тем больше радости приносит продукт. Однако удовлетворённость от использования приложения зависит прежде всего от объёма его функций. По мере увеличения количества и сложности задач они требуют всё больше и больше времени. Если архитектура приложения предполагает, что все они выполняются в главном потоке, то задачи бизнес-логики начинают конкурировать за время с задачами отрисовки интерфейса. При таком подходе рано или поздно обязательно находится сценарий, исполнение которого приводит к залипанию приложения. Для борьбы с этой напастью существует три принципиально разных подхода:

  1. Оптимизация алгоритмов и структур данных, вовлечённых в исполнение проблемного сценария.
  2. Вынос проблемного сценария из главного потока.
  3. Вынос из главного потока всех функций приложения, за исключением собственно рендеринга пользовательского интерфейса.

Паттерн SchedulableObject позволяет аккуратно реализовать третий сценарий. Под катом рассматриваются его части с примерами реализации на Swift, а также преимущества и недостатки по сравнению с первыми двумя подходами.

Постановка задачи
Принято считать плавным такой интерфейс, который может обновляться не менее 60 раз в секунду. На эти цифры можно посмотреть и с другой стороны:

0a17f74222f24d959c8ec82204f286dc.png

Получается, что каждый цикл обработки событий должен успевать завершаться за 16,7 мс. Допустим, пользователь наблюдает окно, которое может отрисоваться за 10 мс. Это означает, что все задачи бизнес-логики должны успеть выполниться за 6,7 мс.

5eadf704065c43898802ea1fe254293d.png
Рассмотрим в качестве примера бизнес-логику добавления файла в Облако. Она состоит из множества этапов, суть которых в контексте данной статьи нас не интересует.

1b2af58ae97a4b7b8e4704c7a019c29a.png

Важно то, что все они вместе занимают 2,6 мс. Поделив максимальное время, отведённое на работу бизнес-логики, на время добавления одного файла, мы получим 3. Значит, если приложение хочет сохраняться отзывчивым при отработке данного сценария, оно не может добавлять более трёх файлов единовременно. К счастью для пользователя, но к сожалению для разработчиков, в приложении Облако существуют кейсы, когда необходимо добавить более трёх файлов за раз.

841e9206a94c4e16b3b4c3def8421424.png

На скриншотах выше изображены некоторые из них:

  1. Множественный выбор файлов из системной галереи устройства.
  2. Автоматическая загрузка новых файлов из галереи.

В данный момент, чтобы избежать длительного подвисания приложения, скорость добавления файлов в очередь на загрузку сервисом автозагрузки искусственно ограничивается методом позорных констант. Вот две из них:
// Позорные константы сервиса автозагрузки
static uint const kMRCCameraUploaderBatchSize = 1000;
static NSTimeInterval const kMRCCameraUploaderBatchDelaySec = 5;

Их семантика такова: по итогам сканирования галереи на предмет новых фото сервис должен добавлять их в очередь загрузки пачками не более 1000 штук каждая с интервалом 5 с. Но даже при таком ограничении мы имеем подвисание на 1000×2,6 мс = 2,6 с каждые 5 с, что не может не огорчать. Искусственное ограничение пропускной способности бизнес-логики — это и есть тот самый симптом, свидетельствующий о необходимости посмотреть в сторону паттерна SchedulableObject.SchedulableObject vs алгоритмы
Почему бы для решения проблемы не пойти по пути оптимизации алгоритмов и структур данных? Признаюсь, с настойчивостью, достойной лучшего применения, когда всё становится совсем плохо, мы оптимизируем те или иные шаги, участвующие в добавлении фото в очередь загрузки. Однако потенциал этих усилий заведомо ограничен. Да, можно что-то подкрутить и увеличить размер пачки до 2 или даже 4 тыс. штук, однако это не решает проблему фундаментально. Во-первых, на любую оптимизацию обязательно найдётся такой поток данных, который нивелирует весь её эффект. Применительно к Облаку это пользователь с 20 тыс. фото и более в галерее. Во-вторых, руководство обязательно захочет сделать ваш сценарий ещё более интеллектуальным, что неизбежно приведёт к усложнению его логики, придётся оптимизировать ранее проведённую оптимизацию. В-третьих, загрузка — не единственный сценарий, пропускная способность которого искусственно ограничена. Расшивание бутылочных горлышек алгоритмическим способом потребует индивидуального подхода к каждому сценарию. Что ещё хуже, атрибут качества «Производительность» является антагонистом другого, более важного, на мой взгляд, под названием «Поддерживаемость». Зачастую, чтобы достигнуть требуемой производительности, необходимо либо идти на разного рода ухищрения в алгоритмах, либо выбирать более сложные структуры данных, либо и то и другое вместе. Любой выбор не замедлит негативно сказаться или на публичном интерфейсе классов, или, по крайней мере, на их внутренней реализации.SchedulableObject vs выделение сценария в отдельный поток
Рассмотрим недостатки подхода, при котором для каждого сценария принимается своё решение о целесообразности его выделения в отдельный поток. С этой целью проследим за эволюцией архитектуры некоторого бизнес-приложения, где для решения проблемы «тормозов» будем руководствоваться данным принципом. Поскольку здесь особо интересны потоки (threads), в контексте которых происходит вызов методов объектов, каждый из них будет кодироваться своим цветом. Изначально, когда тяжёлые сценарии ещё не появились, всё происходит в главном потоке, поэтому все связи и сущности имеют одинаковый синий цвет.

6270b33a6b4d4a27875d44e7dea01c44.png

Допустим, появился сценарий, при отработке которого один из объектов стал потреблять слишком много времени. Из-за этого отзывчивость пользовательского интерфейса начинает страдать. Обозначим проблемный поток данных (data flow) жирными стрелками.

244b614378cb4b2aa513d7776e131689.png

Без рефакторинга ресурсоёмкого класса осуществлять вызовы к нему в отдельном красном потоке нельзя.

1cd3c08e1dc44726a7f9d91365f17f3a.png

Причина в том, что у него не один клиент, а два, и второй по-прежнему осуществляет вызовы из главного, синего потока. Если эти вызовы изменяют разделяемое состояние объекта, то произойдёт классическая проблема гонки к данным. Для защиты от неё необходимо реализовывать красный объект потокобезопасным образом.

1c7041293178488985f7a6b84920e63e.png

По мере развития проекта ещё один компонент становится узким местом. К счастью, у него только один клиент, и потокобезопасная реализация не потребовалась.

3d1ed43b4569421d9a1b64f5370e91df.png

Если экстраполировать указанный подход на проектирование многопоточной архитектуры, рано или поздно она приходит к следующему состоянию.

4d0b24d4a31a453186eadd05294dae3c.png

При небольшом усложнении оно становится совсем плачевным.

d904569cbccb4864b81b9ace1a4a5c40.png

Недостатки представленного подхода с условным названием Thread-Safe Architecture таковы:

  1. Необходимо постоянно отслеживать связи между объектами для своевременного рефакторинга однопоточной реализации метода или класса на потокобезопасную (и обратно).
  2. Потокобезопасные методы сложно реализовать, поскольку, помимо прикладной логики, необходимо учитывать специфику многопоточного программирования.
  3. Активное использование примитивов синхронизации может в итоге сделать приложение ещё более медленным, чем его однопоточная реализация.

Принцип действия паттерна SchedulableObject
В мире серверной, десктопной и даже Android-разработки тяжёлую бизнес-логику часто выделяют в отдельный процесс. Взаимодействие между сервисами внутри каждого из процессов остаётся однопоточным. Сервисы из разных процессов взаимодействуют друг с другом с использованием тех или иных механизмов межпроцессного взаимодействия (COM/DCOM, Corba, .Net Remoting, Boost.Interprocess и т. п.).

8a60e810c01f46c98e23c29fc037fa79.png

К сожалению, в мире iOS-разработки мы ограничены лишь одним процессом, и AS IS такая архитектура не подходит. Однако её можно воспроизвести в миниатюре, заменив отдельный процесс отдельным потоком, а механизм межпроцессного взаимодействия — косвенными вызовами.

f5ea336c5f4b48daa7703ff7ba4a08b5.png

Более формально суть трансформации такова:

  1. Завести один отдельный рабочий поток.
  2. Проассоциировать с ним цикл обработки событий и специальный объект для доставки в него сообщений — планировщик (от англ. scheduler).
  3. Связать каждый изменяемый объект с одним из планировщиков. Чем больше объектов будет связано с планировщиками рабочих потоков, тем больше времени останется у главного потока на свою основную обязанность — отрисовку пользовательского интерфейса.
  4. Выбирать правильный способ взаимодействия объектов друг с другом в зависимости от их принадлежности к планировщикам. Если планировщик общий, то взаимодействие происходит путём прямого вызова методов, если же нет — то опосредованно, через отправку специализированных событий.

Предлагаемый подход уже взят на вооружение iOS-сообществом. Вот так выглядит высокоуровневая архитектура популярного фреймворка React Native от Facebook.

4eb1d81563294ffeb503a1405bbf4e59.png

Весь JavaScript-код выполняется в отдельном потоке, а взаимодействие с нативным кодом происходит посредством косвенных вызовов путём отправки сообщений через asynchronous bridge.

Компоненты паттерна SchedulableObject
В основе паттерна SchedulableObject лежат пять компонентов. Ниже для каждого из них определяется зона ответственности и предлагается наивная реализация, чтобы наиболее наглядно проиллюстрировать его внутреннее устройство.

События


Наиболее удобной абстракцией для событий в iOS являются блоки, внутри которых происходит вызов нужного метода объекта.
typealias Event = () -> Void

Очередь событий


Поскольку события в очередь поступают из разных потоков, то очередь требует потокобезопасной реализации. Фактически именно она берёт на себя все трудности многопоточной разработки из прикладных компонентов.
class EventQueue {
    private let semaphore = DispatchSemaphore(value: 1)
    private var events = [Event]()
    
    func pushEvent(event: @escaping Event) {
        semaphore.wait()
        events.append(event)
        semaphore.signal()
    }
    
    func resetEvents() -> [Event] {
        semaphore.wait()
        let currentEvents = events
        events = [Event]()
        semaphore.signal()
        return currentEvents
    }
}

Цикл обработки сообщений


Реализует строго последовательную обработку событий из очереди. Это свойство компонента гарантирует, что все вызовы к реализующим его объектам осуществляются в одном, строго определённом потоке.
class RunLoop {
    let eventQueue = EventQueue()
    var disposed = false

    @objc func run() {
        while !disposed {
            for event in eventQueue.resetEvents() {
                event()
            }
            Thread.sleep(forTimeInterval: 0.1)
        }
    }
}

В iOS SDK имеется стандартная реализация данного компонента — NSRunLoop.

Поток


Объект ядра операционной системы, в котором происходит исполнение кода цикла обработки сообщений. Наиболее низкоуровневая реализация в iOS SDK — класс NSThread. Для практических целей рекомендуется использовать более высокоуровневые примитивы вроде NSOperationQueue или очереди из Grand Central Dispatch.

Планировщик


Обеспечивает механизм доставки событий до требуемой очереди. Будучи главным компонентом, посредством которого клиентский код исполняет методы объектов, он дает название как микропаттерну SchedulableObject, так и макропаттерну Schedulable Architecture.
class Scheduler {
    private let runLoop = RunLoop()
    private let thread: Thread
    
    init() {
        self.thread = Thread(target:runLoop,
                             selector:#selector(RunLoop.run),
                             object:nil)
        thread.start()
    }
    
    func schedule(event: @escaping Event) {
        runLoop.eventQueue.pushEvent(event: event)
    }
    
    func dispose() {
        runLoop.disposed = true
    }
}

SchedulableObject
Предоставляет стандартный интерфейс для косвенных вызовов. По отношению к целевому объекту может выступать в роли и агрегата, как в примере ниже, и базового класса, как в библиотеке POSSchedulableObject.
class SchedulableObject {
    private let object: T
    private let scheduler: Scheduler
    
    init(object: T, scheduler: Scheduler) {
        self.object = object
        self.scheduler = scheduler
    }
    
    func schedule(event: @escaping (T) -> Void) {
        scheduler.schedule {
            event(self.object)
        }
    }
}

Соединяем всё вместе


Программа ниже дублирует в консоли вводимые в неё символы. Слой бизнес-логики, который мы хотим вынести из главного потока, представлен классом Assembly. Он создаёт и предоставляет доступ к двум сервисам:
  1. Printer печатает подаваемые ему строки в консоль.
  2. PrintOptionsProvider позволяет конфигурировать сервис Printer.

//
//  main.swift
//  SchedulableObjectDemo
//

class PrintOptionsProvider {
    var richFormatEnabled = false;
}

class Printer {
    private let optionsProvider: PrintOptionsProvider
    
    init(optionsProvider: PrintOptionsProvider) {
        self.optionsProvider = optionsProvider
    }
    
    func doWork(what: String) {
        if optionsProvider.richFormatEnabled {
            print("\(Thread.current): out \(what)")
        } else {
            print("out \(what)")
        }
    }
}

class Assembly {
    let backgroundScheduler = Scheduler()
    let printOptionsProvider: SchedulableObject
    let printer: SchedulableObject
    
    init() {
        let optionsProvider = PrintOptionsProvider()
        self.printOptionsProvider = SchedulableObject(
            object: optionsProvider,
            scheduler: backgroundScheduler);
        self.printer = SchedulableObject(
            object: Printer(optionsProvider: optionsProvider),
            scheduler: backgroundScheduler)
    }
}

let assembly = Assembly()

while true {
    guard let value = readLine(strippingNewline: true) else {
        continue
    }
    if (value == "q") {
        assembly.backgroundScheduler.dispose()
        break;
    }
    assembly.printOptionsProvider.schedule(
        event: { (printOptionsProvider: PrintOptionsProvider) in
            printOptionsProvider.richFormatEnabled = arc4random() % 2 == 0
    })
    assembly.printer.schedule(event: { (printer: Printer) in
        printer.doWork(what: value)
    })
}

Последний блок кода при желании можно упростить:
assembly.backgroundScheduler.schedule {
    assembly.printOptionsProvider.object.richFormatEnabled = arc4random() % 2 == 0
    assembly.printer.object.doWork(what: value)
}

Правила взаимодействия с schedulable-объектами


Приведённая выше программа наглядно демонстрирует два правила взаимодействия с schedulable-объектами.
  1. Если с клиентом объекта и с вызываемым объектом проассоциирован один и тот же планировщик, то метод вызывается обычным образом. Так, Printer напрямую общается с PrintOptionsProvider.
  2. Если с клиентом объекта и с вызываемым объектом проассоциированы разные планировщики, то вызов происходит косвенно, путём отправки события. В примере выше цикл while считывает пользовательский ввод, исполняясь в главном потоке приложения, и поэтому не может напрямую обращаться к объектам бизнес-логики. С ними он взаимодействует опосредованно — через отправку событий.

Полный листинг приложения доступен здесь.Недостатки паттерна SchedulableObject
При всей элегантности паттерна у него есть и тёмная сторона: высокая инвазивность. Всё хорошо, когда Schedulable Architecture закладывается при первоначальном проектировании, как в этом демоприложении, и дело принимает совсем другой оборот, когда жизнь заставляет внедрять её в сложившуюся объемную кодовую базу. N-поточная природа паттерна порождает два жёстких требования с далеко идущими последствиями.

Требование № 1: Immutable Models


Все сущности, перемещающиеся между потоками, должны быть либо immutable, либо schedulable. В противном случае весь спектр проблем конкурентного изменения их состояния не замедлит себя ждать. Сегодня отчётливо прослеживается тренд на использование immutable объектов модели. В его авангарде как раз находятся компании, столкнувшиеся с необходимостью выделения бизнес-логики из главного потока. Вот список, пожалуй, самых ярких материалов на эту тему:
  • Apple WWDC: Building Better Apps with Value Types in Swift
  • Facebook: ComponentKit… emphasizes a one-way data flow from immutable models to immutable components
  • Dropbox: Practical Cross-Platform Mobile C++ Development
  • Pinterest: Immutable models and data consistency in our iOS App
  • LinkedIn: Managing Consistency of Immutable Models

Однако в кодовых базах наших дней мы с большой вероятностью столкнемся с мутабельными моделями. Более того, readwrite свойства — единственная возможность обновить их при использовании таких persistence-фреймворков, как Core Data или Realm. Внедрение Schedulable Architecture заставляет либо отказаться от них, либо предусмотреть какие-то особые механизмы для работы с моделями. Так, команда Realm предлагает следующее: «Therefore, the only limitation with Realm is that you cannot pass Realm objects between threads. If you need the same data on another thread, you just query for that data on the other thread». С Core Data тоже есть обходные манёвры, но, на мой взгляд, это всё весьма неудобно и выглядит как «штука сбоку», которую совершенно не хочется закладывать в архитектуру на этапе проектирования. Не так давно Facebook в статье «Making News Feed nearly 50% faster on iOS» объявил о своём отказе от Core Data. LinkedIn, ссылаясь на этот же недостаток Core Data, недавно представил свой фреймворк для персистентного хранения данных: «Rocket Data is a better option to Core Data because of the speed and stability guarantees as well as working with immutable instead of mutable models».

Требование № 2: Clusters of Services


Миграция в отдельный поток имеет смысл только тогда, когда к этому готов весь кластер объектов. Если участвующие в разных сценариях сервисы живут в разных потоках, то обилие косвенных вызовов между ними спровоцирует code blast невероятных масштабов.

d646a08e755b49b5998969e833199b22.jpg

Сейчас в Облаке Mail.Ru в рамках продуктовой разработки мы постепенно готовим бизнес-логику к жизни за пределами главного потока. Так, с каждым релизом у нас увеличивается количество сервисов, реализующих паттерн SchedulableObject. Как только их количество достигнет критической массы, достаточной для реализации «тяжёлых» сценариев, им единовременно будет выставлен планировщик рабочего потока, и тормоза из-за бизнес-логики останутся в прошлом.

Библиотека POSSchedulableObject
Библиотека POSSchedulableObject — ключевой ингредиент для полноценного воплощения в жизнь паттерна Schedulable Architecture в iOS приложении Облака Mail.Ru. Несмотря на то что кодовая база ещё только готовится к трансформации из однопоточного состояния в двухпоточное, рефакторинг уже приносит пользу. Поскольку в качестве базового класса для всех управляемых объектов используется POSSchedulableObject, некоторые его свойства активно эксплуатируются уже сейчас. Одно из ключевых — отслеживание несанкционированных прямых вызовов методов объекта из «вражеских» для него потоков. Не раз и не два POSSchedulableObject сообщал нам ассертом, что мы пытаемся обратиться к сервису бизнес-логики из некоего рабочего потока. Частая причина — тщетные надежды на то, что если в iOS 9 completion-блоки методов классов из iOS SDK дёргаются в главном потоке приложения, то в iOS 10 этот контракт не изменится.

Особенность реализации механизма обнаружения вызовов из некорректного потока — то, что он может применяться отдельно от класса POSSchedulableObject. Мы использовали это свойство для проверки того, что вызовы методов наших ViewController«ов происходят только в главном потоке. Выглядит это следующим образом.

@implementation UIViewController (MRCApp)

- (BOOL)mrc_protectForMainThreadScheduler {
    POSScheduleProtectionOptions *options =
    [POSScheduleProtectionOptions
     include:[POSSchedulableObject
              selectorsForClass:self.class
              nonatomicOnly:YES
              predicate:^BOOL(SEL  _Nonnull selector) {
                  NSString *selectorName = NSStringFromSelector(selector);
                  return [selectorName rangeOfString:@"_"].location != 0;
              }]
     exclude:[POSSchedulableObject selectorsForClass:[UIResponder class]]];
    return [POSSchedulableObject protect:self
                            forScheduler:[RACTargetQueueScheduler pos_mainThreadScheduler]
                                 options:options];
}

@end 

Более подробную информацию о библиотеке вы найдёте в её описании в репозитории на GitHub. Как только мы прекратим поддержку iOS 7, так сразу озаботимся версией для Swift, наброски которой были продемонстрированы в рамках листингов компонентов паттерна.Заключение
Паттерн SchedulableObject предоставляет системный подход для выноса бизнес-логики приложения из главного потока. Получающаяся на его основе Schedulable Architecture хорошо масштабируется по двум причинам. Во-первых, количество рабочих потоков не зависит от числа сервисов. Во-вторых, вся сложность многопоточной разработки перенесена из прикладных классов в инфраструктурные. У архитектуры также есть интересные скрытые возможности. Например, мы можем вынести бизнес-логику не в один поток, а в несколько потоков. Меняя приоритет каждого из них, мы изменяем на макроуровне интенсивность использования системных ресурсов каждым из кластеров объектов. Это может оказаться полезным, например при реализации многоаккаунтности в приложении. Повышая приоритет потока, в котором исполняется цикл обработки сообщений бизнес-логики текущего аккаунта, мы тем самым можем интенсифицировать выполнение наиболее актуальных для пользователя задач.Ссылки
  1. Playground с демонстрацией основных компонент паттерна SchedulableObject
  2. Библиотека POSSchedulableObject
  3. Демоприложение с использованием библиотеки POSSchedulableObject

Комментарии (0)

© Habrahabr.ru