socketry / async

An awesome asynchronous event-driven reactor for Ruby.
MIT License
2.09k stars 86 forks source link

transient task behaviour? #179

Closed emiltin closed 1 year ago

emiltin commented 1 year ago

Hi, My understanding of using transient:true is that any child task will be terminated as soon as the parent task ends.

But I don't understand this:

require 'async'
Async(transient: true) do |task|
  task.sleep 10000
end.wait
puts "done"

Which immediately prints "done" for both async 1.30.3 and 2.1. Why does wait() have no effect here?

emiltin commented 1 year ago

Or just:

require 'async'
Async(transient: true) do |task|
  task.sleep 10000
end
puts "done"

Which also immediately prints "done".

ioquatix commented 1 year ago

Transient tasks do not keep the reactor alive, so IIUC, it's exiting after the first iteration of the event loop because it's considered finished.

emiltin commented 1 year ago

OK I see. My use case is that I want to run a task that uses some background child tasks. Once the main task completes, I want all childs (and grand childs) to be stopped immediately. Maybe the parent task can call stop on itself?

ioquatix commented 1 year ago

Can you make the background tasks transient?

emiltin commented 1 year ago

Yes I can make the background task transient, at least by wrapping them in a. transient task.

Transient tasks do not keep the reactor alive, so IIUC, it's exiting after the first iteration of the event loop because it's considered finished.

I think this is what I don't understand. If the task task.sleep 10000 why is it immediately considered finished? So in the example above, why does it immediately print 'done'?

Do they need a parent task that's not completed, to keep them running?

ioquatix commented 1 year ago

Thanks for your questions. I'll try my best to answer it, but if it's not clear keep on asking.

Transient tasks do not keep the reactor alive even if they are busy, they will be stopped forcefully. This is by design. If you don't expect/want that behaviour, don't make transient tasks.

image

https://github.com/socketry/async/blob/442d242efc1a8ec994b9520c938bf3efe4c6f141/lib/async/node.rb#L138-L140 is true when there are only transient children. When this is true at the top level, reactor will exit. Exiting the reactor will terminate all remaining tasks.

Transient tasks let you have behaviour scoped to the life of the reactor, which isn't always the same as lexical/stack scope. This is very useful for implementing caches and other things which should be cleaned up when the reactor exits (e.g. connection pools, etc).

emiltin commented 1 year ago

Thank you for the clear explanation.

I'm using this for RSpec testing of a tcp server/client. The server and the client normally run until stopped. For testing, I want to start them, then run some tests by communicating with them, and then make sure the client/server and all their possible subtask are terminated. That's why I looked running the server/client inside a transient task.

But if I use a transient task, the tests will sometimes fail due to timeouts when waiting for the server/client to reach specific states. If I don't use a transient task, but instead run the server/client in normal task, which I manually stop(), then tests always succeed.

It's not clear to me whether this is expected behaviour.

# always works
Async do |task|
  Async do |transient|
    # open a tcp server
  end
  # read/write to server using Async::IO classes
  # use Async::Notification to wait for the server reaching certain states
ensure
  task.stop
end

# sometimes times out
Async do |task|
  Async transient: true do |transient|
    # run a tcp server
  end
  # read/write to server using Async::IO classes
  # use Async::Notification to wait for the server reaching certain states
end

The timeouts seems a bit random, and seems to happen on both Mac, Linux and Windows. The server and client both has a reader in a child task, and the server listens for connections, etc. so there's a bit of code involved.

https://github.com/rsmp-nordic/rsmp/actions/runs/3060194417/attempts/2

ioquatix commented 1 year ago

Can you please double check the code example formatting is correct? It might be missing a line or have an extra end.

emiltin commented 1 year ago

Ah sorry. Fixed.

ioquatix commented 1 year ago

In the 2nd case, is it true that the Async do |task| is the top level task? Otherwise, you can leak the transient task. In the first example, you are calling task.stop which will stop all children, but in the 2nd you aren't, if you have an outer async block, the transient task will re-parent.

Async do
  Async do |task|
    Async transient: true do |transient|
      # run a tcp server
    end
    # read/write to server using Async::IO classes
    # use Async::Notification to wait for the server reaching certain states
  end

  # Transient task may still exist here.
end

If you leak the transient task, you might be talking to the wrong server. Transient tasks are the mechanism by which the life time of the task is not tied to the parent.

It's important to guarantee isolation, then you shouldn't use transient tasks, and should call task.stop to ensure all state is cleaned up.

emiltin commented 1 year ago

Thanks. Leaking subtask was what I was trying to make sure couldn't happen, and though that transient task might be helpful. But it seems I'm as confused about transient tasks as ever :-( Where's the best explanation that I go back to?

ioquatix commented 1 year ago

I've written about this more in the documentation: https://socketry.github.io/async/guides/asynchronous-tasks/index.html#transient-tasks

ioquatix commented 1 year ago

I'm going to close this issue. Please feel free to create a discussion if you have further questions.

https://github.com/socketry/async/discussions