Lec 27: Server Sockets
Table of Contents
1 Client Socket Programming (Review)
In this lesson, we are going to look at the other side of the socket protocol, the server side. To bootstrap that discussion, lets first review the relevant system calls associated with a client socket. We can visual these functions like below:
To open a new socket, use the socket()
system call, but the socket
being open doesn't mean it is connected to anything. To do that, you
use the connect()
system call that takes as input a given address,
IP-port pair. Once connected with the remote server, the client can
read and write to that socket, participating in the application
layer protocol.
At the other end, at the server, sockets are also used. The interface for using sockets is similar but there are a few additional steps involved.
2 Server Sockets
Server sockets are much like client sockets except instead of connecting they accept incoming connection. The result of accepting an incoming connection generates a new socekt for that client which is used for all further communication. The server socket remains and can accept more incoming connections on the listening port.
Before a connection can be accepted, there is some setup, which we can trace through the server socket life cycle below:
2.1 Binding a Socket: bind()
The first step in establishing a server socket is to bind the
socket to a given IP address and port. This way the O.S. knows what
port the socket is listening on. The bind()
system call has the
following description:
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
The arguments can be interpreted as follows:
socket
: an open socket fromsocket()
address
: a reference to a socket address to be bound, in our case, this will reference asockaddr_in
with an IP address and port in theAF_INET
family.address_len
: the size of the socket address, which will besizeof(struct sockaddr_in)
It returns 0 on success and a negative value on error. We can see how this functions in code with the following example:
char hostname[]="127.0.0.1"; //localhost ip address to bind to short port=1845; //the port we are to bind to struct sockaddr_in saddr_in; //socket interent address of server //set up the address information saddr_in.sin_family = AF_INET; inet_aton(hostname, &saddr_in.sin_addr); saddr_in.sin_port = htons(port); //open a socket if( (server_sock = socket(AF_INET, SOCK_STREAM, 0)) < 0){ perror("socket"); exit(1); } //bind the socket if(bind(server_sock, (struct sockaddr *) &saddr_in, saddr_len) < 0){ perror("bind"); exit(1); }
Note that we are binding to an ip address 127.0.0.1
which is
special IP address referring the local machine. You could also use
the domain name localhost
and perform a look up with
getaddrinfo()
.
2.2 Queuing incoming connections: listen()
Once you've bound the socket to an IP address and port, you must
still indicated to the Operating System that this socket is a
server socket. The system call that does this is called listen()
and has the following function description:
int listen(int socket, int backlog);
The argument socket
is the server socket file descriptor, but the
argument backlog
requires a bit more explanation. As you will see
below, to establish a connection with a client, accept()
must be
called, but this doesn't happen immediately. There is a period of
limbo between when the incoming connection is recognized and
accept()
is called to establish the connection. Further, many
incoming connections can occur at the same time, and the operating
system has limited resources to queue up client connections prior
to accept()
. The backlog
argument indicates to the OS how many
incoming connections should be allowed to queue prior to accept()
before starting to reject connections. A typical value for
backlog
is 5, but higher and lower values is acceptable.
Here is an example of the listen()
in the running example:
//ready to listen, queue up to 5 pending connections if(listen(server_sock, 5) < 0){ perror("listen"); exit(1); }
2.3 Accepting Incoming Connections: accept()
Finally, everything is in place to accept a connection, and to do
that we use the accept()
system call. It has the following
function description:
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
The arguments to accept are as follows:
socket
is the server socket that you have bound and established as a listener.adress
is a reference to socket address structure of the client. Since we are usingAF_INET
sockets, you can pass a reference to astruct sockaddr_in
and cast appropriately.address_len
is a pointer to a size reference for the address. This is necessary because, as we learned already, not all socket addresses are the same size, but since we are usingAF_INET
we know that this willsizeof(struct sockaddr_in)
When a client connection has been accepted properly, the return
value of accept
is a file descriptor for a new socket that we
can use to communicate with the client. The server socket remains
because we might want to use that to accept other incoming
connections.
Here is an example in code:
int client_sock; //accept incoming connections if((client_sock = accept(server_sock, (struct sockaddr *) &client_saddr_in, &saddr_len)) < 0){ perror("accept"); exit(1); } printf("Connection From: %s:%d (%d)\n", inet_ntoa(client_saddr_in.sin_addr), //address as dotted quad ntohs(client_saddr_in.sin_port), //the port in host order client_sock); //the file descriptor number
You'll note that the address of the new socket is different than the address of the server socket. This make sense. You can't have two sockets communicating on the same port, so when a client connects, the OS must establish a new connection on a different port so not to collide with the server port. Further, if you think about it more, once the connection is established, the port doesn't really matter as long as both ends agree on it. The server port address, the address that accepts connections, is the only port that must truly be known and declared. We will see more examples of this later.
Another important thing to note about the server socket is that
accepting an incoming connections is a blocking operation. That
means, accept()
will not return until a new connection is
provided. This fact becomes quite a challenge when developing many
server programs that wish to provide service to multiple clients,
and we will explore different techniques for achieving multi-client
services.
2.4 Communicating with the Client: read()/write()/close()
Once we have the connection established with the client, the new
client socket, from accept()
, is how we communicate. Recall that
a socket is just a file descriptor, so we can use the standard
read()
and write()
operations on the socket to send and receive
data from the child. At this point, these procedures should be
familiar to you:
//read from client if((n = read(client_sock,response, BUF_SIZE-1)) < 0){ perror("read"); exit(1); } response[n] = '\0'; //NULL terminate string printf("Read from client: %s", response); //construct response snprintf(response, BUF_SIZE, "Hello %s:%d \nGo Navy! Beat Army\n", inet_ntoa(client_saddr_in.sin_addr), //address as dotted quad ntohs(client_saddr_in.sin_port)); //the port in host order printf("Sending: %s",response); //send response if(write(client_sock, response, strlen(response)) < 0){ perror("write"); exit(1); } printf("Closing socket\n\n"); //close client socket close(client_sock); //close the server socket close(server_sock);
Once all the operations are over, the act of closing the socket will bring down the connection with client. Closing the server socket stops the listening process.
2.5 Putting it all together
With all the pieces in place, we can connect the client and server socket procedures and see how the two interfaces interact:
And the entirity of the hello server program:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <arpa/inet.h> #include <netdb.h> #define BUF_SIZE 4096 int main(){ char hostname[]="127.0.0.1"; //localhost ip address to bind to short port=1845; //the port we are to bind to struct sockaddr_in saddr_in; //socket interent address of server struct sockaddr_in client_saddr_in; //socket interent address of client socklen_t saddr_len = sizeof(struct sockaddr_in); //length of address int server_sock, client_sock; //socket file descriptor char response[BUF_SIZE]; //what to send to the client int n; //length measure //set up the address information saddr_in.sin_family = AF_INET; inet_aton(hostname, &saddr_in.sin_addr); saddr_in.sin_port = htons(port); //open a socket if( (server_sock = socket(AF_INET, SOCK_STREAM, 0)) < 0){ perror("socket"); exit(1); } //bind the socket if(bind(server_sock, (struct sockaddr *) &saddr_in, saddr_len) < 0){ perror("bind"); exit(1); } //ready to listen, queue up to 5 pending connectinos if(listen(server_sock, 5) < 0){ perror("listen"); exit(1); } saddr_len = sizeof(struct sockaddr_in); //length of address printf("Listening On: %s:%d\n", inet_ntoa(saddr_in.sin_addr), ntohs(saddr_in.sin_port)); //accept incoming connections if((client_sock = accept(server_sock, (struct sockaddr *) &client_saddr_in, &saddr_len)) < 0){ perror("accept"); exit(1); } printf("Connection From: %s:%d (%d)\n", inet_ntoa(client_saddr_in.sin_addr), //address as dotted quad ntohs(client_saddr_in.sin_port), //the port in host order client_sock); //the file descriptor number //read from client if((n = read(client_sock,response, BUF_SIZE-1)) < 0){ perror("read"); exit(1); } response[n] = '\0'; //NULL terminate string printf("Read from client: %s", response); //construct response snprintf(response, BUF_SIZE, "Hello %s:%d \nGo Navy! Beat Army\n", inet_ntoa(client_saddr_in.sin_addr), //address as dotted quad ntohs(client_saddr_in.sin_port)); //the port in host order printf("Sending: %s",response); //send response if(write(client_sock, response, strlen(response)) < 0){ perror("write"); exit(1); } printf("Closing socket\n\n"); //close client_sock close(client_sock); //close the socket close(server_sock); return 0; //success }
And we can see a few runs of the program. In one terminal we have
our small server program running and in the other we have a netcat
client running.
#> ./hello_server Listening On: 127.0.0.1:1845 Connection From: 127.0.0.1:58740 (4) Read from client: hello Sending: Hello 127.0.0.1:58740 Go Navy! Beat Army Closing socket
#> netcat localhost 1845 hello Hello 127.0.0.1:58740 Go Navy! Beat Army
Note that the client socket, after the connection was accepted, is
operating on the port 58740, which is not the same as the port the
server socket was listening on 1845
.
3 Handling Multiple Incoming Connections of Server Sockets
Now that we understand how to setup a server, let's consider how we might be able to handle multiple incoming connections. This is a common task for servers since every client has its own client socket, it is only natural to serve multiple clients at the same time. However, this is more complicated then it might seem at first because of blocking operations. Let's first explore the perils of blocking operations and how to overcome this challenge. In a later lessons, we'll see another method for handling multiple client services that uses threads that is simpler but comes with its own challenge.
3.1 Challenge of blocking
Let's consider an improvement to our server: instead of just responding with a token phrase, it echos back whatever is sent to it until the client closes the connection. Further, we'd like to be able to serve multiple clients. To start, we could simply just change the program logic to look like this:
//accept incoming connections in a loop while((client_sock = accept(server_sock, (struct sockaddr *) &client_saddr_in, &saddr_len)) > 0){ printf("Connection From: %s:%d (%d)\n", inet_ntoa(client_saddr_in.sin_addr), ntohs(client_saddr_in.sin_port), client_sock); //echo loop, break when read 0 or error while((n = read(client_sock, response, BUF_SIZE-1)) > 0){ response[n] = '\0' ; //NULL terminate printf("Received From: %s:%d (%d): %s\n", //LOGGING inet_ntoa(client_saddr_in.sin_addr), ntohs(client_saddr_in.sin_port), client_sock, response); if(write(client_sock, response, n) < 0){ perror("write"); break; } } if( n < 0){ perror("read"); } printf("Client Closed: %s:%d (%d)\n", //LOGGING inet_ntoa(client_saddr_in.sin_addr), ntohs(client_saddr_in.sin_port), client_sock); //close client socket close(client_sock); //reset socket len just in case saddr_len = sizeof(struct sockaddr_in); //length of address }
Essentially, we've place the server accepting incoming connections in a loop. When there is a connection, anything that is written from the client is echoed back. If the client closes the connection, so does the server. We can see that happening with a simple example:
#> ./echo_server >./echo_server serer sock listening: (3) Connection From: 127.0.0.1:59088 (4) Received From: 127.0.0.1:59088 (4): testing client #1 Received From: 127.0.0.1:59088 (4): who are you?
#>netcat localhost 1845 testing client #1 testing client #1 who are you? who are you?
However, if the first client keeps the connection open when the next client connects, what happens?
#>nc localhost 1845 testing why am I not getting an echo?
There is no echo response. That's because the server is blocking
while attempting to read()
from the first client and the connection
has not been accepted()
. The connection is queued, so when the
first client closes the socket, the expected response is provided,
but we'd like all of this to occur simultaneously. In this way, a
server can provide services to multiple clients.
3.2 Identifying Readable File Descriptors with select()
There are a few ways to solve this problem. One is to set all the
socket file descriptors to non-blocking, which is a possibility and
we've seen how to do this with fnctl()
and pipes. But it can
overly complicate the code.
Instead, we need a way to check to see if a file descriptors is
ready to be used (e.g., read, write, or accepted) so that the
operation will not block and return immediately. The simplest
method for testing the readiness of a file descriptor is to use
select()
.
select()
is a system call that given a set of file descriptors,
will allow you to iterate over the file descriptors that are ready
for reading (or writing). The protocol for select has a few
functions, but it's easy to see their use with an example.
fd_set select_set; //stores interested file descriptors FD_ZERO(select_set); //clear the set FD_SET(fd, select_set); //add fd to the set //add other file descriptors //select at most FD_SETSIZE file descriptor from set that are ready for an action select(FD_SETSIZE, &select_set, NULL, NULL, NULL) < 0) //check for activity on all file descriptors for(i=0; i < FD_SETSIZE; i++){ //was the file descriptor i set? if(FD_ISSET(i, &select_set)){ //i is the file descriptor number read( i, buf, BUF_SIZE); //etc. FD_CLR(i,select_set); //remove file descriptor i from the set } }
First, a set of file descriptors must be declared, this is of type
fd_set
. After initialization, FD_ZERO()
, interested file
descriptors can be added to the set using FD_SET()
. Once all the
file descriptors are provided, the select()
system call will
check all the file descriptors in the set to see if they are
reading for an action, like a read()
. Finally, you can iterate
through all the file descriptor numbers checking if the file
descriptor was selected with FD_ISSET()
and if so, do some
action. A file descriptor can be removed from the set with
FD_CLR()
.
The specifics of how this works is not important here. The key
takeaway is that we can now check if a file descriptor needs an
action before taking said action: we are avoiding blocking! Let's
see how we would use select()
with our echo server:
fd_set activefds, readfds; //server setup and etc. while(1){ //loop //update the set of selectable file descriptors readfds = activefds; //Perform a select if( select(FD_SETSIZE, &readfds, NULL, NULL, NULL) < 0){ perror("select"); exit(1); } //check for activity on all file descriptors for(i=0; i < FD_SETSIZE; i++){ //was the file descriptor i set? if(FD_ISSET(i, &readfds)){ if(i == server_sock){ //activity on server socket, incoming connection //accept incoming connections = NON BLOCKING client_sock = accept(server_sock, (struct sockaddr *) &client_saddr_in, &saddr_len); printf("Connection From: %s:%d (%d)\n", inet_ntoa(client_saddr_in.sin_addr), ntohs(client_saddr_in.sin_port), client_sock); //add socket file descriptor to set FD_SET(client_sock, &activefds); }else{ //otherwise client socket sent something to us client_sock = i; //get the address of the socket getpeername(client_sock, (struct sockaddr *) &client_saddr_in, &saddr_len); //read from client and echo back n = read(client_sock, response, BUF_SIZE-1); if(n < 0){ //closed or error on socket //close client sockt close(client_sock); //remove file descriptor from set FD_CLR(client_sock, &activefds); printf("Client Closed: %s:%d (%d)\n", //LOG inet_ntoa(client_saddr_in.sin_addr), ntohs(client_saddr_in.sin_port), client_sock); }else{ //client sent a message response[n] = '\0'; //NULL terminate //echo messget to client write(client_sock, response, n); printf("Received From: %s:%d (%d): %s", //LOG inet_ntoa(client_saddr_in.sin_addr), ntohs(client_saddr_in.sin_port), client_sock, response); } } } } }
That's a lot to take in, but here are few key points. First off,
the accept()
call is also a blocking operation, so we can use
select()
to determine if we have an incoming connection. We still
need to check for clients closing on every read, which increases the
complexity of the code. In the end, though, it works.
But, it's not clean. It lacks a certain something. What's not quite
right about this code block is that we like to think of the process
of handling a client a lot more like the while loop from
echo_server
and not the while loop from
echo_server_select
. What we want is a way to parallelize the
process so we can write a simple bit of code that can handle all
client connections the same and let that code run in parallel from
the accepting connection. That's exactly what we'll look at next
when we investigated threading and how threading can be used for
socket server programming.