Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple actors calling each other can deadlock #1

Open
theospears opened this issue Nov 20, 2024 · 1 comment
Open

Multiple actors calling each other can deadlock #1

theospears opened this issue Nov 20, 2024 · 1 comment

Comments

@theospears
Copy link

theospears commented Nov 20, 2024

TL;DR: The use of context.performAndWait can introduce deadlocks into programs which would not deadlock with normal actors.

Background

I was excited to use this library to solve some threading issues I was experiencing with an app which used CoreData and actors. While introducing the library did address the crashes I was seeing, it also introduced mysterious deadlocks. I believe this is because under certain circumstances the logic here can deadlock.

Cause

Unlike normal executors, where enqueue queues up work to be done later, with NSModelActor the enqueue method is now synchronous, which means the calling thread cannot continue until the called method is complete. This became true with the move to use performAndWait introduced in da22a14. Reverting that change addresses the issue in my app, and also in my minimal repro. I don't understand why the change was initially made so can't comment on whether it is safe to revert in the general case.

Minimal Test Case

Below is some code which demonstrates an interaction pattern which is safe with normal actors, but deadlocks with NSModelActor

import CoreData
import CoreDataEvolution

/// A helper class which causes all threads to wait until an expected number have reached
/// the synchronization point, and then allows all to continue.
/// This is used to allow us to reliably exercise the race condition to be demonstrated.
class ThreadBarrier {
    private let condition = NSCondition()
    private var threadCount: Int
    private var currentCount = 0

    init(threadCount: Int) {
        self.threadCount = threadCount
    }

    func wait() {
        condition.lock()
        defer { condition.unlock() }

        currentCount += 1

        if currentCount < threadCount {
            // Wait until all threads reach the barrier
            condition.wait()
        } else {
            // Last thread wakes up all waiting threads
            condition.broadcast()
        }
    }
}


/// An actor which will oinvoke an inner method on another instance of the
/// same type. To demonstrate the deadlock we will create two actors which each
/// enter their own code (taking the performAndWait lock) and then try to call each other.
protocol MutuallyInvokingActor : Actor {}
extension MutuallyInvokingActor {
    func outer(barrier: ThreadBarrier, other: any MutuallyInvokingActor) async {
        print("Start Outer")
        barrier.wait()
        print("After Barrier")
        await other.inner()
        print("End Outer")
    }

    func inner() {
        print("Inner")
    }
}

/// This is a normal actor which will not produce a deadlock, because the normal actor
/// `enqueue` method just queues up a method to call later
actor NormalActor : MutuallyInvokingActor {}

/// This `NSModelActor` will deadlock because enqueue calls actor methods synchronously
@NSModelActor
actor DeadlockActor : MutuallyInvokingActor {}

/// Run the two actors in parallel to attempt to demonstrate the deadlock
func attemptDeadlock(_ actorA: MutuallyInvokingActor, _ actorB: MutuallyInvokingActor) async {
    print("Attempting to demonstrate actor deadlock")
    let barrier = ThreadBarrier(threadCount: 2)

    // Invoke DeadlockActor.outer on both actors in parallel
    async let result1 = actorA.outer(barrier: barrier, other: actorB)
    async let result2 = actorB.outer(barrier: barrier, other: actorA)

    let _ = await (result1, result2)
    print("Comlete - actors did not deadlock")
}


// With normal actors demonstrate the program does not deadlock
print("Running mutually invoking code between two normal actors")
let normalActorA = NormalActor()
let normalAactorB = NormalActor()
await attemptDeadlock(normalActorA, normalAactorB)


// With ModelActors demonstrate that the program does deadlock
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
let container = NSPersistentContainer(name: "Model")
container.persistentStoreDescriptions = [description]
container.loadPersistentStores(completionHandler: { _, _ in })

print("Running mutually invoking code between two NSModelActors")
let deadlockActorA = DeadlockActor(container: container)
let deadlockAactorB = DeadlockActor(container: container)
await attemptDeadlock(deadlockActorA, deadlockAactorB)
@fatbobman
Copy link
Owner

Thank you for the information you provided. I have switched back to the call method of perform.
In fact, I hesitated between perform and performAndWait (as can be seen from the previous adjustment records of the code). At present, it seems that my final choice was wrong.
Regarding the details, I will find time to study it carefully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants