Forum: PC-Programmierung [c] Linux: Sendepuffer für Serveranwendung


Announcement: there is an English version of this forum on EmbDev.net. Posts you create there will be displayed on Mikrocontroller.net and EmbDev.net.
von Daniel A. (daniel-a)


Lesenswert?

Ich schreibe gerade einen Server. Clients Verbinden sich mit diesem 
lokal per Unix Sockets, oder übers Netzwerk per TCP.

Es geht mir nun erstmal um die Lokalen Verbindungen, um das Senden. Die 
Sockets sind non-Blocking, und nutzen SOCK_SEQPACKET (also ein read pro 
write). Mit jedem write sende ich ein oder mehrere Nachrichten, die 
haben immer zuerst ein Typ und Längenfeld. Der Empfangspuffer ist 4KiB 
gross, die Nachrichten müssen also in 4KiB Chunks passen.

Ich habe vor, mehrere Nachrichten auf einmal zu senden. Es gibt einen 
Main Loop mit epoll. Die FDs darin warten entweder auf POLLIN oder 
POLLOUT. Solange es zu sendende Daten gibt, werden keine neuen 
empfangen, damit sich da möglichst wenig anstaut.


Es gibt eine Funktion, um eine Nachricht zu senden. Nun gibt es ein paar 
Variationen, wann ich den Speicher reservieren, und wann ich die Daten 
effektiv Senden könnte. Was schon mal klar ist, wenn es schon zu 
sendende Daten gibt, muss ich die Liste mit den zu sendenden Daten 
erweitern. In den anderen Fällen könnte ich folgende Dinge tun:

A) Erstmal versuchen zu Senden, falls nötig kopieren

Ich könnte erstmal ein write() versuchen. Falls ich EWOULDBLOCK kriege, 
kann ich die Daten kopieren zur Queue hinzufügen. Dann würde ich sie 
senden, sobald ich POLLOUT von epoll erhalte.

Vorteil dieser Variante: Nachrichten sind oft klein, und können auf dem 
Stack liegen. Solange das write() klappt, muss ich nichts Kopieren, und 
brauche kein malloc.

B) Daten gleich beim caller mit malloc reservieren, und erstmal 
versuchen zu senden

Ich könnte es so machen, dass die Funktion zum Senden der Nachrichten 
sich um das freigeben der Daten kümmert, diese also nicht kopiert. Ich 
könnte immer noch erstmal ein write() versuchen, und falls nötig die 
Daten zur Queue hinzufügen, aber müsste nichts kopieren.

Vorteil dieser Variante: Es muss nichts kopiert werden, aber es brauch 
immer ein malloc / free.

C) Daten kopieren, später Senden

Hier würde ich die Daten zuerst mal in die Queue kopieren, die würden 
dann später gesendet.

Vorteil dieser Variante: Eventuell könnte ich mehrere Nachrichten in den 
selben Buffer packen, dann hätte ich weniger Allokationen. Oft würde 
aber mehr Speicher Verbraucht, als bei A und B. Ausserdem braucht es 
nicht für jede einzelne Nachricht einen write() call.

D) Daten gleich beim caller mit malloc reservieren, später Senden

Hier würde ich die Daten einfach direkt zur Queue hinzufügen. Eine 
simple linked list sollte da genügen.

Vorteil dieser Variante: Die Funktion kann nicht fehlschlagen. Ausserdem 
braucht es nicht für jede einzelne Nachricht einen write() call. Braucht 
aber mehr Speicher, als A und B.


Welche der Varianten ist am Besten?

Bei C und D gibt es noch etwas, das ich tun könnte. Am Ende des main 
loops, nach dem Empfangen von Daten, könnte ich, falls es zu sendende 
Daten in der Queue gibt, versuchen die mit writev zu senden. So könnte 
ich mehrere Nachrichten auf einmal senden, ohne vorher die epoll events 
anzupassen, und noch einen Zyklus zu warten (das müsste ich nur, falls 
das Senden fehlschlägt). Wäre das Sinnvoll?

von Hmmm (hmmm)


Lesenswert?

Daniel A. schrieb:
> Welche der Varianten ist am Besten?

