Understanding Internet Sockets: From Basics to Implementation

Key attribute in React

Berkeley sockets, often simply referred to as sockets, are a fundamental component of internet communication.. Developed in the early 1980s as part of the Berkeley Software Distribution (BSD) operating system, sockets are used to establish a connection between two devices over the internet. In this article, we'll delve into the basics of internet sockets and how they work.

1. What are internet sockets?

Sockets are an abstraction or interface for network communication, implemented through an API that allows for network communication between different processes (programs), either on the same machine or across different machines over the internet. Sockets provide a bidirectional communication channel between a client and a server, allowing them to exchange data.

Okay, but how is this possible? the short answet is the file descriptor, in Unix-like operating systems, a file descriptor is a unique identifier that represents an open file in the kernel's file system. This file can be a regular file, a directory, a block device, a character device, a pipe, a socket, or a symbolic link. The file descriptor is an integer value that is used to access the file or device for reading, writing, or other operations.

I am sure that you have heard the term "everything in Unix is a file", this is true because everything in Unix is represented as a file descriptor, when you doing some I/O operation you are actually reading or writing to a file descriptor which simply represents the file, and that file can be a socket, a pipe, a terminal, or a regular file on the disk as we mentioned before.

So the conclusion is that it is a file descriptor, to send or receive data from it, we use the read( recv() ) and write( send() ) system calls, which are used to read and write data from/to a file descriptor.

Because at the end the socket is a file descriptor, you may have asked yourself why we don't use the read() and write() directly? the answer is that you can do that, but send() and recv() are more convenient and provide more control over the data being sent and received.:

1. Setting Up the Server

  • Server Socket Creation:
    • The server process starts by creating a socket using the socket() system call. This socket is a file descriptor in the process’s file descriptor table.
    • The server then binds this socket to a specific IP address and port using the bind() system call. This tells the kernel that this process wants to listen for incoming connections on this IP and port.
  • Listening for Connections:
    • The server process then calls listen() on the socket, which tells the kernel to queue up incoming connection requests until the process can handle them.
  • Accepting Connections:
    • When a client attempts to connect, the server calls accept() on the listening socket. This system call blocks the server until a connection attempt is made by a client. When a connection is established, accept() returns a new socket file descriptor, specific to this client-server communication.

2. Setting Up the Client

  • Client Socket Creation:

    • The client process similarly creates a socket using the socket() system call. This socket is also a file descriptor.
  • Connecting to the Server:

    • The client then attempts to establish a connection to the server using the connect() system call. The client specifies the server’s IP address and port.
    • The kernel handles the connection attempt by sending a request to the server’s IP and port specified by the client.

3. Kernel’s Role in Connection Establishment

  • TCP Handshake (if using TCP):
    • If the communication is over TCP, a three-way handshake occurs.
      1. The client sends a SYN packet to the server.
      2. The server responds with a SYN-ACK packet.
      3. The client sends an ACK packet back to the server.
    • This handshake is managed by the kernel, which keeps track of the connection’s state.
  • Socket Buffer Management:
    • The kernel also manages socket buffers for both the client and the server. These buffers temporarily store data sent or received over the network.
  • File Descriptor Assignment:
    • Once the connection is established, the kernel assigns a file descriptor to the connection on both the client and server. This allows both processes to read from or write to the connection as if they were reading or writing to a file.

4. Data Transmission

  • Sending Data:

    • When the client wants to send data, it writes to the socket using send() or write(). The data is first placed in the socket buffer managed by the kernel.
    • The kernel then takes care of packaging the data into network packets (with TCP/UDP headers, IP headers, etc.) and sends them over the network to the server.
  • Receiving Data:

    • On the server side, the kernel receives the network packets, reconstructs the data from them, and places it into the server’s socket buffer.
    • The server process can then read this data using recv() or read() from the socket.

5. Network Layer Involvement

  • Routing and Delivery:

    • The data travels across the network infrastructure, which may involve passing through routers, switches, and other network devices.
    • The IP layer handles routing the packets from the client’s machine to the server’s machine, based on IP addresses and routing tables.
  • Error Checking and Data Integrity:

    • Protocols like TCP provide error checking and ensure that data is delivered correctly and in order. The kernel on both client and server machines handles retransmissions if packets are lost or corrupted.

