Jeffrey Yasskin
Jeffrey Yasskin

Reputation: 5732

C++ memory management patterns for objects used in callback chains

A couple codebases I use include classes that manually call new and delete in the following pattern:

class Worker {
 public:
  void DoWork(ArgT arg, std::function<void()> done) {
    new Worker(std::move(arg), std::move(done)).Start();
  }

 private:
  Worker(ArgT arg, std::function<void()> done)
      : arg_(std::move(arg)),
        done_(std::move(done)),
        latch_(2) {} // The error-prone Latch interface isn't the point of this question. :)

  void Start() {
    Async1(<args>, [=]() { this->Method1(); });
  }
  void Method1() {
    StartParallel(<args>, [=]() { this->latch_.count_down(); });
    StartParallel(<other_args>, [=]() { this->latch_.count_down(); });
    latch_.then([=]() { this->Finish(); });
  }
  void Finish() {
    done_();
    // Note manual memory management!
    delete this;
  }

  ArgT arg_
  std::function<void()> done_;
  Latch latch_;
};

Now, in modern C++, explicit delete is a code smell, as, to some extent is delete this. However, I think this pattern (creating an object to represent a chunk of work managed by a callback chain) is fundamentally a good, or at least not a bad, idea.

So my question is, how should I rewrite instances of this pattern to encapsulate the memory management?

One option that I don't think is a good idea is storing the Worker in a shared_ptr: fundamentally, ownership is not shared here, so the overhead of reference counting is unnecessary. Furthermore, in order to keep a copy of the shared_ptr alive across the callbacks, I'd need to inherit from enable_shared_from_this, and remember to call that outside the lambdas and capture the shared_ptr into the callbacks. If I ever wrote the simple code using this directly, or called shared_from_this() inside the callback lambda, the object could be deleted early.

Upvotes: 1

Views: 746

Answers (2)

bcmills
bcmills

Reputation: 5197

I agree that delete this is a code smell, and to a lesser extent delete on its own. But I think that here it is a natural part of continuation-passing style, which (to me) is itself something of a code smell.

The root problem is that the design of this API assumes unbounded control-flow: it acknowledges that the caller is interested in what happens when the call completes, but signals that completion via an arbitrarily-complex callback rather than simply returning from a synchronous call. Better to structure it synchronously and let the caller determine an appropriate parallelization and memory-management regime:

class Worker {
 public:
  void DoWork(ArgT arg) {
    // Async1 is a mistake; fix it later.  For now, synchronize explicitly.
    Latch async_done(1);
    Async1(<args>, [&]() { async_done.count_down(); });
    async_done.await();

    Latch parallel_done(2);
    RunParallel([&]() { DoStuff(<args>); parallel_done.count_down(); });
    RunParallel([&]() { DoStuff(<other_args>); parallel_done.count_down(); };
    parallel_done.await();
  }
};

On the caller-side, it might look something like this:

Latch latch(tasks.size());
for (auto& task : tasks) {
  RunParallel([=]() { DoWork(<args>); latch.count_down(); });
}
latch.await();

Where RunParallel can use std::thread or whatever other mechanism you like for dispatching parallel events.

The advantage of this approach is that object lifetimes are much simpler. The ArgT object lives for exactly the scope of the DoWork call. The arguments to DoWork live exactly as long as the closures containing them. This also makes it much easier to add return-values (such as error codes) to DoWork calls: the caller can just switch from a latch to a thread-safe queue and read the results as they complete.

The disadvantage of this approach is that it requires actual threading, not just boost::asio::io_service. (For example, the RunParallel calls within DoWork() can't block on waiting for the RunParallel calls from the caller side to return.) So you either have to structure your code into strictly-hierarchical thread pools, or you have to allow a potentially-unbounded number of threads.

Upvotes: 3

Jeffrey Yasskin
Jeffrey Yasskin

Reputation: 5732

One option is that the delete this here is not a code smell. At most, it should be wrapped into a small library that would detect if all the continuation callbacks were destroyed without calling done_().

Upvotes: 0

Related Questions