Socket 编程

IP 协议层

地址协议族

Linux 将网络协议分为多个地址协议族(protocol family),每个协议族包含一组相关的协议,这些协议族以 AF_ 开头,其中,A 代表 Address,F 代表 Family。其中最为常用的协议族是 AF_INETAF_INET6I 代表 Internet),分别对应了 IPv4 和 IPv6。

  • AF_INET:IPv4 协议族,使用 IPv4 地址。
  • AF_INET6:IPv6 协议族,使用 IPv6 地址。

IP 地址

IP 地址是网络中设备的唯一标识符,分为 IPv4 和 IPv6 两种格式,对应类型 in_addrin6_addr,前缀 in 代表 Internet:

/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
  {
    in_addr_t s_addr;
  };
struct in6_addr
  {
    union
      {
	uint8_t	__u6_addr8[16];
	uint16_t __u6_addr16[8];
	uint32_t __u6_addr32[4];
      } __in6_u;
#define s6_addr			__in6_u.__u6_addr8
#ifdef __USE_MISC
# define s6_addr16		__in6_u.__u6_addr16
# define s6_addr32		__in6_u.__u6_addr32
#endif
  };

以 IPv4 为例,in_addr 本质是一个 32 位的无符号整数。要将一个点分十进制的 IPv4 地址转换为 in_addr 结构体,可以使用 <arpa/inet.h> 中的 inet_addrinet_pton 函数。其中,inet_addr 仅支持 IPv4 协议族,已经被废弃,不推荐使用,建议使用 inet_pton 函数:

inet_ptonpton 表示 presentation format to network format。

#include <arpa/inet.h>
const char *ipv4_str = "127.0.0.1";
const char *ipv6_str = "::1";

struct in_addr ipv4_addr;
struct in6_addr ipv6_addr;

// convert dot-decimal IPv4 string to in_addr structure, deprecated
ipv4_addr.s_addr = inet_addr(ipv4_str);

// convert dot-decimal IPv4 string to in_addr structure
if (inet_pton(AF_INET, ipv4_str, &ipv4_addr) <= 0) {
    // handle error
}

// convert IPv6 string to in6_addr structure
if (inet_pton(AF_INET6, ipv6_str, &ipv6_addr) <= 0) {
    // handle error
}

inet_pton() returns 1 on success (network address was successfully converted). 0 is returned if src does not contain a character string representing a valid net‐work address in the specified address family. If af does not contain a valid address family, -1 is returned and errno is set to EAFNOSUPPORT.

针对特殊的 IP 地址,Linux 定义了一些特殊的常量(在 <netinet/in.h> 中定义):

  • INADDR_ANY:表示任意地址,通常用于服务器端,表示接受来自任何 IP 地址的连接。
  • INADDR_LOOPBACK:表示回环地址。
  • INAADDR_BROADCAST:表示广播地址。

