Как повлияет группировка цикла выполнения UndoManager в разных контекстах потоков?

TLDR: мне интересно, как UndoManager автоматическое группирование отмены, основанное на циклах выполнения, выполняется при использовании из фонового потока, и что мне лучше всего подходит для этого.


я использую UndoManager (ранее NSUndoManager) в пользовательской среде Swift с целями для iOS и macOS.

В рамках этого приличного объема работы выполняется в фоновых последовательных очередях GCD. Я это понимаю UndoManager автоматически группирует зарегистрированные отмененные действия верхнего уровня за цикл цикла выполнения, но я не уверен, как различные ситуации с потоками могут повлиять на это.

Мои вопросы:

  • Какое влияние окажут следующие ситуации на UndoManagers запустить цикл группировки зарегистрированных действий отмены?
  • Какая ситуация (кроме ситуации 1, которая неосуществима) идеальна для обеспечения естественной группировки, при условии, что все изменения, которые требуют регистрации отмены, будут иметь место в единственной фоновой очереди последовательной отправки?

В следующих ситуациях предположим methodCausingUndoRegistration() а также anotherMethodCausingUndoRegistration() ничего сложного UndoManager.registerUndo из потока они были вызваны без какой-либо отправки.

Ситуация 1: встроенный в основной поток

// Assume this runs on main thread
methodCausingUndoRegistration()
// Other code here
anotherMethodCausingUndoRegistration()
// Also assume every other undo registration in this framework takes place inline on the main thread

Мое понимание: вот как UndoManager ожидает быть использованным. Обе из вышеперечисленных регистраций отмены будут происходить в одном и том же цикле цикла выполнения и, следовательно, помещаться в одну группу отмены.

Ситуация 2: синхронная отправка в главном потоке

// Assume this runs on an arbitrary background thread, possibly managed by GCD.
// It is guaranteed not to run on the main thread to prevent deadlock.
DispatchQueue.main.sync {
    methodCausingUndoRegistration()
}
// Other code here
DispatchQueue.main.sync {
    anotherMethodCausingUndoRegistration()
}

// Also assume every other undo registration in this framework takes place
// by syncing on main thread first as above

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

Ситуация 3: асинхронная отправка в главном потоке

// Assume this runs from an unknown context. Might be the main thread, might not.
DispatchQueue.main.async {
    methodCausingUndoRegistration()
}
// Other code here
DispatchQueue.main.async {
    anotherMethodCausingUndoRegistration()
}

// Also assume every other undo registration in this framework takes place
// by asyncing on the main thread first as above

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

Ситуация 4: одиночная асинхронная отправка в фоновом потоке

// Assume this runs from an unknown context. Might be the main thread, might not.
backgroundSerialDispatchQueue.async {
    methodCausingUndoRegistration()
    // Other code here
    anotherMethodCausingUndoRegistration()
}

// Also assume all other undo registrations take place
// via async on this same queue, and that undo operations
// that ought to be grouped together would be registered
// within the same async block.

Мое понимание: я действительно надеюсь, что это будет действовать так же, как ситуация 1, пока UndoManager используется исключительно из этой же фоновой очереди. Однако я беспокоюсь, что могут быть некоторые факторы, которые делают группировку неопределенной, особенно потому, что я не думаю, что очереди GCD (или их управляемые потоки) всегда (если вообще когда-либо) получают циклы выполнения.

1 ответ

Решение

TLDR: при работе с UndoManager из фонового потока, наименее сложным вариантом является просто отключить автоматическую группировку с помощью groupsByEvent и сделать это вручную. Ни одна из вышеперечисленных ситуаций не будет работать так, как задумано. Если вы действительно хотите автоматическую группировку в фоновом режиме, вам следует избегать GCD.


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

Автоматическая отмена группировки

Глава "Отменить менеджер" руководства Apple по использованию какао-приложений для iOS гласит:

NSUndoManager обычно создает группы отмен автоматически во время цикла выполнения цикла. При первом запросе на запись операции отмены в цикле создается новая группа. Затем в конце цикла он закрывает группу. Вы можете создавать дополнительные, вложенные группы отмены.

Это поведение легко можно наблюдать в проекте или на игровой площадке, зарегистрировавшись в NotificationCenter в качестве наблюдателя NSUndoManagerDidOpenUndoGroup а также NSUndoManagerDidCloseUndoGroup, Наблюдая за этим уведомлением и выводя результаты на консоль, в том числе undoManager.levelsOfUndoМы можем точно увидеть, что происходит с группировкой в ​​режиме реального времени.

