johnco3
johnco3

Reputation: 2652

Using boost's scoped_allocator_adaptor for shared memory container

I am writing a C++17 application and I need to manage an STL or boost::collections equivalent data structure in shared memory.

I'm not sure of the simplest syntax (which avoids passing allocators all over the place) to create and update the shared data structure.

I have been searching for some time, but other than a trivial String->String map, examples focused on custom data structures or POD structs are hard to come by. (I suspect that the allocator associated with POD structs would be fairly easy, as these can be allocated from contiguous memory, and therefore could use the simple char allocator - equivalent to Shared::Alloc<char> below).

From what I understand, the key to managing collections of data structures in shared memory centers around the correct choice of a stateful allocators and the ability to have that allocator shared with its nested children.

So for example, let's say I have a map<Shared::String, vector<Shared::String>> in shared memory, somehow the magic of the scoped_allocator_adaptor would work.

Beyond the simple example of a map<SHMString, vector<String>> above, I would really like to manage a map<SHMString, vector<UserStruct>> where UserStruct can be either a POD struct or a struct containing a String or List of strings.

I started out with the following as a useful starting point from another answer I found in SO:

namespace bip = boost::interprocess;

namespace Shared {
    using Segment = bip::managed_shared_memory;

    template <typename T>
        using Alloc   = bip::allocator<T, Segment::segment_manager>;
    using Scoped  = boost::container::scoped_allocator_adaptor<Alloc<char>>;

    using String  = boost::container::basic_string<char, std::char_traits<char>, Scoped>;
    using KeyType = String;
}

It looks like the Shared:Scoped allocator adapter is key to propagating the allocator from a top level container to its children. I'm not sure if this is different when applied to the boost containers vs the standard containers.

An example, and explanation on how to construct these objects in a way that would allow me to propagate the scoped_allocator_adaptor to my POD or custom struct is what I am looking for.

Upvotes: 2

Views: 952

Answers (1)

sehe
sehe

Reputation: 393694

Shooting for the stars, are we :) Painless allocator propagation is the holy grail.

It looks like the Shared:Scoped allocator adapter is key to propagating the allocator from a top level container to its children.

Indeed

I'm not sure if this is different when applied to the boost containers vs the standard containers.

In my understanding, modern C++ standard libraries should support the same, but in practice my experience has shown that it often worked with Boost Container containers. (YMMV and standard library implementations may/will catch up)

What To Do

I think you will want to understand the uses_allocator protocol: https://en.cppreference.com/w/cpp/memory/uses_allocator

enter image description here

This really answers all of your questions, I suppose. I'll try to come up with a quick sample if I can.

Demo

So far I have got the following two approaches working:

struct MyStruct {
    String data;

    using allocator_type = Alloc<char>;

    MyStruct(MyStruct const& rhs, allocator_type = {}) : data(rhs.data) {}
    template <typename I, typename = std::enable_if_t<not std::is_same_v<MyStruct, I>, void> >
    MyStruct(I&& init, allocator_type a)
     : data(std::forward<I>(init), a)
    { }
};

This allows:

Shared::Segment mf(bip::open_or_create, "test.bin", 10<<20);

auto& db = *mf.find_or_construct<Shared::Database>("db")(mf.get_segment_manager());

db.emplace_back("one");
db.emplace_back("two");
db.emplace_back("three");

The slightly more complicated/versatile (?) approach also works:

    MyStruct(std::allocator_arg_t, allocator_type, MyStruct const& rhs) : data(rhs.data) {}

    template <
        typename I,
        typename A = Alloc<char>,
        typename = std::enable_if_t<not std::is_same_v<MyStruct, I>, void> >
    MyStruct(std::allocator_arg_t, A alloc, I&& init)
     : data(std::forward<I>(init), alloc.get_segment_manager())
    { }

It appears that for the current use-case, the inner typedef allocator_type is enough to signal that MyStruct supports allocator-construction, making the specialization of uses_allocator<MyStruct, ...> redundant.

Full Listing

Live On Coliru

