Search code examples
c++multithreadingcompilationmutexcondition-variable

Concurrent program compiled with clang runs fine, but hangs with gcc


I wrote a class to share a limited number of resources (for instance network interfaces) between a larger number of threads. The resources are pooled and, if not in use, they are borrowed out to the requesting thread, which otherwise waits on a condition_variable. Nothing really exotic: apart for the fancy scoped_lock which requires c++17, it should be good old c++11.

Both gcc10.2 and clang11 compile the test main fine, but while the latter produces an executable which does pretty much what expected, the former hangs without consuming CPU (deadlock?).

With the help of https://godbolt.org/ I tried older versions of gcc and also icc (passing options -O3 -std=c++17 -pthread), all reproducing the bad result, while even there clang confirms the proper behavior.

I wonder if I made a mistake or if the code triggers some compiler misbehavior and in case how to work around that.

#include <iostream>
#include <vector>
#include <stdexcept>
#include <mutex>
#include <condition_variable>

template <typename T>
class Pool {
///////////////////////////
  class Borrowed {
    friend class Pool<T>;
    Pool<T>& pool;
    const size_t id;
    T * val;

    public:
    Borrowed(Pool & p, size_t i, T& v): pool(p), id(i), val(&v) {}
    ~Borrowed() { release(); }
  
    T& get() const {
      if (!val) throw std::runtime_error("Borrowed::get() this resource was collected back by the pool");
      return *val;
    }

    void release() { pool.collect(*this); }
  };
///////////////////////////    
  struct Resource {
    T val;
    bool available = true;
    Resource(T v): val(std::move(v)) {}
  };
///////////////////////////

  std::vector<Resource> vres;
  size_t hint = 0;

  std::condition_variable cv;
  std::mutex mtx;
  size_t available_cnt;

  public:

  Pool(std::initializer_list<T> l): available_cnt(l.size()) {
    vres.reserve(l.size());
    for (T t: l) {
      vres.emplace_back(std::move(t));
    }
std::cout << "Pool has size " << vres.size() << std::endl;
  }

  ~Pool() {
    for ( auto & res: vres ) {
      if ( ! res.available ) {
        std::cerr << "WARNING Pool::~Pool resources are still in use\n";
      }
    }
  }

  Borrowed borrow() {
    std::unique_lock<std::mutex> lk(mtx);
    cv.wait(lk, [&](){return available_cnt > 0;});
    if ( vres[hint].available ) {
      // quick path, if hint points to an available resource
std::cout << "hint good" << std::endl;
      vres[hint].available = false;
      --available_cnt;
      Borrowed b(*this, hint, vres[hint].val);
      if ( hint + 1 < vres.size() ) ++hint;
      return b; // <--- gcc seems to hang here
    } else {
      // full scan to find the available resource
std::cout << "hint bad" << std::endl;
      for ( hint = 0; hint < vres.size(); ++hint ) {
        if ( vres[hint].available ) {
          vres[hint].available = false;
          --available_cnt;
          return Borrowed(*this, hint, vres[hint].val);
        }
      }
    }
    throw std::runtime_error("Pool::borrow() no resource is available - internal logic error");
  }

  void collect(Borrowed & b) {
    if ( &(b.pool) != this ) 
      throw std::runtime_error("Pool::collect() trying to collect resource owned by another pool!");
    if ( b.val ) {
      b.val = nullptr;
      {
        std::scoped_lock<std::mutex> lk(mtx);
        hint = b.id;
        vres[hint].available = true;
        ++available_cnt;
      }
      cv.notify_one();
    }
  }
};

///////////////////////////////////////////////////////////////////

#include <thread>
#include <chrono>

int main() {
  Pool<std::string> pool{"hello","world"};

  std::vector<std::thread> vt;
  for (int i = 10; i > 0; --i) {
    vt.emplace_back( [&pool, i]()
      { 
        auto res = pool.borrow();
        std::this_thread::sleep_for(std::chrono::milliseconds(i*300));
        std::cout << res.get() << std::endl;
      }
    );
  }

  for (auto & t: vt) t.join();

  return 0;
}

Solution

  • Locking destructor coupled with missed NRVO caused the issue (credits to Secundi for pointing this out in the comments).

    If the compiler skips NRVO, the few lines below if will call the destructor of b. The destructor tries to acquire the mutex before this gets released by the unique_lock, resulting in a deadlock.

    Borrowed b(*this, hint, vres[hint].val);
    if ( hint + 1 < vres.size() ) ++hint;
    return b; // <--- gcc seems to hang here
    

    It is of crucial importance here to avoid destroying b. In fact, even if manually releasing the unique_lock before returning will avoid the deadlock, the destructor of b will mark the pooled resource as available, while this is just being borrowed out, making the code wrong.

    A possible fix consists in replacing the lines above with:

    const auto tmp = hint;
    if ( hint + 1 < vres.size() ) ++hint;
    return Borrowed(*this, tmp, vres[tmp].val);
    

    Another possibility (which does not exclude the former) is to delete the (evil) copy ctor of Borrowed and only provide a move ctor:

    Borrowed(const Borrowed &) = delete;
    Borrowed(Borrowed && b): pool(b.pool), id(b.id), val(b.val) { b.val = nullptr; }