回环地址与虚拟网卡设备 lo

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 502  bytes 65207 (65.2 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 502  bytes 65207 (65.2 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

使用 ifconfig 命令可以查看系统中的网络接口,其中 lo 是一个特殊的虚拟网卡设备,该设备并不对应于任何物理网络接口。当使用回环地址进行通信时,数据包会被发送到 lo 设备。

传输层

在 IP 协议的基础上,Linux 定义了多种套接字类型,每种类型对应不同的通信方式,其本质是不同的传输层协议:

  • SOCK_STREAM:面向连接的套接字,使用 TCP 协议,提供可靠的、双向的字节流通信。
  • SOCK_DGRAM:无连接的套接字,使用 UDP 协议,提供不可靠的、无序的消息传递。
  • SOCK_RAW:原始套接字,允许直接访问底层协议,通常用于实现自定义协议或进行网络调试。

套接字

套接字(socket)是网络通信的基本抽象,提供了一个统一的接口来进行网络通信。套接字类型由地址协议族和套接字类型共同决定。

套接字创建的基本流程如下:

  1. 使用 socket 函数创建一个套接字。
  2. 对于服务器,使用 bind 函数将套接字绑定到一个特定的地址和端口,并调用 listen 进行监听,随后调用 accept 接受客户端连接;对于客户端,使用 connect 函数连接到服务器。
  3. 双方使用 sendrecv 等函数进行数据传输。
  4. 通信完成后,调用 close 函数关闭套接字。 close 可由通信的任意一方发起,调用后将向另一方发送 FIN 执行四次挥手流程断开连接。

创建套接字

#include <sys/socket.h>

int socket(int domain, int type, int protocol);

int serverfd = socket(AF_INET, SOCK_STREAM, 0);

其中,domain 参数指定了套接字的地址协议族,type 参数指定了套接字的类型,protocol 参数指定了使用的协议,通常设置为 0,表示使用默认协议。

open 等 API 类似,使用 socket 创建的套接字返回文件描述符,需要在使用完成后调用 close 进行关闭。

绑定套接字到指定地址

完整的地址由 IP 地址(网络层)和端口(传输层)构成。 sockaddr 结构体用于标识套接字的地址信息。sockaddr 是一个通用的套接字地址结构体,其中 sa_data 的具体内容取决于地址协议族,并且受历史遗留因素影响,其定义的长度为 14 字节,但实际上长度并不止 14 字节。

/* Structure describing a generic socket address.  */
struct sockaddr
  {
    __SOCKADDR_COMMON (sa_);	/* Common data: address family and length.  */
    char sa_data[14];		/* Address data.  */
  };

对于不同的地址协议族,Linux 定义了不同的套接字地址结构体:

/* Structure describing an Internet socket address.  */
struct sockaddr_in
  {
    __SOCKADDR_COMMON (sin_);
    in_port_t sin_port;			/* Port number.  */
    struct in_addr sin_addr;		/* Internet address.  */

    /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[sizeof (struct sockaddr)
			   - __SOCKADDR_COMMON_SIZE
			   - sizeof (in_port_t)
			   - sizeof (struct in_addr)];
  };
/* Ditto, for IPv6.  */
struct sockaddr_in6
  {
    __SOCKADDR_COMMON (sin6_);
    in_port_t sin6_port;	/* Transport layer port # */
    uint32_t sin6_flowinfo;	/* IPv6 flow information */
    struct in6_addr sin6_addr;	/* IPv6 address */
    uint32_t sin6_scope_id;	/* IPv6 scope-id */
  };

在定义套接字地址时,一般使用对应协议族的套接字地址结构体,例如,使用 sockaddr_in 定义 IPv4 套接字地址,然后将其强制转换为 sockaddr 结构体指针传递给相关的套接字函数,在 bind 内部根据协议族将 sockaddr 解析为相应的具体协议类型(这实际上是 C 语言实现泛型的做法)。

使用 bind 将套接字将服务器绑定到指定地址:

struct sockaddr_in server_addr = {
    .sin_family = AF_INET,
    .sin_port = htons(8080),
    .sin_addr = {
        .s_addr = htonl(INADDR_ANY)
        // .s_addr = inet_addr("127.0.0.1") // for IPv4 only, deprecated, use inet_pton instead
    }
};

if (bind(serverfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
    perror("Failed to bind socket.");
}
  • sin_family:指定地址协议族。
  • sin_port:指定端口号。
  • sin_addr:指定 IP 地址。以 IPv4 为例,该字段是 32 位的无符号整数。

字节序问题

RFC 规定网络传输使用大端字节序(network byte order),需要使用 htons(host to network short)将端口号从主机字节序转换为网络字节序,使用 htonl(host to network long)将 IP 地址从主机字节序转换为网络字节序。

建立连接

服务器

服务器需要调用 listen 函数监听套接字,并使用 accept 函数接受客户端连接:

if (listen(serverfd, 1) < 0)
{
    perror("Failed to listen on socket.");
}

listen 的原型如下:

/* Prepare to accept connections on socket FD.
   N connection requests will be queued before further requests are refused.
   Returns 0 on success, -1 for errors.  */
extern int listen (int __fd, int __n) __THROW;

其中,fd 参数指定要监听的套接字文件描述符,n 参数指定连接请求的最大排队数量。调用该函数后,套接字将进入监听状态,等待客户端连接请求。当有客户端连接请求到达时,内核会执行三次握手,连接请求进入就绪状态,需要服务器调用 accept 函数接受连接请求。

连接排队数量

listen 函数的第二个参数 n 指定了连接请求的最大排队数量,该参数在内核源码中对应于 backlog。当有多个客户端同时尝试连接服务器时,超过排队数量的连接请求将被拒绝。

Linux 内核源码中,系统调用 listen 调用情况如下:

SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
	return __sys_listen(fd, backlog);
}

int __sys_listen(int fd, int backlog)
{
	CLASS(fd, f)(fd);
	struct socket *sock;

	if (fd_empty(f))
		return -EBADF;
	sock = sock_from_file(fd_file(f));
	if (unlikely(!sock))
		return -ENOTSOCK;

	return __sys_listen_socket(sock, backlog);
}

int __sys_listen_socket(struct socket *sock, int backlog)
{
	int somaxconn, err;

	somaxconn = READ_ONCE(sock_net(sock->sk)->core.sysctl_somaxconn);
	if ((unsigned int)backlog > somaxconn)
		backlog = somaxconn;

	err = security_socket_listen(sock, backlog);
	if (!err)
		err = READ_ONCE(sock->ops)->listen(sock, backlog);
	return err;
}

somaxconn 是系统允许的最大连接排队数量,定义在 /proc/sys/net/core/somaxconn

Linux 内核中维护了两个队列来管理连接请求:半连接队列(half-open queue)和全连接队列(full-open queue)。当客户端发送 SYN 包请求连接时,服务器会将该连接请求放入半连接队列中,等待客户端完成三次握手。当三次握手完成后,服务器会将该连接从半连接队列移到全连接队列中,等待调用 accept 函数接受连接。如果半连接队列已满,新的连接请求将被拒绝。半连接队列的最大容量定义在 /proc/sys/net/ipv4/tcp_max_syn_backlog 中,默认为 1024。全连接队列的最大容量由 listen 函数的第二个参数 n 指定,且不能超过系统允许的最大连接排队数量 somaxconn

当半连接队列未满而全连接队列已满时,TCP 状态取决于 /proc/sys/net/ipv4/tcp_abort_on_overflow。若值为 0,则服务器在第三次握手不会发送 ACK,导致客户端一直超时并发送第三个分包;若值为 1,则服务器端向客户端发送 RST 分包,重置连接状态,客户端会收到 “104 connect reset by peer”。

半连接、全连接队列的问题对于短连接为主的场景优化比较关键。

服务器调用 accept 函数接受客户端连接:

struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int clientfd = accept(serverfd, (struct sockaddr*)&client_addr, &len);
if (clientfd < 0)
{
    perror("Failed to accept client connection.");
}

当全连接队列非空时,accept 函数会从全连接队列中取出一个连接请求并返回一个新的套接字文件描述符 clientfd,该文件描述符用于与客户端进行通信;队空时,accept 阻塞。

其中,client_addr 用于存储客户端的地址信息,lenclient_addr 结构体的大小,调用成功后 len 将被更新为实际的地址长度。

接受连接后,服务器可以使用 clientfd 文件描述符与客户端进行通信,通信完成后需要调用 close(clientfd) 关闭连接。

使用 recv 从客户端读取数据:

char buffer[1024];
do
{
    int count = recv(clientfd, buffer, sizeof(buffer), 0);
    if (count < 0)
    {
        perror("Failed to read.");
        break;
    }
} while (count != 0);

在客户端尚未发送数据时,recv 阻塞。 客户端有数据到达时,recv 函数会从套接字缓冲区中读取数据并返回读取的字节数;当客户端关闭连接时,recv 返回 0;当发生错误时,recv 返回 -1

需要指出,TCP 是面向字节流的协议,流之间没有明确的边界,客户端通过 send 发送的数据可能会被拆分为多个分包发送。当 TCP 连接被客户端断开时,recv 将会返回 0。当 recv 返回值是正数(读取到数据),服务器应该始终保持循环,以确保读取到所有数据。

When a stream socket peer has performed an orderly shutdown, the return value will be 0 (the traditional “end-of-file” return).

服务器完整代码如下:

#include <netinet/in.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

int main(int argc, char* argv[])
{
    int serverfd = socket(AF_INET, SOCK_STREAM, 0);
    if (serverfd < 0)
    {
        perror("Failed to create socket.");
        return 0;
    }

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr = {
            .s_addr = htonl(INADDR_ANY)
        }
    };

    int err = 0;

    if (bind(serverfd, (struct sockaddr*)&addr, 16))
    {
        perror("Failed to bind.");
        err = -1;
        goto serverfd_ok;
    }
    else
    {
        puts("Bind ok.");
    }

    if (listen(serverfd, 10) < 0)
    {
        perror("Failed to listen.");
        err = -1;
        goto serverfd_ok;
    }
    else
    {
        puts("Listen ok.");
    }

    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);

    int clientfd = accept(serverfd, (struct sockaddr*)&client_addr, &len);
    if (clientfd < 0)
    {
        perror("Failed to accept.");
        err = -1;
        goto serverfd_ok;
    }
    else
    {
        puts("Accept ok.");
    }
    char buffer[1024];
    int count = recv(clientfd, buffer, sizeof(buffer), 0);
    if (count < 0)
    {
        perror("Failed to read.");
        err = -1;
        goto clientfd_ok;
    }
    else
    {
        puts("Receive ok.");
    }
    buffer[count] = '\0';
    puts(buffer);

    puts("exited.");
clientfd_ok:
    close(clientfd);
serverfd_ok:
    close(serverfd);
    return err;
}
echo "hello" | nc 127.0.0.1 8080