6. Connection Termination

  • Client or Server Closes the Connection:
    • When either the client or server decides to close the connection, they call close() on the socket.
    • If using TCP, a four-way handshake occurs to gracefully close the connection:
      1. The closing side sends a FIN packet.
      2. The other side responds with an ACK packet.
      3. The other side then sends its own FIN packet.
      4. The closing side responds with an ACK packet.
    • The kernel is responsible for cleaning up the socket buffers, releasing resources, and updating the connection state.

2. Types of sockets

There are plenty of types of sockets, but the most common are: internet sockets, Unix domain sockets, and raw sockets, but here we are going to focus on internet sockets which are the most common type of socket used in network programming.

2.1 Stream Sockets (aka SOCK_STREAM)

Stream sockets are connection-oriented and stream-oriented sockets that provide a reliable, two-way communication channel between a client and a server. They guarantee that data is delivered in the order it was sent and that it is error-free. Stream sockets are commonly used for protocols such as Transmission Control Protocol (TCP), which is used for most internet communication.

Let's break the definition down a bit:

1- Connection-oriented: Stream sockets require a connection to be established between the client and server before data can be exchanged. This connection is established using a three-way handshake, which ensures that both parties are ready to communicate.

2- Reliable Connection: TCP ensures that all data are delivered in the order they were sent and that they are error-free. This is achieved through mechanisms such as sequence numbers, acknowledgments, and retransmissions.

3- Stream-Oriented: Data sent over a stream socket is treated as a continuous stream of bytes. This means that there are no message boundaries, and the sender and receiver must agree on how to delimit messages.

Example of Stream Sockets: HTTP, HTTPS, FTP, SMTP, POP3, IMAP, SSH, Telnet, etc.

2.2 Datagram Sockets (aka SOCK_DGRAM)

Unlike Stream Sockets, Datagram Sockets are connectionless and message-oriented sockets that provide an unreliable, two-way communication channel between a client and a server. Datagram sockets do not guarantee that data is delivered in the order it was sent or that it is error-free. Datagram sockets are commonly used for protocols such as User Datagram Protocol (UDP), which is used for real-time communication and multimedia streaming.

Let's break the definition down a bit:

1- Connectionless: Datagram sockets do not require a connection to be established between the client and server before data can be exchanged, what is happening is that the data gets packed into a datagram and sent to the destination without establishing a connection.

2- Unreliable Connection: UDP does not guarantee that data is delivered in the order it was sent or that it is error-free. This means that the sender does not know if the data was received by the receiver, and there is no mechanism for retransmitting lost packets.

3- Message-Oriented: Data sent over a datagram socket is treated as discrete messages with boundaries. This means that each message is self-contained and can be processed independently of other messages.

As you might have guessed before, the datagram sockets are more faster than the stream sockets, because they don't have to establish a connection before sending the data, and they don't have to wait for the acknowledgment of the receiver, and if the data is lost, the sender doesn't have to retransmit the data, so they are more faster than the stream sockets.

To send a data using Datagram Sockets you just build the packet and add the destination information to it and just send it, we can call it "fire and forget".

Example of Datagram Sockets: DNS, DHCP, SNMP, TFTP, NTP, etc.

Note: You can build your own protocol above the UDP, and you can implement the acknowledgment and retransmission mechanism, but this is not the default behavior of the UDP, other protocols like the QUIC protocol which is used by Google, it is built above the UDP and it provides a reliable connection and it is faster than the TCP.

3. How do internet sockets work in kernel space?

When a process wants to establish a network connection using sockets, it interacts with the operating system's kernel, which is responsible for managing network communication. The kernel provides a set of system calls that allow processes to create, bind, listen, accept, connect, send, and receive data over sockets.

Here's a C high-level overview of how internet sockets work in kernel space:

First let's disccuss the server side:

  1. Creating a Socket: The process creates a socket using the socket() system call, which returns a file descriptor representing the socket, also allocates kernel resources for the socket. The process specifies the type of socket (stream or datagram) and the protocol family (IPv4 or IPv6) when creating the socket.

  2. Binding a Socket: The process binds the socket to a local address and port using the bind() system call. This step is necessary for servers that want to listen for incoming connections on a specific port.

  3. Listening for Connections: If the process is a server, it calls the listen() system call to put the socket in a listening state. This allows the server to accept incoming connections from clients.

  4. Accepting Connections: When a client connects to the server, the server calls the accept() system call to accept the connection. This creates a new socket that is used to communicate with the client.

  5. Sending and Receiving Data: The server and client can now send and receive data over the established connection using the send() and recv() system calls.