#include <boost/interprocess/containers/vector.hpp>
#include <boost/interprocess/containers/string.hpp>
#include <boost/interprocess/managed_mapped_file.hpp>
#include <boost/interprocess/allocators/allocator.hpp>
#include <boost/container/scoped_allocator.hpp>
#include <iostream>

namespace bip = boost::interprocess;

namespace Shared {
    using Segment = bip::managed_mapped_file;
    using SMgr = Segment::segment_manager;

    template <typename T> using Alloc = boost::container::scoped_allocator_adaptor<
            bip::allocator<T, SMgr>
        >;

    template <typename T> using Vec = boost::container::vector<T, Alloc<T> >;

    using String = bip::basic_string<char, std::char_traits<char>, Alloc<char> >;

    struct MyStruct {
        String data;

        using allocator_type = Alloc<char>;

#if 1 // one approach
        MyStruct(std::allocator_arg_t, allocator_type, MyStruct const& rhs) : data(rhs.data) {}

        template <
            typename I,
            typename A = Alloc<char>,
            typename = std::enable_if_t<not std::is_same_v<MyStruct, I>, void> >
        MyStruct(std::allocator_arg_t, A alloc, I&& init)
         : data(std::forward<I>(init), alloc.get_segment_manager())
        { }
#else // the simpler(?) approach
        MyStruct(MyStruct const& rhs, allocator_type = {}) : data(rhs.data) {}
        template <typename I, typename = std::enable_if_t<not std::is_same_v<MyStruct, I>, void> >
        MyStruct(I&& init, allocator_type a)
         : data(std::forward<I>(init), a)
        { }
#endif
    };

    using Database = Vec<MyStruct>;
}

namespace std {
    // this appears optional for the current use case
    template <typename T> struct uses_allocator<Shared::MyStruct, T> : std::true_type {};
}

int main() {
    Shared::Segment mf(bip::open_or_create, "test.bin", 10<<20);

    auto& db = *mf.find_or_construct<Shared::Database>("db")(mf.get_segment_manager());

    db.emplace_back("one");
    db.emplace_back("two");
    db.emplace_back("three");

    std::cout << "db has " << db.size() << " elements:";

    for (auto& el : db) {
        std::cout << " " << el.data;
    }

    std::cout << std::endl;
}

Invoking it three times:

db has 3 elements: one two three
db has 6 elements: one two three one two three
db has 9 elements: one two three one two three one two three

Update: More Complicated

