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,由内核自动选择对应协议)
- 创建socket结构体
首先:socket函数是一个glibc封装函数,会通过syscall进入内核态,对应sys_socket系统调用;
然后:内核会调用sock_create,创建一个struct socket (内核的socket对象);
分配对于的文件描述符(fd);
在socket结构体中的ops指针指向具体协议的操作函数集合。
- 创建协议控制块
如果是TCP,会创建struct net_sock/tcp_sock结构体,用于表示状态机和控制数据;
初始化sock状态为close;
- 返回文件描述符
所以总结起来,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内核做的事情:
- 校验地址格式是不是正确
- 校验端口:分配端口,或者检查指定端口是否被占用
- 进行绑定:操作ient_bind_bucket结构体
- 把地址写入控制块: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: 连接请求队列的长度(半连接队列最大长度)
- 检查socket类型:类型需要时SOCK_STREAM(TCP)
- 检查状态是否为TCP_BOUND
- 创建连接请求队列(backlog)
- 设置状态为TCP_LISTEN
关于listen函数的backlog参数:
在不同的Linux版本中有不同的含义,有可能是以下几种情况:
- 表示半连接队列的长度
- 表示半连接+全连接队列的长度
- 表示全连接的长度
accept函数
accept()
用于从已经完成三次握手的连接队列中取出一个客户端连接,并为其分配一个新的 socket 文件描述符,用于数据通信。
//函数原型
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//sockfd: 监听 socket(必须已调用 listen())
//addr: 输出参数,用于存放客户端地址信息
//addrlen: addr 的长度,调用前设置,调用后会被修改为实际大小
内核在accept的操作:
- 检查sockfd是否为监听socket,状态需要时listen
- 检查已经完成连接队列:有没有客户端完成握手
- 如果队列为空:会阻塞,如果设置非阻塞会返回EAGAIN
- 取出一个连接请求,创建新的struct sock ,分配描述符
- 返回新的socketfd,用于长连接通信
关于三次握手:
三次握手的时机:在listen状态的时候,客户端发起connect,内核协议栈会进行握手,握手完成后,会讲结构体添加到完成连接队列
三次握手的流程与状态变化:
通常来说,服务端在收到syn包的时候就会创建一个轻量级的TCB(struct request_sock),这个结构体会填充初始状态(初始序列号,窗口等)然后加入半连接队列;
等待收到ACK后,才会创建完整的struct sock,分配描述符,加入全连接队列。
使用request_sock的原因:
- 避免服务端资源被浪费(客户端没有回复ack会被一直占用)
- 对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()
- 标记socket不可写;
- 如果是发送方:发送fin包,进入FIN_WAIT_1
- 如果是最后一方(被动关闭后再 close)
- 清空写队列、接收队列中的 sk_buff
- 释放内核中相关资源(如 TCB、缓冲区、文件描述符)
close涉及到的四次挥手:
这里涉及到几个问题:
设置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的情况需要这种机制。