An example when we don’t need std::shared_ptr in C++

In the previous example, independent components needed shared ownership of cached objects. For a TCP session, however, we can compare two designs: multiple callbacks keeping the session alive through std::shared_ptr, or a single owning coroutine using std::unique_ptr and structured concurrency. In the second design, reading and writing still run concurrently, but the owner waits for both operations to stop before destroying the session. No shared ownership or reference counting is needed.

#include <utility>
#include <boost/asio.hpp>
#include <boost/asio/experimental/awaitable_operators.hpp>
#include <array>
#include <chrono>
#include <iostream>
#include <memory>
#include <string>

namespace asio = boost::asio;
using tcp = asio::ip::tcp;
using namespace std::chrono_literals;

// Each TCP session has exactly one owner.
class Session
{
public:
    explicit Session(tcp::socket socket)
        : _socket(std::move(socket)),
          _heartbeatTimer(_socket.get_executor())
    {
        std::cout << "Session created\n";
    }

    ~Session()
    {
        std::cout << "Session destroyed\n";
    }

    asio::awaitable<void> run()
    {
        using namespace asio::experimental::awaitable_operators;

        // Run both loops concurrently, not sequentially.
        // If either fails, cancel the other and wait for it to stop.
        co_await (readLoop() && writeLoop());
    }

private:
    asio::awaitable<void> readLoop()
    {
        std::array<char, 4096> buffer;

        for (;;)
        {
            const auto bytes_read = co_await _socket.async_read_some(
                asio::buffer(buffer), asio::use_awaitable);

            std::cout.write(buffer.data(),
                static_cast<std::streamsize>(bytes_read));
            std::cout.flush();
        }
    }

    asio::awaitable<void> writeLoop()
    {
        const std::string heartbeat = "heartbeat\n";

        for (;;)
        {
            _heartbeatTimer.expires_after(5s);
            co_await _heartbeatTimer.async_wait(asio::use_awaitable);

            co_await asio::async_write(
                _socket, asio::buffer(heartbeat), asio::use_awaitable);
        }
    }

    tcp::socket _socket;
    asio::steady_timer _heartbeatTimer;
};

// The unique_ptr lives in this coroutine's frame while it awaits run().
asio::awaitable<void> handleSession(std::unique_ptr<Session> session)
{
    try
    {
        co_await session->run();
    }
    catch (const std::exception& error)
    {
        std::cout << "Session finished: " << error.what() << '\n';
    }

    // Both child loops have stopped; the session can now be destroyed.
}

asio::awaitable<void> acceptLoop(tcp::acceptor& acceptor)
{
    auto executor = co_await asio::this_coro::executor;

    for (;;)
    {
        auto socket = co_await acceptor.async_accept(asio::use_awaitable);
        auto session = std::make_unique<Session>(std::move(socket));

        // The spawned parent coroutine owns this session.
        // The accept loop immediately continues accepting clients.
        asio::co_spawn(executor,
            handleSession(std::move(session)), asio::detached);
    }
}

int main()
{
    try
    {
        asio::io_context context(1);
        tcp::acceptor acceptor(context, {tcp::v4(), 5555});

        asio::co_spawn(context, acceptLoop(acceptor), asio::detached);
        context.run();
    }
    catch (const std::exception& error)
    {
        std::cerr << "Server error: " << error.what() << '\n';
        return 1;
    }
}

The point: concurrent operations do not automatically imply shared ownership. Here, handleSession() owns the TCP session, and run() waits for both loops to finish. This example focuses on session lifetime; production server shutdown must also stop accepting connections and coordinate all outstanding sessions.

This design also follows C++ Core Guidelines R.21: “Prefer unique_ptr over shared_ptr unless you need to share ownership.” When an object has a single owner, std::unique_ptr makes its lifetime easier to predict and avoids unnecessary reference counting. Use std::shared_ptr when shared ownership is actually required, not simply because several operations access the same object.

Build with a recent Boost.Asio and C++20: g++ -std=c++20 -Wall -Wextra -pthread main.cpp -o tcp_server. Connect using nc 127.0.0.1 5555.

Which pointer should you use in C++?

TypeOwnershipWhen to use it
TDirect ownershipStore the object by value when possible.
std::unique_ptr<T>Exclusive ownershipOne component is responsible for the object’s lifetime.
std::shared_ptr<T>Shared ownershipSeveral independent components need to keep the same object alive.
std::weak_ptr<T>Non-owning observer of shared stateObserve an object managed by std::shared_ptr without extending its lifetime.
T*Non-owning accessRefer to an existing object that may be absent (null).
T&Non-owning accessRefer to an existing object that must be present.

This table is a practical summary, not a table reproduced verbatim from the guidelines. See the C++ Core Guidelines — Resource management, particularly R.21 on preferring unique_ptr unless shared ownership is needed.

Leave a Reply

Your email address will not be published. Required fields are marked *