Now let's discuss the client side:

  1. Creating a Socket: The process creates a socket using the socket() system call, specifying the type of socket (stream or datagram) and the protocol family (IPv4 or IPv6).

  2. Connecting to a Server: The client calls the connect() system call to establish a connection to the server. The client specifies the server's address and port when connecting.

  3. Sending and Receiving Data: The client and server can now send and receive data over the established connection using the send() and recv() system calls.

Socket in Kernel Space

4. How to implement internet sockets in C/C++?

4.1 For the Server Side

As we have mentioned before, there are different system calls that you can use to create a server:

  1. socket(): to create a socket.
  2. bind(): to bind the socket to a local address and port.
  3. listen(): to put the socket in a listening state.
  4. accept(): to accept incoming connections from clients.
  5. close(): to close the socket when done.

So let begin with the code:

4.1.1 Creating a Socket

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
    perror("socket");
    exit(EXIT_FAILURE);
}

Above we have created a socket using the socket() system call. The AF_INET argument specifies the address family (IPv4), SOCK_STREAM specifies the socket type (stream), and 0 specifies the protocol (default protocol for the address family and socket type).

4.1.2 Binding a Socket

The server need to bind the socket to a specific IP address and port number, we are using bind to make this possible

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd: The file descriptor of the socket to bind.
  • addr: A pointer to a sockaddr structure that contains the IP address and port number to bind to.
  • addrlen: The size of the sockaddr structure.

Here is an example of how to bind a socket to a specific IP address and port number:

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_addr.s_addr = INADDR_ANY; // Bind to all available interfaces
server_addr.sin_port = htons(8080); // Port number
// handle addrlen
int addrlen = sizeof(server_addr);

if (bind(sockfd, (struct sockaddr *)&server_addr, addrlen) == -1) {
    perror("bind");
    exit(EXIT_FAILURE);
}
  • INADDR_ANY: This constant specifies that the socket should bind to all available interfaces on the machine. This allows the server to accept connections from any client that can reach the machine.
  • htons(): This function converts the port number from host byte order to network byte order. This is necessary because network protocols use big-endian (network byte order) to represent numbers, to read more about the byte order you can read this article.
  • sizeof(server_addr): This returns the size of the sockaddr_in structure, which is needed by the bind() system call.
  • perror(): This function prints an error message to stderr based on the value of the errno variable.

Note: if a process is already using the port number that you are trying to bind to, the bind() system call will fail with the EADDRINUSE error. You can avoid this error by using the SO_REUSEADDR socket option, which allows multiple sockets to bind to the same port number.

4.1.3 Listening for Connections

After binding now the server need to listen for incoming connections, the listen() system call is used to put the socket in a listening state:

int listen(int sockfd, int backlog);
  • sockfd: The file descriptor of the socket to listen on.
  • backlog: The maximum number of pending connections that can be queued up before the server starts rejecting new connections.

Please be aware of backlog here, the kernal will maintain a queue of the incoming connections, and the backlog is the maximum number of connections that can be queued up before the server starts rejecting new connections, so if the server is busy and the queue is full, the server will start rejecting the new connections.

Here is an example of how to listen for incoming connections:

if (listen(sockfd, 10) == -1) {
    perror("listen");
    exit(EXIT_FAILURE);
}

4.1.4 Accepting Connections

When you are listening the kernal will accept connection from the client and will queue them up, and you can accept them using the accept() system call and this will create a new socket for the client connection (which means a new file descriptor).

Then from that new file descriptor you can send and receive data from the client, each client will have its own file descriptor.

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd: The file descriptor of the listening socket.
  • addr: A pointer to a sockaddr structure that will store the client's address and port number.
  • addrlen: A pointer to a variable that stores the size of the sockaddr structure.

Note: The accept() system call blocks until a new connection is established. When a new connection is accepted, it returns a new file descriptor that represents the connection.

Please note that the sockfd is different from the

