Cleanup in Callback-based Asynchronous APIs?

Started by
6 comments, last by SeanMiddleditch 9 years, 10 months ago

I'm not really used to callback APIs (except glut, which I like to pretend never happened) and have traditionally used futures or some kind of thread-pool mechanism for asynchrony in my code. As such, I don't really understand how this works.

So I've got a "kick-off" endpoint:


void doneHandler(SomeLibraryObjectAsync *caller, other_args)
{
  //write to some static memory somewhere.
  delete caller;

}

void doStuff()
{
  SomeLibraryObjectAsync* task = new SomeLibraryObjectAsync;
  task.GoAndCallThisWhenDone(doneHandler);
}

This is obviously highly pared-down, but the problem still rears its ugly head -- how can I safely delete the async object without a blocking wait on the main thread? The delete call in the handler should take care of it, but as far as I can tell there's no way I can know if that delete call is safe, because the object itself is above it in the stack, and without looking at the source code (if it's even available, which in my case it's not) I have no way of knowing whether it's safe to destroy at that moment.

Is it just assumed that all callback-based APIs ensure safe destruction when their last callback runs? Is there some magical "delete but don't call the destructor until the object I want to destroy gets popped off the callstack-but-not-quite-RAII" thing I've never heard of in C++?

Perhaps I've just drunk the RAII kool-aid so much I can't even reason around objects with non-deterministic lifetimes.

Advertisement

I'm not quite sure what you're asking. In your example code, "task" is never on the callstack to begin with. A pointer to task is on the callstack, but not the object itself. So, assuming the destructor to SomeLibraryObjectAsync is thread safe and the delete operator is thread safe, it should be fine to delete the object in the callback. The main problem I see is that if the "doStuff" function had more stuff after the task.GoAndCallThisWhenDone() call then task could potentially become a dangling reference.

If you were really concerned about things you could have the doneHandler add the caller pointer to a "dead" list that you run through at some deterministic point on the main thread. That way you can keep all your new/deletes on the same thread.

I'm not quite sure what you're asking. In your example code, "task" is never on the callstack to begin with. A pointer to task is on the callstack, but not the object itself. So, assuming the destructor to SomeLibraryObjectAsync is thread safe and the delete operator is thread safe, it should be fine to delete the object in the callback. The main problem I see is that if the "doStuff" function had more stuff after the task.GoAndCallThisWhenDone() call then task could potentially become a dangling reference.

If you were really concerned about things you could have the doneHandler add the caller pointer to a "dead" list that you run through at some deterministic point on the main thread. That way you can keep all your new/deletes on the same thread.

Perhaps I wasn't clear. The assumption here is that "GoAndCallThisWhenDone" does some asynchronous work. Maybe It's a thread that does stuff. Maybe it's an IO interrupt. Whatever. The point is that (if it's a thread, but interrupts basically incur the same problem) the call stack will look like this:

task.SomeInternalFunction()

- doneHandler()

If task.SomeInternalFunction() looks like this:


void SomeLibraryObjectAsync::SomeInternalFunction()
{
  //do stuff
  doneHandler(this, args);
  //do more stuff
}

"do more stuff" could cause the program to crash if the handler deletes the task.