Geschmackssache, ich nehme normalerweise A, dann entsteht der Overhead 
erst dann, wenn wirklich mal viel am Stück gesendet wird.

Denk daran, dass es bei TCP passieren kann, dass ein Teil des 
übergebenen Pakets gesendet wird und nur der Rest in Deine Queue wandern 
muss.

von Peter (pittyj)


Lesenswert?

So wie ich das verstanden habe, sind die Buffergrößen egal bei TCP. Das 
wird alles intern erledigt.
Ich schicke gerade Bilder, und da habe ich einen write() mit mehreren 
Megabytes. Das geht oft mit einem Aufruf. Die 4KB sind also Kinderkram.
Und wenn der write() nicht alles sendet, in einer Schleife den write() 
für den Rest gleich hinterher schicken.
Funktioniert bei mir prima. 1G Ethernet wird komplett ausgenutzt. Komme 
auf 120MBytes/sek.

Und warum kann man nicht einfach die verschiedenen Möglichkeiten mal 
ausprobieren? So viel Code ist das gar nicht.

von Daniel A. (daniel-a)


Lesenswert?

Peter schrieb:
> So wie ich das verstanden habe, sind die Buffergrößen egal bei TCP. Das
> wird alles intern erledigt.
> Ich schicke gerade Bilder, und da habe ich einen write() mit mehreren
> Megabytes. Das geht oft mit einem Aufruf. Die 4KB sind also Kinderkram.
> Und wenn der write() nicht alles sendet, in einer Schleife den write()
> für den Rest gleich hinterher schicken.

Ja, bei den TCP Verbindungen von aussen muss ich das so machen. Bei den 
lokalen Unix Sockets kann ich mir das aber sparen, da kann ich 
SOCK_SEQPACKET statt SOCK_STREAM nehmen. Dort muss ich mich dann auch 
nicht um halb gesendete / empfangene Pakete usw. kümmern, und muss 
nichts zwischenspeichern.
Ich behandle da diverse Dinge unterschiedlich, jenachdem, woher die 
Verbindung kommt.

Peter schrieb:
> Und warum kann man nicht einfach die verschiedenen Möglichkeiten mal
> ausprobieren? So viel Code ist das gar nicht.

Ich komme eventuell am Abend zuhause mal ein paar Minuten dazu, daran 
weiterzumachen. Aber Zeit darüber nachzudenken hab ich viel.

von Hannes J. (Firma: _⌨_) (pnuebergang)


Lesenswert?

Nimm den einfachsten, robustesten und damit am besten wartbaren Code, 
der die von dir benötigte Performance bringt. Schön nach Lehrbuch 
implementiert. Erst mal keine Sonderlocken.

Welche Performance? Hast du uns nicht gesagt. Daher nur als Anmerkung:

Wenn du sowieso Code schreibst um Messages zu queuen würde ich mir das 
zusätzliche "probieren wir erst mal so" write() sparen. Ja, das macht es 
eventuell ein bisschen schneller. Aber malloc()/realloc()/free() sind 
jetzt nicht die wahnsinnig großen Performance-Katastrophen wenn man es 
nicht übertreibt. Nur eine Stelle zu haben an der gesendet wird macht 
die Fehlerbehandlung einfacher und Debuggen sowieso, weil das Ganze 
etwas deterministischer wird. Wie gesagt, robuster, wartbarer Code vor 
"premature optimization".

Ebenso würde ich gathered writev() nur dann als Optimierung erwägen (und 
dann messen) wenn die einfache Implementierung zu langsam ist. Dann 
direkt in die Queue-Verwaltung eingewebt. Also direkt ein iovec[] als 
die Basis für die Queue nehmen und nicht separat von einer Queue immer 
ein iovec[] für writev() aufbauen wenn gesendet werden soll.

iovec[] entweder statisch oder mit realloc() verwaltet. Ich würde mit 
einem statischen Array beginnen, 16 Elemente. Das ist die Minimalgröße 
die eine Implementierung immer unterstützen muss. Wenn das nicht reicht, 
IOV_MAX ist auf modernen Implementierungen ziemlich groß und daher würde 
ich die Konstante nicht blind als Array-Größe nehmen. Also was zwischen 
16 ≤ iovcnt ⋘ IOV_MAX.

