How should I use async_read_until and async_write simultaneously in the client app in boost::asio?

Viewed 98

My motivation.

I am trying to build a simple messenger. Currently I have written client and server apps that support "mail like functional", that is they lack chat interaction that you have in every instant messenger.

Here is a model I use.

Server: The server for every connected client has a dedicated Service class that provides actual service. An instance of the Service class has an id.

Client: At particular moment simultaneously starts reading messages from and writing messages to the associated Service instance.

Tracker: Records current sessions of users by saving their logins and Service ids in a map. Also records opened chats by saving key-value pairs (chat participant id 1, chat participant id 2). I use logins and ids of users interchangeably because I have a database.

Here is a typical usage scenario.

  1. A user is trying to log in. The server dedicates to the user the Service instance with id 1. Then the user identified as Bob.
  2. Bob opens a chat with Ann. Tracker records that Bob uses Service 1 and that Bob opened the chat with Ann.
  3. A user is trying to log in. The server dedicates to the user the Service instance with id 2. Then the user identified as Ann.
  4. Ann opens a chat with Bob. Tracker records that Ann uses Service 2 and that Ann opened the chat with Bob.
  5. Ann writes a message to Bob. Service 2 receives the message and asks Service 1 to send the message to Bob's chat if Bob has opened the chat with Ann. For that purpose I use Tracker. In our case Bob is in the chat so Bob's client app should read the message from Service 1. Otherwise Service 2 only stores the new message in the database.

When a user opens a chat with somebody the client app simultaneously starts reading and writing messages to the associate Service instance.

Issue

  1. Bob opens a chat with Ann. Ann opens a chat with Bob.
  2. Ann sends messages. They are displayed in Bobs chat.
  3. Bob sends a message. It is not displayed in Ann's chat. Moreover, further Ann's messages are no longer displayed in Bob's chat.

Here is a portion of my server code. I have added some context but you probably want to look at Service::onMessageReceived, Service::receive_message, Service::send_to_chat

/// Struct to track active sessions of clients
struct Tracker {
  static std::mutex current_sessions_guard; ///< mutex to lock the map of current sessions between threads
  static std::map<long, long> current_sessions; 
  static std::map<long, int> client_to_service_id;
};

Class that provides the actual service in the client-service model

class Service {
public:
  void send_to_chat(const std::string& new_message) {
    asio::async_write(*m_sock.get(), asio::buffer(new_message),
      [this]() {
        onAnotherPartyMessageSent(); 
      });
  } 

private:
  void onReceivedReady();
  void receive_message() {
    /// Server loop for reading messages from the client
    spdlog::info("[{}] in receive_message", service_id);

    asio::async_read_until(*m_sock.get(), *(m_message.get()), '\n',
      [this]() {
        onMessageReceived();
      });
  } 
  void onMessageReceived();

private:
  std::shared_ptr<asio::ip::tcp::socket> m_sock; ///< Pointer to an active socket that is used to communicate
                                                 ///< with the client
  int service_id;  
  long dialog_id = -1, client_id = -1, another_party_id = -1;
  std::shared_ptr<asio::streambuf> m_message;
};

Definitions of methods


void Service::onMessageReceived() {
  /// Updates the database with the new message and asks Service instance of another participant
  /// to send the message if they opened this chat.

  std::istream istrm(m_message.get());
  std::string new_message;
  std::getline(istrm, new_message);
  m_message.reset(new asio::streambuf);

  std::unique_lock<std::mutex> tracker_lock(Tracker::current_sessions_guard);

  if (Tracker::current_sessions.find(another_party_id) != Tracker::current_sessions.end()) {
    if (Tracker::current_sessions[another_party_id] == client_id) {
      int another_party_service_id = Tracker::client_to_service_id[another_party_id];
      std::string formatted_msg = _form_message_str(login, new_message);
      
      spdlog::info("[{}] sends to chat '{}'", another_party_service_id, new_message);

      Server::launched_services[another_party_service_id]->send_to_chat(formatted_msg);
    }
  }
  tracker_lock.unlock();
  receive_message();
} 

Here is a portion of my client code. I have added some context but you probably want to look at AsyncTCPClient::onSentReady, AsyncTCPClient::message_send_loop, AsyncTCPClient::message_wait_loop.

