Search code examples
iosuitableviewthread-safetygrand-central-dispatchnscondition

Thread-safe access to a datasource during a tableView update


My app uses a tableView with a data source that can be updated asynchronously by multiple threads.
When the data source is changed, the tableView is updated, not reloaded, using

tableView.performBatchUpdates({ 
    tableView.deleteRows(at: deletions, with: .automatic)
    tableView.insertRows(at: insertions, with: .automatic)
    for (from, to) in movements {
        tableView.moveRow(at: from, to: to)
    }
}, completion: { (finished) in
    if !finished {
        self.tableView.reloadData()
    } else {
        // some cleanup code
    }
    completion(finished)
}  

where completion(finished) is some completion block.

Typically, the tableView update takes 0.25 sec. During this time, the data source must not be changed. What is the best method to ensure this?

I can imagine that I acquire an NSCondition before tableView.performBatchUpdates, and release it in the completion block, of course also for every other read and write access to the data source, as suggested here. But most posts on SO suggest not to use such low-level sync mechanism, but use GCD instead.

One suggestion is to use a thread-safe SynchronizedArray using a concurrent queue with sync reads and async barrier writes, which allows concurrent reads. But I don’t know how I could lock out writes during tableView.performBatchUpdates when I use GCD.

Is there a standard solution to this problem?


Solution

  • EDIT:

    I was not happy with the solution I provided below, since it does not allow concurrent reads.
    Thus I came up with the following better solution, a WriteLockableSynchronizedArray.

    It is based much closer on Basem Emara's SynchronizedArray (thanks again, Basem), i.e. it does allow concurrent reads, and has the following features:

    • It has a convenience initialiser that makes a WriteLockableSynchronizedArray from an Array, and a readonly property array that returns the underlying array.
    • It has 2 functions lockArray() and unlockArray() that can be called before and after batch tableView operations.
    • It adopts the Sequence protocol, so that a WriteLockableSynchronizedArray can be used e.g. in statements like for element in writeLockableSynchronizedArray {}.
    • If a WriteLockableSynchronizedArray is write locked, all writes are delayed until it is unlocked again. After unlocking, all delayed writes are executed in order.

    Here is the new solution (recommended):

    import Foundation
    
    /// A lockable, thread-safe array.
    // It is a modification of Basem Emara's SynchronizedArray, see <http://basememara.com/creating-thread-safe-arrays-in-swift/>
    // It provides concurrent reads and serialized writes. A write is only executed after all reads have been completed.
    // If the LockableSynchronizedArray is locked, new writes are deferred until it is unlocked again, while new reads are executed normally.
    public class WriteLockableSynchronizedArray<Element> {
    
        typealias WriteOperation = ()->Void
    
        fileprivate var lockCounter = 0
        fileprivate let queue = DispatchQueue(label: "com.zeh4soft.WriteLockableSynchronizedArray", attributes: .concurrent)
        fileprivate var internalArray = [Element]()
        fileprivate var deferredWriteOperations: [WriteOperation] = []
    
        /// The internal array of the elements
        var array: [Element]? {
            var result: [Element]?
            queue.sync { result = self.internalArray }
            return result
        }
    }
    
    // MARK: - Properties
    public extension WriteLockableSynchronizedArray {
    
        /// The first element of the collection.
        var first: Element? {
            var result: Element?
            queue.sync { result = self.internalArray.first }
            return result
        }
    
        /// The last element of the collection.
        var last: Element? {
            var result: Element?
            queue.sync { result = self.internalArray.last }
            return result
        }
    
        /// The number of elements in the array.
        var count: Int {
            var result = 0
            queue.sync { result = self.internalArray.count }
            return result
        }
    
        /// A Boolean value indicating whether the collection is empty.
        var isEmpty: Bool {
            var result = false
            queue.sync { result = self.internalArray.isEmpty }
            return result
        }
    
        /// A textual representation of the array and its elements.
        var description: String {
            var result = ""
            queue.sync { result = self.internalArray.description }
            return result
        }
    }
    
    // MARK: - Init
    public extension WriteLockableSynchronizedArray {
        convenience init(with array: [Element]) {
            self.init()
            self.internalArray = array
        }
    }
    
    // MARK: - Lock - Unlock
    public extension WriteLockableSynchronizedArray {
        /// Locks the array for writes. Must be unlocked by unlockArray()
        func lockArray() {
            queue.async(flags: .barrier) {
                self.lockCounter += 1
            }
        }
    
        /// Unlocks the array after it has been locked by lockArray()
        func unlockArray() {
            queue.sync(flags: .barrier) {
                if self.lockCounter > 0 { 
                    self.lockCounter -= 1 
                }
                if self.lockCounter == 0 {
                    while self.deferredWriteOperations.count > 0 {
                        let nextOp = self.deferredWriteOperations.remove(at: 0)
                        self.queue.async(flags: .barrier) { nextOp() }
                        print("Enqueued deferred write op")
                    }
                }
            }
        }
    }
    
    // MARK: - Immutable
    public extension WriteLockableSynchronizedArray {
        /// Returns the first element of the sequence that satisfies the given predicate or nil if no such element is found.
        ///
        /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match.
        /// - Returns: The first match or nil if there was no match.
        func first(where predicate: (Element) -> Bool) -> Element? {
            var result: Element?
            queue.sync { result = self.internalArray.first(where: predicate) }
            return result
        }
    
        /// Returns an array containing, in order, the elements of the sequence that satisfy the given predicate.
        ///
        /// - Parameter isIncluded: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element should be included in the returned array.
        /// - Returns: An array of the elements that includeElement allowed.
        func filter(_ isIncluded: (Element) -> Bool) -> [Element] {
            var result = [Element]()
            queue.sync { result = self.internalArray.filter(isIncluded) }
            return result
        }
    
        /// Returns the first index in which an element of the collection satisfies the given predicate.
        ///
        /// - Parameter predicate: A closure that takes an element as its argument and returns a Boolean value that indicates whether the passed element represents a match.
        /// - Returns: The index of the first element for which predicate returns true. If no elements in the collection satisfy the given predicate, returns nil.
        func index(where predicate: (Element) -> Bool) -> Int? {
            var result: Int?
            queue.sync { result = self.internalArray.index(where: predicate) }
            return result
        }
    
        /// Returns the elements of the collection, sorted using the given predicate as the comparison between elements.
        ///
        /// - Parameter areInIncreasingOrder: A predicate that returns true if its first argument should be ordered before its second argument; otherwise, false.
        /// - Returns: A sorted array of the collection’s elements.
        func sorted(by areInIncreasingOrder: (Element, Element) -> Bool) -> [Element] {
            var result = [Element]()
            queue.sync { result = self.internalArray.sorted(by: areInIncreasingOrder) }
            return result
        }
    
        /// Returns an array containing the non-nil results of calling the given transformation with each element of this sequence.
        ///
        /// - Parameter transform: A closure that accepts an element of this sequence as its argument and returns an optional value.
        /// - Returns: An array of the non-nil results of calling transform with each element of the sequence.
        func flatMap<ElementOfResult>(_ transform: (Element) -> ElementOfResult?) -> [ElementOfResult] {
            var result = [ElementOfResult]()
            queue.sync { result = self.internalArray.compactMap(transform) }
            return result
        }
    
        /// Calls the given closure on each element in the sequence in the same order as a for-in loop.
        ///
        /// - Parameter body: A closure that takes an element of the sequence as a parameter.
        func forEach(_ body: (Element) -> Void) {
            queue.sync { self.internalArray.forEach(body) }
        }
    
        /// Returns a Boolean value indicating whether the sequence contains an element that satisfies the given predicate.
        ///
        /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value that indicates whether the passed element represents a match.
        /// - Returns: true if the sequence contains an element that satisfies predicate; otherwise, false.
        func contains(where predicate: (Element) -> Bool) -> Bool {
            var result = false
            queue.sync { result = self.internalArray.contains(where: predicate) }
            return result
        }
    }
    
    // MARK: - Mutable
    public extension WriteLockableSynchronizedArray {
    
        /// Adds a new element at the end of the array.
        ///
        /// - Parameter element: The element to append to the array.
        func append( _ element: Element) {
            let op = { self.internalArray.append(element) }
            handleWriteOperation(op)
        }
    
        /// Adds a new element at the end of the array.
        ///
        /// - Parameter element: The element to append to the array.
        func append( _ elements: [Element]) {
            let op = { self.internalArray += elements }
            handleWriteOperation(op)
        }
    
        /// Inserts a new element at the specified position.
        ///
        /// - Parameters:
        ///   - element: The new element to insert into the array.
        ///   - index: The position at which to insert the new element.
        func insert( _ element: Element, at index: Int) {
            let op = { self.internalArray.insert(element, at: index) }
            handleWriteOperation(op)
        }
    
        /// Removes and returns the element at the specified position.
        ///
        /// - Parameters:
        ///   - index: The position of the element to remove.
        ///   - completion: The handler with the removed element.
        func remove(at index: Int, completion: ((Element) -> Void)? = nil) {
            let op = {
                let element = self.internalArray.remove(at: index)
                DispatchQueue.main.async {
                    completion?(element)
                }
            }
            handleWriteOperation(op)
        }
    
        /// Removes and returns the element at the specified position.
        ///
        /// - Parameters:
        ///   - predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match.
        ///   - completion: The handler with the removed element.
        func remove(where predicate: @escaping (Element) -> Bool, completion: ((Element) -> Void)? = nil) {
            let op = {
                guard let index = self.internalArray.index(where: predicate) else { return }
                let element = self.internalArray.remove(at: index)
                DispatchQueue.main.async {
                    completion?(element)
                }
            }
            handleWriteOperation(op)
        }
    
        /// Removes all elements from the array.
        ///
        /// - Parameter completion: The handler with the removed elements.
        func removeAll(completion: (([Element]) -> Void)? = nil) {
            let op = {
                let elements = self.internalArray
                self.internalArray.removeAll()
                DispatchQueue.main.async {
                    completion?(elements)
                }
            }
            handleWriteOperation(op)
        }
    }
    
    public extension WriteLockableSynchronizedArray {
    
        /// Accesses the element at the specified position if it exists.
        ///
        /// - Parameter index: The position of the element to access.
        /// - Returns: optional element if it exists.
        subscript(index: Int) -> Element? {
            get {
                var result: Element?
    
                queue.sync {
                    guard self.internalArray.startIndex..<self.internalArray.endIndex ~= index else { return }
                    result = self.internalArray[index]
                }
    
                return result
            }
            set {
                guard let newValue = newValue else { return }
    
                let op = { self.internalArray[index] = newValue }
                handleWriteOperation(op)
            }
        }
    }
    
    
    // MARK: - Equatable
    public extension WriteLockableSynchronizedArray where Element: Equatable {
    
        /// Returns a Boolean value indicating whether the sequence contains the given element.
        ///
        /// - Parameter element: The element to find in the sequence.
        /// - Returns: true if the element was found in the sequence; otherwise, false.
        func contains(_ element: Element) -> Bool {
            var result = false
            queue.sync { result = self.internalArray.contains(element) }
            return result
        }
    }
    
    // MARK: - Infix operators
    public extension WriteLockableSynchronizedArray {
    
        static func +=(left: inout WriteLockableSynchronizedArray, right: Element) {
            left.append(right)
        }
    
        static func +=(left: inout WriteLockableSynchronizedArray, right: [Element]) {
            left.append(right)
        }
    }
    
    // MARK: - Protocol Sequence
    extension WriteLockableSynchronizedArray: Sequence {
    
        public func makeIterator() -> Iterator {
            return Iterator(self.array)
        }
    
        public struct Iterator: IteratorProtocol {
            private var index: Int
            private var arr: [Element]?
    
            init(_ array: [Element]?) {
                self.arr = array
                index = 0
            }
    
            mutating public func next() -> Element? {
                guard let arr = self.arr, arr.count > index else { return nil }
                let returnValue = arr[index]
                index += 1
                return returnValue
            }
        }
    }
    
    // MARK: - Private helper
    fileprivate extension WriteLockableSynchronizedArray {
        func handleWriteOperation(_ op: @escaping WriteLockableSynchronizedArray.WriteOperation) {
            queue.sync { 
                if self.lockCounter > 0 {
                    self.deferredWriteOperations.append { op() }
                } else {
                    queue.async(flags: .barrier) {
                        op()
                    }
                }
            }
        }
    
    }
    

    Here is my previous solution (no longer recommended):

    • I implemented a class LockableArray, a modification of Basem Emara's (thanks!) SynchronizedArray that uses a recursive lock to synchronize accesses to the array.
    • I implemented a convenience initialiser that makes a LockableArray from an Array, and a readonly property that returns the underlying Array.
    • I defined 2 functions lockArray() and unlockArray() that can be called before and after batch tableView operations.
    • I adopted the Sequence protocol, so that a LockableArray can be used e.g. in statements like for element in lockablaArray {}.

    A disadvantage of this solution is that multiple reads can not be executed concurrently as in Basem Emara's SynchronizedArray.

    Here is the implementation:

    import Foundation
    
    /// A lockable, thread-safe array.
    // It is a modification of Basem Emara's SynchronizedArray, see <http://basememara.com/creating-thread-safe-arrays-in-swift/>
    // It does not use dispatch queues, but a recursive lock, so that multiple array operations can be locked as a group.
    // Additions to Basem Emara's implementation:
    // - A convenience initializer make a LockableArray from an Array, and a readonly property allows to acces the underlying Array.
    // - Protocol Sequence is adopted, so that statements like "for .. in .." can be used.
    
    public class LockableArray<Element> {
        fileprivate var lock = NSRecursiveLock() // Must be recursive, so that batch accesses can be locked together
        fileprivate var privateArray = [Element]()
    
        /// The internal array of the elements
        var array: [Element]? {
            let result: [Element]
            lock.lock()
            result = privateArray
            lock.unlock()
            return result
        }
    }
    
    // MARK: - Properties
    public extension LockableArray {
    
        /// The first element of the collection.
        var first: Element? {
            var result: Element?
            lock.lock()
            result = self.privateArray.first
            lock.unlock()
            return result
        }
    
        /// The last element of the collection.
        var last: Element? {
            var result: Element?
            lock.lock()
            result = self.privateArray.last
            lock.unlock()
            return result
        }
    
        /// The number of elements in the array.
        var count: Int {
            var result = 0
            lock.lock()
            result = self.privateArray.count
            lock.unlock()
            return result
        }
    
        /// A Boolean value indicating whether the collection is empty.
        var isEmpty: Bool {
            var result = false
            lock.lock()
            result = self.privateArray.isEmpty
            lock.unlock()
            return result
        }
    
        /// A textual representation of the array and its elements.
        var description: String {
            var result = ""
            lock.lock()
            result = self.privateArray.description
            lock.unlock()
            return result
        }
    }
    
    // MARK: - Init
    public extension LockableArray {
        convenience init(with array: [Element]) {
            self.init()
            self.privateArray = array
        }
    }
    
    // MARK: - Lock - Unlock
    public extension LockableArray {
        /// Locks the array for multiple writes. Must be unlocked by unlockArray()
        func lockArray() {
            lock.lock()
        }
    
        /// Unlocks the array after it has been locked by lockArray()
        func unlockArray() {
            lock.unlock()
        }
    }
    
    // MARK: - Immutable
    public extension LockableArray {
    
        /// Returns the first element of the sequence that satisfies the given predicate or nil if no such element is found.
        ///
        /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match.
        /// - Returns: The first match or nil if there was no match.
        func first(where predicate: (Element) -> Bool) -> Element? {
            var result: Element?
            lock.lock()
            result = self.privateArray.first(where: predicate)
            lock.unlock()
            return result
        }
    
        /// Returns an array containing, in order, the elements of the sequence that satisfy the given predicate.
        ///
        /// - Parameter isIncluded: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element should be included in the returned array.
        /// - Returns: An array of the elements that includeElement allowed.
        func filter(_ isIncluded: (Element) -> Bool) -> [Element] {
            var result = [Element]()
            lock.lock()
            result = self.privateArray.filter(isIncluded)
            lock.unlock()
            return result
        }
    
        /// Returns the first index in which an element of the collection satisfies the given predicate.
        ///
        /// - Parameter predicate: A closure that takes an element as its argument and returns a Boolean value that indicates whether the passed element represents a match.
        /// - Returns: The index of the first element for which predicate returns true. If no elements in the collection satisfy the given predicate, returns nil.
        func index(where predicate: (Element) -> Bool) -> Int? {
            var result: Int?
            lock.lock()
            result = self.privateArray.index(where: predicate)
            lock.unlock()
            return result
        }
    
        /// Returns the elements of the collection, sorted using the given predicate as the comparison between elements.
        ///
        /// - Parameter areInIncreasingOrder: A predicate that returns true if its first argument should be ordered before its second argument; otherwise, false.
        /// - Returns: A sorted array of the collection’s elements.
        func sorted(by areInIncreasingOrder: (Element, Element) -> Bool) -> [Element] {
            var result = [Element]()
            lock.lock()
            result = self.privateArray.sorted(by: areInIncreasingOrder)
            lock.unlock()
            return result
        }
    
        /// Returns an array containing the non-nil results of calling the given transformation with each element of this sequence.
        ///
        /// - Parameter transform: A closure that accepts an element of this sequence as its argument and returns an optional value.
        /// - Returns: An array of the non-nil results of calling transform with each element of the sequence.
        func compactMap<ElementOfResult>(_ transform: (Element) -> ElementOfResult?) -> [ElementOfResult] {
            var result = [ElementOfResult]()
            lock.lock()
            result = self.privateArray.compactMap(transform)
            lock.unlock()
            return result
        }
    
        /// Returns an array containing the non-nil results of calling the given transformation with each element of this sequence.
        ///
        /// - Parameter transform: A closure that accepts an element of this sequence as its argument and returns an optional value.
        /// - Returns: An array of the non-nil results of calling transform with each element of the sequence.
        func map<ElementOfResult>(_ transform: (Element) -> ElementOfResult) -> [ElementOfResult] {
            var result = [ElementOfResult]()
            lock.lock()
            result = self.privateArray.map(transform)
            lock.unlock()
            return result
        }
    
        /// Calls the given closure on each element in the sequence in the same order as a for-in loop.
        ///
        /// - Parameter body: A closure that takes an element of the sequence as a parameter.
        func forEach(_ body: (Element) -> Void) {
            lock.lock()
            self.privateArray.forEach(body)
            lock.unlock()
        }
    
        /// Returns a Boolean value indicating whether the sequence contains an element that satisfies the given predicate.
        ///
        /// - Parameter predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value that indicates whether the passed element represents a match.
        /// - Returns: true if the sequence contains an element that satisfies predicate; otherwise, false.
        func contains(where predicate: (Element) -> Bool) -> Bool {
            var result = false
            lock.lock()
            result = self.privateArray.contains(where: predicate)
            lock.unlock()
            return result
        }
    }
    
    // MARK: - Mutable
    public extension LockableArray {
    
        /// Adds a new element at the end of the array.
        ///
        /// - Parameter element: The element to append to the array.
        func append( _ element: Element) {
            lock.lock()
            self.privateArray.append(element)
            lock.unlock()
        }
    
        /// Adds a new element at the end of the array.
        ///
        /// - Parameter element: The element to append to the array.
        func append( _ elements: [Element]) {
            lock.lock()
            self.privateArray += elements
            lock.unlock()
        }
    
        /// Inserts a new element at the specified position.
        ///
        /// - Parameters:
        ///   - element: The new element to insert into the array.
        ///   - index: The position at which to insert the new element.
        func insert( _ element: Element, at index: Int) {
            lock.lock()
            self.privateArray.insert(element, at: index)
            lock.unlock()
        }
    
        /// Removes and returns the element at the specified position.
        ///
        /// - Parameters:
        ///   - index: The position of the element to remove.
        ///   - completion: The handler with the removed element.
        func remove(at index: Int, completion: ((Element) -> Void)? = nil) {
            lock.lock()
            let element = self.privateArray.remove(at: index)
            DispatchQueue.main.async {
                completion?(element)
            }
            lock.unlock()
        }
    
        /// Removes and returns the element at the specified position.
        ///
        /// - Parameters:
        ///   - predicate: A closure that takes an element of the sequence as its argument and returns a Boolean value indicating whether the element is a match.
        ///   - completion: The handler with the removed element.
        func remove(where predicate: @escaping (Element) -> Bool, completion: ((Element) -> Void)? = nil) {
            lock.lock()
            guard let index = self.privateArray.index(where: predicate) else { return }
            let element = self.privateArray.remove(at: index)
            DispatchQueue.main.async {
                completion?(element)
            }
            lock.unlock()
        }
    
        /// Removes all elements from the array.
        ///
        /// - Parameter completion: The handler with the removed elements.
        func removeAll(completion: (([Element]) -> Void)? = nil) {
            lock.lock()
            let elements = self.privateArray
            self.privateArray.removeAll()
            DispatchQueue.main.async {
                completion?(elements)
            }
            lock.unlock()
        }
    }
    
    public extension LockableArray {
    
        /// Accesses the element at the specified position if it exists.
        ///
        /// - Parameter index: The position of the element to access.
        /// - Returns: optional element if it exists.
        subscript(index: Int) -> Element? {
            get {
                var result: Element?
                lock.lock()
                guard self.privateArray.startIndex ..< self.privateArray.endIndex ~= index else { return nil }
                result = self.privateArray[index]
                lock.unlock()
                return result
            }
            set {
                guard let newValue = newValue else { return }
                lock.lock()
                self.privateArray[index] = newValue
                lock.unlock()
            }
        }
    }
    
    // MARK: - Equatable
    public extension LockableArray where Element: Equatable {
    
        /// Returns a Boolean value indicating whether the sequence contains the given element.
        ///
        /// - Parameter element: The element to find in the sequence.
        /// - Returns: true if the element was found in the sequence; otherwise, false.
        func contains(_ element: Element) -> Bool {
            var result = false
            lock.lock()
            result = self.privateArray.contains(element)
            lock.unlock()
            return result
        }
    }
    
    // MARK: - Infix operators
    public extension LockableArray {
    
        static func +=(left: inout LockableArray, right: Element) {
            left.append(right)
        }
    
        static func +=(left: inout LockableArray, right: [Element]) {
            left.append(right)
        }
    }
    
    // MARK: - Protocol Sequence
    extension LockableArray: Sequence {
    
        public func makeIterator() -> Iterator {
            return Iterator(self.array)
        }
    
        public struct Iterator: IteratorProtocol {
            private var index: Int
            private var arr: [Element]?
    
            init(_ array: [Element]?) {
                self.arr = array
                index = 0
            }
    
            mutating public func next() -> Element? {
                guard let arr = self.arr, arr.count > index else { return nil }
                let returnValue = arr[index]
                index += 1
                return returnValue
            }
        }
    }