Search code examples
swiftasync-awaitconcurrencythread-safetysendable

Should I make my service Sendable? Issues with concurrency in Swift


I have multiple services implemented through protocols to be able to inject a mock service on initialization of ViewModels, and after enabling strict concurrency check I have many warnings "Capture of 'self' with non-sendable type "ViewModelType" in 'async let' binding"

Here's a minimal reproducible example:

class ViewController: UIViewController {

    let viewModel: UserProfileViewModel

    init(service: UserProfileServiceProtocol) {
        self.viewModel = UserProfileViewModel(service: service)
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        getUserProfileData()
        // Do any additional setup after loading the view.
    }

    func getUserProfileData() {
        Task {
            do {
                try await viewModel.getProfileData()
                // update UI
            } catch {
                print(error)
            }
        }
    }
}

struct UserModel {
    var userID: String
    var username: String
    var profilePictureURL: URL?
    var profilePhoto: UIImage?
}

class UserProfileViewModel {

    private let service: any UserProfileServiceProtocol

    var user: UserModel?
    var postsCount: Int?
    var followersCount: Int?
    var followedUsersCount: Int?

    init(service: UserProfileServiceProtocol) {
        self.service = service
    }

    func getProfileData() async throws {
        async let user = service.getUserData()
        async let followersCount = service.getFollowersCount()
        async let followedUsersCount = service.getFollowingCount()
        async let postsCount = service.getPostsCount()

        self.user = try await user
        self.followersCount = try await followersCount
        self.followedUsersCount = try await followedUsersCount
        self.postsCount = try await postsCount
    }
}



protocol UserProfileServiceProtocol {
    var followService: FollowSystemProtocol { get }
    var userPostsService: UserPostsServiceProtocol { get }
    var userDataService: UserDataServiceProtocol { get }

    func getFollowersCount() async throws -> Int
    func getFollowingCount() async throws -> Int
    func getPostsCount() async throws -> Int
    func getUserData() async throws -> UserModel
}

protocol FollowSystemProtocol {
    func getFollowersNumber(for uid: String) async throws -> Int
    func getFollowingNumber(for uid: String) async throws -> Int
}

protocol UserPostsServiceProtocol {
    func getPostCount(for userID: String) async throws -> Int
}

protocol UserDataServiceProtocol {
    func getUser(for userID: String) async throws -> UserModel
}

class UserService: UserProfileServiceProtocol {

    let userID: String

    let followService: FollowSystemProtocol
    let userPostsService: UserPostsServiceProtocol
    let userDataService: UserDataServiceProtocol

    init(userID: String, followService: FollowSystemProtocol, userPostsService: UserPostsServiceProtocol, userDataService: UserDataServiceProtocol) {
        self.userID = userID
        self.followService = followService
        self.userPostsService = userPostsService
        self.userDataService = userDataService
    }

    func getFollowersCount() async throws -> Int {
        let followersCount = try await followService.getFollowersNumber(for: userID)
        return followersCount
    }
    func getFollowingCount() async throws -> Int {
        let followersCount = try await followService.getFollowingNumber(for: userID)
        return followersCount
    }

    func getPostsCount() async throws -> Int {
        let postsCount = try await userPostsService.getPostCount(for: userID)
        return postsCount
    }

    func getUserData() async throws -> UserModel {
        let user = try await userDataService.getUser(for: userID)
        return user
    }
}

class FollowSystemService: FollowSystemProtocol {
    func getFollowersNumber(for uid: String) async throws -> Int {
        try await Task.sleep(for: .seconds(1))
        return 5
    }
    
    func getFollowingNumber(for uid: String) async throws -> Int {
        try await Task.sleep(for: .seconds(1))
        return 19
    }
}

class UserPostsService: UserPostsServiceProtocol {
    func getPostCount(for userID: String) async throws -> Int {
        try await Task.sleep(for: .seconds(1))
        return 27
    }
}

class UserProfileService: UserDataServiceProtocol {
    func getUser(for userID: String) async throws -> UserModel {
        try await Task.sleep(for: .seconds(1))
        return UserModel(userID: "testUser_01", username: "testUser", profilePictureURL: nil)
    }
}

I don't have enough experience to judge what would be the correct way to tackle this problem so I'm just nervously trying to dig any information on this with no luck.
Should I make service protocols conform to sendable? Is it even a common practice to do so? Or should I do something entirely different to fix this?


