Post

Sinnige/richtige Implementierung eines Multi-Threaded TCP-Servers mit Qt

Sinnige/richtige Implementierung eines Multi-Threaded TCP-Servers mit Qt

In diesem Artikel wird beschrieben, wie man einen TCP-Server mit Qt so programmiert, dass eingehende Verbindungen in separaten Threads ausgeführt werden und so der Server nicht durch aufwändige Abfragen ausgebremst wird. Erklärt wird vor allem die Besonderheit der Signal-Slot-Verknüpfung zwischen den aufzurufenden Funktionen, sodass die “Arbeits-“Funktion auch tatsächlich im Socketthread ausgeführt wird.

Allgemeines

Qt bietet die Klassen QTcpServer sowie QTcpSocket bzw. QSslSocket für die TCP/IP Kommunikation an. Der Server lauscht auf eingehende Verbindungen und öffnet nach Eingang einer Verbindungsanfrage ein Socket und bearbeitet dann darüber eingehende Anfragen.

Da die Beantwortung solcher Anfragen mitunter etwas Zeit dauern kann, bspw. bei komplexeren Datenbankabfragen oder Downloads größerer Dateien, sollte man hier für jeden Socket einen eigenen Thread starten, damit der Server für anderen Anfragen verfügbar bleibt.

Das Tutorial zeigt, wie man so einen Server minimalistisch aufsetzt, die Arbeiter-/Socketthreads erstellt und die Speicherverwaltung macht.

Ein komplettes Beispiel gibt es als Quelltext-Archiv: MultiThreadedServer-Example.7z

Die wichtigsten Bausteine werden nachfolgend vorgestellt.

Hauptprogramm

Minimalistisch.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <QCoreApplication>
#include "MultiThreadedServer.h"

int main(int argc, char *argv[]) {
	QCoreApplication app(argc, argv);

	MultiThreadedServer server;

	constexpr quint16 port = 1234;
	// listen on any IP address of our host system
	if (!server.listen(QHostAddress::LocalHost, port)) {
		qCritical() << "Failed to start server on port" << port;
		return EXIT_FAILURE;
	}

	qInfo() << "Server listening on port" << port;
	return app.exec();
}

Die Funktion listen() liefert üblicherweise dann false zurück, wenn der Port bereits belegt ist (z.B. wenn der Server bereits läuft). Statt QHostAddress::LocalHost könnte man auch QHostAddress::Any verwenden, wenn der Server auf allen verfügbaren Hostadaptern lauschen soll.

Implementierung des Servers

Implementiert die Schnittstellenfunktion QTcpServer::incomingConnection(), welche beim Eingang neuer Verbindungen aufgerufen wird.

Der Slot onThreadFinished() dient der Buchhaltung, sodass der Speicher freigegeben wird und der Server einen Überblick über die Anzahl aktuell laufender Verbindungen behält. Das Merken der Verbindungsthreads in m_socketThreads ist sinnvoll, wenn man später mal ein geplantes Herunterfahren des Servers und damit Beenden aller Threads implementieren will. Außerdem kann der Server so die Anzahl der gleichzeitigen Verbindungen kontrolliere und z.B. im Falle eines Denial-of-Service-Angriffs vermeiden, dass zu viele Threads letztlich das System lahmlegen oder Speicherknappheit erzeugen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*! The server implementation. */
class MultiThreadedServer : public QTcpServer {
	Q_OBJECT
public:
	explicit MultiThreadedServer(QObject *parent = nullptr);

protected:
	/*! Called, whenever someone attempts to connect to our server. */
	void incomingConnection(qintptr socketDescriptor) override;

private slots:
	/*! Connected to the thread finished signal, notifies server of reduces socket count. */
	void onThreadFinished();

private:
	/*! Holds list of currently active sockets.
		Used to check how many active connections there are and to prevent denial-of-service attacks.
	*/
	QSet<SocketThread*>	m_socketThreads;
};

Beim Eingehen einer neuen Verbindung wird ein Thread für den zu erzeugenden Socket erstellt.

Die Funktion incomingConnection() wird vom Master-Thread aufgerufen (in dem der Server läuft), und damit wird auch der Konstruktor der Klasse SocketThread vom Master-Thread ausgeführt. Daher können die im Konstruktor des Threads erstellten Objekte nicht “geparented” werden. Deshalb wird der Thread via new-operator auf dem Heap erstellt. Der Speicher für das Objekt wird in der Event-Loop des Master-Threads freigegeben im Slot onThreadFinished() der aufgerufen wird, sobald der Thread via Signal finished() sein Ende ankündigt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// This function is called by QTcpServer when a new connection is available.
void MultiThreadedServer::incomingConnection(qintptr socketDescriptor) {
	// here we could check if m_socketThreads exceeds a certain limit of maximum connections and refuse
	// to handle the incoming connection

	// Every new connection will be run in a newly created thread
	SocketThread *thread = new SocketThread(socketDescriptor);
	m_socketThreads.insert(thread);

	// We have a new connection
	qDebug() << "Connecting with socket descriptor..." << socketDescriptor << " (total number of connections " << m_socketThreads.size() << ")";

	// once thread is finished, call function to cleanup afterwards
	connect(thread, &SocketThread::finished, this, &MultiThreadedServer::onThreadFinished);

	// now start the actual communiation thread
	thread->start();
}

