《TCP/UDP网络编程全攻略:从基础协议到并发服务器的理论与实现》
ps:并发的实现将会放在下一章,本章主要聚焦于理论与流程
TCP特点:
面向连接的,可靠的,基于字节流的(连续的字节序列,没有消息边界。),需要三次握手来建立连接(客户端先发起请求、服务器应答、客户端应答,再进行发送数据),从而开始收发数据。还需要通过四次挥手来断开连接,确保所有未完成的数据传输都能顺利完成。并通过滑动窗口实现流量控制。
-
具体来说,客户端调用
connect()向服务器发送SYN包(第一次握手),服务器收到这个请求后,如果当前处于监听状态,则会响应SYN-ACK(第二次握手)。之后,客户端再回复ACK确认(第三次握手),完成三次握手过程。此时,服务器才会从accept()返回,表示连接已经建立成功,并准备好进行数据传输。三次握手的是SYN包,客户端请求、服务器确认、客户端再次确认。 -
四次挥手的话是FIN包,客户端发起终止请求、服务器发送确认、服务器发送终止请求、客户端确认。
-
滑动窗口理解:简单来说,滑动窗口就像是接收方递给发送方的一把“尺子”。这把尺子的长度(窗口大小)由接收方根据自己的“消化能力”(缓冲区空间)来决定。发送方只能在这把尺子量出来的范围内发送数据。当接收方“消化”掉一部分数据后,就把尺子往前挪一挪(滑动),并可能加长一点(增大窗口),让发送方可以继续发送。
UDP特定:
无连接,这意味着可以直接收发数据。是不可靠传输,无法确定按序抵达,无法确认数据有无丢失,基于报文(固定长度,发送的数据都会作为一个独立的“数据报”传输),没有流量控制(不会控制发送速率)和没有拥塞控制(不会因为网络拥塞而减少数据发送量)。支持广播(无差别范围攻击)与多播(有针对性的多个单位)。
简易理解:DDoS 攻击就是基于udp的特点,攻击者使用原始套接字(raw socket) 构造 UDP 数据包,伪造源 IP 地址为目标受害者的 IP,并将小型 DNS 查询请求发送到多个开放的 DNS 递归服务器。这些服务器收到请求后,会向伪造的源 IP(即受害者)返回较大的响应数据。由于响应大小远大于请求(放大效应),攻击者可以用较小的带宽消耗,导致受害者遭受巨大的入站流量冲击,最终服务瘫痪。

linux下网络通信:
基于 Socket 层的 TCP 通信流程
服务器的accept与客户端的connect就是tcp面向连接的具象化。
服务端
-
创建套接字:
socket() -
定义地址结构:
sockaddr_in -
绑定地址:
bind() -
监听连接请求:
listen() -
接受连接:服务器使用
accept() -
发送数据:
send()或write() -
接收数据:
recv()或read()OS内核会有收发缓冲区 -
关闭套接字:
close()
客户端
-
创建套接字:
socket() -
定义地址结构体:
sockaddr_in -
发起连接:客户端使用
connect(),connect优先于accept触发,因为服务器是通过listen所有connect情况并限制connect数量。最后才进行accept,accept后才可收发数据。调用connect后os内核会自动分配本地内核,从而无需bind。 -
发送数据:
send()或write() -
接收数据:
recv()或read() -
关闭套接字:
close()
没有 Socket 层的 TCP 通信

