Oh, yes, that was not well phrased on my part. I guess "retain cycles" have kind of become a term often used to describe general unexpected ownerships (which are perceived as memory leaks because you don't realize why the memory is not freed), so people (or at least me) suspect a "cycle" as the first suspect when we see that.
I was aware that's not the case here, as it is not "object owns task and task also owns object".
Rather it is "object starts task, task does not capture object, but awaits the object being done with its doSomething method which never returns and then object is never done". I guess you could say that the execution of the doSomething method itself is what "captures" the object.
In my experience people tend to forget that if a method is defined on a (reference) type, the instance of that type is passed to the method, even if said method does not use it at all. So in this case here while doSomething is running, self (i.e. the model) cannot be released. That's what it means if a function is defined "on" a type.
So the task is not being done (and thus deallocated, I guess, but that's unimportant here) because doSomething runs forever. Then since self is caught "in" doSomething, it is also never released.
This funnily means that the following works:
Example 6 (modified from 1)
@MainActor class Model {
init() {
print("init")
startObservingNotifications()
}
deinit {
print("deinit")
}
private func startObservingNotifications() {
Task {
print("starting to listen")
for await _ in NotificationCenter.default.notifications(named: Notification.Name("hello")) {
print("got noti")
}
print("stopping to listen") // we never get here!
}
}
}
//Invoking
Task { @MainActor in
var model: Model? = Model()
try? await Task.sleep(nanoseconds: 2_000_000_000)
NotificationCenter.default.post(Notification(name: Notification.Name("hello")))
try? await Task.sleep(nanoseconds: 2_000_000_000)
model = nil
print("ideally should have been dealloced before this, check: \(model == nil)") // check just to silence a warning...
try? await Task.sleep(nanoseconds: 2_000_000_000)
NotificationCenter.default.post(Notification(name: Notification.Name("hello")))
try? await Task.sleep(nanoseconds: 2_000_000_000)
print("leaving outer task")
}
RunLoop.main.run()
Output for Example 6
init
starting to listen
got noti
deinit
ideally should deallocate after this, check: true
got noti
leaving outer task
Note that the body in the task is now a completely anonymous closure, not tied to Model at all. There is no method that captures the instance as there is no method that is defined on the type used here. This basically lets the task "escape" (colloquially speaking) from the lifetime of the instance of Model.
I wholeheartedly agree. I don't think tasks are a good fit (atm) to basically create something like simple run-loop like, never ending structure. The name kind of implies that, I might add, but this thread shows people can easily be misled.
In my experience structured concurrency is, at the moment, not a good fit if what you want is basically a serial execution queue, especially if the execution items are calls to async functions themselves. I think once we can define our own (serial) custom executors for actors that might be easier, but I am not super sure about that for async functions.
Regarding your questions in general: As long as doSomething is no longer running forever I think that solution is actually quite nice. You properly tie the task's lifetime to the instance, i.e. it gets cancelled when the instance is deallocated. I don't see why that would be a problem, other than perhaps writing a unit test to ensure nobody else eventually messes with that. If that task?cancel() ever gets removed you in effect create a forever listening task that does nothing with the notifications it receives once the creating instance is deallocated. But those things are a dime a dozen in coding... 
I am perhaps not a pro about designing things with structured concurrency, so I am curious what others say.