Handling multiple connections: Looping, forking, `select` (C Networking Part 3)
In part 1, we covered the basics of creating a server: setting up a socket, binding it to an address, listening for connections, accepting them, and receiving data. In part 2, we extended our server’s functionality by sending responses back to clients. Now let’s extend our server to handle a real production environment with multiple incoming connections.
Looping
First of all in our existing code from part 2, you’ll notice that we respond to one client a single time, before shutting down the entire server. To begin fixing this, let’s start out by extracting the client handling logic into a separate function, then putting it all in a loop. That lets us handle unlimited clients in sequence.
void handleClient(int clientfd) {
// Wait to receive data
char receiveBuffer[1024];
ssize_t bytesReceived =
recv(clientfd, receiveBuffer, sizeof(receiveBuffer) - 1, 0);
receiveBuffer[bytesReceived] = '\0';
// Send HTTP response
const char *response =
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
"<html><h1>HTML Response</h1><p>The server works!</p></html>";
send(clientfd, response, strlen(response), 0);
close(clientfd);
}
int main() {
// ...
// Wait for and accept incoming TCP connection
struct sockaddr_in clientAddress;
socklen_t clientAddressSize = sizeof(clientAddress);
while (true) {
int clientfd =
accept(socketfd, (struct sockaddr *)&clientAddress, &clientAddressSize);
handleClient(clientfd);
}
return 0;
}
Now let’s test it out by running the curl command ten times in a row.
cbrown@typhoon ~ % for i in {1..10}
for> do
for> curl --http0.9 localhost;
for> echo;
for> done
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
This is great, the server is now capable of handling many connections without closing right away. However, you may have noticed another problem. Consider the situation where we have hundreds of clients connecting all at once. Right now, our server is only capable of handling connections serially, that is to say one after another. What if we want connections to be handled in parallel?
Forking
The most straightforward way of handling multiple connections in parallel is to fork a new process for each incoming connection. Each client will be assigned its own server process, and therefore address space, which has the added benefit of isolating memory between clients. To do this, can just call fork()
inside of the while loop.
while (true) {
int clientfd =
accept(socketfd, (struct sockaddr *)&clientAddress, &clientAddressSize);
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
close(clientfd);
continue;
} else if (pid == 0) {
printf("|Child process (pid %d)|", getpid());
close(socketfd); // close listening socket in child process
handleClient(clientfd);
exit(0);
} else {
printf("|Parent process (pid %d)|", pid);
close(clientfd); // close client socket in parent process
}
}
As a review, fork()
will return a value of zero in the child process, and the parent’s pid in the parent process. Each child process is tasked with handling the client and then shutting down, so in it we close the listening socket right away. Likewise, the parent’s only job is to spawn child processes to handle requests, so in the parent we close the client file descriptor, once the child is spawned.
To test the changes, let’s run the following.
Client:
cbrown@typhoon ~ % for i in {1..5}
for> do
for> curl --http0.9 localhost;
for> echo;
for> done
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
<html><h1>HTML Response</h1><p>The server works!</p></html>
Server:
cbrown@typhoon ~/Documents/repo/server % ./server
|Child process (pid 10291)||Parent process (pid 10291)||Child process (pid 10293)||Parent process (pid 10291)||Parent process (pid 10293)||Child process (pid 10295)||Parent process (pid 10291)||Parent process (pid 10293)||Parent process (pid 10295)||Child process (pid 10297)||Parent process (pid 10291)||Parent process (pid 10293)||Parent process (pid 10295)||Parent process (pid 10297)||Child process (pid 10299)|
Note that in each case, it is the same exact parent process that is being continuously used to receive connections and spawn children. Note that each client connection receives a different child process to handle it.
As mentioned, forking has the benefit of ensuring parallelism in a sufficiently multicore processor server, while also granting each client its own memory and address space. However, creating an entirely new process for each individual client has a large resource overhead, and is impractical when hundreds or thousands of clients all connect at the same time.
select
efficiently handles multiple connections in one process
Actually, we don’t need true parallelism to speed up the rate we process multiple clients. We can handle clients asynchronously; that is use one single process and have it idle on open connections until one is ready to be read from. We can do that using the select()
call.
Add an include for <sys/select.h>
. Then, underneath listen()
, add the following:
fd_set masterSet, workingSet;
FD_ZERO(&masterSet); // set masterSet as empty
FD_SET(socketfd, &masterSet); // ad socketfd to masterSet
int maxfd = socketfd;
while (true) {
// copy masterSet to workingSet, since select call modifies workingSet
memcpy(&workingSet, &masterSet, sizeof(masterSet));
struct timeval *timeout = NULL;
select(maxfd + 1, &workingSet, NULL, NULL, timeout);
for (int i = 0; i <= maxfd; i++) {
if (FD_ISSET(i, &workingSet)) {
// file descriptor i is ready to be examined
if (i == socketfd) {
// the current file descriptor is the listening socket
// a new connection request is incoming
struct sockaddr_in clientAddress;
socklen_t clientAddressSize = sizeof(clientAddress);
int clientfd = accept(socketfd, (struct sockaddr *)&clientAddress,
&clientAddressSize);
FD_SET(clientfd, &masterSet);
if (clientfd > maxfd) maxfd = clientfd;
} else {
// an existing client fd is ready to recv() data
handleClient(i);
FD_CLR(i, &masterSet);
}
}
}
}
Explanation
fd_set
is a unique type that represents, unsurprisingly, a set of different file descriptors. FD_ZERO
, FD_CLR
, and FD_SET
are the API for working with this type; you can zero out all file descriptors so the set is empty, clear out one specific file descriptor, or set a specific file descriptor as a member of the set.
masterSet
keeps track of all the file descriptors we want to check for read availability. Recall that, originally, our server blocks in two places. First on accept
, until there is an incoming client connect request. Then on recv
until that client actually sends a message. Including both the server listening socket, as well as any generated client sockets in masterSet
allows select()
to monitor both new connection and data receiving events.
We declare both a master and a working set, and the master set gets copied over into the working set before calling select()
. This is because the set that we pass in select
gets modified to only include the set of file descriptors that are actually ready to be read.
Examining the select()
call closer, we see the following
select(maxfd + 1, &workingSet, NULL, NULL, timeout);
We pass in the maximum file descriptor maxfd, plus one. This indicates that all file descriptors from number 0 to number maxfd are in the potential range of file descriptors to monitor. The next entry is where we pass in the set of file descriptors to actually monitor for read readiness; this object is modified with the set of file descriptors actually ready to be read. The next two params is used for whenever you want to monitor a file descriptor for write readiness. The next one is used if you want to monitor a set of file descriptors for error processing. Finally, we pass in the timeout. In this case, we actually just pass in a null pointer, which tells select
to block indefinitely until an fd is ready for reading.
select
: Pros and Cons
Before, when we forked the process, each client received its own individual client process. This allowed for each client to have its own memory space, as well as for there to be true parallelism. However, this allows triggers all the overhead associated with starting a new proces. select
is a more lightweight option; all processing happens inside of one particular process. It just allows that one process to be informed when one client is ready to connect or send data, allowing asynchronous (although not parallel) processing of multiple clients. In almost any real workload forking a new process for every client will be infeasible, so using select
in fact represents a major improvement.
Conclusion
In this part, our server went from handling one single client and immediately closing the connection, to managing the connections of multiple clients. We introduced looping, which allows multiple clients to be handled serially and synchronously. Forking allows multiple clients to be handled in parallel, at the cost of the overhead of a new process. select
acts as a compromise, allowing one process to asynchronously handle connection requests as they come in.
There are more other tools we can use to improve our server, such as multithreading. Coming up next we will expand our overview of C server programming to include multithreading, and other strategies for handling high loads.