: Bearbeitet durch User
von Rolf M. (rmagnus)


Lesenswert?

Peter schrieb:
> So wie ich das verstanden habe, sind die Buffergrößen egal bei TCP. Das
> wird alles intern erledigt.

Naja, was wesentlich ist: Wenn du Daten blockweise sendest, müssen sie 
nicht in den gleichen Blöcken auf der anderen Seite ankommen. Die Daten 
von mehreren send()-Aufrufen können zusammengefasst oder ein einzelner 
send()-Aufruf in mehrere Blöcke aufgeteilt werden (siehe auch 
Nagle-Algorithmus). Zu einem gewissen Grad kann man das beeinflussen mit 
der Socket-Option TCP_NODELAY. Man sollte aber trotzdem nicht einfach 
von der Annahme ausgehen, dass die Daten in genau den Blöcken auf der 
Empfangsseite ankommen, in denen sie losgeschickt wurden. TCP ist halt 
Stream-orientiert und nicht Datagram-orientiert. Für letzteres ist UDP 
eigentlich gemacht, aber da fehlen natürlich ein paar andere Features 
gegenüber TCP.

von Daniel A. (daniel-a)


Lesenswert?

Ja, klar, bei TCP ist das so. Aber um es noch einmal zu wiederholen, ich 
habe, neben TCP Verbindungen, auch lokale per Unix Socket, mit 
SOCK_SEQPACKET, und da ist das anders:

https://www.man7.org/linux/man-pages/man2/socket.2.html
>       SOCK_SEQPACKET
>              Provides a sequenced, reliable, two-way connection-based
>              data transmission path for datagrams of fixed maximum
>              length; a consumer is required to read an entire packet
>              with each input system call.

Das ist praktisch, weil ich dann keine halben Pakete zwischenspeichern 
muss. Kann ich aber leider nur lokal verwenden.
Es gäbe für übers Netzwerk zwar noch SCTP, aber aber da kann ich nicht 
davon ausgehen, dass die Netzwerkgeräte dazwischen das unterstützen. Und 
UDP ist nicht zuverlässig. Darum muss ich dort TCP nehmen, und das etwas 
anders handhaben, als meine lokalen Verbindungen.

von Peter (pittyj)


Lesenswert?

Rolf M. schrieb:
> Peter schrieb:
>> So wie ich das verstanden habe, sind die Buffergrößen egal bei TCP. Das
>> wird alles intern erledigt.
>
> Naja, was wesentlich ist: Wenn du Daten blockweise sendest, müssen sie
> nicht in den gleichen Blöcken auf der anderen Seite ankommen. Die Daten
> von mehreren send()-Aufrufen können zusammengefasst oder ein einzelner
> send()-Aufruf in mehrere Blöcke aufgeteilt werden (siehe auch
> Nagle-Algorithmus).

Ja, das ist mit schon klar, dass ich auf der Empfangsseite den Stream 
wieder in einzelne Datenelement aufspalten muss. Dazu gibt es ein 
entsprechendes 'Protokoll' in meinen Daten mit Längen und Prüfsummen.

Alleine schon wegen unterschiedlicher Ethernet Pakete (wir verwenden bei 
>1GBit/sec gerne Jumbo Frames), kann die Anzahl der write() und read() 
Aufrufe schon differieren.

von Rolf M. (rmagnus)


Lesenswert?

Daniel A. schrieb:
> Ja, klar, bei TCP ist das so. Aber um es noch einmal zu wiederholen, ich
> habe, neben TCP Verbindungen, auch lokale per Unix Socket, mit
> SOCK_SEQPACKET, und da ist das anders:

Das hab ich schon verstanden, aber wenn du beides unterstützen willst, 
musst du deinen Code ja auch so schreiben, dass er mit beidem klar 
kommt.

> Und UDP ist nicht zuverlässig.

So würde ich es nicht sagen. Bei TCP wird halt automatisch ein 
Retransmit ausgelöst, wenn die Daten nicht angekommen sind, und die neue 
Daten werden ggf. zwischengepuffert und zurückgehalten, bis alles davor 
angekommen ist. Bei UDP muss man das selbst machen, wenn man es braucht. 
Je nach Anwendungsfall will man das nicht immer.