In response to the comments, let's make it more complicated in two ways:

  • The struct constructor will take various arguments initializing various members, some of which will use an allocator.
  • We want to store it in a Map, and some of the use-patterns involving map are pesky with scoped allocator support (emplacement, map[k]=v update-assignment with default-construction requirements)
  • std::initalizer_list<> will not be deduced in generic forwarding wrappers :(

Defining the struct:

struct MyPodStruct {
    using allocator_type = ScopedAlloc<char>;

    int a = 0; // simplify default constructor using NSMI
    int b = 0;
    Vec<uint8_t> data;

    explicit MyPodStruct(allocator_type alloc) : data(alloc) {}
    //MyPodStruct(MyPodStruct const&) = default;
    //MyPodStruct(MyPodStruct&&) = default;
    //MyPodStruct& operator=(MyPodStruct const&) = default;
    //MyPodStruct& operator=(MyPodStruct&&) = default;

    MyPodStruct(std::allocator_arg_t, allocator_type, MyPodStruct&& rhs) : MyPodStruct(std::move(rhs)) {}
    MyPodStruct(std::allocator_arg_t, allocator_type, MyPodStruct const& rhs) : MyPodStruct(rhs) {}

    template <typename I, typename A = Alloc<char>>
        MyPodStruct(std::allocator_arg_t, A alloc, int a, int b, I&& init)
         : MyPodStruct(a, b, Vec<uint8_t>(std::forward<I>(init), alloc)) { }

  private:
    explicit MyPodStruct(int a, int b, Vec<uint8_t> data) : a(a), b(b), data(std::move(data)) {}
};    

It addresses "default construction" (under uses-allocator regime), and the various constructors that take multiple arguments. Not that SFINAE is no longer required to disambiguate the uses-allocator copy-constructor, because the number of arguments differs.

Now, using it is more involved than above. Specifically, since there are multiple constructor arguments to be forwarded, we need another bit of "construction protocol": std::piece_wise_construct_t.

The inline comments talk about QoL/QoI concerns and pitfalls:

int main() {
    using Shared::MyPodStruct;
    Shared::Segment mf(bip::open_or_create, "test.bin", 10<<10); // smaller for Coliru
    auto mgr = mf.get_segment_manager();

    auto& db = *mf.find_or_construct<Shared::Database>("complex")(mgr);

    // Issues with brace-enclosed initializer list
    using Bytes = std::initializer_list<uint8_t>;

    // More magic: piecewise construction protocol :)
    static constexpr std::piecewise_construct_t pw{};
    using std::forward_as_tuple;
    db.emplace(pw, forward_as_tuple("one"), forward_as_tuple(1,2, Bytes {1,2}));
    db.emplace(pw, forward_as_tuple("two"), forward_as_tuple(2,3, Bytes {4}));
    db.emplace(pw, forward_as_tuple("three"), forward_as_tuple(3,4, Bytes {5,8}));

    std::cout << "\n=== Before updates\n" << db << std::endl;

    // Clumsy:
    db[Shared::String("one", mgr)] = MyPodStruct{std::allocator_arg, mgr, 1,20, Bytes {7,8,9}};

    // As efficient or better, and less clumsy:
    auto insert_or_update = [&db](auto&& key, auto&&... initializers) -> MyPodStruct& {
        // Be careful not to move twice: https://en.cppreference.com/w/cpp/container/map/emplace
        // > The element may be constructed even if there already is an element
        // > with the key in the container, in which case the newly constructed
        // > element will be destroyed immediately.
        if (auto insertion = db.emplace(pw, forward_as_tuple(key), std::tie(initializers...)); insertion.second) {
            return insertion.first->second;
        } else {
            return insertion.first->second = MyPodStruct(
                std::allocator_arg, 
                db.get_allocator(),
                std::forward<decltype(initializers)>(initializers)...); // forwarding ok here
        }
    };

    insert_or_update("two", 2,30, Bytes{});
    insert_or_update("nine", 9,100, Bytes{5,6});

    // partial updates:
    db.at(Shared::String("nine", mgr)).data.push_back(42);

    // For more efficient key lookups in the case of unlikely insertion, use
    // heterogeneous comparer, see https://stackoverflow.com/a/27330042/85371

    std::cout << "\n=== After updates\n" << db << std::endl;
}

Which prints Live On Coliru

=== Before updates
db has 3 elements: {one: 1,2, [1,2,]} {three: 3,4, [5,8,]} {two: 2,3, [4,]}

=== After updates
db has 4 elements: {nine: 9,100, [5,6,42,]} {one: 1,20, [7,8,9,]} {three: 3,4, [5,8,]} {two: 2,30, []}

Full Listing

For conservation: Live On Coliru

#include <boost/interprocess/containers/map.hpp>
#include <boost/interprocess/containers/string.hpp>
#include <boost/interprocess/containers/vector.hpp>
#include <boost/interprocess/managed_mapped_file.hpp>
#include <boost/interprocess/allocators/allocator.hpp>
#include <boost/container/scoped_allocator.hpp>
#include <iostream>

namespace bip = boost::interprocess;

namespace Shared {
    using Segment = bip::managed_mapped_file;
    using SMgr = Segment::segment_manager;

    template <typename T> using Alloc = bip::allocator<T, SMgr>;
    template <typename T> using ScopedAlloc = boost::container::scoped_allocator_adaptor<Alloc<T> >;

    using String = bip::basic_string<char, std::char_traits<char>, Alloc<char> >;

    using boost::interprocess::map;

    template <typename T> using Vec = 
        boost::container::vector<T, ScopedAlloc<T>>;

    template <typename K, typename T> using Map = 
        map<K, T, std::less<K>, ScopedAlloc<typename map<K, T>::value_type>>;

    struct MyPodStruct {
        using allocator_type = ScopedAlloc<char>;

        int a = 0; // simplify default constructor using NSMI
        int b = 0;
        Vec<uint8_t> data;

        explicit MyPodStruct(allocator_type alloc) : data(alloc) {}
        //MyPodStruct(MyPodStruct const&) = default;
        //MyPodStruct(MyPodStruct&&) = default;
        //MyPodStruct& operator=(MyPodStruct const&) = default;
        //MyPodStruct& operator=(MyPodStruct&&) = default;

        MyPodStruct(std::allocator_arg_t, allocator_type, MyPodStruct&& rhs) : MyPodStruct(std::move(rhs)) {}
        MyPodStruct(std::allocator_arg_t, allocator_type, MyPodStruct const& rhs) : MyPodStruct(rhs) {}

        template <typename I, typename A = Alloc<char>>
            MyPodStruct(std::allocator_arg_t, A alloc, int a, int b, I&& init)
             : MyPodStruct(a, b, Vec<uint8_t>(std::forward<I>(init), alloc)) { }

      private:
        explicit MyPodStruct(int a, int b, Vec<uint8_t> data) : a(a), b(b), data(std::move(data)) {}
    };    

    using Database = Map<String, MyPodStruct>;

    static inline std::ostream& operator<<(std::ostream& os, Database const& db) {
        os << "db has " << db.size() << " elements:";

        for (auto& [k,v] : db) {
            os << " {" << k << ": " << v.a << "," << v.b << ", [";
            for (unsigned i : v.data)
                os << i << ",";
            os << "]}";
        }

        return os;
    }
}

int main() {
    using Shared::MyPodStruct;
    Shared::Segment mf(bip::open_or_create, "test.bin", 10<<10); // smaller for Coliru
    auto mgr = mf.get_segment_manager();

    auto& db = *mf.find_or_construct<Shared::Database>("complex")(mgr);

    // Issues with brace-enclosed initializer list
    using Bytes = std::initializer_list<uint8_t>;

    // More magic: piecewise construction protocol :)
    static constexpr std::piecewise_construct_t pw{};
    using std::forward_as_tuple;
    db.emplace(pw, forward_as_tuple("one"), forward_as_tuple(1,2, Bytes {1,2}));
    db.emplace(pw, forward_as_tuple("two"), forward_as_tuple(2,3, Bytes {4}));
    db.emplace(pw, forward_as_tuple("three"), forward_as_tuple(3,4, Bytes {5,8}));

    std::cout << "\n=== Before updates\n" << db << std::endl;

    // Clumsy:
    db[Shared::String("one", mgr)] = MyPodStruct{std::allocator_arg, mgr, 1,20, Bytes {7,8,9}};

    // As efficient or better, and less clumsy:
    auto insert_or_update = [&db](auto&& key, auto&&... initializers) -> MyPodStruct& {
        // Be careful not to move twice: https://en.cppreference.com/w/cpp/container/map/emplace
        // > The element may be constructed even if there already is an element
        // > with the key in the container, in which case the newly constructed
        // > element will be destroyed immediately.
        if (auto insertion = db.emplace(pw, forward_as_tuple(key), std::tie(initializers...)); insertion.second) {
            return insertion.first->second;
        } else {
            return insertion.first->second = MyPodStruct(
                std::allocator_arg, 
                db.get_allocator(),
                std::forward<decltype(initializers)>(initializers)...); // forwarding ok here
        }
    };

    insert_or_update("two", 2,30, Bytes{});
    insert_or_update("nine", 9,100, Bytes{5,6});

    // partial updates:
    db.at(Shared::String("nine", mgr)).data.push_back(42);

    // For more efficient key lookups in the case of unlikely insertion, use
    // heterogeneous comparer, see https://stackoverflow.com/a/27330042/85371

    std::cout << "\n=== After updates\n" << db << std::endl;
}

Upvotes: 4

Related Questions