基于 Socket 层的 UDP 通信流程 少了accept和connect(与tcp相比)
服务端
-
创建套接字:
socket()-
使用
AF_INET和SOCK_DGRAM创建一个 UDP 套接字。
-
-
定义地址结构:
sockaddr_in-
定义并初始化服务器的 IP 地址和端口号。
-
-
绑定地址:
bind()-
将套接字与指定的 IP 地址和端口号绑定。
-
-
接收数据:
recvfrom()或read()-
使用
recvfrom()接收来自客户端的数据,并获取发送方的地址信息。
-
-
发送数据:
sendto()或write()-
使用
sendto()向特定的客户端发送数据,需要指定目标地址。
-
-
关闭套接字:
close()-
关闭套接字,释放资源。
-
客户端
-
创建套接字:
socket()-
使用
AF_INET和SOCK_DGRAM创建一个 UDP 套接字。
-
-
定义地址结构体:
sockaddr_in-
定义并初始化服务器的 IP 地址和端口号。
-
-
发送数据:
sendto()或write()sendto触发自动绑定本地套接字-
使用
sendto()向服务器发送数据,需要指定目标地址。
-
-
接收数据:
recvfrom()或read()recvfrom()不会触发自动绑定-
使用
recvfrom()接收来自服务器的数据,并获取发送方的地址信息。
-
-
关闭套接字:
close()-
关闭套接字,释放资源。
-
没有 Socket 层的 UDP 通信
在没有 Socket 层的情况下,你需要自己实现 UDP 协议栈的核心功能,包括:
| 功能 | 描述 |
|---|---|
| 构造 UDP 数据包头 | 包括源端口、目的端口、长度、校验和等字段 |
| 构造 IP 数据包头 | 包括源 IP、目标 IP、协议类型、校验和等 |
| 校验和计算 | 每个协议层都要做校验和验证,否则数据包会被丢弃 |
| 发送数据包 | 需要通过系统调用(如 sendto)配合 Raw Socket 来发送原始数据包 |
| 接收数据包 | 需要通过系统调用(如 recvfrom)配合 Raw Socket 来接收原始数据包 |
tcp与udp客户端的bind(linux)
已知无论是哪个协议,服务器的套接字都会由bind来进行地址结构体的绑定。 而客户端一般不会显式调用bind,但是客户端的套接字实际也是需要绑定地址结构体的,两种协议在客户端的绑定过程和触发时机不同,但均由内核来进行套接字与地址结构体的绑定。
tcp是面向连接的,所以客户端有显示的connect,这就是面向连接的具象化,并且客户端的套接字绑定地址其实隐式的在该函数中触发。 触发时机:在tcp中,用户端的clientsock的地址会在connect函数时由内核分配。TCP客户端的套接字在未显式绑定本地端口时,connect()函数将会立即分配一个本地端口,让客户端套接字与该地址绑定,这个行为是隐式的,由内核完成,在这之后,会触发三次握手,让客户端套接字与服务器套接字进行通信。并且其端口分配机制与UDP的connect不同,其允许复用TIME_WAIT端口(取决于系统配置)。connect隐式绑定了客户端套接字的地址的时候就会一直持续到关闭。timewait是为了让地址无法马上被复用。
UDP是无连接的,所以不会有connect,当客户端调用sendto时,如果套接字未绑定(即未显示调用bind函数时),内核会自动分配一个本地端口和IP。一旦分配后,这个本地地址就会被固定,后续的sendto不会重新分配(调用多次sendto进行通信,内核只会在第一次的时候将套接字与地址结构体绑定,后续的sendto只会单纯通信),所以不论多少次sendto,客户端套接字结构体只会绑定一次地址直到套接字关闭。 触发时机:在udp中,用户端的clientsock的地址会根据sendto函数中的servaddr由内核去分配。(与recvfrom无关)
timewait是为了让地址无法马上被复用。作用是为了保证4次挥手!!(可靠关闭与数据隔离)

