Как повлияет группировка цикла выполнения UndoManager в разных контекстах потоков?
TLDR: мне интересно, как UndoManager
автоматическое группирование отмены, основанное на циклах выполнения, выполняется при использовании из фонового потока, и что мне лучше всего подходит для этого.
я использую UndoManager
(ранее NSUndoManager
) в пользовательской среде Swift с целями для iOS и macOS.
В рамках этого приличного объема работы выполняется в фоновых последовательных очередях GCD. Я это понимаю UndoManager
автоматически группирует зарегистрированные отмененные действия верхнего уровня за цикл цикла выполнения, но я не уверен, как различные ситуации с потоками могут повлиять на это.
Мои вопросы:
- Какое влияние окажут следующие ситуации на
UndoManager
s запустить цикл группировки зарегистрированных действий отмены? - Какая ситуация (кроме ситуации 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 не всегда (если вообще) использует стандартные CFRunLoop
s с его очередями отправки или связанными с ними потоками. Фактически, единственная очередь отправки, которая, кажется, имеет 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, потому что они не CFRunLoop
s. Но что, если мы создали свой собственный 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 и использовать ручную отмену группировки.