Solution

  • Currently, your view model does not conform to Sendable. The deeper problem is that it is not thread-safe. Because the view model is not isolated to any particular actor, all of its async methods (by virtue of SE-0338) run on a “generic executor” (i.e., not the main thread). So you have a background thread updating properties that the view controller accesses from the main thread. If you set the “Strict Concurrency Checking” build setting to “Complete”, you will see more warnings about the lack of thread-safety.

    The view model should, at the very least, isolate properties accessed by the view to the main actor. Simpler, we would often isolate the whole view model to the main actor. The entire job of a view model is to support the view (which is on the main actor), so it makes sense to isolate the whole view model to the main actor as well:

    @MainActor
    class UserProfileViewModel {…}
    

    Regarding the services, yes, you will want to make those protocols Sendable, too. The compiler (especially with a “Strict Concurrency Checking” build setting of “Complete”) will warn you that a Sendable object cannot have properties that are not Sendable. I.e., an object is not thread-safe if its properties are not thread-safe. So, make the protocols Sendable:

    protocol UserPostsServiceProtocol: Sendable {
        func getPostCount(for userID: String) async throws -> Int
    }
    

    And then, of course, make your implementations Sendable, too. E.g., if the service has no mutable properties, you could just declare it as final. (It will inherit the Sendable from the protocol). Perhaps:

    final class UserPostsService: UserPostsServiceProtocol {
        func getPostCount(for userID: String) async throws -> Int {
            try await Task.sleep(for: .seconds(1))
            return 27
        }
    }
    

    But if the service has some internal mutable state, it will require some synchronization to avoid data races. Or, rather than adding your own manual synchronization, it is easier to actor isolate the entire service. You can either isolate that to the main actor (which is less compelling for a service than it was for the view model), or just make it its own actor:

    actor UserPostsService: UserPostsServiceProtocol {
        private var value = 0
    
        func getPostCount(for userID: String) async throws -> Int {
            try await Task.sleep(for: .seconds(1))
            value = 27
            return value
        }
    }
    

    So pulling this all together, you might end up with:

    class ViewController: UIViewController {
        let viewModel: UserProfileViewModel
        private var task: Task<Void, Error>?
    
        init(service: UserProfileServiceProtocol) {
            self.viewModel = UserProfileViewModel(service: service)
            super.init(nibName: nil, bundle: nil)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            getUserProfileData() // you might consider doing this in `viewDidAppear` … it depends upon whether this view presents other view controllers and whether you want it to re-fetch user profile data when it re-appears
        }
    
        // If you use unstructured concurrency, you are responsible for 
        // canceling the task whenever its results are no longer needed.
    
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            task?.cancel()
        }
    
        // So, make sure to save this unstructured concurrency `Task` in a property so you can cancel it when no longer needed
    
        func getUserProfileData() {
            task = Task {
                do {
                    try await viewModel.getProfileData()
                    // update UI
                } catch {
                    print(error)
                }
            }
        }
    }
    
    struct UserModel {
        let userID: String                // this probably should be immutable
        let username: String              // this probably should be immutable
        let profilePictureURL: URL? = nil // this probably should be immutable
        var profilePhoto: UIImage? = nil
    }
    
    extension UserModel: Identifiable {   // you might want to make this `Identifiable`
        var id: String { userID }
    }
    
    @MainActor
    class UserProfileViewModel {
        private let service: any UserProfileServiceProtocol
    
        var user: UserModel?
        var postsCount: Int?
        var followersCount: Int?
        var followedUsersCount: Int?
    
        init(service: UserProfileServiceProtocol) {
            self.service = service
        }
    
        func getProfileData() async throws {
            async let user = service.getUserData()
            async let followersCount = service.getFollowersCount()
            async let followedUsersCount = service.getFollowingCount()
            async let postsCount = service.getPostsCount()
    
            self.user = try await user
            self.followersCount = try await followersCount
            self.followedUsersCount = try await followedUsersCount
            self.postsCount = try await postsCount
        }
    }
    
    protocol UserProfileServiceProtocol: Sendable {
        var followService: FollowSystemProtocol { get }
        var userPostsService: UserPostsServiceProtocol { get }
        var userDataService: UserDataServiceProtocol { get }
    
        func getFollowersCount() async throws -> Int
        func getFollowingCount() async throws -> Int
        func getPostsCount() async throws -> Int
        func getUserData() async throws -> UserModel
    }
    
    protocol FollowSystemProtocol: Sendable {
        func getFollowersNumber(for uid: String) async throws -> Int
        func getFollowingNumber(for uid: String) async throws -> Int
    }
    
    protocol UserPostsServiceProtocol: Sendable {
        func getPostCount(for userID: String) async throws -> Int
    }
    
    protocol UserDataServiceProtocol: Sendable {
        func getUser(for userID: String) async throws -> UserModel
    }
    
    final class UserService: UserProfileServiceProtocol {
        let userID: String
    
        let followService: FollowSystemProtocol
        let userPostsService: UserPostsServiceProtocol
        let userDataService: UserDataServiceProtocol
    
        init(userID: String, followService: FollowSystemProtocol, userPostsService: UserPostsServiceProtocol, userDataService: UserDataServiceProtocol) {
            self.userID = userID
            self.followService = followService
            self.userPostsService = userPostsService
            self.userDataService = userDataService
        }
    
        func getFollowersCount() async throws -> Int {
            let followersCount = try await followService.getFollowersNumber(for: userID)
            return followersCount
        }
        func getFollowingCount() async throws -> Int {
            let followersCount = try await followService.getFollowingNumber(for: userID)
            return followersCount
        }
    
        func getPostsCount() async throws -> Int {
            let postsCount = try await userPostsService.getPostCount(for: userID)
            return postsCount
        }
    
        func getUserData() async throws -> UserModel {
            let user = try await userDataService.getUser(for: userID)
            return user
        }
    }
    
    final class FollowSystemService: FollowSystemProtocol {
        func getFollowersNumber(for uid: String) async throws -> Int {
            try await Task.sleep(for: .seconds(1))
            return 5
        }
    
        func getFollowingNumber(for uid: String) async throws -> Int {
            try await Task.sleep(for: .seconds(1))
            return 19
        }
    }
    
    actor UserPostsService: UserPostsServiceProtocol {
        var value = 0
    
        func getPostCount(for userID: String) async throws -> Int {
            try await Task.sleep(for: .seconds(1))
            value = 27
            return value
        }
    }
    
    final class UserProfileService: UserDataServiceProtocol {
        func getUser(for userID: String) async throws -> UserModel {
            try await Task.sleep(for: .seconds(1))
            return UserModel(userID: "testUser_01", username: "testUser")
        }
    }