/// Struct that stores a session with the given server
struct Session {
  asio::ip::tcp::socket m_sock; //!< The socket for the client application to connect to the server
  asio::ip::tcp::endpoint m_ep; //!< The server's endpoint
  std::string current_chat;

  std::shared_ptr<asio::streambuf> m_chat_buf;
  std::shared_ptr<asio::streambuf> m_received_message;
};

/// Class that implements an asynchronous TCP client to interact with Service class
class AsyncTCPClient: public asio::noncopyable {

  void onSentReady(std::shared_ptr<Session> session) {
  
    msg_wait_thread.reset(new std::thread([this, session] {
      asio::async_read_until(session->m_sock, *(session->m_received_message.get()), "\n", 
        [this, session] () {
          message_wait_loop(session);
        });
      }));
    msg_wait_thread->detach();

    msg_thread.reset(new std::thread([this, session] {
      message_send_loop(session);
      }));

    msg_thread->detach();
  } 


  void message_send_loop(std::shared_ptr<Session> session) {
    /// Starts loop in the current chat enabling the client to keep sending messages to another party
    logger->info("'{}' in message_send_loop", session->login);

    clear_console();
    m_console.write(session->current_chat);
    m_console.write("Write your message: ");

    std::string new_message;

    // We use a do/while loop to prevent empty messages either because of the client input
    // or \n's that were not read before

    do {
      new_message = m_console.read();
    } while (new_message.empty());
    

    std::unique_lock<std::mutex> lock_std_out(std_out_guard);
    session->current_chat.append(_form_message_str(session->login, new_message));
    lock_std_out.unlock();

    asio::async_write(session->m_sock, asio::buffer(new_message + "\n"), 
      [this, session] () {
        message_send_loop(session);
      }); 
  } 

  void message_wait_loop(std::shared_ptr<Session> session) {
    /// Starts loop in the current chat enabling the client to keep reading messages from another party

    logger->info("'{}' in message_wait_loop", session->login);

    std::istream istrm(session->m_received_message.get());
    std::string received_message;
    std::getline(istrm, received_message);

    session->m_received_message.reset(new asio::streambuf);

    std::unique_lock<std::mutex> lock_std_out(std_out_wait_guard);
    session->current_chat.append(received_message + "\n");
    lock_std_out.unlock();

    clear_console();
    m_console.write(session->current_chat);
    m_console.write("Write your message: ");
    
    asio::async_read_until(session->m_sock, *(session->m_received_message.get()), "\n", 
      [this, session] (std::size_t) {
        message_wait_loop(session);
      });
  }

private:
  asio::io_context m_ios;
};

So, when I described the issue I do not have "'{}' in message_wait_loop" logs for both clients at the point 3). But I have these logs at the point 2) for the Bob's client.

Also I use Console from answer here. It removes echo and controls standard input/output resources by means of mutexes. However it does not solve my problem.

Any help would be appreciated.

1 Answers

There's too much code and too little. Too much for the question, and too little to actually suggest improvements. I see an overuse of shared_ptr, threads, In particular it is very weird to run async-operations on their own threads. Let alone detached:

msg_wait_thread.reset(new std::thread([this, session] {
      asio::async_read_until(session->m_sock, *(session->m_received_message.get()), "\n", 
        [this, session] () {
          message_wait_loop(session);
        });
      }));
    msg_wait_thread->detach();

That whole thing is best replaced by the completely equivalent (but safer)

  asio::async_read_until(session->m_sock, *(session->m_received_message.get()), "\n", 
    [this, session] () {
      message_wait_loop(session);
    });

I imagine the read loop is on a thread so that the input wouldn't block. However, it becomes much easier if you consider the main thread the "UI thread" (it is), and accept that console IO is blocking there, instead posting the resulting requests to a single IO thread for all the non-blocking operations.

If you share a link to a repo or something I'm happy to look at it more.

UPDATE

In the comments I reviewed the code from the github repo and posted a PR: https://github.com/cepessh/mymsg/pull/1

This is a very raw proof-of-concept. I have included many changes that aren't actually related to the suggested concurrency fix, but they happened:

  • to allow me to run
  • during review (you will probably want to look at a number of those changes and keep them anyways)
  • fixes that were apparently missing from main branch (e.g. the default value for Message.read_by_recipient database column)

You should be able to work out what changes were made and why by the commit messages.

Only the last two commits actually focus on the idea discussed in chat.

Related