Speicherfreigabe

Speicherfreigabe erfolgt dann indirekt im Slot onThreadFinished(), welche auch vom Master-Thread aufgerufen wird.

Das den Slot aufrufende Objekt (SocketThread) wird durch die Verwendung von deleteLater() später gelöscht. Ein Objekt kann man natürlich nicht direkt in einer Funktion löschen, die es selbst aufgerufen hat, sondern muss das später tun (via Event loop). Die Funktion deleteLater() fügt einen Funktionsaufruf in die Eventloop des Master-Threads ein, welche bei Abarbeitung das Objekt löscht. Das funktioniert auch bei Qt::DirectConnection, also wenn das sendende Objekt im selben Thread lebt und somit die Funktion selbst aufruft.

Variante mit deleteLater():

1
2
3
4
5
6
7
8
9
void MultiThreadedServer::onThreadFinished() {
	SocketThread* st = qobject_cast<SocketThread*>(sender());
	m_socketThreads.remove(st); // bookkeeping
	qDebug() << st <<
		"finished, total number of "
		"connections remaining: " << m_socketThreads.size();
	// delete memory later in event loop
	st->deleteLater();
}

Verbindungs-/Socket-Thread

Die Klasse SocketThread implementiert die QThread-Schnittstelle. Der Konstruktor (im Master-Thread ausgeführt) übernimmt lediglich die Socket-ID.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*! Wraps a single socket (connection) in its own thread. */
class SocketThread : public QThread {
	Q_OBJECT
public:
	SocketThread(qintptr socketDescriptor);
	~SocketThread() override; // frees memory

	/*! This function is run from within the thread once started.
		The thread lives as long is this function is being executed.
	*/
	void run() override;

public slots:
	void onReadyRead();
	void onDisconnected();

private:
	QTcpSocket	*m_socket = nullptr;
	qintptr		m_socketDescriptor;
};

Konstructor und Destruktor der Klasse sind trivial:

1
2
3
4
5
6
7
8
9
10
11
SocketThread::SocketThread(qintptr socketDescriptor) :
	m_socketDescriptor(socketDescriptor)
{
}


SocketThread::~SocketThread() {
	qDebug() << "SocketThread::~SocketThread():" << this;
	// free allocated memory
	delete m_socket;
}

Im Thread ist die Funktion run() wie die main()-Funktion eines eigenständigen Hauptprogramms. Bei Verlassen der Funktion wird der Thread beendet und sendet das finished() Signal aus.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void SocketThread::run() {
	// thread starts here and this function is already executed by our thread
	qDebug() << "Client connected with socket descriptor "<< m_socketDescriptor
			 << " on thread " << this;

	m_socket = new QTcpSocket();

	// set the ID
	if (!m_socket->setSocketDescriptor(m_socketDescriptor)) {
		qCritical() << "Couldn't set socket descriptor";
		return;
	}

	// connect signals

	// IMPORTANT:
	//   We use Qt::DirectConnection for readyRead(), so that the function
	//   SocketThread::onReadyRead() is executed by our own thread and we can
	//   do lengthy stuff there, without hampering performance of the main thread.
	//   The effect of this can be tested, when we do a lengthy loop in onReadyRead()
	//   and try to connect via a different terminal to the server. Without
	//   DirectConnection we have to wait until master thread is finished, with
	//   DirectConnection the server remains responsive!
	//   Also, when sending data back to client via m_socket->write(), this call must
	//   be executed from the socket thread.
	connect(m_socket, &QTcpSocket::readyRead, this, &SocketThread::onReadyRead, Qt::DirectConnection);
	connect(m_socket, &QTcpSocket::disconnected, this, &SocketThread::onDisconnected);

	// start event loop
	exec();
}

Als erstes wird ein QTcpSocket-Objekt auf dem Heap erstellt, welches im Destruktor wieder gelöscht wird (siehe Diskussion oben in Speicherfreigabe, wann und wie das SocketThread-Objekt wieder freigegeben wird).

Dann wird das Socket-Objekt mit dem Descriptor initialisiert und die relevanten Signale verknüpft.

WICHTIG

