Работа с сетью в Qt

QTcpServer: пишем простой многоклиентский сервер на Qt

Введение

QTcpServer — серверный аналог QTcpSocket: он принимает входящие подключения и для каждого клиента создаёт отдельный объект QTcpSocket. В отличие от классических серверов на блокирующих сокетах с потоком на клиента, Qt-сервер по умолчанию однопоточный и обслуживает всех клиентов асинхронно в одном event loop — это снижает накладные расходы и упрощает совместный доступ к данным. В статье разберём, как принимать подключения, рассылать сообщения и отслеживать отключение клиентов.

Концепция

Сервер слушает порт через listen(), а каждое новое подключение сигнализируется через newConnection(). В обработчике этого сигнала нужно вызвать nextPendingConnection(), который возвращает указатель на QTcpSocket для конкретного клиента. Дальше с этим сокетом работают как с обычным QTcpSocket: подписываются на readyRead() и disconnected().

Поскольку все клиенты обслуживаются в одном потоке, нет проблем гонок данных при обращении к общим структурам (например, списку подключённых клиентов) — но любая долгая синхронная операция в обработчике (тяжёлые вычисления, блокирующий ввод-вывод) заблокирует весь сервер для всех клиентов одновременно. Для CPU-интенсивной нагрузки используют QThreadPool или выносят обработку в отдельные потоки с moveToThread.

Пример кода: QTcpServer

#include <QCoreApplication>
#include <QTcpServer>
#include <QTcpSocket>
#include <QDebug>
#include <QList>

class ChatServer : public QObject
{
    Q_OBJECT
public:
    explicit ChatServer(quint16 port, QObject *parent = nullptr) : QObject(parent)
    {
        connect(&m_server, &QTcpServer::newConnection, this, &ChatServer::onNewConnection);
        if (!m_server.listen(QHostAddress::Any, port)) {
            qWarning() << "Не удалось запустить сервер:" << m_server.errorString();
            return;
        }
        qDebug() << "Сервер слушает порт" << port;
    }

private slots:
    void onNewConnection()
    {
        while (m_server.hasPendingConnections()) {
            QTcpSocket *client = m_server.nextPendingConnection();
            m_clients.append(client);
            qDebug() << "Новый клиент:" << client->peerAddress().toString();

            connect(client, &QTcpSocket::readyRead, this, [this, client]() {
                const QByteArray data = client->readAll();
                broadcast(data, client);
            });

            connect(client, &QTcpSocket::disconnected, this, [this, client]() {
                qDebug() << "Клиент отключился:" << client->peerAddress().toString();
                m_clients.removeOne(client);
                client->deleteLater();
            });
        }
    }

private:
    void broadcast(const QByteArray &data, QTcpSocket *sender)
    {
        for (QTcpSocket *client : m_clients) {
            if (client != sender) {
                client->write(data);
            }
        }
    }

    QTcpServer m_server;
    QList<QTcpSocket *> m_clients;
};

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);
    ChatServer server(12345);
    return app.exec();
}

#include "main.moc"

Пояснения к коду

При каждом сигнале newConnection() сервер может иметь несколько ожидающих подключений в очереди, поэтому корректно обрабатывать их в цикле while (hasPendingConnections()), а не ограничиваться одним вызовом nextPendingConnection(). Для каждого клиента подключаются лямбда-обработчики на readyRead и disconnected, захватывающие указатель на сокет — это удобный способ избежать создания отдельного класса-обёртки для каждого подключения, если логика простая.

Метод broadcast() рассылает полученные данные всем клиентам, кроме отправителя — классическая основа простого чат-сервера.

Подводные камни

  • Удаление сокета через deleteLater(), а не delete. Сокет мог инициировать сигнал, который сейчас обрабатывается — немедленное удаление может привести к use-after-free внутри текущего стека вызовов.
  • QList<QTcpSocket*> m_clients хранит «сырые» указатели. Если забыть удалить указатель из списка при отключении, последующая рассылка попытается писать в уже удалённый объект. Всегда синхронизируйте список с реальным состоянием подключений.
  • Один поток — одна точка отказа. Тяжёлая синхронная обработка в обработчике readyRead одного клиента замораживает обслуживание всех остальных. Для нагруженных серверов рассмотрите QTcpServer::incomingConnection() с раздачей клиентов по пулу потоков.
  • Backlog подключений. По умолчанию listen() использует ограниченную очередь ожидающих подключений; при резком наплыве клиентов часть из них может получить отказ на уровне TCP до того, как сервер успеет их принять.