В руководстве также говорится:

Менеджер отмены собирает все операции отмены, которые происходят в пределах одного цикла цикла выполнения, такого как основной цикл события приложения...

Этот язык будет означать, что основной цикл выполнения не является единственным циклом выполнения UndoManager способен наблюдать. Скорее всего, тогда UndoManager наблюдает уведомления, которые отправляются от имени CFRunLoop экземпляр, который был текущим, когда была записана первая операция отмены и была открыта группа.

GCD и Run Loops

Хотя общее правило для циклов выполнения на платформах Apple - "один цикл выполнения на поток", есть исключения из этого правила. В частности, общепринято, что Grand Central Dispatch не всегда (если вообще) использует стандартные CFRunLoops с его очередями отправки или связанными с ними потоками. Фактически, единственная очередь отправки, которая, кажется, имеет CFRunLoop кажется основной очередью.

Руководство по программированию параллелизма Apple гласит:

Основная очередь отправки - это глобально доступная последовательная очередь, которая выполняет задачи в главном потоке приложения. Эта очередь работает с циклом выполнения приложения (если таковой имеется), чтобы чередовать выполнение поставленных в очередь задач с выполнением других источников событий, подключенных к циклу выполнения.

Имеет смысл, что основной поток приложения не всегда будет иметь цикл выполнения (например, инструменты командной строки), но если это так, кажется, что он гарантирует, что GCD будет координироваться с циклом выполнения. Эта гарантия, по-видимому, отсутствует для других очередей отправки, и, по-видимому, не существует какого-либо общедоступного API или документированного способа связывания произвольной очереди отправки (или одного из лежащих в ее основе потоков) с CFRunLoop,

Это можно увидеть с помощью следующего кода:

DispatchQueue.main.async {
    print("Main", RunLoop.current.currentMode)
}

DispatchQueue.global().async {
    print("Global", RunLoop.current.currentMode)
}

DispatchQueue(label: "").async {
    print("Custom", RunLoop.current.currentMode)
}

// Outputs:
// Custom nil
// Global nil
// Main Optional(__C.RunLoopMode(_rawValue: kCFRunLoopDefaultMode))

Документация для RunLoop.currentMode состояния:

Этот метод возвращает текущий режим ввода только во время работы приемника; в противном случае возвращается ноль.

Из этого можно сделать вывод, что глобальные и пользовательские очереди отправки не всегда (если вообще когда-либо) имеют свои собственные CFRunLoop (который является основным механизмом позади RunLoop). Итак, если мы не отправляем в основную очередь, UndoManager не будет активного RunLoop наблюдать. Это будет важно для Ситуации 4 и далее.


Теперь давайте рассмотрим каждую из этих ситуаций, используя игровую площадку (с PlaygroundPage.current.needsIndefiniteExecution = true) и механизм наблюдения за уведомлениями, обсужденный выше.

Ситуация 1: встроенный в основной поток

Это именно так UndoManager ожидает использования (на основании документации). Наблюдение за уведомлениями об отмене показывает, что создается одна группа отмены с обеими отменами внутри.

Ситуация 2: синхронная отправка в главном потоке

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

Ситуация 3: асинхронная отправка в главном потоке

Однако когда async вместо этого используется простой тест, демонстрирующий то же поведение, что и в ситуации 1. Кажется, что, поскольку оба блока были отправлены в основной поток, прежде чем любой из них имел возможность фактически выполняться циклом выполнения, цикл выполнения выполнил оба блока в одном и том же цикл. Поэтому обе отмененные регистрации были помещены в одну группу.

Основываясь исключительно на наблюдении, это, кажется, вводит тонкую разницу в sync а также async, Так как sync блокирует текущий поток до завершения, цикл выполнения должен начать (и завершить) цикл перед возвратом. Разумеется, тогда цикл выполнения не сможет запустить другой блок в том же цикле, потому что он не был бы там, когда цикл запуска начинался и искал сообщения. С asyncоднако цикл выполнения, скорее всего, не запустился, пока оба блока не были поставлены в очередь, поскольку async возвращается до того, как работа сделана.

Основываясь на этом наблюдении, мы можем смоделировать ситуацию 2 внутри ситуации 3, вставив sleep(1) звонок между двумя async звонки. Таким образом, цикл выполнения имеет шанс начать свой цикл до того, как будет отправлен второй блок. Это действительно вызывает создание двух групп отмены.

