POSIX 网络编程 API

在网络Linux网络编程中,会使用到的API有如下的几个:

Server侧:
socket、bind、listen、accept、recv、send、close、connect(可选)。

client侧:

socket、bind、connect、send、recv、close。


socket函数

调用socket函数的时候会发生什么?

//socket的函数原型
int sockfd = socket(int domain, int type, int protocol);
//domain: 决定协议族
//type: 套接字类型
//protocol: 协议号(通常为 0,由内核自动选择对应协议)
  1. 创建socket结构体

​ 首先:socket函数是一个glibc封装函数,会通过syscall进入内核态,对应sys_socket系统调用;

​ 然后:内核会调用sock_create,创建一个struct socket (内核的socket对象);

​ 分配对于的文件描述符(fd);

​ 在socket结构体中的ops指针指向具体协议的操作函数集合。

  1. 创建协议控制块

​ 如果是TCP,会创建struct net_sock/tcp_sock结构体,用于表示状态机和控制数据;

​ 初始化sock状态为close;

  1. 返回文件描述符

所以总结起来,socket函数,就是封装了系统调用,为接下来的网络连接分配好资源,得到操作这个资源的描述符。

sys_socket的源码位置:

//路径
net/socket.c
//函数原型
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
//展开后
asmlinkage long sys_socket(int family, int type, int protocol)

bind函数

函数原型
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//sockfd: 通过 socket() 创建的 socket 描述符
//addr: 指定要绑定的地址结构(如 struct sockaddr_in)
//addrlen: 地址结构的长度

调用bind内核做的事情:

  1. 校验地址格式是不是正确
  2. 校验端口:分配端口,或者检查指定端口是否被占用
  3. 进行绑定:操作ient_bind_bucket结构体
  4. 把地址写入控制块:sk->sk_rcv_saddr,tcp状态会转换为TCP_BOUND;(IP地址和端口会被写入TCB结构体)

listen函数

listen函数将bind的socket转换为监听状态,内核会为该socket创建连接请求队列

//函数原型
int listen(int sockfd, int backlog);
//sockfd: 使用 socket() 创建并 bind() 过的 socket
//backlog: 连接请求队列的长度(半连接队列最大长度)
  1. 检查socket类型:类型需要时SOCK_STREAM(TCP)
  2. 检查状态是否为TCP_BOUND
  3. 创建连接请求队列(backlog)
  4. 设置状态为TCP_LISTEN

关于listen函数的backlog参数:

在不同的Linux版本中有不同的含义,有可能是以下几种情况:

  1. 表示半连接队列的长度
  2. 表示半连接+全连接队列的长度
  3. 表示全连接的长度

accept函数

accept() 用于从已经完成三次握手的连接队列中取出一个客户端连接,并为其分配一个新的 socket 文件描述符,用于数据通信。

//函数原型
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//sockfd: 监听 socket(必须已调用 listen())
//addr: 输出参数,用于存放客户端地址信息
//addrlen: addr 的长度,调用前设置,调用后会被修改为实际大小

内核在accept的操作:

  1. 检查sockfd是否为监听socket,状态需要时listen
  2. 检查已经完成连接队列:有没有客户端完成握手
  3. 如果队列为空:会阻塞,如果设置非阻塞会返回EAGAIN
  4. 取出一个连接请求,创建新的struct sock ,分配描述符
  5. 返回新的socketfd,用于长连接通信

关于三次握手:

三次握手的时机:在listen状态的时候,客户端发起connect,内核协议栈会进行握手,握手完成后,会讲结构体添加到完成连接队列

三次握手的流程与状态变化:

通常来说,服务端在收到syn包的时候就会创建一个轻量级的TCB(struct request_sock),这个结构体会填充初始状态(初始序列号,窗口等)然后加入半连接队列;

等待收到ACK后,才会创建完整的struct sock,分配描述符,加入全连接队列。

使用request_sock的原因:

  1. 避免服务端资源被浪费(客户端没有回复ack会被一直占用)
  2. 对SYN Flood进行防御

关于SYN Flood:

SYN Flood是指攻击者一直不停的进行伪装给服务器发送SYN包,占满服务器的连接队列,让服务器无法正常建立连接;

这种时候可以启用SYN Cookie,启用SYN Cookie 后,服务器收到SYN包不会创建request_sock,会返回SYN+ACK(将时间戳、序列号等通过计算作为SYN进行返回),如果客户端ACK,在创建TCB;

这种方法可以有效防止SYN Flood。


send、recv函数

send和recv函数其实只是将用户态的数据拷贝到内核态的缓冲区,以及从内核态缓冲区将数据拷贝到用户态。

//函数原型
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
//sockfd	套接字文件描述符(由 socket()/accept() 得到)
//buf	要发送/接收的数据缓冲区
//len	缓冲区大小
//flags	传输控制标志,常为 0(可设置 MSG_OOB、MSG_DONTWAIT 等)

用户态数据进入内核缓冲区是存放到sk_buffer

//sk_buff 核心字段
struct sk_buff {
    struct sk_buff *next;     // 链表结构
    struct sk_buff *prev;

    char *data;               // 指向 payload 起始位置
    char *head, *tail, *end;  // buffer 管理指针
    unsigned int len;         // 有效数据长度