Peter schrieb:
> Ja, das ist mit schon klar, dass ich auf der Empfangsseite den Stream
> wieder in einzelne Datenelement aufspalten muss. Dazu gibt es ein
> entsprechendes 'Protokoll' in meinen Daten mit Längen und Prüfsummen.

Prüfsummen braucht's bei TCP eigentlich nicht, weil das eh schon selber 
welche bildet.

von Daniel A. (daniel-a)


Lesenswert?

Rolf M. schrieb:
> Daniel A. schrieb:
>> Ja, klar, bei TCP ist das so. Aber um es noch einmal zu wiederholen, ich
>> habe, neben TCP Verbindungen, auch lokale per Unix Socket, mit
>> SOCK_SEQPACKET, und da ist das anders:
>
> Das hab ich schon verstanden, aber wenn du beides unterstützen willst,
> musst du deinen Code ja auch so schreiben, dass er mit beidem klar
> kommt.

Ja. Ich werde da unterschiedliche Handler nutzen. Ich habe da quasi:
1
struct epoll_event {
2
  uint32_t events;
3
  void* data.ptr; //  --->  fd_common_t*
4
};
5
6
struct fd_common_t {
7
  void(*onevent)(uint32_t events, struct fd_common_t* ptr);
8
  int fd;
9
};
10
11
struct client_t {
12
  struct fd_common_t super;
13
  struct send_queue send_queue;
14
  ...
15
};
16
17
struct tcp_client_t {
18
  struct client_t super;
19
  void* message_fragment_buffer;
20
  ...
21
};
22
23
struct unix_client_t {
24
  client_t super;
25
  ...
26
};

Jeder tcp client kriegt eine tcp_client_t Instanz, jeder unix socket 
client eine unix_client_t Instanz. Dann hab ich noch fd_common_t 
Instanzen für die listening Sockets, eine für TCP, eine für den unix 
socket. Da ist dann also immer eine fd_common_t Instanz, und jeder Typ 
von fd kriegt seinen eigenen Handler. Einer setzt TCP Nachrichten 
zusammen, der andere kann sich das sparen, und dann gibt es die, die 
einfach nur ein accept() aufrufen, und neue Clients registrieren.
Sobald ich eine Message dann vollständig empfangen / zusammengesetzt 
habe, wird die dann aber an die selbe Funktion übergeben.

Tatsächlich wird der Aufbau noch etwas komplexer. Bei den Unix Sockets 
frag ich noch mit ab, welcher User sich verbunden hat (SO_PEERCRED). Der 
Socket wird möglicherweise mit anderen Anwendungen geteilt. Und dann 
sende ich noch ein unix socket pair zum Server, wo ich auch nochmal den 
User abfrage. Nur über den werden tatsächlich Daten ausgetauscht. Die 
Anwendungen können dann auch mit anderen Usern und / oder in Containern 
laufen.

Bei den TCP Sockets wird das ganze etwas komplexer. Da habe ich vor mit 
Kerberos für die Authentifizierung zu arbeiten, und die Verbindung noch 
mit dem zwischen den Clients so ausgetauschten Keys zu verschlüsseln. 
Aber das kommt später.

Ein Paar Mechanismen zum Streamen von Bild, Audio und Videoressourcen 
muss ich auch noch einbauen. Da werde ich dann vermutlich jeweils eine 
weitere Verbindung dafür aufmachen. Der Client sagt dem Server dann, 
welche Ressourcen er hat, und der Server sagt dem Client, welche davon 
er gerade braucht / priorisiert werden sollen. Das muss ich bei Lokal 
vs. Remote dann auch wieder anders handhaben. Lokal kann ich einfach die 
Dateien per FD sharen, oder auch auch einen Puffer zum rein zeichnen. 
Remote hingegen werde ich Multiplexing der Ressourcen, Ratenbegrenzte 
Transcodierung von Videos, und Progressives Laden von Bildern umsetzen 
müssen.

Bitte melde dich an um einen Beitrag zu schreiben. Anmeldung ist kostenlos und dauert nur eine Minute.
Bestehender Account
Schon ein Account bei Google/GoogleMail? Keine Anmeldung erforderlich!
Mit Google-Account einloggen
Noch kein Account? Hier anmelden.