summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--opentracker.c37
-rw-r--r--ot_event.c18
-rw-r--r--ot_socket.c26
-rw-r--r--ot_socket.h14
-rw-r--r--ot_udp.c7
-rw-r--r--ot_udp.h3
-rw-r--r--tests/integration/harness.py6
-rw-r--r--tests/udp_load.c202
-rw-r--r--tests/udp_modes.c240
-rw-r--r--tests/udp_modes.py139
-rw-r--r--tests/udp_scaling.py159
-rw-r--r--tests/udp_wake_probe.c99
12 files changed, 932 insertions, 18 deletions
diff --git a/opentracker.c b/opentracker.c
index 34b8fd5..f579b92 100644
--- a/opentracker.c
+++ b/opentracker.c
@@ -84,7 +84,7 @@ static void install_signal_handlers(void) {
84 84
85 sa.sa_handler = signal_handler; 85 sa.sa_handler = signal_handler;
86 sigemptyset(&sa.sa_mask); 86 sigemptyset(&sa.sa_mask);
87 sa.sa_flags = SA_RESTART; 87 sa.sa_flags = 0;
88 if ((sigaction(SIGINT, &sa, NULL) == -1) || (sigaction(SIGALRM, &sa, NULL) == -1)) 88 if ((sigaction(SIGINT, &sa, NULL) == -1) || (sigaction(SIGALRM, &sa, NULL) == -1))
89 panic("install_signal_handlers"); 89 panic("install_signal_handlers");
90 90
@@ -320,8 +320,17 @@ static void *server_mainloop(void *args) {
320} 320}
321 321
322 322
323/* A group of sockets sharing one address only spreads datagrams if every
324 member asked for it before binding, the first one included, so whether
325 this entry will have workers has to be known here. */
326static socket_sharing listen_sharing_for(const ot_listen *entry) {
327 if (entry->proto == FLAG_UDP && entry->workers > 1 && socket_can_balance())
328 return SOCKET_BALANCED;
329 return entry->shared ? SOCKET_SHARED : SOCKET_EXCLUSIVE;
330}
331
323static void listen_bind_one(ot_listen *entry) { 332static void listen_bind_one(ot_listen *entry) {
324 socket_sharing sharing = entry->shared ? SOCKET_SHARED : SOCKET_EXCLUSIVE; 333 socket_sharing sharing = listen_sharing_for(entry);
325 334
326#ifdef _DEBUG 335#ifdef _DEBUG
327 { 336 {
@@ -370,6 +379,10 @@ static void listen_bind_all(void) {
370 listen_bind_one(entries + i); 379 listen_bind_one(entries + i);
371} 380}
372 381
382/* The socket the event loop was using becomes the first worker's. Where the
383 kernel spreads datagrams, every further worker binds one of its own, so
384 none of them share a receive queue.
385 All others read from the first */
373static void listen_start_udp_workers(void) { 386static void listen_start_udp_workers(void) {
374 size_t count, i; 387 size_t count, i;
375 ot_listen *entries = config_listen_entries(&count); 388 ot_listen *entries = config_listen_entries(&count);
@@ -377,14 +390,30 @@ static void listen_start_udp_workers(void) {
377 udp_init(); 390 udp_init();
378 391
379 for (i = 0; i < count; ++i) { 392 for (i = 0; i < count; ++i) {
380 ot_listen *entry = entries + i; 393 ot_listen *entry = entries + i;
394 int balance = listen_sharing_for(entry) == SOCKET_BALANCED;
395 int worker;
381 396
382 if (entry->proto != FLAG_UDP || entry->workers <= 0) 397 if (entry->proto != FLAG_UDP || entry->workers <= 0)
383 continue; 398 continue;
384 399
385 socket_set_blocking((int)entry->sock, 1); 400 socket_set_blocking((int)entry->sock, 1);
386 event_del((int)entry->sock); 401 event_del((int)entry->sock);
387 udp_workers_install(entry->sock, (unsigned int)entry->workers); 402 udp_worker_install(entry->sock);
403
404 for (worker = 1; worker < entry->workers; ++worker) {
405 int64_t sock = entry->sock;
406
407 if (balance) {
408 sock = socket_udp_bind(entry->ip, entry->port, SOCKET_BALANCED);
409 if (sock == -1) {
410 fprintf(stderr, "Warning: could not bind another socket for udp worker %d: %s. Sharing the first instead.\n", worker, strerror(errno));
411 sock = entry->sock;
412 } else
413 socket_set_blocking((int)sock, 1);
414 }
415 udp_worker_install(sock);
416 }
388 } 417 }
389} 418}
390 419
diff --git a/ot_event.c b/ot_event.c
index ce0feef..1542bf5 100644
--- a/ot_event.c
+++ b/ot_event.c
@@ -83,8 +83,10 @@ int event_wait(ot_event *out, int max, int timeout_ms) {
83 g_ready_space = max; 83 g_ready_space = max;
84 } 84 }
85 85
86 while ((count = epoll_wait(g_epoll, g_ready, max, timeout_ms)) == -1 && errno == EINTR) 86 /* An interrupted wait is reported as no events rather than retried here,
87 ; 87 so that the caller gets to look at whatever the signal was about */
88 if ((count = epoll_wait(g_epoll, g_ready, max, timeout_ms)) == -1)
89 return errno == EINTR ? 0 : -1;
88 for (i = 0; i < count; ++i) { 90 for (i = 0; i < count; ++i) {
89 uint32_t events = g_ready[i].events; 91 uint32_t events = g_ready[i].events;
90 92
@@ -189,8 +191,10 @@ int event_wait(ot_event *out, int max, int timeout_ms) {
189 deadline = &timeout; 191 deadline = &timeout;
190 } 192 }
191 193
192 while ((count = kevent(g_kqueue, NULL, 0, g_ready, max, deadline)) == -1 && errno == EINTR) 194 /* An interrupted wait is reported as no events rather than retried here,
193 ; 195 so that the caller gets to look at whatever the signal was about */
196 if ((count = kevent(g_kqueue, NULL, 0, g_ready, max, deadline)) == -1)
197 return errno == EINTR ? 0 : -1;
194 for (i = 0; i < count; ++i) { 198 for (i = 0; i < count; ++i) {
195 uint64_t udata = (uint64_t)(uintptr_t)g_ready[i].udata; 199 uint64_t udata = (uint64_t)(uintptr_t)g_ready[i].udata;
196 int eof = (g_ready[i].flags & (EV_EOF | EV_ERROR)) != 0; 200 int eof = (g_ready[i].flags & (EV_EOF | EV_ERROR)) != 0;
@@ -310,8 +314,10 @@ int event_del(int fd) {
310int event_wait(ot_event *out, int max, int timeout_ms) { 314int event_wait(ot_event *out, int max, int timeout_ms) {
311 int ready, found = 0, i; 315 int ready, found = 0, i;
312 316
313 while ((ready = poll(g_pfds, (nfds_t)g_count, timeout_ms)) == -1 && errno == EINTR) 317 /* An interrupted wait is reported as no events rather than retried here,
314 ; 318 so that the caller gets to look at whatever the signal was about */
319 if ((ready = poll(g_pfds, (nfds_t)g_count, timeout_ms)) == -1)
320 return errno == EINTR ? 0 : -1;
315 if (ready <= 0) 321 if (ready <= 0)
316 return ready; 322 return ready;
317 323
diff --git a/ot_socket.c b/ot_socket.c
index 576087f..158459f 100644
--- a/ot_socket.c
+++ b/ot_socket.c
@@ -75,13 +75,37 @@ static int socket_v6(int type) {
75 return s; 75 return s;
76} 76}
77 77
78/* SO_REUSEPORT means three different things. Linux spreads datagrams over
79 the group. FreeBSD permits the bind and spreads nothing, which is what
80 SO_REUSEPORT_LB was added for. Darwin hands every datagram to whichever
81 socket bound last, so a group there starves all but one reader. */
82#if defined(SO_REUSEPORT_LB)
83#define OT_BALANCE_OPTION SO_REUSEPORT_LB
84#elif defined(SO_REUSEPORT) && !defined(__APPLE__)
85#define OT_BALANCE_OPTION SO_REUSEPORT
86#endif
87
88int socket_can_balance(void) {
89#ifdef OT_BALANCE_OPTION
90 return 1;
91#else
92 return 0;
93#endif
94}
95
78/* The only place in the tree that needs to know what a kernel calls this */ 96/* The only place in the tree that needs to know what a kernel calls this */
79static void socket_set_sharing(int s, socket_sharing sharing) { 97static void socket_set_sharing(int s, socket_sharing sharing) {
80 int one = 1; 98 int one = 1;
81 99
82 setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); 100 setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
101#ifdef OT_BALANCE_OPTION
102 if (sharing == SOCKET_BALANCED) {
103 setsockopt(s, SOL_SOCKET, OT_BALANCE_OPTION, &one, sizeof(one));
104 return;
105 }
106#endif
83#ifdef SO_REUSEPORT 107#ifdef SO_REUSEPORT
84 if (sharing == SOCKET_SHARED) 108 if (sharing != SOCKET_EXCLUSIVE)
85 setsockopt(s, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); 109 setsockopt(s, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
86#endif 110#endif
87} 111}
diff --git a/ot_socket.h b/ot_socket.h
index aa4e2d2..f73a5c9 100644
--- a/ot_socket.h
+++ b/ot_socket.h
@@ -16,8 +16,18 @@
16 as the v4 mapped address */ 16 as the v4 mapped address */
17 17
18/* How an address may be shared with other sockets. Which socket option says 18/* How an address may be shared with other sockets. Which socket option says
19 so differs between kernels, which is socket_set_sharing's business */ 19 so differs between kernels, which is socket_set_sharing's business.
20typedef enum { SOCKET_EXCLUSIVE, SOCKET_SHARED } socket_sharing; 20
21 SHARED lets another socket bind the same address. BALANCED asks the kernel
22 to spread arriving datagrams over the sockets that did, which is a
23 different thing and not everywhere available: ask socket_can_balance
24 before binding a group that depends on it. */
25typedef enum { SOCKET_EXCLUSIVE, SOCKET_SHARED, SOCKET_BALANCED } socket_sharing;
26
27/* Whether this kernel spreads datagrams over a group of sockets bound to one
28 address. Where it does not, binding a group is worse than one socket with
29 several readers, so the caller wants to know first. */
30int socket_can_balance(void);
21 31
22/* Create, bind and, for TCP, listen. The returned socket is non blocking 32/* Create, bind and, for TCP, listen. The returned socket is non blocking
23 Returns -1 on failure with errno set */ 33 Returns -1 on failure with errno set */
diff --git a/ot_udp.c b/ot_udp.c
index 14210c3..87741da 100644
--- a/ot_udp.c
+++ b/ot_udp.c
@@ -235,13 +235,12 @@ void udp_init(void) {
235 udp_generate_rijndael_round_key(); 235 udp_generate_rijndael_round_key();
236} 236}
237 237
238void udp_workers_install(int64_t sock, unsigned int worker_count) { 238void udp_worker_install(int64_t sock) {
239 pthread_t thread_id; 239 pthread_t thread_id;
240 240
241#ifdef _DEBUG 241#ifdef _DEBUG
242 fprintf(stderr, "Installing %d workers on udp socket %ld\n", worker_count, (unsigned long)sock); 242 fprintf(stderr, "Installing a worker on udp socket %ld\n", (unsigned long)sock);
243#endif 243#endif
244 while (worker_count--) 244 pthread_create(&thread_id, NULL, udp_worker, (void *)sock);
245 pthread_create(&thread_id, NULL, udp_worker, (void *)sock);
246} 245}
247 246
diff --git a/ot_udp.h b/ot_udp.h
index a8ab9fc..7d4432f 100644
--- a/ot_udp.h
+++ b/ot_udp.h
@@ -11,7 +11,8 @@
11#include "trackerlogic.h" 11#include "trackerlogic.h"
12 12
13void udp_init(void); 13void udp_init(void);
14void udp_workers_install(int64_t sock, unsigned int worker_count); 14/* One thread reading one socket. Called once per worker. */
15void udp_worker_install(int64_t sock);
15int handle_udp6(int64_t serversocket, struct ot_workstruct *ws); 16int handle_udp6(int64_t serversocket, struct ot_workstruct *ws);
16 17
17#endif 18#endif
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
index 03a0561..122c36d 100644
--- a/tests/integration/harness.py
+++ b/tests/integration/harness.py
@@ -121,6 +121,7 @@ class Tracker:
121 all: LeakSanitizer reports from an exit handler, so a killed tracker 121 all: LeakSanitizer reports from an exit handler, so a killed tracker
122 reports nothing and the suite passes over leaks it never looked for. 122 reports nothing and the suite passes over leaks it never looked for.
123 """ 123 """
124 refused_to_exit = False
124 if self.process is not None and self.process.poll() is None: 125 if self.process is not None and self.process.poll() is None:
125 self.process.send_signal(signal.SIGINT) 126 self.process.send_signal(signal.SIGINT)
126 try: 127 try:
@@ -128,11 +129,16 @@ class Tracker:
128 except subprocess.TimeoutExpired: 129 except subprocess.TimeoutExpired:
129 self.process.kill() 130 self.process.kill()
130 self.process.wait() 131 self.process.wait()
132 refused_to_exit = True
131 complaints = [ 133 complaints = [
132 line 134 line
133 for line in self.output().splitlines() 135 for line in self.output().splitlines()
134 if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR") 136 if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR")
135 ] 137 ]
138 if refused_to_exit:
139 # Killing it throws away whatever the sanitizer had to say on the
140 # way out, so a test that ends this way has checked nothing.
141 complaints.append("the tracker did not exit within 5 s of SIGINT and had to be killed")
136 self._cleanup() 142 self._cleanup()
137 return complaints 143 return complaints
138 144
diff --git a/tests/udp_load.c b/tests/udp_load.c
new file mode 100644
index 0000000..d86c157
--- /dev/null
+++ b/tests/udp_load.c
@@ -0,0 +1,202 @@
1/* A UDP load generator speaking the tracker's own protocol.
2
3 Each thread keeps a socket of its own, does the BEP 15 connect handshake
4 once and then announces in a loop, counting replies. Separate sockets
5 matter: the kernel spreads datagrams across a SO_REUSEPORT group by a hash
6 of the four tuple, so a generator sending everything from one source port
7 would pile every packet onto one of the tracker's workers and measure the
8 wrong thing.
9
10 cc -O2 -pthread -o udp_load tests/udp_load.c
11 ./udp_load 127.0.0.1 6969 4 5 10000 64
12
13 The last argument is how many announces each thread keeps in flight. With
14 one, a thread sends and waits, so the offered load is threads divided by
15 the round trip and the figure says more about scheduling than about the
16 tracker: measured here, a tracker answering 60k/s that way was using
17 three quarters of one core. Raising the window offers load without
18 needing a thread per outstanding request, which is what lets the tracker
19 be pushed to the point where it is actually the limit.
20
21 The last argument is how many distinct torrents to announce. It is what
22 keeps the figure honest: a fresh info hash every time measures how fast
23 the tracker can insert a million torrents into sorted vectors, which is
24 not what a tracker spends its life doing, and which punishes whichever
25 configuration is quickest because it reaches the expensive end of the run
26 soonest. Cycling a fixed set reaches a steady state where almost every
27 announce renews a peer that is already there. */
28
29#include <arpa/inet.h>
30#include <netinet/in.h>
31#include <pthread.h>
32#include <stdatomic.h>
33#include <stdint.h>
34#include <stdio.h>
35#include <stdlib.h>
36#include <string.h>
37#include <sys/socket.h>
38#include <sys/time.h>
39#include <time.h>
40#include <unistd.h>
41
42#define CONNECT_MAGIC 0x41727101980ULL
43
44static struct sockaddr_in g_tracker;
45static long g_torrents = 10000;
46static long g_window = 64;
47static _Atomic long long g_sent = 0;
48static _Atomic int g_running = 1;
49static _Atomic long long g_replies = 0;
50static _Atomic long long g_timeouts = 0;
51
52static double seconds(void) {
53 struct timespec now;
54 clock_gettime(CLOCK_MONOTONIC, &now);
55 return now.tv_sec + now.tv_nsec / 1e9;
56}
57
58static void put64(unsigned char *at, uint64_t value) {
59 int i;
60 for (i = 0; i < 8; ++i)
61 at[i] = (unsigned char)(value >> (56 - 8 * i));
62}
63static void put32(unsigned char *at, uint32_t value) {
64 at[0] = (unsigned char)(value >> 24);
65 at[1] = (unsigned char)(value >> 16);
66 at[2] = (unsigned char)(value >> 8);
67 at[3] = (unsigned char)value;
68}
69static uint64_t get64(const unsigned char *at) {
70 uint64_t value = 0;
71 int i;
72 for (i = 0; i < 8; ++i)
73 value = (value << 8) | at[i];
74 return value;
75}
76
77static void *loader(void *args) {
78 long index = (long)args;
79 unsigned char request[98], reply[1500];
80 uint64_t connection_id = 0;
81 int sock = socket(AF_INET, SOCK_DGRAM, 0);
82 struct timeval patience = {0, 50000}; /* 50 ms */
83 unsigned seed = (unsigned)index * 2654435761u + 1;
84
85 if (sock < 0)
86 return NULL;
87 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &patience, sizeof(patience));
88
89 /* connect handshake */
90 memset(request, 0, sizeof(request));
91 put64(request, CONNECT_MAGIC);
92 put32(request + 8, 0);
93 put32(request + 12, (uint32_t)index);
94 if (sendto(sock, request, 16, 0, (struct sockaddr *)&g_tracker, sizeof(g_tracker)) != 16) {
95 close(sock);
96 return NULL;
97 }
98 if (recvfrom(sock, reply, sizeof(reply), 0, NULL, NULL) < 16) {
99 fprintf(stderr, " thread %ld: no answer to connect\n", index);
100 close(sock);
101 return NULL;
102 }
103 connection_id = get64(reply + 8);
104
105 /* Keep g_window announces outstanding: send that many, then drain what has
106 come back, rather than waiting for each reply in turn */
107 while (atomic_load_explicit(&g_running, memory_order_relaxed)) {
108 ssize_t got;
109 int i;
110 long outstanding;
111
112 for (outstanding = 0; outstanding < g_window; ++outstanding) {
113 memset(request, 0, sizeof(request));
114 put64(request, connection_id);
115 put32(request + 8, 1); /* announce */
116 put32(request + 12, (uint32_t)index);
117 /* One of a fixed set, so that after the first pass almost every announce
118 renews a peer rather than creating a torrent. The index is hashed into
119 all twenty bytes so the buckets, which key off the first four, are
120 spread rather than walked in order. */
121 {
122 unsigned which = (unsigned)((seed = seed * 1103515245u + 12345u) >> 8) % (unsigned)g_torrents;
123 unsigned spread;
124 for (i = 0; i < 20; ++i) {
125 spread = which * 2654435761u + (unsigned)i * 40503u;
126 spread ^= spread >> 13;
127 request[16 + i] = (unsigned char)(spread >> 11);
128 }
129 }
130 for (i = 0; i < 20; ++i)
131 request[36 + i] = (unsigned char)('a' + (i % 26));
132 put64(request + 56, 0); /* downloaded */
133 put64(request + 64, 0); /* left: a seeder */
134 put64(request + 72, 0); /* uploaded */
135 put32(request + 80, 0); /* event */
136 put32(request + 84, 0); /* ip */
137 put32(request + 88, (uint32_t)index); /* key */
138 put32(request + 92, (uint32_t)-1); /* num_want */
139 /* A port of this thread's own, so each thread is a distinct peer and the
140 swarms hold more than one */
141 request[96] = (unsigned char)((6881 + index) >> 8);
142 request[97] = (unsigned char)((6881 + index) & 0xff);
143
144 if (sendto(sock, request, 98, 0, (struct sockaddr *)&g_tracker, sizeof(g_tracker)) != 98)
145 break;
146 atomic_fetch_add_explicit(&g_sent, 1, memory_order_relaxed);
147 }
148
149 for (outstanding = 0; outstanding < g_window; ++outstanding) {
150 got = recvfrom(sock, reply, sizeof(reply), 0, NULL, NULL);
151 if (got >= 20)
152 atomic_fetch_add_explicit(&g_replies, 1, memory_order_relaxed);
153 else {
154 atomic_fetch_add_explicit(&g_timeouts, 1, memory_order_relaxed);
155 break; /* nothing more is coming; go round and send again */
156 }
157 }
158 }
159
160 close(sock);
161 return NULL;
162}
163
164int main(int argc, char **argv) {
165 const char *host = argc > 1 ? argv[1] : "127.0.0.1";
166 int port = argc > 2 ? atoi(argv[2]) : 6969;
167 long threads = argc > 3 ? atol(argv[3]) : 16;
168 double run_for = argc > 4 ? atof(argv[4]) : 5.0;
169 long torrents = argc > 5 ? atol(argv[5]) : 10000;
170 long window = argc > 6 ? atol(argv[6]) : 64;
171 pthread_t *ids = calloc((size_t)threads, sizeof(pthread_t));
172 long i;
173 double started, elapsed;
174
175 g_torrents = torrents > 0 ? torrents : 1;
176 g_window = window > 0 ? window : 1;
177
178 memset(&g_tracker, 0, sizeof(g_tracker));
179 g_tracker.sin_family = AF_INET;
180 g_tracker.sin_port = htons((uint16_t)port);
181 if (inet_pton(AF_INET, host, &g_tracker.sin_addr) != 1) {
182 fprintf(stderr, "cannot parse address %s\n", host);
183 return 1;
184 }
185
186 for (i = 0; i < threads; ++i)
187 pthread_create(ids + i, NULL, loader, (void *)i);
188
189 started = seconds();
190 usleep((useconds_t)(run_for * 1e6));
191 atomic_store(&g_running, 0);
192 elapsed = seconds() - started;
193
194 for (i = 0; i < threads; ++i)
195 pthread_join(ids[i], NULL);
196
197 printf("%.0f replies/s (%.0f sent/s, %lld replies, %lld lost, %ld threads x %ld in flight, %.1f s)\n",
198 (double)atomic_load(&g_replies) / elapsed, (double)atomic_load(&g_sent) / elapsed,
199 (long long)atomic_load(&g_replies), (long long)atomic_load(&g_timeouts), threads, g_window, elapsed);
200 free(ids);
201 return 0;
202}
diff --git a/tests/udp_modes.c b/tests/udp_modes.c
new file mode 100644
index 0000000..654cfd8
--- /dev/null
+++ b/tests/udp_modes.c
@@ -0,0 +1,240 @@
1/* A receiver with the udp arrangements we are choosing between, so they can
2 be compared without any of them being built into the tracker first.
3
4 It echoes what it is sent, so tests/udp_load.c drives it unchanged. There
5 is no tracker work here on purpose: with nothing else happening, whatever
6 the kernel's receive path costs is the whole figure, which is the clearest
7 view of it. Pass a work time to dilute that back towards realism.
8
9 cc -O2 -pthread -o udp_modes tests/udp_modes.c
10 ./udp_modes 6969 4 shared 1
11 ./udp_modes 6969 4 shared 32
12 ./udp_modes 6969 4 per-thread 1
13 ./udp_modes 6969 4 per-thread 32
14
15 Arguments: port, threads, shared|per-thread, datagrams per syscall,
16 and optionally nanoseconds of pretend work per datagram. */
17
18#define _GNU_SOURCE /* struct mmsghdr on glibc */
19
20#include <errno.h>
21#include <netinet/in.h>
22#include <pthread.h>
23#include <signal.h>
24#include <stdatomic.h>
25#include <stdio.h>
26#include <stdlib.h>
27#include <string.h>
28#include <sys/socket.h>
29#include <time.h>
30#include <unistd.h>
31
32#if defined(__linux__) || defined(__FreeBSD__)
33#define HAVE_MMSG 1
34#endif
35
36#define MAX_BATCH 256
37#define BUFFER_SIZE 2048
38
39static int g_port;
40static long g_threads;
41static long g_batch;
42static long g_work_ns;
43static int g_shared_socket = -1; /* -1 means one socket per thread */
44static _Atomic int g_running = 1;
45
46static void stop(int sig) {
47 (void)sig;
48 atomic_store(&g_running, 0);
49}
50
51/* Which option the platform offers for spreading datagrams over a group of
52 sockets bound to one address. They are not the same thing: FreeBSD's plain
53 SO_REUSEPORT permits the bind without spreading anything, and Darwin's
54 hands every datagram to whichever socket bound last. */
55static const char *share_the_port(int sock) {
56 int one = 1;
57
58 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
59#ifdef SO_REUSEPORT_LB
60 if (!setsockopt(sock, SOL_SOCKET, SO_REUSEPORT_LB, &one, sizeof(one)))
61 return "SO_REUSEPORT_LB";
62#endif
63#ifdef SO_REUSEPORT
64 if (!setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)))
65 return "SO_REUSEPORT";
66#endif
67 return "neither";
68}
69
70static int bind_one(int share, const char **option) {
71 struct sockaddr_in address;
72 int sock = socket(AF_INET, SOCK_DGRAM, 0);
73
74 if (sock < 0)
75 return -1;
76 *option = share ? share_the_port(sock) : "exclusive";
77
78 memset(&address, 0, sizeof(address));
79 address.sin_family = AF_INET;
80 address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
81 address.sin_port = htons((uint16_t)g_port);
82 if (bind(sock, (struct sockaddr *)&address, sizeof(address)) < 0) {
83 close(sock);
84 return -1;
85 }
86 return sock;
87}
88
89static void pretend_to_work(void) {
90 struct timespec started, now;
91 long waited = 0;
92
93 if (!g_work_ns)
94 return;
95 clock_gettime(CLOCK_MONOTONIC, &started);
96 while (waited < g_work_ns) {
97 clock_gettime(CLOCK_MONOTONIC, &now);
98 waited = (now.tv_sec - started.tv_sec) * 1000000000L + (now.tv_nsec - started.tv_nsec);
99 }
100}
101
102static void one_at_a_time(int sock) {
103 char buffer[BUFFER_SIZE];
104 struct sockaddr_in from;
105 socklen_t length;
106 ssize_t got;
107
108 while (atomic_load_explicit(&g_running, memory_order_relaxed)) {
109 length = sizeof(from);
110 got = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr *)&from, &length);
111 if (got < 0) {
112 if (errno == EINTR || errno == EAGAIN)
113 continue;
114 return;
115 }
116 pretend_to_work();
117 sendto(sock, buffer, (size_t)got, 0, (struct sockaddr *)&from, length);
118 }
119}
120
121#ifdef HAVE_MMSG
122static void in_batches(int sock) {
123 static _Thread_local char buffers[MAX_BATCH][BUFFER_SIZE];
124 static _Thread_local struct sockaddr_in froms[MAX_BATCH];
125 static _Thread_local struct iovec iovecs[MAX_BATCH];
126 static _Thread_local struct mmsghdr messages[MAX_BATCH];
127 int got, i;
128
129 while (atomic_load_explicit(&g_running, memory_order_relaxed)) {
130 for (i = 0; i < g_batch; ++i) {
131 iovecs[i].iov_base = buffers[i];
132 iovecs[i].iov_len = BUFFER_SIZE;
133 memset(messages + i, 0, sizeof(messages[i]));
134 messages[i].msg_hdr.msg_name = froms + i;
135 messages[i].msg_hdr.msg_namelen = sizeof(froms[i]);
136 messages[i].msg_hdr.msg_iov = iovecs + i;
137 messages[i].msg_hdr.msg_iovlen = 1;
138 }
139
140 /* WAITFORONE so a quiet receiver behaves like the unbatched one rather
141 than holding the first datagram until the batch fills */
142 got = recvmmsg(sock, messages, (unsigned)g_batch, MSG_WAITFORONE, NULL);
143 if (got < 0) {
144 if (errno == EINTR || errno == EAGAIN)
145 continue;
146 return;
147 }
148
149 for (i = 0; i < got; ++i) {
150 pretend_to_work();
151 iovecs[i].iov_len = messages[i].msg_len;
152 messages[i].msg_hdr.msg_namelen = sizeof(froms[i]);
153 }
154 sendmmsg(sock, messages, (unsigned)got, 0);
155 }
156}
157#endif
158
159static void *worker(void *args) {
160 const char *option = "";
161 int sock = g_shared_socket;
162
163 (void)args;
164 if (sock < 0) {
165 sock = bind_one(1, &option);
166 if (sock < 0) {
167 fprintf(stderr, " a worker could not bind: %s\n", strerror(errno));
168 return NULL;
169 }
170 }
171
172#ifdef HAVE_MMSG
173 if (g_batch > 1)
174 in_batches(sock);
175 else
176#endif
177 one_at_a_time(sock);
178
179 if (g_shared_socket < 0)
180 close(sock);
181 return NULL;
182}
183
184int main(int argc, char **argv) {
185 const char *how = argc > 3 ? argv[3] : "shared";
186 const char *option = "";
187 pthread_t *threads;
188 long i;
189
190 g_port = argc > 1 ? atoi(argv[1]) : 6969;
191 g_threads = argc > 2 ? atol(argv[2]) : 1;
192 g_batch = argc > 4 ? atol(argv[4]) : 1;
193 g_work_ns = argc > 5 ? atol(argv[5]) : 0;
194 if (g_batch < 1)
195 g_batch = 1;
196 if (g_batch > MAX_BATCH)
197 g_batch = MAX_BATCH;
198
199#ifndef HAVE_MMSG
200 if (g_batch > 1) {
201 fprintf(stderr, " no recvmmsg on this platform; batching unavailable\n");
202 return 2;
203 }
204#endif
205
206 signal(SIGINT, stop);
207 signal(SIGTERM, stop);
208
209 if (!strcmp(how, "shared")) {
210 g_shared_socket = bind_one(0, &option);
211 if (g_shared_socket < 0) {
212 fprintf(stderr, " bind failed: %s\n", strerror(errno));
213 return 1;
214 }
215 } else {
216 /* Probe once so the mode line can say what the platform gave us */
217 int probe = bind_one(1, &option);
218 if (probe < 0) {
219 fprintf(stderr, " bind failed: %s\n", strerror(errno));
220 return 1;
221 }
222 close(probe);
223 }
224
225 printf(" port %d, %ld threads, %s socket%s (%s), %ld per syscall, %ld ns work\n",
226 g_port, g_threads, strcmp(how, "shared") ? "one per thread" : "one shared",
227 strcmp(how, "shared") ? "s" : "", option, g_batch, g_work_ns);
228 fflush(stdout);
229
230 threads = calloc((size_t)g_threads, sizeof(pthread_t));
231 for (i = 0; i < g_threads; ++i)
232 pthread_create(threads + i, NULL, worker, NULL);
233 for (i = 0; i < g_threads; ++i)
234 pthread_join(threads[i], NULL);
235
236 if (g_shared_socket >= 0)
237 close(g_shared_socket);
238 free(threads);
239 return 0;
240}
diff --git a/tests/udp_modes.py b/tests/udp_modes.py
new file mode 100644
index 0000000..19cd264
--- /dev/null
+++ b/tests/udp_modes.py
@@ -0,0 +1,139 @@
1"""The four udp arrangements, side by side.
2
3 cc -O2 -pthread -o /tmp/udp_modes tests/udp_modes.c
4 cc -O2 -pthread -o /tmp/udp_load tests/udp_load.c
5 python3 tests/udp_modes.py
6
7Two questions at once. Down the table: does a shared socket stop scaling as
8threads are added? Across it: does taking several datagrams per syscall make
9that better, or only cheaper?
10
11The receiver does no tracker work by default, so the figure is the kernel's
12receive path and nothing else. WORK_NS=1500 dilutes it back towards what the
13tracker actually costs per announce.
14
15The number reported is cpu microseconds the receiver spent per datagram
16answered, not throughput. Throughput here says more about the load generator
17than the receiver, because both run on the same machine and an echo costs so
18little that the generator runs out of cores first. Cpu per datagram does not
19care: it is what the receive path costs, whoever is the bottleneck.
20"""
21import os
22import signal
23import socket
24import subprocess
25import sys
26import time
27
28MODES = os.environ.get("MODES", "shared per-thread").split()
29BATCHES = [int(n) for n in os.environ.get("BATCHES", "1 8 32").split()]
30THREAD_COUNTS = [int(n) for n in os.environ.get("THREADS", "1 2 4 8").split()]
31LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4"))
32WINDOW = int(os.environ.get("WINDOW", "32"))
33SECONDS = float(os.environ.get("SECONDS", "3"))
34REPEATS = int(os.environ.get("REPEATS", "2"))
35WORK_NS = int(os.environ.get("WORK_NS", "0"))
36RECEIVER = os.environ.get("UDP_MODES", "/tmp/udp_modes")
37LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load")
38PORT_BASE = int(os.environ.get("PORT_BASE", "20100"))
39
40
41def cpu_seconds(pid):
42 try:
43 out = subprocess.run(["ps", "-o", "time=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()
44 except OSError:
45 return None
46 if not out:
47 return None
48 total = 0.0
49 for part in out.replace("-", ":").split(":"):
50 total = total * 60 + float(part)
51 return total
52
53
54def one_run(port, threads, mode, batch):
55 receiver = subprocess.Popen(
56 [RECEIVER, str(port), str(threads), mode, str(batch), str(WORK_NS)],
57 stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
58 )
59 for _ in range(200):
60 if receiver.poll() is not None:
61 return None, None
62 probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
63 probe.settimeout(0.1)
64 try:
65 probe.sendto(b"\x00" * 16, ("127.0.0.1", port))
66 probe.recv(64)
67 probe.close()
68 break
69 except OSError:
70 probe.close()
71 time.sleep(0.02)
72 else:
73 receiver.send_signal(signal.SIGINT)
74 receiver.wait()
75 return None, None
76
77 try:
78 subprocess.run([LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), "1", "10000", str(WINDOW)],
79 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
80 spent, wall = cpu_seconds(receiver.pid), time.time()
81 out = subprocess.run(
82 [LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), str(SECONDS), "10000", str(WINDOW)],
83 capture_output=True, text=True,
84 ).stdout.strip()
85 used = cpu_seconds(receiver.pid)
86 finally:
87 receiver.send_signal(signal.SIGINT)
88 try:
89 receiver.wait(timeout=5)
90 except subprocess.TimeoutExpired:
91 receiver.kill()
92 receiver.wait()
93
94 head = out.split()
95 if not head or not head[0].replace(".", "").isdigit():
96 return None, None
97 cores = (used - spent) / max(time.time() - wall, 1e-9) if (used is not None and spent is not None) else None
98 return float(head[0]), cores
99
100
101def main():
102 for binary in (RECEIVER, LOADER):
103 if not os.path.exists(binary):
104 raise SystemExit("missing %s; see the comment at the top of tests/udp_modes.c" % binary)
105
106 print(" %d load threads x %d in flight, %.0f s x %d runs, %d ns work per datagram"
107 % (LOAD_THREADS, WINDOW, SECONDS, REPEATS, WORK_NS))
108 print(" cpu microseconds per datagram, and the throughput it was measured at")
109 header = " %-14s %-8s" % ("sockets", "threads")
110 for batch in BATCHES:
111 header += "%20s" % ("%d per syscall" % batch)
112 print(header)
113
114 port = PORT_BASE
115 for mode in MODES:
116 for threads in THREAD_COUNTS:
117 row = " %-14s %-8d" % (mode, threads)
118 for batch in BATCHES:
119 results = []
120 for _ in range(REPEATS):
121 port += 1
122 rate, cores = one_run(port, threads, mode, batch)
123 if rate:
124 results.append((rate, cores))
125 if not results:
126 row += "%18s" % "-"
127 continue
128 results.sort()
129 rate, cores = results[len(results) // 2]
130 if cores:
131 row += "%9.2f us %6.0fk/s" % (cores * 1e6 / rate, rate / 1000.0)
132 else:
133 row += "%20s" % ("%.0fk/s" % (rate / 1000.0))
134 print(row)
135 sys.stdout.flush()
136
137
138if __name__ == "__main__":
139 main()
diff --git a/tests/udp_scaling.py b/tests/udp_scaling.py
new file mode 100644
index 0000000..7691fcb
--- /dev/null
+++ b/tests/udp_scaling.py
@@ -0,0 +1,159 @@
1"""Does listen.udp.workers help or hurt, on this platform, with this tracker?
2
3Starts a tracker once per worker count and drives it with tests/udp_load.c,
4which speaks the real protocol, so what comes out is the tracker's own
5throughput rather than an echo server's.
6
7 cc -O2 -pthread -o /tmp/udp_load tests/udp_load.c
8 make opentracker
9 python3 tests/udp_scaling.py
10
11Worker count 0 means no dedicated workers: udp is served from the main event
12loop, which is what a default build does.
13
14LOAD_THREADS and WINDOW decide how much load is offered. Both matter. Too
15little and the tracker idles while the figure measures round trips; too much
16and the kernel drops datagrams, every lost reply costs the generator a
17timeout, and the figure collapses.
18
19Three columns say which side the number came from. "cores" is the tracker's
20cpu, "load" the generator's. Whichever is near its machine's core count is
21the one being measured. "lost" counts datagrams that went unanswered: once
22it leaves zero the throughput figure is timeouts, not service, and the run
23needs a smaller WINDOW.
24"""
25import os
26import resource
27import signal
28import socket
29import subprocess
30import sys
31import tempfile
32import time
33
34TRACKER = os.environ.get("OPENTRACKER", "./opentracker")
35LOADER = os.environ.get("UDP_LOAD", "/tmp/udp_load")
36WORKER_COUNTS = [int(n) for n in os.environ.get("WORKERS", "0 1 2 4 8").split()]
37LOAD_THREADS = int(os.environ.get("LOAD_THREADS", "4"))
38SECONDS = float(os.environ.get("SECONDS", "5"))
39TORRENTS = int(os.environ.get("TORRENTS", "10000"))
40WINDOW = int(os.environ.get("WINDOW", "32"))
41REPEATS = int(os.environ.get("REPEATS", "3"))
42PORT_BASE = int(os.environ.get("PORT_BASE", "19960"))
43
44
45def start(port, workers):
46 directory = tempfile.mkdtemp(prefix="ot-udpscale-")
47 config = "listen.tcp 127.0.0.1:%d\nlisten.udp 127.0.0.1:%d\n" % (port, port)
48 if workers:
49 config += "listen.udp.workers %d\n" % workers
50 with open(os.path.join(directory, "conf"), "w") as handle:
51 handle.write(config)
52 log = open(os.path.join(directory, "log"), "w+b")
53 tracker = subprocess.Popen([TRACKER, "-f", os.path.join(directory, "conf")], stdout=log, stderr=log)
54 for _ in range(300):
55 if tracker.poll() is not None:
56 log.seek(0)
57 raise SystemExit("tracker exited at once on port %d with %d workers:\n%s"
58 % (port, workers, log.read().decode("utf-8", "replace")))
59 try:
60 socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
61 return tracker, log
62 except OSError:
63 time.sleep(0.02)
64 tracker.kill()
65 raise SystemExit("tracker never listened on %d" % port)
66
67
68def cpu_seconds(pid):
69 """Cumulative cpu time of a process, the one spelling ps has everywhere."""
70 try:
71 out = subprocess.run(["ps", "-o", "time=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()
72 except OSError:
73 return None
74 if not out:
75 return None
76 total, parts = 0.0, out.replace("-", ":").split(":")
77 for part in parts:
78 total = total * 60 + float(part)
79 return total
80
81
82def reaped_cpu_seconds():
83 """Cpu of every child already waited for. The generator is the only one
84 that starts and finishes inside a measured run."""
85 spent = resource.getrusage(resource.RUSAGE_CHILDREN)
86 return spent.ru_utime + spent.ru_stime
87
88
89def stop(tracker):
90 tracker.send_signal(signal.SIGINT)
91 try:
92 tracker.wait(timeout=10)
93 except subprocess.TimeoutExpired:
94 tracker.kill()
95 tracker.wait()
96
97
98def parse(line):
99 """replies/s and the count of announces that were never answered"""
100 words = line.split()
101 if not words or not words[0].replace(".", "").isdigit():
102 return None, None
103 lost = 0
104 if "lost," in words:
105 lost = int(words[words.index("lost,") - 1])
106 return float(words[0]), lost
107
108
109def main():
110 if not os.path.exists(LOADER):
111 raise SystemExit("build the generator first: cc -O2 -pthread -o %s tests/udp_load.c" % LOADER)
112
113 print(" tracker %s, %d load threads x %d in flight, %d torrents, %.0f s x %d runs"
114 % (TRACKER, LOAD_THREADS, WINDOW, TORRENTS, SECONDS, REPEATS))
115 print(" %-8s %12s %7s %7s %7s %10s %s"
116 % ("workers", "median/s", "spread", "cores", "load", "lost", "vs 0 workers"))
117 baseline = None
118 for index, workers in enumerate(WORKER_COUNTS):
119 results = []
120 for repeat in range(REPEATS):
121 port = PORT_BASE + index * 16 + repeat
122 tracker, log = start(port, workers)
123 try:
124 # Warm up until the torrent set exists and the peers are in
125 # place, so the measured run is renewals rather than insertions
126 subprocess.run([LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), "2", str(TORRENTS), str(WINDOW)],
127 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
128 spent, offered, wall = cpu_seconds(tracker.pid), reaped_cpu_seconds(), time.time()
129 result = subprocess.run(
130 [LOADER, "127.0.0.1", str(port), str(LOAD_THREADS), str(SECONDS), str(TORRENTS), str(WINDOW)],
131 capture_output=True, text=True,
132 )
133 used, drove, elapsed = cpu_seconds(tracker.pid), reaped_cpu_seconds(), max(time.time() - wall, 1e-9)
134 finally:
135 stop(tracker)
136 log.close()
137 rate, lost = parse(result.stdout.strip())
138 if rate is None:
139 continue
140 cores = (used - spent) / elapsed if (used is not None and spent is not None) else None
141 results.append((rate, cores, (drove - offered) / elapsed, lost))
142
143 if not results:
144 print(" %-8s no result" % workers)
145 continue
146 results.sort()
147 rate, cores, load, lost = results[len(results) // 2]
148 if baseline is None:
149 baseline = rate
150 # A spread wider than the differences between rows means the rows are
151 # not telling you anything
152 spread = 100.0 * (results[-1][0] - results[0][0]) / rate
153 print(" %-8s %12.0f %6.0f%% %7s %7.2f %10d %.2fx"
154 % (workers, rate, spread, ("%.2f" % cores) if cores else "?", load, lost, rate / baseline))
155 sys.stdout.flush()
156
157
158if __name__ == "__main__":
159 main()
diff --git a/tests/udp_wake_probe.c b/tests/udp_wake_probe.c
new file mode 100644
index 0000000..8a0f7a8
--- /dev/null
+++ b/tests/udp_wake_probe.c
@@ -0,0 +1,99 @@
1/* Three ways to get a thread out of a blocking recvfrom, tested rather than
2 assumed: close the socket, shut it down, or give it a receive timeout.
3
4 This decides how udp workers can be stopped. They sit in recvfrom and are
5 never joined today, and the sockets of a SO_REUSEPORT group all share one
6 address, so a worker cannot be singled out by sending it a datagram.
7
8 cc -O2 -pthread -o /tmp/wake tests/udp_wake_probe.c && /tmp/wake
9
10 On Linux 6.1: close does not wake it, shutdown does, SO_RCVTIMEO does. */
11#include <errno.h>
12#include <netinet/in.h>
13#include <pthread.h>
14#include <stdatomic.h>
15#include <stdio.h>
16#include <string.h>
17#include <sys/socket.h>
18#include <sys/time.h>
19#include <unistd.h>
20
21static _Atomic int returned;
22static _Atomic int saved_errno;
23static int sock;
24
25static void *reader(void *args) {
26 char buffer[64];
27 (void)args;
28 if (recvfrom(sock, buffer, sizeof(buffer), 0, NULL, NULL) < 0)
29 atomic_store(&saved_errno, errno);
30 atomic_store(&returned, 1);
31 return NULL;
32}
33
34static int bind_udp(int timeout_ms) {
35 struct sockaddr_in address;
36 int s = socket(AF_INET, SOCK_DGRAM, 0);
37
38 memset(&address, 0, sizeof(address));
39 address.sin_family = AF_INET;
40 address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
41 address.sin_port = 0;
42 bind(s, (struct sockaddr *)&address, sizeof(address));
43
44 if (timeout_ms) {
45 struct timeval how_long;
46 how_long.tv_sec = timeout_ms / 1000;
47 how_long.tv_usec = (timeout_ms % 1000) * 1000;
48 setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &how_long, sizeof(how_long));
49 }
50 return s;
51}
52
53static void try_it(const char *name, int timeout_ms, int do_close, int do_shutdown) {
54 pthread_t thread;
55 int waited;
56
57 atomic_store(&returned, 0);
58 atomic_store(&saved_errno, 0);
59 sock = bind_udp(timeout_ms);
60 pthread_create(&thread, NULL, reader, NULL);
61 usleep(300000); /* let it reach recvfrom */
62
63 if (do_shutdown)
64 shutdown(sock, SHUT_RD);
65 if (do_close)
66 close(sock);
67
68 for (waited = 0; waited < 2000 && !atomic_load(&returned); ++waited)
69 usleep(1000);
70
71 if (atomic_load(&returned)) {
72 printf(" %-24s returned after %4d ms, errno %s\n", name, waited,
73 atomic_load(&saved_errno) ? strerror(atomic_load(&saved_errno)) : "none");
74 pthread_join(thread, NULL);
75 } else {
76 printf(" %-24s STILL BLOCKED after 2000 ms\n", name);
77 /* unblock it so the probe can finish */
78 struct sockaddr_in to;
79 socklen_t length = sizeof(to);
80 int poke = socket(AF_INET, SOCK_DGRAM, 0);
81 if (!getsockname(sock, (struct sockaddr *)&to, &length))
82 sendto(poke, "x", 1, 0, (struct sockaddr *)&to, length);
83 close(poke);
84 usleep(200000);
85 if (atomic_load(&returned))
86 pthread_join(thread, NULL);
87 else
88 pthread_detach(thread);
89 }
90 if (!do_close)
91 close(sock);
92}
93
94int main(void) {
95 try_it("close() the socket", 0, 1, 0);
96 try_it("shutdown(SHUT_RD)", 0, 0, 1);
97 try_it("SO_RCVTIMEO 500 ms", 500, 0, 0);
98 return 0;
99}