    struct sock *sk;          // 所属 socket
    struct net_device *dev;   // 接收到此包的网卡设备
    ...
};

sk_buff 会保存上层协议的信息,以支持网络层和传输层的协议解析以及操作(比如TCP分包),sk_buff是链式队列管理,支持多层协议解析,有引用计数器、时间戳、优先级、分片扩展等字段,支持拥塞控制和流量控制。

内核不会为用户进行沾包、拆包处理,需要用户自己处理。


close函数

close() 会关闭 socket 文件描述符,并启动 TCP 四次挥手的连接终止过程,最后释放所有资源。所以close其实不是网络编程api,是文件系统api。

//函数原型
int close(int sockfd);

close涉及到的内核操作:

sys_close()
  └─ filp_close()
       └─ sock_close()
             └─ inet_release()
                   └─ tcp_close()
  1. 标记socket不可写;
  2. 如果是发送方:发送fin包,进入FIN_WAIT_1
  3. 如果是最后一方(被动关闭后再 close)
  4. 清空写队列、接收队列中的 sk_buff
  5. 释放内核中相关资源(如 TCB、缓冲区、文件描述符)

close涉及到的四次挥手:

截屏2025-03-28 10.31.45

这里涉及到几个问题:

设置TIME_WAIT状态的原因:

进入TIME_WAIT状态需要等待2 * MSL的时间,MSL(最大报文生成时间),Linux通常使用60的MSL,相当于会在这里等待60秒,这个时间是为了保证对方可以收到ACK包,然后保证自己的ACK可以到达;如果收到了对方重传的fin,说明ACK丢失,需要重传ACK,这个时候2MSL的计时器会被重置;

另外一个原因是保障当前连接的数据包已经不在网络上生存,防止建立连接后,新建的连接(四元组相同的情况)收到之前的连接延迟的旧数据包,导致数据混乱。

几个常见、安全的优化方案:

优化方案 效果 风险
SO_REUSEADDR 允许重用 TIME_WAIT 中的端口 安全、标准做法
SO_REUSEPORT 多进程共享监听端口 增加并发、负载均衡
服务端被动关闭连接 让客户端 close(),服务端不会进入 TIME_WAIT 业务层设计复杂
启用 tcp_tw_reuse(Linux) Linux 内核支持复用 TIME_WAIT 的连接 可控风险

如果是作为高性能服务端(比如DPDK高性能服务器,有海量短连接),是不能允许一个连接资源等待这么长时间的,优化方法是可以进行RTT测量并实时更新(降低网络波动带来的影响),然后使用2RTT作为等待时间,这种方案在均衡负载器、CDN边缘节点、高频交易系统会采用。

如果发送端没有收到ack就收到fin会怎么样:

TCP 协议栈会认为你们双方同时关闭连接,然后进入 CLOSING 状态,等待对方的 ACK

如果双方同时调用发送fin会怎么样:

双方都会进入FAI_WAIT1,然后编程TIME_WAIT状态,这种情况会让服务器出现大量的TIME_WAIT状态。


其他:

在server侧的connect函数:

在服务端调用connect函数是合法的,会用在一些特殊的场景:

某些系统中(如 P2P / NAT 穿透 / 双向连接系统):

  • 服务端接收到客户端信息后,反向用 connect() 主动连接客户端
  • 常用于 打洞、反向 shell、RPC 反向通道

服务端作为“中间人”转发连接

  • 接收到客户端连接后,server 主动连接另一个后端服务
  • 类似:代理 / 负载均衡器 / VPN 隧道等

主动连接的 server-client 混合设计

  • 某些高性能系统中,客户端与服务端都是“全双工角色”
  • 建立连接后,会反向主动连接另一侧用于心跳、控制流

此外,在云环境,使用connect可以进行端口绑定,在bind的时候绑定本地的IP地址和端口,然后使用connect发起连接,让socket另一侧,绑定外侧的地址和端口;

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

struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(50000);              // 自定义源端口
local.sin_addr.s_addr = inet_addr("10.0.0.1");  // 自定义源 IP

bind(fd, (struct sockaddr*)&local, sizeof(local));  // 绑定本地 IP:Port

struct sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_port = htons(80);
peer.sin_addr.s_addr = inet_addr("1.2.3.4");

connect(fd, (struct sockaddr*)&peer, sizeof(peer)); // 主动发起连接

是否有效取决于一下几点:

​ 是否绑定了允许使用的私网 IP?

​ 端口是否可用?

​ 安全组和 VPC 网络规则是否允许返回流量?

云环境中的典型使用场景:

场景 是否使用 bind
自定义 NAT 回源 使用 bind 固定源 IP/端口
TCP 多连接管理(高性能) 绑定端口可用于五元组区分连接
负载均衡器主动拨测 需要固定 source IP/Port
一般 server connect 到 backend 不绑定也可以,让系统自动选择
同时发送SYN的情况

在SYN_SENT 收到SYN是合法的,两边都会进入SYN_SRCV,然后回复ACK,进入连接状态,属于同时打开;

在P2P的情况需要这种机制。

posted @ 2025-03-30 10:13  Tohomson  阅读(33)  评论(0)    收藏  举报