POSIX API网络通信TCP协议接口详解
简介:详细介绍了应用程序与内核在网络通信中的数据交互,以及TCP协议在通信时的各种状态转化。
引言
POSIX API是操作Linux操作系统所提供的统一接口,往往涉及对操作系统各种组件的操作。包括但不限于:文件系统、进程控制、线程操作、网络通信等。本文从客户端服务器端进行通信时分别所涉及到的Posix API出发,在tcp连接的通信流程中介绍各API的原理及作用。
原理及架构
一、总览
在网络编程下,常用的API如下:
网络通信中所有的操作都基于socket套接字,作为网络的入口。服务器与客户端都需要在通信时创建socket套接字。socket函数需要三个参数:
int socket(int domain, int type, int protocol);
domain代表该套接字使用的协议族(譬如IPv4、IPv6等);type指定套接字类型传输类型,常用有SOCK_STREAM:面向连接的流式传输(TCP)、SOCK_DGRAM:面向无连接的数据报传输(UDP);protocol指定了采用的具体协议,一般情况下函数会根据前两个参数自动选择默认协议。
初始化套接字后,双端bind()同时绑定地址与端口号,本文介绍基于连接的TCP协议,在创建套接字时应该指定数据传输方式为SOCK_STREAM。
在数据的传输与接收过程中有一系列的应用层与内核的交互,本文将分别从TCP连接的三个阶段结合POSIX API的调用进行介绍。
TCP连接的建立和结束由上图所示,在连接建立时需要服务端与客户端进行三报文握手,建立连接状态后传输数据,断开连接时需要进行四报文挥手。
二、初始化
socket()函数被调用后,操作系统会为该套接字分配资源。主要包括TCB(Transmission Control Block)、文件描述符fd(File Descriptor)、文件位图描述符(bitmap)。
TCB在内核中是一个用于存储套接字状态的数据结构包括标识连接的四元组、TCP数据传输中的序列号、流量控制的发送接收窗口等。但调用socket()表示在本地创建了通信的端点,还未绑定本地和远程地址,所以TCB中大部分字段并不会赋值,而是保持默认状态。
每一个进程都会维护一个文件描述符表,用于记录当前打开的所有文件。该表由bitmap、fd数组组成。当socket()被调用,内核会选择当前最小未被使用fd,并将该fd指向一个新的struct file结构体与此同时内核扫描位图找到第一个为0的比特位,将其置为1。最后返回该fd的编号到用户程序。
到此为止,socket()初始化了TCB,分配了fd。进一步使用bind()填充上述结构。
int bind(int sockfd, struct sockaddr *addr, socklen_t addrlen);
bind()函数需要fd,及一个stuct sockaddr类型的值。stuct sockaddr是一个用于表示通用套接字地址的数据结构。实际使用时会根据不同的操作系统内核被转换为不同的地址结构体(如struct sockaddr_in、struct sockaddr_in6 、 struct sockaddr_un)。其数据结构定义如下:
struct sockaddr {
sa_family_t sa_family; // 地址族(Address Family),如 AF_INET、AF_INET6
char sa_data[14]; // 地址数据(具体内容由地址族决定)
};
在linux操作系统中会根据协议转换为实际使用的结构体:
IPv4地址
struct sockaddr_in {
sa_family_t sin_family; // 地址族(必须为 AF_INET)
in_port_t sin_port; // 16 位端口号(需用 htons() 转换字节序)
struct in_addr sin_addr; // 32 位 IPv4 地址
char sin_zero[8]; // 填充字段(未使用,通常置 0)
};
struct in_addr {
uint32_t s_addr; // IPv4 地址(需用 inet_addr() 或 htonl(INADDR_ANY))
};
IPv6地址
struct sockaddr_in6 {
sa_family_t sin6_family; // 地址族(必须为 AF_INET6)
in_port_t sin6_port; // 16 位端口号
uint32_t sin6_flowinfo; // 流标签(通常为 0)
struct in6_addr sin6_addr; // 128 位 IPv6 地址
uint32_t sin6_scope_id; // 作用域 ID(用于链路本地地址)
};
struct in6_addr {
unsigned char s6_addr[16]; // IPv6 地址(如 ::1)
};
内核解析地址并检查IP与端口的有效性,将fd关联至监听态的TCB,更新其字段。此时fd仍然指向其原有的struct file实例,此时的TCB还缺少了远端的连接参数。至此所有的准备工作结束,服务端于客户端等待连接的建立。
三、建立连接
TCP连接的建立需要三次报文握手。
双方完成socket()初始化后连接都处于关闭状态。服务器主动调用listen()监听是否有请求。
int listen(int sockfd, int backlog);
在内核中,listen()函数会对fd所关联的监听TCB进行操作。首先将TCB中的状态由TCP_CLOSE转变为TCP_LISTEN,同时初始化两个关键队列:半连接队列(SYN Queue)、全连接队列(Accept Queue)
由客户端主动调用connect()向服务器发起连接,发送同步报文,该报文FIN同步字段置为1,同时告知本方传输数据的开始序号。服务器此时接收到请求,将该连接放入半连接队列,TCB中初始化本地传输序列号y
,记录客户端初始序号x
。状态值转为SYN_RECEIVED。向客户端会送确认报文,告知客户端本地传输序号与期望接收序号。客户端收到响应向服务器发送ACK报文,服务器将连接放入全连接队列,TCB状态转为ESTABLISHED。
至此三报文握手建立连接成功,后续数据传输按字节序号传输。在listen()中整数类型的backlog值往往起到重要的作用:早期的Linux版本backlog用于限制半连接队列的长度用于避免泛洪的出现;往后backlog用于表示半连接+全连接队列的总长度,避免长时间创建全连接而不处理。最后现目前的backlog值仅仅用于限制全连接队列长度,进一步提升了服务器的接入量。
当连接建立成功后,此时的fd并未完全与TCB进行映射。通过accept()函数将fd与TCB关联。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept()去到内核中取出全连接队列的队首,生成一个新的客户端fd与负责数据传输的TCB绑定。该fd专用于与当前客户端进行通信。
至此TCP连接建立完成,往后的数据操作都是通过生成的客户端fd进行通信,在连接建立过程中的fd和TCB分配情况如下表所示:
函数 | 操作阶段 | fd 来源/作用 | TCB 分配时机 | 关键说明 |
---|---|---|---|---|
socket() | 创建套接字 | 创建新的 fd(未绑定地址和端口) | 调用时分配 TCB | 返回的 fd 代表一个未绑定的套接字,TCB 初始化但未关联具体地址或端口。 |
bind() | 绑定地址和端口 | 使用 socket() 返回的 fd | 不分配 TCB | 将 fd 关联到本地 IP 和端口,TCB 中填充地址信息。 |
listen() | 开始监听连接请求 | 使用已绑定的 fd | 不分配 TCB(TCB 已存在) | 将套接字设为被动模式,TCB 中设置监听队列(半连接队列和全连接队列)。 |
accept() | 接受客户端连接 | 从监听队列中取出新连接,生成新 fd | 新 TCB 在连接建立时分配 | 返回的新 fd 代表已建立的连接,与原监听 fd 共享监听 TCB 的部分信息,但有自己的独立 TCB。 |
四、传输数据
当连接建立完成,服务器与客户端开始通信。客户端调用send()向服务器发送数据,服务器使用recv()接收数据。但send()、recv()函数都是在用户层面的接口,所有的数据都要切换到内核统一管理。
客户端与服务端数据收发如上图所示。当用户程序调用send()函数时,内核使用copy_from_user()将数据放入发送缓冲区中,在发送窗口拥塞控制的各种因素下向对端发送数据。对端内核接收到数据,用户态程序使用recv()将内核态中的数据读取。在这整个流程中,TCB都起到一个载体的作用。TCB中存储着有关TCP连接各个功能的参数,每一次的数据发送与数据接收都会参照ISS、RCV.WND、SND.NXT等参数。
五、关闭连接
关闭连接由客户端主动发起。当服务器的recv()方法取到的数据为0时,说明客户端主动调用close()关闭连接。双方关闭连接流程如下:
客户端主动调用close(),向对端发送FIN报文,此时客户端的状态由ESTABLISHED转变为FIN-EAIT-1;处于ESTABLISHED状态的服务器收到FIN报文,被动关闭转变为CLOSE-WAIT状态。此时代表客户端向服务器发送的数据全部发送完毕,服务器向客户端回送ACK报文,客户端此时转变为FIN-WAIT-2状态;客户端在该状态下持续接收服务器可能传来的数据,直到收到服务器的FIN报文,此时客户端状态转变为TIME-WAIT,并向服务器发送回应报文。至此服务器连接关闭,客户端还需等待两个最长报文时间关闭连接。
可以简单地将客户端(A)和服务器(B)类比为两个相互说话的人。在一番交谈之后,A主动向B说:“我想要结束谈话”;B听到A的提议,回应A:“好的,可能我还有一些话要讲”;A听到B的回应,得知B可能还有些话要讲持续等待;等到B讲完了所有的话,也主动向A说:“我讲完了”,并等待A的确认;A收到了B的主动结束,发起回应,但避免B突然临时有话要说,仍然等待一段时间;最后B收到回应进入关闭,A在经过等待时间后也关闭对话。
最后,在实际工程中并不是所有情况都按照标准流程。很有可能双方同时发送SYN报文发起连接,或者双方同时关闭连接。下面附上TCP连接的状态机,清晰地展示了TCP连接中可能出现的情况。

问题与解决方案
在TCP的数据收发阶段,会出现频繁的用户态与内核态的切换,显著增加系统的性开销,有没有什么办法可以解决?
- 批量IO操作:通过writev()/readv()一次性处理多个缓冲区,积累到一定数量后再发送。
- 内核旁路技术:使用DPDK、XDP等框架,绕过内核中的TCP/IP解析协议栈,直接再用户态读取处理数据。
总结
本文详细介绍了Linux操作系统中,网络通信的常用API。介绍了TCP协议中创建连接、传输数据、关闭连接所调用的POSIX API,以及相应的文件描述符与TCP控制块的参数设置。在最后,针对Linux操作系统收发数据导致的用户态内核态频繁切换问题提出了部分解决方案。
相关链接:https://github.com/0voice