Socket 基础
本文以 POSIX Socket API 为主,适用于 Linux、macOS 等 Unix-like 系统。Windows 使用 Winsock,函数名大多相同,但需要 WSAStartup() / closesocket() 等额外处理。
什么是 Socket
Socket(套接字)是操作系统提供给应用程序的网络通信接口。对程序而言,它通常以文件描述符(int fd)的形式存在,因此可以创建、读写、关闭。
对于一条已经建立的 TCP 连接,通常用以下四元组唯一标识:
例如,客户端 192.168.1.100:50000 连接服务器 192.168.1.10:8080。即使两个客户端连接同一台服务器的同一端口,它们的源端口不同,仍是两条不同的连接。
常见 Socket 类型:
| 类型 |
特性 |
常见协议 |
SOCK_STREAM |
面向连接、可靠、有序的字节流 |
TCP |
SOCK_DGRAM |
无连接、尽力而为、保留数据报边界 |
UDP |
SOCK_RAW |
原始套接字,直接处理更底层的报文,通常需要高权限 |
IP / ICMP 等 |
TCP 保证字节可靠、有序到达,但不保证应用层消息边界;UDP 保留一条数据报的边界,但可能丢包、乱序或重复。
TCP 与 UDP 的通信流程
TCP 服务端和客户端的典型流程:
1 2
| 服务端:socket → setsockopt(可选)→ bind → listen → accept → recv/send → close 客户端:socket → connect → recv/send → close
|
1 2 3 4 5 6 7 8 9
| 客户端 服务端 | | |------------- connect -------------->| | accept | |------------- send ----------------->| | recv | |<------------ send / recv -----------| | | |------------- close ---------------->|
|
listen_fd 只用于监听和 accept();每次 accept() 成功都会返回一个新的 client_fd,它才用于和该客户端收发数据。
UDP 没有连接建立过程,典型流程为:
1
| UDP:socket → bind(接收方通常需要)→ sendto/recvfrom → close
|
UDP 客户端也可以先 connect() 固定默认对端;这不会建立 TCP 那样的连接,只是让之后可以使用 send() / recv(),并过滤非该对端的数据报。
开始前:头文件、地址结构与字节序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| #include <arpa/inet.h> #include <cerrno> #include <cstddef> #include <cstdint> #include <cstdio> #include <cstring> #include <iostream> #include <fcntl.h> #include <netdb.h> #include <netinet/in.h> #include <netinet/tcp.h> #include <sys/socket.h> #include <sys/time.h> #include <sys/types.h> #include <unistd.h>
|
核心类型和常量:
| 名称 |
含义 |
AF_INET / AF_INET6 |
IPv4 / IPv6 地址族 |
AF_UNIX(或 AF_LOCAL) |
本机进程间通信地址族 |
sockaddr |
通用地址结构,作为 API 参数类型 |
sockaddr_in / sockaddr_in6 |
IPv4 / IPv6 的实际地址结构 |
IPPROTO_TCP / IPPROTO_UDP |
TCP / UDP 协议号 |
fd |
Socket 文件描述符,失败通常为 -1 |
IPv4 地址通常这样初始化:
1 2 3 4 5 6
| sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(8080); if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) { }
|
网络协议使用大端序(网络字节序)。端口和自定义协议中的多字节整数应转换:
| 函数 |
作用 |
htons() / ntohs() |
16 位主机序 ↔ 网络序;常用于端口 |
htonl() / ntohl() |
32 位主机序 ↔ 网络序;常用于长度字段 |
1 2
| std::uint32_t length = 1024; std::uint32_t network_length = htonl(length);
|
INADDR_ANY(即 0.0.0.0)表示监听本机所有 IPv4 网卡:
1
| addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
若只希望本机访问,绑定 127.0.0.1;若绑定某个具体 IP,该 IP 必须配置在本机网卡上。端口 0 表示让系统选择可用的临时端口,可再通过 getsockname() 查询实际端口。
常用 API
socket():创建套接字
1 2 3 4 5 6
| int socket(int domain, int type, int protocol);
int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd == -1) { perror("socket"); }
|
domain 指定地址族,type 指定通信语义,protocol 通常传 0,由系统根据前两项选择默认协议。成功时返回文件描述符,失败时返回 -1 并设置 errno。
参数说明:
| 参数 |
说明 |
常用值 |
domain |
地址族,决定地址的表示方式 |
AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本机 IPC) |
type |
套接字通信语义 |
SOCK_STREAM(TCP 字节流)、SOCK_DGRAM(UDP 数据报) |
protocol |
具体协议号 |
通常为 0;也可显式传 IPPROTO_TCP 或 IPPROTO_UDP |
domain 必须和后续地址结构的 sin_family / sin6_family 一致。例如创建 AF_INET socket 后,应传入 sockaddr_in,并将 sin_family 设为 AF_INET。
setsockopt():配置选项
1 2
| int setsockopt(int sockfd, int level, int optname, const void* optval, socklen_t optlen);
|
常用选项:
| 选项 |
层级 |
作用与注意事项 |
SO_REUSEADDR |
SOL_SOCKET |
通常在 bind() 前设置,便于服务端重启后重新绑定;不同系统语义略有差异 |
SO_REUSEPORT |
SOL_SOCKET |
多个监听 socket 共享端口、内核分流;平台差异较大 |
SO_RCVTIMEO / SO_SNDTIMEO |
SOL_SOCKET |
设置收发等待超时;超时后常见 EAGAIN / EWOULDBLOCK |
SO_KEEPALIVE |
SOL_SOCKET |
启用 TCP 保活;默认探测周期通常很长 |
TCP_NODELAY |
IPPROTO_TCP |
关闭 Nagle 算法,可能降低小包延迟,也可能降低吞吐效率 |
1 2 3 4
| int enabled = 1; if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == -1) { perror("setsockopt SO_REUSEADDR"); }
|
不要把 SO_REUSEADDR 理解为“多个程序可以任意抢占同一地址端口”。TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 等细化保活选项在 Linux 常见,但不具备完全可移植性。
参数说明:
| 参数 |
说明 |
sockfd |
要配置的 socket。与绑定相关的选项通常应在 bind() 前设置。 |
level |
选项所属层级:通用 socket 选项使用 SOL_SOCKET,TCP 专用选项使用 IPPROTO_TCP。 |
optname |
要设置的选项名,例如 SO_REUSEADDR、SO_RCVTIMEO、TCP_NODELAY。 |
optval |
指向选项值的指针;多数开关选项传 int*,超时选项传 timeval*。 |
optlen |
optval 所指对象的字节数,例如 sizeof(enabled)。 |
例如接收超时使用 timeval:
1 2 3 4 5
| timeval timeout{}; timeout.tv_sec = 5; if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) == -1) { perror("setsockopt SO_RCVTIMEO"); }
|
bind():绑定本地地址
1 2 3 4 5 6 7 8 9 10 11
| int bind(int sockfd, const sockaddr* addr, socklen_t addrlen);
sockaddr_in server_addr{}; server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(8080);
if (bind(fd, reinterpret_cast<const sockaddr*>(&server_addr), sizeof(server_addr)) == -1) { perror("bind"); }
|
服务端通常必须在 listen() 前调用 bind();UDP 接收端也通常要绑定端口。常见错误包括:
| 错误 |
常见原因 |
EADDRINUSE |
IP:端口已被占用,或旧连接仍影响重绑 |
EACCES |
无权限绑定特权端口(通常小于 1024) |
EADDRNOTAVAIL |
要绑定的 IP 不属于本机 |
sockaddr_in 到 sockaddr* 的转换只是在适配统一 API;sizeof 应传实际地址结构的大小。
参数说明:
| 参数 |
说明 |
sockfd |
socket() 成功返回的描述符。TCP 服务端通常绑定监听 socket;UDP 接收端绑定自己的 UDP socket。 |
addr |
指向本地地址结构。IPv4 使用 sockaddr_in,调用时转换为 const sockaddr*。 |
addrlen |
addr 指向的实际地址结构大小,例如 sizeof(sockaddr_in)。 |
IPv4 的关键字段如下:
| 字段 |
说明 |
sin_family |
必须为 AF_INET。 |
sin_addr |
要绑定的本地 IPv4 地址;INADDR_ANY 表示全部网卡。 |
sin_port |
网络字节序端口,使用 htons(端口号) 设置。 |
listen():让 TCP Socket 进入监听状态
1
| int listen(int sockfd, int backlog);
|
1 2 3
| if (listen(fd, SOMAXCONN) == -1) { perror("listen"); }
|
listen() 仅用于 TCP 等面向连接的 socket。它必须在服务端完成 socket()、setsockopt()(如需要)和 bind() 后调用。
| 参数 |
说明 |
sockfd |
已成功 bind() 的 TCP socket。不能传给某个客户端通信用的 client_fd。 |
backlog |
内核中等待程序 accept() 的连接队列上限。它不是最大在线客户端数;通常使用 SOMAXCONN 或项目规定的合理值。 |
成功返回 0,失败返回 -1。服务端只需调用一次 listen(),随后可以反复调用 accept()。
accept():取出一个已建立的 TCP 连接
1
| int accept(int sockfd, sockaddr* addr, socklen_t* addrlen);
|
1 2 3 4 5 6
| sockaddr_in client_addr{}; socklen_t client_len = sizeof(client_addr); int client_fd = accept(fd, reinterpret_cast<sockaddr*>(&client_addr), &client_len); if (client_fd == -1) { perror("accept"); }
|
| 参数 |
说明 |
sockfd |
已调用过 listen() 的监听 socket。它在整个服务周期内继续用于后续 accept()。 |
addr |
输出参数,用于接收客户端地址。IPv4 时传入 sockaddr_in 的地址并转换为 sockaddr*;不关心地址时可传 nullptr。 |
addrlen |
addr 的长度指针,是输入输出参数。调用前填 sizeof(client_addr),调用后是系统实际写入的地址长度;若 addr 为 nullptr,此参数也传 nullptr。 |
成功时返回一个新的非负 client_fd;应使用这个新描述符与该客户端 send() / recv(),并在结束时 close(client_fd)。失败返回 -1。阻塞 socket 在没有连接到来时会阻塞;被信号中断时常见 errno == EINTR,通常可重试。
connect():连接服务器
1
| int connect(int sockfd, const sockaddr* addr, socklen_t addrlen);
|
对 TCP,connect() 发起三次握手;成功后可 send() / recv()。阻塞 socket 常见错误是 ECONNREFUSED(端口没有监听者)和 ETIMEDOUT(网络或防火墙导致超时)。
非阻塞 socket 上,connect() 返回 -1 且 errno == EINPROGRESS 表示仍在连接,不是最终失败。等待其可写后,通过 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 检查结果:值为 0 才代表连接成功。
| 参数 |
说明 |
sockfd |
客户端通过 socket() 创建的描述符。TCP 连接成功后,它就是通信 socket。 |
addr |
目标服务器的地址。IPv4 常用 sockaddr_in,转换成 const sockaddr* 传入。 |
addrlen |
目标地址结构大小,例如 sizeof(server_addr)。 |
成功返回 0,失败返回 -1 并设置 errno。对 UDP 调用 connect() 不会发起三次握手,只是记录默认对端和改变后续收发行为。
getsockopt():读取 Socket 选项或状态
1 2
| int getsockopt(int sockfd, int level, int optname, void* optval, socklen_t* optlen);
|
getsockopt() 的参数含义与 setsockopt() 类似,但 optval 用于接收结果,optlen 是输入输出参数:调用前填入结果缓冲区大小,调用后得到实际结果长度。
它最常见的用途是确认非阻塞 connect() 的最终结果:
1 2 3 4 5 6 7 8
| int socket_error = 0; socklen_t error_len = sizeof(socket_error); if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &error_len) == -1) { perror("getsockopt"); } else if (socket_error != 0) { errno = socket_error; perror("connect"); }
|
| 参数 |
说明 |
sockfd |
要查询的 socket。 |
level / optname |
选项所在层级和名称;上例是 SOL_SOCKET / SO_ERROR。 |
optval |
接收查询结果的可写内存地址。 |
optlen |
optval 的长度指针,调用前后分别表示容量和实际长度。 |
注意:getsockopt() 返回 0 只表示“查询成功”,还必须检查 socket_error 是否为 0。
send():向已连接的 Socket 发送数据
1
| ssize_t send(int sockfd, const void* buf, size_t len, int flags);
|
send() 用于已连接的 TCP socket:服务端通常传 accept() 返回的 client_fd,客户端传 connect() 成功后的 socket。它的返回值可能小于请求长度,因此必须循环处理。
| 返回值 |
含义 |
> 0 |
本次实际成功写入内核发送缓冲区的字节数,可能小于 len。 |
0 |
本次没有发送数据;len == 0 时允许出现。 |
-1 |
发送失败;EINTR 通常可重试,非阻塞时 EAGAIN / EWOULDBLOCK 表示暂时不可写。 |
| 参数 |
说明 |
sockfd |
已连接的通信 socket,而不是监听 socket。 |
buf |
待发送数据的内存首地址。它可以是字节数组、std::string::data() 或序列化后的二进制数据。 |
len |
本次希望发送的字节数,不自动包含字符串结尾的 \0。若协议不要求结尾符,发送 message.size() 而不是 message.size() + 1。 |
flags |
一般传 0。Linux 可用 MSG_NOSIGNAL 抑制 SIGPIPE;该标志不是所有系统都支持。 |
可靠地发送完整缓冲区:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| bool send_all(int fd, const void* data, std::size_t length) { const auto* p = static_cast<const char*>(data); std::size_t sent = 0;
while (sent < length) { const ssize_t n = send(fd, p + sent, length - sent, 0); if (n > 0) { sent += static_cast<std::size_t>(n); } else if (n == -1 && errno == EINTR) { continue; } else { return false; } } return true; }
|
在 Linux 上可使用 MSG_NOSIGNAL 防止向已关闭连接发送时触发 SIGPIPE。macOS 常见替代方案是对 socket 设置 SO_NOSIGPIPE。这两个方案都属于平台相关行为。
recv():从已连接的 Socket 接收数据
1
| ssize_t recv(int sockfd, void* buf, size_t len, int flags);
|
| 参数 |
说明 |
sockfd |
已连接的通信 socket。 |
buf |
可写的接收缓冲区。网络数据不会自动补 \0;若要作为 C 字符串使用,应预留一个字节并在收到后自行写入终止符。 |
len |
缓冲区可容纳的最大字节数。它是上限,不保证本次一定收到这么多数据。 |
flags |
一般传 0;MSG_PEEK 表示查看数据但不取走,MSG_WAITALL 尝试读满 len,容易长时间阻塞,应谨慎使用。 |
| 返回值 |
TCP 中的含义 |
> 0 |
本次收到的有效字节数;只有 buf[0] 到 buf[n - 1] 有效。 |
0 |
对端已按序关闭发送方向(收到 FIN);当前连接通常应结束读取。 |
-1 |
出错;EINTR 可重试,非阻塞时 EAGAIN / EWOULDBLOCK 表示暂时无数据。 |
1 2 3 4 5
| char buffer[4096]; const ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = '\0'; }
|
TCP 没有消息边界
客户端连续执行:
1 2
| send(fd, "hello", 5, 0); send(fd, "world", 5, 0);
|
服务端可能一次收到 helloworld,也可能分多次收到 hel 和 loworld。因此应用协议必须自行定义边界,常见方案:
- 定长消息;
- 分隔符,例如一行一条消息;
- 长度前缀,例如
[4 字节网络序长度][正文]。
若把收到的数据当作 C 字符串处理,要自行预留末尾并补 \0;网络数据本身不会自动终止。
sendto():向指定地址发送 UDP 数据报
1 2
| ssize_t sendto(int sockfd, const void* buf, size_t len, int flags, const sockaddr* dest_addr, socklen_t addrlen);
|
1 2 3 4 5 6 7 8
| sockaddr_in peer{}; peer.sin_family = AF_INET; peer.sin_port = htons(9000); inet_pton(AF_INET, "127.0.0.1", &peer.sin_addr);
const char message[] = "hello udp"; const ssize_t n = sendto(udp_fd, message, sizeof(message) - 1, 0, reinterpret_cast<const sockaddr*>(&peer), sizeof(peer));
|
一次 sendto() 对应一个完整 UDP 数据报。和 TCP 不同,UDP 发送结果若成功,通常应等于 len;小于 len 应视为异常处理。
| 参数 |
说明 |
sockfd |
由 socket(AF_INET, SOCK_DGRAM, 0) 创建的 UDP socket。 |
buf / len |
待发送数据及其字节数。 |
flags |
通常传 0。 |
dest_addr |
目标地址;IPv4 时是 sockaddr_in 转换得到的 const sockaddr*。 |
addrlen |
目标地址结构的大小,例如 sizeof(peer)。 |
成功返回发送字节数,失败返回 -1。UDP 也可先 connect() 固定目标,此后用 send(),但初学阶段使用 sendto() 更直观。
recvfrom():接收 UDP 数据报并获得发送方地址
1 2
| ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, sockaddr* src_addr, socklen_t* addrlen);
|
1 2 3 4 5 6
| char buffer[4096]; sockaddr_in peer_addr{}; socklen_t peer_len = sizeof(peer_addr);
const ssize_t n = recvfrom(udp_fd, buffer, sizeof(buffer), 0, reinterpret_cast<sockaddr*>(&peer_addr), &peer_len);
|
| 参数 |
说明 |
sockfd |
UDP socket。作为接收端时,通常先通过 bind() 绑定端口。 |
buf / len |
可写接收缓冲区及最大容量。若数据报大于 len,超出部分会被丢弃,因此缓冲区要满足协议最大包长。 |
flags |
通常传 0;MSG_PEEK 可查看但不取走数据。 |
src_addr |
输出参数,接收发送方 IP 和端口;不关心时可传 nullptr。 |
addrlen |
src_addr 的长度指针,是输入输出参数;调用前填缓冲区大小,调用后是实际地址长度。若 src_addr 为 nullptr,此参数也传 nullptr。 |
成功返回该数据报实际长度,失败返回 -1。返回 0 表示收到了一个合法的空 UDP 数据报,不表示“对端关闭”。
inet_pton():将文本 IP 转为二进制地址
1
| int inet_pton(int af, const char* src, void* dst);
|
| 参数 |
说明 |
af |
地址族:IPv4 传 AF_INET,IPv6 传 AF_INET6。 |
src |
形如 "127.0.0.1" 或 "::1" 的 IP 文本,不能是域名。 |
dst |
保存二进制结果的地址;IPv4 传 &addr.sin_addr,IPv6 传 &addr6.sin6_addr。 |
返回 1 表示成功,返回 0 表示 src 不是该地址族的合法 IP,返回 -1 表示地址族不支持等系统错误。解析 example.com 这类域名应使用 getaddrinfo()。
inet_ntop():将二进制 IP 转为文本
1
| const char* inet_ntop(int af, const void* src, char* dst, socklen_t size);
|
| 参数 |
说明 |
af |
AF_INET 或 AF_INET6。 |
src |
二进制 IP 的地址,例如 &client_addr.sin_addr。 |
dst |
存放结果的字符数组。IPv4 使用 char ip[INET_ADDRSTRLEN]{};,IPv6 使用 INET6_ADDRSTRLEN。 |
size |
dst 的容量,通常传 sizeof(ip)。 |
成功返回 dst,失败返回 nullptr 并设置 errno。
getaddrinfo():解析域名或生成地址结构
1 2
| int getaddrinfo(const char* node, const char* service, const addrinfo* hints, addrinfo** res);
|
推荐使用 getaddrinfo() 解析地址:它支持域名、IPv4、IPv6 和服务端绑定。它失败返回 EAI 错误码,应该使用 gai_strerror(),而不是 perror():
1 2 3 4 5 6 7 8 9 10 11 12
| addrinfo hints{}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM;
addrinfo* result = nullptr; const int rc = getaddrinfo("example.com", "443", &hints, &result); if (rc != 0) { std::cerr << gai_strerror(rc) << '\n'; } else { freeaddrinfo(result); }
|
| 参数 |
说明 |
node |
主机名、域名或 IP 文本,例如 "example.com"、"127.0.0.1";服务端配合 AI_PASSIVE 时传 nullptr。 |
service |
端口或服务名的字符串,例如 "8080"、"https",不能直接传整数 8080。 |
hints |
期望结果的筛选条件。常设 ai_family = AF_UNSPEC(同时接受 IPv4/IPv6)和 ai_socktype = SOCK_STREAM(TCP)。服务端再设置 ai_flags = AI_PASSIVE。 |
res |
输出参数。成功后得到 addrinfo 链表;逐个尝试 socket()、connect() 或 bind(),最后必须调用 freeaddrinfo(*res)。 |
服务端将 hints.ai_flags 设置为 AI_PASSIVE 并传 node == nullptr,可以获得适合 bind() 的本地地址。
getsockname() 与 getpeername():查询本地和对端地址
1 2
| int getsockname(int sockfd, sockaddr* addr, socklen_t* addrlen); int getpeername(int sockfd, sockaddr* addr, socklen_t* addrlen);
|
两者的参数形式相同:
| 参数 |
说明 |
sockfd |
要查询的 socket。getpeername() 需要已连接的 TCP socket,或已 connect() 的 UDP socket。 |
addr |
输出地址缓冲区;IPv4 常用 sockaddr_in 并转换为 sockaddr*。 |
addrlen |
输入输出参数。调用前填地址缓冲区大小,调用后得到实际地址长度。 |
getsockname() 查询本地 IP 和端口,常用于 bind(..., 端口 0) 后查询系统分配的实际端口;getpeername() 查询已连接对端的 IP 和端口。端口字段 sin_port 仍是网络字节序,打印前要用 ntohs() 转换。
shutdown():关闭一个或两个通信方向
1
| int shutdown(int sockfd, int how);
|
| 操作 |
可发送 |
可接收 |
释放文件描述符 |
shutdown(fd, SHUT_RD) |
是 |
否 |
否 |
shutdown(fd, SHUT_WR) |
否 |
是 |
否 |
shutdown(fd, SHUT_RDWR) |
否 |
否 |
否 |
close(fd) |
否 |
否 |
是 |
| 参数 |
说明 |
sockfd |
已连接的通信 socket。 |
how |
SHUT_RD 停止读取、SHUT_WR 停止发送、SHUT_RDWR 同时停止收发。 |
shutdown(fd, SHUT_WR) 会发送 FIN,适用于“请求已全部发送,但仍要读取完整响应”的协议。成功返回 0,失败返回 -1。它不释放文件描述符,因此最终仍应 close(fd)。
close():释放 Socket 文件描述符
fd 是需要关闭的 socket 描述符,可以是 socket() 返回的监听或客户端 socket,也可以是 accept() 返回的 client_fd。成功返回 0,失败返回 -1。每个成功创建或接受的 socket 最终都应关闭一次。close() 报错时不要盲目重试,因为该描述符编号可能已经被系统复用。
fcntl():设置非阻塞模式
1
| int fcntl(int fd, int cmd, ...);
|
使用 fcntl() 设置非阻塞:
1 2 3 4
| bool set_nonblocking(int fd) { const int flags = fcntl(fd, F_GETFL, 0); return flags != -1 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1; }
|
| 参数 |
说明 |
fd |
要设置的 socket 描述符。 |
cmd |
F_GETFL 读取当前文件状态标志,F_SETFL 设置文件状态标志。 |
| 第三个参数 |
F_GETFL 时通常传 0;F_SETFL 时传入新的标志组合,例如 `flags |
非阻塞模式不会等待:暂时不能读写时常返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。生产程序通常配合 poll()、select()、epoll(Linux)或 kqueue(macOS/BSD)等 I/O 多路复用机制。
最小可运行 TCP 回显示例
下面代码仅用于学习:服务端一次处理一个连接,协议是“读到什么就回写什么”。它不适用于高并发生产环境,但展示了资源释放、错误检查和部分发送处理。
服务端:tcp_echo_server.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| #include <arpa/inet.h> #include <cerrno> #include <cstddef> #include <cstdio> #include <cstring> #include <iostream> #include <sys/socket.h> #include <unistd.h>
bool send_all(int fd, const char* data, std::size_t length) { std::size_t sent = 0; while (sent < length) { const ssize_t n = send(fd, data + sent, length - sent, 0); if (n > 0) { sent += static_cast<std::size_t>(n); } else if (n == -1 && errno == EINTR) { continue; } else { return false; } } return true; }
int main() { const int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket"); return 1; }
int enabled = 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == -1) { perror("setsockopt"); close(listen_fd); return 1; }
sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = htons(8080);
if (bind(listen_fd, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) == -1) { perror("bind"); close(listen_fd); return 1; } if (listen(listen_fd, SOMAXCONN) == -1) { perror("listen"); close(listen_fd); return 1; }
std::cout << "listening on 127.0.0.1:8080\n"; for (;;) { const int client_fd = accept(listen_fd, nullptr, nullptr); if (client_fd == -1) { if (errno == EINTR) continue; perror("accept"); break; }
char buffer[4096]; for (;;) { const ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0); if (n > 0) { if (!send_all(client_fd, buffer, static_cast<std::size_t>(n))) break; } else if (n == 0) { break; } else if (errno == EINTR) { continue; } else { perror("recv"); break; } } close(client_fd); } close(listen_fd); }
|
客户端:tcp_echo_client.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| #include <arpa/inet.h> #include <cerrno> #include <cstddef> #include <cstdio> #include <cstring> #include <iostream> #include <string> #include <sys/socket.h> #include <unistd.h>
bool send_all(int fd, const char* data, std::size_t length) { std::size_t sent = 0; while (sent < length) { const ssize_t n = send(fd, data + sent, length - sent, 0); if (n > 0) { sent += static_cast<std::size_t>(n); } else if (n == -1 && errno == EINTR) { continue; } else { return false; } } return true; }
int main() { const int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd == -1) { perror("socket"); return 1; }
sockaddr_in server{}; server.sin_family = AF_INET; server.sin_port = htons(8080); if (inet_pton(AF_INET, "127.0.0.1", &server.sin_addr) != 1) { std::cerr << "invalid server IP\n"; close(fd); return 1; } if (connect(fd, reinterpret_cast<const sockaddr*>(&server), sizeof(server)) == -1) { perror("connect"); close(fd); return 1; }
const char message[] = "hello socket"; if (!send_all(fd, message, sizeof(message) - 1)) { perror("send"); close(fd); return 1; } if (shutdown(fd, SHUT_WR) == -1) { perror("shutdown"); close(fd); return 1; }
std::string reply; char buffer[128]; for (;;) { const ssize_t n = recv(fd, buffer, sizeof(buffer), 0); if (n > 0) { reply.append(buffer, static_cast<std::size_t>(n)); } else if (n == 0) { break; } else if (errno == EINTR) { continue; } else { perror("recv"); close(fd); return 1; } } std::cout << "reply: " << reply << '\n'; close(fd); }
|
在两个终端分别编译运行:
1 2 3 4
| c++ -std=c++17 -Wall -Wextra -pedantic tcp_echo_server.cpp -o server c++ -std=c++17 -Wall -Wextra -pedantic tcp_echo_client.cpp -o client ./server ./client
|
常见问题
- TCP 是字节流:不能假定一次
send() 对应一次 recv();必须设计协议边界。
send() 可部分发送:发送完整消息需要循环;非阻塞场景还要在可写事件到来后继续。
recv() == 0 对 TCP 表示对端正常关闭;对 UDP 则可表示空数据报。
EINTR 表示调用被信号中断,通常可重试;EAGAIN / EWOULDBLOCK 在非阻塞或设置超时时表示“暂时无法继续”。
- 地址、端口和长度字段的字节序不能混用;端口写入
sin_port 前使用 htons(),读取时使用 ntohs()。
- 不要把
sockaddr_in 用于 IPv6;需要双栈或域名解析时优先使用 getaddrinfo()。
- 每个
socket() 和每个成功的 accept() 都应恰好对应一次 close()。
- 网络输入不可信:长度字段应设上限,解析前检查缓冲区边界,避免把接收数据直接当作可信字符串或结构体。