//tcp 服务器
socket();
sockaddr_in
bind()
listen()
accept()
send/write()
recv/read()
close()
//tcp 客户端
socket();
sockaddr_in
connect() //内核分配地址
send/write()
recv/read()
close()
//udp 服务器
socket();
sockaddr_in
bind()
recvfrom()
sendto()
close()
//udp 客户端
socket();
sockaddr_in
sendto() //内核分配地址
recvfrom()
close()
TCP服务器套接字创建流程与意义
socket();
// 创建监听套接字,参数说明:
// 1. domain: 告知是选择 IPv4 (AF_INET) 还是 IPv6 (AF_INET6) 的地址族。
// 2. type: 告知是选择 TCP (SOCK_STREAM) 还是 UDP (SOCK_DGRAM) 协议。
// 3. protocol: 通常为 0。本意是告知选取了哪个具体协议,但由于前两个参数已唯一确定协议(如 AF_INET + SOCK_STREAM = TCP),所以填 0 即可,由系统自动选择。
struct sockaddr_in addr;
// 创建 IPv4 地址结构体。有了这个结构体,才能将套接字绑定到一个具体的网络地址上。
// 结构体成员:
// addr.sin_family = AF_INET; // 地址族:IPv4
// addr.sin_port = htons(8080); // 端口号:8080 (需转换为网络字节序)
// addr.sin_addr.s_addr = inet_addr("192.168.1.100"); // IP地址 (转换为网络字节序)
bind();
// 绑定套接字,参数:
// 1. sockfd: 要绑定的套接字描述符。
// 2. addr: 指向 sockaddr_in 结构体的指针(需强制转换为 struct sockaddr*)。
// 3. addrlen: 地址结构体的长度 (sizeof(addr))。
// 作用:将套接字与一个本地网络地址(IP + Port)关联起来。此后,客户端可以通过 "192.168.1.100:8080" 访问该服务器。
listen();
// 将套接字设置为监听状态,参数:
// 1. sockfd: 监听套接字描述符。
// 2. backlog: 监听队列的大小,即等待 accept() 处理的已完成三次握手的连接请求的最大数量。
// 作用:使服务器开始被动等待客户端的连接请求。
accept();
// 接受一个客户端的连接请求,参数:
// 1. sockfd: 监听套接字描述符。
// 2. cliaddr: 指向客户端地址结构体 (struct sockaddr_in*) 的指针,用于获取客户端的 IP 和端口(可选,可传 NULL)。
// 3. addrlen: 客户端地址结构体长度的指针(可选,可传 NULL)。
// 返回值:一个新的套接字描述符,这个套接字是**专用于与该特定客户端进行通信的**。
// 说明:监听套接字 (sockfd) 本身不用于数据传输,它只负责接收新的连接请求。
// 总结:TCP 通信中涉及的套接字:
// 1. 服务器监听套接字:用于监听和接受新连接。
// 2. 服务器通信套接字:由 accept() 返回,用于与一个特定的客户端进行数据收发。 由这个套接字进行通信
// 3. 客户端套接字:客户端调用 socket() 创建的套接字。
send() / write();
// 通过通信套接字向对方发送数据。
recv() / read();
// 通过通信套接字从对方接收数据。
close();
// 关闭套接字。通常,服务器需要关闭每个与客户端通信的套接字,最后关闭监听套接字。
TCP客户端套接字创建流程与含义
socket();
// 创建客户端套接字,参数同上。例如:socket(AF_INET, SOCK_STREAM, 0); 创建 TCP 套接字。
struct sockaddr_in serv_addr;
// 创建服务器地址结构体,用于指定要连接的服务器地址。
// 成员设置同服务器端:
// serv_addr.sin_family = AF_INET;
// serv_addr.sin_port = htons(8080); // 服务器的端口号
// serv_addr.sin_addr.s_addr = inet_addr("192.168.1.100"); // 服务器的 IP 地址
connect();
// 客户端发起连接请求,参数:
// 1. sockfd: 客户端套接字描述符。
// 2. serv_addr: 指向服务器地址结构体的指针(需强制转换为 struct sockaddr*)。
// 3. addrlen: 服务器地址结构体的长度 (sizeof(serv_addr))。
// 作用:客户端通过此函数向服务器的监听套接字发起连接(触发三次握手)。
// 注意:客户端通常不需要调用 bind()。客户端的本地 IP 和端口由操作系统内核在 connect() 时自动分配(称为临时端口或 ephemeral port)。
//与服务器不同,客户端的套接字没有显示的设置自己的结构体地址,而是由内核分配,客户端只需要设置服务器的监听套接字同款的地址结构体。
send() / write();
// 通过客户端套接字向服务器发送数据。
recv() / read();
// 通过客户端套接字从服务器接收数据。
close();
// 关闭客户端套接字。
UDP服务器套接字创建流程与含义
socket();
// 创建套接字,参数说明:
// 1. domain: 告知是选择 IPv4 (AF_INET) 还是 IPv6 (AF_INET6) 的地址族。
// 2. type: 告知是选择 UDP 协议 (SOCK_DGRAM)。
// 3. protocol: 通常为 0。因为 AF_INET + SOCK_DGRAM 已唯一确定使用 UDP 协议,所以填 0 即可。
struct sockaddr_in addr;
// 创建 IPv4 地址结构体,用于绑定服务器的监听地址。
// 结构体成员:
// addr.sin_family = AF_INET; // 地址族:IPv4
// addr.sin_port = htons(8080); // 端口号:8080 (需转换为网络字节序)
// addr.sin_addr.s_addr = inet_addr("192.168.1.100"); // IP地址 (转换为网络字节序)
// // 也可以使用 INADDR_ANY (0.0.0.0),表示绑定到机器的所有网络接口。
bind();
// 绑定套接字,参数:
// 1. sockfd: 要绑定的套接字描述符。
// 2. addr: 指向 sockaddr_in 结构体的指针(需强制转换为 struct sockaddr*)。
// 3. addrlen: 地址结构体的长度 (sizeof(addr))。
// 作用:将 UDP 套接字与一个本地网络地址(IP + Port)关联起来。此后,客户端可以向 "192.168.1.100:8080" 发送数据包。
recvfrom();
// 接收数据包并获取发送方地址,参数:
// 1. sockfd: 绑定的套接字描述符。
// 2. buf: 指向接收缓冲区的指针。
// 3. len: 缓冲区大小。
// 4. flags: 通常为 0。
// 5. src_addr: 指向客户端地址结构体 (struct sockaddr_in*) 的指针,用于获取发送方的 IP 和端口。
// 6. addrlen: 指向客户端地址结构体长度的指针 (初始值为 sizeof(struct sockaddr_in))。
// 作用:从任意客户端接收一个数据报,并记录下该客户端的地址信息。这是 UDP 通信的核心接收函数。
sendto();
// 向指定地址发送数据包,参数:
// 1. sockfd: 套接字描述符。
// 2. buf: 指向要发送数据的指针。
// 3. len: 数据长度。
// 4. flags: 通常为 0。
// 5. dest_addr: 指向目标地址结构体 (struct sockaddr_in*) 的指针(即客户端地址,来自 recvfrom)。
// 6. addrlen: 目标地址结构体的长度 (sizeof(struct sockaddr_in))。
// 作用:向特定的客户端(通过其地址)发送一个数据报。
close();
// 关闭套接字。
UDP客户端套接字创建流程与含义
socket();
// 创建客户端套接字,参数同上。例如:socket(AF_INET, SOCK_DGRAM, 0); 创建 UDP 套接字。
struct sockaddr_in serv_addr;
// 创建服务器地址结构体,用于指定要发送数据的目标服务器。
// 成员设置:
// serv_addr.sin_family = AF_INET;
// serv_addr.sin_port = htons(8080); // 服务器的端口号
// serv_addr.sin_addr.s_addr = inet_addr("192.168.1.100"); // 服务器的 IP 地址
// 注意:UDP 客户端通常不需要调用 connect(),但也可以调用。
// 如果调用 connect(),则可以将套接字与一个特定服务器“关联”起来,之后就可以使用 send()/recv() 而不是 sendto()/recvfrom()。
sendto();
// 向服务器发送数据包,参数:
// 1. sockfd: 客户端套接字描述符。
// 2. buf: 指向要发送数据的指针。
// 3. len: 数据长度。
// 4. flags: 通常为 0。
// 5. dest_addr: 指向服务器地址结构体 (struct sockaddr_in*) 的指针。
// 6. addrlen: 服务器地址结构体的长度 (sizeof(serv_addr))。
// 作用:将数据报发送到指定的服务器地址。
recvfrom();
// 接收来自服务器的响应数据包,参数:
// 1. sockfd: 客户端套接字描述符。
// 2. buf: 指向接收缓冲区的指针。
// 3. len: 缓冲区大小。
// 4. flags: 通常为 0。
// 5. src_addr: (可选) 指向服务器地址结构体的指针,用于验证数据来源。
// 6. addrlen: (可选) 服务器地址结构体长度的指针。
// 作用:接收服务器的响应。由于 UDP 是无连接的,需要 recvfrom() 来确认数据来源。
// 可选:如果之前调用了 connect(serv_addr),则可以使用:
// recv(); // 等价于 recvfrom(),但不需要指定地址。
// send(); // 等价于 sendto(),但不需要指定地址。
close();
// 关闭客户端套接字。
读写函数(linux)
| 函数 | TCP | UDP | 是否异步? | 特点 |
|---|---|---|---|---|
read() / write() | ✅ 可用 | ⚠️ 不推荐 | ❌ 同步 | 简单易用,适用于 TCP,UDP 中缺乏地址信息支持 |
recv() / send() | ✅ 推荐 | ⚠️ 需要使用 connect() | ❌ 同步 | TCP 标准接口,UDP 中可用但需先调用 connect() |
recvfrom() / sendto() | ⚠️ 可用 | ✅ 推荐 | ❌ 同步 | 适用于无连接的 UDP,可指定/获取目标地址 |
recvmsg() / sendmsg() | ✅ 推荐 | ✅ 推荐 | ❌ 同步 | 功能最强大,支持多缓冲区、辅助数据(如控制消息) |
readv() / writev() | ✅ 可用 | ⚠️ 少用 | ❌ 同步 | 支持分散/聚集 I/O,适用于 TCP 或已连接 UDP |
splice() / tee() | ✅ 高效零拷贝 | ❌ 不适用 | ❌ 同步 | 常用于高性能 TCP 数据传输,需配合管道使用 |
mmap() | ⚠️ 特殊用途 | ❌ 不适用 | ❌ 同步 | 可将 socket 映射为内存(较少见) |
io_uring 相关接口(现代异步) | ✅ 推荐 | ✅ 推荐 | ✅ 异步(现代 AIO) | Linux 最新高效的异步 I/O 框架,性能极佳 |
aio_read() / aio_write() | ⚠️ 有限支持 | ⚠️ 有限支持 | ✅ 异步(传统 POSIX AIO) | POSIX 异步 I/O,对 socket 支持较弱 |
tcp:一般用send和recv
udp:一般用sendto和recvfrom
-
上述两者均可以用write和read,
read()/write()是 POSIX 标准的通用文件 I/O 函数,适用于所有“文件描述符”(包括普通文件、管道、socket 等)Linux一切皆文件,所以这个是无敌的。
-
但还是建议用send与recv系列,
send()/recv()系列是 专为 socket 设计的函数,提供了更多网络通信相关的控制选项(如 flags、地址信息等)。 -
recvmsg()/sendmsg()功能最全,但也更为复杂: 
Windows 下网络通信:
Windows 使用 Winsock API(Windows Sockets) 实现网络通信,其接口设计与 Linux 非常相似,但也有一些关键区别,比如初始化步骤、错误处理方式、部分函数命名等。
基于 Winsock 层的 TCP 通信流程
服务端
-
初始化 Winsock 库
WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); // 初始化 Winsock 2.2
-
创建套接字:
socket()SOCKET sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
-
定义地址结构:
sockaddr_in -
绑定地址:
bind() -
监听连接请求:
listen() -
接受连接:
accept() -
发送数据:
send()或WSASend()-
WSASend()支持重叠 I/O(异步操作)
-
-
接收数据:
recv()或WSARecv() -
关闭套接字:
closesocket() -
清理 Winsock库
WSACleanup();
客户端
-
初始化 Winsock 库
WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); // 初始化 Winsock 2.2
-
创建套接字:
socket() -
定义地址结构体:
sockaddr_in -
发起连接:
connect()-
调用后 OS 内核会自动分配本地端口,无需手动调用
bind()。
-
-
发送数据:
send()/WSASend() -
接收数据:
recv()/WSARecv() -
关闭套接字:
closesocket() -
清理 Winsock库
WSACleanup();
没有 Winsock 层的 TCP 通信(不常见)
-
在 Windows 中几乎不可能绕过 Winsock 直接操作 TCP/IP 协议栈。
-
如果需要底层控制,可以使用 NDIS(Network Driver Interface Specification) 或开发驱动程序。
-
更常见的“伪原始”操作是通过 Raw Socket(需管理员权限),但功能受限:
-
只能构造 IP/TCP 头部,不能完全控制协议栈;
-
Windows Vista 后限制较多,不推荐用于普通应用。
-
基于 Winsock 层的 UDP 通信流程
服务端
-
初始化 Winsock 库
WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); // 初始化 Winsock 2.2
-
创建套接字:
socket()SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
-
定义地址结构:
sockaddr_in -
绑定地址:
bind() -
接收数据:
recvfrom()或WSARecvFrom()-
推荐使用
recvfrom()获取发送方地址信息
-
-
发送数据:
sendto()或WSASendTo() -
关闭套接字:
closesocket() -
清理 Winsock库
WSACleanup();
客户端
-
初始化 Winsock 库
WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); // 初始化 Winsock 2.2
-
创建套接字:
socket() -
定义地址结构体:
sockaddr_in -
发送数据:
sendto()-
第一次调用
sendto()时,如果未调用bind(),系统内核会自动分配一个本地端口并绑定。
-
-
接收数据:
recvfrom()-
不会触发本地地址绑定
-
-
关闭套接字:
closesocket() -
清理 Winsock库
WSACleanup();
没有 Winsock 层的 UDP 通信(不常见)
-
类似 TCP,无法真正绕过 Winsock;
-
若想操作原始 UDP 数据包,可使用 Raw Socket(需要管理员权限);
-
手动构造 UDP 数据包头、IP 数据包头,并计算校验和;
-
使用
sendto()发送原始数据包; -
使用
recvfrom()接收原始数据包;
⚠️ 注意:Windows 对 Raw Socket 的支持非常有限,仅允许构造 ICMP、UDP、TCP 等特定类型的包,且某些版本禁止构造 TCP 包。
TCP 与 UDP 客户端的 bind 行为(Windows)
与linux中情况一致。都是用connect与sendto来自动绑定本地套接字
| 协议 | 自动绑定时机 | 是否固定地址 | 允许复用 TIME_WAIT |
|---|---|---|---|
| TCP | connect() | ✅ 是 | ⚠️ 取决于 SO_REUSEADDR |
| UDP | sendto() | ✅ 是 | ✅ 可复用(默认) |
-
TCP 客户端:在调用
connect()时由系统自动分配本地端口; -
UDP 客户端:第一次调用
sendto()时由系统自动绑定本地端口; -
一旦绑定,后续操作不会再重新绑定;
-
可以通过
bind()显式指定本地端口。
读写函数(Windows)
| 函数 | TCP | UDP | 特点 |
|---|---|---|---|
send() / recv() | ✅ 推荐 | ❌ 必须先 connect | |
sendto() / recvfrom() | ⚠️ 可用 | ✅ 推荐 | |
WSASend() / WSARecv() | ✅ 异步/重叠 I/O | ⚠️ 可用于 UDP | |
WSASendTo() / WSARecvFrom() | ⚠️ | ✅ 异步 UDP | |
write() / read() | ✅ 可用 | ❌ 不可用(Windows 不推荐) |
⚠️ 在 Windows 上,虽然
write()和read()可用于 TCP 套接字(因为它们继承自 POSIX 兼容层),但在 UDP 中不建议使用,因为缺乏地址信息支持。
总结对比表:Linux vs Windows
| 功能 | Linux | Windows |
|---|---|---|
| 初始化网络库 | 不需要 | WSAStartup() / WSACleanup() |
| 错误码获取 | errno | WSAGetLastError() |
| 关闭套接字 | close() | closesocket() |
| 通用读写 | read() / write() | read() / write()(仅限 TCP) |
| 异步 I/O | send() / recv() + epoll/select | WSASend() / WSARecv() + IOCP |
| Raw Socket 权限 | root | 管理员 |
| Raw Socket 支持 | 较强 | 有限制 |
SOCKET的阻塞与非阻塞
Linux 下 socket 情况说明
默认行为:
-
通过
socket()创建的 socket 默认是阻塞模式(blocking)。 -
所有与之相关的 I/O 函数(如
read(),write(),send(),recv()等)在没有数据或缓冲区满时会 阻塞当前线程,直到操作完成或出错。
如何设置为非阻塞?
使用 fcntl() 设置 socket 标志位:
int flags = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 设置为非阻塞
非阻塞行为表现:
-
如果调用
read()或recv()但当前没有数据可读,函数立即返回-1,并设置errno == EAGAIN或EWOULDBLOCK。 -
如果调用
write()或send()但发送缓冲区已满,函数也立即返回-1,并设置errno == EAGAIN。
常见配合使用机制:
-
多路复用模型:
epoll,poll,select -
异步模型(现代):
io_uring -
异步模型(传统):POSIX AIO(对 socket 支持有限)
Windows 下 socket 情况说明
默认行为:
-
使用
socket()创建的 socket 默认也是阻塞模式(blocking)。 -
所有 I/O 函数(如
recv(),send(),ReadFile()等)在无数据或缓冲区满时也会 阻塞当前线程。
如何设置为非阻塞?
使用 ioctlsocket() 设置非阻塞标志:
u_long nb = 1; ioctlsocket(sock, FIONBIO, &nb); // 设置为非阻塞
非阻塞行为表现:
-
如果调用
recv()或WSARecv()但当前没有数据可读,函数立即返回SOCKET_ERROR,并设置WSAGetLastError() == WSAEWOULDBLOCK。 -
类似地,
send()在缓冲区满时也立即返回错误码。
常见配合使用机制:
-
多路复用模型:
select,WSAPoll -
异步模型(基于事件):
WSAAsyncSelect -
异步模型(基于重叠结构体):
WSARecv,WSASend+OVERLAPPED -
最强大异步模型:完成端口(I/O Completion Ports, IOCP)
Linux vs Windows Socket 对照表
| 对比维度 | Linux | Windows |
|---|---|---|
| 默认 socket 模式 | 阻塞 | 阻塞 |
| 是否需要显式设置非阻塞 | ✅ 是(fcntl(fd, F_SETFL, O_NONBLOCK)) | ✅ 是(ioctlsocket(sock, FIONBIO, &nb)) |
| 非阻塞时 I/O 返回值 | -1,errno=EAGAIN / EWOULDBLOCK | SOCKET_ERROR,WSAGetLastError=WSAEWOULDBLOCK |
| 常用多路复用模型 | epoll, poll, select | select, WSAPoll |
| 异步 I/O 接口 | io_uring, aio_read(有限) | WSARecv, WSASend + IOCP |
| 是否支持零拷贝异步 I/O | ✅ 支持 io_uring | ❌ 不直接支持 |
| 文件描述符类型 | int | SOCKET(本质是句柄) |
总结一句话:
Linux 和 Windows 下 socket 默认都是阻塞的,都需要显式设置才能进入非阻塞模式。虽然两者接口不同,但设计思想相似:非阻塞行为由 socket 模式决定,而不是 I/O 函数本身;选择不同的 I/O 模型(同步、异步、多路复用)决定了程序的并发能力和性能表现。
并发服务器(从单线程开始)
服务器与客户端在单线程模式下同一时刻只能进行一对一通信。若要实现一对多的并发通信,通常可以采用多进程或多线程模型,也可以使用 I/O 多路复用(I/O Multiplexing)技术,也可以采用异步IO方式。
之所以只能一对一通信,是因为像 accept() 这样的监听函数,以及 send()/sendto()、recv()/recvfrom() 等通信函数,在没有数据到达或无法立即完成操作时,默认都会进入阻塞状态。
以 accept() 为例,操作系统为其维护了一个输入缓冲区。当没有新的客户端连接到来时,该缓冲区为空,调用 accept() 的线程会进入阻塞状态,直到有新连接到达、缓冲区变为非空,程序才会继续执行。
accept 和阻塞 :
阻塞模式:这是 socket 的默认行为。当没有新连接到达时,accept() 会阻塞当前线程,直到有新连接到来。
非阻塞模式:可以通过 fcntl(fd, F_SETFL, O_NONBLOCK) 将监听 socket 设置为非阻塞模式。此时,如果没有连接到达,accept() 会立即返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK,表示“当前无连接可接受”。
因此,在一个线程(或进程)中,由于 accept()、recv() 等函数是阻塞的(源头是socket是阻塞状态),程序(该线程)在同一时刻只能处理一个客户端的连接和通信请求,其他客户端必须等待当前请求完成后才能被服务。
引入多线程/多进程模型 :
为了提高并发处理能力,可以引入多线程或多进程模型来分别处理不同的客户端连接。 并发:在同一时间段内处理多个任务的能力。即处理多个客户端的连接,即accept返回的通信socketfd
多线程核心思想是:为每个新连接创建独立的线程来负责处理通信任务(一个线程对应一个客户端连接),各线程之间互不干扰,从而避免主线程因(阻塞)等待某个连接的数据而影响其他连接的响应速度。 服务器用多个线程/进程 避免因为阻塞函数等待 如果等待 则只是一对一。
弊端:
然而,为每个连接创建独立线程或进程的方式虽然提高了并发能力,但也带来了较大的系统资源消耗和上下文切换开销。因此,对于高并发场景,这种方式并不理想。
引入 I/O 复用技术:
于是,我们引入了 I/O 多路复用(I/O Multiplexing)技术——它允许程序同时监控多个文件描述符(如 socket),并等待其中任意一个进入可操作状态(如可读、可写等)。这与传统的“串行式”处理方式不同:后者一次只能监听一个 socket,必须完成当前连接的所有操作后才能处理下一个。 I/O 复用特别适用于需要同时管理多个网络连接的服务器程序,因为它可以在单个线程中高效地处理成百上千个并发连接,显著提升性能。
I/O复用技术可以让单个进程或线程能够同时监控多个 I/O 流。它允许程序在多个文件描述符(如 socket)上等待事件发生(比如数据到达、可以发送等),而不需要为每个连接创建独立的线程或进程。这与传统的“阻塞式串行处理”不同:以前必须在一个连接完成全部读写操作后,才能处理下一个连接;而 I/O 复用则可以在多个连接之间高效切换,仅在有数据可读或可写时才进行处理。
IO复用,核心思想就是:一个线程、进程,能处理多个客户端连接。
io复用下:同时存在多个连接,谁有读写情况就处理谁。
传统方法:同时只能存在一个连接,当它的内容处理完,关闭以后,才能有下一个连接,并再次为之处理,循环往复。
引入异步 I/O 模型:
windows的iocp,linux的io_uring。
参考:
0voice·Github