Bei der Verknüpfung der Signale in Multi-Thread-Anwendung ist es wichtig zu verstehen, wie Signal-Slot-Verbindung innerhalb eines Threads und threadübergreifend funktionieren. Innerhalb eines Threads kann der Qt Meta-Compiler bei einem emit statement die verknüpften Slot-Funktionen direkt aufrufen. Dies entspricht dem Verknüpfungstyp Qt::DirectConnection und wird als Standard ausgewählt, wenn im connect()-Aufruf das letzte (optionale) Argument leer ist, oder auf Qt::AutoConnection steht.

Bei Signal-Slot-Verknüpfungen zwischen Threads, d.h. Objekt a in Thread x sendet eine Nachricht an Objekt b in Thread y, wird stattdessen eine Qt::QueuedConnection ausgeführt. Dabei bearbeitet der sendende Thread die Event-Loop des empfangenden Threads und fügt dort einen Funktionsaufruf zum addressierten Slot hinzu. Dieser wird dann vom empfangenden Thread ausgeführt.
Die letzte Variante Qt::BlockingQueuedConnection lässt den sendenden Thread so lange anhalten, bis der Slot im empfangende Thread abgearbeitet wurde und ein entsprechendes Bestätigungssignal in der Event-Loop des sendenden Threads eingetroffen ist. Diese Variante sollte man aber meiden, da man so leicht Deadlocks erzeugen kann.

Man kann beim connect()-Statement den ConnectionType als optionales Argument mit angeben. Wobei Qt::DirectConnection bei threadübergreifenden Signal-Slot-Verküpfungen ignoriert wird und stets durch Qt::QueuedConnection ersetzt wird. Wichtig ist aber zu wissen, dass bei Signal-Slot-Verknüpfungen in QThread Klassen, stets automatisch Qt::QueuedConnection verwendet wird, auch wenn Sender und Empfänger beide im gleichen Thread liegen. Deshalb MUSS man in diesem Fall Qt::DirectConnection angeben.

Wenn man das Signal readyRead() mittels Qt::DirectConnection verknüpft, wird der Slot auch tatsächlich im Workerthread ausgeführt:

Call stack und aktiver Thread bei Verwendung von Qt::DirectConnection:

DirectConnection

Wenn man stattdessen Qt::QueuedConnection verwendet (den Standard, wenn nichts angegeben ist), dann wird die Funktion vom Master-Thread ausgeführt:

Call stack und aktiver Thread bei Verwendung von Qt::QueuedConnection

QueuedConnection

Wenn der Master-Thread die ganze Arbeit des Sockets macht, ist natürlich der ganze Vorteil der Multi-Thread-Socket Variante weg. Man kann das auch schön an einem Beispiel testen, in dem die onReadyRead() “etwas” länger braucht:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// client has send data, we send it back
void SocketThread::onReadyRead() {
	// get the information
	QByteArray data = m_socket->readAll();

	// will write on server side window
	qDebug() << "Data received from socket descriptor "
			 << m_socketDescriptor << ": " << data;

	// uncomment the following code to check responsiveness
	// of server when multiple requests are being handled
//#define DO_LENGTHY_STUFF
#ifdef DO_LENGTHY_STUFF
	double val = 1.01;
	for (unsigned int i=0; i<4000000000; ++i) {
		val = std::pow(1.0, val);
		val = std::sqrt(val);
	}
	qDebug() << "Sending echo" << data << val;
#endif // DO_LENGTHY_STUFF

	m_socket->write("Echo from server: " + data); // write back
}

Wenn man das nun testet (siehe Testen) und zwei Anfragen gleichzeitig stellt, so merkt man ganz klar die zeitliche Verzögerung.

Beim Verbindungsabbruch/Ende (Signal disconnected() ) wird der Slot onDisconnected() aufgerufen, in dem dann die Event-Loop beendet wird.

1
2
3
4
5
6
7
// called when client disconnects, simply exit event loop
void SocketThread::onDisconnected() {
	qDebug() << "Disconnected client with socket descriptor " << m_socketDescriptor;

	// leave event loop -> sends finished() signal
	exit(0);
}

Testen des Servers

Man kann den Server ganz einfach mittels Telnet-Programm testen (gerne auch parallel in mehreren Terminal-Fenstern):

1
2
3
4
5
6
> telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Test Text eingegeben
Echo from server: Test Text eingegeben

Nach erfolgtem Verbindungsaufbau kann man einen beliebigen Text eingeben und an den Server senden. Dieser schickt dann die Antwort (ggfs. etwas zeitverzögert, falls man wie im Beispiel oben “etwas” Extraarbeit macht). Beenden kann man Telnet durch Drücken von Ctrl + ] (bzw, Ctrl + Alt Gr + 9), gefolgt von quit, um Telnet zu schließen.

This post is licensed under CC BY 4.0 by the author.