Ситуация 4: одиночная асинхронная отправка в фоновом потоке

Здесь вещи становятся интересными. Если предположить, backgroundSerialDispatchQueue является настраиваемой последовательной очередью GCD, одна группа отмены создается непосредственно перед первой регистрацией отмены, но никогда не закрывается. Если мы подумаем о нашем обсуждении выше о GCD и циклах выполнения, это имеет смысл. Группа отмены создается просто потому, что мы назвали registerUndo и еще не было группы высшего уровня. Однако он никогда не закрывался, потому что никогда не получал уведомление о завершении цикла выполнения. Он никогда не получал это уведомление, потому что фоновые очереди GCD не работают CFRunLoopс ними, так UndoManager Скорее всего, никогда даже не смог наблюдать цикл запуска.

Правильный подход

При использовании UndoManager из фонового потока необходимо, ни одна из вышеперечисленных ситуаций не является идеальной (кроме первой, которая не соответствует требованию запуска в фоновом режиме). Есть два варианта, которые, кажется, работают. Оба предполагают, что UndoManager будет использоваться только из той же фоновой очереди / потока. В конце концов, UndoManager не является потокобезопасным.

Просто не используйте автоматическую группировку

Эта автоматическая группировка отмены на основе циклов выполнения может быть легко отключена с помощью undoManager.groupsByEvent, Тогда ручная группировка может быть достигнута так:

undoManager.groupsByEvent = false

backgroundSerialDispatchQueue.async {
    undoManager.beginUndoGrouping() // <--
    methodCausingUndoRegistration()
    // Other code here
    anotherMethodCausingUndoRegistration()
    undoManager.endUndoGrouping() // <--
}

Это работает точно так, как задумано, помещая обе регистрации в одну группу.

Используйте Фонд вместо GCD

В моем производственном коде я намереваюсь просто отключить автоматическую группировку отмены и сделать это вручную, но я обнаружил альтернативу, исследуя поведение UndoManager,

Мы обнаружили ранее, что UndoManager не удалось наблюдать пользовательские очереди GCD, потому что они не CFRunLoops. Но что, если мы создали свой собственный Thread и установить соответствующий RunLoop вместо. Теоретически это должно работать, и код ниже демонстрирует:

// Subclass NSObject so we can use performSelector to send a block to the thread
class Worker: NSObject {

    let backgroundThread: Thread

    let undoManager: UndoManager

    override init() {
        self.undoManager = UndoManager()

        // Create a Thread to run a block
        self.backgroundThread = Thread {
            // We need to attach the run loop to at least one source so it has a reason to run.
            // This is just a dummy Mach Port
            NSMachPort().schedule(in: RunLoop.current, forMode: .commonModes) // Should be added for common or default mode
            // This will keep our thread running because this call won't return
            RunLoop.current.run()
        }

        super.init()
        // Start the thread running
        backgroundThread.start()
        // Observe undo groups
        registerForNotifications()
    }

    func registerForNotifications() {
        NotificationCenter.default.addObserver(forName: Notification.Name.NSUndoManagerDidOpenUndoGroup, object: undoManager, queue: nil) { _ in
            print("opening group at level \(self.undoManager.levelsOfUndo)")
        }

        NotificationCenter.default.addObserver(forName: Notification.Name.NSUndoManagerDidCloseUndoGroup, object: undoManager, queue: nil) { _ in
            print("closing group at level \(self.undoManager.levelsOfUndo)")
        }
    }

    func doWorkInBackground() {
        perform(#selector(Worker.doWork), on: backgroundThread, with: nil, waitUntilDone: false)
    }

    // This function needs to be visible to the Objc runtime
    @objc func doWork() {
        registerUndo()

        print("working on other things...")
        sleep(1)
        print("working on other things...")
        print("working on other things...")

        registerUndo()
    }

    func registerUndo() {
        let target = Target()
        print("registering undo")
        undoManager.registerUndo(withTarget: target) { _ in }
    }

    class Target {}
}

let worker = Worker()
worker.doWorkInBackground()

Как и ожидалось, вывод указывает, что обе отмены находятся в одной группе. UndoManager был в состоянии наблюдать за циклами, потому что Thread использовал RunLoopв отличие от ГКД.

Честно говоря, тем не менее, вероятно, легче придерживаться GCD и использовать ручную отмену группировки.

Другие вопросы по тегам