Is a strong reference cycle created in this simple case of using URLSession?

232 Views Asked by At

I'm a little confused about how strong references are created and when reference cycles occur. Here's a simple example:

class Model {
    var foo: Data?
    
    func makeRequest(url: URL) {
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            
            // assume request completes successfully
            self.foo = data!
        }

        task.resume()
    }
}

class ViewController: UIViewController {
    var model = Model()
    let url = URL(string: "abc.com")! // assume URL is valid
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        model.makeRequest(url: url)
    }
}

This is my understanding of how the references work in the code above:

  1. The variable model holds a strong reference to an instance of the Model class
  2. URLSession holds a strong reference to its data task, which holds a strong reference to the closure.
  3. The closure escapes the function, and because it needs to update self, it holds a strong reference to the Model instance
  4. However, the Model instance does not hold a strong reference to the data task and therefore there is no reference cycle.

Is this correct? And if so, I really don't understand step 4. Why doesn't the Model instance hold a strong reference to the data task, since the task is created in a function of the Model class?

Note: I've seen several related questions, but I still don't understand why the Model instance does not hold a strong reference back to the session, task, or closure.

2

There are 2 best solutions below

0
On BEST ANSWER

There is not a cycle here (as you note). However, URLSession.shared, which never goes away, does hold a reference to the task, which holds a reference to Model. This means that Model cannot deallocate until the task completes. (If Model had a urlSession property, then there technically would be a loop, but it wouldn't change anything in practice. "Loops" are not magical. If something that lives forever holds a reference to something, it will keep that object alive forever.)

This is generally a good thing. URLSession tasks automatically release their completion block when they finish, so Model is only kept alive until the task is done. As long as Model doesn't assume that ViewController still exists (which it shouldn't), there is nothing wrong here as far as reference cycles.

The one thing that is kind of bad about this code is that Model doesn't hold onto the task so it can cancel it, or even detect that one is in progress (to avoid making duplicate requests in parallel). That's not a major problem for simple apps, but is something that is useful to improve in more complex apps.

1
On

Let’s go through your questions one at a time:

This is my understanding of how the references work in the code above:

  1. The variable model holds a strong reference to an instance of the Model class.

Correct, the view controller keeps that strong reference until the view controller, itself, is deallocated.

  1. URLSession holds a strong reference to its data task, which holds a strong reference to the closure.

The URLSession maintains this strong reference until the request finishes/fails, at which point the data task is released. You do not need to keep a reference to the data task, as URLSession automatically hangs on to it for the duration of the request anyway. That having been said, we often would keep our own weak reference to it, which we would use if/when we might want to cancel the request. (See below.)

  1. The closure escapes the function, and because it needs to update self, it holds a strong reference to the Model instance

Yes, as it stands, the closure maintains a strong reference to self, and this closure will not be released until the data task finishes or fails.

As an aside, we generally would not do that. Often we would use a [weak self] capture list in this closure, so that it does not keep this strong reference to self. E.g., you might:

let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
    // see if `self` was released
    guard let self else { return }

    // see if request succeeded
    guard let data else {
        print(error ?? URLError(.badServerResponse))
        return
    }

    // if we got here, we have our `Data`; we always avoid forced unwrapping operators when dealing with data from a remote server
    self.foo = data
}
  1. However, the Model instance does not hold a strong reference to the data task and therefore there is no reference cycle.

Yes. Or more accurately, as implemented in the question, the URLSession will keep a strong reference to self, will release that strong reference when the request finishes or fails. But, again, if we use [weak self] capture list as outlined above, it won’t keep a strong reference at all, and the Model will be deallocated as soon as the view controller is deallocated.


Even better, unless we explicitly need the task to continue running even if the Model is deallocated for some reason, we would cancel the task when Model is deallocated:

class Model {
    var foo: Data?
    private weak var task: URLSessionTask?

    deinit {
        task?.cancel()
    }
    
    func makeRequest(url: URL) {
        let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let self else { return }
        
            guard let data else {
                print(error ?? URLError(.badServerResponse))
                return
            }
        
            self.foo = data
        }

        task.resume()
        self.task = task
    }
}

Note, we neither need nor want to keep a strong reference to the URLSessionTask. (The URLSession will manage the lifecycle of the URLSessionTask.) But we keep our own weak reference, which will automatically be set to nil when the URLSessionTask is done. That way, if the request is not yet done when Model is deallocated, we can cancel the request. But if the request is already done, that task reference will be set to nil automatically for us, in which case the task?.cancel(), becomes a “no op”.