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 verfügbar für anderen Anfragen 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
int main(int argc, char *argv[]) {
	QCoreApplication a(argc, argv);

	MultiThreadedServer server;
	if (!server.startServer())
		return EXIT_FAILURE;

	return a.exec();
}

Implementierung des Servers

Implementiert die Schnittstellenfunktion QTcpServer::incomingConnection(), welche beim Eingang neuer Verbindungen aufgerufen wird. Der Slot onThreadFinished() dient nur der (optionalen) Buchhaltung, sodass der Server einen Überblick über die Anzahl aktuell laufender Verbindungen behält. Das ist sinnvoll, wenn man im Falle eines Denial-of-Service-Angriffs vermeiden will, 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
21
22
23
24
25
/*! The server implementation. */
class MultiThreadedServer : public QTcpServer {
	Q_OBJECT
public:
	explicit MultiThreadedServer(QObject *parent = nullptr);
	bool startServer();

	/*! 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;

private slots:

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

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

Beim Server starten ist eigentlich nur der Port wichtig, und vielleicht noch eine Begrenzung, auf welcher IP der Server lauschen soll. QHostAddress::Any bezeichnet hier jede Netzwerkschnittstelle/Adresse.

Die Funktion listen() liefert üblicherweise dann false zurück, wenn der Port bereits belegt ist (z.B. wenn der Server bereits läuft).

1
2
3
4
5
6
7
8
9
10
11
12
13
bool MultiThreadedServer::startServer() {
	int port = 1234;

	// listen on any IP address of our host system
	if (!this->listen(QHostAddress::Any, port)) {
		qDebug() << "Could not start server";
		return false;
	}
	else {
		qDebug() << "Listening to port " << port << "...";
		return true;
	}
}

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, 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
19
void MultiThreadedServer::incomingConnection(qintptr socketDescriptor){
	// 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 a thread is not needed, it will be beleted later 
	// in function onThreadFinished()
	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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void MultiThreadedServer::onThreadFinished() {
	SocketThread* st = qobject_cast<SocketThread*>(sender());
	delete st; // delete memory
	m_socketThreads.remove(st); // bookkeeping
	qDebug() << "Thread " << (void*)st 
		<< " finished (total number of connections " 
		<< m_socketThreads.size() << ")";
	// NOTE: in the call above, we cannot dump st direct to qDebug(), 
	//       as qDebug() would recognice a QObject pointer and
	//       call member functions to retrieve meta data. As the object 
	//       has been deleted, already, we cannot do that any longer.
	//       Or, we could use st->deleteLater() and let the event loop 
	//       delete the object.
}

Es gibt hier zwei Möglichkeiten: wir können wie im Beispielquelltext das nicht länger benutzte Objekt direkt löschen. Das geht hier, da das sendende Objekt in einem anderen Thread lebt und die Signal-Slot-Verbindung damit zwangsläufig über asynchrone Event-Loop QueuedConnection läuft (siehe auch Diskussion dazu unten). Damit wird die Funktion von der Eventloop des Master-Threads aufgerufen, und das SocketThread ist komplett unbenutzt und nicht länger im CallStack und kann daher gefahrlos gelöscht werden.

Üblich in der Qt-Welt ist jedoch die Verwendung von

1
st->deleteLater();

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 DirectConnection, also wenn das sendende Objekt im selben Thread lebt und somit die Funktion selbst aufruft. Dann kann man das aufrufende Objekt natürlich nicht direkt löschen, sondern muss das später tun.

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
21
22
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;

signals:
	void error(QTcpSocket::SocketError socketerror);

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

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

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
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)) {
		// something's wrong, we just emit a signal
		emit error(m_socket->error());
		return; // stops the event loop
	}

	// connect signals, mind use of Qt::DirectConnection
	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 «sec: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
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;

	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;

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

Wenn man das nun testet (siehe «sec:tests») 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
8
// 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.