Here is an example of how to accept a new connection:

struct sockaddr_in client_addr;
int client_sockfd;
socklen_t client_addrlen = sizeof(client_addr);

client_sockfd = accept(sockfd, (struct sockaddr *)&client_addr, &client_addrlen);
if (client_sockfd == -1) {
    perror("accept");
    exit(EXIT_FAILURE);
}
  • client_sockfd: The file descriptor of the new socket created for the client connection.
  • client_addr: The address of the client that connected. This can be used to log or display the client’s IP address.

4.1.5 Sending and Receiving Data

Once a connection is accepted, the server can receive data from the client using the recv() function.

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd: The file descriptor of the socket to receive data from.
  • buf: A pointer to a buffer where the received data will be stored.
  • len: The maximum number of bytes to receive.
  • flags: Optional flags (typically set to 0).

example of how to receive data from the client:


char buffer[1024];
ssize_t bytes_received = recv(client_sockfd, buffer, sizeof(buffer) - 1, 0);
if (bytes_received == -1) {
    perror("recv");
    close(client_sockfd);
    exit(EXIT_FAILURE);
}

buffer[bytes_received] = '\0'; // Null-terminate the received data
printf("Received: %s\n", buffer);

Similarly, the server can send data to the client using the send() function.


ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • sockfd: The file descriptor of the socket to send data to.
  • buf: A pointer to the data to send.
  • len: The number of bytes to send.
  • flags: Optional flags (typically set to 0).

example of how to send data to the client:


const char *message = "Hello, client!";

if (send(client_sockfd, message, strlen(message), 0) == -1) {
    perror("send");
    close(client_sockfd);
    exit(EXIT_FAILURE);
}

4.1.6 Closing the Socket

After the server is done communicating with the client, it should close the client socket using the close() system call.

close(client_sockfd);

The server should also close the listening socket when it is done accepting connections.

close(sockfd);

4.2 For the Client Side

The client side is simpler than the server side, as the client only needs to connect to the server and send and receive data.

Here is a high-level overview of how to implement a client using internet sockets in C/C++:

  1. socket(): to create a socket.
  2. connect(): to establish a connection to the server.

4.2.1 Creating a Socket

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
    perror("socket");
    exit(EXIT_FAILURE);
}

4.2.2 Connecting to a Server

The connect system call establishes a connection between the local system (the client) and the foreign system (the server).

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd: The file descriptor of the socket to connect.
  • addr: A pointer to a sockaddr structure that contains the server's IP address and port number.
  • addrlen: The size of the sockaddr structure.

Here is an example of how to connect to a server:


struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // IPv4
erver_addr.sin_port = htons(PORT);

#define PORT 8080
#define SERVER_IP "127.0.0.1" 

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(PORT); // Server port number

// Convert IPv4 and IPv6 addresses from text to binary form
if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
    perror("Invalid address/ Address not supported");
    exit(EXIT_FAILURE);
}

if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
    perror("Connection Failed");
    exit(EXIT_FAILURE);
}

4.2.3 Sending and Receiving Data

Once the client is connected to the server, it can send data to the server using the send() function.

const char *message = "Hello, server!";
if (send(sockfd, message, strlen(message), 0) == -1) {
    perror("send");
    close(sockfd);
    exit(EXIT_FAILURE);
}

The client can also receive data from the server using the recv() function.

char buffer[1024];
ssize_t bytes_received = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (bytes_received == -1) {
    perror("recv");
    close(sockfd);
    exit(EXIT_FAILURE);
}

buffer[bytes_received] = '\0'; // Null-terminate the received data
printf("Received: %s\n", buffer);

4.2.4 Closing the Socket

close(sockfd);

Conclusion

Internet sockets are a fundamental part of network programming and are used to establish communication between different processes over the internet. Sockets provide a bidirectional communication channel between a client and a server, allowing them to exchange data. Stream sockets are connection-oriented and provide a reliable, two-way communication channel, while datagram sockets are connectionless and provide an unreliable, two-way communication channel. Implementing internet sockets in C/C++ involves creating a socket, binding it to an address and port, listening for connections, accepting connections, sending and receiving data, and closing the socket when done. By understanding how internet sockets work and how to implement them, you can build powerful networked applications that communicate over the internet.

References