运行结果如下:

Bind ok.
Listen ok.
Receive ok.
hello

exited.

鉴于 recv 的阻塞特性,为了实现公平的连接响应,一般在 accept 后在一个线程(或协程)中收取数据,并配合 epoll 等 API 实现事件驱动的响应逻辑。

客户端

一般来说,客户端无需通过 bind 指定端口,操作系统在未绑定端口时会自动分配,客户端创建套接字后使用 connect 函数连接到服务器即可。连接建立后,通过 send 发送数据。

int main()
{
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);

    if (clientfd < 0)
    {
        return -1;
    }

    int err = 0;

    struct sockaddr_in server_addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080)
    };

    assert(1 == inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr)); // 1 stands for success

    int status = connect(clientfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    if (status < 0)
    {
        perror("Failed to establish connection.");
        err = -1;
        goto cleanup;
    }
    else
    {
        puts("Connection established.");
    }

    send(clientfd, "hello", 5, 0);

    cleanup:
        close(clientfd);
        return err;
}

Q&A

  • 半连接队列和全连接队列是什么?

    • 半连接队列:收到 SYN 但尚未完成三次握手的连接称为半连接,OS 将其添加到半连接队列;
    • 全连接队列:完成三次握手,但服务器尚未调用 accept 接受的连接是全连接,OS 将其添加到全连接队列,大小对应于 listen 中传入的 backlog 参数。
  • shutdownclose 的区别是什么?

    • shutdown
      • 允许 SHUT_RD / SHUT_WR / SHUT_RDWR,维护完整的 TCP 四次挥手;
      • 不影响 socket 生命周期,调用后 socket 仍然可用。
    • close
      • 发送 FIN,由 FIN_WAIT 转入 TIME_WAIT
      • 若接收缓冲区仍有数据,发送 RST 重置连接状态;
      • 减少 socket 引用计数。