The point is that (if it's a thread, but interrupts basically incur the same problem) the call stack will look like this:

task.SomeInternalFunction()
- doneHandler()

I see what you're saying now.

It's certainly possible that your stack looks like that on the worker thread, but it's not guaranteed to be like that. The doneHandler could just as easily be called at the end of a function that wraps all the task calls. It's up to the API documentation/samples to indicate what kinds of operations are appropriate from within the callback functions. I don't think there's really a better answer than that. There is no magic delete operator like you suggested in the OP, you've just got to read the documents and look at sample code to determine what the correct usage is.

I'm normally really against shared_ptr, but this is a circumstance it handles well: you don't know the exact lifetime of your object, but you need it to be cleaned up when you're done.

With a little more thought you can use the much-better unique_ptr, but making it easy requires generalized lambda capture (a C++14 feature not yet in any shipping version of Visual C++, though it's in the latest GCC and Clang releases).

Sean Middleditch – Game Systems Engineer – Join my team!

I see what you're saying now.

It's certainly possible that your stack looks like that on the worker thread, but it's not guaranteed to be like that. The doneHandler could just as easily be called at the end of a function that wraps all the task calls. It's up to the API documentation/samples to indicate what kinds of operations are appropriate from within the callback functions. I don't think there's really a better answer than that. There is no magic delete operator like you suggested in the OP, you've just got to read the documents and look at sample code to determine what the correct usage is.

I know it's not guaranteed to be like that. I was more asking "how do I know it's not like that, or if it is, what can I do about it?"

From the sound of things, the answers are "you don't, unless the docs say so" and "nothing, redesign your architecture". That's unfortunate, but c'est la vie. Thanks.

I'm normally really against shared_ptr, but this is a circumstance it handles well: you don't know the exact lifetime of your object, but you need it to be cleaned up when you're done.

With a little more thought you can use the much-better unique_ptr, but making it easy requires generalized lambda capture (a C++14 feature not yet in any shipping version of Visual C++, though it's in the latest GCC and Clang releases).

It's not really an issue of what kind of owning pointer is used. The problem is that you've got a transfer of control flow where the object you're deleting is technically in use, so the safety of delete/release/whatever is unknown. In the real world example I ran into, I have an object that owns a bunch of ComPtrs (which are reference counted like shared_ptr) that wrap the whole asynchronous transaction which calls back into it at the end to "delete this". That's when I realized there was no apparently well-defined way to know if I was actually at the end. It's unfortunate that the docs are the only outlet in this situation. I was hoping it was some well-trodden path, but I guess I'll just have to test it a lot and hope for the best.

A well-designed API will make it clear when you are allowed to clean up, or possibly just handle cleanup itself internally.

Sadly, most APIs are not well-designed, so you have to fall back to documentation. Oh wait, most people don't document their APIs, either.



This is why callback-based APIs in manual memory management languages are a bad idea.

Wielder of the Sacred Wands
[Work - ArenaNet] [Epoch Language] [Scribblings]

It's really not that hard at all. You absolutely don't need garbage collection and you don't need to abandon callbacks or use a different paradigm. Just separate the object that owns state from the operation itself.

shared_ptr<State> pState = make_shared<State>(); // ref = 1

do_async_foo1([pState](){ // ++ ref during lambda construction
  intro1(pState);
  do_async_foo2([pState](){ // ++ ref during lambda construction
    intro2(pState);
    do_async_foo3([pState](){ // ++ ref during lambda construction
      intro3(pState);
      outtro3(pState);
      // -- ref at end of scope, probably when the deletion happens if the async operations are all delayed
    });
    outtro2(pState);
    // -- ref at end of scope
  });
  outtro1(pState);
  // -- ref at end of scope
});
// -- ref at end of scope
The problem with using member functions directly is that `this` doesn't participate in reference counting, so you _must_ bind a shared_ptr to the object and not just `this`.

Using the above pattern, though, your lambdas could invoke member functions on `pState` or member functions on objects owned by `pState` as necessary.

With a little bit of (admittedly non-trivial) work you can also create a helper function similar to `std::bind` to bind member functions around a `shared_ptr` allowing you to chain operation on a reference counted object, e.g.

http://goo.gl/BlYOCd

The interesting bit being:

// returns an unnamed functor that can implicitly case to std::function<ReturnType(ParamTypes...)>
template <typename ObjectType, template <typename> class PtrType, typename ReturnType, typename ...ParamTypes>
auto my_bind(PtrType<ObjectType> ptr, ReturnType(ObjectType::*member)(ParamTypes...))
{
  // C++14 generalized lambda capture
  //return [ptr = std::move(ptr), &member](ParamTypes&&... argv){ return (ptr.get()->*member)(std::forward<ParamTypes>(argv)...); };

  // pre-C++14 generalized lambda capture
  struct wrapper {
    PtrType<ObjectType> ptr;
    ReturnType(ObjectType::*member)(ParamTypes...);
    wrapper(PtrType<ObjectType> ptr, ReturnType(ObjectType::*member)(ParamTypes...)) : ptr(ptr), member(member) {}
    ReturnType operator()(ParamTypes&&... argv) {
      return (ptr.get()->*member)(std::forward<ParamTypes>(argv)...);
    }
  };
  return wrapper(std::move(ptr), member);
}
pState->do_async(my_bind(pState, &State::on_done));
You can cobble something together using std::bind and std::mem_fn, too, but you can't make it generic to any number of parameters without some serious contortions.

You can remove the use of an explicit `pState` object with a bit of appropriate application of lambdas, but I leave that as an exercise for the reader (which should be trivial at this point if you've been following along).

Sean Middleditch – Game Systems Engineer – Join my team!

This topic is closed to new replies.

Advertisement